diff --git a/src/infrastructure/persistence/catalog_store.ts b/src/infrastructure/persistence/catalog_store.ts index 0a6e2661..b08cb82e 100644 --- a/src/infrastructure/persistence/catalog_store.ts +++ b/src/infrastructure/persistence/catalog_store.ts @@ -20,6 +20,9 @@ import { DatabaseSync } from "node:sqlite"; import { dirname } from "@std/path"; import { ensureDirSync } from "@std/fs"; +import { getLogger } from "@logtape/logtape"; + +const logger = getLogger(["swamp", "persistence", "catalog"]); /** * A single row in the catalog table, representing one version of a data @@ -559,9 +562,19 @@ export class CatalogStore { * Must be called outside any open transaction. Acquires an exclusive lock * and rewrites the entire database file — on a large catalog this may take * several seconds. Safe to call only when no other connections are active. + * + * Returns `true` if VACUUM succeeded, `false` if it was skipped due to a + * runtime limitation (e.g. SQLITE_LIMIT_ATTACHED=0 in the canary Deno + * runtime). */ - vacuum(): void { - this.db.exec("VACUUM"); + vacuum(): boolean { + try { + this.db.exec("VACUUM"); + return true; + } catch (error) { + logger.warn`VACUUM skipped: ${error}`; + return false; + } } /** diff --git a/src/infrastructure/persistence/catalog_store_test.ts b/src/infrastructure/persistence/catalog_store_test.ts index 2bfc588a..3f040fe7 100644 --- a/src/infrastructure/persistence/catalog_store_test.ts +++ b/src/infrastructure/persistence/catalog_store_test.ts @@ -582,6 +582,17 @@ Deno.test("CatalogStore: checkpoint returns WAL page counts and truncates WAL", store.close(); }); +Deno.test("CatalogStore: vacuum returns boolean and does not throw", () => { + const dbPath = makeTempDbPath(); + const store = new CatalogStore(dbPath); + store.upsert(makeRow()); + + const result = store.vacuum(); + assertEquals(typeof result, "boolean"); + + store.close(); +}); + Deno.test("CatalogStore: invalidate clears populated flag but keeps data", () => { const dbPath = makeTempDbPath(); const store = new CatalogStore(dbPath); diff --git a/src/libswamp/datastores/compact.ts b/src/libswamp/datastores/compact.ts index bf5b5d6c..3fa13d06 100644 --- a/src/libswamp/datastores/compact.ts +++ b/src/libswamp/datastores/compact.ts @@ -27,6 +27,8 @@ export interface DatastoreCompactData { walPagesCheckpointed: number; /** Bytes reclaimed from the main database file after VACUUM. */ dbBytesReclaimed: number; + /** True when VACUUM was skipped due to a runtime limitation. */ + vacuumSkipped: boolean; } export type DatastoreCompactEvent = @@ -38,7 +40,7 @@ export type DatastoreCompactEvent = /** Dependencies for the datastore compact operation. */ export interface DatastoreCompactDeps { checkpoint: () => { walPagesTotal: number; walPagesCheckpointed: number }; - vacuum: () => void; + vacuum: () => boolean; catalogDbSize: () => Promise; } @@ -58,7 +60,7 @@ export async function* datastoreCompact( const beforeSize = await deps.catalogDbSize(); yield { kind: "vacuuming" } as const; - deps.vacuum(); + const vacuumed = deps.vacuum(); const afterSize = await deps.catalogDbSize(); yield { @@ -67,6 +69,7 @@ export async function* datastoreCompact( walPagesTotal: stats.walPagesTotal, walPagesCheckpointed: stats.walPagesCheckpointed, dbBytesReclaimed: Math.max(0, beforeSize - afterSize), + vacuumSkipped: !vacuumed, }, }; })(), diff --git a/src/libswamp/datastores/compact_test.ts b/src/libswamp/datastores/compact_test.ts index bedb8d61..e4771834 100644 --- a/src/libswamp/datastores/compact_test.ts +++ b/src/libswamp/datastores/compact_test.ts @@ -41,7 +41,7 @@ function makeDeps( ): DatastoreCompactDeps { return { checkpoint: () => ({ walPagesTotal: 10, walPagesCheckpointed: 10 }), - vacuum: () => {}, + vacuum: () => true, catalogDbSize: () => Promise.resolve(0), ...overrides, }; @@ -102,5 +102,22 @@ Deno.test("datastoreCompact: dbBytesReclaimed is 0 when db size does not decreas const completed = events.find((e) => e.kind === "completed"); if (completed?.kind === "completed") { assertEquals(completed.data.dbBytesReclaimed, 0); + assertEquals(completed.data.vacuumSkipped, false); + } +}); + +Deno.test("datastoreCompact: reports vacuumSkipped when vacuum returns false", async () => { + await initializeLogging({}); + const ctx = createLibSwampContext({}); + const deps = makeDeps({ + vacuum: () => false, + }); + + const events = await collectEvents(datastoreCompact(ctx, deps)); + const completed = events.find((e) => e.kind === "completed"); + assertEquals(completed?.kind, "completed"); + if (completed?.kind === "completed") { + assertEquals(completed.data.vacuumSkipped, true); + assertEquals(completed.data.dbBytesReclaimed, 0); } }); diff --git a/src/presentation/renderers/datastore_compact.ts b/src/presentation/renderers/datastore_compact.ts index 3690f9ef..f12420c7 100644 --- a/src/presentation/renderers/datastore_compact.ts +++ b/src/presentation/renderers/datastore_compact.ts @@ -47,7 +47,10 @@ class LogDatastoreCompactRenderer implements Renderer { } else { logger.info`WAL checkpointed and truncated`; } - if (e.data.dbBytesReclaimed > 0) { + if (e.data.vacuumSkipped) { + logger + .warn`VACUUM skipped (runtime limitation) — WAL checkpoint still reclaimed space`; + } else if (e.data.dbBytesReclaimed > 0) { logger .info`Catalog compacted: reclaimed ${e.data.dbBytesReclaimed} bytes`; } else { @@ -72,6 +75,7 @@ class JsonDatastoreCompactRenderer implements Renderer { walPagesTotal: e.data.walPagesTotal, walPagesCheckpointed: e.data.walPagesCheckpointed, dbBytesReclaimed: e.data.dbBytesReclaimed, + vacuumSkipped: e.data.vacuumSkipped, }, null, 2,