diff --git a/src/core/services/immersion-tracker/lexical-rollup-worker-runtime.test.ts b/src/core/services/immersion-tracker/lexical-rollup-worker-runtime.test.ts index 802df029..dbfa8ad1 100644 --- a/src/core/services/immersion-tracker/lexical-rollup-worker-runtime.test.ts +++ b/src/core/services/immersion-tracker/lexical-rollup-worker-runtime.test.ts @@ -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(); + } +}); diff --git a/src/core/services/immersion-tracker/lexical-rollup-worker-runtime.ts b/src/core/services/immersion-tracker/lexical-rollup-worker-runtime.ts index 5c320873..e83c0146 100644 --- a/src/core/services/immersion-tracker/lexical-rollup-worker-runtime.ts +++ b/src/core/services/immersion-tracker/lexical-rollup-worker-runtime.ts @@ -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; } +interface LexicalRollupWorkerRuntimeOptions { + resolveWorkerPath?: () => string | null; + createWorker?: (workerPath: string, workerData: { dbPath: string }) => Promise; + 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(); private destroyed = false; + constructor(private readonly options: LexicalRollupWorkerRuntimeOptions = {}) {} + async run(dbPath: string): Promise { 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; } diff --git a/src/core/services/immersion-tracker/lexical-rollups.ts b/src/core/services/immersion-tracker/lexical-rollups.ts index 7052df36..1c02987e 100644 --- a/src/core/services/immersion-tracker/lexical-rollups.ts +++ b/src/core/services/immersion-tracker/lexical-rollups.ts @@ -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; } } diff --git a/src/core/services/immersion-tracker/query-lexical.ts b/src/core/services/immersion-tracker/query-lexical.ts index f31e96fd..de1dfce9 100644 --- a/src/core/services/immersion-tracker/query-lexical.ts +++ b/src/core/services/immersion-tracker/query-lexical.ts @@ -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}) `, diff --git a/src/core/services/immersion-tracker/query-trends.ts b/src/core/services/immersion-tracker/query-trends.ts index 0ff7fbdc..dfa3c3e2 100644 --- a/src/core/services/immersion-tracker/query-trends.ts +++ b/src/core/services/immersion-tracker/query-trends.ts @@ -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, diff --git a/stats/src/hooks/useVocabulary.ts b/stats/src/hooks/useVocabulary.ts index 471b5e89..e5afebdf 100644 --- a/stats/src/hooks/useVocabulary.ts +++ b/stats/src/hooks/useVocabulary.ts @@ -76,6 +76,7 @@ export function useVocabulary() { }) .catch((chartError: unknown) => { console.error('Failed to load vocabulary charts', chartError); + if (!cancelled) chartRetryTimer = setTimeout(loadCharts, 1_000); }); }; loadCharts();