fix(stats): stop deleted KOReader sessions returning and surface reset holds

Deleting a KOReader session left its page events stored, so the next upload within the
session gap re-derived it. Record the deleted span in koreader_session_tombstones and skip
those events during derivation. Show the reset-hold notice on the Reading Log as well,
since Reset is reachable there and a held device otherwise reads as unexplained progress.

Refs #1355
This commit is contained in:
neon
2026-10-13 01:33:09 +00:00
parent 9c15a9a5cc
commit 613c7d2ef2
20 changed files with 24724 additions and 83 deletions
+122 -7
View File
@@ -4,13 +4,8 @@ import { join } from 'path';
import { and, asc, eq, gte, like } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import {
KOREADER_SESSION_GAP_SECONDS,
buildSessionId,
buildSessionIdPrefix,
deriveKoreaderSessions,
type KoreaderPageEvent,
} from '../src/modules/koreader/koreader-stats.util';
import { buildSessionId, buildSessionIdPrefix } from '../src/common/utils/koreader-session-id.utils';
import { KOREADER_SESSION_GAP_SECONDS, deriveKoreaderSessions, type KoreaderPageEvent } from '../src/modules/koreader/koreader-stats.util';
import { createEpubFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder';
import {
authHeader,
@@ -265,6 +260,126 @@ describe('KOReader page stats derivation (e2e)', { timeout: 180_000 }, () => {
expect(await waitForAchievement(session!.userId, 'power_hour')).toBe(true);
});
async function deleteSessionFromLog(sessionRowId: number) {
const response = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/books/${epub.bookId}/sessions/${sessionRowId}`,
headers: authHeader(ctx.adminToken),
});
expect(response.statusCode).toBe(204);
}
async function sessionsBetween(fromEpoch: number, toEpoch: number) {
return (await storedSessions()).filter((row) => row.startedAt.getTime() >= fromEpoch * 1000 && row.startedAt.getTime() < toEpoch * 1000);
}
async function tombstonesFor(fromEpoch: number) {
return ctx.db
.select({
deviceKey: schema.koreaderSessionTombstones.deviceKey,
startTime: schema.koreaderSessionTombstones.startTime,
endTime: schema.koreaderSessionTombstones.endTime,
})
.from(schema.koreaderSessionTombstones)
.where(and(eq(schema.koreaderSessionTombstones.bookFileId, epub.bookFileId), gte(schema.koreaderSessionTombstones.startTime, fromEpoch)));
}
it('keeps a deleted session deleted when the device reads on within the session gap', async () => {
const base = BASE_EPOCH + 15_000_000;
const sitting = cluster(base, 20, 1);
await uploadPageStats(sitting);
const [derived] = await sessionsBetween(base, base + 86_400);
expect(derived?.sessionId).toBe(buildSessionId(DEVICE_ID, epub.bookFileId, base));
await deleteSessionFromLog(derived!.id);
expect(await sessionsBetween(base, base + 86_400)).toEqual([]);
expect(await tombstonesFor(base)).toEqual([
{ deviceKey: DEVICE_ID.slice(0, 8), startTime: base, endTime: sitting.at(-1)!.startTime + sitting.at(-1)!.durationSeconds },
]);
// Ten minutes after the deleted sitting ended: close enough that derivation used to merge the
// stored events back in and re-insert the deleted session under its old id.
const later = cluster(sitting.at(-1)!.startTime + sitting.at(-1)!.durationSeconds + 600, 5, 21);
await uploadPageStats(later);
const after = await sessionsBetween(base, base + 86_400);
expect(comparable(after)).toEqual(expectedSessions(later));
expect(after.some((row) => row.sessionId === derived!.sessionId)).toBe(false);
});
it('counts the page that starts on the second a deleted session ended', async () => {
const base = BASE_EPOCH + 15_500_000;
const sitting = cluster(base, 10, 1);
await uploadPageStats(sitting);
const [derived] = await sessionsBetween(base, base + 86_400);
await deleteSessionFromLog(derived!.id);
const sessionEnd = sitting.at(-1)!.startTime + sitting.at(-1)!.durationSeconds;
const next = cluster(sessionEnd, 5, 11);
await uploadPageStats(next);
expect(comparable(await sessionsBetween(base, base + 86_400))).toEqual(expectedSessions(next));
});
it('does not bring a deleted session back when its events are uploaded again', async () => {
const base = BASE_EPOCH + 16_000_000;
const sitting = cluster(base, 15, 30);
await uploadPageStats(sitting);
const [derived] = await sessionsBetween(base, base + 86_400);
await deleteSessionFromLog(derived!.id);
// KOReader's own statistics sync can deliver more pages for the same span from another copy of
// the history, and a plugin that lost its state resends everything it has.
const sameSpan = sitting.map((event) => ({ ...event, page: event.page + 500 }));
const extra = await uploadPageStats(sameSpan);
expect(extra.results[0]!.accepted).toBe(sameSpan.length);
expect(await sessionsBetween(base, base + 86_400)).toEqual([]);
await ctx.db
.delete(schema.koreaderPageStats)
.where(and(eq(schema.koreaderPageStats.bookFileId, epub.bookFileId), gte(schema.koreaderPageStats.startTime, base)));
const resent = await uploadPageStats(sitting);
expect(resent.results[0]!.accepted).toBe(sitting.length);
expect(await sessionsBetween(base, base + 86_400)).toEqual([]);
});
it('builds a bridged session from the surviving events only', async () => {
const base = BASE_EPOCH + 17_000_000;
const early = cluster(base, 10, 0);
const late = cluster(base + 3 * KOREADER_SESSION_GAP_SECONDS, 10, 40);
await uploadPageStats([...early, ...late]);
const [earlyRow, lateRow] = await sessionsBetween(base, base + 86_400);
expect(earlyRow?.sessionId).toBe(buildSessionId(DEVICE_ID, epub.bookFileId, base));
await deleteSessionFromLog(earlyRow!.id);
// Would have merged early, bridge and late into one session starting at the early sitting.
const bridge = cluster(early.at(-1)!.startTime + 1_760, 22, 10);
await uploadPageStats(bridge);
const after = await sessionsBetween(base, base + 86_400);
expect(comparable(after)).toEqual(expectedSessions([...bridge, ...late]));
expect(after.some((row) => row.sessionId === earlyRow!.sessionId)).toBe(false);
expect(after.some((row) => row.sessionId === lateRow!.sessionId)).toBe(false);
});
it('leaves the neighbouring sessions of a deleted one untouched', async () => {
const base = BASE_EPOCH + 18_000_000;
const first = cluster(base, 8, 0);
const second = cluster(base + 3 * KOREADER_SESSION_GAP_SECONDS, 8, 10);
const third = cluster(base + 6 * KOREADER_SESSION_GAP_SECONDS, 8, 20);
await uploadPageStats([...first, ...second, ...third]);
const rows = await sessionsBetween(base, base + 86_400);
expect(rows).toHaveLength(3);
await deleteSessionFromLog(rows[1]!.id);
await uploadPageStats(cluster(third.at(-1)!.startTime + 120, 4, 30));
const after = await sessionsBetween(base, base + 86_400);
expect(after.map((row) => row.sessionId)).toEqual([rows[0]!.sessionId, rows[2]!.sessionId]);
expect(after[0]).toEqual(rows[0]);
});
// A file hash is not unique: identical bytes in two places are two book file rows. The server
// must refuse to guess, then honor an accessible file identity previously verified by the
// plugin so every later upload has one stable target.