mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
fix(base): serialize writer operations and prune dead code in cache service
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user