Spaces:
Sleeping
Sleeping
File size: 10,013 Bytes
8d2f1b6 e511dfb 8d2f1b6 e511dfb 8d2f1b6 e511dfb 8d2f1b6 e511dfb 8d2f1b6 e511dfb 8d2f1b6 e511dfb 8d2f1b6 e511dfb 8d2f1b6 e511dfb 8d2f1b6 e511dfb 8d2f1b6 e511dfb 8d2f1b6 e511dfb 8d2f1b6 e511dfb 8d2f1b6 e511dfb 8d2f1b6 e511dfb 8d2f1b6 e511dfb 8d2f1b6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 | 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');
});
});
|