Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion services/keeper/src/keeper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(
Expand Down
47 changes: 42 additions & 5 deletions services/keeper/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
32 changes: 24 additions & 8 deletions services/keeper/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -68,10 +79,19 @@ export class KeeperStore {
const rounds: Record<string, WatchedRound> = {};
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<WatchedRound>;
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 };
Expand Down Expand Up @@ -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 {
Expand Down