assafvayner HF Staff commited on
Commit
8d2f1b6
·
1 Parent(s): ea1afde

test(ledger): prove compaction crash safety with fault injection and against the real bucket

Browse files
src/lib/server/ledger/ledger.ts CHANGED
@@ -71,7 +71,7 @@ export class Ledger {
71
  try {
72
  pending.set(entry.path, decodeEvent(bytes));
73
  } catch (e) {
74
- throw new Error(`Corrupt event file ${entry.path}: ${(e as Error).message}`);
75
  }
76
  }
77
  // A compactor deleted an event after publishing a ledger that contains it: re-read the ledger.
@@ -110,7 +110,7 @@ export class Ledger {
110
  /** `id` may be supplied so callers can store a photo under the expense id before the event is written. */
111
  createExpense(actor: string, body: ExpenseBody, id = `e_${this.newId()}`): Promise<Expense> {
112
  return this.run(async () => {
113
- if (this.state.latest.has(id)) throw new ConflictError(id, this.state.expenses.get(id)?.version ?? 0);
114
  const now = this.clock().toISOString();
115
  const expense: Expense = { ...body, id, version: 1, createdBy: actor, createdAt: now, updatedBy: actor, updatedAt: now };
116
  const written = await this.writeEvent({ kind: 'expense', op: 'upsert', entityId: id, version: 1, data: expense }, actor, now);
 
71
  try {
72
  pending.set(entry.path, decodeEvent(bytes));
73
  } catch (e) {
74
+ throw new Error(`Corrupt event file ${entry.path}`, { cause: e });
75
  }
76
  }
77
  // A compactor deleted an event after publishing a ledger that contains it: re-read the ledger.
 
110
  /** `id` may be supplied so callers can store a photo under the expense id before the event is written. */
111
  createExpense(actor: string, body: ExpenseBody, id = `e_${this.newId()}`): Promise<Expense> {
112
  return this.run(async () => {
113
+ if (this.state.latest.has(id)) throw new ConflictError(id, this.state.latest.get(id)!.version);
114
  const now = this.clock().toISOString();
115
  const expense: Expense = { ...body, id, version: 1, createdBy: actor, createdAt: now, updatedBy: actor, updatedAt: now };
116
  const written = await this.writeEvent({ kind: 'expense', op: 'upsert', entityId: id, version: 1, data: expense }, actor, now);
tests/integration/ledger.test.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterAll, describe, expect, it } from 'vitest';
2
+ import type { ExpenseBody } from '../../src/lib/domain/types';
3
+ import { ulid } from '../../src/lib/domain/ulid';
4
+ import { Ledger } from '../../src/lib/server/ledger/ledger';
5
+ import { HfBucketStore } from '../../src/lib/server/storage/hf-bucket-store';
6
+ import { PrefixedStore } from '../../src/lib/server/storage/prefixed-store';
7
+ import { realBucket } from './env';
8
+
9
+ const creds = realBucket();
10
+ const body = (description: string): ExpenseBody => ({
11
+ description, amountCents: 300, currency: 'USD', date: '2026-08-01', paidBy: 'u_a',
12
+ split: { mode: 'equal', participants: ['u_a', 'u_b', 'u_c'] }, shares: { u_a: 100, u_b: 100, u_c: 100 }
13
+ });
14
+
15
+ describe.skipIf(!creds)(
16
+ 'Ledger against the real bucket',
17
+ () => {
18
+ const store = creds
19
+ ? new PrefixedStore(new HfBucketStore(creds.bucket, creds.token), `test/${ulid()}`)
20
+ : (undefined as unknown as PrefixedStore);
21
+
22
+ afterAll(() => store.wipe(), 60_000);
23
+
24
+ it('writes events, compacts twice (append path), and reloads identically', async () => {
25
+ const ledger = await Ledger.load(store);
26
+ await ledger.createExpense('u_a', body('one'));
27
+ await ledger.createExpense('u_a', body('two'));
28
+ expect(await ledger.compact()).toMatchObject({ appended: 2, deleted: 2 });
29
+ await ledger.createExpense('u_a', body('three'));
30
+ expect(await ledger.compact()).toMatchObject({ appended: 1, deleted: 1, backedUp: true });
31
+ expect(await store.list('events/')).toEqual([]);
32
+
33
+ const reloaded = await Ledger.load(store);
34
+ expect(reloaded.listExpenses().map((e) => e.description).sort()).toEqual(['one', 'three', 'two']);
35
+ expect(reloaded.stats()).toEqual({ pendingEvents: 0, ledgerEvents: 3, ledgerExists: true });
36
+ });
37
+ },
38
+ 120_000
39
+ );
tests/unit/helpers/failing-store.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { BucketStore, StoreEntry } from '../../../src/lib/server/storage/store';
2
+
3
+ type Method = keyof BucketStore;
4
+ export interface Fault {
5
+ method: Method;
6
+ /** 'before' = operation never happens; 'after' = operation happens but the caller sees an error (lost ack). */
7
+ when: 'before' | 'after';
8
+ /** Fail only the nth call (1-based) of that method; default every call. */
9
+ nth?: number;
10
+ }
11
+
12
+ /** Wraps a store and injects one fault, then behaves normally. */
13
+ export class FailingStore implements BucketStore {
14
+ private calls = new Map<Method, number>();
15
+ constructor(
16
+ private readonly inner: BucketStore,
17
+ public fault: Fault | null
18
+ ) {}
19
+
20
+ private async wrap<T>(method: Method, op: () => Promise<T>): Promise<T> {
21
+ const n = (this.calls.get(method) ?? 0) + 1;
22
+ this.calls.set(method, n);
23
+ const f = this.fault;
24
+ const hit = f && f.method === method && (f.nth === undefined || f.nth === n);
25
+ if (hit && f.when === 'before') {
26
+ this.fault = null;
27
+ throw new Error(`injected failure before ${method}`);
28
+ }
29
+ const result = await op();
30
+ if (hit && f.when === 'after') {
31
+ this.fault = null;
32
+ throw new Error(`injected failure after ${method}`);
33
+ }
34
+ return result;
35
+ }
36
+
37
+ get(path: string): Promise<Uint8Array | null> {
38
+ return this.wrap('get', () => this.inner.get(path));
39
+ }
40
+ put(path: string, bytes: Uint8Array, contentType?: string): Promise<void> {
41
+ return this.wrap('put', () => this.inner.put(path, bytes, contentType));
42
+ }
43
+ append(path: string, bytes: Uint8Array): Promise<void> {
44
+ return this.wrap('append', () => this.inner.append(path, bytes));
45
+ }
46
+ list(prefix: string): Promise<StoreEntry[]> {
47
+ return this.wrap('list', () => this.inner.list(prefix));
48
+ }
49
+ delete(paths: string[]): Promise<void> {
50
+ return this.wrap('delete', () => this.inner.delete(paths));
51
+ }
52
+ copy(src: string, dst: string): Promise<void> {
53
+ return this.wrap('copy', () => this.inner.copy(src, dst));
54
+ }
55
+ }
tests/unit/ledger/compaction.test.ts ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import type { ExpenseBody } from '../../../src/lib/domain/types';
6
+ import { Ledger } from '../../../src/lib/server/ledger/ledger';
7
+ import { LocalFsStore } from '../../../src/lib/server/storage/local-fs-store';
8
+ import { decodeText } from '../../../src/lib/server/storage/store';
9
+ import { FailingStore, type Fault } from '../helpers/failing-store';
10
+
11
+ const body = (description: string): ExpenseBody => ({
12
+ description, amountCents: 300, currency: 'USD', date: '2026-08-01', paidBy: 'u_a',
13
+ split: { mode: 'equal', participants: ['u_a', 'u_b', 'u_c'] }, shares: { u_a: 100, u_b: 100, u_c: 100 }
14
+ });
15
+
16
+ describe('Ledger.compact', () => {
17
+ let dir: string;
18
+ let base: LocalFsStore;
19
+ let day = Date.UTC(2026, 7, 29, 12);
20
+ const clock = () => new Date(day);
21
+
22
+ beforeEach(async () => {
23
+ dir = await mkdtemp(join(tmpdir(), 'splitwise-compact-'));
24
+ base = new LocalFsStore(dir);
25
+ });
26
+ afterEach(() => rm(dir, { recursive: true, force: true }));
27
+
28
+ async function seed(n: number): Promise<Ledger> {
29
+ const ledger = await Ledger.load(base, { clock });
30
+ for (let i = 0; i < n; i++) await ledger.createExpense('u_a', body(`e${i}`));
31
+ return ledger;
32
+ }
33
+
34
+ it('does nothing when there are no events', async () => {
35
+ const ledger = await Ledger.load(base, { clock });
36
+ expect(await ledger.compact()).toEqual({ appended: 0, deleted: 0, backedUp: false });
37
+ expect(await base.get('ledger.jsonl')).toBeNull();
38
+ });
39
+
40
+ it('moves events into the ledger in ULID order and deletes the files', async () => {
41
+ const ledger = await seed(3);
42
+ const result = await ledger.compact();
43
+ expect(result).toEqual({ appended: 3, deleted: 3, backedUp: false });
44
+ expect(await base.list('events/')).toEqual([]);
45
+ const lines = decodeText((await base.get('ledger.jsonl'))!).trim().split('\n');
46
+ expect(lines).toHaveLength(3);
47
+ expect(lines.map((l) => JSON.parse(l).data.description)).toEqual(['e0', 'e1', 'e2']);
48
+ expect(ledger.stats()).toEqual({ pendingEvents: 0, ledgerEvents: 3, ledgerExists: true });
49
+
50
+ const reloaded = await Ledger.load(base, { clock });
51
+ expect(reloaded.listExpenses().map((e) => e.description).sort()).toEqual(['e0', 'e1', 'e2']);
52
+ });
53
+
54
+ it('appends on the second compaction and backs up once per day', async () => {
55
+ const ledger = await seed(2);
56
+ await ledger.compact();
57
+ await ledger.createExpense('u_a', body('later'));
58
+ const second = await ledger.compact();
59
+ expect(second).toEqual({ appended: 1, deleted: 1, backedUp: true });
60
+ const backup = decodeText((await base.get('backups/ledger-2026-08-29.jsonl'))!).trim().split('\n');
61
+ expect(backup).toHaveLength(2);
62
+ expect(decodeText((await base.get('ledger.jsonl'))!).trim().split('\n')).toHaveLength(3);
63
+
64
+ await ledger.createExpense('u_a', body('same day'));
65
+ expect((await ledger.compact()).backedUp).toBe(false);
66
+ day += 24 * 3600 * 1000;
67
+ await ledger.createExpense('u_a', body('next day'));
68
+ expect((await ledger.compact()).backedUp).toBe(true);
69
+ expect(await base.get('backups/ledger-2026-08-30.jsonl')).not.toBeNull();
70
+ });
71
+
72
+ const faults: Fault[] = [
73
+ { method: 'copy', when: 'before' },
74
+ { method: 'append', when: 'before' },
75
+ { method: 'append', when: 'after' },
76
+ { method: 'delete', when: 'before' },
77
+ { method: 'delete', when: 'after' }
78
+ ];
79
+
80
+ for (const fault of faults) {
81
+ it(`loses nothing when the store fails ${fault.when} ${fault.method}`, async () => {
82
+ // First compaction creates the ledger so later ones exercise copy+append.
83
+ const seeded = await seed(2);
84
+ await seeded.compact();
85
+ await seeded.createExpense('u_a', body('x'));
86
+ await seeded.createExpense('u_a', body('y'));
87
+
88
+ const failing = new FailingStore(base, fault);
89
+ const ledger = await Ledger.load(failing, { clock });
90
+ await expect(ledger.compact()).rejects.toThrow('injected failure');
91
+
92
+ // Simulate a restart from whatever the store now contains.
93
+ const after = await Ledger.load(base, { clock });
94
+ expect(after.listExpenses().map((e) => e.description).sort()).toEqual(['e0', 'e1', 'x', 'y']);
95
+
96
+ // A retry finishes the job.
97
+ const retried = await Ledger.load(base, { clock });
98
+ await retried.compact();
99
+ expect(await base.list('events/')).toEqual([]);
100
+ const lines = decodeText((await base.get('ledger.jsonl'))!).trim().split('\n').map((l) => JSON.parse(l));
101
+ // Duplicated lines are allowed on disk (lost ack) but never lost, and fold dedups them.
102
+ expect(new Set(lines.map((l) => l.id)).size).toBe(4);
103
+ const final = await Ledger.load(base, { clock });
104
+ expect(final.listExpenses().map((e) => e.description).sort()).toEqual(['e0', 'e1', 'x', 'y']);
105
+ expect(final.stats().pendingEvents).toBe(0);
106
+ });
107
+ }
108
+
109
+ it('writes made while a compaction is in flight are not lost', async () => {
110
+ const ledger = await seed(1);
111
+ const compaction = ledger.compact();
112
+ const write = ledger.createExpense('u_a', body('during'));
113
+ await Promise.all([compaction, write]);
114
+ const reloaded = await Ledger.load(base, { clock });
115
+ expect(reloaded.listExpenses().map((e) => e.description).sort()).toEqual(['during', 'e0']);
116
+ });
117
+ });