@effectstream/runtime 0.100.22 → 0.100.23
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 +34 -0
- 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 +68 -7
- package/src/api/stream-status.ts +35 -0
- package/src/coalesce.ts +291 -0
- package/src/finalized-stream.ts +73 -0
- package/src/main.ts +111 -89
- package/src/process-blocks.ts +126 -27
- package/src/types.ts +7 -0
- package/test/coalesce.unit.test.ts +69 -0
- package/test/reproduction/buffering.test.ts +650 -0
- package/test/reproduction/coalesce-memory.test.ts +181 -0
- package/test/reproduction/coalesce.test.ts +133 -0
- package/test/reproduction/consistency-snapshot.ts +168 -0
- package/test/reproduction/consistency.test.ts +197 -0
- package/test/reproduction/harness.ts +574 -270
- package/test/reproduction/node-runner.ts +154 -0
- package/test/reproduction/poll.ts +160 -0
- package/test/reproduction/resume.test.ts +77 -0
- package/test/reproduction/sanity.test.ts +40 -91
- package/test/reproduction/scenario.ts +126 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory / queue-depth characterization for empty-block coalescing during a deep
|
|
3
|
+
* catch-up (sync/CLAUDE.md Finding #1 + Fix E).
|
|
4
|
+
*
|
|
5
|
+
* Coalescing is a consumer-side speedup: folding empty catch-up blocks removes the
|
|
6
|
+
* per-block STF + DB-commit bottleneck that otherwise acts as accidental
|
|
7
|
+
* backpressure. The worry is that, with that brake gone, the fetchers race to tip
|
|
8
|
+
* and either (a) balloon the uncapped `finalizedBlockStream` subscriber queue or
|
|
9
|
+
* (b) keep a parallel chain in the cap-lifted merge-demand exemption long enough to
|
|
10
|
+
* grow its Deque toward the whole backlog.
|
|
11
|
+
*
|
|
12
|
+
* This test drives a deep, all-empty catch-up with coalescing ON and asserts hard
|
|
13
|
+
* ceilings on the stream backlog (`inFlight`) and per-protocol buffers, plus a soft
|
|
14
|
+
* memory guard and a liveness check. Green → coalescing keeps everything bounded
|
|
15
|
+
* (regression guard). Red on the backlog/buffer bound → reproduces the leak, and the
|
|
16
|
+
* captured peak series localizes it.
|
|
17
|
+
*
|
|
18
|
+
* Postgres-only (chosen backend): higher-fidelity apply throughput than PGLite.
|
|
19
|
+
* Skips when no Postgres binary is on PATH.
|
|
20
|
+
*/
|
|
21
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
22
|
+
import {
|
|
23
|
+
ConfigBuilder,
|
|
24
|
+
ConfigNetworkType,
|
|
25
|
+
ConfigSyncProtocolType,
|
|
26
|
+
} from "@effectstream/config";
|
|
27
|
+
import { TestChainControl } from "@effectstream/sync";
|
|
28
|
+
import {
|
|
29
|
+
bufferSize,
|
|
30
|
+
type Harness,
|
|
31
|
+
latestFinalizedHeight,
|
|
32
|
+
postgresAvailable,
|
|
33
|
+
type RunningNode,
|
|
34
|
+
setupHarness,
|
|
35
|
+
sleep,
|
|
36
|
+
TEST_PRIMITIVE_TYPE,
|
|
37
|
+
} from "./harness.ts";
|
|
38
|
+
|
|
39
|
+
const RUN = postgresAvailable();
|
|
40
|
+
|
|
41
|
+
const START_TIME = 1_700_000_000_000; // ~Nov 2023: every catch-up block is "old"
|
|
42
|
+
const MAIN_BLOCK_MS = 1000;
|
|
43
|
+
const PAR_BLOCK_MS = 100; // 10 parallel blocks per root slot
|
|
44
|
+
const STEP_SIZE = 1000;
|
|
45
|
+
const CAP = 3000;
|
|
46
|
+
|
|
47
|
+
// A deep backlog: ~15k root slots the one-block-per-txn merge could never drain to
|
|
48
|
+
// in time, so without coalescing the node would crawl — and any unbounded structure
|
|
49
|
+
// would grow toward the whole backlog. Coalescing must fold the empties and keep
|
|
50
|
+
// memory flat.
|
|
51
|
+
const MAIN_TIP = 15_000;
|
|
52
|
+
const PAR_TIP = MAIN_TIP * (MAIN_BLOCK_MS / PAR_BLOCK_MS); // cover the same wall range
|
|
53
|
+
|
|
54
|
+
// Buffer settles in [~CAP, CAP + stepSize]; allow one overshoot chunk + slack.
|
|
55
|
+
const BUFFER_BOUND = CAP + STEP_SIZE + 200;
|
|
56
|
+
// The stream backlog must stay far below the whole-backlog (~MAIN_TIP) failure mode.
|
|
57
|
+
// A healthy fast consumer keeps this in the low hundreds; we allow generous slack.
|
|
58
|
+
const QUEUE_BOUND = 5_000;
|
|
59
|
+
// Liveness: the node must catch up essentially to the tip (no deadlock / no crawl).
|
|
60
|
+
const LIVE_TARGET = Math.floor(MAIN_TIP * 0.9);
|
|
61
|
+
|
|
62
|
+
const MAIN = "memMainClock";
|
|
63
|
+
const PAR = "memParallelP";
|
|
64
|
+
|
|
65
|
+
function buildConfig() {
|
|
66
|
+
return new ConfigBuilder()
|
|
67
|
+
.setNamespace((b) => b.setSecurityNamespace("sync-coalesce-mem"))
|
|
68
|
+
.buildNetworks((b) =>
|
|
69
|
+
b
|
|
70
|
+
.addNetwork({
|
|
71
|
+
name: "clock",
|
|
72
|
+
type: ConfigNetworkType.TEST,
|
|
73
|
+
startTime: START_TIME,
|
|
74
|
+
blockTimeMS: MAIN_BLOCK_MS,
|
|
75
|
+
})
|
|
76
|
+
.addNetwork({
|
|
77
|
+
name: "chainP",
|
|
78
|
+
type: ConfigNetworkType.TEST,
|
|
79
|
+
startTime: START_TIME,
|
|
80
|
+
blockTimeMS: PAR_BLOCK_MS,
|
|
81
|
+
})
|
|
82
|
+
)
|
|
83
|
+
.buildDeployments((b) => b)
|
|
84
|
+
.buildSyncProtocols((b) =>
|
|
85
|
+
b
|
|
86
|
+
.addMain(
|
|
87
|
+
(n) => n.clock,
|
|
88
|
+
() => ({
|
|
89
|
+
name: MAIN,
|
|
90
|
+
type: ConfigSyncProtocolType.TEST_MAIN,
|
|
91
|
+
startBlockHeight: 1,
|
|
92
|
+
pollingInterval: 30,
|
|
93
|
+
stepSize: STEP_SIZE,
|
|
94
|
+
maxBufferedPages: CAP,
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
.addParallel(
|
|
98
|
+
(n) => (n as any).chainP,
|
|
99
|
+
() => ({
|
|
100
|
+
name: PAR,
|
|
101
|
+
type: ConfigSyncProtocolType.TEST_PARALLEL,
|
|
102
|
+
startBlockHeight: 1,
|
|
103
|
+
pollingInterval: 30,
|
|
104
|
+
stepSize: STEP_SIZE,
|
|
105
|
+
delayMs: 0,
|
|
106
|
+
events: [], // no primitives → every block is coalescable
|
|
107
|
+
maxBufferedPages: CAP,
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
.buildPrimitives((b) =>
|
|
112
|
+
b.addPrimitive(
|
|
113
|
+
(sp) => (sp as any)[PAR],
|
|
114
|
+
() => ({ name: "noEvtMem", type: TEST_PRIMITIVE_TYPE, startBlockHeight: 1 }),
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
.build();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let h: Harness;
|
|
121
|
+
beforeEach(async () => {
|
|
122
|
+
if (!RUN) return;
|
|
123
|
+
h = await setupHarness({ backend: "postgres" });
|
|
124
|
+
});
|
|
125
|
+
afterEach(async () => {
|
|
126
|
+
if (!RUN) return;
|
|
127
|
+
await h?.teardown();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("coalescing keeps stream backlog & buffers bounded during a deep empty catch-up", async () => {
|
|
131
|
+
if (!RUN) {
|
|
132
|
+
console.warn("coalesce-memory: skipped (no Postgres binary on PATH)");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
TestChainControl.setTip(MAIN, MAIN_TIP);
|
|
137
|
+
TestChainControl.setTip(PAR, PAR_TIP);
|
|
138
|
+
|
|
139
|
+
const node: RunningNode = await h.runNode({
|
|
140
|
+
config: buildConfig(),
|
|
141
|
+
apiPort: 19181,
|
|
142
|
+
coalesce: true,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
let peakMain = 0;
|
|
146
|
+
let peakPar = 0;
|
|
147
|
+
let peakInFlight = 0;
|
|
148
|
+
let lastCoalesced = 0;
|
|
149
|
+
let lastApplied = 0;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const startMs = Date.now();
|
|
153
|
+
const deadline = startMs + 110_000;
|
|
154
|
+
while (Date.now() < deadline) {
|
|
155
|
+
peakMain = Math.max(peakMain, bufferSize(node, MAIN));
|
|
156
|
+
peakPar = Math.max(peakPar, bufferSize(node, PAR));
|
|
157
|
+
peakInFlight = Math.max(peakInFlight, node.inFlight);
|
|
158
|
+
lastCoalesced = node.coalescedBlocks;
|
|
159
|
+
lastApplied = (await latestFinalizedHeight(h.pool)) ?? lastApplied;
|
|
160
|
+
if (lastApplied >= LIVE_TARGET) break; // caught up — bounded steady state captured
|
|
161
|
+
await sleep(50);
|
|
162
|
+
}
|
|
163
|
+
} finally {
|
|
164
|
+
console.log(
|
|
165
|
+
`coalesce-memory: peakInFlight=${peakInFlight} mainBuf=${peakMain} ` +
|
|
166
|
+
`parBuf=${peakPar} coalesced=${lastCoalesced} applied=${lastApplied}`,
|
|
167
|
+
);
|
|
168
|
+
await node.stop();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 1. Coalescing actually fired (else this is a no-op test).
|
|
172
|
+
expect(lastCoalesced).toBeGreaterThan(1000);
|
|
173
|
+
// 2. Stream backlog stayed bounded — the merge did NOT outrun the consumer
|
|
174
|
+
// toward the whole backlog (the uncapped subscriber queue, Finding #1). This
|
|
175
|
+
// bounded queue is what keeps memory flat; no separate RSS check is needed.
|
|
176
|
+
expect(peakInFlight).toBeLessThanOrEqual(QUEUE_BOUND);
|
|
177
|
+
// 3. Per-protocol buffers stayed at the cap, even under coalescing's fast consumer.
|
|
178
|
+
expect(Math.max(peakMain, peakPar)).toBeLessThanOrEqual(BUFFER_BOUND);
|
|
179
|
+
// 4. Liveness: the node caught up to the tip (no deadlock / no crawl).
|
|
180
|
+
expect(lastApplied).toBeGreaterThanOrEqual(LIVE_TARGET);
|
|
181
|
+
}, 120_000);
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
);
|
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
}
|
|
@@ -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
|
+
);
|