fix(stats): keep lexical rollup backfill async

This commit is contained in:
2026-08-15 22:46:00 -07:00
parent e53f6d61ff
commit 05da8cf86a
6 changed files with 45 additions and 12 deletions
@@ -54,3 +54,16 @@ test('lexical rollup worker module resolves in the current layout', () => {
assert.ok(workerPath, 'expected the lexical rollup worker module to resolve');
assert.ok(workerPath.endsWith(__filename.endsWith('.ts') ? '.ts' : '.js'));
});
test('lexical rollup worker leaves a backfill pending when no worker can start', async () => {
const runtime = new LexicalRollupWorkerRuntime({
resolveWorkerPath: () => null,
warn: () => {},
} as never);
try {
await assert.doesNotReject(runtime.run('/tmp/not-used.sqlite'));
} finally {
runtime.destroy();
}
});
@@ -1,7 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { createLogger } from '../../../logger';
import { executeLexicalRollupBackfillTask } from './lexical-rollup-worker';
interface WorkerResponse {
ok?: boolean;
@@ -15,6 +14,12 @@ interface WorkerHandle {
terminate(): Promise<number>;
}
interface LexicalRollupWorkerRuntimeOptions {
resolveWorkerPath?: () => string | null;
createWorker?: (workerPath: string, workerData: { dbPath: string }) => Promise<WorkerHandle>;
warn?: (message: string, ...meta: unknown[]) => void;
}
const logger = createLogger('main:immersion-tracker:lexical-rollup-worker');
export function resolveLexicalRollupWorkerPath(): string | null {
@@ -29,21 +34,27 @@ export class LexicalRollupWorkerRuntime {
private readonly activeWorkers = new Set<WorkerHandle>();
private destroyed = false;
constructor(private readonly options: LexicalRollupWorkerRuntimeOptions = {}) {}
async run(dbPath: string): Promise<void> {
if (this.destroyed) throw new Error('Lexical rollup worker is shut down');
let worker: WorkerHandle;
try {
const workerPath = resolveLexicalRollupWorkerPath();
const workerPath = (this.options.resolveWorkerPath ?? resolveLexicalRollupWorkerPath)();
if (!workerPath) throw new Error('Emitted lexical rollup worker module was not found');
const { Worker } = await import('node:worker_threads');
worker = new Worker(workerPath, { workerData: { dbPath } });
const createWorker =
this.options.createWorker ??
(async (resolvedPath, workerData) => {
const { Worker } = await import('node:worker_threads');
return new Worker(resolvedPath, { workerData });
});
worker = await createWorker(workerPath, { dbPath });
} catch (error) {
if (this.destroyed) throw new Error('Lexical rollup worker is shut down');
logger.warn(
'Lexical rollup worker unavailable; running backfill on the current thread',
(this.options.warn ?? logger.warn)(
'Lexical rollup worker unavailable; leaving backfill pending for a later startup',
error,
);
executeLexicalRollupBackfillTask(dbPath);
return;
}
@@ -11,7 +11,7 @@ const LOCAL_EPOCH_DAY_SQL = `
CAST(julianday(CAST(%VALUE% AS REAL), 'unixepoch', 'localtime') - 2440587.5 AS INTEGER)
`;
function localEpochDaySql(value: string): string {
export function localEpochDaySql(value: string): string {
return LOCAL_EPOCH_DAY_SQL.replace('%VALUE%', value);
}
@@ -131,8 +131,10 @@ export function markLexicalDailyRollupsReady(db: DatabaseSync): void {
/** Rebuild from the first-seen source of truth; run off the UI/main DB thread. */
export function rebuildLexicalDailyRollups(db: DatabaseSync): void {
db.exec('BEGIN IMMEDIATE');
let transactionStarted = false;
try {
db.exec('BEGIN IMMEDIATE');
transactionStarted = true;
db.exec('DELETE FROM imm_lexical_daily_rollups');
db.exec(`
INSERT INTO imm_lexical_daily_rollups(epoch_day, word_count, word_count_without_names, kanji_count)
@@ -151,7 +153,7 @@ export function rebuildLexicalDailyRollups(db: DatabaseSync): void {
markLexicalDailyRollupsReady(db);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
if (transactionStarted) db.exec('ROLLBACK');
throw error;
}
}
@@ -20,7 +20,11 @@ import type {
} from './types';
import { fromDbTimestamp, toDbTimestamp } from './query-shared';
import { nowMs } from './time';
import { areLexicalDailyRollupsReady, getLexicalDailyRollups } from './lexical-rollups';
import {
areLexicalDailyRollupsReady,
getLexicalDailyRollups,
localEpochDaySql,
} from './lexical-rollups';
const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4;
const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100;
@@ -185,7 +189,7 @@ export function getVocabularyChartData(db: DatabaseSync): VocabularyChartData {
.prepare(
`
SELECT headword, word, reading, pos2,
CAST(julianday(CAST(first_seen AS REAL), 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS epochDay
${localEpochDaySql('first_seen')} AS epochDay
FROM imm_words
WHERE headword IN (${placeholders}) OR word IN (${placeholders}) OR reading IN (${placeholders})
`,
@@ -662,6 +662,8 @@ function buildNewWordsPerDay(
axis: number[] | null,
): TrendChartPoint[] {
if (areLexicalDailyRollupsReady(db)) {
// A trend range is defined in calendar buckets, so the rollup includes the
// complete local cutoff day rather than applying a time-of-day boundary.
const cutoffDay = cutoffMs === null ? null : getLocalEpochDay(db, cutoffMs);
const rows = getLexicalDailyRollups(db).filter(
(row) => cutoffDay === null || row.epochDay >= cutoffDay,