Skip to content

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

Open
edrpls wants to merge 3 commits into
emdash-cms:mainfrom
edrpls:fix/media-usage-gc-sweep
Open

fix: garbage-collect superseded media-usage generations#2306
edrpls wants to merge 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 (Macabro festival site, 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

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>
@changeset-bot

changeset-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 343d532

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: 343d532

@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
console.error("[cleanup] Failed to prune revisions:", error);
}

// 6. Media-usage occurrence rows superseded by newer generations

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.

[needs fixing] Line 146 adds a numbered subsystem comment (// 6. ...). AGENTS.md's Comments section prohibits numbered comments, and the next line (await cleanupMediaUsageGenerations(db)) already says what the step does. Drop the numbered comment.

Suggested change
// 6. Media-usage occurrence rows superseded by newer generations
try {
const usage = await cleanupMediaUsageGenerations(db);
result.mediaUsageStaleGenerations = usage.staleGenerations;
result.mediaUsageAbandonedGenerations = usage.abandonedGenerations;
result.mediaUsageOrphanOccurrences = usage.orphanOccurrences;
} catch (error) {
console.error("[cleanup] Failed to clean media-usage generations:", error);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dropped in 8ccea09.

const cutoff = new Date(Date.now() - GC_MAX_AGE_MS).toISOString();
const repo = new MediaUsageRepository(db);
return {
staleGenerations: await repo.deleteStaleGenerationsOlderThan(cutoff, GC_BATCH_LIMIT),

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.

[suggestion] The sweep now actively runs three age-gated DELETE/SELECT pairs against _emdash_media_usage ordered by created_at (see MediaUsageRepository.deleteStaleGenerationsOlderThan, deleteAbandonedGenerationsOlderThan, and deleteOrphanOccurrencesOlderThan). Migration 046 created indexes on media_id, source_key+generation, and provider+provider_asset_id, but not on created_at. Without it, the maintenance sweep will scan or sort the whole table for any non-trivial backlog. Consider adding a forward-only migration such as:

// packages/core/src/database/migrations/055_media_usage_created_at_index.ts
import type { Kysely } from "kysely";

export async function up(db: Kysely<unknown>): Promise<void> {
	await db.schema
		.createIndex("idx__emdash_media_usage_created_at")
		.ifNotExists()
		.on("_emdash_media_usage")
		.column("created_at")
		.execute();
}

export async function down(db: Kysely<unknown>): Promise<void> {
	await db.schema.dropIndex("idx__emdash_media_usage_created_at").ifExists().execute();
}

(If production already has this index out-of-band, a guarded ifNotExists migration is still the safest place to document/make it portable.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed the created_at index is worth having for backlogs. I'm deliberately not adding the migration in this PR because of migration-name interlock with two other in-flight PRs from this audit batch: Kysely's runner rejects any new migration that sorts before an already-executed one, and 055/056 are claimed by the FTS fixes about to open from the same fork. Grabbing 057 here would brick upgrades for any site that runs this PR's migration before those land. I'll send the index as a tiny follow-up PR once the in-flight numbering settles — or fold it into whichever of the batch merges last, if maintainers prefer.

* the superseded generation behind; reads join on current_generation, so
* stale rows are dead weight that grows one generation per save. The sweep
* composes the repository GC methods behind a safety window and runs from
* runSystemCleanup (untestable here directly — see tests/unit/cleanup.test.ts).

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.

[suggestion] Line 8 points readers to tests/unit/cleanup.test.ts for runSystemCleanup coverage, but that file explicitly says it does not test runSystemCleanup directly. The parenthetical is also reviewer-facing justification/narrative, which AGENTS.md discourages. Remove the cross-reference and keep only the test's actual purpose.

Suggested change
* runSystemCleanup (untestable here directly see tests/unit/cleanup.test.ts).
/**
* Maintenance sweep for media-usage generations.
*
* Every content save writes a fresh generation of occurrence rows and leaves
* the superseded generation behind; reads join on current_generation, so
* stale rows are dead weight that grows one generation per save. The sweep
* composes the repository GC methods behind a safety window and is exercised
* here without the scheduler.
*/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adopted your suggested docstring — pushed in 8ccea09.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added 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
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 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
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.

1 participant