fix: garbage-collect superseded media-usage generations - #2306
Conversation
🦋 Changeset detectedLatest commit: c70e6b2 The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
This is the right change for the right problem. _emdash_media_usage was growing without bound because the repository already had GC helpers but nothing invoked them; wiring them into the existing cron-only runSystemCleanup path is additive, low-risk, and avoids adding a DELETE round-trip to the authenticated save path.
I checked the changed files, traced runSystemCleanup call sites in emdash-runtime.ts, and confirmed the sweep is only reached from scheduler ticks (not logged-out HTTP routes), so the three extra age-gated queries per tick fit the project's query-count conventions. The sweep correctly preserves in-flight work via the one-hour cutoff, and the three repository GC partitions (stale created_at < indexed_at, abandoned created_at >= indexed_at, orphaned no-source) are disjoint. The integration test reproduces the superseded-generation case under describeEachDialect and will fail on main because the new module is absent.
No logic bugs or regressions. I did find one AGENTS.md comment-convention violation (a numbered subsystem comment) and one performance/discipline note: the GC queries order _emdash_media_usage by created_at, but the table has no index on that column, so backlogs will scan. Both are fixable in small follow-up edits.
|
Follow-up in 89d8219: added an end-to-end test asserting |
89d8219 to
343d532
Compare
There was a problem hiding this comment.
This is good and just needs a couple of fixes.
On the three open threads:
- The two comment nits (the numbered step comment in
cleanup.ts, the stale cross-reference in the test header) — please take both suggestions as-is. - The
created_atindex concern is real — I've checked migrations 046 and 052 and nothing indexescreated_at, so each candidate SELECT scans and sorts on a non-trivial backlog. We'll take that as a separate follow-up PR rather than grow this one: the sweep is capped at 500 rows per tick off any request path, so landing the wiring first is fine, and the index can follow independently.
With the two comment fixes this is good to merge.
Every content save writes a fresh generation of _emdash_media_usage rows and leaves the superseded generation behind. Reads join on current_generation, so non-current rows are pure dead weight — and the three repository GC methods that reclaim them had no callers, growing the table one generation per save without bound (90.5% of rows stale on an audited production database). Compose the GC methods into a sweep behind a one-hour safety window (guarded writers insert occurrence rows before winning the source CAS) with a bounded per-tick batch, and run it from runSystemCleanup so both scheduler drivers pick it up. A large backlog drains across ticks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
343d532 to
7d9b635
Compare
|
@ascorbic thanks for the feedback. The comments have been addressed. |
|
Checking the CI failure. |
The sweep's own behavior was covered, but nothing asserted that runSystemCleanup actually invokes it — the step could have been dropped without a test noticing. runSystemCleanup is directly callable from integration tests, so assert the new result fields end to end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7d9b635 to
c70e6b2
Compare
The GC sweep added in emdash-cms#2306 selects and deletes _emdash_media_usage rows by age, but every index on the table leads with identity columns, so the age-gated queries scan the table once a backlog builds. Follow-up agreed with ascorbic in the emdash-cms#2306 review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175x2Nu2m7LZUznaEQUAVQd
The GC sweep added in emdash-cms#2306 selects and deletes _emdash_media_usage rows by age, but every index on the table leads with identity columns, so the age-gated queries scan the table once a backlog builds. Follow-up agreed with ascorbic in the emdash-cms#2306 review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175x2Nu2m7LZUznaEQUAVQd
The GC sweep added in emdash-cms#2306 selects and deletes _emdash_media_usage rows by age, but every index on the table leads with identity columns, so the age-gated queries scan the table once a backlog builds. Follow-up agreed with ascorbic in the emdash-cms#2306 review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175x2Nu2m7LZUznaEQUAVQd
The GC sweep added in emdash-cms#2306 selects and deletes _emdash_media_usage rows by age, but every index on the table leads with identity columns, so the age-gated queries scan the table once a backlog builds. Follow-up agreed with ascorbic in the emdash-cms#2306 review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175x2Nu2m7LZUznaEQUAVQd
The GC sweep added in emdash-cms#2306 selects and deletes _emdash_media_usage rows by age, but every index on the table leads with identity columns, so the age-gated queries scan the table once a backlog builds. Follow-up agreed with ascorbic in the emdash-cms#2306 review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175x2Nu2m7LZUznaEQUAVQd
What does this PR do?
Fixes
_emdash_media_usagegrowing without bound: the table's generation model writes a fresh set of occurrence rows on every content save and leaves the superseded generation behind, and the three repository GC methods that exist to reclaim them (deleteStaleGenerationsOlderThan,deleteAbandonedGenerationsOlderThan,deleteOrphanOccurrencesOlderThan) had zero call sites. On the audited production deployment (emdash 0.31.1), 90.5% of rows (3,136 / 3,466) were stale generations, growing ~191 rows/day. Reads join oncurrent_generation, so the stale rows are pure dead weight.The fix wires the existing GC into the periodic maintenance path:
cleanupMediaUsageGenerations(db)inmedia/usage/gc.tscomposes the three GC methods behind a shared one-hour cutoff and a bounded per-tick batch (500). The age gate matters: guarded writers insert occurrence rows before winning the source CAS, so the window must exceed any plausible in-flight write (it mirrors the pending-upload abandonment window). The batch cap amortizes a large backlog across ticks instead of one oversized D1 batch.runSystemCleanupruns it as a new independent, non-fatal step (same try/catch-per-subsystem pattern as the other five), which covers both scheduler drivers — the Cloudflare Cron Trigger path and the Node scheduler tick.CleanupResultis extended additively with three new count fields.Deliberately not done: deleting the superseded generation inline in the save path. That would add a DELETE round-trip to every authenticated save on D1, would not reclaim abandoned (losing-CAS) generations or orphans anyway, and the repository's existing dialect-parity tests intentionally construct stale generations via
replaceSource— the sweep-only fix is purely additive. The cron path is not the logged-out hot path, so the three idle SELECTs per tick are within the project's query rules.Found during a measured database audit of a production deployment.
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain. — n/a: no admin UI strings changedAI-generated code disclosure
Screenshots / test output
The new test fails on
main(the sweep module does not exist — nothing calls the GC). After the fix, the sweep suite runs underdescribeEachDialect(SQLite + Postgres parity) and asserts: two saves + aged superseded rows → only the current generation survives and current usage still resolves; superseded rows younger than the safety window are untouched.🤖 Generated with Claude Code