Spaces:
Sleeping
Sleeping
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}
|
| 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.
|
| 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 |
+
});
|