Skip to content

fix: garbage-collect superseded media-usage generations - #2306

Merged
ascorbic merged 3 commits into
emdash-cms:mainfrom
edrpls:fix/media-usage-gc-sweep
Aug 9, 2026
Merged

fix: garbage-collect superseded media-usage generations#2306
ascorbic merged 3 commits into
emdash-cms:mainfrom
edrpls:fix/media-usage-gc-sweep

Conversation

@edrpls

@edrpls edrpls commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes _emdash_media_usage growing 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 on current_generation, so the stale rows are pure dead weight.

The fix wires the existing GC into the periodic maintenance path:

  • New cleanupMediaUsageGenerations(db) in media/usage/gc.ts composes 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.
  • runSystemCleanup runs 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. CleanupResult is 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

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main. — n/a: no admin UI strings changed
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion — n/a: bug fix

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Fable 5 (Claude Code)

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 under describeEachDialect (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.

Tests  54 passed (54)   (media-usage-gc-sweep, media-usage-repository, cleanup suites)

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c70e6b2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet area/core size/M labels Jul 31, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jul 31, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2306

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2306

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2306

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2306

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2306

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2306

emdash

npm i https://pkg.pr.new/emdash@2306

create-emdash

npm i https://pkg.pr.new/create-emdash@2306

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2306

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2306

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2306

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2306

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2306

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2306

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2306

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2306

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2306

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2306

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2306

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2306

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2306

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2306

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2306

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2306

commit: c70e6b2

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread packages/core/src/cleanup.ts Outdated
Comment thread packages/core/src/media/usage/gc.ts
Comment thread packages/core/tests/integration/database/media-usage-gc-sweep.test.ts Outdated
@github-actions github-actions Bot added cla: signed review/needs-rereview Author pushed changes since the last review and removed review/needs-review No maintainer or bot review yet labels Jul 31, 2026
@edrpls

edrpls commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up in 89d8219: added an end-to-end test asserting runSystemCleanup actually invokes the sweep. Your review flagged the stale tests/unit/cleanup.test.ts cross-reference, and an adversarial pass over the branch found the substantive half — the wiring itself had no coverage, so the step could have been dropped without any test noticing. runSystemCleanup turns out to be directly callable from integration tests (media-stream-upload.test.ts already does it), so the new test calls it and asserts the new CleanupResult fields plus the actual row deletion. Verified it fails when the step is removed.

@github-actions github-actions Bot added size/L and removed size/M labels Jul 31, 2026
@edrpls
edrpls force-pushed the fix/media-usage-gc-sweep branch from 89d8219 to 343d532 Compare July 31, 2026 18:12

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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_at index concern is real — I've checked migrations 046 and 052 and nothing indexes created_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.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Aug 8, 2026
edrpls and others added 2 commits August 8, 2026 08:06
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>
@edrpls
edrpls force-pushed the fix/media-usage-gc-sweep branch from 343d532 to 7d9b635 Compare August 8, 2026 14:14
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Aug 8, 2026
@edrpls
edrpls requested a review from ascorbic August 8, 2026 14:21
@edrpls

edrpls commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

@ascorbic thanks for the feedback. The comments have been addressed.

@edrpls

edrpls commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

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>
@edrpls
edrpls force-pushed the fix/media-usage-gc-sweep branch from 7d9b635 to c70e6b2 Compare August 8, 2026 14:53

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks!

@ascorbic
ascorbic merged commit e07f0c8 into emdash-cms:main Aug 9, 2026
46 checks passed
@edrpls

edrpls commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

The created_at index follow-up from the review discussion is up: #2391 (migration 059 — numbered after the in-flight FTS pair #2313/#2314 per the migration-ordering constraint discussed there).

@emdashbot emdashbot Bot mentioned this pull request Aug 9, 2026
edrpls added a commit to edrpls/emdash that referenced this pull request Aug 9, 2026
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
edrpls added a commit to edrpls/emdash that referenced this pull request Aug 9, 2026
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
edrpls added a commit to edrpls/emdash that referenced this pull request Aug 9, 2026
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
edrpls added a commit to edrpls/emdash that referenced this pull request Aug 10, 2026
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
edrpls added a commit to edrpls/emdash that referenced this pull request Aug 11, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core cla: signed review/needs-rereview Author pushed changes since the last review size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants