mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-18 00:21:41 -07:00
fix(stats): refresh vocabulary aggregates after exclusion edits
- Refetch vocabulary summary/charts once the exclusion list write is server-acknowledged, and retry failed aggregate loads with backoff before showing an inline error with a Retry control - Coalesce concurrent getVocabularySummary calls into a single worker task - Scan imm_words in id-keyed batches to bound memory for large vocabularies
This commit is contained in:
@@ -5052,3 +5052,58 @@ test('ensureAnimeCoverArt fetches art via the latest video of the anime', async
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getVocabularySummary coalesces concurrent requests into one worker task', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
let taskRuns = 0;
|
||||
let releaseTask: (() => void) | null = null;
|
||||
const summary = {
|
||||
uniqueWords: 1,
|
||||
uniqueWordsWithoutNames: 1,
|
||||
uniqueKanji: 0,
|
||||
newThisWeek: 0,
|
||||
newThisWeekWithoutNames: 0,
|
||||
knownWordCount: null,
|
||||
knownWordCountWithoutNames: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor(
|
||||
{ dbPath },
|
||||
{
|
||||
runVocabularySummaryTask: async () => {
|
||||
taskRuns += 1;
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseTask = resolve;
|
||||
});
|
||||
return summary;
|
||||
},
|
||||
destroyVocabularySummaryRunner: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const first = tracker.getVocabularySummary(null);
|
||||
const second = tracker.getVocabularySummary(new Set(['猫']));
|
||||
await waitForCondition(() => releaseTask !== null);
|
||||
let release = releaseTask as (() => void) | null;
|
||||
assert.ok(release);
|
||||
release();
|
||||
assert.deepEqual(await first, summary);
|
||||
assert.equal(await second, await first);
|
||||
assert.equal(taskRuns, 1);
|
||||
|
||||
releaseTask = null;
|
||||
const third = tracker.getVocabularySummary(null);
|
||||
await waitForCondition(() => releaseTask !== null);
|
||||
release = releaseTask as (() => void) | null;
|
||||
assert.ok(release);
|
||||
release();
|
||||
assert.deepEqual(await third, summary);
|
||||
assert.equal(taskRuns, 2);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -421,6 +421,7 @@ export class ImmersionTrackerService {
|
||||
private readonly runVocabularySummaryTask: (
|
||||
knownWords: ReadonlySet<string> | null,
|
||||
) => Promise<VocabularyStatsSummary>;
|
||||
private vocabularySummaryInFlight: Promise<VocabularyStatsSummary> | null = null;
|
||||
private readonly destroyVocabularySummaryRunner: () => void;
|
||||
private readonly runLexicalRollupBackfillTask: () => Promise<void>;
|
||||
private readonly destroyLexicalRollupBackfillRunner: () => void;
|
||||
@@ -680,7 +681,17 @@ export class ImmersionTrackerService {
|
||||
}
|
||||
|
||||
async getVocabularySummary(knownWords: ReadonlySet<string> | null) {
|
||||
return this.runVocabularySummaryTask(knownWords);
|
||||
// Concurrent dashboard refreshes share one worker scan; the coalesced
|
||||
// callers accept the first caller's known-words snapshot.
|
||||
const inFlight = this.vocabularySummaryInFlight;
|
||||
if (inFlight) return inFlight;
|
||||
const task = this.runVocabularySummaryTask(knownWords);
|
||||
this.vocabularySummaryInFlight = task;
|
||||
try {
|
||||
return await task;
|
||||
} finally {
|
||||
if (this.vocabularySummaryInFlight === task) this.vocabularySummaryInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
async getVocabularyChartData() {
|
||||
|
||||
@@ -1958,6 +1958,33 @@ test('getVocabularySummary applies vocabulary exclusions and Hide Names totals',
|
||||
}
|
||||
});
|
||||
|
||||
test('getVocabularySummary counts identically across id-keyed scan batches', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const insertWord = db.prepare(`
|
||||
INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3,
|
||||
first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, '', 'noun', '名詞', '一般', '', 1, 1, 1)
|
||||
`);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
insertWord.run(`単語${index}`, `単語${index}`);
|
||||
}
|
||||
|
||||
const fullScan = getVocabularySummary(db, new Set(['単語0']), 9 * 86_400_000);
|
||||
const batchedScan = getVocabularySummary(db, new Set(['単語0']), 9 * 86_400_000, 2);
|
||||
|
||||
assert.equal(fullScan.uniqueWords, 5);
|
||||
assert.deepEqual(batchedScan, fullScan);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = openTestDb(dbPath);
|
||||
|
||||
@@ -31,6 +31,7 @@ const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100;
|
||||
const VOCABULARY_CHART_LIMIT = 12;
|
||||
const VOCABULARY_CHART_PAGE_SIZE = 100;
|
||||
const EXCLUSION_ALIAS_BATCH_SIZE = 300;
|
||||
const VOCABULARY_SUMMARY_SCAN_BATCH_SIZE = 5_000;
|
||||
const SENTENCE_SEARCH_DEFAULT_LIMIT = 50;
|
||||
const SENTENCE_SEARCH_MAX_LIMIT = 100;
|
||||
const KANJI_PATTERN = /\p{Script=Han}/gu;
|
||||
@@ -293,19 +294,21 @@ export function getVocabularySummary(
|
||||
db: DatabaseSync,
|
||||
knownWords: ReadonlySet<string> | null,
|
||||
nowMs: number = Date.now(),
|
||||
scanBatchSize: number = VOCABULARY_SUMMARY_SCAN_BATCH_SIZE,
|
||||
): VocabularyStatsSummary {
|
||||
const words = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id AS wordId, headword, word, reading,
|
||||
part_of_speech AS partOfSpeech, pos1, pos2, pos3,
|
||||
frequency, frequency_rank AS frequencyRank,
|
||||
first_seen AS firstSeen, last_seen AS lastSeen,
|
||||
0 AS animeCount
|
||||
FROM imm_words
|
||||
`,
|
||||
)
|
||||
.all() as VocabularyStatsRow[];
|
||||
// Visibility and exclusion rules live in JS, so rows are scanned in id-keyed
|
||||
// batches to keep memory bounded on large vocabularies.
|
||||
const scanStmt = db.prepare(`
|
||||
SELECT id AS wordId, headword, word, reading,
|
||||
part_of_speech AS partOfSpeech, pos1, pos2, pos3,
|
||||
frequency, frequency_rank AS frequencyRank,
|
||||
first_seen AS firstSeen, last_seen AS lastSeen,
|
||||
0 AS animeCount
|
||||
FROM imm_words
|
||||
WHERE id > ?
|
||||
ORDER BY id
|
||||
LIMIT ?
|
||||
`);
|
||||
const excludedAliases = new Set(
|
||||
getStatsExcludedWords(db).flatMap((word) => excludedVocabularyAliases(word)),
|
||||
);
|
||||
@@ -321,26 +324,33 @@ export function getVocabularySummary(
|
||||
knownWordCountWithoutNames: knownWords ? 0 : null,
|
||||
};
|
||||
|
||||
for (const word of words) {
|
||||
if (
|
||||
!isVocabularyStatsRowVisible(word) ||
|
||||
excludedVocabularyAliases(word).some((alias) => excludedAliases.has(alias))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const isName = word.pos2 === '固有名詞';
|
||||
const isNewThisWeek = timestampSeconds(fromDbTimestamp(word.firstSeen) ?? 0) >= weekAgoSec;
|
||||
const isKnown = knownWords?.has(word.headword) ?? false;
|
||||
summary.uniqueWords += 1;
|
||||
if (!isName) summary.uniqueWordsWithoutNames += 1;
|
||||
if (isNewThisWeek) {
|
||||
summary.newThisWeek += 1;
|
||||
if (!isName) summary.newThisWeekWithoutNames += 1;
|
||||
}
|
||||
if (isKnown) {
|
||||
summary.knownWordCount! += 1;
|
||||
if (!isName) summary.knownWordCountWithoutNames! += 1;
|
||||
let lastId = Number.MIN_SAFE_INTEGER;
|
||||
for (;;) {
|
||||
const words = scanStmt.all(lastId, scanBatchSize) as VocabularyStatsRow[];
|
||||
if (words.length === 0) break;
|
||||
lastId = words[words.length - 1]!.wordId;
|
||||
for (const word of words) {
|
||||
if (
|
||||
!isVocabularyStatsRowVisible(word) ||
|
||||
excludedVocabularyAliases(word).some((alias) => excludedAliases.has(alias))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const isName = word.pos2 === '固有名詞';
|
||||
const isNewThisWeek = timestampSeconds(fromDbTimestamp(word.firstSeen) ?? 0) >= weekAgoSec;
|
||||
const isKnown = knownWords?.has(word.headword) ?? false;
|
||||
summary.uniqueWords += 1;
|
||||
if (!isName) summary.uniqueWordsWithoutNames += 1;
|
||||
if (isNewThisWeek) {
|
||||
summary.newThisWeek += 1;
|
||||
if (!isName) summary.newThisWeekWithoutNames += 1;
|
||||
}
|
||||
if (isKnown) {
|
||||
summary.knownWordCount! += 1;
|
||||
if (!isName) summary.knownWordCountWithoutNames! += 1;
|
||||
}
|
||||
}
|
||||
if (words.length < scanBatchSize) break;
|
||||
}
|
||||
|
||||
return summary;
|
||||
|
||||
Reference in New Issue
Block a user