Spaces:
Running
Running
assafvayner HF Staff
test(ledger): same-instance compaction retries, heal-path put faults, real in-flight write test
e511dfb Download tests/unit/ledger/compaction.test.ts from assafvayner/splitwise: direct link, hf CLI and curl.
- Browser
- Download file 10 kB
-
https://huggingface.co/spaces/assafvayner/splitwise/resolve/main/tests/unit/ledger/compaction.test.ts
- Command line
-
hf download hf://spaces/assafvayner/splitwise/tests/unit/ledger/compaction.test.ts
-
curl -L -o compaction.test.ts https://huggingface.co/spaces/assafvayner/splitwise/resolve/main/tests/unit/ledger/compaction.test.ts
10 kB
| import { mkdtemp, rm } from 'node:fs/promises'; | |
| import { tmpdir } from 'node:os'; | |
| import { join } from 'node:path'; | |
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | |
| import type { ExpenseBody } from '../../../src/lib/domain/types'; | |
| import { parseJsonl } from '../../../src/lib/server/ledger/events'; | |
| import { Ledger } from '../../../src/lib/server/ledger/ledger'; | |
| import { LocalFsStore } from '../../../src/lib/server/storage/local-fs-store'; | |
| import { decodeText, encodeText } from '../../../src/lib/server/storage/store'; | |
| import type { BucketStore } from '../../../src/lib/server/storage/store'; | |
| import { FailingStore, type Fault } from '../helpers/failing-store'; | |
| const body = (description: string): ExpenseBody => ({ | |
| description, amountCents: 300, currency: 'USD', date: '2026-08-01', paidBy: 'u_a', | |
| split: { mode: 'equal', participants: ['u_a', 'u_b', 'u_c'] }, shares: { u_a: 100, u_b: 100, u_c: 100 } | |
| }); | |
| /** Distinct event ids on disk, tolerating the duplicate lines a lost-ack retry is allowed to leave. */ | |
| function distinctIds(text: string): Set<string> { | |
| return new Set(text.trim().split('\n').map((l) => JSON.parse(l).id as string)); | |
| } | |
| describe('Ledger.compact', () => { | |
| let dir: string; | |
| let base: LocalFsStore; | |
| let day = Date.UTC(2026, 7, 29, 12); | |
| const clock = () => new Date(day); | |
| beforeEach(async () => { | |
| dir = await mkdtemp(join(tmpdir(), 'splitwise-compact-')); | |
| base = new LocalFsStore(dir); | |
| }); | |
| afterEach(() => rm(dir, { recursive: true, force: true })); | |
| async function seed(n: number): Promise<Ledger> { | |
| const ledger = await Ledger.load(base, { clock }); | |
| for (let i = 0; i < n; i++) await ledger.createExpense('u_a', body(`e${i}`)); | |
| return ledger; | |
| } | |
| it('does nothing when there are no events', async () => { | |
| const ledger = await Ledger.load(base, { clock }); | |
| expect(await ledger.compact()).toEqual({ appended: 0, deleted: 0, backedUp: false }); | |
| expect(await base.get('ledger.jsonl')).toBeNull(); | |
| }); | |
| it('moves events into the ledger in ULID order and deletes the files', async () => { | |
| const ledger = await seed(3); | |
| const result = await ledger.compact(); | |
| expect(result).toEqual({ appended: 3, deleted: 3, backedUp: false }); | |
| expect(await base.list('events/')).toEqual([]); | |
| const lines = decodeText((await base.get('ledger.jsonl'))!).trim().split('\n'); | |
| expect(lines).toHaveLength(3); | |
| expect(lines.map((l) => JSON.parse(l).data.description)).toEqual(['e0', 'e1', 'e2']); | |
| expect(ledger.stats()).toEqual({ pendingEvents: 0, ledgerEvents: 3, ledgerExists: true }); | |
| const reloaded = await Ledger.load(base, { clock }); | |
| expect(reloaded.listExpenses().map((e) => e.description).sort()).toEqual(['e0', 'e1', 'e2']); | |
| }); | |
| it('appends on the second compaction and backs up once per day', async () => { | |
| const ledger = await seed(2); | |
| await ledger.compact(); | |
| await ledger.createExpense('u_a', body('later')); | |
| const second = await ledger.compact(); | |
| expect(second).toEqual({ appended: 1, deleted: 1, backedUp: true }); | |
| const backup = decodeText((await base.get('backups/ledger-2026-08-29.jsonl'))!).trim().split('\n'); | |
| expect(backup).toHaveLength(2); | |
| expect(decodeText((await base.get('ledger.jsonl'))!).trim().split('\n')).toHaveLength(3); | |
| await ledger.createExpense('u_a', body('same day')); | |
| expect((await ledger.compact()).backedUp).toBe(false); | |
| day += 24 * 3600 * 1000; | |
| await ledger.createExpense('u_a', body('next day')); | |
| expect((await ledger.compact()).backedUp).toBe(true); | |
| expect(await base.get('backups/ledger-2026-08-30.jsonl')).not.toBeNull(); | |
| }); | |
| const faults: Fault[] = [ | |
| { method: 'copy', when: 'before' }, | |
| { method: 'copy', when: 'after' }, | |
| { method: 'append', when: 'before' }, | |
| { method: 'append', when: 'after' }, | |
| { method: 'delete', when: 'before' }, | |
| { method: 'delete', when: 'after' } | |
| ]; | |
| for (const fault of faults) { | |
| it(`loses nothing when the store fails ${fault.when} ${fault.method}`, async () => { | |
| // First compaction creates the ledger so later ones exercise copy+append. | |
| const seeded = await seed(2); | |
| await seeded.compact(); | |
| await seeded.createExpense('u_a', body('x')); | |
| await seeded.createExpense('u_a', body('y')); | |
| const failing = new FailingStore(base, fault); | |
| const ledger = await Ledger.load(failing, { clock }); | |
| await expect(ledger.compact()).rejects.toThrow(`injected failure ${fault.when} ${fault.method}`); | |
| // Simulate a restart from whatever the store now contains right after the crash. | |
| const after = await Ledger.load(base, { clock }); | |
| expect(after.listExpenses().map((e) => e.description).sort()).toEqual(['e0', 'e1', 'x', 'y']); | |
| // The SAME instance, retried without a restart, must also fully self-heal. A premature | |
| // update to in-memory bookkeeping (e.g. marking an event as ledgered before its write is | |
| // durable) would be invisible to a fresh reload but would make this retry silently drop | |
| // the event once its file is deleted. | |
| await ledger.compact(); | |
| expect(await base.list('events/')).toEqual([]); | |
| expect(distinctIds(decodeText((await base.get('ledger.jsonl'))!)).size).toBe(4); | |
| expect(ledger.listExpenses().map((e) => e.description).sort()).toEqual(['e0', 'e1', 'x', 'y']); | |
| expect(ledger.stats().pendingEvents).toBe(0); | |
| // A retry from a fresh restart also finishes the job. | |
| const retried = await Ledger.load(base, { clock }); | |
| await retried.compact(); | |
| expect(await base.list('events/')).toEqual([]); | |
| // Duplicated lines are allowed on disk (lost ack) but never lost, and fold dedups them. | |
| expect(distinctIds(decodeText((await base.get('ledger.jsonl'))!)).size).toBe(4); | |
| const final = await Ledger.load(base, { clock }); | |
| expect(final.listExpenses().map((e) => e.description).sort()).toEqual(['e0', 'e1', 'x', 'y']); | |
| expect(final.stats().pendingEvents).toBe(0); | |
| }); | |
| } | |
| describe('heals a truncated ledger even when the rewrite crashes', () => { | |
| /** | |
| * Compacts 2 events, then leaves a 3rd fully-written pending event file plus a truncated | |
| * trailing line appended to ledger.jsonl, mirroring an append that crashed mid-line. | |
| */ | |
| async function seedTruncated(): Promise<void> { | |
| const seeded = await seed(2); | |
| await seeded.compact(); | |
| await seeded.createExpense('u_a', body('third')); | |
| const [pendingFile] = await base.list('events/'); | |
| const partialLine = decodeText((await base.get(pendingFile.path))!).slice(0, 24); | |
| const ledgerText = decodeText((await base.get('ledger.jsonl'))!); | |
| await base.put('ledger.jsonl', encodeText(ledgerText + partialLine)); | |
| } | |
| const putFaults: Fault[] = [ | |
| { method: 'put', when: 'before' }, | |
| { method: 'put', when: 'after' } | |
| ]; | |
| for (const fault of putFaults) { | |
| it(`heals the truncated ledger when the rewrite fails ${fault.when} ${fault.method}`, async () => { | |
| await seedTruncated(); | |
| const failing = new FailingStore(base, fault); | |
| const ledger = await Ledger.load(failing, { clock }); | |
| await expect(ledger.compact()).rejects.toThrow(`injected failure ${fault.when} ${fault.method}`); | |
| // Right after the crash, a fresh reload must already see every event. | |
| const after = await Ledger.load(base, { clock }); | |
| expect(after.listExpenses().map((e) => e.description).sort()).toEqual(['e0', 'e1', 'third']); | |
| // The SAME instance, retried without a restart, converges to a fully parseable ledger. | |
| await ledger.compact(); | |
| expect(await base.list('events/')).toEqual([]); | |
| const parsed = parseJsonl(decodeText((await base.get('ledger.jsonl'))!)); | |
| expect(parsed.truncated).toBe(false); | |
| expect(new Set(parsed.events.map((e) => e.id)).size).toBe(3); | |
| expect(ledger.listExpenses().map((e) => e.description).sort()).toEqual(['e0', 'e1', 'third']); | |
| expect(ledger.stats().pendingEvents).toBe(0); | |
| // A restart-based retry converges too. | |
| const retried = await Ledger.load(base, { clock }); | |
| await retried.compact(); | |
| expect(await base.list('events/')).toEqual([]); | |
| const reparsed = parseJsonl(decodeText((await base.get('ledger.jsonl'))!)); | |
| expect(reparsed.truncated).toBe(false); | |
| const final = await Ledger.load(base, { clock }); | |
| expect(final.listExpenses().map((e) => e.description).sort()).toEqual(['e0', 'e1', 'third']); | |
| expect(final.stats().pendingEvents).toBe(0); | |
| }); | |
| } | |
| }); | |
| it('writes made while a compaction is in flight are not lost', async () => { | |
| const setup = await seed(1); | |
| await setup.compact(); | |
| await setup.createExpense('u_a', body('toCompact')); | |
| let appendCalled = false; | |
| let releaseAppend!: () => void; | |
| const blocked = new Promise<void>((resolve) => { | |
| releaseAppend = resolve; | |
| }); | |
| // A store that behaves normally except append blocks until released, so we can catch | |
| // compaction with its write in flight and prove a concurrent write is queued behind it. | |
| const blocking: BucketStore = { | |
| get: (p) => base.get(p), | |
| put: (p, b, ct) => base.put(p, b, ct), | |
| append: async (p, b) => { | |
| appendCalled = true; | |
| await blocked; | |
| await base.append(p, b); | |
| }, | |
| list: (p) => base.list(p), | |
| delete: (p) => base.delete(p), | |
| copy: (s, d) => base.copy(s, d) | |
| }; | |
| const ledger = await Ledger.load(blocking, { clock }); | |
| const compaction = ledger.compact(); | |
| await vi.waitFor(() => expect(appendCalled).toBe(true)); | |
| const write = ledger.createExpense('u_a', body('during')); | |
| let writeSettled = false; | |
| write.then( | |
| () => { | |
| writeSettled = true; | |
| }, | |
| () => { | |
| writeSettled = true; | |
| } | |
| ); | |
| await new Promise<void>((r) => setTimeout(r, 0)); | |
| expect(writeSettled).toBe(false); | |
| releaseAppend(); | |
| await Promise.all([compaction, write]); | |
| const reloaded = await Ledger.load(base, { clock }); | |
| expect(reloaded.listExpenses().map((e) => e.description).sort()).toEqual(['during', 'e0', 'toCompact']); | |
| const pendingFiles = await base.list('events/'); | |
| expect(pendingFiles).toHaveLength(1); | |
| expect(JSON.parse(decodeText((await base.get(pendingFiles[0].path))!)).data.description).toBe('during'); | |
| }); | |
| }); | |