@effectstream/runtime 0.100.18 → 0.100.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
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
+ );
@@ -0,0 +1,160 @@
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
+ }
@@ -0,0 +1,77 @@
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
+ );
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Sanity end-to-end test for the synthetic `test` chain (no fixes required).
3
+ *
4
+ * Proves the foundation this PR adds works: a TEST_MAIN clock + a TEST_PARALLEL
5
+ * chain sync through the real runtime (`start()`), a config-declared event is
6
+ * processed into `effectstream.primitive_accounting`, and committed state
7
+ * survives a restart.
8
+ *
9
+ * Runs on whichever backend `setupHarness` picks: the default in-memory PGLite
10
+ * (no external dependency) or real Postgres when `PGLITE=false`. Either way
11
+ * each node runs in its own subprocess (see harness.ts / node-runner.ts), so a
12
+ * restart is a genuine new OS process booting against the same database.
13
+ *
14
+ * (The buffering / resume / fast-catchup reproduction tests land alongside their
15
+ * fixes in follow-up PRs — they assert behaviour that only those fixes provide.)
16
+ */
17
+ import { afterAll, beforeAll, expect, test } from "bun:test";
18
+ import {
19
+ countEventRows,
20
+ type Harness,
21
+ latestFinalizedHeight,
22
+ setupHarness,
23
+ } from "./harness.ts";
24
+
25
+ let h: Harness;
26
+
27
+ beforeAll(async () => {
28
+ h = await setupHarness();
29
+ });
30
+
31
+ afterAll(async () => {
32
+ await h?.teardown();
33
+ });
34
+
35
+ test("test chain syncs, processes a configured event, and survives restart", async () => {
36
+ const events = [{ atBlock: 50, marker: "evt50" }];
37
+
38
+ // Parallel tip (200) stays ahead of the main clock (100) so the merge is not
39
+ // gated at the chunk boundary.
40
+ await h.runToHeight({
41
+ events,
42
+ tips: { mainClock: 100, parallelP: 200 },
43
+ target: 100,
44
+ apiPort: 19131,
45
+ });
46
+
47
+ const height1 = await latestFinalizedHeight(h.pool);
48
+ const events1 = await countEventRows(h.pool, "evt50");
49
+ console.log(
50
+ `[sanity] after sync: height ${height1} (expect 100), ` +
51
+ `evt50 rows ${events1} (expect 1)`,
52
+ );
53
+ expect(height1).toBe(100);
54
+ // The event declared at block 50 was processed into primitive_accounting.
55
+ expect(events1).toBe(1);
56
+
57
+ // Restart: a new process boots against the same database. Committed state must
58
+ // persist; the chain is already fully caught up, so nothing new is produced.
59
+ await h.runToHeight({
60
+ events,
61
+ tips: { mainClock: 100, parallelP: 200 },
62
+ target: 100,
63
+ apiPort: 19132,
64
+ });
65
+
66
+ const height2 = await latestFinalizedHeight(h.pool);
67
+ const events2 = await countEventRows(h.pool, "evt50");
68
+ console.log(
69
+ `[sanity] after restart: height ${height2} (expect 100, was ${height1}), ` +
70
+ `evt50 rows ${events2} (expect 1, was ${events1})`,
71
+ );
72
+ expect(height2).toBe(100);
73
+ expect(events2).toBe(1);
74
+ }, 60_000);
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Shared scenario building blocks for the reproduction tests.
3
+ *
4
+ * Both the in-process test files and the out-of-process {@link ./node-runner.ts}
5
+ * subprocess build the *same* synthetic `test`-chain config from these helpers,
6
+ * so a node booted in a child process is identical to one a test would describe.
7
+ * Keeping the config + primitive here (no DB, no cluster imports) lets the
8
+ * subprocess import it cheaply.
9
+ */
10
+ import {
11
+ ConfigBuilder,
12
+ ConfigNetworkType,
13
+ ConfigSyncProtocolType,
14
+ } from "@effectstream/config";
15
+ import { Primitive } from "@effectstream/sm";
16
+
17
+ /** Fixed wall-clock origin so block timestamps are pure arithmetic. */
18
+ export const START_TIME = 1_700_000_000_000;
19
+ export const BLOCK_TIME_MS = 1000;
20
+
21
+ export type EventSpec = { atBlock: number; marker: string };
22
+
23
+ export type ScenarioOpts = {
24
+ events: EventSpec[];
25
+ /** Parallel-chain page size. Large (default 1000) => whole tip is one chunk. */
26
+ parallelStepSize?: number;
27
+ /** Security namespace; distinct per test keeps databases independent. */
28
+ securityNamespace?: string;
29
+ };
30
+
31
+ export function buildConfig(opts: ScenarioOpts) {
32
+ const stepSize = opts.parallelStepSize ?? 1000;
33
+ const namespace = opts.securityNamespace ?? "sync-repro";
34
+ return new ConfigBuilder()
35
+ .setNamespace((b) => b.setSecurityNamespace(namespace))
36
+ .buildNetworks((b) =>
37
+ b
38
+ .addNetwork({
39
+ name: "clock",
40
+ type: ConfigNetworkType.TEST,
41
+ startTime: START_TIME,
42
+ blockTimeMS: BLOCK_TIME_MS,
43
+ })
44
+ .addNetwork({
45
+ name: "chainP",
46
+ type: ConfigNetworkType.TEST,
47
+ startTime: START_TIME,
48
+ blockTimeMS: BLOCK_TIME_MS,
49
+ })
50
+ )
51
+ .buildDeployments((b) => b)
52
+ .buildSyncProtocols((b) =>
53
+ b
54
+ .addMain(
55
+ (n) => n.clock,
56
+ () => ({
57
+ name: "mainClock",
58
+ type: ConfigSyncProtocolType.TEST_MAIN,
59
+ startBlockHeight: 1,
60
+ pollingInterval: 30,
61
+ stepSize: 1000,
62
+ }),
63
+ )
64
+ .addParallel(
65
+ (n) => (n as any).chainP,
66
+ () => ({
67
+ name: "parallelP",
68
+ type: ConfigSyncProtocolType.TEST_PARALLEL,
69
+ startBlockHeight: 1,
70
+ pollingInterval: 30,
71
+ stepSize,
72
+ delayMs: 0,
73
+ events: opts.events.map((e) => ({
74
+ atBlock: e.atBlock,
75
+ payload: { marker: e.marker },
76
+ })),
77
+ }),
78
+ )
79
+ )
80
+ .buildPrimitives((b) =>
81
+ b.addPrimitive(
82
+ (sp) => (sp as any).parallelP,
83
+ () => ({
84
+ name: "testEvt",
85
+ type: TEST_PRIMITIVE_TYPE,
86
+ startBlockHeight: 1,
87
+ }),
88
+ )
89
+ )
90
+ .build();
91
+ }
92
+
93
+ // ── A minimal user-defined primitive ─────────────────────────────────────────
94
+ // Writes one row to effectstream.primitive_accounting per event (no STF needed:
95
+ // stateMachinePayload is null). Tests assert on that table.
96
+ export const TEST_PRIMITIVE_TYPE = "TEST:EVENT";
97
+
98
+ export class TestEventPrimitive extends Primitive<any, any> {
99
+ readonly internalTypeName = TEST_PRIMITIVE_TYPE as any;
100
+ override grammar = [] as any;
101
+ constructor(config: any) {
102
+ super(config);
103
+ }
104
+ // deno-lint-ignore require-yield
105
+ override *getPayload(_height: any, primitiveTransactionData: any): any {
106
+ const payload = primitiveTransactionData.output.payload;
107
+ return {
108
+ isBatched: false,
109
+ data: [
110
+ {
111
+ fromAddressAndType: { type: "none", address: "0x0" },
112
+ accountingPayload: payload,
113
+ stateMachinePayload: null,
114
+ },
115
+ ],
116
+ };
117
+ }
118
+ override getConfig(): any {
119
+ return {
120
+ name: this.instanceName,
121
+ type: this.internalTypeName,
122
+ startBlockHeight: this.startBlockHeight,
123
+ scheduledPrefix: this.stateMachinePrefix,
124
+ };
125
+ }
126
+ }