diff --git a/changes/stats-vocabulary-summary-totals.md b/changes/stats-vocabulary-summary-totals.md index fe3a24ae..af8f4023 100644 --- a/changes/stats-vocabulary-summary-totals.md +++ b/changes/stats-vocabulary-summary-totals.md @@ -2,5 +2,5 @@ type: fixed area: stats - Fixed Vocabulary totals and charts counting only the first browsing page instead of all tracked vocabulary, without delaying the rest of the page. -- New-word history now uses permanent daily lexical rollups, backfilled in the background and repaired when tracked material is removed or reprocessed. +- New-word history now uses permanent daily lexical rollups, backfilled in the background and repaired when tracked material is removed or reprocessed; playback writes queue safely during the one-time rebuild and resume afterward. - Calendar-day chart labels now preserve the recorded local date in time zones west of UTC. diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index a8c55b1a..6e4e6a68 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -609,6 +609,99 @@ test('tracker starts the injected lexical rollup backfill when it is pending', a } }); +test('tracker queues playback writes until lexical rollup backfill settles', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + let startBackfill = (): void => {}; + let releaseBackfill = (): void => {}; + let markBackfillStarted = (): void => {}; + const backfillStartGate = new Promise((resolve) => { + startBackfill = resolve; + }); + const heldBackfill = new Promise((resolve) => { + releaseBackfill = resolve; + }); + const backfillStarted = new Promise((resolve) => { + markBackfillStarted = resolve; + }); + + try { + const setupDb = new Database(dbPath); + const { ensureSchema } = await import('./immersion-tracker/storage'); + ensureSchema(setupDb); + setupDb + .prepare( + `UPDATE imm_rollup_state SET state_value = '0' WHERE state_key = 'lexical_daily_rollups_ready'`, + ) + .run(); + setupDb.close(); + + const Ctor = await loadTrackerCtor(); + tracker = new Ctor( + { dbPath }, + { + runLexicalRollupBackfillTask: async (workerDbPath) => { + await backfillStartGate; + const workerDb = new Database(workerDbPath); + try { + workerDb.exec('BEGIN IMMEDIATE'); + markBackfillStarted(); + await heldBackfill; + workerDb.exec('COMMIT'); + } catch (error) { + try { + workerDb.exec('ROLLBACK'); + } catch { + // Preserve the original worker failure. + } + throw error; + } finally { + workerDb.close(); + } + }, + }, + ); + tracker.handleMediaChange('https://example.com/backfill-test.mp4', 'Backfill Test'); + startBackfill(); + await backfillStarted; + tracker.recordCardsMined(1); + + const privateApi = tracker as unknown as { + db: DatabaseSync; + queue: unknown[]; + flushNow: () => void; + writeLock: { locked: boolean }; + }; + assert.equal(privateApi.writeLock.locked, true); + privateApi.flushNow(); + + assert.ok(privateApi.queue.length > 0); + assert.equal( + ( + privateApi.db.prepare('SELECT COUNT(*) AS total FROM imm_session_events').get() as { + total: number; + } + ).total, + 0, + ); + + releaseBackfill(); + await waitForCondition(() => privateApi.queue.length === 0); + assert.equal( + ( + privateApi.db.prepare('SELECT COUNT(*) AS total FROM imm_session_events').get() as { + total: number; + } + ).total, + 1, + ); + } finally { + releaseBackfill(); + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + test('startup backfills lifetime summaries when retained sessions exist but summary tables are empty', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 56f53277..0cca853c 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -413,7 +413,10 @@ export class ImmersionTrackerService { private readonly monthlyRollupRetentionMs: number; private readonly vacuumIntervalMs: number; private readonly dbPath: string; - private readonly writeLock = { locked: false }; + private readonly writeLock = { + locked: false, + reasons: new Set<'flush' | 'delete-maintenance' | 'lexical-rollup-backfill'>(), + }; private readonly destroyDeleteMaintenanceRunner: () => void; private readonly runVocabularySummaryTask: ( knownWords: ReadonlySet | null, @@ -471,10 +474,10 @@ export class ImmersionTrackerService { runTask: (task) => runDeleteMaintenanceTask(this.dbPath, task), onBusy: () => { this.requireWriteQueueDrained('delete maintenance'); - this.writeLock.locked = true; + this.setWriteLock('delete-maintenance', true); }, onIdle: () => { - this.writeLock.locked = false; + this.setWriteLock('delete-maintenance', false); if (!this.isDestroyed && this.queue.length > 0) this.scheduleFlush(0); }, }); @@ -558,14 +561,6 @@ export class ImmersionTrackerService { this.db = new Database(this.dbPath); applyPragmas(this.db); ensureSchema(this.db); - if (!areLexicalDailyRollupsReady(this.db)) { - void this.runLexicalRollupBackfillTask().catch((error: unknown) => { - this.logger.warn( - 'Lexical daily rollup backfill failed; it will retry on next startup', - error, - ); - }); - } const reconciledSessions = reconcileStaleActiveSessions(this.db); if (reconciledSessions > 0) { this.logger.info( @@ -594,6 +589,7 @@ export class ImmersionTrackerService { } } this.preparedStatements = createTrackerPreparedStatements(this.db); + if (!areLexicalDailyRollupsReady(this.db)) this.startLexicalRollupBackfill(); this.scheduleMaintenance(); this.scheduleFlush(); } @@ -967,6 +963,31 @@ export class ImmersionTrackerService { } } + private setWriteLock( + reason: 'flush' | 'delete-maintenance' | 'lexical-rollup-backfill', + active: boolean, + ): void { + if (active) this.writeLock.reasons.add(reason); + else this.writeLock.reasons.delete(reason); + this.writeLock.locked = this.writeLock.reasons.size > 0; + } + + private startLexicalRollupBackfill(): void { + this.requireWriteQueueDrained('lexical rollup backfill'); + this.setWriteLock('lexical-rollup-backfill', true); + void this.runLexicalRollupBackfillTask() + .catch((error: unknown) => { + this.logger.warn( + 'Lexical daily rollup backfill failed; it will retry on next startup', + error, + ); + }) + .finally(() => { + this.setWriteLock('lexical-rollup-backfill', false); + if (!this.isDestroyed && this.queue.length > 0) this.scheduleFlush(0); + }); + } + async reassignAnimeAnilist( animeId: number, info: { @@ -2022,7 +2043,7 @@ export class ImmersionTrackerService { } const batch = this.queue.splice(0, Math.min(this.batchSize, this.queue.length)); - this.writeLock.locked = true; + this.setWriteLock('flush', true); try { this.db.exec('BEGIN IMMEDIATE'); for (const write of batch) { @@ -2034,7 +2055,7 @@ export class ImmersionTrackerService { this.queue.unshift(...batch); this.logger.warn('Immersion tracker flush failed, retrying later', error as Error); } finally { - this.writeLock.locked = false; + this.setWriteLock('flush', false); this.flushScheduled = false; if (this.queue.length > 0) { this.scheduleFlush(this.flushIntervalMs);