Files
upstream-zulip/zerver/lib/retention.py
upstream-mirror 64898b2c79
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
2026-09-15 15:05:30 +05:00

869 lines
32 KiB
Python

# Core implementation of message retention policies and low-level
# helpers for deleting messages.
#
# Because bugs in code that deletes message content can cause
# irreversible harm in installations without backups, this is a
# particularly sensitive system that requires careful design,
# thoughtful database transaction boundaries, and a well-written test
# suite to make bugs unlikely and mitigate their impact.
#
# The core design principle of this system is we never delete a live
# Message/Reaction/etc. object. Instead, we use move_rows, which moves
# objects to a "deleted objects" table like ArchiveMessage, recording
# the change using a structure linked to an ArchiveTransaction object
# that can be used to undo that deletion transaction in a clean
# fashion.
#
# We move all of the data associated with a given block of messages in
# a single database transaction in order to avoid broken intermediate
# states where, for example, a message's reactions were deleted but
# not the messages themselves.
#
# And then a separate process deletes ArchiveTransaction objects
# ARCHIVED_DATA_VACUUMING_DELAY_DAYS after they were created.
#
# Because of the nice properties of this deletion system, we use the
# same system for routine deletions via the Zulip UI (deleting a
# message or group of messages) as we use for message retention policy
# deletions.
import copy
import logging
import time
from collections.abc import Iterable, Mapping
from datetime import timedelta
from typing import Any
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.db import connection, transaction
from django.db.models import Case, CharField, Model, Value, When
from django.db.models.functions import Upper
from django.utils.timezone import now as timezone_now
from psycopg2.sql import SQL, Composable, Identifier, Literal
from zerver.actions.message_flags import do_clear_mobile_push_notifications_for_ids
from zerver.lib.logging_util import log_to_file
from zerver.lib.message import bulk_access_messages, event_recipient_ids_for_action_on_messages
from zerver.lib.request import RequestVariableConversionError
from zerver.lib.topic import DB_TOPIC_NAME
from zerver.lib.utils import assert_is_not_none
from zerver.models import (
ArchivedAttachment,
ArchivedReaction,
ArchivedSubMessage,
ArchivedUserMessage,
ArchiveTransaction,
Attachment,
Message,
Reaction,
Realm,
Recipient,
Stream,
SubMessage,
UserMessage,
UserProfile,
)
from zerver.tornado.django_api import send_event_on_commit
logger = logging.getLogger("zulip.retention")
log_to_file(logger, settings.RETENTION_LOG_PATH)
MESSAGE_BATCH_SIZE = 1000
STREAM_MESSAGE_BATCH_SIZE = 100
TRANSACTION_DELETION_BATCH_SIZE = 100
# This data structure declares the details of all database tables that
# hang off the Message table (with a foreign key to Message being part
# of its primary lookup key). This structure allows us to share the
# code for managing these related tables.
models_with_message_key: list[dict[str, Any]] = [
{
"class": Reaction,
"archive_class": ArchivedReaction,
"table_name": "zerver_reaction",
"archive_table_name": "zerver_archivedreaction",
},
{
"class": SubMessage,
"archive_class": ArchivedSubMessage,
"table_name": "zerver_submessage",
"archive_table_name": "zerver_archivedsubmessage",
},
{
"class": UserMessage,
"archive_class": ArchivedUserMessage,
"table_name": "zerver_usermessage",
"archive_table_name": "zerver_archivedusermessage",
},
]
EXCLUDE_FIELDS = {Message._meta.get_field("search_tsvector")}
@transaction.atomic(savepoint=False)
def move_rows(
base_model: type[Model],
raw_query: SQL,
*,
src_db_table: str | None = None,
returning_id: bool = False,
**kwargs: Composable,
) -> list[int]:
"""Core helper for bulk moving rows between a table and its archive table"""
if src_db_table is None:
# Use base_model's db_table unless otherwise specified.
src_db_table = base_model._meta.db_table
fields = [
field.column
for field in base_model._meta.fields
if field not in EXCLUDE_FIELDS and field.column is not None
]
src_fields = [Identifier(src_db_table, field) for field in fields]
dst_fields = [Identifier(field) for field in fields]
with connection.cursor() as cursor:
cursor.execute(
raw_query.format(
src_fields=SQL(",").join(src_fields), dst_fields=SQL(",").join(dst_fields), **kwargs
)
)
if returning_id:
return [id for (id,) in cursor.fetchall()] # return list of row ids
else:
return []
def run_archiving(
query: SQL,
type: int,
realm: Realm,
chunk_size: int | None = MESSAGE_BATCH_SIZE,
skip_notify: bool = False,
acting_user: UserProfile | None = None,
**kwargs: Composable,
) -> int:
# This function is carefully designed to achieve our
# transactionality goals: A batch of messages is either fully
# archived-and-deleted or not transactionally.
#
# We implement this design by executing queries that archive messages and their related objects
# (such as UserMessage, Reaction, and Attachment) inside the same transaction.atomic block.
assert type in (ArchiveTransaction.MANUAL, ArchiveTransaction.RETENTION_POLICY_BASED)
if chunk_size is not None:
kwargs["chunk_size"] = Literal(chunk_size)
message_count = 0
while True:
start_time = time.time()
with transaction.atomic(savepoint=False):
archive_transaction = ArchiveTransaction.objects.create(type=type, realm=realm)
new_chunk = move_rows(
Message,
query,
src_db_table=None,
returning_id=True,
archive_transaction_id=Literal(archive_transaction.id),
**kwargs,
)
if new_chunk:
move_related_objects_to_archive(new_chunk)
delete_messages(new_chunk, realm, skip_notify=skip_notify, acting_user=acting_user)
message_count += len(new_chunk)
else:
archive_transaction.delete() # Nothing was archived
total_time = time.time() - start_time
# This line needs to be outside of the atomic block, to capture the actual moment
# archiving of the chunk is finished (since Django does some significant additional work
# when leaving the block).
if len(new_chunk) > 0:
logger.info(
"Archived %s messages in %.2fs in transaction %s.",
len(new_chunk),
total_time,
archive_transaction.id,
)
# We run the loop, until the query returns fewer results than chunk_size,
# which means we are done; or if we're not chunking, we're done
# after one iteration.
if chunk_size is None or len(new_chunk) < chunk_size:
break
return message_count
# Note about batching these Message archiving queries:
# We can simply use LIMIT without worrying about OFFSETs and ordering
# while executing batches, because any Message already archived (in the previous batch)
# will not show up in the "SELECT ... FROM zerver_message ..." query for the next batches.
def move_expired_messages_to_archive_by_recipient(
recipient: Recipient,
message_retention_days: int,
realm: Realm,
chunk_size: int = MESSAGE_BATCH_SIZE,
) -> int:
assert message_retention_days != -1
# Uses index: zerver_message_realm_recipient_date_sent
query = SQL(
"""
INSERT INTO zerver_archivedmessage ({dst_fields}, archive_transaction_id)
SELECT {src_fields}, {archive_transaction_id}
FROM zerver_message
WHERE zerver_message.realm_id = {realm_id}
AND zerver_message.recipient_id = {recipient_id}
AND zerver_message.date_sent < {check_date}
LIMIT {chunk_size}
ON CONFLICT (id) DO UPDATE SET archive_transaction_id = {archive_transaction_id}
RETURNING id
"""
)
check_date = timezone_now() - timedelta(days=message_retention_days)
return run_archiving(
query,
type=ArchiveTransaction.RETENTION_POLICY_BASED,
realm=realm,
realm_id=Literal(realm.id),
recipient_id=Literal(recipient.id),
check_date=Literal(check_date.isoformat()),
chunk_size=chunk_size,
)
def move_expired_direct_messages_to_archive(
realm: Realm,
chunk_size: int = MESSAGE_BATCH_SIZE,
) -> int:
message_retention_days = realm.message_retention_days
assert message_retention_days != -1
check_date = timezone_now() - timedelta(days=message_retention_days)
# Archive expired direct Messages in the realm, including cross-realm messages.
# Uses index: zerver_message_realm_date_sent
query = SQL(
"""
INSERT INTO zerver_archivedmessage ({dst_fields}, archive_transaction_id)
SELECT {src_fields}, {archive_transaction_id}
FROM zerver_message
WHERE zerver_message.realm_id = {realm_id}
AND NOT zerver_message.is_channel_message
AND zerver_message.date_sent < {check_date}
LIMIT {chunk_size}
ON CONFLICT (id) DO UPDATE SET archive_transaction_id = {archive_transaction_id}
RETURNING id
"""
)
message_count = run_archiving(
query,
type=ArchiveTransaction.RETENTION_POLICY_BASED,
realm=realm,
realm_id=Literal(realm.id),
check_date=Literal(check_date.isoformat()),
chunk_size=chunk_size,
)
return message_count
def move_models_with_message_key_to_archive(msg_ids: list[int]) -> None:
assert len(msg_ids) > 0
for model in models_with_message_key:
query = SQL(
"""
INSERT INTO {archive_table_name} ({dst_fields})
SELECT {src_fields}
FROM {table_name}
WHERE {table_name}.message_id IN {message_ids}
ON CONFLICT (id) DO NOTHING
"""
)
move_rows(
model["class"],
query,
table_name=Identifier(model["table_name"]),
archive_table_name=Identifier(model["archive_table_name"]),
message_ids=Literal(tuple(msg_ids)),
)
# Attachments can't use the common models_with_message_key system,
# because they can be referenced by more than one Message, and we only
# want to delete the Attachment if we're deleting the last message
# referencing them.
def move_attachments_to_archive(msg_ids: list[int]) -> None:
assert len(msg_ids) > 0
query = SQL(
"""
INSERT INTO zerver_archivedattachment ({dst_fields})
SELECT {src_fields}
FROM zerver_attachment
INNER JOIN zerver_attachment_messages
ON zerver_attachment_messages.attachment_id = zerver_attachment.id
WHERE zerver_attachment_messages.message_id IN {message_ids}
GROUP BY zerver_attachment.id
ON CONFLICT (id) DO NOTHING
"""
)
move_rows(Attachment, query, message_ids=Literal(tuple(msg_ids)))
def move_attachment_messages_to_archive(msg_ids: list[int]) -> None:
assert len(msg_ids) > 0
query = SQL(
"""
INSERT INTO zerver_archivedattachment_messages (id, archivedattachment_id, archivedmessage_id)
SELECT zerver_attachment_messages.id, zerver_attachment_messages.attachment_id,
zerver_attachment_messages.message_id
FROM zerver_attachment_messages
WHERE zerver_attachment_messages.message_id IN %(message_ids)s
ON CONFLICT (id) DO NOTHING
"""
)
with connection.cursor() as cursor:
cursor.execute(query, dict(message_ids=tuple(msg_ids)))
def _process_grouped_messages_deletion(
realm: Realm,
message_ids: list[int],
*,
stream: Stream | None,
topic: str | None,
skip_notify: bool,
acting_user: UserProfile | None,
) -> None:
"""
Helper for delete_messages. Should not be called directly otherwise.
"""
from zerver.actions.message_delete import DeleteMessagesEvent, check_update_first_message_id
if not message_ids:
return # nocoverage
event: DeleteMessagesEvent = {
"type": "delete_message",
"message_ids": sorted(message_ids),
}
if stream is None:
assert topic is None
message_type = "private"
else:
assert topic is not None
message_type = "stream"
event["stream_id"] = stream.id
event["topic"] = topic
event["message_type"] = message_type
# We exclude long-term idle users, since they by definition have no active clients.
users_to_notify = set()
if not skip_notify:
users_to_notify = event_recipient_ids_for_action_on_messages(
message_ids,
is_channel_message=message_type == "stream",
channel=stream if message_type == "stream" else None,
)
acting_user_event: DeleteMessagesEvent | None = None
if acting_user is not None and message_type == "stream":
# Send event to the user who deleted the messages only if the
# user has access to the messages, and the event should only
# include message IDs which the user can access.
#
# For DMs, acting_user will already be included in users_to_notify
# if they can access the messages, so this logic applies only to
# channel messages.
#
# We need to check access here for cases where messages
# are deleted in bulk by an admin, like when deactivating
# a spam user, when we do not check access to messages.
messages = Message.objects.filter(id__in=message_ids)
assert stream is not None
accessible_messages = bulk_access_messages(
acting_user, messages, stream=stream, is_modifying_message=False
)
if len(accessible_messages) == len(message_ids):
if acting_user.id not in users_to_notify:
users_to_notify.add(acting_user.id)
elif len(accessible_messages) != 0:
if acting_user.id in users_to_notify:
users_to_notify.remove(acting_user.id)
accessible_message_ids = [msg.id for msg in accessible_messages]
acting_user_event = copy.deepcopy(event)
acting_user_event["message_ids"] = sorted(accessible_message_ids)
# Uses index: zerver_message_pkey
Message.objects.filter(id__in=message_ids).delete()
if not skip_notify:
if stream is not None:
check_update_first_message_id(realm, stream, message_ids, users_to_notify)
send_event_on_commit(realm, event, users_to_notify)
if acting_user_event is not None:
assert acting_user is not None
send_event_on_commit(realm, acting_user_event, [acting_user.id])
def delete_messages(
msg_ids: list[int],
realm: Realm,
skip_notify: bool = False,
acting_user: UserProfile | None = None,
) -> None:
# Important note: This also deletes related objects with a foreign
# key to Message (due to `on_delete=CASCADE` in our models
# configuration), so we need to be sure we've taken care of
# archiving the messages before doing this step.
# Besides deleting messages, this function needs to send out message deletion events.
# This requires grouping the messages per (channel, topic) for channel messages
# and per conversation for DMs.
# Message archiving through retention policy can involve large numbers of messages,
# so we care about performance here and thus prefer to offload this work to the database
# instead of running loops in Python.
query = (
Message.objects.filter(id__in=msg_ids)
.annotate(
group_topic=Case(
When(is_channel_message=True, then=Upper(DB_TOPIC_NAME)),
default=Value(None),
output_field=CharField(),
)
)
.values("recipient_id", "group_topic")
.annotate(message_ids=ArrayAgg("id", order_by="id"))
)
grouped_message_ids: dict[tuple[int, str | None], list[int]] = {}
unique_recipient_ids: set[int] = {
row["recipient_id"] for row in query if row["group_topic"] is not None
}
recipient_id_to_stream: dict[int, Stream] = {
assert_is_not_none(stream.recipient_id): stream
for stream in Stream.objects.filter(recipient_id__in=unique_recipient_ids)
}
for row in query:
recipient_id = row["recipient_id"]
topic = row["group_topic"]
key = (recipient_id, topic)
grouped_message_ids[key] = row["message_ids"]
do_clear_mobile_push_notifications_for_ids(user_profile_ids=None, message_ids=msg_ids)
for (recipient_id, topic), message_ids in grouped_message_ids.items():
if topic is not None:
topic_name = topic.lower()
stream = recipient_id_to_stream[recipient_id]
else:
topic_name = None
stream = None
_process_grouped_messages_deletion(
realm,
message_ids,
stream=stream,
topic=topic_name,
skip_notify=skip_notify,
acting_user=acting_user,
)
def delete_expired_attachments(realm: Realm) -> None:
(num_deleted, _deletions) = Attachment.objects.filter(
messages__isnull=True,
scheduled_messages__isnull=True,
realm_id=realm.id,
id__in=ArchivedAttachment.objects.filter(realm_id=realm.id),
).delete()
if num_deleted > 0:
logger.info("Cleaned up %s attachments for realm %s", num_deleted, realm.string_id)
def move_related_objects_to_archive(msg_ids: list[int]) -> None:
move_models_with_message_key_to_archive(msg_ids)
move_attachments_to_archive(msg_ids)
move_attachment_messages_to_archive(msg_ids)
def archive_messages_by_recipient(
recipient: Recipient,
message_retention_days: int,
realm: Realm,
chunk_size: int = MESSAGE_BATCH_SIZE,
) -> int:
return move_expired_messages_to_archive_by_recipient(
recipient, message_retention_days, realm, chunk_size
)
def archive_direct_messages(realm: Realm, chunk_size: int = MESSAGE_BATCH_SIZE) -> None:
logger.info("Archiving personal and group direct messages for realm %s", realm.string_id)
message_count = move_expired_direct_messages_to_archive(realm, chunk_size)
logger.info("Done. Archived %s messages", message_count)
def archive_stream_messages(
realm: Realm, streams: list[Stream], chunk_size: int = STREAM_MESSAGE_BATCH_SIZE
) -> None:
if not streams:
return # nocoverage # TODO
logger.info("Archiving stream messages for realm %s", realm.string_id)
retention_policy_dict: dict[int, int] = {}
for stream in streams:
# if stream.message_retention_days is null, use the realm's policy
if stream.message_retention_days:
retention_policy_dict[stream.id] = stream.message_retention_days
else:
assert realm.message_retention_days != -1
retention_policy_dict[stream.id] = realm.message_retention_days
recipients = [stream.recipient for stream in streams]
message_count = 0
for recipient in recipients:
assert recipient is not None
message_count += archive_messages_by_recipient(
recipient,
retention_policy_dict[recipient.type_id],
realm,
chunk_size,
)
logger.info("Done. Archived %s messages.", message_count)
@transaction.atomic(durable=True)
def archive_messages(chunk_size: int = MESSAGE_BATCH_SIZE) -> None:
logger.info("Starting the archiving process with chunk_size %s", chunk_size)
for realm, streams in get_realms_and_streams_for_archiving():
archive_stream_messages(realm, streams, chunk_size=STREAM_MESSAGE_BATCH_SIZE)
if realm.message_retention_days != -1:
archive_direct_messages(realm, chunk_size)
# Messages have been archived for the realm, now we can clean up attachments:
delete_expired_attachments(realm)
def get_realms_and_streams_for_archiving() -> list[tuple[Realm, list[Stream]]]:
"""
This function constructs a list of (realm, streams_of_the_realm) tuples
where each realm is a Realm that requires calling the archiving functions on it,
and streams_of_the_realm is a list of streams of the realm to call archive_stream_messages with.
The purpose of this is performance - for servers with thousands of realms, it is important
to fetch all this data in bulk.
"""
realm_id_to_realm = {}
realm_id_to_streams_list: dict[int, list[Stream]] = {}
# All realms with a retention policy set qualify for archiving:
for realm in Realm.objects.exclude(message_retention_days=-1):
realm_id_to_realm[realm.id] = realm
realm_id_to_streams_list[realm.id] = []
# Now we find all streams that require archiving.
# First category are streams in retention-enabled realms,
# that don't have retention explicitly disabled (through the value -1).
query_one = (
Stream.objects.exclude(message_retention_days=-1)
.exclude(realm__message_retention_days=-1)
.select_related("realm", "recipient")
)
# Second category are streams that are in realms without a realm-wide retention policy,
# but have their own stream-specific policy enabled.
query_two = (
Stream.objects.filter(realm__message_retention_days=-1)
.exclude(message_retention_days__isnull=True)
.exclude(message_retention_days=-1)
.select_related("realm", "recipient")
)
query = query_one.union(query_two)
for stream in query:
realm = stream.realm
realm_id_to_realm[realm.id] = realm
if realm.id not in realm_id_to_streams_list:
realm_id_to_streams_list[realm.id] = []
realm_id_to_streams_list[realm.id].append(stream)
return [
(realm_id_to_realm[realm_id], realm_id_to_streams_list[realm_id])
for realm_id in realm_id_to_realm
]
def move_messages_to_archive(
message_ids: list[int],
realm: Realm,
chunk_size: int = MESSAGE_BATCH_SIZE,
skip_notify: bool = False,
acting_user: UserProfile | None = None,
) -> None:
"""
Callers using this to archive a large amount of messages should
send sorted message_ids, which can improve database performance by
accessing adjacent blocks at the same time.
If skip_notify=True is used, the caller is responsible for
updating Stream.first_message_id when deleting channel messages.
"""
count = 0
# In order to avoid sending a massive list of message ids to the database,
# we'll handle chunking the list of ids directly here.
message_ids_head = message_ids
while message_ids_head:
message_ids_chunk = message_ids_head[0:chunk_size]
message_ids_head = message_ids_head[chunk_size:]
# Uses index: zerver_message_pkey
query = SQL(
"""
INSERT INTO zerver_archivedmessage ({dst_fields}, archive_transaction_id)
SELECT {src_fields}, {archive_transaction_id}
FROM zerver_message
WHERE zerver_message.id IN {message_ids}
ON CONFLICT (id) DO UPDATE SET archive_transaction_id = {archive_transaction_id}
RETURNING id
"""
)
count += run_archiving(
query,
type=ArchiveTransaction.MANUAL,
message_ids=Literal(tuple(message_ids_chunk)),
realm=realm,
chunk_size=None,
skip_notify=skip_notify,
acting_user=acting_user,
)
# Clean up attachments:
archived_attachments = ArchivedAttachment.objects.filter(
messages__id__in=message_ids_chunk
).distinct()
Attachment.objects.filter(
messages__isnull=True, scheduled_messages__isnull=True, id__in=archived_attachments
).delete()
if message_ids and count == 0:
raise Message.DoesNotExist
def restore_messages_from_archive(archive_transaction_id: int) -> list[int]:
query = SQL(
"""
INSERT INTO zerver_message ({dst_fields})
SELECT {src_fields}
FROM zerver_archivedmessage
WHERE zerver_archivedmessage.archive_transaction_id = {archive_transaction_id}
ON CONFLICT (id) DO NOTHING
RETURNING id
"""
)
return move_rows(
Message,
query,
src_db_table="zerver_archivedmessage",
returning_id=True,
archive_transaction_id=Literal(archive_transaction_id),
)
def restore_models_with_message_key_from_archive(archive_transaction_id: int) -> None:
for model in models_with_message_key:
query = SQL(
"""
INSERT INTO {table_name} ({dst_fields})
SELECT {src_fields}
FROM {archive_table_name}
INNER JOIN zerver_archivedmessage ON {archive_table_name}.message_id = zerver_archivedmessage.id
WHERE zerver_archivedmessage.archive_transaction_id = {archive_transaction_id}
ON CONFLICT (id) DO NOTHING
"""
)
move_rows(
model["class"],
query,
src_db_table=model["archive_table_name"],
table_name=Identifier(model["table_name"]),
archive_transaction_id=Literal(archive_transaction_id),
archive_table_name=Identifier(model["archive_table_name"]),
)
def restore_attachments_from_archive(archive_transaction_id: int) -> None:
query = SQL(
"""
INSERT INTO zerver_attachment ({dst_fields})
SELECT {src_fields}
FROM zerver_archivedattachment
INNER JOIN zerver_archivedattachment_messages
ON zerver_archivedattachment_messages.archivedattachment_id = zerver_archivedattachment.id
INNER JOIN zerver_archivedmessage
ON zerver_archivedattachment_messages.archivedmessage_id = zerver_archivedmessage.id
WHERE zerver_archivedmessage.archive_transaction_id = {archive_transaction_id}
GROUP BY zerver_archivedattachment.id
ON CONFLICT (id) DO NOTHING
"""
)
move_rows(
Attachment,
query,
src_db_table="zerver_archivedattachment",
archive_transaction_id=Literal(archive_transaction_id),
)
def restore_attachment_messages_from_archive(archive_transaction_id: int) -> None:
query = SQL(
"""
INSERT INTO zerver_attachment_messages (id, attachment_id, message_id)
SELECT zerver_archivedattachment_messages.id,
zerver_archivedattachment_messages.archivedattachment_id,
zerver_archivedattachment_messages.archivedmessage_id
FROM zerver_archivedattachment_messages
INNER JOIN zerver_archivedmessage
ON zerver_archivedattachment_messages.archivedmessage_id = zerver_archivedmessage.id
WHERE zerver_archivedmessage.archive_transaction_id = %(archive_transaction_id)s
ON CONFLICT (id) DO NOTHING
"""
)
with connection.cursor() as cursor:
cursor.execute(query, dict(archive_transaction_id=archive_transaction_id))
def restore_data_from_archive(archive_transaction: ArchiveTransaction) -> int:
logger.info("Restoring %s", archive_transaction)
# transaction.atomic needs to be used here, rather than being a wrapper on the whole function,
# so that when we log "Finished", the process has indeed finished - and that happens only after
# leaving the atomic block - Django does work committing the changes to the database when
# the block ends.
with transaction.atomic(durable=True):
msg_ids = restore_messages_from_archive(archive_transaction.id)
restore_models_with_message_key_from_archive(archive_transaction.id)
restore_attachments_from_archive(archive_transaction.id)
restore_attachment_messages_from_archive(archive_transaction.id)
archive_transaction.restored = True
archive_transaction.restored_timestamp = timezone_now()
archive_transaction.save()
logger.info("Finished. Restored %s messages", len(msg_ids))
return len(msg_ids)
def restore_data_from_archive_by_transactions(
archive_transactions: Iterable[ArchiveTransaction],
) -> int:
# Looping over the list of ids means we're batching the restoration process by the size of the
# transactions:
message_count = 0
for archive_transaction in archive_transactions:
message_count += restore_data_from_archive(archive_transaction)
return message_count
def restore_data_from_archive_by_realm(realm: Realm) -> None:
transactions = ArchiveTransaction.objects.exclude(restored=True).filter(
realm=realm, type=ArchiveTransaction.RETENTION_POLICY_BASED
)
logger.info("Restoring %s transactions from realm %s", len(transactions), realm.string_id)
message_count = restore_data_from_archive_by_transactions(transactions)
logger.info("Finished. Restored %s messages from realm %s", message_count, realm.string_id)
def restore_all_data_from_archive(restore_manual_transactions: bool = True) -> None:
for realm in Realm.objects.all().iterator():
restore_data_from_archive_by_realm(realm)
if restore_manual_transactions:
restore_data_from_archive_by_transactions(
ArchiveTransaction.objects.exclude(restored=True).filter(
type=ArchiveTransaction.MANUAL
),
)
def restore_retention_policy_deletions_for_stream(stream: Stream) -> None:
"""
Utility function for calling in the Django shell if a stream's policy was
set to something too aggressive and the administrator wants to restore
the messages deleted as a result.
"""
relevant_transactions = ArchiveTransaction.objects.filter(
archivedmessage__recipient=stream.recipient, type=ArchiveTransaction.RETENTION_POLICY_BASED
).distinct("id")
restore_data_from_archive_by_transactions(list(relevant_transactions))
def clean_archived_data() -> None:
"""This function deletes archived data that was archived at least
settings.ARCHIVED_DATA_VACUUMING_DELAY_DAYS days ago.
It works by deleting ArchiveTransaction objects that are
sufficiently old. We've configured most archive tables, like
ArchiveMessage, with on_delete=CASCADE, so that deleting an
ArchiveTransaction entails deleting associated objects, including
ArchivedMessage, ArchivedUserMessage, ArchivedReaction.
The exception to this rule is ArchivedAttachment. Archive
attachment objects that were only referenced by ArchivedMessage
objects that have now been deleted will be left with an empty
`.messages` relation. A separate step,
delete_old_unclaimed_attachments, will delete those
ArchivedAttachment objects (and delete the files themselves from
the storage).
"""
logger.info("Cleaning old archive data.")
check_date = timezone_now() - timedelta(days=settings.ARCHIVED_DATA_VACUUMING_DELAY_DAYS)
# Associated archived objects will get deleted through the on_delete=CASCADE property:
count = 0
transaction_ids = list(
ArchiveTransaction.objects.filter(
timestamp__lt=check_date, protect_from_deletion=False
).values_list("id", flat=True)
)
while len(transaction_ids) > 0:
transaction_block = transaction_ids[0:TRANSACTION_DELETION_BATCH_SIZE]
transaction_ids = transaction_ids[TRANSACTION_DELETION_BATCH_SIZE:]
ArchiveTransaction.objects.filter(
# The protect_from_deletion=False condition is redundant at this point, but can act
# as an extra safeguard against future bugs.
id__in=transaction_block,
protect_from_deletion=False,
).delete()
count += len(transaction_block)
logger.info("Deleted %s old ArchiveTransactions.", count)
def parse_message_retention_days(
value: int | str,
special_values_map: Mapping[str, int | None],
) -> int | None:
if isinstance(value, str) and value in special_values_map:
return special_values_map[value]
if isinstance(value, str) or value <= 0:
raise RequestVariableConversionError("message_retention_days", value)
assert isinstance(value, int)
return value