Skip to content

feat(retention): retire content weeks on one creation-anchored clock (DW-992) - #2115

Open
tobydigz wants to merge 5 commits into
feat/dw-992/file-expiry-apifrom
feat/dw-992/single-clock-retirement
Open

tobydigz wants to merge 5 commits into
feat/dw-992/file-expiry-apifrom
feat/dw-992/single-clock-retirement

Conversation

@tobydigz

@tobydigz tobydigz commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Second layer of the DW-992 stack, on top of #2113. This is the change that replaces the retention design; the first layer made the API honest about expires_at and validates the file expiry settings everywhere.

Why

Three loops ran in sequence to delete batch content, and each had a failure mode of its own:

  • An archive week waited 28 days after the last batch in it froze. A single late retry could hold every other batch's completions in the week far beyond the retention period.
  • Retiring an archive week stamped retention_expired_at on every batch in the week, in one statement inside the drop transaction. A large week can exceed the statement timeout; the transaction rolls back, drop included, and the engine retries on every tick, so that week never completes and every later week queues behind it.
  • That stamp was the only thing that released input files, which an hourly sweep then re-examined in bulk to expire almost none; only expired files released a template week. The sweep was also the files writer that stalled uploads behind weekly partition attach (DW-950).

Retention was also configured separately from the expiry the API shows, so the two could disagree and content could go before the date a file reported.

What

One clock, one period

Both weekly families (batch archive and request templates) drop a partition for one reason: the week is past the retention period R, measured from the week itself, and no batch created in it (archive) or reading input uploaded in it (templates) is still running.

R is derived from batches.files.max_expiry_seconds, rounded up to whole days. Every file's expires_at is at most created_at + max_expiry_seconds, which is at most the week's end plus R, so the date the API shows can never outrun the content. There is no separate retention setting and nothing left to warn about.

  • batch_archive_retirement.rs: candidate anchored on the week. completion_sql now stamps retention_expired_at only on the week's explicitly deleted batches, read through idx_batches_explicitly_deleted, so they leave the erasure purge's tombstone set once their archived rows are dropped. Finished batches are not written, so the statement stays small however large the week.
  • template_retirement.rs: candidate anchored on the week with the unfinished-batch guard; the two per-file scans removed. A dedicated (file-less) template row still fails the gate closed. The guard also bounds batches.created_at from the week start (a batch is always created after its input), so it range-scans the new index.
  • The guard reads batches (created_at) WHERE counts_frozen_at IS NULL AND deleted_at IS NULL, a new partial index that holds only the batches still running. Three-file concurrent migration (create / reindex / validate) with down files and checksum entries.
  • The file-content sweep (expire_file_content, FileExpiryCursor/FileExpiryStep, the daemon's hourly phase and constants) is deleted across fusillade-core, fusillade-arsenal and fusillade. Its metric, fusillade_file_content_expired_total, is removed; any dashboard or alert on it will flat-line and should be dropped.
  • Batch creation and retry additionally require the input's template week to be active, so a request is never populated from content that is gone.
  • The retirement enable flags are unchanged.

Deprecated settings

batch_daemon.batch_archive_retention_days and batch_daemon.template_retention_days are deprecated and ignored. They still parse so existing values files keep loading, and dwctl logs a warning at startup when either is set. Operators should remove them. For a deployment that set them, the effective retention period may change to the one derived from max_expiry_seconds.

In the fusillade crate, RetentionMaintenanceConfig takes one with_content_retention_days in place of the two per-family setters.

Nothing reads batches.retention_expired_at or files.retention_expired_at any more except the orphan purge, which uses them to tell explicit deletes apart. Archive retirement still sets the batch column on deleted batches (see above), and the columns stay.

Retry age cut-off

A retry makes a batch unfinished again, and an unfinished batch holds its weeks open. To keep that hold short and predictable, a batch cannot be retried after a certain age. While content retirement is enabled, retry (whole batch, or selected requests) is refused with a 400 once now > created_at + R - 1 day, counted from the batch's creation and from its input file's upload, whichever is earlier (a retry re-reads the input templates).

A week drops no earlier than week_start + 7 days + R, and everything in it was created at or after week_start, so the one-day margin always puts the cut-off before the partition can drop, never after.

Races between writers and retirement

  • Batch creation and both retry paths read the input's request_template_buckets row FOR SHARE (alongside the existing FOR UPDATE on the file).
  • Retrying an archived or split batch reads its own batch_archive_buckets row FOR SHARE (as archive_batch does) and is refused unless the week is active. Without this, the move-back would read a dropped partition, find nothing, and reset the batch over completions that are gone.
  • The shared engine's claim() now re-runs the family's unfinished-batch guard (FamilySpec::recheck_sql) in a fresh statement after locking the bucket row FOR UPDATE and before fencing it. Under READ COMMITTED that statement sees a batch committed while the claim waited for the lock; if one is found the claim backs off. A writer that arrives after the fence waits for it and then reads the week as no longer active.
  • The archive guard keys on the week a batch was created in, while archived rows live in archive_bucket. These differ only for a batch archived into a later week because its creation week was already fenced; the retry archive-week check and the age cut-off bound that case (documented in batch_archive_retirement.rs).

Rollout notes

  • Once live, every week past R becomes eligible at once. The engine retires one week per tick and re-ticks after 1 s; with no per-row work each drop is sub-second, so the backlog clears one week per tick shortly after deploy. No paced backfill is needed.
  • A week that previously failed to complete because its completion statement timed out completes on the next attempt (there is no completion statement any more).
  • Remove batch_archive_retention_days and template_retention_days from values files; until then each logs a deprecation warning at startup. Check that max_expiry_seconds is the retention you want, since it now sets it.
  • Retries of batches older than the cut-off now return 400 instead of being accepted.

Tests

  • Archive family: retires without touching batch metadata; a late finish no longer holds the week; a young week is not a candidate; only an unfinished batch created in the week blocks (live/split/other-week do not); a batch committed while the claim waits on the bucket lock stops the fence (fails without the re-check).
  • Template family: retires on age unless an unfinished batch reads it, and the file row is untouched; a young week is not a candidate; an unfinished batch on input from another week does not block; the unowned-row gate is unchanged.
  • Refusals: batch creation, batch retry and request retry each refuse with a validation error when the input week is retiring or retired, and succeed when it is active; an archived batch's retry is refused when its archive week is retiring or retired, without resetting the batch.
  • Retry age: just inside the cut-off is accepted and just past it is refused, from the batch's creation and from the input file's upload.
  • Config: retention rounds max_expiry_seconds up to whole days, and the deprecated keys still parse.
  • Daemon: the expiry-walk test is removed with the phase. Full just test rust passes.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Deploying control-layer with  Cloudflare Pages  Cloudflare Pages

Latest commit: c32232d
Status: ✅  Deploy successful!
Preview URL: https://85a612c4.control-layer.pages.dev
Branch Preview URL: https://feat-dw-992-single-clock-ret.control-layer.pages.dev

View logs

@tobydigz
tobydigz added this pull request to stack #2116 October 23, 2026 12:33
@tobydigz
tobydigz force-pushed the feat/dw-992/single-clock-retirement branch from 92ebe5e to 9580636 Compare October 26, 2026 12:46
@tobydigz
tobydigz marked this pull request as ready for review October 26, 2026 12:47
@cubic-dev-ai

cubic-dev-ai Bot commented Oct 26, 2026

Copy link
Copy Markdown

Running ultrareview automatically — this PR appears to change concurrency-sensitive code such as locks, queues, or retries, where a missed race may only surface under production load, so a deeper multi-pass review is worth running. I'll post findings when complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ultrareview ran automatically — this PR appears to change concurrency-sensitive code such as locks, queues, or retries, where a missed race may only surface under production load, so a deeper multi-pass review is worth running.

All reported issues were addressed across 26 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread dwctl/src/api/handlers/batches.rs Resolved
Comment thread docs/src/reference/configuration.md Resolved
@tobydigz
tobydigz force-pushed the feat/dw-992/single-clock-retirement branch from 9580636 to 6c61442 Compare October 26, 2026 13:46

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 reviewed files. 1 file intentionally excluded from review.

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread dwctl/src/api/handlers/batches.rs Resolved

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 2 files (changes from recent commits).

Requires human review: Retention redesign: one creation-anchored clock from max_expiry_seconds, partition retirement, retry cut-off, new index; drops content and changes retry API. Requires human sign-off for data deletion and request-path behavior change.

View guided diff | Turn on auto-fix | Re-trigger cubic

…(DW-992)

Both content families now drop a weekly partition for one reason: the
week is past its retention period, measured from the week itself, and no
batch created in it (or reading input uploaded in it) is still running.

Before, three loops ran in sequence. An archive week waited 28 days after
the *last* batch in it froze, so one retry weeks later held every other
batch's completions (the week of 24 Aug reached 65 days). Its completion
stamped retention_expired_at on every batch in the week in one statement
inside the drop transaction; at a million batches that outgrew the 30 s
statement timeout, the transaction rolled back drop and all, and the week
of 14 Sep has retried every 30 s since 19 Oct. Only that stamp released
input files, which an hourly sweep then re-examined, four million at a
time, to expire none; only expired files released a template week. The
sweep was the writer that stalled uploads behind weekly partition attach.

The stamp, the sweep, its cursor types and daemon phase, and the per-file
scans in the template candidate are gone. The guard that remains reads
unfinished batches through a new partial index on (created_at) WHERE
counts_frozen_at IS NULL AND deleted_at IS NULL: a few dozen rows. A
finished batch holds nothing open; its results do not need its templates.
Batch creation and retry additionally require the input's template week
to be active, so a request is never populated from content that is gone.

Results and input files report expiry from the expires_at they carry.
Startup warns when either retention period is shorter than the expiry the
API shows, since content would then go before its stated date.

The retention keys are unchanged. Set both to the same value for one
clock; the oldest row in a dropped week is 7 + N days old.
Review fixes for the creation-anchored retention clock.

A retry makes a batch unfinished again, and an unfinished batch holds its
weeks open, so a batch can no longer be retried once it is within one day
of either retention period (results from batch creation, input from file
upload). Weeks drop no earlier than week start + 7 days + N, so the
one-day margin keeps the cut-off before the drop. The limits come from the
same daemon retention settings, and apply only while that family's
retirement is enabled. Refusals map to 400 instead of 500.

Retrying an archived batch now share-locks its archive week and refuses
unless it is active; before, the move-back could read a dropped partition
and reset the batch over completions that were gone.

Creation and both retry paths share-lock the input's template week, and
claim() re-runs the family's unfinished-batch guard in a fresh statement
after locking the bucket row and before fencing it. Under READ COMMITTED
that statement sees a batch committed while the claim waited, closing the
window between the candidate query and the fence.

The template guard also bounds batches.created_at from the week start so
it range-scans the partial index. The startup warnings move into a helper
with a test of the comparison direction.
… (DW-992)

Retention was configured separately from the expiry the API shows, so the
two could disagree and content could go before a file's expires_at. Both
weekly families now drop a week once it is R days past its end, where R is
batches.files.max_expiry_seconds rounded up to whole days. Every file's
expires_at is at most created_at + max_expiry_seconds, which is at most the
week's end plus R, so the shown date can never outrun the content and the
startup warnings have nothing left to check.

batch_archive_retention_days and template_retention_days still parse but
are ignored, with a deprecation warning at startup. The fusillade
retention config takes one content_retention_days instead of two.

The retry age cut-off uses the same R: while either family's retirement is
enabled, a batch cannot be retried once it, or its input file, is older
than R minus one day.
… drop (DW-992)

Dropping the per-week stamp also dropped the only thing that took
explicitly deleted, archived batches out of the erasure purge's tombstone
set, so that set only grew and every purge tick probed each member.
Archive retirement now stamps retention_expired_at on the week's deleted
batches alone, read through idx_batches_explicitly_deleted; finished
batches stay untouched, so the write is small however large the week.

Also document the two retirement switches by name, and the retry-age
and retirement refusals in the retry endpoints' 400 descriptions, and
say that content retention follows max_expiry_seconds.
…cs (DW-992)

Retrying specific requests refuses with 400 only at the retry age
cut-off; a request whose input has been retired is left out and the
batch is returned with 200. The archive retirement module doc still
said nothing is stamped at retirement, but deleted batches now are.
@tobydigz
tobydigz force-pushed the feat/dw-992/single-clock-retirement branch from 6ca5fb9 to c32232d Compare October 26, 2026 14:01

This branch has not been deployed

No deployments
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