migration: Rename 'general chat' topic to empty string topic.

Zulip now supports empty string as a valid topic name.

For clients predating this feature, such messages appear
in "general chat" topic. Messages sent to "general chat" are
stored in the database as having a "" topic.

This commit adds a migration to rename the existing
"general chat" topic in the database to "".

Fixes parts of #32996.
This commit is contained in:
Prakhar Pratyush
2025-02-25 18:08:15 +05:30
committed by Tim Abbott
parent d731f0d7a8
commit 1462c8ac1b
3 changed files with 177 additions and 1 deletions

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.6 on 2025-02-26 17:25
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
class Migration(migrations.Migration):
atomic = False
dependencies = [
("zerver", "0678_remove_realm_allow_edit_history"),
]
operations = [
AddIndexConcurrently(
model_name="message",
index=models.Index(
condition=models.Q(("edit_history__isnull", False)),
fields=["id"],
name="zerver_message_edit_history_id",
),
),
]

View File

@@ -0,0 +1,147 @@
from typing import Any
from django.db import connection, migrations, models, transaction
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
from psycopg2.sql import SQL, Identifier
def update_edit_history(message_model: type[Any]) -> None:
BATCH_SIZE = 10000
lower_id_bound = 0
max_id = message_model.objects.aggregate(models.Max("id"))["id__max"]
if max_id is None:
return
while lower_id_bound < max_id:
upper_id_bound = min(lower_id_bound + BATCH_SIZE, max_id)
with connection.cursor() as cursor:
query = SQL(
"""
UPDATE {table_name}
SET edit_history = (
SELECT JSONB_AGG(
elem
||
(CASE
WHEN elem ? 'prev_topic' AND elem->>'prev_topic' = 'general chat'
THEN '{{"prev_topic": ""}}'::jsonb
ELSE '{{}}'::jsonb
END)
||
(CASE
WHEN elem ? 'topic' AND elem->>'topic' = 'general chat'
THEN '{{"topic": ""}}'::jsonb
ELSE '{{}}'::jsonb
END)
)::text
FROM JSONB_ARRAY_ELEMENTS(edit_history::jsonb) AS elem
)
WHERE edit_history IS NOT NULL
AND id > %(lower_id_bound)s AND id <= %(upper_id_bound)s
AND (
edit_history::jsonb @> '[{{"prev_topic": "general chat"}}]' OR
edit_history::jsonb @> '[{{"topic": "general chat"}}]'
);
"""
).format(table_name=Identifier(message_model._meta.db_table))
cursor.execute(
query,
{
"lower_id_bound": lower_id_bound,
"upper_id_bound": upper_id_bound,
},
)
print(f"Processed {upper_id_bound} / {max_id}")
lower_id_bound += BATCH_SIZE
def rename_general_chat_to_empty_string_topic(
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
"""Because legacy clients will be unable to distinguish the topic "general chat"
from the "" topic (displayed as italicized "general chat"), it's helpful
to not have both types of topics exist in an organization.
Further, the "general chat" topic is likely to in almost every
case be the result of an organization that followed our advice to
just make a "general chat" topic for topic-free chat; the new
"general chat" feature naturally replaces that learned
behavior.
So it makes sense to just consider those older "general chat"
topics to be the same as the modern general chat topic.
The technical way to do that is to rewrite those topics in the
database to be represented as `""` rather than "general chat",
since we've endeavored to make the distinction between those two
storage approaches invisible to legacy clients at the API layer.
Thus, we don't generate edit history entries for this, since we're
thinking of it as redefining how "general chat" is stored in the
database.
"""
Realm = apps.get_model("zerver", "Realm")
Message = apps.get_model("zerver", "Message")
UserTopic = apps.get_model("zerver", "UserTopic")
ArchivedMessage = apps.get_model("zerver", "ArchivedMessage")
ScheduledMessage = apps.get_model("zerver", "ScheduledMessage")
for realm in Realm.objects.all():
with transaction.atomic(durable=True):
# Uses index "zerver_message_realm_upper_subject"
message_queryset = Message.objects.filter(realm=realm, subject__iexact="general chat")
channel_ids = list(
message_queryset.distinct("recipient__type_id").values_list(
"recipient__type_id", flat=True
)
)
message_queryset.update(subject="")
# Limiting the UserTopic query to only those channels that
# contain an actual general chat topic does not guaranteed
# updating all UserTopic rows, since it's possible to
# follow/mute an empty topic. But it does guarantee that
# we update all rows that have any current effect.
#
# Uses index "zerver_mutedtopic_stream_topic"
UserTopic.objects.filter(
stream_id__in=channel_ids, topic_name__iexact="general chat"
).update(topic_name="")
ArchivedMessage.objects.filter(realm=realm, subject__iexact="general chat").update(
subject=""
)
ScheduledMessage.objects.filter(realm=realm, subject__iexact="general chat").update(
subject=""
)
for message_model in [Message, ArchivedMessage]:
update_edit_history(message_model)
class Migration(migrations.Migration):
"""
Zulip now supports empty string as a valid topic name.
For clients predating this feature, such messages appear
in "general chat" topic. Messages sent to "general chat" are
stored in the database as having a "" topic. This migration
renames the existing "general chat" topic in the database to "".
"""
atomic = False
dependencies = [
("zerver", "0679_zerver_message_edit_history_id"),
]
operations = [
migrations.RunPython(
rename_general_chat_to_empty_string_topic,
reverse_code=migrations.RunPython.noop,
elidable=True,
),
]

View File

@@ -163,7 +163,7 @@ class Message(AbstractMessage):
# Name to be used for the empty topic with clients that have not
# yet migrated to have the `empty_topic_name` client capability.
EMPTY_TOPIC_FALLBACK_NAME = "test general chat"
EMPTY_TOPIC_FALLBACK_NAME = "general chat"
class Meta:
indexes = [
@@ -238,6 +238,12 @@ class Message(AbstractMessage):
F("id").desc(nulls_last=True),
name="zerver_message_realm_id",
),
models.Index(
# Used by 0680_rename_general_chat_to_empty_string_topic
fields=["id"],
condition=Q(edit_history__isnull=False),
name="zerver_message_edit_history_id",
),
]
def topic_name(self) -> str: