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 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(
private readonly configProvider: QueryCacheConfigProvider,
private readonly baseRepo: BaseRepo,
@@ -204,7 +228,7 @@ export class BaseQueryCacheService
const tShape = debug ? Date.now() : 0;
const items = duckRows.map((r) =>
shapeBaseRow(r, collection.columns, sortBuilds),
shapeBaseRow(r, collection.columns),
);
const shapeMs = debug ? Date.now() - tShape : 0;
@@ -252,7 +276,9 @@ export class BaseQueryCacheService
async invalidate(baseId: string): Promise<void> {
const collection = this.collections.get(baseId);
if (!collection) return;
await this.serializeWrite(async () => {
await this.runtime.detachBase(collection.schema);
});
this.collections.delete(baseId);
}
@@ -372,7 +398,9 @@ export class BaseQueryCacheService
}
if (existing) {
await this.serializeWrite(async () => {
await this.runtime.detachBase(existing.schema);
});
this.collections.delete(baseId);
}
@@ -428,7 +456,9 @@ export class BaseQueryCacheService
}
if (oldestKey) {
const col = this.collections.get(oldestKey)!;
await this.serializeWrite(async () => {
await this.runtime.detachBase(col.schema);
});
this.collections.delete(oldestKey);
this.logger.debug(`Evicted LRU collection ${oldestKey}`);
}
@@ -438,6 +468,7 @@ export class BaseQueryCacheService
collection: LoadedCollection,
row: Record<string, unknown>,
): Promise<void> {
return this.serializeWrite(async () => {
const specs = collection.columns;
const columnList = specs.map((s) => quoteIdent(s.column)).join(', ');
const placeholders = specs.map(() => '?').join(', ');
@@ -478,18 +509,21 @@ export class BaseQueryCacheService
}
}
await prepared.run();
});
}
private async deleteRow(
collection: LoadedCollection,
rowId: string,
): Promise<void> {
return this.serializeWrite(async () => {
const writer = this.runtime.getWriter();
const prepared = await writer.prepare(
`DELETE FROM ${collection.schema}.rows WHERE id = ?`,
);
prepared.bindVarchar(1, rowId);
await prepared.run();
});
}
private async updatePosition(
@@ -497,6 +531,7 @@ export class BaseQueryCacheService
rowId: string,
position: string,
): Promise<void> {
return this.serializeWrite(async () => {
const writer = this.runtime.getWriter();
const prepared = await writer.prepare(
`UPDATE ${collection.schema}.rows SET position = ? WHERE id = ?`,
@@ -504,18 +539,22 @@ export class BaseQueryCacheService
prepared.bindVarchar(1, position);
prepared.bindVarchar(2, rowId);
await prepared.run();
});
}
private async refreshRowCount(collection: LoadedCollection): Promise<void> {
return this.serializeWrite(async () => {
try {
const res = await this.runtime.getWriter().runAndReadAll(
`SELECT count(*) AS c FROM ${collection.schema}.rows`,
);
const row = res.getRowObjects()[0] as { c: bigint | number };
collection.rowCount = Number(row.c);
collection.approxBytes = collection.rowCount * collection.columns.length * 64;
} catch {
// stale rowCount self-corrects on next reload
}
});
}
private recordAccess(baseId: string): void {
@@ -562,7 +601,6 @@ function quoteIdent(name: string): string {
function shapeBaseRow(
raw: Record<string, unknown>,
specs: ColumnSpec[],
sortBuilds: SortBuild[],
): BaseRow {
const cells: Record<string, unknown> = {};
for (const spec of specs) {