perf(server): skip per-request row count when collection is resident

This commit is contained in:
Philipinho
2026-04-19 23:39:27 +01:00
parent 7e07d77510
commit c931fa5ec9
5 changed files with 114 additions and 1 deletions
@@ -279,6 +279,13 @@ export class BaseQueryCacheService
return this.collections.size;
}
// Production-facing fast path for the router: returns the resident
// collection without triggering a load. Used to avoid a per-request
// Postgres COUNT when the cached rowCount already answers the question.
peek(baseId: string): LoadedCollection | undefined {
return this.collections.get(baseId);
}
/*
* Apply a change envelope received from Redis pub/sub to the local
* collection (if any). Rows that target bases not resident on this node
@@ -300,12 +307,15 @@ export class BaseQueryCacheService
return;
case 'row-upsert':
await this.upsertRow(collection, env.row);
await this.refreshRowCount(collection);
return;
case 'row-delete':
await this.deleteRow(collection, env.rowId);
await this.refreshRowCount(collection);
return;
case 'rows-delete':
for (const id of env.rowIds) await this.deleteRow(collection, id);
await this.refreshRowCount(collection);
return;
case 'row-reorder':
await this.updatePosition(collection, env.rowId, env.position);
@@ -321,6 +331,19 @@ export class BaseQueryCacheService
}
}
private async refreshRowCount(collection: LoadedCollection): Promise<void> {
try {
const res = await collection.connection.runAndReadAll(
'SELECT count(*) AS c FROM rows',
);
const row = res.getRowObjects()[0] as { c: bigint | number };
collection.rowCount = Number(row.c);
} catch {
// swallow — stale rowCount drifts at most by the size of the burst; the
// next reload-from-Postgres or pubsub event corrects it.
}
}
private async upsertRow(
collection: LoadedCollection,
row: Record<string, unknown>,
@@ -1,6 +1,7 @@
import { BaseQueryRouter } from './base-query-router';
import { QueryCacheConfigProvider } from './query-cache.config';
import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
import { BaseQueryCacheService } from './base-query-cache.service';
import { FilterNode, SearchSpec, SortSpec } from '../engine';
type FakeConfig = { enabled: boolean; minRows: number };
@@ -21,8 +22,13 @@ function makeRouter(
const countSpy = jest.fn().mockResolvedValue(count);
const baseRowRepo = { countActiveRows: countSpy } as unknown as BaseRowRepo;
// Default fake: always miss, so `decide` falls through to countActiveRows.
const fakeCacheService = {
peek: () => undefined,
} as unknown as BaseQueryCacheService;
return {
router: new BaseQueryRouter(configProvider, baseRowRepo),
router: new BaseQueryRouter(configProvider, baseRowRepo, fakeCacheService),
countSpy,
};
}
@@ -98,4 +104,56 @@ describe('BaseQueryRouter.decide', () => {
const decision = await router.decide({ ...baseArgs, search: trgmSearch });
expect(decision).toBe('postgres');
});
it('uses cached row count from resident collection (no Postgres call)', async () => {
const countSpy = jest.fn().mockResolvedValue(999999); // shouldn't be called
const cacheService = {
peek: jest.fn().mockReturnValue({ baseId: 'base-1', rowCount: 50_000 }),
} as unknown as BaseQueryCacheService;
const router = new BaseQueryRouter(
{
config: {
enabled: true,
minRows: 25_000,
maxCollections: 10,
warmTopN: 0,
},
} as unknown as QueryCacheConfigProvider,
{ countActiveRows: countSpy } as unknown as BaseRowRepo,
cacheService,
);
const decision = await router.decide({
...baseArgs,
sorts,
});
expect(decision).toBe('cache');
expect((cacheService.peek as jest.Mock)).toHaveBeenCalledWith('base-1');
expect(countSpy).not.toHaveBeenCalled();
});
it('falls back to Postgres count when collection is not resident', async () => {
const countSpy = jest.fn().mockResolvedValue(30_000);
const cacheService = {
peek: jest.fn().mockReturnValue(undefined),
} as unknown as BaseQueryCacheService;
const router = new BaseQueryRouter(
{
config: {
enabled: true,
minRows: 25_000,
maxCollections: 10,
warmTopN: 0,
},
} as unknown as QueryCacheConfigProvider,
{ countActiveRows: countSpy } as unknown as BaseRowRepo,
cacheService,
);
const decision = await router.decide({
...baseArgs,
sorts,
});
expect(decision).toBe('cache');
expect((cacheService.peek as jest.Mock)).toHaveBeenCalledWith('base-1');
expect(countSpy).toHaveBeenCalledWith('base-1', { workspaceId: 'ws-1' });
});
});
@@ -3,6 +3,7 @@ import { QueryCacheConfigProvider } from './query-cache.config';
import { BaseRowRepo } from '@docmost/db/repos/base/base-row.repo';
import type { FilterNode, SearchSpec, SortSpec } from '../engine';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { BaseQueryCacheService } from './base-query-cache.service';
export type RouteDecision = 'postgres' | 'cache';
@@ -19,6 +20,7 @@ export class BaseQueryRouter {
constructor(
private readonly configProvider: QueryCacheConfigProvider,
private readonly baseRowRepo: BaseRowRepo,
private readonly cacheService: BaseQueryCacheService,
@Optional() private readonly env: EnvironmentService | null = null,
) {}
@@ -34,6 +36,26 @@ export class BaseQueryRouter {
// v1: any search stays on Postgres — loader doesn't populate search_text yet.
if (hasSearch) return 'postgres';
// Fast path: if the collection is already resident, read the cached
// row count instead of running a Postgres COUNT on every request.
const resident = this.cacheService.peek(args.baseId);
if (resident) {
const debug = this.env?.getBaseQueryCacheDebug() ?? false;
if (debug) {
console.log(
'[cache-perf]',
JSON.stringify({
phase: 'router.residentCount',
baseId: args.baseId.slice(0, 8),
count: resident.rowCount,
minRows,
}),
);
}
if (resident.rowCount < minRows) return 'postgres';
return 'cache';
}
const debug = this.env?.getBaseQueryCacheDebug() ?? false;
const tCount = debug ? Date.now() : 0;
const count = await this.baseRowRepo.countActiveRows(args.baseId, {
@@ -117,6 +117,13 @@ export class CollectionLoader {
`Loaded ${rowCount} rows for base ${baseId} (schemaVersion=${schemaVersion})`,
);
const countResult = await connection.runAndReadAll(
'SELECT count(*) AS c FROM rows',
);
const cachedRowCount = Number(
(countResult.getRowObjects()[0] as { c: bigint | number }).c,
);
return {
baseId,
schemaVersion,
@@ -124,6 +131,7 @@ export class CollectionLoader {
instance,
connection,
lastAccessedAt: Date.now(),
rowCount: cachedRowCount,
};
} catch (err) {
if (appender) {
@@ -27,6 +27,8 @@ export type LoadedCollection = {
instance: DuckDBInstance;
connection: DuckDBConnection;
lastAccessedAt: number;
// cached; set by loader, maintained by applyChange
rowCount: number;
};
export type ChangeEnvelope =