Skip to content

Make S3 rollout for issue attachments time-driven (T1/T2) instead of … - #682

Merged
znick merged 4 commits into
masterfrom
s3-rollout-t1-t2
Oct 12, 2026
Merged

znick merged 4 commits into
masterfrom
s3-rollout-t1-t2

Conversation

@znick

@znick znick commented Oct 11, 2026

Copy link
Copy Markdown
Owner

…flag-driven

Previously, migrating a File attachment to S3 was a single atomic step: upload bytes and immediately rewrite File.file.name to the S3 path in the same command invocation. There was no record of when a file was uploaded, and - contrary to the informal "day 1 upload, day 2 delete" mental model - there was actually no code path that ever deleted a local copy on a run after the one that uploaded it.

Add File.s3_uploaded_at (nullable). The management command's new two-phase flow:

  • Phase 1 (always runs on --execute): upload every File not yet on S3. Does NOT touch file.name, so anytask keeps serving the local link.
  • Phase 2 (only with --delete-local-copies): once a file's effective T2 threshold has passed, flip file.name to the S3 path and delete the local copy.

T1 (default: s3_uploaded_at + 7 months) is when File.get_url() switches from serving the local link to a virtual S3 link computed on the fly via S3OverlayStorage.append_s3_prefix(), without mutating file.name. T2 (default: effective T1 + 8 days) is when the command performs the flip+delete. Both are also settable as an absolute cutover datetime (AWS_S3_SERVE_AT / AWS_S3_DELETE_LOCAL_AT) that overrides the per-file relative delta for all uploaded files at once - an ops lever for forcing a bulk rollout. See common/s3_rollout.py for the threshold logic.

Also adds File.create_time/update_time (matching the Issue model's existing convention) as general hygiene, kept deliberately separate from s3_uploaded_at so unrelated future edits can never corrupt the S3 rollout schedule. Migration 0012 backfills create_time from Event.timestamp and s3_uploaded_at with a safe synthetic sentinel for files already migrated under the old algorithm.

Scope: issues.File only. users.UserProfile.avatar / s3migrate_user_avatars / the shared S3MigrateCommand base class are untouched (avatar's ImageField is hardcoded to storage=OverwriteStorage(), a local-only storage, so avatar URLs don't go through S3OverlayStorage today - fixing that is separate, out-of-scope work).

Verified end-to-end: full issues/api/common test suite (no new failures vs. baseline - same pre-existing locale/i18n failures before and after), plus the S3-dependent tests (upload, get_url() state transitions, delete+flip after T2, absolute override cutover) run for real against a live DigitalOcean Spaces bucket.

znick and others added 4 commits October 11, 2026 20:44
…flag-driven

Previously, migrating a File attachment to S3 was a single atomic step:
upload bytes and immediately rewrite File.file.name to the S3 path in the
same command invocation. There was no record of *when* a file was
uploaded, and - contrary to the informal "day 1 upload, day 2 delete"
mental model - there was actually no code path that ever deleted a local
copy on a run after the one that uploaded it.

Add File.s3_uploaded_at (nullable). The management command's new two-phase
flow:
  - Phase 1 (always runs on --execute): upload every File not yet on S3.
    Does NOT touch file.name, so anytask keeps serving the local link.
  - Phase 2 (only with --delete-local-copies): once a file's effective T2
    threshold has passed, flip file.name to the S3 path and delete the
    local copy.

T1 (default: s3_uploaded_at + 7 months) is when File.get_url() switches
from serving the local link to a virtual S3 link computed on the fly via
S3OverlayStorage.append_s3_prefix(), without mutating file.name. T2
(default: effective T1 + 8 days) is when the command performs the
flip+delete. Both are also settable as an absolute cutover datetime
(AWS_S3_SERVE_AT / AWS_S3_DELETE_LOCAL_AT) that overrides the per-file
relative delta for all uploaded files at once - an ops lever for forcing a
bulk rollout. See common/s3_rollout.py for the threshold logic.

Also adds File.create_time/update_time (matching the Issue model's
existing convention) as general hygiene, kept deliberately separate from
s3_uploaded_at so unrelated future edits can never corrupt the S3 rollout
schedule. Migration 0012 backfills create_time from Event.timestamp and
s3_uploaded_at with a safe synthetic sentinel for files already migrated
under the old algorithm.

Scope: issues.File only. users.UserProfile.avatar / s3migrate_user_avatars
/ the shared S3MigrateCommand base class are untouched (avatar's
ImageField is hardcoded to storage=OverwriteStorage(), a local-only
storage, so avatar URLs don't go through S3OverlayStorage today - fixing
that is separate, out-of-scope work).

Verified end-to-end: full issues/api/common test suite (no new failures
vs. baseline - same pre-existing locale/i18n failures before and after),
plus the S3-dependent tests (upload, get_url() state transitions,
delete+flip after T2, absolute override cutover) run for real against a
live DigitalOcean Spaces bucket.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…_s3, fix flake8

- settings_docker.py: AWS_S3_SERVE_DELAY_DAYS, AWS_S3_DELETE_LOCAL_DELAY_DAYS,
  AWS_S3_SERVE_AT, AWS_S3_DELETE_LOCAL_AT are now overridable via env vars,
  matching the existing AWS_S3_PRESIGNED_URLS_ENABLED pattern.
- issues/models.py: extract File.is_migrated_to_s3 property for readability,
  no behavior change.
- issues/tests.py: wrap a line over 120 chars (flake8 E501).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lready-uploaded path

issue.set_field_comment() re-wraps a previously uploaded solution file in a
brand new File row when it's attached to a comment event (same storage
path, same already-committed FieldFile bytes, no re-upload). That new row
started with s3_uploaded_at=NULL and was wrongly treated as never-uploaded
by get_url()'s T1 check, even though the underlying bytes had long since
been pushed to S3 - breaking test_attached_file_in_s3 in CI (local run had
skipped it, since minio wasn't reachable in the sandbox).

File.save() now inherits s3_uploaded_at from any sibling row already
pointing at the same path, for freshly created rows. Verified by
reproducing the exact CI failure locally against a moto-backed S3 mock
(matching CI's minio setup) with the fix reverted, then confirming it
passes with the fix and the full common/issues/api suite shows the same
12 pre-existing, unrelated i18n failures and no new ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@znick
znick merged commit f76769e into master Oct 12, 2026
6 checks passed
@znick
znick deleted the s3-rollout-t1-t2 branch October 12, 2026 18:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant