@effectstream/runtime 0.100.17 → 0.100.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/conciseinputhash-determinism.md +114 -0
- package/package.json +12 -12
- package/src/api/apply-status.ts +30 -0
- package/src/api/http-server.ts +74 -7
- package/src/config-snapshot.ts +12 -4
- package/src/main.ts +35 -32
- package/src/process-blocks.ts +126 -27
- package/src/types.ts +14 -0
- package/test/reproduction/buffering.test.ts +389 -0
- package/test/reproduction/consistency-snapshot.ts +168 -0
- package/test/reproduction/consistency.test.ts +197 -0
- package/test/reproduction/harness.ts +613 -0
- package/test/reproduction/node-runner.ts +152 -0
- package/test/reproduction/poll.ts +160 -0
- package/test/reproduction/resume.test.ts +77 -0
- package/test/reproduction/sanity.test.ts +74 -0
- package/test/reproduction/scenario.ts +126 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E database-consistency checks over the synthetic `test` chain.
|
|
3
|
+
*
|
|
4
|
+
* The database the engine builds must be a pure function of the source blocks —
|
|
5
|
+
* independent of *how* it got there. Two scenarios:
|
|
6
|
+
*
|
|
7
|
+
* A. Determinism baseline — two from-scratch syncs to the same height produce
|
|
8
|
+
* identical databases. Expected to PASS (proves reproducibility).
|
|
9
|
+
*
|
|
10
|
+
* B. Resume guarantee — a sync that stops mid-chunk and resumes to N must
|
|
11
|
+
* match a clean sync to N. Regression test for block-accurate resume
|
|
12
|
+
* (see @effectstream/sync CLAUDE.md, design idea #5): a restart must
|
|
13
|
+
* re-fetch (committed, chunk_end] instead of silently skipping it.
|
|
14
|
+
*
|
|
15
|
+
* Each node runs in its own subprocess (see harness.ts / node-runner.ts): a
|
|
16
|
+
* restart is a genuine new OS process whose in-memory Deque is gone while the
|
|
17
|
+
* database keeps every committed row — exactly the production restart condition.
|
|
18
|
+
*
|
|
19
|
+
* These tests assert a property of the **production** database path, so they run
|
|
20
|
+
* only against real Postgres — i.e. only when `PGLITE=false` and `postgresql@18`
|
|
21
|
+
* is installed. In the default PGLite mode (or if Postgres isn't installed) they
|
|
22
|
+
* skip rather than run, since PGLite is not the backend whose fidelity these
|
|
23
|
+
* guarantees are about.
|
|
24
|
+
*/
|
|
25
|
+
import { expect, test } from "bun:test";
|
|
26
|
+
import { postgresAvailable, setupHarness } from "./harness.ts";
|
|
27
|
+
import {
|
|
28
|
+
compareConsistencySnapshots,
|
|
29
|
+
type ConsistencySnapshot,
|
|
30
|
+
dumpConsistencySnapshot,
|
|
31
|
+
type SnapshotDiff,
|
|
32
|
+
type SnapshotOpts,
|
|
33
|
+
SYNC_BOOKKEEPING_TABLES,
|
|
34
|
+
} from "./consistency-snapshot.ts";
|
|
35
|
+
|
|
36
|
+
// Postgres-only: run only when PGLITE=false and Postgres is present.
|
|
37
|
+
const canRun = process.env.PGLITE === "false" && postgresAvailable();
|
|
38
|
+
const it = canRun ? test : test.skip;
|
|
39
|
+
|
|
40
|
+
/** "<n> tables, <m> rows". */
|
|
41
|
+
function summarize(snap: ConsistencySnapshot): string {
|
|
42
|
+
let rows = 0;
|
|
43
|
+
for (const f of snap.values()) rows += f.rowCount;
|
|
44
|
+
return `${snap.size} tables, ${rows} rows`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** e.g. "effectstream.primitive_accounting [row-count A=1 B=0]". */
|
|
48
|
+
function formatDiffs(diffs: SnapshotDiff[]): string {
|
|
49
|
+
return diffs
|
|
50
|
+
.map((d) => `${d.table} [${d.reason} A=${d.aRows ?? "—"} B=${d.bRows ?? "—"}]`)
|
|
51
|
+
.join(", ");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Log the two snapshots and their comparison result. */
|
|
55
|
+
function logComparison(
|
|
56
|
+
tag: string,
|
|
57
|
+
label: string,
|
|
58
|
+
aLabel: string,
|
|
59
|
+
a: ConsistencySnapshot,
|
|
60
|
+
bLabel: string,
|
|
61
|
+
b: ConsistencySnapshot,
|
|
62
|
+
diffs: SnapshotDiff[],
|
|
63
|
+
): void {
|
|
64
|
+
console.log(`${tag} ${label}`);
|
|
65
|
+
console.log(`${tag} ${aLabel}: ${summarize(a)}`);
|
|
66
|
+
console.log(`${tag} ${bLabel}: ${summarize(b)}`);
|
|
67
|
+
console.log(
|
|
68
|
+
diffs.length === 0
|
|
69
|
+
? `${tag} → IDENTICAL (0 differences)`
|
|
70
|
+
: `${tag} → ${diffs.length} difference(s): ${formatDiffs(diffs)}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
it("two from-scratch syncs to the same height produce identical databases", async () => {
|
|
75
|
+
const events = [
|
|
76
|
+
{ atBlock: 25, marker: "e25" },
|
|
77
|
+
{ atBlock: 50, marker: "e50" },
|
|
78
|
+
{ atBlock: 75, marker: "e75" },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// A fresh harness == a fresh database == "syncing again from scratch".
|
|
82
|
+
const runFromScratch = async (
|
|
83
|
+
apiPort: number,
|
|
84
|
+
): Promise<ConsistencySnapshot> => {
|
|
85
|
+
const h = await setupHarness({ backend: "postgres" });
|
|
86
|
+
try {
|
|
87
|
+
await h.runToHeight({
|
|
88
|
+
events,
|
|
89
|
+
tips: { mainClock: 100, parallelP: 200 },
|
|
90
|
+
target: 100,
|
|
91
|
+
apiPort,
|
|
92
|
+
});
|
|
93
|
+
return await dumpConsistencySnapshot(h.pool);
|
|
94
|
+
} finally {
|
|
95
|
+
await h.teardown();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const snapA = await runFromScratch(19140);
|
|
100
|
+
const snapB = await runFromScratch(19141);
|
|
101
|
+
|
|
102
|
+
const { diffs } = compareConsistencySnapshots(snapA, snapB);
|
|
103
|
+
logComparison(
|
|
104
|
+
"[consistency A]",
|
|
105
|
+
"two from-scratch syncs to height 100:",
|
|
106
|
+
"run A",
|
|
107
|
+
snapA,
|
|
108
|
+
"run B",
|
|
109
|
+
snapB,
|
|
110
|
+
diffs,
|
|
111
|
+
);
|
|
112
|
+
expect(diffs).toEqual([]);
|
|
113
|
+
}, 120_000);
|
|
114
|
+
|
|
115
|
+
it(
|
|
116
|
+
"a mid-chunk restart resumes to the same database as a clean sync",
|
|
117
|
+
async () => {
|
|
118
|
+
// One event inside the gap the restart will skip: it must fall in
|
|
119
|
+
// (phase-1 committed height 10, parallel page 50] AND be ≤ the phase-2 main
|
|
120
|
+
// tip 30, so a *correct* engine would re-fetch and commit it after resume.
|
|
121
|
+
const events = [{ atBlock: 20, marker: "gap20" }];
|
|
122
|
+
// Large parallel stepSize => the whole tip is fetched as ONE chunk, so its
|
|
123
|
+
// page row persists at page_number = tip (the chunk-granular watermark that
|
|
124
|
+
// Fix D wrongly resumes from).
|
|
125
|
+
const parallelStepSize = 1000;
|
|
126
|
+
// Sync bookkeeping (page queue, version/migration history) legitimately
|
|
127
|
+
// differs after a restart even in a correct engine; compare observable
|
|
128
|
+
// state only.
|
|
129
|
+
const dumpOpts: SnapshotOpts = { excludeTables: SYNC_BOOKKEEPING_TABLES };
|
|
130
|
+
|
|
131
|
+
// Reference: one clean sync straight to height 30. Parallel stays ahead of
|
|
132
|
+
// the main clock so the final main block can finalize (the merge gates block
|
|
133
|
+
// T on the parallel page advancing past T). Contains the block-20 event.
|
|
134
|
+
const ref = await setupHarness({ backend: "postgres" });
|
|
135
|
+
let refSnap: ConsistencySnapshot;
|
|
136
|
+
try {
|
|
137
|
+
await ref.runToHeight({
|
|
138
|
+
events,
|
|
139
|
+
parallelStepSize,
|
|
140
|
+
tips: { mainClock: 30, parallelP: 50 },
|
|
141
|
+
target: 30,
|
|
142
|
+
apiPort: 19150,
|
|
143
|
+
});
|
|
144
|
+
refSnap = await dumpConsistencySnapshot(ref.pool, dumpOpts);
|
|
145
|
+
} finally {
|
|
146
|
+
await ref.teardown();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Resumed: one database, two subprocess runs (= a real restart).
|
|
150
|
+
const resumed = await setupHarness({ backend: "postgres" });
|
|
151
|
+
let resumeSnap: ConsistencySnapshot;
|
|
152
|
+
try {
|
|
153
|
+
// Phase 1: the parallel fetcher (no backpressure) races to its own tip
|
|
154
|
+
// (50) into the in-memory Deque, but the main clock only drives commits to
|
|
155
|
+
// height 10 — so parallel blocks 11..50 (incl. the event at 20) are fetched
|
|
156
|
+
// but never committed, and the in-memory Deque is lost when the process
|
|
157
|
+
// exits. The runtime persists a block-accurate resume marker at the last
|
|
158
|
+
// COMMITTED parallel block (≤ 10), not the fetched-ahead tip.
|
|
159
|
+
await resumed.runToHeight({
|
|
160
|
+
events,
|
|
161
|
+
parallelStepSize,
|
|
162
|
+
tips: { mainClock: 10, parallelP: 50 },
|
|
163
|
+
target: 10,
|
|
164
|
+
apiPort: 19151,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Phase 2 (restart): a new process boots against the same database. Its
|
|
168
|
+
// Deque is empty; restoreState reads the persisted resume marker (the last
|
|
169
|
+
// committed parallel block ≤ 10) and resumes parallel from there. The main
|
|
170
|
+
// clock now advances to 30, so the parallel blocks it needs (11..30,
|
|
171
|
+
// including the event at 20) are re-fetched and committed.
|
|
172
|
+
await resumed.runToHeight({
|
|
173
|
+
events,
|
|
174
|
+
parallelStepSize,
|
|
175
|
+
tips: { mainClock: 30, parallelP: 50 },
|
|
176
|
+
target: 30,
|
|
177
|
+
apiPort: 19152,
|
|
178
|
+
});
|
|
179
|
+
resumeSnap = await dumpConsistencySnapshot(resumed.pool, dumpOpts);
|
|
180
|
+
} finally {
|
|
181
|
+
await resumed.teardown();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const { diffs } = compareConsistencySnapshots(refSnap, resumeSnap);
|
|
185
|
+
logComparison(
|
|
186
|
+
"[consistency B]",
|
|
187
|
+
"clean sync vs mid-chunk restart, both to height 30:",
|
|
188
|
+
"clean ref",
|
|
189
|
+
refSnap,
|
|
190
|
+
"resumed ",
|
|
191
|
+
resumeSnap,
|
|
192
|
+
diffs,
|
|
193
|
+
);
|
|
194
|
+
expect(diffs).toEqual([]);
|
|
195
|
+
},
|
|
196
|
+
120_000,
|
|
197
|
+
);
|