fix(base): serialize writer operations and prune dead code in cache service

This commit is contained in:
Philipinho
2026-04-23 16:50:11 +01:00
parent 38cd94b2d7
commit dbc1eb539c
@@ -52,6 +52,30 @@ export class BaseQueryCacheService
private readonly collections = new Map<string, LoadedCollection>(); private readonly collections = new Map<string, LoadedCollection>();
private readonly inFlightLoads = new Map<string, Promise<LoadedCollection>>(); private readonly inFlightLoads = new Map<string, Promise<LoadedCollection>>();
/*
* Serializes every write-path call into the shared writer connection.
* DuckDB connections aren't thread-safe for concurrent prepared statements,
* and Redis pub/sub can fire `applyChange` calls concurrently since the
* subscriber's `pmessage` handler doesn't await. We funnel all writes
* (`upsertRow`, `deleteRow`, `updatePosition`, `refreshRowCount`,
* `invalidate`, `evictLru`) through this simple Promise chain so only
* one is in flight at a time. Reads are unaffected — they flow through
* the reader pool, which handles its own concurrency.
*/
private writeQueue: Promise<void> = Promise.resolve();
private async serializeWrite<T>(fn: () => Promise<T>): Promise<T> {
const prev = this.writeQueue;
let unblock!: () => void;
this.writeQueue = new Promise<void>((resolve) => { unblock = resolve; });
try {
await prev;
return await fn();
} finally {
unblock();
}
}
constructor( constructor(
private readonly configProvider: QueryCacheConfigProvider, private readonly configProvider: QueryCacheConfigProvider,
private readonly baseRepo: BaseRepo, private readonly baseRepo: BaseRepo,
@@ -204,7 +228,7 @@ export class BaseQueryCacheService
const tShape = debug ? Date.now() : 0; const tShape = debug ? Date.now() : 0;
const items = duckRows.map((r) => const items = duckRows.map((r) =>
shapeBaseRow(r, collection.columns, sortBuilds), shapeBaseRow(r, collection.columns),
); );
const shapeMs = debug ? Date.now() - tShape : 0; const shapeMs = debug ? Date.now() - tShape : 0;
@@ -252,7 +276,9 @@ export class BaseQueryCacheService
async invalidate(baseId: string): Promise<void> { async invalidate(baseId: string): Promise<void> {
const collection = this.collections.get(baseId); const collection = this.collections.get(baseId);
if (!collection) return; if (!collection) return;
await this.serializeWrite(async () => {
await this.runtime.detachBase(collection.schema); await this.runtime.detachBase(collection.schema);
});
this.collections.delete(baseId); this.collections.delete(baseId);
} }
@@ -372,7 +398,9 @@ export class BaseQueryCacheService
} }
if (existing) { if (existing) {
await this.serializeWrite(async () => {
await this.runtime.detachBase(existing.schema); await this.runtime.detachBase(existing.schema);
});
this.collections.delete(baseId); this.collections.delete(baseId);
} }
@@ -428,7 +456,9 @@ export class BaseQueryCacheService
} }
if (oldestKey) { if (oldestKey) {
const col = this.collections.get(oldestKey)!; const col = this.collections.get(oldestKey)!;
await this.serializeWrite(async () => {
await this.runtime.detachBase(col.schema); await this.runtime.detachBase(col.schema);
});
this.collections.delete(oldestKey); this.collections.delete(oldestKey);
this.logger.debug(`Evicted LRU collection ${oldestKey}`); this.logger.debug(`Evicted LRU collection ${oldestKey}`);
} }
@@ -438,6 +468,7 @@ export class BaseQueryCacheService
collection: LoadedCollection, collection: LoadedCollection,
row: Record<string, unknown>, row: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
return this.serializeWrite(async () => {
const specs = collection.columns; const specs = collection.columns;
const columnList = specs.map((s) => quoteIdent(s.column)).join(', '); const columnList = specs.map((s) => quoteIdent(s.column)).join(', ');
const placeholders = specs.map(() => '?').join(', '); const placeholders = specs.map(() => '?').join(', ');
@@ -478,18 +509,21 @@ export class BaseQueryCacheService
} }
} }
await prepared.run(); await prepared.run();
});
} }
private async deleteRow( private async deleteRow(
collection: LoadedCollection, collection: LoadedCollection,
rowId: string, rowId: string,
): Promise<void> { ): Promise<void> {
return this.serializeWrite(async () => {
const writer = this.runtime.getWriter(); const writer = this.runtime.getWriter();
const prepared = await writer.prepare( const prepared = await writer.prepare(
`DELETE FROM ${collection.schema}.rows WHERE id = ?`, `DELETE FROM ${collection.schema}.rows WHERE id = ?`,
); );
prepared.bindVarchar(1, rowId); prepared.bindVarchar(1, rowId);
await prepared.run(); await prepared.run();
});
} }
private async updatePosition( private async updatePosition(
@@ -497,6 +531,7 @@ export class BaseQueryCacheService
rowId: string, rowId: string,
position: string, position: string,
): Promise<void> { ): Promise<void> {
return this.serializeWrite(async () => {
const writer = this.runtime.getWriter(); const writer = this.runtime.getWriter();
const prepared = await writer.prepare( const prepared = await writer.prepare(
`UPDATE ${collection.schema}.rows SET position = ? WHERE id = ?`, `UPDATE ${collection.schema}.rows SET position = ? WHERE id = ?`,
@@ -504,18 +539,22 @@ export class BaseQueryCacheService
prepared.bindVarchar(1, position); prepared.bindVarchar(1, position);
prepared.bindVarchar(2, rowId); prepared.bindVarchar(2, rowId);
await prepared.run(); await prepared.run();
});
} }
private async refreshRowCount(collection: LoadedCollection): Promise<void> { private async refreshRowCount(collection: LoadedCollection): Promise<void> {
return this.serializeWrite(async () => {
try { try {
const res = await this.runtime.getWriter().runAndReadAll( const res = await this.runtime.getWriter().runAndReadAll(
`SELECT count(*) AS c FROM ${collection.schema}.rows`, `SELECT count(*) AS c FROM ${collection.schema}.rows`,
); );
const row = res.getRowObjects()[0] as { c: bigint | number }; const row = res.getRowObjects()[0] as { c: bigint | number };
collection.rowCount = Number(row.c); collection.rowCount = Number(row.c);
collection.approxBytes = collection.rowCount * collection.columns.length * 64;
} catch { } catch {
// stale rowCount self-corrects on next reload // stale rowCount self-corrects on next reload
} }
});
} }
private recordAccess(baseId: string): void { private recordAccess(baseId: string): void {
@@ -562,7 +601,6 @@ function quoteIdent(name: string): string {
function shapeBaseRow( function shapeBaseRow(
raw: Record<string, unknown>, raw: Record<string, unknown>,
specs: ColumnSpec[], specs: ColumnSpec[],
sortBuilds: SortBuild[],
): BaseRow { ): BaseRow {
const cells: Record<string, unknown> = {}; const cells: Record<string, unknown> = {};
for (const spec of specs) { for (const spec of specs) {