mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 13:55:51 -07:00
fix(stats): harden vocabulary rollups
This commit is contained in:
@@ -3,3 +3,4 @@ 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.
|
- 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.
|
||||||
|
- Calendar-day chart labels now preserve the recorded local date in time zones west of UTC.
|
||||||
|
|||||||
@@ -559,6 +559,56 @@ test('fresh tracker DB creates lifetime summary tables', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('fresh tracker DB skips lexical rollup backfill work', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
let backfillRuns = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath }, {
|
||||||
|
runLexicalRollupBackfillTask: async () => {
|
||||||
|
backfillRuns += 1;
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.equal(backfillRuns, 0);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracker starts the injected lexical rollup backfill when it is pending', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
let backfillRuns = 0;
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
backfillRuns += 1;
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.equal(backfillRuns, 1);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('startup backfills lifetime summaries when retained sessions exist but summary tables are empty', async () => {
|
test('startup backfills lifetime summaries when retained sessions exist but summary tables are empty', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ import {
|
|||||||
type RunVocabularySummaryTask,
|
type RunVocabularySummaryTask,
|
||||||
} from './immersion-tracker/vocabulary-summary-worker-runtime';
|
} from './immersion-tracker/vocabulary-summary-worker-runtime';
|
||||||
import { LexicalRollupWorkerRuntime } from './immersion-tracker/lexical-rollup-worker-runtime';
|
import { LexicalRollupWorkerRuntime } from './immersion-tracker/lexical-rollup-worker-runtime';
|
||||||
|
import { areLexicalDailyRollupsReady } from './immersion-tracker/lexical-rollups';
|
||||||
import { DeleteMaintenanceScheduler } from './immersion-tracker/delete-maintenance-scheduler';
|
import { DeleteMaintenanceScheduler } from './immersion-tracker/delete-maintenance-scheduler';
|
||||||
import {
|
import {
|
||||||
cleanupDuplicateSubtitleLines,
|
cleanupDuplicateSubtitleLines,
|
||||||
@@ -418,6 +419,7 @@ export class ImmersionTrackerService {
|
|||||||
knownWords: ReadonlySet<string> | null,
|
knownWords: ReadonlySet<string> | null,
|
||||||
) => Promise<VocabularyStatsSummary>;
|
) => Promise<VocabularyStatsSummary>;
|
||||||
private readonly destroyVocabularySummaryRunner: () => void;
|
private readonly destroyVocabularySummaryRunner: () => void;
|
||||||
|
private readonly runLexicalRollupBackfillTask: () => Promise<void>;
|
||||||
private readonly destroyLexicalRollupBackfillRunner: () => void;
|
private readonly destroyLexicalRollupBackfillRunner: () => void;
|
||||||
private readonly deleteMaintenanceScheduler: DeleteMaintenanceScheduler;
|
private readonly deleteMaintenanceScheduler: DeleteMaintenanceScheduler;
|
||||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -448,6 +450,8 @@ export class ImmersionTrackerService {
|
|||||||
destroyDeleteMaintenanceRunner?: () => void;
|
destroyDeleteMaintenanceRunner?: () => void;
|
||||||
runVocabularySummaryTask?: RunVocabularySummaryTask;
|
runVocabularySummaryTask?: RunVocabularySummaryTask;
|
||||||
destroyVocabularySummaryRunner?: () => void;
|
destroyVocabularySummaryRunner?: () => void;
|
||||||
|
runLexicalRollupBackfillTask?: (dbPath: string) => Promise<void>;
|
||||||
|
destroyLexicalRollupBackfillRunner?: () => void;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
this.dbPath = options.dbPath;
|
this.dbPath = options.dbPath;
|
||||||
@@ -485,8 +489,16 @@ export class ImmersionTrackerService {
|
|||||||
vocabularySummaryRuntime.run(this.dbPath, knownWords);
|
vocabularySummaryRuntime.run(this.dbPath, knownWords);
|
||||||
this.destroyVocabularySummaryRunner = () => vocabularySummaryRuntime.destroy();
|
this.destroyVocabularySummaryRunner = () => vocabularySummaryRuntime.destroy();
|
||||||
}
|
}
|
||||||
const lexicalRollupRuntime = new LexicalRollupWorkerRuntime();
|
if (dependencies.runLexicalRollupBackfillTask) {
|
||||||
this.destroyLexicalRollupBackfillRunner = () => lexicalRollupRuntime.destroy();
|
this.runLexicalRollupBackfillTask = () =>
|
||||||
|
dependencies.runLexicalRollupBackfillTask!(this.dbPath);
|
||||||
|
this.destroyLexicalRollupBackfillRunner =
|
||||||
|
dependencies.destroyLexicalRollupBackfillRunner ?? (() => {});
|
||||||
|
} else {
|
||||||
|
const lexicalRollupRuntime = new LexicalRollupWorkerRuntime();
|
||||||
|
this.runLexicalRollupBackfillTask = () => lexicalRollupRuntime.run(this.dbPath);
|
||||||
|
this.destroyLexicalRollupBackfillRunner = () => lexicalRollupRuntime.destroy();
|
||||||
|
}
|
||||||
const parentDir = path.dirname(this.dbPath);
|
const parentDir = path.dirname(this.dbPath);
|
||||||
if (!fs.existsSync(parentDir)) {
|
if (!fs.existsSync(parentDir)) {
|
||||||
fs.mkdirSync(parentDir, { recursive: true });
|
fs.mkdirSync(parentDir, { recursive: true });
|
||||||
@@ -546,12 +558,14 @@ export class ImmersionTrackerService {
|
|||||||
this.db = new Database(this.dbPath);
|
this.db = new Database(this.dbPath);
|
||||||
applyPragmas(this.db);
|
applyPragmas(this.db);
|
||||||
ensureSchema(this.db);
|
ensureSchema(this.db);
|
||||||
void lexicalRollupRuntime.run(this.dbPath).catch((error: unknown) => {
|
if (!areLexicalDailyRollupsReady(this.db)) {
|
||||||
this.logger.warn(
|
void this.runLexicalRollupBackfillTask().catch((error: unknown) => {
|
||||||
'Lexical daily rollup backfill failed; it will retry on next startup',
|
this.logger.warn(
|
||||||
error,
|
'Lexical daily rollup backfill failed; it will retry on next startup',
|
||||||
);
|
error,
|
||||||
});
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
const reconciledSessions = reconcileStaleActiveSessions(this.db);
|
const reconciledSessions = reconcileStaleActiveSessions(this.db);
|
||||||
if (reconciledSessions > 0) {
|
if (reconciledSessions > 0) {
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
|
|||||||
@@ -67,3 +67,37 @@ test('lexical rollup worker leaves a backfill pending when no worker can start',
|
|||||||
runtime.destroy();
|
runtime.destroy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('lexical rollup worker absorbs termination failures after settling', async () => {
|
||||||
|
let sendMessage: ((message: { ok: boolean }) => void) | null = null;
|
||||||
|
const runtime = new LexicalRollupWorkerRuntime({
|
||||||
|
resolveWorkerPath: () => '/tmp/fake-worker.js',
|
||||||
|
createWorker: async () => ({
|
||||||
|
once(event: string, listener: (value: never) => void) {
|
||||||
|
if (event === 'message') sendMessage = listener as (message: { ok: boolean }) => void;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
terminate: async () => {
|
||||||
|
throw new Error('termination failed');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
warn: () => {},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const unhandled: unknown[] = [];
|
||||||
|
const captureUnhandled = (reason: unknown) => unhandled.push(reason);
|
||||||
|
process.on('unhandledRejection', captureUnhandled);
|
||||||
|
try {
|
||||||
|
const task = runtime.run('/tmp/not-used.sqlite');
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
const notify = sendMessage as ((message: { ok: boolean }) => void) | null;
|
||||||
|
assert.ok(notify);
|
||||||
|
notify({ ok: true });
|
||||||
|
await task;
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.deepEqual(unhandled, []);
|
||||||
|
} finally {
|
||||||
|
process.off('unhandledRejection', captureUnhandled);
|
||||||
|
runtime.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export class LexicalRollupWorkerRuntime {
|
|||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
this.activeWorkers.delete(worker);
|
this.activeWorkers.delete(worker);
|
||||||
void worker.terminate();
|
void worker.terminate().catch(() => undefined);
|
||||||
if (error) reject(error);
|
if (error) reject(error);
|
||||||
else resolve();
|
else resolve();
|
||||||
};
|
};
|
||||||
@@ -101,7 +101,9 @@ export class LexicalRollupWorkerRuntime {
|
|||||||
destroy(): void {
|
destroy(): void {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
for (const worker of this.activeWorkers) void worker.terminate();
|
for (const worker of this.activeWorkers) {
|
||||||
|
void worker.terminate().catch(() => undefined);
|
||||||
|
}
|
||||||
this.activeWorkers.clear();
|
this.activeWorkers.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { getLexicalDailyRollups } from './lexical-rollups';
|
import { getLexicalDailyRollups, rebuildLexicalDailyRollups } from './lexical-rollups';
|
||||||
import { getTrendsDashboard } from './query-trends';
|
import { getTrendsDashboard } from './query-trends';
|
||||||
import { getVocabularyChartData } from './query-lexical';
|
import { getVocabularyChartData, replaceStatsExcludedWords } from './query-lexical';
|
||||||
import { Database } from './sqlite';
|
import { Database } from './sqlite';
|
||||||
|
import type { DatabaseSync } from './sqlite';
|
||||||
import { ensureSchema } from './storage';
|
import { ensureSchema } from './storage';
|
||||||
|
|
||||||
function makeDbPath(): string {
|
function makeDbPath(): string {
|
||||||
@@ -75,6 +76,81 @@ test('vocabulary charts use complete top-word and lexical rollup data', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('vocabulary charts find full top-word sets beyond excluded and name rows', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
const insertWord = db.prepare(
|
||||||
|
`INSERT INTO imm_words(headword, word, reading, pos2, first_seen, last_seen, frequency)
|
||||||
|
VALUES (?, ?, '', ?, 1700000000, 1700000000, ?)`,
|
||||||
|
);
|
||||||
|
const exclusions = [];
|
||||||
|
for (let index = 0; index < 100; index += 1) {
|
||||||
|
const headword = `語${index}`;
|
||||||
|
insertWord.run(
|
||||||
|
headword,
|
||||||
|
headword,
|
||||||
|
index < 80 && index >= 60 ? '固有名詞' : '一般',
|
||||||
|
100 - index,
|
||||||
|
);
|
||||||
|
if (index < 60) exclusions.push({ headword, word: headword, reading: '' });
|
||||||
|
}
|
||||||
|
replaceStatsExcludedWords(db, exclusions);
|
||||||
|
|
||||||
|
const charts = getVocabularyChartData(db);
|
||||||
|
|
||||||
|
assert.equal(charts.topWords.length, 12);
|
||||||
|
assert.equal(charts.topWords[0]?.headword, '語60');
|
||||||
|
assert.equal(charts.topWordsWithoutNames.length, 12);
|
||||||
|
assert.equal(charts.topWordsWithoutNames[0]?.headword, '語80');
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vocabulary charts handle exclusion lists above one SQLite variable batch', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO imm_words(headword, word, reading, first_seen, last_seen, frequency)
|
||||||
|
VALUES ('語0', '語0', '', 1700000000, 1700000000, 1)`,
|
||||||
|
).run();
|
||||||
|
const exclusions = Array.from({ length: 10_923 }, (_, index) => ({
|
||||||
|
headword: `語${index}`,
|
||||||
|
word: `語${index}`,
|
||||||
|
reading: '',
|
||||||
|
}));
|
||||||
|
replaceStatsExcludedWords(db, exclusions);
|
||||||
|
|
||||||
|
const charts = getVocabularyChartData(db);
|
||||||
|
|
||||||
|
assert.deepEqual(charts.topWords, []);
|
||||||
|
assert.deepEqual(charts.newWordsTimeline, []);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lexical rollup rebuild preserves the original error when rollback also fails', () => {
|
||||||
|
const originalError = new Error('rebuild failed');
|
||||||
|
const db = {
|
||||||
|
exec(sql: string) {
|
||||||
|
if (sql === 'BEGIN IMMEDIATE') return;
|
||||||
|
if (sql === 'ROLLBACK') throw new Error('rollback failed');
|
||||||
|
throw originalError;
|
||||||
|
},
|
||||||
|
} as unknown as DatabaseSync;
|
||||||
|
|
||||||
|
assert.throws(() => rebuildLexicalDailyRollups(db), originalError);
|
||||||
|
});
|
||||||
|
|
||||||
test('trends read historical new-word buckets from lexical rollups', () => {
|
test('trends read historical new-word buckets from lexical rollups', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|||||||
@@ -153,7 +153,13 @@ export function rebuildLexicalDailyRollups(db: DatabaseSync): void {
|
|||||||
markLexicalDailyRollupsReady(db);
|
markLexicalDailyRollupsReady(db);
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (transactionStarted) db.exec('ROLLBACK');
|
if (transactionStarted) {
|
||||||
|
try {
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
} catch {
|
||||||
|
// Preserve the rebuild failure; it is the actionable cause.
|
||||||
|
}
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ import {
|
|||||||
|
|
||||||
const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4;
|
const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4;
|
||||||
const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100;
|
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 SENTENCE_SEARCH_DEFAULT_LIMIT = 50;
|
const SENTENCE_SEARCH_DEFAULT_LIMIT = 50;
|
||||||
const SENTENCE_SEARCH_MAX_LIMIT = 100;
|
const SENTENCE_SEARCH_MAX_LIMIT = 100;
|
||||||
const KANJI_PATTERN = /\p{Script=Han}/gu;
|
const KANJI_PATTERN = /\p{Script=Han}/gu;
|
||||||
@@ -179,25 +182,39 @@ export function getVocabularyChartData(db: DatabaseSync): VocabularyChartData {
|
|||||||
);
|
);
|
||||||
const isExcluded = (word: Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'>): boolean =>
|
const isExcluded = (word: Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'>): boolean =>
|
||||||
excludedVocabularyAliases(word).some((alias) => excludedAliases.has(alias));
|
excludedVocabularyAliases(word).some((alias) => excludedAliases.has(alias));
|
||||||
const topWords = getVocabularyStats(db, 48).filter((word) => !isExcluded(word));
|
const topWords = getTopVocabularyChartWords(db, isExcluded);
|
||||||
const rollups = ready ? getLexicalDailyRollups(db) : [];
|
const rollups = ready ? getLexicalDailyRollups(db) : [];
|
||||||
const timeline = new Map(rollups.map((row) => [row.epochDay, { ...row }]));
|
const timeline = new Map(rollups.map((row) => [row.epochDay, { ...row }]));
|
||||||
if (excludedAliases.size > 0 && ready) {
|
if (excludedAliases.size > 0 && ready) {
|
||||||
const aliases = [...excludedAliases];
|
const aliases = [...excludedAliases];
|
||||||
const placeholders = aliases.map(() => '?').join(', ');
|
const excludedRows = new Map<
|
||||||
const excludedRows = db
|
number,
|
||||||
.prepare(
|
Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading' | 'pos2'> & {
|
||||||
`
|
wordId: number;
|
||||||
SELECT headword, word, reading, pos2,
|
epochDay: number;
|
||||||
${localEpochDaySql('first_seen')} AS epochDay
|
}
|
||||||
FROM imm_words
|
>();
|
||||||
WHERE headword IN (${placeholders}) OR word IN (${placeholders}) OR reading IN (${placeholders})
|
for (let offset = 0; offset < aliases.length; offset += EXCLUSION_ALIAS_BATCH_SIZE) {
|
||||||
`,
|
const batch = aliases.slice(offset, offset + EXCLUSION_ALIAS_BATCH_SIZE);
|
||||||
)
|
const placeholders = batch.map(() => '?').join(', ');
|
||||||
.all(...aliases, ...aliases, ...aliases) as Array<
|
const rows = db
|
||||||
Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading' | 'pos2'> & { epochDay: number }
|
.prepare(
|
||||||
>;
|
`
|
||||||
for (const word of excludedRows) {
|
SELECT id AS wordId, headword, word, reading, pos2,
|
||||||
|
${localEpochDaySql('first_seen')} AS epochDay
|
||||||
|
FROM imm_words
|
||||||
|
WHERE headword IN (${placeholders}) OR word IN (${placeholders}) OR reading IN (${placeholders})
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(...batch, ...batch, ...batch) as Array<
|
||||||
|
Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading' | 'pos2'> & {
|
||||||
|
wordId: number;
|
||||||
|
epochDay: number;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
for (const row of rows) excludedRows.set(row.wordId, row);
|
||||||
|
}
|
||||||
|
for (const word of excludedRows.values()) {
|
||||||
if (!isExcluded(word)) continue;
|
if (!isExcluded(word)) continue;
|
||||||
const rollup = timeline.get(word.epochDay);
|
const rollup = timeline.get(word.epochDay);
|
||||||
if (!rollup) continue;
|
if (!rollup) continue;
|
||||||
@@ -207,15 +224,16 @@ export function getVocabularyChartData(db: DatabaseSync): VocabularyChartData {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
topWords: topWords.slice(0, 12).map((word) => ({
|
topWords: topWords.all.map((word) => ({
|
||||||
|
wordId: word.wordId,
|
||||||
|
headword: word.headword,
|
||||||
|
frequency: word.frequency,
|
||||||
|
})),
|
||||||
|
topWordsWithoutNames: topWords.withoutNames.map((word) => ({
|
||||||
wordId: word.wordId,
|
wordId: word.wordId,
|
||||||
headword: word.headword,
|
headword: word.headword,
|
||||||
frequency: word.frequency,
|
frequency: word.frequency,
|
||||||
})),
|
})),
|
||||||
topWordsWithoutNames: topWords
|
|
||||||
.filter((word) => word.pos2 !== '固有名詞')
|
|
||||||
.slice(0, 12)
|
|
||||||
.map((word) => ({ wordId: word.wordId, headword: word.headword, frequency: word.frequency })),
|
|
||||||
newWordsTimeline: [...timeline.values()]
|
newWordsTimeline: [...timeline.values()]
|
||||||
.filter((row) => row.wordCount > 0)
|
.filter((row) => row.wordCount > 0)
|
||||||
.map((row) => ({ epochDay: row.epochDay, wordCount: row.wordCount })),
|
.map((row) => ({ epochDay: row.epochDay, wordCount: row.wordCount })),
|
||||||
@@ -225,6 +243,40 @@ export function getVocabularyChartData(db: DatabaseSync): VocabularyChartData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTopVocabularyChartWords(
|
||||||
|
db: DatabaseSync,
|
||||||
|
isExcluded: (word: Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'>) => boolean,
|
||||||
|
): { all: VocabularyStatsRow[]; withoutNames: VocabularyStatsRow[] } {
|
||||||
|
const stmt = 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
|
||||||
|
ORDER BY frequency DESC, id
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`);
|
||||||
|
const all: VocabularyStatsRow[] = [];
|
||||||
|
const withoutNames: VocabularyStatsRow[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (all.length < VOCABULARY_CHART_LIMIT || withoutNames.length < VOCABULARY_CHART_LIMIT) {
|
||||||
|
const page = stmt.all(VOCABULARY_CHART_PAGE_SIZE, offset) as VocabularyStatsRow[];
|
||||||
|
if (page.length === 0) break;
|
||||||
|
for (const word of page) {
|
||||||
|
if (!isVocabularyStatsRowVisible(word) || isExcluded(word)) continue;
|
||||||
|
if (all.length < VOCABULARY_CHART_LIMIT) all.push(word);
|
||||||
|
if (word.pos2 !== '固有名詞' && withoutNames.length < VOCABULARY_CHART_LIMIT) {
|
||||||
|
withoutNames.push(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset += page.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { all, withoutNames };
|
||||||
|
}
|
||||||
|
|
||||||
function excludedVocabularyAliases(
|
function excludedVocabularyAliases(
|
||||||
word: Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'>,
|
word: Pick<VocabularyStatsRow, 'headword' | 'word' | 'reading'>,
|
||||||
): string[] {
|
): string[] {
|
||||||
|
|||||||
@@ -49,3 +49,19 @@ test('vocabulary summary worker module resolves in the current layout', () => {
|
|||||||
assert.ok(workerPath, 'expected the vocabulary summary worker module to resolve');
|
assert.ok(workerPath, 'expected the vocabulary summary worker module to resolve');
|
||||||
assert.ok(workerPath.endsWith(__filename.endsWith('.ts') ? '.ts' : '.js'));
|
assert.ok(workerPath.endsWith(__filename.endsWith('.ts') ? '.ts' : '.js'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('vocabulary summary worker never falls back to the caller thread', async () => {
|
||||||
|
const runtime = new VocabularySummaryWorkerRuntime({
|
||||||
|
resolveWorkerPath: () => null,
|
||||||
|
warn: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
runtime.run('/tmp/subminer-summary-worker-not-used.sqlite', null),
|
||||||
|
/worker unavailable/i,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
runtime.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { createLogger } from '../../../logger';
|
import { createLogger } from '../../../logger';
|
||||||
import type { VocabularyStatsSummary } from './types';
|
import type { VocabularyStatsSummary } from './types';
|
||||||
import { executeVocabularySummaryTask } from './vocabulary-summary-worker';
|
|
||||||
|
|
||||||
interface VocabularySummaryWorkerResponse {
|
interface VocabularySummaryWorkerResponse {
|
||||||
summary?: VocabularyStatsSummary;
|
summary?: VocabularyStatsSummary;
|
||||||
@@ -22,7 +21,6 @@ interface VocabularySummaryWorkerRuntimeOptions {
|
|||||||
workerPath: string,
|
workerPath: string,
|
||||||
workerData: { dbPath: string; knownWords: string[] | null },
|
workerData: { dbPath: string; knownWords: string[] | null },
|
||||||
) => Promise<VocabularySummaryWorkerHandle>;
|
) => Promise<VocabularySummaryWorkerHandle>;
|
||||||
executeFallback?: typeof executeVocabularySummaryTask;
|
|
||||||
warn?: (message: string, ...meta: unknown[]) => void;
|
warn?: (message: string, ...meta: unknown[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,13 +65,10 @@ export class VocabularySummaryWorkerRuntime {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.destroyed) throw new Error('Vocabulary summary worker is shut down');
|
if (this.destroyed) throw new Error('Vocabulary summary worker is shut down');
|
||||||
(this.options.warn ?? logger.warn)(
|
(this.options.warn ?? logger.warn)(
|
||||||
'Vocabulary summary worker unavailable; running summary on the current thread',
|
'Vocabulary summary worker unavailable; refusing to scan vocabulary on the current thread',
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
return (this.options.executeFallback ?? executeVocabularySummaryTask)(
|
throw new Error('Vocabulary summary worker unavailable');
|
||||||
dbPath,
|
|
||||||
workerData.knownWords,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.destroyed) {
|
if (this.destroyed) {
|
||||||
@@ -88,7 +83,7 @@ export class VocabularySummaryWorkerRuntime {
|
|||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
this.activeWorkers.delete(worker);
|
this.activeWorkers.delete(worker);
|
||||||
void worker.terminate();
|
void worker.terminate().catch(() => undefined);
|
||||||
if (result instanceof Error) reject(result);
|
if (result instanceof Error) reject(result);
|
||||||
else resolve(result);
|
else resolve(result);
|
||||||
};
|
};
|
||||||
@@ -122,7 +117,9 @@ export class VocabularySummaryWorkerRuntime {
|
|||||||
destroy(): void {
|
destroy(): void {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
for (const worker of this.activeWorkers) void worker.terminate();
|
for (const worker of this.activeWorkers) {
|
||||||
|
void worker.terminate().catch(() => undefined);
|
||||||
|
}
|
||||||
this.activeWorkers.clear();
|
this.activeWorkers.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { epochMsFromDbTimestamp, formatRelativeDate, formatSessionDayLabel } from './formatters';
|
import {
|
||||||
|
epochDayToDate,
|
||||||
|
epochMsFromDbTimestamp,
|
||||||
|
formatRelativeDate,
|
||||||
|
formatSessionDayLabel,
|
||||||
|
} from './formatters';
|
||||||
|
|
||||||
const FIXED_NOW = new Date(2026, 2, 16, 12, 0, 0).getTime();
|
const FIXED_NOW = new Date(2026, 2, 16, 12, 0, 0).getTime();
|
||||||
|
|
||||||
@@ -108,6 +113,19 @@ test('epochMsFromDbTimestamp keeps ms timestamps as-is', () => {
|
|||||||
assert.equal(epochMsFromDbTimestamp(1_700_000_000_000), 1_700_000_000_000);
|
assert.equal(epochMsFromDbTimestamp(1_700_000_000_000), 1_700_000_000_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('epochDayToDate preserves the calendar day west of UTC', () => {
|
||||||
|
const previousTimezone = process.env.TZ;
|
||||||
|
process.env.TZ = 'America/Los_Angeles';
|
||||||
|
try {
|
||||||
|
const epochDay = Math.floor(Date.UTC(2026, 2, 16) / 86_400_000);
|
||||||
|
const date = epochDayToDate(epochDay);
|
||||||
|
assert.deepEqual([date.getFullYear(), date.getMonth(), date.getDate()], [2026, 2, 16]);
|
||||||
|
} finally {
|
||||||
|
if (previousTimezone === undefined) delete process.env.TZ;
|
||||||
|
else process.env.TZ = previousTimezone;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('formatSessionDayLabel formats today and yesterday', () => {
|
test('formatSessionDayLabel formats today and yesterday', () => {
|
||||||
withFixedNow((now) => {
|
withFixedNow((now) => {
|
||||||
const oneDayMs = 24 * 60 * 60_000;
|
const oneDayMs = 24 * 60 * 60_000;
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ export function formatRelativeDate(ms: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function epochDayToDate(epochDay: number): Date {
|
export function epochDayToDate(epochDay: number): Date {
|
||||||
return new Date(epochDay * 86_400_000);
|
const utcDate = new Date(epochDay * 86_400_000);
|
||||||
|
return new Date(utcDate.getUTCFullYear(), utcDate.getUTCMonth(), utcDate.getUTCDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function localDayFromMs(ms: number): number {
|
export function localDayFromMs(ms: number): number {
|
||||||
|
|||||||
Reference in New Issue
Block a user