Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
26e1ab8
test(core): cover bounded media usage cleanup
khoinguyenpham04 Aug 2, 2026
0ccc632
feat(core): add bounded media usage cleanup primitives
khoinguyenpham04 Aug 2, 2026
af7ad57
fix(core): run bounded media usage cleanup on schedule
khoinguyenpham04 Aug 2, 2026
e16fd58
docs(core): note scheduled media usage retention fix
khoinguyenpham04 Aug 2, 2026
a0b93e9
fix(core): prevent media usage cleanup deadlocks
khoinguyenpham04 Aug 2, 2026
ac53d5c
refactor(core): simplify media usage cleanup comments
khoinguyenpham04 Aug 3, 2026
4c6c2f5
test(core): enforce cleanup statement ceiling
khoinguyenpham04 Aug 3, 2026
4e45938
docs: correct PostgreSQL test opt-in variable
khoinguyenpham04 Aug 3, 2026
ba738c8
refactor(core): remove redundant cleanup comment
khoinguyenpham04 Aug 3, 2026
187a9ea
docs: document PostgreSQL test role requirement
khoinguyenpham04 Aug 3, 2026
afa10fe
Update packages/core/tests/integration/database/media-usage-cleanup-p…
khoinguyenpham04 Aug 3, 2026
a8aecb3
test(core): clarify media usage cleanup budgets
khoinguyenpham04 Aug 3, 2026
6bea8a4
fix(core): enforce media cleanup statement deadline
khoinguyenpham04 Aug 3, 2026
217fadb
test(core): cover partial cleanup deadline
khoinguyenpham04 Aug 3, 2026
15b9237
fix(core): preserve cleanup cursor on deadline
khoinguyenpham04 Aug 3, 2026
6305a9a
fix(core): retain incomplete cleanup sweeps
khoinguyenpham04 Aug 3, 2026
949feea
fix(core): preserve sweep at cleanup row cap
khoinguyenpham04 Aug 3, 2026
c36c46b
fix(core): strengthen media cleanup coordination
khoinguyenpham04 Aug 4, 2026
3107e88
test(core): remove superseded media usage GC test
khoinguyenpham04 Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cleanup-media-usage-generations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes retention of superseded media-usage projection generations during scheduled maintenance.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ In libraries used in a Worker but not themselves Workers, install `@cloudflare/w
# Testing

- **Framework:** vitest. Tests in `packages/core/tests/`.
- **No mocks for the DB.** SQLite (`better-sqlite3`) by default. PostgreSQL parity tests via a real `pg` connection with per-test schema isolation (set `PG_CONNECTION_STRING` to opt in).
- **No mocks for the DB.** SQLite (`better-sqlite3`) by default. PostgreSQL parity tests via a real `pg` connection with per-test schema isolation (set `EMDASH_TEST_PG` to a connection string for a role with `CREATEDB` to opt in).
- **Utilities:** `tests/utils/test-db.ts` exposes `setupTestDatabase()`, `setupTestDatabaseWithCollections()`, `teardownTestDatabase()` for SQLite and `setupTestPostgresDatabase()` etc. for Postgres. Dialect-agnostic: `setupForDialect`, `setupForDialectWithCollections`, `teardownForDialect`, plus `describeEachDialect(name, fn)`. Use the dialect wrapper for query-builder code -- regressions tend to be dialect-specific.
- **Structure:** `tests/unit/`, `tests/integration/`, `tests/e2e/` (Playwright). Test files mirror source structure. Each test gets a fresh DB.

Expand Down
18 changes: 6 additions & 12 deletions packages/core/src/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { MediaRepository } from "./database/repositories/media.js";
import { RevisionRepository } from "./database/repositories/revision.js";
import type { Database } from "./database/types.js";
import { removeUploadAttempt } from "./media/upload-attempts.js";
import { cleanupMediaUsageGenerations } from "./media/usage/gc.js";
import { cleanupMediaUsage } from "./media/usage/cleanup.js";
import type { Storage } from "./storage/types.js";

/**
Expand All @@ -31,9 +31,7 @@ export interface CleanupResult {
pendingUploadFiles: number;
uploadAttempts: number;
revisionsPruned: number;
mediaUsageStaleGenerations: number;
mediaUsageAbandonedGenerations: number;
mediaUsageOrphanOccurrences: number;
mediaUsage: number;
}

const REVISION_KEEP_COUNT = 50;
Expand Down Expand Up @@ -61,9 +59,7 @@ export async function runSystemCleanup(
pendingUploadFiles: -1,
uploadAttempts: -1,
revisionsPruned: -1,
mediaUsageStaleGenerations: -1,
mediaUsageAbandonedGenerations: -1,
mediaUsageOrphanOccurrences: -1,
mediaUsage: -1,
};

// 1. Passkey challenges (expire after 60s, clean anything past 5 min)
Expand Down Expand Up @@ -140,12 +136,10 @@ export async function runSystemCleanup(
}

try {
const usage = await cleanupMediaUsageGenerations(db);
result.mediaUsageStaleGenerations = usage.staleGenerations;
result.mediaUsageAbandonedGenerations = usage.abandonedGenerations;
result.mediaUsageOrphanOccurrences = usage.orphanOccurrences;
const mediaUsage = await cleanupMediaUsage(db);
result.mediaUsage = mediaUsage.status === "failed" ? -1 : mediaUsage.deletedRows;
} catch (error) {
console.error("[cleanup] Failed to clean media-usage generations:", error);
console.error("[cleanup] Failed to clean media usage:", error);
}

return result;
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/database/migrations/061_media_usage_cleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { sql, type Kysely } from "kysely";

import { currentTimestamp } from "../dialect-helpers.js";

const CLEANUP_TASK_KEY = "projection_gc";
const INITIAL_ELIGIBLE_AT = "1970-01-01T00:00:00.000Z";

export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createIndex("idx__emdash_media_usage_cleanup_scan")
.ifNotExists()
.on("_emdash_media_usage")
.columns(["created_at", "id", "source_key", "generation"])
.execute();

await db.schema
.createTable("_emdash_media_usage_cleanup")
.ifNotExists()
.addColumn("task_key", "text", (c) => c.primaryKey())
.addColumn("lease_token", "text")
.addColumn("lease_expires_at", "text")
.addColumn("next_eligible_at", "text", (c) => c.notNull())
.addColumn("cursor_created_at", "text")
.addColumn("cursor_id", "text")
.addColumn("scan_before_at", "text")
.addColumn("consecutive_failures", "integer", (c) => c.notNull().defaultTo(0))
.addColumn("last_started_at", "text")
.addColumn("last_completed_at", "text")
.addColumn("last_candidate_count", "integer", (c) => c.notNull().defaultTo(0))
.addColumn("last_deleted_orphans", "integer", (c) => c.notNull().defaultTo(0))
.addColumn("last_deleted_stale", "integer", (c) => c.notNull().defaultTo(0))
.addColumn("last_deleted_abandoned", "integer", (c) => c.notNull().defaultTo(0))
.addColumn("last_deleted_write_leases", "integer", (c) => c.notNull().defaultTo(0))
.addColumn("last_backlog_lower_bound", "integer", (c) => c.notNull().defaultTo(0))
.addColumn("last_scan_has_more", "integer", (c) => c.notNull().defaultTo(0))
.addColumn("last_duration_ms", "integer", (c) => c.notNull().defaultTo(0))
.addColumn("last_error_code", "text")
.addColumn("updated_at", "text", (c) => c.notNull().defaultTo(currentTimestamp(db)))
.execute();

await db.schema
.createTable("_emdash_media_usage_generation_writes")
.ifNotExists()
.addColumn("source_key", "text", (c) => c.notNull())
.addColumn("generation", "text", (c) => c.notNull())
.addColumn("lease_token", "text", (c) => c.primaryKey())
.addColumn("expires_at", "text", (c) => c.notNull())
.addColumn("created_at", "text", (c) => c.notNull().defaultTo(currentTimestamp(db)))
.addUniqueConstraint("_emdash_media_usage_generation_writes_source_generation", [
"source_key",
"generation",
])
.execute();

await db.schema
.createIndex("idx__emdash_media_usage_generation_writes_expiry")
.ifNotExists()
.on("_emdash_media_usage_generation_writes")
.columns(["expires_at", "lease_token"])
.execute();

await sql`
INSERT INTO _emdash_media_usage_cleanup (task_key, next_eligible_at)
VALUES (${CLEANUP_TASK_KEY}, ${INITIAL_ELIGIBLE_AT})
ON CONFLICT (task_key) DO NOTHING
`.execute(db);
}

export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema
.dropIndex("idx__emdash_media_usage_generation_writes_expiry")
.ifExists()
.execute();
await db.schema.dropTable("_emdash_media_usage_generation_writes").ifExists().execute();
await db.schema.dropTable("_emdash_media_usage_cleanup").ifExists().execute();
await db.schema.dropIndex("idx__emdash_media_usage_cleanup_scan").ifExists().execute();
}
Loading
Loading