@effectstream/runtime 0.100.19 → 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/package.json +13 -13
- package/src/api/http-server.ts +7 -55
- package/src/main.ts +38 -32
- package/src/process-blocks.ts +27 -126
- package/src/types.ts +0 -7
- package/test/reproduction/harness.ts +270 -556
- package/test/reproduction/sanity.test.ts +91 -40
- package/conciseinputhash-determinism.md +0 -114
- package/src/api/apply-status.ts +0 -30
- package/test/reproduction/buffering.test.ts +0 -389
- package/test/reproduction/consistency-snapshot.ts +0 -168
- package/test/reproduction/consistency.test.ts +0 -197
- package/test/reproduction/node-runner.ts +0 -152
- package/test/reproduction/poll.ts +0 -160
- package/test/reproduction/resume.test.ts +0 -77
- package/test/reproduction/scenario.ts +0 -126
|
@@ -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
|
-
}
|
|
@@ -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,152 +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
|
-
};
|
|
52
|
-
|
|
53
|
-
const noopStf: StartConfigGameStateTransitions = function* () {};
|
|
54
|
-
|
|
55
|
-
async function main() {
|
|
56
|
-
const spec: RunSpec = JSON.parse(process.argv[2] ?? "{}");
|
|
57
|
-
|
|
58
|
-
// DB_* and PGLITE are set by the harness (real Postgres or a PGLite gateway);
|
|
59
|
-
// do NOT override them here. Only force the bits every run shares: no MQTT
|
|
60
|
-
// broker (it binds fixed ports and isn't freed on halt) and a per-run API port.
|
|
61
|
-
process.env.MQTT_BROKER = "false";
|
|
62
|
-
process.env.EFFECTSTREAM_API_PORT = String(spec.apiPort);
|
|
63
|
-
const isPglite = process.env.PGLITE === "true";
|
|
64
|
-
|
|
65
|
-
for (const [name, tip] of Object.entries(spec.tips)) {
|
|
66
|
-
TestChainControl.setTip(name, tip);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const pool = new pg.Pool({
|
|
70
|
-
host: process.env.DB_HOST,
|
|
71
|
-
port: Number(process.env.DB_PORT),
|
|
72
|
-
user: process.env.DB_USER,
|
|
73
|
-
database: process.env.DB_NAME,
|
|
74
|
-
password: process.env.DB_PW || undefined,
|
|
75
|
-
// PGLite is a single WASM backend: cap polls at one connection (they're also
|
|
76
|
-
// mutex-serialized against the engine — see poll.ts).
|
|
77
|
-
max: isPglite ? 1 : 10,
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
const cfg = (spec as any).config ?? buildConfig({
|
|
81
|
-
securityNamespace: spec.securityNamespace,
|
|
82
|
-
events: spec.events,
|
|
83
|
-
parallelStepSize: spec.parallelStepSize,
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
let bootErr: unknown;
|
|
87
|
-
let started = false;
|
|
88
|
-
let live: any[] = [];
|
|
89
|
-
const task: Task<unknown> = run(function* () {
|
|
90
|
-
yield* init();
|
|
91
|
-
yield* withEffectstreamStaticConfig(cfg, function* () {
|
|
92
|
-
yield* start({
|
|
93
|
-
appName: "sync-repro",
|
|
94
|
-
appVersion: "1.0.0",
|
|
95
|
-
syncInfo: toSyncProtocolWithNetwork(cfg),
|
|
96
|
-
gameStateTransitions: noopStf,
|
|
97
|
-
userDefinedPrimitives: { [TEST_PRIMITIVE_TYPE]: TestEventPrimitive },
|
|
98
|
-
dev: {
|
|
99
|
-
resetPublicData: false,
|
|
100
|
-
onStarted: ({ syncProtocols }: any) => {
|
|
101
|
-
live = syncProtocols;
|
|
102
|
-
started = true;
|
|
103
|
-
},
|
|
104
|
-
},
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
// start() runs forever; capture a genuine boot failure (not the expected
|
|
109
|
-
// "halted" rejection from our own task.halt() below).
|
|
110
|
-
Promise.resolve(task).catch((e) => {
|
|
111
|
-
if (!String(e).includes("halted")) bootErr = e;
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
const guard = async <T>(p: Promise<T>): Promise<T> => {
|
|
115
|
-
const r = await p;
|
|
116
|
-
if (bootErr) throw new Error(`node boot failed: ${String(bootErr)}`);
|
|
117
|
-
return r;
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
// Don't poll until `onStarted` fires: init()'s migrations and startup() run
|
|
121
|
-
// before it and aren't mutex-guarded, so on PGLite a poll could overlap them
|
|
122
|
-
// and corrupt the single WASM backend. Steady-state queries after this are.
|
|
123
|
-
while (!started) {
|
|
124
|
-
if (bootErr) throw new Error(`node boot failed: ${String(bootErr)}`);
|
|
125
|
-
await new Promise((r) => setTimeout(r, 20));
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
try {
|
|
129
|
-
await guard(waitForHeight(pool, spec.target));
|
|
130
|
-
await guard(waitUntilStable(pool));
|
|
131
|
-
for (const [name, page] of Object.entries(spec.waitPages ?? {})) {
|
|
132
|
-
await guard(waitForPage(pool, name, page));
|
|
133
|
-
}
|
|
134
|
-
} catch (e) {
|
|
135
|
-
const probe = live.map((p) =>
|
|
136
|
-
`${p.name}: lastPage=${JSON.stringify(p.lastPage)} buf=${p.bufferedData.size()} errs=${p.consecutiveErrors}`
|
|
137
|
-
);
|
|
138
|
-
console.error("PROBE:\n " + probe.join("\n "));
|
|
139
|
-
throw e;
|
|
140
|
-
} finally {
|
|
141
|
-
await task.halt().catch(() => {});
|
|
142
|
-
await pool.end().catch(() => {});
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
main().then(
|
|
147
|
-
() => process.exit(0),
|
|
148
|
-
(e) => {
|
|
149
|
-
console.error(String(e instanceof Error ? e.stack ?? e.message : e));
|
|
150
|
-
process.exit(1);
|
|
151
|
-
},
|
|
152
|
-
);
|
|
@@ -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
|
-
}
|