diff --git a/comments/admin.py b/comments/admin.py index 259d64fe20..c5f2017bee 100644 --- a/comments/admin.py +++ b/comments/admin.py @@ -1,8 +1,12 @@ from admin_auto_filters.filters import AutocompleteFilterFactory +from django.conf import settings from django.contrib import admin from django.contrib.postgres.search import SearchQuery +from django.utils.html import format_html -from utils.models import CustomTranslationAdmin +from comments.services.text_archive import get_full_text +from utils.models import CustomTranslationAdmin, uniques_ordered_list +from utils.translation import build_supported_localized_fieldname from .models import Comment, KeyFactor, KeyFactorDriver @@ -30,12 +34,14 @@ class CommentAdmin(CustomTranslationAdmin): "created_at", "is_soft_deleted", "is_private", + "is_text_archived", ] list_filter = [ AutocompleteFilterFactory("Author", "author"), AutocompleteFilterFactory("Post", "on_post"), "is_soft_deleted", "is_private", + "is_text_archived", AutocompleteFilterFactory("Project", "on_project"), ] autocomplete_fields = [ @@ -43,7 +49,7 @@ class CommentAdmin(CustomTranslationAdmin): "on_post", "on_project", ] - readonly_fields = ["included_forecast"] + readonly_fields = ["included_forecast", "is_text_archived"] fields = [ "author", "text", @@ -52,6 +58,7 @@ class CommentAdmin(CustomTranslationAdmin): "is_soft_deleted", "included_forecast", "is_private", + "is_text_archived", ] # `search_fields` must be non-empty for Django admin to render the search box # and dispatch to `get_search_results`, but its contents are unused because we @@ -62,6 +69,55 @@ class CommentAdmin(CustomTranslationAdmin): def should_update_translations(self, obj): return not obj.on_post.is_private() + @admin.display(description="Archived text (read-only, fetched from S3)") + def archived_text(self, obj): + """ + The full text of an archived comment, read back from the archive. + + The admin is where staff investigate a comment, and the row itself + now holds nothing but a 200-character stub. Reading this costs an S3 + round trip per change-page load, which is why `get_fields` only adds + it for rows that are actually archived. + """ + + text = get_full_text(obj) + + if text is None: + return format_html( + "{}", + "The archived text could not be retrieved from S3. " + "Only the stub above remains in the database.", + ) + + return format_html( + '
{}',
+ text,
+ )
+
+ def get_fields(self, request, obj=None):
+ fields = list(super().get_fields(request, obj))
+
+ if obj and obj.is_text_archived:
+ fields.append("archived_text")
+
+ return uniques_ordered_list(fields)
+
+ def get_readonly_fields(self, request, obj=None):
+ readonly_fields = list(super().get_readonly_fields(request, obj))
+
+ if obj and obj.is_text_archived:
+ # Only a stub of the text is left in the db, so editing it here
+ # would bypass the `update_comment` guard and leave the row out of
+ # sync with the archived original. `archived_text` is not a model
+ # field at all, so it has to be declared read-only to appear.
+ readonly_fields += ["text", "archived_text"] + [
+ build_supported_localized_fieldname("text", lang)
+ for lang, _label in settings.LANGUAGES
+ ]
+
+ return uniques_ordered_list(readonly_fields)
+
def get_search_results(self, request, queryset, search_term):
search_term = search_term.strip()
if not search_term:
diff --git a/comments/management/commands/archive_bot_comment_texts.py b/comments/management/commands/archive_bot_comment_texts.py
new file mode 100644
index 0000000000..72527ddc49
--- /dev/null
+++ b/comments/management/commands/archive_bot_comment_texts.py
@@ -0,0 +1,179 @@
+import time
+from collections.abc import Callable
+
+from django.conf import settings
+from django.core.management.base import BaseCommand, CommandError
+
+from comments.services.text_archive import (
+ ARCHIVE_AGE_DAYS,
+ ARCHIVE_MIN_TEXT_LENGTH,
+ ARCHIVE_STUB_LENGTH,
+ DEFAULT_BATCH_SIZE,
+ DEFAULT_CONCURRENCY,
+ S3_KEY_PREFIX,
+ ArchiveStats,
+ archive_bot_comment_texts,
+ check_is_enabled,
+)
+
+
+def format_duration(seconds: float) -> str:
+ seconds = int(seconds)
+ hours, remainder = divmod(seconds, 3600)
+ minutes, seconds = divmod(remainder, 60)
+
+ if hours:
+ return f"{hours}h{minutes:02d}m"
+ if minutes:
+ return f"{minutes}m{seconds:02d}s"
+
+ return f"{seconds}s"
+
+
+class ProgressWriter:
+ """
+ Prints a running one-line summary with a rate and an ETA.
+
+ This command works through hundreds of thousands of rows over hours, so
+ the point is to make a long run observable rather than to look pretty.
+ Output is one line per batch, not a redrawn line, so it survives being
+ piped to a log file.
+ """
+
+ def __init__(self, stdout, total: int = 0):
+ self.stdout = stdout
+ self.total = total
+ self.started = time.monotonic()
+
+ @property
+ def elapsed(self) -> float:
+ return time.monotonic() - self.started
+
+ def write(self, line: str) -> None:
+ self.stdout.write(line)
+ self.stdout.flush()
+
+ def update(self, done: int, summary: str, detail: str = "") -> None:
+ elapsed = self.elapsed
+ rate = done / elapsed if elapsed else 0
+ percent = (done / self.total * 100) if self.total else 0
+ remaining = max(self.total - done, 0)
+ eta = format_duration(remaining / rate) if rate else "?"
+
+ line = (
+ f" {done:,}/{self.total:,} ({percent:.1f}%) {summary} "
+ f"{rate:.1f}/s elapsed {format_duration(elapsed)} eta {eta}"
+ )
+
+ if detail:
+ line += f" [{detail}]"
+
+ self.write(line)
+
+
+class Command(BaseCommand):
+ help = (
+ "Moves the full text of private bot comments older than "
+ f"{ARCHIVE_AGE_DAYS} days and longer than {ARCHIVE_MIN_TEXT_LENGTH} "
+ f"characters to S3, leaving a {ARCHIVE_STUB_LENGTH}-character stub in "
+ "the database. Runs monthly as a cron job; the full text stays "
+ "readable through the comment-full-text endpoint."
+ )
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Report what would be archived without writing to S3 or the database",
+ )
+ parser.add_argument(
+ "--limit",
+ type=int,
+ default=None,
+ help="Maximum number of comments to archive (useful for the first backfill)",
+ )
+ parser.add_argument(
+ "--batch-size",
+ type=int,
+ default=DEFAULT_BATCH_SIZE,
+ help=f"Comments per database update (default: {DEFAULT_BATCH_SIZE})",
+ )
+ parser.add_argument(
+ "--concurrency",
+ type=int,
+ default=DEFAULT_CONCURRENCY,
+ help=(
+ "Uploads to keep in flight at once. S3 has no multi-object PUT, "
+ "so this is what makes a large backfill finish in minutes "
+ f"rather than hours (default: {DEFAULT_CONCURRENCY})"
+ ),
+ )
+
+ def handle(self, *args, **options):
+ dry_run = options["dry_run"]
+
+ if not check_is_enabled():
+ raise CommandError(
+ "AWS_STORAGE_BUCKET_COMMENTS_TEXT is not configured, "
+ "comment text archiving is disabled."
+ )
+
+ progress = ProgressWriter(self.stdout)
+ on_progress: Callable[[ArchiveStats], None] | None = None
+
+ if not dry_run:
+ progress.write(
+ f"Archiving to {settings.AWS_STORAGE_BUCKET_COMMENTS_TEXT}/"
+ f"{S3_KEY_PREFIX}/ with concurrency {options['concurrency']}, "
+ f"batches of {options['batch_size']}"
+ )
+ progress.write("Counting eligible comments...")
+
+ def write_progress(stats: ArchiveStats) -> None:
+ progress.total = stats.total
+ detail = ", ".join(
+ f"{count} {label}"
+ for label, count in (
+ ("failed", stats.failed),
+ ("skipped", stats.skipped),
+ )
+ if count
+ )
+ progress.update(
+ stats.archived + stats.failed + stats.skipped,
+ f"{stats.chars_reclaimed:,} chars reclaimed",
+ detail,
+ )
+
+ on_progress = write_progress
+
+ stats = archive_bot_comment_texts(
+ dry_run=dry_run,
+ limit=options["limit"],
+ batch_size=options["batch_size"],
+ concurrency=options["concurrency"],
+ on_progress=on_progress,
+ )
+
+ verb = "Would archive" if dry_run else "Archived"
+ elapsed = "" if dry_run else f" in {format_duration(progress.elapsed)}"
+ progress.write(
+ f"{verb} {stats.archived:,} comment(s), "
+ f"reclaiming {stats.chars_reclaimed:,} characters{elapsed}"
+ )
+
+ if stats.sample_ids:
+ sample = ", ".join(str(pk) for pk in stats.sample_ids)
+ progress.write(f"Sample comment ids: {sample}")
+
+ if stats.skipped:
+ self.stdout.write(
+ self.style.WARNING(
+ f"Skipped {stats.skipped} comment(s) edited during the run"
+ )
+ )
+
+ if stats.failed:
+ self.stdout.write(
+ self.style.ERROR(f"Failed to upload {stats.failed} comment(s)")
+ )
diff --git a/comments/management/commands/sync_archived_comment_texts.py b/comments/management/commands/sync_archived_comment_texts.py
new file mode 100644
index 0000000000..2e642f7d22
--- /dev/null
+++ b/comments/management/commands/sync_archived_comment_texts.py
@@ -0,0 +1,197 @@
+import time
+
+from django.conf import settings
+from django.core.management.base import BaseCommand, CommandError
+
+from comments.services.text_archive import (
+ DEFAULT_BATCH_SIZE,
+ DEFAULT_CONCURRENCY,
+ S3_KEY_PREFIX,
+ SyncStats,
+ check_is_enabled,
+ sync_archived_comment_texts,
+)
+
+
+def format_duration(seconds: float) -> str:
+ seconds = int(seconds)
+ hours, remainder = divmod(seconds, 3600)
+ minutes, seconds = divmod(remainder, 60)
+
+ if hours:
+ return f"{hours}h{minutes:02d}m"
+ if minutes:
+ return f"{minutes}m{seconds:02d}s"
+
+ return f"{seconds}s"
+
+
+class ProgressWriter:
+ """
+ Prints a running one-line summary with a rate and an ETA.
+
+ This command works through hundreds of thousands of objects over hours,
+ so the point is to make a long run observable rather than to look pretty.
+ Output is one line per batch, not a redrawn line, so it survives being
+ piped to a log file.
+ """
+
+ def __init__(self, stdout, total: int = 0):
+ self.stdout = stdout
+ self.total = total
+ self.started = time.monotonic()
+
+ @property
+ def elapsed(self) -> float:
+ return time.monotonic() - self.started
+
+ def write(self, line: str) -> None:
+ self.stdout.write(line)
+ self.stdout.flush()
+
+ def update(self, done: int, summary: str, detail: str = "") -> None:
+ elapsed = self.elapsed
+ rate = done / elapsed if elapsed else 0
+ percent = (done / self.total * 100) if self.total else 0
+ remaining = max(self.total - done, 0)
+ eta = format_duration(remaining / rate) if rate else "?"
+
+ line = (
+ f" {done:,}/{self.total:,} ({percent:.1f}%) {summary} "
+ f"{rate:.1f}/s elapsed {format_duration(elapsed)} eta {eta}"
+ )
+
+ if detail:
+ line += f" [{detail}]"
+
+ self.write(line)
+
+
+class Command(BaseCommand):
+ help = (
+ "Truncates comments whose full text is already in the S3 archive, "
+ "without uploading anything. This is the second half of a one-off "
+ "migration: `archive_bot_comment_texts` is run once against a copy of "
+ "the database to populate the bucket, then this brings the real "
+ "database in line with it. Afterwards the monthly cron job takes over."
+ )
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Report what would be truncated without writing to the database",
+ )
+ parser.add_argument(
+ "--verify",
+ action="store_true",
+ help=(
+ "Re-read every archived object and require it to match the row "
+ "before truncating. Much slower, and downloads the whole "
+ "archive, but it is the only check that the archived copy is "
+ "still current"
+ ),
+ )
+ parser.add_argument(
+ "--batch-size",
+ type=int,
+ default=DEFAULT_BATCH_SIZE,
+ help=f"Comments per database update (default: {DEFAULT_BATCH_SIZE})",
+ )
+ parser.add_argument(
+ "--concurrency",
+ type=int,
+ default=DEFAULT_CONCURRENCY,
+ help=(
+ "Downloads to keep in flight at once, for --verify "
+ f"(default: {DEFAULT_CONCURRENCY})"
+ ),
+ )
+
+ def handle(self, *args, **options):
+ if not check_is_enabled():
+ raise CommandError(
+ "AWS_STORAGE_BUCKET_COMMENTS_TEXT is not configured, "
+ "comment text archiving is disabled."
+ )
+
+ dry_run = options["dry_run"]
+ progress = ProgressWriter(self.stdout)
+
+ progress.write(
+ f"Syncing against {settings.AWS_STORAGE_BUCKET_COMMENTS_TEXT}/"
+ f"{S3_KEY_PREFIX}/"
+ + (" (verifying every object)" if options["verify"] else "")
+ )
+ progress.write("Listing the archive...")
+
+ def on_progress(stats: SyncStats) -> None:
+ progress.total = stats.total
+ done = (
+ stats.synced
+ + stats.already_archived
+ + stats.orphaned
+ + stats.ineligible
+ + stats.mismatched
+ + stats.verify_failed
+ )
+ detail = ", ".join(
+ f"{count} {label}"
+ for label, count in (
+ ("already archived", stats.already_archived),
+ ("orphaned", stats.orphaned),
+ ("ineligible", stats.ineligible),
+ ("mismatched", stats.mismatched),
+ ("unreadable", stats.verify_failed),
+ )
+ if count
+ )
+ progress.update(done, f"{stats.chars_reclaimed:,} chars reclaimed", detail)
+
+ stats = sync_archived_comment_texts(
+ dry_run=dry_run,
+ verify=options["verify"],
+ batch_size=options["batch_size"],
+ concurrency=options["concurrency"],
+ on_progress=on_progress,
+ )
+
+ verb = "Would sync" if dry_run else "Synced"
+ progress.write(
+ f"\n{verb} {stats.synced:,} of {stats.total:,} archived object(s), "
+ f"reclaiming {stats.chars_reclaimed:,} characters "
+ f"in {format_duration(progress.elapsed)}"
+ )
+
+ for label, count in (
+ ("already truncated", stats.already_archived),
+ ("orphaned (no such comment)", stats.orphaned),
+ ("ineligible (not a long private bot comment)", stats.ineligible),
+ ):
+ if count:
+ progress.write(f" {count:,} {label}")
+
+ if stats.sample_ids:
+ sample = ", ".join(str(pk) for pk in stats.sample_ids)
+ progress.write(f"Sample comment ids: {sample}")
+
+ if stats.mismatched:
+ # Not fatal: these keep their text and the monthly job re-archives
+ # them, but a large number means the archive is further out of
+ # date than expected
+ self.stdout.write(
+ self.style.WARNING(
+ f"{stats.mismatched:,} archived object(s) did not match the "
+ "current text and were left alone"
+ )
+ )
+
+ if stats.verify_failed:
+ # Distinct from a mismatch: nothing is known about these objects,
+ # so a non-zero count here means the bucket is what needs looking at
+ self.stdout.write(
+ self.style.ERROR(
+ f"{stats.verify_failed:,} archived object(s) could not be read "
+ "back and were left alone"
+ )
+ )
diff --git a/comments/migrations/0027_comment_is_text_archived.py b/comments/migrations/0027_comment_is_text_archived.py
new file mode 100644
index 0000000000..8963d98a6e
--- /dev/null
+++ b/comments/migrations/0027_comment_is_text_archived.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.15 on 2026-08-19 17:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('comments', '0026_comment_key_factor_votes_score'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='comment',
+ name='is_text_archived',
+ field=models.BooleanField(default=False, editable=False, help_text='True if the full text has been moved to S3 and only a truncated stub remains in the text columns. Archived comments cannot be edited; use the comment-full-text endpoint to read them.'),
+ ),
+ ]
diff --git a/comments/models.py b/comments/models.py
index c35836561b..b23585ba5a 100644
--- a/comments/models.py
+++ b/comments/models.py
@@ -99,6 +99,15 @@ class Comment(TimeStampedModel, TranslatedModel):
is_soft_deleted = models.BooleanField(default=False, db_index=True)
# Some comments with KeyFactors can have empty text
text = models.TextField(max_length=150_000, blank=True)
+ # Set by the `archive_bot_comment_texts` command. The full text lives in S3
+ # under a key derived from the comment id, so no pointer is stored here.
+ is_text_archived = models.BooleanField(
+ default=False,
+ editable=False,
+ help_text="True if the full text has been moved to S3 and only a "
+ "truncated stub remains in the text columns. Archived comments "
+ "cannot be edited; use the comment-full-text endpoint to read them.",
+ )
on_post = models.ForeignKey(
Post, models.CASCADE, null=True, related_name="comments"
)
diff --git a/comments/serializers/common.py b/comments/serializers/common.py
index 4d435c4f42..b7cf427e8c 100644
--- a/comments/serializers/common.py
+++ b/comments/serializers/common.py
@@ -76,6 +76,10 @@ class Meta:
"text_edited_at",
"is_soft_deleted",
"text",
+ # TODO: consumed by the front end in a later commit, which will
+ # replace the stub in `text` with a "load full text" affordance
+ # backed by the `comment-full-text` endpoint
+ "is_text_archived",
"on_post",
"on_post_data",
"included_forecast",
diff --git a/comments/services/common.py b/comments/services/common.py
index 418f0bf6e6..62e0baba3f 100644
--- a/comments/services/common.py
+++ b/comments/services/common.py
@@ -187,6 +187,13 @@ def perform_create_comment(
def update_comment(
comment: Comment, text: str = None, included_forecast: Forecast = None
):
+ if comment.is_text_archived:
+ # Only a stub of the text remains in the db, so we can neither diff
+ # against it nor let it be overwritten
+ raise ValidationError(
+ "This comment's text has been archived and can no longer be edited."
+ )
+
differ = difflib.Differ()
diff = list(differ.compare(comment.text.splitlines(), text.splitlines()))
diff --git a/comments/services/text_archive.py b/comments/services/text_archive.py
new file mode 100644
index 0000000000..eb7459675a
--- /dev/null
+++ b/comments/services/text_archive.py
@@ -0,0 +1,532 @@
+import json
+import logging
+from collections.abc import Callable
+from concurrent.futures import ThreadPoolExecutor
+from dataclasses import dataclass, field
+from datetime import timedelta
+
+from botocore.config import Config
+from django.conf import settings
+from django.core.serializers.json import DjangoJSONEncoder
+from django.db.models import Count, Q, QuerySet, Sum, TextField, Value
+from django.db.models.functions import Coalesce, Length, NullIf, Substr
+from django.utils import timezone
+
+from comments.models import Comment
+from utils.aws import get_boto_client
+from utils.translation import build_supported_localized_fieldname
+
+logger = logging.getLogger(__name__)
+
+# Comments older than this are eligible for archiving
+ARCHIVE_AGE_DAYS = 30
+# Only archive comments whose text is longer than this. Below this, it's not important
+# to move.
+ARCHIVE_MIN_TEXT_LENGTH = 500
+# Length of the stub left behind in the text columns
+ARCHIVE_STUB_LENGTH = 200
+
+S3_KEY_PREFIX = "comments_text"
+
+DEFAULT_BATCH_SIZE = 500
+# S3 has no multi-object PUT, so the only way to cut the wall-clock cost of the
+# uploads is to keep several of them in flight at once. They are latency bound,
+# not bandwidth bound, so this scales close to linearly.
+DEFAULT_CONCURRENCY = 8
+
+# `text` is the base column shadowed by modeltranslation: it holds a duplicate
+# of the original content that is written on save but never read back (reads of
+# `comment.text` resolve to `text_original` through the translation
+# descriptor). `text_original` may be NULL or empty on rows that were never
+# saved through the descriptor, so fall back to the base column.
+# `output_field` is required, not decorative: `text_original` is a
+# modeltranslation `TranslationTextField` and `Value("")` a `CharField`, which
+# Django refuses to reconcile on its own as soon as the expression is selected
+# rather than wrapped in `Length`/`Substr`.
+ORIGINAL_TEXT = Coalesce(
+ NullIf("text_original", Value("")), "text", output_field=TextField()
+)
+
+
+def check_is_enabled() -> bool:
+ return bool(settings.AWS_STORAGE_BUCKET_COMMENTS_TEXT)
+
+
+def build_key(comment_id: int) -> str:
+ """
+ The archive key is derived from the comment id, so it never needs to be
+ stored on the comment itself. This function is the only place that knows
+ the key layout.
+ """
+
+ return f"{S3_KEY_PREFIX}/{comment_id}.json"
+
+
+def get_archive_s3_client(concurrency: int = 1):
+ """
+ S3 client for the archive. Building a client is expensive, so callers that
+ upload many objects should build one and pass it around. The connection
+ pool has to be at least as large as the number of concurrent uploads, or
+ botocore serialises them behind the default pool of 10.
+ """
+
+ return get_boto_client(
+ "s3",
+ config=Config(
+ max_pool_connections=max(concurrency, 10),
+ # S3 answers a request rate it cannot sustain with 503 SlowDown.
+ # We run far below the limit, but `standard` mode covers the
+ # throttling error codes explicitly and backs off with jitter,
+ # rather than relying on the looser `legacy` default.
+ retries={"mode": "standard", "max_attempts": 5},
+ ),
+ )
+
+
+def upload_text(comment_id: int, text: str, s3=None) -> str:
+ """
+ Uploads the full original text of a comment to S3 and returns the key.
+
+ Only the original text is stored: bot/private comments are never
+ translated (see `trigger_update_comment_translations`), and storing
+ machine translations of an archived text would be pointless anyway.
+ """
+
+ s3 = s3 or get_archive_s3_client()
+ key = build_key(comment_id)
+
+ s3.put_object(
+ Bucket=settings.AWS_STORAGE_BUCKET_COMMENTS_TEXT,
+ Key=key,
+ Body=json.dumps(
+ {
+ "comment_id": comment_id,
+ "archived_at": timezone.now(),
+ "text": text,
+ },
+ cls=DjangoJSONEncoder,
+ ),
+ ContentType="application/json",
+ )
+
+ return key
+
+
+def fetch_text(comment_id: int, s3=None) -> str | None:
+ """
+ Reads the archived full text of a comment back from S3.
+ Returns None if the object is missing.
+ """
+
+ s3 = s3 or get_archive_s3_client()
+
+ try:
+ obj = s3.get_object(
+ Bucket=settings.AWS_STORAGE_BUCKET_COMMENTS_TEXT,
+ Key=build_key(comment_id),
+ )
+ except s3.exceptions.NoSuchKey:
+ logger.error("Archived text is missing for comment %s", comment_id)
+
+ return None
+
+ return json.loads(obj["Body"].read().decode("utf-8"))["text"]
+
+
+def get_full_text(comment: Comment) -> str | None:
+ """
+ Full text of a comment, transparently reading from the archive when the
+ stored text has been truncated.
+ """
+
+ if not comment.is_text_archived:
+ return comment.text
+
+ return fetch_text(comment.pk)
+
+
+def get_archivable_comments() -> QuerySet[Comment]:
+ """
+ Long private bot comments old enough to be archived.
+
+ Soft-deleted comments are included: their text is not rendered anywhere,
+ but it still occupies the row, and archiving keeps it recoverable.
+ """
+
+ cutoff = timezone.now() - timedelta(days=ARCHIVE_AGE_DAYS)
+
+ return (
+ # `rewrite(False)` is essential, not an optimisation. Comment is
+ # registered with modeltranslation, whose queryset rewrites every
+ # mention of `text` into the current language's column. Without it,
+ # `Length(ORIGINAL_TEXT)` degrades to measuring `text_original` twice
+ # and rows whose text only lives in the base column are never seen.
+ Comment.objects.rewrite(False)
+ .filter(
+ author__is_bot=True,
+ is_private=True,
+ is_text_archived=False,
+ created_at__lt=cutoff,
+ )
+ .annotate(text_length=Length(ORIGINAL_TEXT))
+ .filter(text_length__gt=ARCHIVE_MIN_TEXT_LENGTH)
+ )
+
+
+@dataclass
+class ArchiveStats:
+ # Number of comments the run expects to process. Only populated when a
+ # progress callback asks for it, since counting means measuring the length
+ # of every candidate text.
+ total: int = 0
+ archived: int = 0
+ failed: int = 0
+ skipped: int = 0
+ chars_reclaimed: int = 0
+ sample_ids: list[int] = field(default_factory=list)
+
+
+def _build_truncate_kwargs() -> dict:
+ """
+ Update kwargs that leave a stub in both copies of the original text and
+ drop every machine translation.
+ """
+
+ stub = Substr(ORIGINAL_TEXT, 1, ARCHIVE_STUB_LENGTH)
+ kwargs = {"text": stub, "text_original": stub, "is_text_archived": True}
+
+ for lang, _label in settings.LANGUAGES:
+ if lang == settings.ORIGINAL_LANGUAGE_CODE:
+ continue
+
+ kwargs[build_supported_localized_fieldname("text", lang)] = None
+
+ return kwargs
+
+
+def archive_bot_comment_texts(
+ dry_run: bool = False,
+ limit: int | None = None,
+ batch_size: int = DEFAULT_BATCH_SIZE,
+ concurrency: int = DEFAULT_CONCURRENCY,
+ on_progress: Callable[[ArchiveStats], None] | None = None,
+) -> ArchiveStats:
+ """
+ Moves the full text of long, private, old bot comments to S3, leaving a
+ truncated stub in the database.
+
+ `on_progress` is called with the running stats after every batch.
+ """
+
+ stats = ArchiveStats()
+ queryset = get_archivable_comments()
+
+ if dry_run:
+ # Aggregate without transferring any text. The limit has to be applied
+ # before aggregating, so that the reported totals describe the rows the
+ # real run would actually touch.
+ scoped = queryset.order_by("id")
+
+ if limit is not None:
+ scoped = scoped[:limit]
+
+ totals = scoped.aggregate(count=Count("id"), chars=Sum("text_length"))
+ count = totals["count"] or 0
+
+ stats.archived = count
+ stats.chars_reclaimed = max(
+ (totals["chars"] or 0) - count * ARCHIVE_STUB_LENGTH, 0
+ )
+ stats.sample_ids = list(
+ queryset.order_by("id").values_list("id", flat=True)[:5]
+ )
+
+ return stats
+
+ if on_progress is not None:
+ # Counting is not free: the eligibility filter measures the length of
+ # every candidate text, so this reads the whole candidate set
+ total = queryset.count()
+ stats.total = min(total, limit) if limit is not None else total
+
+ started_at = timezone.now()
+ truncate_kwargs = _build_truncate_kwargs()
+ cursor = 0
+ concurrency = max(concurrency, 1)
+ # One client, shared by every worker: botocore clients are safe to call
+ # from multiple threads once built, and building one per upload is pure
+ # overhead
+ s3 = get_archive_s3_client(concurrency)
+
+ while limit is None or stats.archived + stats.failed < limit:
+ page_size = batch_size
+ if limit is not None:
+ page_size = min(batch_size, limit - stats.archived - stats.failed)
+
+ # `original_text` is annotated rather than selecting both columns:
+ # they hold the same content, and a page of 500 comments that may run
+ # to 150k characters each is worth not loading twice.
+ rows = list(
+ queryset.filter(id__gt=cursor)
+ .order_by("id")
+ .annotate(original_text=ORIGINAL_TEXT)
+ .values("id", "original_text", "text_length")[:page_size]
+ )
+
+ if not rows:
+ break
+
+ # Advance past the whole page, including rows that failed to upload, so
+ # a persistent failure can never stall the run. Skipped rows stay
+ # eligible for the next one.
+ cursor = rows[-1]["id"]
+ uploaded_ids = []
+
+ # Each comment is still its own independently retrievable object; the
+ # requests are simply issued in parallel, since they are round-trip
+ # bound. The database update below waits for the whole page, so an
+ # upload can never be outrun by its own truncation.
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
+ futures = {
+ pool.submit(upload_text, row["id"], row["original_text"], s3): row["id"]
+ for row in rows
+ }
+
+ for future, comment_id in futures.items():
+ try:
+ future.result()
+ except Exception:
+ logger.exception("Failed to archive text of comment %s", comment_id)
+ stats.failed += 1
+
+ continue
+
+ uploaded_ids.append(comment_id)
+
+ if uploaded_ids:
+ # Only truncate rows that have not been touched since the run
+ # began, so an edit racing the upload can never lose text.
+ # `edited_at` is nullable on rows that predate
+ # TimeStampedModel.save.
+ # `rewrite(False)` again: modeltranslation's `update()` rewrites
+ # the `text` kwarg to `text_original`, which collides with the
+ # `text_original` kwarg and leaves the base column holding the
+ # full text — silently forfeiting half the space this reclaims.
+ untouched = (
+ Comment.objects.rewrite(False)
+ .filter(pk__in=uploaded_ids)
+ .filter(Q(edited_at__lt=started_at) | Q(edited_at__isnull=True))
+ )
+ archived_ids = set(untouched.values_list("id", flat=True))
+ updated = untouched.update(**truncate_kwargs)
+
+ stats.archived += updated
+ stats.skipped += len(uploaded_ids) - updated
+ stats.chars_reclaimed += sum(
+ max(row["text_length"] - ARCHIVE_STUB_LENGTH, 0)
+ for row in rows
+ if row["id"] in archived_ids
+ )
+ stats.sample_ids = (stats.sample_ids + sorted(archived_ids))[:5]
+
+ if on_progress is not None:
+ on_progress(stats)
+
+ return stats
+
+
+def list_archived_comment_ids(s3=None) -> set[int]:
+ """
+ Every comment id that already has an object in the archive, read straight
+ from the bucket.
+
+ The bucket is the authority on what has been uploaded: the point of the
+ sync below is to reconcile a database that knows nothing about uploads
+ performed elsewhere. It is not, however, the authority on what may be
+ truncated — see `get_syncable_comments`.
+ """
+
+ s3 = s3 or get_archive_s3_client()
+ prefix = f"{S3_KEY_PREFIX}/"
+ comment_ids = set()
+
+ for page in s3.get_paginator("list_objects_v2").paginate(
+ Bucket=settings.AWS_STORAGE_BUCKET_COMMENTS_TEXT, Prefix=prefix
+ ):
+ for obj in page.get("Contents", []):
+ stem = obj["Key"][len(prefix) :].removesuffix(".json")
+
+ if stem.isdigit():
+ comment_ids.add(int(stem))
+
+ return comment_ids
+
+
+@dataclass
+class SyncStats:
+ # Objects found in the bucket
+ total: int = 0
+ synced: int = 0
+ # Present in the bucket, but the row is already truncated
+ already_archived: int = 0
+ # Present in the bucket with no matching row: deleted since the upload
+ orphaned: int = 0
+ # A row the archiver would never have uploaded, or one already at or
+ # below the stub length: nothing to reclaim, and a hint that the bucket
+ # holds keys this command did not put there
+ ineligible: int = 0
+ # `--verify` only: the archived text no longer matches the row
+ mismatched: int = 0
+ # `--verify` only: the archived object could not be read back at all,
+ # which says nothing about whether it matches
+ verify_failed: int = 0
+ chars_reclaimed: int = 0
+ sample_ids: list[int] = field(default_factory=list)
+
+
+def get_syncable_comments(comment_ids) -> QuerySet[Comment]:
+ """
+ Rows this command is allowed to truncate against an archive uploaded
+ elsewhere.
+
+ Anything the archiver uploaded was a long, private bot comment, so those
+ invariants are re-asserted here rather than trusting the key alone: a
+ stray or mistyped object in the bucket must not be able to truncate a row
+ the archiver would never have touched.
+
+ Nothing here can tell whether the archived copy is still current — that
+ is what `--verify` is for.
+ """
+
+ return (
+ Comment.objects.rewrite(False)
+ .filter(
+ pk__in=comment_ids,
+ author__is_bot=True,
+ is_private=True,
+ is_text_archived=False,
+ )
+ .annotate(text_length=Length(ORIGINAL_TEXT))
+ .filter(text_length__gt=ARCHIVE_STUB_LENGTH)
+ )
+
+
+def _verify_archived_text(rows, s3, concurrency: int) -> tuple[set[int], set[int]]:
+ """
+ Ids whose archived object still matches the row's text exactly, and ids
+ whose object could not be read back at all.
+
+ The two are kept apart because they mean different things: a mismatch is
+ a stale archive, an unreadable object is an S3 problem. Both leave the
+ row alone.
+ """
+
+ verified = set()
+ unreadable = set()
+
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
+ futures = {pool.submit(fetch_text, row["id"], s3): row for row in rows}
+
+ for future, row in futures.items():
+ try:
+ archived = future.result()
+ except Exception:
+ logger.exception(
+ "Failed to read archived text of comment %s", row["id"]
+ )
+ unreadable.add(row["id"])
+
+ continue
+
+ if archived is None:
+ # `fetch_text` swallows a missing object and logs it
+ unreadable.add(row["id"])
+ elif archived == row["original_text"]:
+ verified.add(row["id"])
+
+ return verified, unreadable
+
+
+def sync_archived_comment_texts(
+ dry_run: bool = False,
+ verify: bool = False,
+ batch_size: int = DEFAULT_BATCH_SIZE,
+ concurrency: int = DEFAULT_CONCURRENCY,
+ on_progress: Callable[[SyncStats], None] | None = None,
+) -> SyncStats:
+ """
+ Truncates rows whose text is already in the archive, uploading nothing.
+
+ This exists for one migration. The uploads are slow and bandwidth-heavy,
+ so they are performed once against a copy of the database; this then
+ brings the real database in line with the bucket without moving the text
+ a second time.
+
+ `verify` re-reads every object and requires it to match the row before
+ truncating. That is the safe-but-slow path, and the only thing standing
+ between an archive that has gone stale and a lost edit: without it a row
+ is truncated on the strength of its key being in the bucket.
+ """
+
+ stats = SyncStats()
+ concurrency = max(concurrency, 1)
+ # One client for the whole run, listing and verification alike: building
+ # one is expensive, and the verification below would otherwise build a
+ # fresh one for every batch.
+ s3 = get_archive_s3_client(concurrency)
+ archived_ids = sorted(list_archived_comment_ids(s3))
+ stats.total = len(archived_ids)
+
+ truncate_kwargs = _build_truncate_kwargs()
+ columns = ["id", "text_length"] + (["original_text"] if verify else [])
+
+ for start in range(0, len(archived_ids), batch_size):
+ chunk = archived_ids[start : start + batch_size]
+
+ # Two queries per chunk so the accounting is exact: what the database
+ # knows about these ids, then which of them may be truncated.
+ states = dict(
+ Comment.objects.rewrite(False)
+ .filter(pk__in=chunk)
+ .values_list("id", "is_text_archived")
+ )
+ syncable = get_syncable_comments(chunk)
+
+ if verify:
+ syncable = syncable.annotate(original_text=ORIGINAL_TEXT)
+
+ rows = list(syncable.values(*columns))
+
+ stats.orphaned += len(chunk) - len(states)
+ already = sum(1 for archived in states.values() if archived)
+ stats.already_archived += already
+ stats.ineligible += len(states) - already - len(rows)
+
+ if verify and rows:
+ verified, unreadable = _verify_archived_text(rows, s3, concurrency)
+ stats.mismatched += len(rows) - len(verified) - len(unreadable)
+ stats.verify_failed += len(unreadable)
+ rows = [row for row in rows if row["id"] in verified]
+
+ if rows and not dry_run:
+ # Re-select at write time: a row archived or shortened between the
+ # select above and this update must not be truncated again.
+ eligible = get_syncable_comments([row["id"] for row in rows])
+ synced_ids = set(eligible.values_list("id", flat=True))
+ updated = eligible.update(**truncate_kwargs)
+
+ stats.synced += updated
+ stats.ineligible += len(rows) - updated
+ rows = [row for row in rows if row["id"] in synced_ids]
+ elif rows:
+ stats.synced += len(rows)
+
+ stats.chars_reclaimed += sum(
+ max(row["text_length"] - ARCHIVE_STUB_LENGTH, 0) for row in rows
+ )
+ stats.sample_ids = (stats.sample_ids + sorted(row["id"] for row in rows))[:5]
+
+ if on_progress is not None:
+ on_progress(stats)
+
+ return stats
diff --git a/comments/tasks.py b/comments/tasks.py
index 3da964b152..777a88dc7a 100644
--- a/comments/tasks.py
+++ b/comments/tasks.py
@@ -101,3 +101,34 @@ def update_current_top_comments_of_week():
# Update the week before
week_start_date = week_start_date - timedelta(days=7)
update_top_comments_of_week(week_start_date)
+
+
+# The monthly run walks a month of long private bot comments, so it needs far
+# more than dramatiq's default 10-minute time limit. Retries are capped at one:
+# every batch commits as it goes, so a failed run resumes rather than repeats,
+# and the default of 20 would just replay the same failure for hours.
+@dramatiq.actor(time_limit=1_800_000, max_retries=1)
+def job_archive_bot_comment_texts():
+ # Import here to avoid circular imports
+ from comments.services.text_archive import (
+ archive_bot_comment_texts,
+ check_is_enabled,
+ )
+
+ if not check_is_enabled():
+ # Logged as an error rather than skipped silently: once this job is
+ # scheduled, a missing bucket means the monthly cleanup never runs
+ logger.error(
+ "AWS_STORAGE_BUCKET_COMMENTS_TEXT is not configured, "
+ "comment text archiving cannot run"
+ )
+
+ return
+
+ stats = archive_bot_comment_texts()
+
+ logger.info(
+ f"Archived the text of {stats.archived} bot comment(s), "
+ f"reclaiming {stats.chars_reclaimed} characters "
+ f"({stats.failed} failed, {stats.skipped} skipped)"
+ )
diff --git a/comments/urls.py b/comments/urls.py
index 6c3a590a19..f98ab9e933 100644
--- a/comments/urls.py
+++ b/comments/urls.py
@@ -10,6 +10,11 @@
name="comment-delete",
),
path("comments/