@effectstream/runtime 0.100.21 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/runtime",
3
- "version": "0.100.21",
3
+ "version": "0.100.23",
4
4
  "license": "MIT",
5
5
  "homepage": "https://effectstream.github.io/docs/",
6
6
  "repository": {
@@ -13,17 +13,17 @@
13
13
  },
14
14
  "exports": "./src/mod.ts",
15
15
  "dependencies": {
16
- "@effectstream/concise": "0.100.21",
17
- "@effectstream/config": "0.100.21",
18
- "@effectstream/coroutine": "0.100.21",
19
- "@effectstream/crypto": "0.100.21",
20
- "@effectstream/db": "0.100.21",
21
- "@effectstream/event-client": "0.100.21",
22
- "@effectstream/event-server": "0.100.21",
23
- "@effectstream/log": "0.100.21",
24
- "@effectstream/sm": "0.100.21",
25
- "@effectstream/sync": "0.100.21",
26
- "@effectstream/utils": "0.100.21",
16
+ "@effectstream/concise": "0.100.23",
17
+ "@effectstream/config": "0.100.23",
18
+ "@effectstream/coroutine": "0.100.23",
19
+ "@effectstream/crypto": "0.100.23",
20
+ "@effectstream/db": "0.100.23",
21
+ "@effectstream/event-client": "0.100.23",
22
+ "@effectstream/event-server": "0.100.23",
23
+ "@effectstream/log": "0.100.23",
24
+ "@effectstream/sm": "0.100.23",
25
+ "@effectstream/sync": "0.100.23",
26
+ "@effectstream/utils": "0.100.23",
27
27
  "@fastify/cors": "^11.0.1",
28
28
  "@metamask/json-rpc-engine": "^10.0.3",
29
29
  "pg": "8.21.0",
@@ -1,6 +1,7 @@
1
1
  import fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
2
2
  import { evmRpcEngine } from "./rpc-evm/eip1193.ts";
3
3
  import { appliedBlockStatus } from "./apply-status.ts";
4
+ import { finalizedStreamStatus } from "./stream-status.ts";
4
5
  import type { Pool } from "pg";
5
6
  import cors from "@fastify/cors";
6
7
  import { run, until } from "effection";
@@ -509,6 +510,14 @@ export const startHttpServer = function* (
509
510
  mergeWaiting: p.mergeWaitingForPage,
510
511
  mergeDemandRoot: p.mergeDemandRoot ?? null,
511
512
  })),
513
+ // Finalized-block stream backlog: blocks the merge has produced but the
514
+ // apply loop hasn't drained. `inFlight` is the otherwise-unobservable
515
+ // subscriber-queue depth, bounded by backpressure (sync/CLAUDE.md Finding #1).
516
+ // `coalesced` = empty blocks folded away during catch-up.
517
+ finalizedStream: {
518
+ inFlight: finalizedStreamStatus.produced - finalizedStreamStatus.consumed,
519
+ coalesced: finalizedStreamStatus.coalesced,
520
+ },
512
521
  // Apply-stage lag: how old the last-applied block is. Unlike `buf` (fetch
513
522
  // backlog) this stays high when the node is write/apply-bound.
514
523
  applied: {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Finalized-block stream backlog for /debug/metrics.
3
+ *
4
+ * `finalized-stream.ts` bumps `produced` when the merge sends a block and `consumed`
5
+ * when the apply loop drains one; their difference is the live subscriber-queue
6
+ * depth that drives backpressure and is surfaced as `inFlight` on /debug/metrics
7
+ * (the one structure flagged unbounded in sync/CLAUDE.md Finding #1). `coalesced`
8
+ * counts empty blocks folded away, so a catch-up test can prove coalescing fired.
9
+ *
10
+ * Process-global: one node runs per process, so a module-level singleton avoids
11
+ * threading a ref through startMerge and the coalescer.
12
+ */
13
+ export type FinalizedStreamStatus = {
14
+ produced: number;
15
+ consumed: number;
16
+ coalesced: number;
17
+ };
18
+
19
+ export const finalizedStreamStatus: FinalizedStreamStatus = {
20
+ produced: 0,
21
+ consumed: 0,
22
+ coalesced: 0,
23
+ };
24
+
25
+ export function recordProduced(): void {
26
+ finalizedStreamStatus.produced++;
27
+ }
28
+
29
+ export function recordConsumed(n: number): void {
30
+ finalizedStreamStatus.consumed += n;
31
+ }
32
+
33
+ export function recordCoalesced(n: number): void {
34
+ finalizedStreamStatus.coalesced += n;
35
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Bounded hand-off queue between the merge (producer) and the apply loop (consumer).
3
+ *
4
+ * The merge sends finalized blocks into an effection channel; the apply loop drains
5
+ * them. effection channel subscribers buffer unbounded, so during deep catch-up the
6
+ * merge (no I/O) outpaces the apply loop and the queue grows toward the whole backlog
7
+ * → OOM (sync/CLAUDE.md Finding #1) — especially with empty-block coalescing removing
8
+ * the per-block apply brake.
9
+ *
10
+ * This wraps the channel with backpressure: the producer blocks once `cap` produced
11
+ * blocks are still unconsumed, and the consumer wakes it as it drains. The cap only
12
+ * ever throttles the producer (the consumer always keeps pulling), so it can't
13
+ * deadlock. `inFlight = produced − consumed` is the live queue depth, surfaced on
14
+ * /debug/metrics.
15
+ */
16
+ import { type Channel, createChannel, type Operation } from "effection";
17
+ import { conditionVariable } from "@effectstream/utils";
18
+ import type { ChainBlock } from "@effectstream/sync";
19
+ import {
20
+ finalizedStreamStatus,
21
+ recordConsumed,
22
+ recordProduced,
23
+ } from "./api/stream-status.ts";
24
+ import type { FinalizedBlockSubscription } from "./coalesce.ts";
25
+
26
+ export type BoundedFinalizedStream = {
27
+ /** Send side — hand to `startMerge`. `send` blocks the producer at the cap. */
28
+ stream: Channel<ChainBlock, void>;
29
+ /** Read side — hand to the coalescer / apply loop. Counts drains and wakes the producer. */
30
+ subscription: FinalizedBlockSubscription;
31
+ };
32
+
33
+ export function* createBoundedFinalizedStream(
34
+ cap: number,
35
+ ): Operation<BoundedFinalizedStream> {
36
+ const channel = createChannel<ChainBlock>();
37
+ // Subscribe BEFORE the caller spawns the merge: effection channels drop sends
38
+ // with no subscriber, so a fast restart could emit blocks before we subscribe
39
+ // and stall at the pre-restart height.
40
+ const subscription = yield* channel;
41
+
42
+ const drained = conditionVariable<void>();
43
+ const inFlight = () =>
44
+ finalizedStreamStatus.produced - finalizedStreamStatus.consumed;
45
+
46
+ // `startMerge` only uses `.send`; gate it. check-then-wait is atomic under
47
+ // effection's cooperative scheduling, so there's no lost wakeup.
48
+ const stream: Channel<ChainBlock, void> = {
49
+ ...channel,
50
+ *send(block: ChainBlock) {
51
+ while (inFlight() >= cap) {
52
+ yield* drained.wait();
53
+ }
54
+ recordProduced();
55
+ yield* channel.send(block);
56
+ },
57
+ };
58
+
59
+ // Count consumed at the pull point (exact channel depth, decoupled from the
60
+ // coalescer's run length) and wake the merge once the queue drops below cap.
61
+ const countedSubscription: FinalizedBlockSubscription = {
62
+ *next() {
63
+ const result = yield* subscription.next();
64
+ if (!result.done) {
65
+ recordConsumed(1);
66
+ if (inFlight() < cap) drained.wake();
67
+ }
68
+ return result;
69
+ },
70
+ };
71
+
72
+ return { stream, subscription: countedSubscription };
73
+ }
package/src/main.ts CHANGED
@@ -23,7 +23,6 @@ import {
23
23
  import { startMerge, startSync } from "@effectstream/sync";
24
24
  import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
25
25
  import {
26
- createChannel,
27
26
  type Operation,
28
27
  sleep,
29
28
  spawn,
@@ -37,6 +36,8 @@ import {
37
36
  import { createEmptyBlockCoalescer } from "./coalesce.ts";
38
37
  import { startHttpServer } from "./api/http-server.ts";
39
38
  import { recordAppliedBlock } from "./api/apply-status.ts";
39
+ import { recordCoalesced } from "./api/stream-status.ts";
40
+ import { createBoundedFinalizedStream } from "./finalized-stream.ts";
40
41
  import type { StartConfig } from "./types.ts";
41
42
  import type { Client } from "pg";
42
43
  import type { EffectstreamBlockHash } from "@effectstream/utils";
@@ -102,16 +103,13 @@ export function* start(config: StartConfig): Operation<void> {
102
103
  (syncInfo[0]?.network as { blockTimeMS?: number } | undefined)?.blockTimeMS;
103
104
  const lagThresholdMs = clockBlockTimeMS != null ? clockBlockTimeMS * 10 : undefined;
104
105
 
105
- const finalizedBlockStream = createChannel<ChainBlock>();
106
+ // Bounded hand-off queue between the merge and the apply loop. Backpressure caps
107
+ // the in-memory queue so deep catch-up can't grow it toward the whole backlog
108
+ // (see finalized-stream.ts / sync/CLAUDE.md Finding #1).
109
+ const { stream: finalizedStream, subscription: finalizedBlocks } =
110
+ yield* createBoundedFinalizedStream(ENV.EFFECTSTREAM_FINALIZED_STREAM_CAP);
106
111
 
107
- // Subscribe BEFORE spawning the merge: effection channels drop sends with no
108
- // subscriber, so a fast restart could emit blocks before we subscribe and
109
- // stall at the pre-restart height.
110
- const finalizedBlocks = yield* finalizedBlockStream;
111
- // NOTE: this subscriber's queue is unbounded — during deep catch-up the merge
112
- // can outpace block processing and grow it (Fix C backpressure, sync/CLAUDE.md).
113
-
114
- yield* spawn(() => startMerge(syncProtocols, finalizedBlockStream));
112
+ yield* spawn(() => startMerge(syncProtocols, finalizedStream));
115
113
 
116
114
  const heartbeatIntervalMs = 60_000;
117
115
  yield* spawn(function* () {
@@ -161,6 +159,9 @@ export function* start(config: StartConfig): Operation<void> {
161
159
  lagThresholdMs,
162
160
  getPreviousBlockHash: () => blockHash,
163
161
  onFlush: (endpoint, length) => {
162
+ // `consumed` is counted at the subscription pull point (above), not here, so
163
+ // it reflects the channel depth regardless of how many blocks a run folds.
164
+ recordCoalesced(length);
164
165
  recordAppliedBlock(endpoint);
165
166
  emitLatestBlocks(
166
167
  endpoint.blockNumber,
@@ -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);
@@ -32,6 +32,8 @@ import { toSyncProtocolWithNetwork } from "@effectstream/config";
32
32
  export type RunNodeOpts = {
33
33
  config: any;
34
34
  apiPort: number;
35
+ /** Enable/disable empty-block coalescing for this long-lived node. */
36
+ coalesce?: boolean;
35
37
  };
36
38
 
37
39
  export type RunningNode = {
@@ -40,6 +42,10 @@ export type RunningNode = {
40
42
  bufferSizes: Record<string, number>;
41
43
  pauses: Record<string, number>;
42
44
  ownBlocks: Record<string, number | null>;
45
+ /** finalizedBlockStream backlog (produced − consumed); 0 until first scrape. */
46
+ inFlight: number;
47
+ /** Empty blocks folded away by the coalescer so far. */
48
+ coalescedBlocks: number;
43
49
  stop: () => Promise<void>;
44
50
  syncProtocols: () => any[];
45
51
  };
@@ -230,8 +236,6 @@ function ensureCluster(): Promise<Cluster> {
230
236
  const dataDir = mkdtempSync(join(tmpdir(), "repro-pg-"));
231
237
  const port = await freePort();
232
238
 
233
- writeFileSync(join(dataDir, "repro-owner.pid"), String(process.pid));
234
-
235
239
  execFileSync(join(bin, "initdb"), [
236
240
  "-D", dataDir,
237
241
  "-U", "postgres",
@@ -240,6 +244,10 @@ function ensureCluster(): Promise<Cluster> {
240
244
  "--encoding=UTF8",
241
245
  "--no-instructions",
242
246
  ], { env, stdio: "ignore" });
247
+
248
+ // Write our owner marker AFTER initdb — it refuses a non-empty data dir, so
249
+ // this must not exist beforehand. Read later by cleanupOrphanedClusters.
250
+ writeFileSync(join(dataDir, "repro-owner.pid"), String(process.pid));
243
251
  appendFileSync(join(dataDir, "postgresql.conf"), `\nport = ${port}\n`);
244
252
  execFileSync(join(bin, "pg_ctl"), [
245
253
  "-D", dataDir,
@@ -384,6 +392,7 @@ function makeRunNode(
384
392
  target: 999999,
385
393
  apiPort: opts.apiPort,
386
394
  config: opts.config,
395
+ coalesce: opts.coalesce,
387
396
  };
388
397
 
389
398
  const child = spawn("bun", [RUNNER, JSON.stringify(spec)], {
@@ -401,6 +410,8 @@ function makeRunNode(
401
410
  bufferSizes: {},
402
411
  pauses: {},
403
412
  ownBlocks: {},
413
+ inFlight: 0,
414
+ coalescedBlocks: 0,
404
415
  syncProtocols() {
405
416
  return Object.entries(this.bufferSizes).map(([name, buf]) => ({
406
417
  name,
@@ -433,6 +444,10 @@ function makeRunNode(
433
444
  node.ownBlocks[p.name] = p.ownBlockNumber;
434
445
  }
435
446
  }
447
+ if (data?.finalizedStream) {
448
+ node.inFlight = data.finalizedStream.inFlight ?? 0;
449
+ node.coalescedBlocks = data.finalizedStream.coalesced ?? 0;
450
+ }
436
451
  }
437
452
  } catch {
438
453
  // ignore