fix(stats): serialize excluded word writes to avoid races

- Queue setExcludedWords writes so a slower in-flight save can't land after and overwrite a newer edit
- Run server-sync listeners independently so one throwing listener doesn't roll back a successful write or block the rest
- Add regression tests for write ordering, listener isolation, coalesced known-word snapshots, and vocabulary hook aggregate refresh/retry
This commit is contained in:
2026-08-17 01:29:11 -07:00
parent 6018f393d2
commit 3c111d2e3b
6 changed files with 470 additions and 25 deletions
@@ -5058,6 +5058,7 @@ test('getVocabularySummary coalesces concurrent requests into one worker task',
let tracker: ImmersionTrackerService | null = null;
let taskRuns = 0;
let releaseTask: (() => void) | null = null;
const seenKnownWords: Array<ReadonlySet<string> | null> = [];
const summary = {
uniqueWords: 1,
uniqueWordsWithoutNames: 1,
@@ -5073,8 +5074,9 @@ test('getVocabularySummary coalesces concurrent requests into one worker task',
tracker = new Ctor(
{ dbPath },
{
runVocabularySummaryTask: async () => {
runVocabularySummaryTask: async (_dbPath, knownWords) => {
taskRuns += 1;
seenKnownWords.push(knownWords);
await new Promise<void>((resolve) => {
releaseTask = resolve;
});
@@ -5093,6 +5095,9 @@ test('getVocabularySummary coalesces concurrent requests into one worker task',
assert.deepEqual(await first, summary);
assert.equal(await second, await first);
assert.equal(taskRuns, 1);
// The coalesced caller's known-words set must not replace the snapshot the
// in-flight scan already started with.
assert.deepEqual(seenKnownWords, [null]);
releaseTask = null;
const third = tracker.getVocabularySummary(null);