fix(stats): version lexical rollups and preserve backfill writes

- Persist vocabulary_visible on imm_words and filter rollups/triggers by it, matching the totals' exclusion rules
- Normalize legacy second/millisecond timestamps when bucketing rollup rows into local days
- Replace the boolean rollup-ready flag with a version key so schema changes trigger an atomic rebuild
- Preserve queued playback writes during rollup backfill instead of dropping them under queue pressure
- Time out the lexical rollup worker instead of hanging forever on no response
- Key concurrent vocabulary summary requests by their known-words snapshot so they no longer share stale state
- Bound stats dashboard retries for unfinished vocabulary loads before showing the inline Retry control
This commit is contained in:
2026-08-17 23:49:04 -07:00
parent e64763b487
commit 3b89886557
20 changed files with 506 additions and 93 deletions
+30
View File
@@ -285,6 +285,36 @@ test('charts poll while the backfill is pending and stop once it is ready', asyn
}
});
test('chart backfill polling stops and surfaces Retry when readiness never arrives', async () => {
let chartCalls = 0;
const restoreClient = stubVocabularyClient({
getVocabularySummary: async () => summaryFixture(),
getVocabularyCharts: async () => {
chartCalls += 1;
return chartsFixture({ ready: false });
},
});
const harness = await mountHook();
try {
await harness.flush();
for (let poll = 0; poll < 65; poll += 1) await harness.tick(5_000);
assert.equal(chartCalls, 60, 'a failed backfill must not poll for the lifetime of the tab');
assert.match(harness.state().aggregatesError ?? '', /still building/i);
await act(async () => {
harness.state().refreshAggregates();
});
await harness.flush();
assert.equal(chartCalls, 61, 'Retry starts one fresh bounded polling cycle');
assert.equal(harness.state().aggregatesError, null);
} finally {
await harness.teardown();
restoreClient();
}
});
test('aggregates refetch after an exclusion edit is acknowledged by the server', async () => {
let summaryCalls = 0;
let chartCalls = 0;
+15 -6
View File
@@ -14,6 +14,7 @@ const AGGREGATE_RETRY_LIMIT = 5;
const CHART_BACKFILL_POLL_MS = 1_000;
const CHART_BACKFILL_SLOW_POLL_MS = 5_000;
const CHART_BACKFILL_FAST_POLLS = 30;
const CHART_BACKFILL_POLL_LIMIT = 60;
function aggregateRetryDelayMs(attempt: number): number {
return Math.min(AGGREGATE_RETRY_BASE_MS * 2 ** attempt, AGGREGATE_RETRY_MAX_MS);
@@ -117,12 +118,20 @@ export function useVocabulary() {
if (cancelled) return;
setCharts(nextCharts);
if (!nextCharts.ready) {
schedule(
() => loadCharts(0, readyPolls + 1),
readyPolls < CHART_BACKFILL_FAST_POLLS
? CHART_BACKFILL_POLL_MS
: CHART_BACKFILL_SLOW_POLL_MS,
);
const completedPolls = readyPolls + 1;
if (completedPolls < CHART_BACKFILL_POLL_LIMIT) {
schedule(
() => loadCharts(0, completedPolls),
completedPolls < CHART_BACKFILL_FAST_POLLS
? CHART_BACKFILL_POLL_MS
: CHART_BACKFILL_SLOW_POLL_MS,
);
} else {
setAggregatesError(
(previous) =>
previous ?? 'Vocabulary charts are still building. Retry to check again.',
);
}
}
})
.catch((chartError: unknown) => {