@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,197 +0,0 @@
|
|
|
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
|
-
);
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One-node subprocess entry point.
|
|
3
|
-
*
|
|
4
|
-
* A real engine restart is a new OS process whose in-memory state (notably the
|
|
5
|
-
* sync Deque) is gone while PostgreSQL keeps every committed row. We model that
|
|
6
|
-
* literally: each node runs here, in its own `bun` process, against a database
|
|
7
|
-
* the parent harness created. Booting the runtime twice *in one process* is not
|
|
8
|
-
* how production works and proved unreliable (a second in-process `start()`
|
|
9
|
-
* stalls the sync loops under `bun test`), so the harness spawns this instead.
|
|
10
|
-
*
|
|
11
|
-
* Invocation (by the harness):
|
|
12
|
-
* bun node-runner.ts '<json-RunSpec>'
|
|
13
|
-
* with DB_* / PGLITE env already set (by the harness) to point at the target
|
|
14
|
-
* database — a real Postgres server or a PGLite gateway.
|
|
15
|
-
*
|
|
16
|
-
* Exit 0 once the node has synced to the requested height (and, for the resume
|
|
17
|
-
* scenario, the requested page watermarks); exit 1 on boot failure.
|
|
18
|
-
*/
|
|
19
|
-
import pg from "pg";
|
|
20
|
-
import { run, type Task } from "effection";
|
|
21
|
-
import {
|
|
22
|
-
toSyncProtocolWithNetwork,
|
|
23
|
-
withEffectstreamStaticConfig,
|
|
24
|
-
} from "@effectstream/config";
|
|
25
|
-
import { TestChainControl } from "@effectstream/sync";
|
|
26
|
-
import { init, start } from "../../src/main.ts";
|
|
27
|
-
import type { StartConfigGameStateTransitions } from "../../src/types.ts";
|
|
28
|
-
import {
|
|
29
|
-
buildConfig,
|
|
30
|
-
type EventSpec,
|
|
31
|
-
TEST_PRIMITIVE_TYPE,
|
|
32
|
-
TestEventPrimitive,
|
|
33
|
-
} from "./scenario.ts";
|
|
34
|
-
import { waitForHeight, waitForPage, waitUntilStable } from "./poll.ts";
|
|
35
|
-
|
|
36
|
-
export type RunSpec = {
|
|
37
|
-
securityNamespace: string;
|
|
38
|
-
events: EventSpec[];
|
|
39
|
-
parallelStepSize?: number;
|
|
40
|
-
/** Manual chain tips, e.g. { mainClock: 100, parallelP: 200 }. */
|
|
41
|
-
tips: Record<string, number>;
|
|
42
|
-
/** Finalized height to reach before exiting. */
|
|
43
|
-
target: number;
|
|
44
|
-
apiPort: number;
|
|
45
|
-
/**
|
|
46
|
-
* Optional per-protocol page watermarks to also wait for before exiting.
|
|
47
|
-
* Used by the resume scenario to guarantee the parallel fetcher has raced
|
|
48
|
-
* ahead and persisted its page row before we kill the process.
|
|
49
|
-
*/
|
|
50
|
-
waitPages?: Record<string, number>;
|
|
51
|
-
coalesce?: boolean;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const noopStf: StartConfigGameStateTransitions = function* () {};
|
|
55
|
-
|
|
56
|
-
async function main() {
|
|
57
|
-
const spec: RunSpec = JSON.parse(process.argv[2] ?? "{}");
|
|
58
|
-
|
|
59
|
-
// DB_* and PGLITE are set by the harness (real Postgres or a PGLite gateway);
|
|
60
|
-
// do NOT override them here. Only force the bits every run shares: no MQTT
|
|
61
|
-
// broker (it binds fixed ports and isn't freed on halt) and a per-run API port.
|
|
62
|
-
process.env.MQTT_BROKER = "false";
|
|
63
|
-
process.env.EFFECTSTREAM_API_PORT = String(spec.apiPort);
|
|
64
|
-
process.env.EFFECTSTREAM_COALESCE_EMPTY_BLOCKS = spec.coalesce ? "true" : "false";
|
|
65
|
-
const isPglite = process.env.PGLITE === "true";
|
|
66
|
-
|
|
67
|
-
for (const [name, tip] of Object.entries(spec.tips)) {
|
|
68
|
-
TestChainControl.setTip(name, tip);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const pool = new pg.Pool({
|
|
72
|
-
host: process.env.DB_HOST,
|
|
73
|
-
port: Number(process.env.DB_PORT),
|
|
74
|
-
user: process.env.DB_USER,
|
|
75
|
-
database: process.env.DB_NAME,
|
|
76
|
-
password: process.env.DB_PW || undefined,
|
|
77
|
-
// PGLite is a single WASM backend: cap polls at one connection (they're also
|
|
78
|
-
// mutex-serialized against the engine — see poll.ts).
|
|
79
|
-
max: isPglite ? 1 : 10,
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
const cfg = (spec as any).config ?? buildConfig({
|
|
83
|
-
securityNamespace: spec.securityNamespace,
|
|
84
|
-
events: spec.events,
|
|
85
|
-
parallelStepSize: spec.parallelStepSize,
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
let bootErr: unknown;
|
|
89
|
-
let started = false;
|
|
90
|
-
let live: any[] = [];
|
|
91
|
-
const task: Task<unknown> = run(function* () {
|
|
92
|
-
yield* init();
|
|
93
|
-
yield* withEffectstreamStaticConfig(cfg, function* () {
|
|
94
|
-
yield* start({
|
|
95
|
-
appName: "sync-repro",
|
|
96
|
-
appVersion: "1.0.0",
|
|
97
|
-
syncInfo: toSyncProtocolWithNetwork(cfg),
|
|
98
|
-
gameStateTransitions: noopStf,
|
|
99
|
-
userDefinedPrimitives: { [TEST_PRIMITIVE_TYPE]: TestEventPrimitive },
|
|
100
|
-
dev: {
|
|
101
|
-
resetPublicData: false,
|
|
102
|
-
onStarted: ({ syncProtocols }: any) => {
|
|
103
|
-
live = syncProtocols;
|
|
104
|
-
started = true;
|
|
105
|
-
},
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
// start() runs forever; capture a genuine boot failure (not the expected
|
|
111
|
-
// "halted" rejection from our own task.halt() below).
|
|
112
|
-
Promise.resolve(task).catch((e) => {
|
|
113
|
-
if (!String(e).includes("halted")) bootErr = e;
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
const guard = async <T>(p: Promise<T>): Promise<T> => {
|
|
117
|
-
const r = await p;
|
|
118
|
-
if (bootErr) throw new Error(`node boot failed: ${String(bootErr)}`);
|
|
119
|
-
return r;
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
// Don't poll until `onStarted` fires: init()'s migrations and startup() run
|
|
123
|
-
// before it and aren't mutex-guarded, so on PGLite a poll could overlap them
|
|
124
|
-
// and corrupt the single WASM backend. Steady-state queries after this are.
|
|
125
|
-
while (!started) {
|
|
126
|
-
if (bootErr) throw new Error(`node boot failed: ${String(bootErr)}`);
|
|
127
|
-
await new Promise((r) => setTimeout(r, 20));
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
await guard(waitForHeight(pool, spec.target));
|
|
132
|
-
await guard(waitUntilStable(pool));
|
|
133
|
-
for (const [name, page] of Object.entries(spec.waitPages ?? {})) {
|
|
134
|
-
await guard(waitForPage(pool, name, page));
|
|
135
|
-
}
|
|
136
|
-
} catch (e) {
|
|
137
|
-
const probe = live.map((p) =>
|
|
138
|
-
`${p.name}: lastPage=${JSON.stringify(p.lastPage)} buf=${p.bufferedData.size()} errs=${p.consecutiveErrors}`
|
|
139
|
-
);
|
|
140
|
-
console.error("PROBE:\n " + probe.join("\n "));
|
|
141
|
-
throw e;
|
|
142
|
-
} finally {
|
|
143
|
-
await task.halt().catch(() => {});
|
|
144
|
-
await pool.end().catch(() => {});
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
main().then(
|
|
149
|
-
() => process.exit(0),
|
|
150
|
-
(e) => {
|
|
151
|
-
console.error(String(e instanceof Error ? e.stack ?? e.message : e));
|
|
152
|
-
process.exit(1);
|
|
153
|
-
},
|
|
154
|
-
);
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pg.Pool query helpers shared by the test harness (parent process, used for
|
|
3
|
-
* assertions) and {@link ./node-runner.ts} (child process, used to decide when a
|
|
4
|
-
* node has finished syncing). No cluster / runtime imports.
|
|
5
|
-
*
|
|
6
|
-
* On PGLite every query is serialized through the engine's process-global DB
|
|
7
|
-
* mutex — see {@link q}. On a real Postgres server that mutex is a no-op and
|
|
8
|
-
* these are plain pooled queries.
|
|
9
|
-
*/
|
|
10
|
-
import type pg from "pg";
|
|
11
|
-
import { run } from "effection";
|
|
12
|
-
import { ENV } from "@effectstream/utils/node-env";
|
|
13
|
-
import { acquireDBMutex, releaseDBMutex } from "@effectstream/db";
|
|
14
|
-
|
|
15
|
-
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Run a query, serialized through the engine's process-global DB mutex on PGLite
|
|
19
|
-
* (a single WASM backend that two overlapping queries corrupt). node-runner
|
|
20
|
-
* polls while the engine syncs in the SAME process, so taking the same mutex the
|
|
21
|
-
* engine uses guarantees no overlap — they share the module-global lock even
|
|
22
|
-
* across separate `run()` roots. On real Postgres the mutex is a no-op.
|
|
23
|
-
*/
|
|
24
|
-
async function q<T extends pg.QueryResultRow = pg.QueryResultRow>(
|
|
25
|
-
pool: pg.Pool,
|
|
26
|
-
sql: string,
|
|
27
|
-
params?: unknown[],
|
|
28
|
-
): Promise<T[]> {
|
|
29
|
-
if (!ENV.PGLITE) {
|
|
30
|
-
return (await pool.query<T>(sql, params)).rows;
|
|
31
|
-
}
|
|
32
|
-
const lock = "repro-poll";
|
|
33
|
-
await run(() => acquireDBMutex(lock));
|
|
34
|
-
try {
|
|
35
|
-
return (await pool.query<T>(sql, params)).rows;
|
|
36
|
-
} finally {
|
|
37
|
-
releaseDBMutex(lock);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Latest finalized effectstream block height (incl. empty blocks). 0 if none. */
|
|
42
|
-
export async function latestFinalizedHeight(pool: pg.Pool): Promise<number> {
|
|
43
|
-
try {
|
|
44
|
-
const rows = await q<{ h: number | null }>(
|
|
45
|
-
pool,
|
|
46
|
-
`SELECT MAX(block_height)::int AS h FROM effectstream.effectstream_blocks
|
|
47
|
-
WHERE effectstream_block_hash IS NOT NULL`,
|
|
48
|
-
);
|
|
49
|
-
return rows[0]?.h ?? 0;
|
|
50
|
-
} catch (e) {
|
|
51
|
-
// The system migrations may not have created the table yet during boot.
|
|
52
|
-
if ((e as { code?: string })?.code === "42P01") return 0;
|
|
53
|
-
throw e;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Highest persisted page-queue number for a protocol (-1 if none yet). */
|
|
58
|
-
export async function maxPage(
|
|
59
|
-
pool: pg.Pool,
|
|
60
|
-
protocolName: string,
|
|
61
|
-
): Promise<number> {
|
|
62
|
-
try {
|
|
63
|
-
const rows = await q<{ p: number | null }>(
|
|
64
|
-
pool,
|
|
65
|
-
`SELECT MAX(page_number)::int AS p FROM effectstream.sync_protocol_pagination
|
|
66
|
-
WHERE protocol_name = $1`,
|
|
67
|
-
[protocolName],
|
|
68
|
-
);
|
|
69
|
-
return rows[0]?.p ?? -1;
|
|
70
|
-
} catch (e) {
|
|
71
|
-
if ((e as { code?: string })?.code === "42P01") return -1;
|
|
72
|
-
throw e;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Wait until finalized height reaches `target` (or throw on timeout). */
|
|
77
|
-
export async function waitForHeight(
|
|
78
|
-
pool: pg.Pool,
|
|
79
|
-
target: number,
|
|
80
|
-
timeoutMs = 30_000,
|
|
81
|
-
): Promise<number> {
|
|
82
|
-
const startedAt = Date.now();
|
|
83
|
-
while (Date.now() - startedAt < timeoutMs) {
|
|
84
|
-
const h = await latestFinalizedHeight(pool);
|
|
85
|
-
if (h >= target) return h;
|
|
86
|
-
await sleep(50);
|
|
87
|
-
}
|
|
88
|
-
throw new Error(
|
|
89
|
-
`waitForHeight(${target}) timed out at ${await latestFinalizedHeight(pool)}`,
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Wait until the finalized height stops advancing for `quietMs` (the system has
|
|
95
|
-
* reached its deterministic stable state given fixed tips).
|
|
96
|
-
*/
|
|
97
|
-
export async function waitUntilStable(
|
|
98
|
-
pool: pg.Pool,
|
|
99
|
-
{ quietMs = 800, timeoutMs = 30_000 } = {},
|
|
100
|
-
): Promise<number> {
|
|
101
|
-
const startedAt = Date.now();
|
|
102
|
-
let last = -1;
|
|
103
|
-
let lastChange = Date.now();
|
|
104
|
-
while (Date.now() - startedAt < timeoutMs) {
|
|
105
|
-
const h = await latestFinalizedHeight(pool);
|
|
106
|
-
if (h !== last) {
|
|
107
|
-
last = h;
|
|
108
|
-
lastChange = Date.now();
|
|
109
|
-
} else if (Date.now() - lastChange >= quietMs) {
|
|
110
|
-
return h;
|
|
111
|
-
}
|
|
112
|
-
await sleep(50);
|
|
113
|
-
}
|
|
114
|
-
return last;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Wait until a protocol's persisted page reaches `target` (or throw). */
|
|
118
|
-
export async function waitForPage(
|
|
119
|
-
pool: pg.Pool,
|
|
120
|
-
protocolName: string,
|
|
121
|
-
target: number,
|
|
122
|
-
timeoutMs = 30_000,
|
|
123
|
-
): Promise<number> {
|
|
124
|
-
const startedAt = Date.now();
|
|
125
|
-
while (Date.now() - startedAt < timeoutMs) {
|
|
126
|
-
const p = await maxPage(pool, protocolName);
|
|
127
|
-
if (p >= target) return p;
|
|
128
|
-
await sleep(50);
|
|
129
|
-
}
|
|
130
|
-
throw new Error(
|
|
131
|
-
`waitForPage(${protocolName}, ${target}) timed out at ${await maxPage(pool, protocolName)}`,
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** Count primitive_accounting rows whose payload carries the given marker. */
|
|
136
|
-
export async function countEventRows(
|
|
137
|
-
pool: pg.Pool,
|
|
138
|
-
marker: string,
|
|
139
|
-
): Promise<number> {
|
|
140
|
-
const rows = await q<{ c: number | null }>(
|
|
141
|
-
pool,
|
|
142
|
-
`SELECT COUNT(*)::int AS c FROM effectstream.primitive_accounting
|
|
143
|
-
WHERE payload->>'marker' = $1`,
|
|
144
|
-
[marker],
|
|
145
|
-
);
|
|
146
|
-
return rows[0]?.c ?? 0;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Page-queue rows for a protocol (the durable per-chunk queue). */
|
|
150
|
-
export async function pageRows(
|
|
151
|
-
pool: pg.Pool,
|
|
152
|
-
protocolName: string,
|
|
153
|
-
): Promise<{ page_number: number }[]> {
|
|
154
|
-
return await q<{ page_number: number }>(
|
|
155
|
-
pool,
|
|
156
|
-
`SELECT page_number FROM effectstream.sync_protocol_pagination
|
|
157
|
-
WHERE protocol_name = $1 ORDER BY page_number ASC`,
|
|
158
|
-
[protocolName],
|
|
159
|
-
);
|
|
160
|
-
}
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Resume-marker position over the synthetic `test` chain.
|
|
3
|
-
*
|
|
4
|
-
* Companion to consistency.test.ts (which proves a mid-chunk restart re-fetches
|
|
5
|
-
* uncommitted data instead of skipping it). This file pins down *where* the
|
|
6
|
-
* persisted resume marker lands for a chain whose data is sparse — the property
|
|
7
|
-
* that bounds restart re-scan (see @effectstream/sync CLAUDE.md, design idea #5).
|
|
8
|
-
*
|
|
9
|
-
* The marker is the highest datum merged into the committed block, and because
|
|
10
|
-
* each fetched chunk's boundary page is buffered and consumed even when empty, it
|
|
11
|
-
* tracks the **committed frontier** — NOT the last block that happened to carry
|
|
12
|
-
* data. So a chain that produced one event long ago and has been quiet since
|
|
13
|
-
* still resumes near the frontier, re-fetching at most ~stepSize blocks, never
|
|
14
|
-
* its whole quiet tail. Here: data lives only at block 5, yet the marker must sit
|
|
15
|
-
* at the frontier (height 50 / 60), not stuck back at 5.
|
|
16
|
-
*/
|
|
17
|
-
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
18
|
-
import {
|
|
19
|
-
countEventRows,
|
|
20
|
-
type Harness,
|
|
21
|
-
latestFinalizedHeight,
|
|
22
|
-
maxPage,
|
|
23
|
-
setupHarness,
|
|
24
|
-
} from "./harness.ts";
|
|
25
|
-
|
|
26
|
-
let h: Harness;
|
|
27
|
-
|
|
28
|
-
beforeAll(async () => {
|
|
29
|
-
h = await setupHarness();
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
afterAll(async () => {
|
|
33
|
-
await h?.teardown();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test(
|
|
37
|
-
"a sparse chain's resume marker tracks the frontier, not its last data block",
|
|
38
|
-
async () => {
|
|
39
|
-
// Data lives ONLY at parallel block 5; everything after is quiet. Block 5 is
|
|
40
|
-
// the marker a naive "resume from last data" scheme would persist.
|
|
41
|
-
const events = [{ atBlock: 5, marker: "evt5" }];
|
|
42
|
-
|
|
43
|
-
// Phase 1 — sync the main clock to 50 (parallel tip stays ahead so the merge
|
|
44
|
-
// can finalize). The block-5 event is merged early; the chain is quiet after.
|
|
45
|
-
await h.runToHeight({
|
|
46
|
-
events,
|
|
47
|
-
tips: { mainClock: 50, parallelP: 200 },
|
|
48
|
-
target: 50,
|
|
49
|
-
apiPort: 19161,
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
expect(await latestFinalizedHeight(h.pool)).toBe(50);
|
|
53
|
-
expect(await countEventRows(h.pool, "evt5")).toBe(1);
|
|
54
|
-
|
|
55
|
-
// The crux: the parallel marker tracks the committed frontier (50), NOT the
|
|
56
|
-
// last data block (5). A restart therefore resumes from ~51 and re-scans only
|
|
57
|
-
// the bounded gap up to the frontier — never blocks 6..50 from scratch.
|
|
58
|
-
expect(await maxPage(h.pool, "parallelP")).toBe(50);
|
|
59
|
-
expect(await maxPage(h.pool, "parallelP")).not.toBe(5);
|
|
60
|
-
|
|
61
|
-
// Phase 2 (restart) — boot a fresh process against the same DB and sync on to
|
|
62
|
-
// 60. The parallel chain resumes from the frontier (51), not from block 5.
|
|
63
|
-
await h.runToHeight({
|
|
64
|
-
events,
|
|
65
|
-
tips: { mainClock: 60, parallelP: 200 },
|
|
66
|
-
target: 60,
|
|
67
|
-
apiPort: 19162,
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
expect(await latestFinalizedHeight(h.pool)).toBe(60);
|
|
71
|
-
// Marker advanced with the new frontier...
|
|
72
|
-
expect(await maxPage(h.pool, "parallelP")).toBe(60);
|
|
73
|
-
// ...and block 5 was neither re-fetched (resume started past it) nor lost.
|
|
74
|
-
expect(await countEventRows(h.pool, "evt5")).toBe(1);
|
|
75
|
-
},
|
|
76
|
-
120_000,
|
|
77
|
-
);
|