@effectstream/runtime 0.100.20 → 0.100.22
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/README.md +0 -34
- package/package.json +13 -13
- package/src/api/http-server.ts +7 -55
- package/src/main.ts +39 -64
- package/src/process-blocks.ts +27 -126
- package/src/types.ts +0 -7
- package/test/reproduction/harness.ts +270 -559
- package/test/reproduction/sanity.test.ts +91 -40
- package/conciseinputhash-determinism.md +0 -114
- package/src/api/apply-status.ts +0 -30
- package/src/coalesce.ts +0 -291
- package/test/coalesce.unit.test.ts +0 -69
- package/test/reproduction/buffering.test.ts +0 -389
- package/test/reproduction/coalesce.test.ts +0 -133
- package/test/reproduction/consistency-snapshot.ts +0 -168
- package/test/reproduction/consistency.test.ts +0 -197
- package/test/reproduction/node-runner.ts +0 -154
- package/test/reproduction/poll.ts +0 -160
- package/test/reproduction/resume.test.ts +0 -77
- package/test/reproduction/scenario.ts +0 -126
|
@@ -1,389 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Verifies the fetch-backpressure feature (Option B′) over the synthetic `test`
|
|
3
|
-
* chain through the real runtime (`start()` + in-process PGLite).
|
|
4
|
-
*
|
|
5
|
-
* 1a — unbounded buffering: with a cap, the fetch loop pauses when the buffer is
|
|
6
|
-
* full, so it stays bounded (~cap + stepSize) instead of racing to the whole
|
|
7
|
-
* backlog — while the node keeps applying blocks (liveness).
|
|
8
|
-
* 1b — head-of-line blocking: a stalled slow chain STILL halts block production
|
|
9
|
-
* (the merge gate is correct), but the fast chain's buffer is now bounded by
|
|
10
|
-
* the cap instead of ballooning behind the head-of-line block.
|
|
11
|
-
*
|
|
12
|
-
* Before the fix these buffers reached ~49k–50k (see
|
|
13
|
-
* e2e/perf/results/BACKPRESSURE-BASELINE.md); these tests assert they now
|
|
14
|
-
* stay at the cap. Each test also writes a JSON time-series artifact to
|
|
15
|
-
* e2e/perf/results/ whose field names match e2e/perf/metrics.ts `Sample`, so the
|
|
16
|
-
* perf HTML report's Backpressure section can render the same signals.
|
|
17
|
-
*/
|
|
18
|
-
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
19
|
-
import {
|
|
20
|
-
ConfigBuilder,
|
|
21
|
-
ConfigNetworkType,
|
|
22
|
-
ConfigSyncProtocolType,
|
|
23
|
-
} from "@effectstream/config";
|
|
24
|
-
import { TestChainControl } from "@effectstream/sync";
|
|
25
|
-
import fs from "node:fs";
|
|
26
|
-
import path from "node:path";
|
|
27
|
-
import {
|
|
28
|
-
bufferSize,
|
|
29
|
-
type Harness,
|
|
30
|
-
latestFinalizedHeight,
|
|
31
|
-
type RunningNode,
|
|
32
|
-
setupHarness,
|
|
33
|
-
sleep,
|
|
34
|
-
TEST_PRIMITIVE_TYPE,
|
|
35
|
-
} from "./harness.ts";
|
|
36
|
-
|
|
37
|
-
const START_TIME = 1_700_000_000_000;
|
|
38
|
-
const BLOCK_TIME_MS = 1000;
|
|
39
|
-
const STEP_SIZE = 1000;
|
|
40
|
-
// A deep backlog the arithmetic fetcher would race through if uncapped (baseline
|
|
41
|
-
// hit ~49k–50k). The slow chain (1b) is pinned low so the merge stalls at its tip.
|
|
42
|
-
const BIG_TIP = 50_000;
|
|
43
|
-
const SLOW_TIP = 50;
|
|
44
|
-
// Explicit small cap so the bound is deterministic and far below the backlog.
|
|
45
|
-
// The fetch pauses at >= CAP; one in-flight chunk can overshoot by ~stepSize, so
|
|
46
|
-
// the buffer settles in [~CAP, CAP + stepSize]. We assert it lands in that band.
|
|
47
|
-
const CAP = 3000;
|
|
48
|
-
const CAP_FLOOR = CAP - STEP_SIZE; // proves backpressure actually engaged
|
|
49
|
-
const BUFFER_BOUND = CAP + STEP_SIZE + 200; // upper bound incl. one overshoot chunk
|
|
50
|
-
|
|
51
|
-
// e2e/perf/results lives at the repo root: reproduction → test → runtime →
|
|
52
|
-
// node-sdk → packages → <root>/e2e/perf/results.
|
|
53
|
-
const RESULTS_DIR = path.resolve(
|
|
54
|
-
import.meta.dirname!,
|
|
55
|
-
"../../../../../e2e/perf/results",
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
/** One time-series row; field names mirror e2e/perf/metrics.ts `Sample`. */
|
|
59
|
-
type BufSample = {
|
|
60
|
-
t: number;
|
|
61
|
-
wall: number;
|
|
62
|
-
mainBuf: number;
|
|
63
|
-
evmBuf: number; // the parallel chain's buffer (report labels it "parallel buf")
|
|
64
|
-
mainOwnBlock: number | null;
|
|
65
|
-
evmOwnBlock: number | null;
|
|
66
|
-
rss: number;
|
|
67
|
-
appliedBlock: number | null;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
function ownBlock(node: RunningNode, name: string): number | null {
|
|
71
|
-
return node.syncProtocols().find((p) => p.name === name)?.lastPage
|
|
72
|
-
?.ownBlockNumber ?? null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Backpressure "fired" counter (rising edges) exposed on SyncState / /debug/metrics. */
|
|
76
|
-
function pauses(node: RunningNode, name: string): number {
|
|
77
|
-
return node.syncProtocols().find((p) => p.name === name)
|
|
78
|
-
?.backpressurePauses ?? 0;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Side-effect-only artifact write; never throws into the test. */
|
|
82
|
-
function writeArtifact(
|
|
83
|
-
name: string,
|
|
84
|
-
samples: BufSample[],
|
|
85
|
-
meta: Record<string, unknown>,
|
|
86
|
-
): void {
|
|
87
|
-
try {
|
|
88
|
-
fs.mkdirSync(RESULTS_DIR, { recursive: true });
|
|
89
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
90
|
-
const file = path.join(RESULTS_DIR, `${name}-${stamp}.json`);
|
|
91
|
-
fs.writeFileSync(file, JSON.stringify({ meta, samples }, null, 2));
|
|
92
|
-
console.log(`buffering artifact: ${file}`);
|
|
93
|
-
} catch (e) {
|
|
94
|
-
console.warn(`failed to write buffering artifact: ${String(e)}`);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
let h: Harness;
|
|
99
|
-
beforeEach(async () => {
|
|
100
|
-
h = await setupHarness();
|
|
101
|
-
});
|
|
102
|
-
afterEach(async () => {
|
|
103
|
-
await h?.teardown();
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
// ── 1a — unbounded buffering, now bounded by the cap ────────────────────────────
|
|
107
|
-
|
|
108
|
-
const MAIN_1A = "mainClock1a";
|
|
109
|
-
const PAR_1A = "parallelP1a";
|
|
110
|
-
|
|
111
|
-
function buildConfig1a() {
|
|
112
|
-
return new ConfigBuilder()
|
|
113
|
-
.setNamespace((b) => b.setSecurityNamespace("sync-buffering-1a"))
|
|
114
|
-
.buildNetworks((b) =>
|
|
115
|
-
b
|
|
116
|
-
.addNetwork({
|
|
117
|
-
name: "clock",
|
|
118
|
-
type: ConfigNetworkType.TEST,
|
|
119
|
-
startTime: START_TIME,
|
|
120
|
-
blockTimeMS: BLOCK_TIME_MS,
|
|
121
|
-
})
|
|
122
|
-
.addNetwork({
|
|
123
|
-
name: "chainP",
|
|
124
|
-
type: ConfigNetworkType.TEST,
|
|
125
|
-
startTime: START_TIME,
|
|
126
|
-
blockTimeMS: BLOCK_TIME_MS,
|
|
127
|
-
})
|
|
128
|
-
)
|
|
129
|
-
.buildDeployments((b) => b)
|
|
130
|
-
.buildSyncProtocols((b) =>
|
|
131
|
-
b
|
|
132
|
-
.addMain(
|
|
133
|
-
(n) => n.clock,
|
|
134
|
-
() => ({
|
|
135
|
-
name: MAIN_1A,
|
|
136
|
-
type: ConfigSyncProtocolType.TEST_MAIN,
|
|
137
|
-
startBlockHeight: 1,
|
|
138
|
-
pollingInterval: 30,
|
|
139
|
-
stepSize: STEP_SIZE,
|
|
140
|
-
maxBufferedPages: CAP,
|
|
141
|
-
}),
|
|
142
|
-
)
|
|
143
|
-
.addParallel(
|
|
144
|
-
(n) => (n as any).chainP,
|
|
145
|
-
() => ({
|
|
146
|
-
name: PAR_1A,
|
|
147
|
-
type: ConfigSyncProtocolType.TEST_PARALLEL,
|
|
148
|
-
startBlockHeight: 1,
|
|
149
|
-
pollingInterval: 30,
|
|
150
|
-
stepSize: STEP_SIZE,
|
|
151
|
-
delayMs: 0,
|
|
152
|
-
events: [],
|
|
153
|
-
maxBufferedPages: CAP,
|
|
154
|
-
}),
|
|
155
|
-
)
|
|
156
|
-
)
|
|
157
|
-
.buildPrimitives((b) =>
|
|
158
|
-
b.addPrimitive(
|
|
159
|
-
(sp) => (sp as any)[PAR_1A],
|
|
160
|
-
() => ({ name: "noEvt1a", type: TEST_PRIMITIVE_TYPE, startBlockHeight: 1 }),
|
|
161
|
-
)
|
|
162
|
-
)
|
|
163
|
-
.build();
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
test("1a: backpressure caps the buffer while the node keeps applying blocks", async () => {
|
|
167
|
-
// Tips far ahead of where the one-block-per-txn merge can drain to: without a
|
|
168
|
-
// cap the fetcher would race to ~50k; with the cap it must plateau near CAP.
|
|
169
|
-
TestChainControl.setTip(MAIN_1A, BIG_TIP);
|
|
170
|
-
TestChainControl.setTip(PAR_1A, BIG_TIP);
|
|
171
|
-
|
|
172
|
-
const node = await h.runNode({ config: buildConfig1a(), apiPort: 19141 });
|
|
173
|
-
const samples: BufSample[] = [];
|
|
174
|
-
let peakMain = 0;
|
|
175
|
-
let peakEvm = 0;
|
|
176
|
-
let lastApplied = 0;
|
|
177
|
-
let mainPauses = 0;
|
|
178
|
-
try {
|
|
179
|
-
const startMs = Date.now();
|
|
180
|
-
const deadline = startMs + 6000;
|
|
181
|
-
while (Date.now() < deadline) {
|
|
182
|
-
const wall = Date.now();
|
|
183
|
-
const mainBuf = bufferSize(node, MAIN_1A);
|
|
184
|
-
const evmBuf = bufferSize(node, PAR_1A);
|
|
185
|
-
const appliedBlock = await latestFinalizedHeight(h.pool);
|
|
186
|
-
samples.push({
|
|
187
|
-
t: wall - startMs,
|
|
188
|
-
wall,
|
|
189
|
-
mainBuf,
|
|
190
|
-
evmBuf,
|
|
191
|
-
mainOwnBlock: ownBlock(node, MAIN_1A),
|
|
192
|
-
evmOwnBlock: ownBlock(node, PAR_1A),
|
|
193
|
-
rss: process.memoryUsage().rss,
|
|
194
|
-
appliedBlock,
|
|
195
|
-
});
|
|
196
|
-
peakMain = Math.max(peakMain, mainBuf);
|
|
197
|
-
peakEvm = Math.max(peakEvm, evmBuf);
|
|
198
|
-
lastApplied = appliedBlock ?? lastApplied;
|
|
199
|
-
// Backpressure engaged (buffer hit the cap) AND the node is still applying
|
|
200
|
-
// blocks → we've captured the bounded steady state; stop early.
|
|
201
|
-
if (peakMain >= CAP && lastApplied >= 20) break;
|
|
202
|
-
await sleep(50);
|
|
203
|
-
}
|
|
204
|
-
} finally {
|
|
205
|
-
mainPauses = pauses(node, MAIN_1A);
|
|
206
|
-
writeArtifact("buffering-1a", samples, {
|
|
207
|
-
mode: "1a-bounded-by-cap",
|
|
208
|
-
stepSize: STEP_SIZE,
|
|
209
|
-
bigTip: BIG_TIP,
|
|
210
|
-
cap: CAP,
|
|
211
|
-
bufferBound: BUFFER_BOUND,
|
|
212
|
-
peakMainBuf: peakMain,
|
|
213
|
-
peakEvmBuf: peakEvm,
|
|
214
|
-
lastApplied,
|
|
215
|
-
pauses: mainPauses,
|
|
216
|
-
});
|
|
217
|
-
await node.stop();
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// Backpressure engaged: the buffer reached the cap (not starved)...
|
|
221
|
-
expect(peakMain).toBeGreaterThanOrEqual(CAP_FLOOR);
|
|
222
|
-
// ...but stayed bounded — nowhere near the uncapped ~49k backlog.
|
|
223
|
-
expect(Math.max(peakMain, peakEvm)).toBeLessThanOrEqual(BUFFER_BOUND);
|
|
224
|
-
// ...the node kept producing/applying blocks (no deadlock)...
|
|
225
|
-
expect(lastApplied).toBeGreaterThanOrEqual(20);
|
|
226
|
-
// ...and the cap actually fired (the observability metric populated).
|
|
227
|
-
expect(mainPauses).toBeGreaterThan(0);
|
|
228
|
-
}, 60_000);
|
|
229
|
-
|
|
230
|
-
// ── 1b — head-of-line blocking, sibling buffer now bounded ──────────────────────
|
|
231
|
-
|
|
232
|
-
const MAIN_1B = "mainClock1b";
|
|
233
|
-
const FAST_1B = "fastP1b";
|
|
234
|
-
const SLOW_1B = "slowP1b";
|
|
235
|
-
|
|
236
|
-
function buildConfig1b() {
|
|
237
|
-
return new ConfigBuilder()
|
|
238
|
-
.setNamespace((b) => b.setSecurityNamespace("sync-buffering-1b"))
|
|
239
|
-
.buildNetworks((b) =>
|
|
240
|
-
b
|
|
241
|
-
.addNetwork({
|
|
242
|
-
name: "clock",
|
|
243
|
-
type: ConfigNetworkType.TEST,
|
|
244
|
-
startTime: START_TIME,
|
|
245
|
-
blockTimeMS: BLOCK_TIME_MS,
|
|
246
|
-
})
|
|
247
|
-
.addNetwork({
|
|
248
|
-
name: "chainFast",
|
|
249
|
-
type: ConfigNetworkType.TEST,
|
|
250
|
-
startTime: START_TIME,
|
|
251
|
-
blockTimeMS: BLOCK_TIME_MS,
|
|
252
|
-
})
|
|
253
|
-
.addNetwork({
|
|
254
|
-
name: "chainSlow",
|
|
255
|
-
type: ConfigNetworkType.TEST,
|
|
256
|
-
startTime: START_TIME,
|
|
257
|
-
blockTimeMS: BLOCK_TIME_MS,
|
|
258
|
-
})
|
|
259
|
-
)
|
|
260
|
-
.buildDeployments((b) => b)
|
|
261
|
-
.buildSyncProtocols((b) =>
|
|
262
|
-
b
|
|
263
|
-
.addMain(
|
|
264
|
-
(n) => n.clock,
|
|
265
|
-
() => ({
|
|
266
|
-
name: MAIN_1B,
|
|
267
|
-
type: ConfigSyncProtocolType.TEST_MAIN,
|
|
268
|
-
startBlockHeight: 1,
|
|
269
|
-
pollingInterval: 30,
|
|
270
|
-
stepSize: STEP_SIZE,
|
|
271
|
-
maxBufferedPages: CAP,
|
|
272
|
-
}),
|
|
273
|
-
)
|
|
274
|
-
.addParallel(
|
|
275
|
-
(n) => (n as any).chainFast,
|
|
276
|
-
() => ({
|
|
277
|
-
name: FAST_1B,
|
|
278
|
-
type: ConfigSyncProtocolType.TEST_PARALLEL,
|
|
279
|
-
startBlockHeight: 1,
|
|
280
|
-
pollingInterval: 30,
|
|
281
|
-
stepSize: STEP_SIZE,
|
|
282
|
-
delayMs: 0,
|
|
283
|
-
events: [],
|
|
284
|
-
maxBufferedPages: CAP,
|
|
285
|
-
}),
|
|
286
|
-
)
|
|
287
|
-
.addParallel(
|
|
288
|
-
(n) => (n as any).chainSlow,
|
|
289
|
-
() => ({
|
|
290
|
-
name: SLOW_1B,
|
|
291
|
-
type: ConfigSyncProtocolType.TEST_PARALLEL,
|
|
292
|
-
startBlockHeight: 1,
|
|
293
|
-
pollingInterval: 30,
|
|
294
|
-
stepSize: STEP_SIZE,
|
|
295
|
-
delayMs: 0,
|
|
296
|
-
events: [],
|
|
297
|
-
maxBufferedPages: CAP,
|
|
298
|
-
}),
|
|
299
|
-
)
|
|
300
|
-
)
|
|
301
|
-
.buildPrimitives((b) =>
|
|
302
|
-
b
|
|
303
|
-
.addPrimitive(
|
|
304
|
-
(sp) => (sp as any)[FAST_1B],
|
|
305
|
-
() => ({ name: "noEvtFast", type: TEST_PRIMITIVE_TYPE, startBlockHeight: 1 }),
|
|
306
|
-
)
|
|
307
|
-
.addPrimitive(
|
|
308
|
-
(sp) => (sp as any)[SLOW_1B],
|
|
309
|
-
() => ({ name: "noEvtSlow", type: TEST_PRIMITIVE_TYPE, startBlockHeight: 1 }),
|
|
310
|
-
)
|
|
311
|
-
)
|
|
312
|
-
.build();
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
test("1b: a stalled chain still halts production, but the fast chain's buffer stays capped", async () => {
|
|
316
|
-
// slowP pinned at a low tip → the merge stalls there forever (correct). fastP and
|
|
317
|
-
// the main clock keep fetching toward their high tips, but the cap now bounds them.
|
|
318
|
-
TestChainControl.setTip(MAIN_1B, BIG_TIP);
|
|
319
|
-
TestChainControl.setTip(FAST_1B, BIG_TIP);
|
|
320
|
-
TestChainControl.setTip(SLOW_1B, SLOW_TIP);
|
|
321
|
-
|
|
322
|
-
const node = await h.runNode({ config: buildConfig1b(), apiPort: 19142 });
|
|
323
|
-
const samples: BufSample[] = [];
|
|
324
|
-
let peakMain = 0;
|
|
325
|
-
let peakFast = 0;
|
|
326
|
-
let stalledHeight = -1;
|
|
327
|
-
let fastPauses = 0;
|
|
328
|
-
try {
|
|
329
|
-
const startMs = Date.now();
|
|
330
|
-
const deadline = startMs + 15_000;
|
|
331
|
-
let lastHeight = -1;
|
|
332
|
-
let lastChange = Date.now();
|
|
333
|
-
while (Date.now() < deadline) {
|
|
334
|
-
const wall = Date.now();
|
|
335
|
-
const mainBuf = bufferSize(node, MAIN_1B);
|
|
336
|
-
const fastBuf = bufferSize(node, FAST_1B);
|
|
337
|
-
const appliedBlock = await latestFinalizedHeight(h.pool);
|
|
338
|
-
samples.push({
|
|
339
|
-
t: wall - startMs,
|
|
340
|
-
wall,
|
|
341
|
-
mainBuf,
|
|
342
|
-
evmBuf: fastBuf,
|
|
343
|
-
mainOwnBlock: ownBlock(node, MAIN_1B),
|
|
344
|
-
evmOwnBlock: ownBlock(node, FAST_1B),
|
|
345
|
-
rss: process.memoryUsage().rss,
|
|
346
|
-
appliedBlock,
|
|
347
|
-
});
|
|
348
|
-
peakMain = Math.max(peakMain, mainBuf);
|
|
349
|
-
peakFast = Math.max(peakFast, fastBuf);
|
|
350
|
-
if (appliedBlock !== lastHeight) {
|
|
351
|
-
lastHeight = appliedBlock;
|
|
352
|
-
lastChange = wall;
|
|
353
|
-
}
|
|
354
|
-
// Production has stalled (height flat ≥1.5s) AND the fast chain has hit its
|
|
355
|
-
// cap → the head-of-line block holds while the sibling buffer is bounded.
|
|
356
|
-
if (wall - lastChange >= 1500 && peakFast >= CAP_FLOOR) {
|
|
357
|
-
stalledHeight = appliedBlock ?? -1;
|
|
358
|
-
break;
|
|
359
|
-
}
|
|
360
|
-
await sleep(50);
|
|
361
|
-
}
|
|
362
|
-
} finally {
|
|
363
|
-
fastPauses = pauses(node, FAST_1B);
|
|
364
|
-
writeArtifact("buffering-1b", samples, {
|
|
365
|
-
mode: "1b-hol-sibling-bounded",
|
|
366
|
-
stepSize: STEP_SIZE,
|
|
367
|
-
bigTip: BIG_TIP,
|
|
368
|
-
slowTip: SLOW_TIP,
|
|
369
|
-
cap: CAP,
|
|
370
|
-
bufferBound: BUFFER_BOUND,
|
|
371
|
-
stalledHeight,
|
|
372
|
-
peakMainBuf: peakMain,
|
|
373
|
-
peakFastBuf: peakFast,
|
|
374
|
-
fastPauses,
|
|
375
|
-
});
|
|
376
|
-
await node.stop();
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// Block production still stalls at ~the slow chain's tip (the merge gate is correct)...
|
|
380
|
-
expect(stalledHeight).toBeGreaterThanOrEqual(0);
|
|
381
|
-
expect(stalledHeight).toBeLessThanOrEqual(SLOW_TIP + 10);
|
|
382
|
-
// ...the fast chain's buffer engaged the cap...
|
|
383
|
-
expect(peakFast).toBeGreaterThanOrEqual(CAP_FLOOR);
|
|
384
|
-
// ...but stayed bounded behind the head-of-line block (was ~50k uncapped)...
|
|
385
|
-
expect(peakFast).toBeLessThanOrEqual(BUFFER_BOUND);
|
|
386
|
-
expect(peakMain).toBeLessThanOrEqual(BUFFER_BOUND);
|
|
387
|
-
// ...and the cap actually fired on the fast chain (observability metric populated).
|
|
388
|
-
expect(fastPauses).toBeGreaterThan(0);
|
|
389
|
-
}, 60_000);
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
/** E2E: coalesced vs non-coalesced syncs produce the same DB; resume through coalescing matches a clean run. */
|
|
2
|
-
import { expect, test } from "bun:test";
|
|
3
|
-
import { setupHarness } from "./harness.ts";
|
|
4
|
-
import {
|
|
5
|
-
compareConsistencySnapshots,
|
|
6
|
-
type ConsistencySnapshot,
|
|
7
|
-
dumpConsistencySnapshot,
|
|
8
|
-
type SnapshotDiff,
|
|
9
|
-
type SnapshotOpts,
|
|
10
|
-
SYNC_BOOKKEEPING_TABLES,
|
|
11
|
-
} from "./consistency-snapshot.ts";
|
|
12
|
-
|
|
13
|
-
function summarize(snap: ConsistencySnapshot): string {
|
|
14
|
-
let rows = 0;
|
|
15
|
-
for (const f of snap.values()) rows += f.rowCount;
|
|
16
|
-
return `${snap.size} tables, ${rows} rows`;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function formatDiffs(diffs: SnapshotDiff[]): string {
|
|
20
|
-
return diffs
|
|
21
|
-
.map((d) => `${d.table} [${d.reason} A=${d.aRows ?? "—"} B=${d.bRows ?? "—"}]`)
|
|
22
|
-
.join(", ");
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function logComparison(
|
|
26
|
-
tag: string,
|
|
27
|
-
aLabel: string,
|
|
28
|
-
a: ConsistencySnapshot,
|
|
29
|
-
bLabel: string,
|
|
30
|
-
b: ConsistencySnapshot,
|
|
31
|
-
diffs: SnapshotDiff[],
|
|
32
|
-
): void {
|
|
33
|
-
console.log(tag);
|
|
34
|
-
console.log(`${tag} ${aLabel}: ${summarize(a)}`);
|
|
35
|
-
console.log(`${tag} ${bLabel}: ${summarize(b)}`);
|
|
36
|
-
console.log(
|
|
37
|
-
diffs.length === 0
|
|
38
|
-
? `${tag} → IDENTICAL (0 differences)`
|
|
39
|
-
: `${tag} → ${diffs.length} diff(s): ${formatDiffs(diffs)}`,
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
test(
|
|
44
|
-
"coalesced and non-coalesced from-scratch syncs produce identical databases",
|
|
45
|
-
async () => {
|
|
46
|
-
const events = [
|
|
47
|
-
{ atBlock: 25, marker: "e25" },
|
|
48
|
-
{ atBlock: 50, marker: "e50" },
|
|
49
|
-
{ atBlock: 75, marker: "e75" },
|
|
50
|
-
];
|
|
51
|
-
|
|
52
|
-
const runOnce = async (
|
|
53
|
-
coalesce: boolean,
|
|
54
|
-
apiPort: number,
|
|
55
|
-
): Promise<ConsistencySnapshot> => {
|
|
56
|
-
const h = await setupHarness();
|
|
57
|
-
try {
|
|
58
|
-
await h.runToHeight({
|
|
59
|
-
events,
|
|
60
|
-
tips: { mainClock: 100, parallelP: 200 },
|
|
61
|
-
target: 100,
|
|
62
|
-
apiPort,
|
|
63
|
-
coalesce,
|
|
64
|
-
});
|
|
65
|
-
return await dumpConsistencySnapshot(h.pool);
|
|
66
|
-
} finally {
|
|
67
|
-
await h.teardown();
|
|
68
|
-
}
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
const off = await runOnce(false, 19160);
|
|
72
|
-
const on = await runOnce(true, 19161);
|
|
73
|
-
|
|
74
|
-
const { diffs } = compareConsistencySnapshots(off, on);
|
|
75
|
-
logComparison("[coalesce A] from-scratch sync to height 100:", "coalesce OFF", off, "coalesce ON", on, diffs);
|
|
76
|
-
expect(diffs).toEqual([]);
|
|
77
|
-
},
|
|
78
|
-
120_000,
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
test(
|
|
82
|
-
"a coalesced sync that resumes matches a clean coalesced sync",
|
|
83
|
-
async () => {
|
|
84
|
-
const events = [{ atBlock: 20, marker: "gap20" }];
|
|
85
|
-
const parallelStepSize = 1000;
|
|
86
|
-
const dumpOpts: SnapshotOpts = { excludeTables: SYNC_BOOKKEEPING_TABLES };
|
|
87
|
-
|
|
88
|
-
const ref = await setupHarness();
|
|
89
|
-
let refSnap: ConsistencySnapshot;
|
|
90
|
-
try {
|
|
91
|
-
await ref.runToHeight({
|
|
92
|
-
events,
|
|
93
|
-
parallelStepSize,
|
|
94
|
-
tips: { mainClock: 30, parallelP: 50 },
|
|
95
|
-
target: 30,
|
|
96
|
-
apiPort: 19170,
|
|
97
|
-
coalesce: true,
|
|
98
|
-
});
|
|
99
|
-
refSnap = await dumpConsistencySnapshot(ref.pool, dumpOpts);
|
|
100
|
-
} finally {
|
|
101
|
-
await ref.teardown();
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const resumed = await setupHarness();
|
|
105
|
-
let resumeSnap: ConsistencySnapshot;
|
|
106
|
-
try {
|
|
107
|
-
await resumed.runToHeight({
|
|
108
|
-
events,
|
|
109
|
-
parallelStepSize,
|
|
110
|
-
tips: { mainClock: 10, parallelP: 50 },
|
|
111
|
-
target: 10,
|
|
112
|
-
apiPort: 19171,
|
|
113
|
-
coalesce: true,
|
|
114
|
-
});
|
|
115
|
-
await resumed.runToHeight({
|
|
116
|
-
events,
|
|
117
|
-
parallelStepSize,
|
|
118
|
-
tips: { mainClock: 30, parallelP: 50 },
|
|
119
|
-
target: 30,
|
|
120
|
-
apiPort: 19172,
|
|
121
|
-
coalesce: true,
|
|
122
|
-
});
|
|
123
|
-
resumeSnap = await dumpConsistencySnapshot(resumed.pool, dumpOpts);
|
|
124
|
-
} finally {
|
|
125
|
-
await resumed.teardown();
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const { diffs } = compareConsistencySnapshots(refSnap, resumeSnap);
|
|
129
|
-
logComparison("[coalesce B] clean vs resumed coalesced sync to height 30:", "clean ref", refSnap, "resumed", resumeSnap, diffs);
|
|
130
|
-
expect(diffs).toEqual([]);
|
|
131
|
-
},
|
|
132
|
-
120_000,
|
|
133
|
-
);
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* In-memory DB *content fingerprints* for test comparison.
|
|
3
|
-
*
|
|
4
|
-
* NOT to be confused with `@effectstream/db`'s snapshot-handler.ts, which does
|
|
5
|
-
* `pg_dump`-style snapshots for retention/recovery. These snapshots never touch
|
|
6
|
-
* disk: they hash every row of every table so two databases can be compared
|
|
7
|
-
* table-by-table, row-by-row (a file hash of the on-disk data dir is useless —
|
|
8
|
-
* the physical layout churns with background-writer / vacuum activity and is
|
|
9
|
-
* not a pure function of the committed rows).
|
|
10
|
-
*
|
|
11
|
-
* Used by consistency.test.ts to assert the engine builds the *same* database
|
|
12
|
-
* regardless of how it got there (one clean run, or stop/restart).
|
|
13
|
-
*/
|
|
14
|
-
import { createHash } from "node:crypto";
|
|
15
|
-
import type pg from "pg";
|
|
16
|
-
|
|
17
|
-
const md5 = (s: string) => createHash("md5").update(s).digest("hex");
|
|
18
|
-
|
|
19
|
-
const DEFAULT_SCHEMAS = ["effectstream", "public", "primitives"];
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* `rollup_input_result.effectstream_tx_hash` is filled from `Math.random()`
|
|
23
|
-
* (runtime/src/process-blocks.ts), so it differs between two otherwise-identical
|
|
24
|
-
* runs. It is only written for scheduled STF inputs (never on the noop-STF test
|
|
25
|
-
* path), so excluding it is a no-op for the current tests — but it keeps the
|
|
26
|
-
* helper correct if reused against a config that runs real STFs.
|
|
27
|
-
*/
|
|
28
|
-
export const VOLATILE_COLUMNS = ["effectstream_tx_hash"];
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Sync-internal bookkeeping. The page queue legitimately differs after a
|
|
32
|
-
* restart even in a correct engine, and the version/migration/config-snapshot
|
|
33
|
-
* tables are engine plumbing rather than observable app state. Excluded when
|
|
34
|
-
* asserting "the resumed DB reaches the same *state* as a clean sync".
|
|
35
|
-
*/
|
|
36
|
-
export const SYNC_BOOKKEEPING_TABLES = [
|
|
37
|
-
"sync_protocol_pagination",
|
|
38
|
-
"effectstream_version_history",
|
|
39
|
-
"effectstream_expected_version",
|
|
40
|
-
"effectstream_migration_history",
|
|
41
|
-
"sync_protocol_config_snapshot",
|
|
42
|
-
];
|
|
43
|
-
|
|
44
|
-
export type TableFingerprint = {
|
|
45
|
-
rowCount: number;
|
|
46
|
-
/** md5 over the ascending-sorted per-row hashes (order-independent). */
|
|
47
|
-
fingerprint: string;
|
|
48
|
-
/** Ascending-sorted per-row md5s; kept for richer diffs if ever needed. */
|
|
49
|
-
rowHashes: string[];
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/** Keyed by `"<schema>.<table>"`. */
|
|
53
|
-
export type ConsistencySnapshot = Map<string, TableFingerprint>;
|
|
54
|
-
|
|
55
|
-
export type SnapshotOpts = {
|
|
56
|
-
/** Schemas to enumerate. Default: effectstream, public, primitives. */
|
|
57
|
-
schemas?: string[];
|
|
58
|
-
/** Bare table names (any schema) to skip entirely. */
|
|
59
|
-
excludeTables?: string[];
|
|
60
|
-
/** Column names to drop before hashing rows (merged with VOLATILE_COLUMNS). */
|
|
61
|
-
excludeColumns?: string[];
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
const quoteIdent = (id: string) => `"${id.replace(/"/g, '""')}"`;
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Hash the full content of every base table in the given schemas.
|
|
68
|
-
*
|
|
69
|
-
* Each row is hashed as `md5(concat_ws(',', quote_nullable(col::text), …))` over
|
|
70
|
-
* its included columns. Each column is cast to text individually (bytea → `\x…`
|
|
71
|
-
* hex, json/jsonb → canonical text), wrapped in `quote_nullable` so a SQL NULL
|
|
72
|
-
* (rendered as the bare token `NULL`) is distinguishable from the literal string
|
|
73
|
-
* `'NULL'`, and joined with a separator — deterministic given identical inserts.
|
|
74
|
-
* Rows are sorted by their hash before folding into the table fingerprint, so
|
|
75
|
-
* insertion order / serial id ordering does not matter.
|
|
76
|
-
*
|
|
77
|
-
* NB: we hash per-column rather than via a composite `ROW(<cols>)::text` cast so
|
|
78
|
-
* NULL handling is explicit — `quote_nullable` renders a SQL NULL as the bare
|
|
79
|
-
* token `NULL`, distinct from the literal string `'NULL'`, which the composite
|
|
80
|
-
* cast conflates. Per-column casts also keep the rendering of each scalar type
|
|
81
|
-
* (bytea → `\x…` hex, json/jsonb → canonical text) under our direct control.
|
|
82
|
-
*/
|
|
83
|
-
export async function dumpConsistencySnapshot(
|
|
84
|
-
pool: pg.Pool,
|
|
85
|
-
opts: SnapshotOpts = {},
|
|
86
|
-
): Promise<ConsistencySnapshot> {
|
|
87
|
-
const schemas = opts.schemas ?? DEFAULT_SCHEMAS;
|
|
88
|
-
const excludeTables = new Set(opts.excludeTables ?? []);
|
|
89
|
-
const excludeColumns = new Set([
|
|
90
|
-
...VOLATILE_COLUMNS,
|
|
91
|
-
...(opts.excludeColumns ?? []),
|
|
92
|
-
]);
|
|
93
|
-
|
|
94
|
-
const tablesRes = await pool.query<{ table_schema: string; table_name: string }>(
|
|
95
|
-
`SELECT table_schema, table_name
|
|
96
|
-
FROM information_schema.tables
|
|
97
|
-
WHERE table_schema = ANY($1)
|
|
98
|
-
AND table_type = 'BASE TABLE'
|
|
99
|
-
ORDER BY table_schema, table_name`,
|
|
100
|
-
[schemas],
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
const snapshot: ConsistencySnapshot = new Map();
|
|
104
|
-
for (const { table_schema, table_name } of tablesRes.rows) {
|
|
105
|
-
if (excludeTables.has(table_name)) continue;
|
|
106
|
-
|
|
107
|
-
const colsRes = await pool.query<{ column_name: string }>(
|
|
108
|
-
`SELECT column_name
|
|
109
|
-
FROM information_schema.columns
|
|
110
|
-
WHERE table_schema = $1 AND table_name = $2
|
|
111
|
-
ORDER BY ordinal_position`,
|
|
112
|
-
[table_schema, table_name],
|
|
113
|
-
);
|
|
114
|
-
const cols = colsRes.rows
|
|
115
|
-
.map((r) => r.column_name)
|
|
116
|
-
.filter((c) => !excludeColumns.has(c));
|
|
117
|
-
if (cols.length === 0) continue;
|
|
118
|
-
|
|
119
|
-
const rowExpr = `concat_ws(',', ${
|
|
120
|
-
cols.map((c) => `quote_nullable(${quoteIdent(c)}::text)`).join(", ")
|
|
121
|
-
})`;
|
|
122
|
-
const rowsRes = await pool.query<{ h: string }>(
|
|
123
|
-
`SELECT md5(${rowExpr}) AS h
|
|
124
|
-
FROM ${quoteIdent(table_schema)}.${quoteIdent(table_name)}
|
|
125
|
-
ORDER BY h`,
|
|
126
|
-
);
|
|
127
|
-
const rowHashes = rowsRes.rows.map((r) => r.h);
|
|
128
|
-
snapshot.set(`${table_schema}.${table_name}`, {
|
|
129
|
-
rowCount: rowHashes.length,
|
|
130
|
-
fingerprint: md5(rowHashes.join("")),
|
|
131
|
-
rowHashes,
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
return snapshot;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export type SnapshotDiff = {
|
|
138
|
-
table: string;
|
|
139
|
-
reason: "only-in-a" | "only-in-b" | "row-count" | "content";
|
|
140
|
-
aRows: number | null;
|
|
141
|
-
bRows: number | null;
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
/** Compare two snapshots table-by-table. `equal` ⇔ `diffs` is empty. */
|
|
145
|
-
export function compareConsistencySnapshots(
|
|
146
|
-
a: ConsistencySnapshot,
|
|
147
|
-
b: ConsistencySnapshot,
|
|
148
|
-
): { equal: boolean; diffs: SnapshotDiff[] } {
|
|
149
|
-
const diffs: SnapshotDiff[] = [];
|
|
150
|
-
const tables = [...new Set([...a.keys(), ...b.keys()])].sort();
|
|
151
|
-
for (const table of tables) {
|
|
152
|
-
const fa = a.get(table);
|
|
153
|
-
const fb = b.get(table);
|
|
154
|
-
if (fa && !fb) {
|
|
155
|
-
diffs.push({ table, reason: "only-in-a", aRows: fa.rowCount, bRows: null });
|
|
156
|
-
} else if (!fa && fb) {
|
|
157
|
-
diffs.push({ table, reason: "only-in-b", aRows: null, bRows: fb.rowCount });
|
|
158
|
-
} else if (fa && fb && fa.fingerprint !== fb.fingerprint) {
|
|
159
|
-
diffs.push({
|
|
160
|
-
table,
|
|
161
|
-
reason: fa.rowCount !== fb.rowCount ? "row-count" : "content",
|
|
162
|
-
aRows: fa.rowCount,
|
|
163
|
-
bRows: fb.rowCount,
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
return { equal: diffs.length === 0, diffs };
|
|
168
|
-
}
|