mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-14 01:55:58 -07:00
fix(stats): fail closed when queued writes cannot drain
- Guard delete maintenance and AniList reassignment - Add regression coverage for undrained write queues
This commit is contained in:
@@ -33,12 +33,85 @@ interface TrackerInternals {
|
|||||||
db: DatabaseSync;
|
db: DatabaseSync;
|
||||||
queue: unknown[];
|
queue: unknown[];
|
||||||
recordWrite: (write: Record<string, unknown>) => void;
|
recordWrite: (write: Record<string, unknown>) => void;
|
||||||
|
deleteSession: (sessionId: number) => Promise<void>;
|
||||||
mergeAnime: (targetAnimeId: number, sourceAnimeIds: number[]) => Promise<unknown>;
|
mergeAnime: (targetAnimeId: number, sourceAnimeIds: number[]) => Promise<unknown>;
|
||||||
moveVideoToAnime: (videoId: number, targetAnimeId: number) => Promise<unknown>;
|
moveVideoToAnime: (videoId: number, targetAnimeId: number) => Promise<unknown>;
|
||||||
rebuildLifetimeSummaries: () => Promise<unknown>;
|
rebuildLifetimeSummaries: () => Promise<unknown>;
|
||||||
|
reassignAnimeAnilist: (animeId: number, info: { anilistId: number }) => Promise<void>;
|
||||||
flushNow: () => void;
|
flushNow: () => void;
|
||||||
|
writeLock: { locked: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('delete maintenance fails closed when queued writes cannot drain', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
let deleteRunnerCalls = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor(
|
||||||
|
{ dbPath, policy: { batchSize: 2 } },
|
||||||
|
{
|
||||||
|
runDeleteMaintenanceTask: async () => {
|
||||||
|
deleteRunnerCalls += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const internals = tracker as unknown as TrackerInternals;
|
||||||
|
seedTwoEntries(internals.db);
|
||||||
|
queueSubtitleLines(internals, 1);
|
||||||
|
let flushCalls = 0;
|
||||||
|
internals.flushNow = () => {
|
||||||
|
flushCalls += 1;
|
||||||
|
if (flushCalls > 1) throw new Error('bounded no-progress sentinel');
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(internals.deleteSession(1), /queue did not drain/i);
|
||||||
|
|
||||||
|
assert.equal(flushCalls, 1);
|
||||||
|
assert.equal(deleteRunnerCalls, 0);
|
||||||
|
assert.equal(internals.writeLock.locked, false);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reassignAnimeAnilist fails closed before resolving a conflict when writes cannot drain', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||||
|
const internals = tracker as unknown as TrackerInternals;
|
||||||
|
seedTwoEntries(internals.db);
|
||||||
|
internals.db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = 2').run();
|
||||||
|
queueSubtitleLines(internals, 1);
|
||||||
|
internals.flushNow = () => {};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
internals.reassignAnimeAnilist(1, { anilistId: 123 }),
|
||||||
|
/queue did not drain/i,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
internals.db
|
||||||
|
.prepare(
|
||||||
|
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
|
||||||
|
)
|
||||||
|
.all(),
|
||||||
|
[
|
||||||
|
{ animeId: 1, anilistId: null },
|
||||||
|
{ animeId: 2, anilistId: 123 },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('mergeAnime fails closed when queued writes cannot drain', async () => {
|
test('mergeAnime fails closed when queued writes cannot drain', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|||||||
@@ -442,8 +442,7 @@ export class ImmersionTrackerService {
|
|||||||
batchWindowMs: DELETE_MAINTENANCE_BATCH_WINDOW_MS,
|
batchWindowMs: DELETE_MAINTENANCE_BATCH_WINDOW_MS,
|
||||||
runTask: (task) => runDeleteMaintenanceTask(this.dbPath, task),
|
runTask: (task) => runDeleteMaintenanceTask(this.dbPath, task),
|
||||||
onBusy: () => {
|
onBusy: () => {
|
||||||
this.flushTelemetry(true);
|
this.requireWriteQueueDrained('delete maintenance');
|
||||||
while (this.queue.length > 0) this.flushNow();
|
|
||||||
this.writeLock.locked = true;
|
this.writeLock.locked = true;
|
||||||
},
|
},
|
||||||
onIdle: () => {
|
onIdle: () => {
|
||||||
@@ -891,6 +890,7 @@ export class ImmersionTrackerService {
|
|||||||
coverUrl?: string | null;
|
coverUrl?: string | null;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
this.requireWriteQueueDrained('reassigning an AniList entry');
|
||||||
// The user is acting on this entry, so it is the one that survives when
|
// The user is acting on this entry, so it is the one that survives when
|
||||||
// another row already claims the same AniList id.
|
// another row already claims the same AniList id.
|
||||||
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId, {
|
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId, {
|
||||||
|
|||||||
Reference in New Issue
Block a user