From 42b34442ebc560e1e79edf55736f4e3d41e9e487 Mon Sep 17 00:00:00 2001 From: Olasunkanmi975 Date: Tue, 25 Aug 2026 21:52:31 +0000 Subject: [PATCH] fix(keeper): drop malformed store entries on load and extract round-id comparator Previously a single malformed/non-numeric round entry in .keeper-store.json caused listRounds() ordering (which did BigInt() on the id) to throw, which the load path surfaced as a full corrupted-file backup that dropped every entry. - Drop individual malformed round entries on load instead of nuking the whole store (still warn, still back up on truly corrupted JSON). - Extract a shared compareRoundIds() numeric comparator so listRounds and callers (parseRoundIdSpec) order rounds consistently regardless of id type. Closes #194 --- services/keeper/src/keeper.ts | 3 +- services/keeper/src/store.test.ts | 47 +++++++++++++++++++++++++++---- services/keeper/src/store.ts | 32 +++++++++++++++------ 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/services/keeper/src/keeper.ts b/services/keeper/src/keeper.ts index 6fc9e06..5b551c2 100644 --- a/services/keeper/src/keeper.ts +++ b/services/keeper/src/keeper.ts @@ -16,6 +16,7 @@ import type { SubRosaClient } from "@sub-rosa/sdk"; import { openBid, fetchRoundSignature, type DrandClient } from "@sub-rosa/tlock"; +import { compareRoundIds } from "./store.js"; export type KeeperLogger = (msg: string) => void; @@ -369,7 +370,7 @@ export function parseRoundIdSpec(spec: string): bigint[] { ids.add(BigInt(part)); } } - return [...ids].sort((x, y) => (x < y ? -1 : x > y ? 1 : 0)); + return [...ids].sort((x, y) => compareRoundIds(x, y)); } export async function discoverRoundIds( diff --git a/services/keeper/src/store.test.ts b/services/keeper/src/store.test.ts index 7429eec..cb18ba5 100644 --- a/services/keeper/src/store.test.ts +++ b/services/keeper/src/store.test.ts @@ -2,7 +2,7 @@ import { describe, it } from "node:test"; import * as assert from "node:assert"; import * as fs from "node:fs"; import * as path from "node:path"; -import { KeeperStore, normalizeRoundId } from "./store.js"; +import { KeeperStore, normalizeRoundId, compareRoundIds } from "./store.js"; describe("KeeperStore", () => { const TEST_STORE_PATH = path.join(process.cwd(), ".test-keeper-store.json"); @@ -77,23 +77,60 @@ describe("KeeperStore", () => { cleanUp(); }); - it("backs up persisted data with an invalid round ID before sorting", () => { + it("drops a single malformed round entry on load and keeps the valid ones", () => { cleanUp(); fs.writeFileSync( TEST_STORE_PATH, - JSON.stringify({ rounds: { invalid: { roundId: "abc", lastStatus: "Unknown", retryCount: 0 } } }), + JSON.stringify({ + rounds: { + "7": { roundId: "7", lastStatus: "Open", retryCount: 0 }, + invalid: { roundId: "abc", lastStatus: "Unknown", retryCount: 0 }, + }, + }), "utf-8", ); const store = new KeeperStore(TEST_STORE_PATH); - assert.deepEqual(store.listRounds(), []); + assert.deepEqual( + store.listRounds().map((round) => round.roundId), + ["7"], + ); + // No full-file corrupted backup should be created for a single bad entry const backups = fs.readdirSync(process.cwd()).filter( (file) => file.startsWith(".test-keeper-store.json.corrupted."), ); - assert.strictEqual(backups.length, 1); + assert.strictEqual(backups.length, 0); + cleanUp(); + }); + + it("drops entries whose key is non-numeric and has no valid roundId", () => { + cleanUp(); + fs.writeFileSync( + TEST_STORE_PATH, + JSON.stringify({ + rounds: { + notanumber: { lastStatus: "Open", retryCount: 0 }, + "12": { roundId: "12", lastStatus: "Open", retryCount: 0 }, + }, + }), + "utf-8", + ); + + const store = new KeeperStore(TEST_STORE_PATH); + assert.deepEqual( + store.listRounds().map((round) => round.roundId), + ["12"], + ); cleanUp(); }); + it("orders rounds numerically via the shared comparator regardless of id type", () => { + assert.strictEqual(compareRoundIds(2n, "10"), -1); + assert.strictEqual(compareRoundIds("10", 2), 1); + assert.strictEqual(compareRoundIds("7", "07"), 0); + assert.strictEqual(compareRoundIds(10, "10"), 0); + }); + it("should remove a round", () => { cleanUp(); const store = new KeeperStore(TEST_STORE_PATH); diff --git a/services/keeper/src/store.ts b/services/keeper/src/store.ts index 665bf0a..a79b2d3 100644 --- a/services/keeper/src/store.ts +++ b/services/keeper/src/store.ts @@ -18,6 +18,17 @@ export interface StoreData { export type RoundIdInput = bigint | number | string; +/** + * Numeric round-id comparator. Orders two round ids by their numeric value + * regardless of the input type (bigint, number, or string). Returns a negative + * number if `a < b`, zero if equal, and a positive number if `a > b`. + */ +export function compareRoundIds(a: RoundIdInput, b: RoundIdInput): number { + const aBig = BigInt(normalizeRoundId(a)); + const bBig = BigInt(normalizeRoundId(b)); + return aBig < bBig ? -1 : aBig > bBig ? 1 : 0; +} + export function normalizeRoundId(roundId: RoundIdInput): string { let value: bigint; @@ -68,10 +79,19 @@ export class KeeperStore { const rounds: Record = {}; for (const [key, value] of Object.entries(parsed.rounds)) { if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error(`invalid stored round ${key}`); + console.warn(`[Store] Dropping malformed stored round entry ${key}: expected an object`); + continue; } const stored = value as Partial; - const id = normalizeRoundId(stored.roundId ?? key); + let id: string; + try { + id = normalizeRoundId(stored.roundId ?? key); + } catch { + console.warn( + `[Store] Dropping malformed stored round entry ${key}: non-numeric or invalid round id ${JSON.stringify(stored.roundId ?? key)}`, + ); + continue; + } rounds[id] = { ...stored, roundId: id } as WatchedRound; } return { rounds }; @@ -140,12 +160,8 @@ export class KeeperStore { } public listRounds(): WatchedRound[] { - // Return sorted by roundId mathematically - return Object.values(this.data.rounds).sort((a, b) => { - const aBig = BigInt(a.roundId); - const bBig = BigInt(b.roundId); - return aBig < bBig ? -1 : aBig > bBig ? 1 : 0; - }); + // Return sorted by roundId numerically, regardless of id type + return Object.values(this.data.rounds).sort((a, b) => compareRoundIds(a.roundId, b.roundId)); } public getRawData(): StoreData {