@effectstream/runtime 0.100.20 → 0.100.21

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.20",
3
+ "version": "0.100.21",
4
4
  "license": "MIT",
5
5
  "homepage": "https://effectstream.github.io/docs/",
6
6
  "repository": {
@@ -13,20 +13,20 @@
13
13
  },
14
14
  "exports": "./src/mod.ts",
15
15
  "dependencies": {
16
- "@effectstream/concise": "0.100.20",
17
- "@effectstream/config": "0.100.20",
18
- "@effectstream/coroutine": "0.100.20",
19
- "@effectstream/crypto": "0.100.20",
20
- "@effectstream/db": "0.100.20",
21
- "@effectstream/event-client": "0.100.20",
22
- "@effectstream/event-server": "0.100.20",
23
- "@effectstream/log": "0.100.20",
24
- "@effectstream/sm": "0.100.20",
25
- "@effectstream/sync": "0.100.20",
26
- "@effectstream/utils": "0.100.20",
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",
27
27
  "@fastify/cors": "^11.0.1",
28
28
  "@metamask/json-rpc-engine": "^10.0.3",
29
- "pg": "8.17.2",
29
+ "pg": "8.21.0",
30
30
  "effection": "^3.5.0",
31
31
  "@opentelemetry/api": "^1.9.0",
32
32
  "@opentelemetry/auto-instrumentations-node": "^0.54.0",
@@ -504,6 +504,10 @@ export const startHttpServer = function* (
504
504
  pausedNow: p.pausedNow,
505
505
  pauses: p.backpressurePauses,
506
506
  pausedMs: p.backpressurePausedMs,
507
+ // Merge-demand exemption: when true the merge is gated on this chain's
508
+ // page and the cap is lifted.
509
+ mergeWaiting: p.mergeWaitingForPage,
510
+ mergeDemandRoot: p.mergeDemandRoot ?? null,
507
511
  })),
508
512
  // Apply-stage lag: how old the last-applied block is. Unlike `buf` (fetch
509
513
  // backlog) this stays high when the node is write/apply-bound.
package/src/main.ts CHANGED
@@ -6,11 +6,13 @@ import {
6
6
  import {
7
7
  acquireDBMutex,
8
8
  createDynamicTables,
9
+ detectCapabilities,
9
10
  getConnection,
10
11
  getLastNonEmptyBlockHash,
11
12
  releaseDBMutex,
12
13
  resetPublicTables,
13
14
  runSnapshotLoop,
15
+ selectViewStrategy,
14
16
  } from "@effectstream/db";
15
17
  import { EventBroker } from "@effectstream/event-server";
16
18
  import { ENV } from "@effectstream/utils/node-env";
@@ -103,12 +105,11 @@ export function* start(config: StartConfig): Operation<void> {
103
105
  const finalizedBlockStream = createChannel<ChainBlock>();
104
106
 
105
107
  // Subscribe BEFORE spawning the merge: effection channels drop sends with no
106
- // active subscriber, so a fast restart (in-memory state, no RPC wait) could
107
- // emit blocks before we subscribe and silently stall at the pre-restart height.
108
+ // subscriber, so a fast restart could emit blocks before we subscribe and
109
+ // stall at the pre-restart height.
108
110
  const finalizedBlocks = yield* finalizedBlockStream;
109
- // NOTE: this subscriber's queue is unbounded. During deep catch-up the merge
110
- // can outpace the per-block processing below and grow it without bound — the
111
- // downstream side of the Fix C backpressure issue (see sync/CLAUDE.md).
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).
112
113
 
113
114
  yield* spawn(() => startMerge(syncProtocols, finalizedBlockStream));
114
115
 
@@ -182,10 +183,9 @@ export function* start(config: StartConfig): Operation<void> {
182
183
  const value = yield* coalescer.advance();
183
184
  if (value === undefined) break;
184
185
 
185
- // processFinalizedBlockWithRetry owns connection checkout, the per-block
186
- // DB mutex (PGLite), and transient-pg retry/backoff. App events it
187
- // collects are flushed below ONLY after it returns — i.e. strictly after
188
- // the block's COMMIT.
186
+ // Owns connection checkout, the per-block DB mutex (PGLite), and
187
+ // transient-pg retry/backoff. App events flush below only after it
188
+ // returns — strictly after the block's COMMIT.
189
189
  const result = yield* processFinalizedBlockWithRetry(
190
190
  value,
191
191
  config,
@@ -202,21 +202,14 @@ export function* start(config: StartConfig): Operation<void> {
202
202
  // Used to emit & log the block range for each protocol.
203
203
  const contentBlocksForProtocol = getRangesForSyncProtocols(value);
204
204
 
205
- // Fire-and-forget: do not stall the sync loop on broker acks.
205
+ // Fire-and-forget: don't stall the sync loop on broker acks. Ordering is
206
+ // safe without a wrapper queue — Opifex assigns packet IDs synchronously in
207
+ // publish() and net.Socket serializes writes in call order, so MQTT 3.1.1
208
+ // §4.6 in-order delivery holds per (publisher, topic, QoS).
206
209
  //
207
- // Opifex's TcpClient assigns packet IDs synchronously in client.publish()
208
- // and writes to the socket via Node's net.Socket, which has its own write
209
- // queue — so concurrent publish() calls land on the wire in call order.
210
- // MQTT 3.1.1 §4.6 then guarantees in-order delivery to each subscriber
211
- // per (publisher, topic, QoS). We don't need a wrapper queue.
212
- //
213
- // TODO(scaling): the broker runs in-process (see `new EventBroker(...)`
214
- // above). Fan-out to N subscribers consumes the same event loop as block
215
- // processing. At ~thousands of subscribers, consider:
216
- // 1) moving the broker to a worker / separate process,
217
- // 2) a publisher-side circuit breaker if ctx.unresolvedPublish.size > N
218
- // (drop events with a counter for observability).
219
- // Today: localhost, in-process, expected O(10²) subscribers — non-issue.
210
+ // TODO(scaling): the broker is in-process, so fan-out shares this event
211
+ // loop. At ~thousands of subscribers, move it to a worker and/or add a
212
+ // publisher-side circuit breaker. Today: localhost, O(10²) subs — non-issue.
220
213
  emitLatestBlocks(
221
214
  value.blockNumber,
222
215
  value.timestamp,
@@ -268,13 +261,9 @@ function getRangesForSyncProtocols(value: ChainBlock): Record<string, [number, n
268
261
  }
269
262
 
270
263
  /**
271
- * Publish built-in block-level events.
272
- *
273
- * Fire-and-forget for the same reasons as the app-event flush above
274
- * (do not stall the sync loop; ordering preserved by Opifex + MQTT 3.1.1).
275
- *
276
- * Errors are caught locally so they don't escape to the global
277
- * `unhandledrejection` handler without context.
264
+ * Publish built-in block-level events. Fire-and-forget like the app-event flush
265
+ * above (don't stall the sync loop; ordering held by Opifex + MQTT 3.1.1).
266
+ * Errors are caught locally so they don't reach the global handler contextless.
278
267
  */
279
268
  function emitLatestBlocks(
280
269
  rollUpBlockHeight: number,
@@ -325,41 +314,48 @@ function* startup(
325
314
  });
326
315
  });
327
316
 
328
-
329
317
  yield* acquireDBMutex(`startup-node`);
318
+ // `finally` releases the mutex on error/cancellation too; a throw in any step
319
+ // below would otherwise leak it and deadlock every later DB operation.
320
+ try {
321
+ // Dev-only reset of user-owned public tables
322
+ if (config.dev?.resetPublicData) {
323
+ yield* resetPublicTables(dbConn as any); // Client,
324
+ }
330
325
 
331
- // Dev-only reset of user-owned public tables
332
- if (config.dev?.resetPublicData) {
333
- yield* resetPublicTables(dbConn as any); // Client,
334
- }
326
+ // When the node is started, we apply system migrations.
327
+ // Either system initial migrations, or migrations given a Paima Engine Update.
328
+ yield* applySystemMigrations(
329
+ config.appVersion,
330
+ versionInfo,
331
+ lastBlockHeight,
332
+ dbConn,
333
+ config.migrations,
334
+ );
335
335
 
336
- // When the node is started, we apply system migrations.
337
- // Either system initial migrations, or migrations given a Paima Engine Update.
338
- yield* applySystemMigrations(
339
- config.appVersion,
340
- versionInfo,
341
- lastBlockHeight,
342
- dbConn,
343
- config.migrations,
344
- );
336
+ // Validate that immutable config fields (e.g. NTP startTime, startBlockHeight)
337
+ // have not changed since the last run. Persists a snapshot on first start.
338
+ // Must run after system migrations so the snapshot table exists.
339
+ yield* validateAndSnapshotConfig(syncInfo, dbConn);
345
340
 
346
- // Validate that immutable config fields (e.g. NTP startTime, startBlockHeight)
347
- // have not changed since the last run. Persists a snapshot on first start.
348
- // Must run after system migrations so the snapshot table exists.
349
- yield* validateAndSnapshotConfig(syncInfo, dbConn);
341
+ const syncProtocols = yield* genSyncProtocols(dbConn as any, // Client,
342
+ syncInfo);
350
343
 
351
- const syncProtocols = yield* genSyncProtocols(dbConn as any, // Client,
352
- syncInfo);
344
+ const capabilities = yield* until(detectCapabilities(dbConn as any));
345
+ const viewStrategy = selectViewStrategy(capabilities);
353
346
 
354
- yield* createDynamicTables(
355
- versionInfo,
356
- lastBlockHeight,
357
- dbConn as any, // Client,
358
- syncProtocols,
359
- );
347
+ yield* createDynamicTables(
348
+ versionInfo,
349
+ lastBlockHeight,
350
+ dbConn as any, // Client,
351
+ syncProtocols,
352
+ viewStrategy,
353
+ );
360
354
 
361
- releaseDBMutex(`startup-node`);
362
- return syncProtocols;
355
+ return syncProtocols;
356
+ } finally {
357
+ releaseDBMutex(`startup-node`);
358
+ }
363
359
  }
364
360
 
365
361
  // Convert the primitive config to the final primitive instance
@@ -8,6 +8,10 @@
8
8
  * 1b — head-of-line blocking: a stalled slow chain STILL halts block production
9
9
  * (the merge gate is correct), but the fast chain's buffer is now bounded by
10
10
  * the cap instead of ballooning behind the head-of-line block.
11
+ * 1c — skip-ahead: the merge-demand exemption lifts the cap while the merge is gated
12
+ * on a chain whose page is far behind the (skipped-ahead) root, so it advances
13
+ * instead of deadlocking.
14
+ * 1d — density: the same exemption when a parallel chain is finer-grained than the cap.
11
15
  *
12
16
  * Before the fix these buffers reached ~49k–50k (see
13
17
  * e2e/perf/results/BACKPRESSURE-BASELINE.md); these tests assert they now
@@ -177,7 +181,7 @@ test("1a: backpressure caps the buffer while the node keeps applying blocks", as
177
181
  let mainPauses = 0;
178
182
  try {
179
183
  const startMs = Date.now();
180
- const deadline = startMs + 6000;
184
+ const deadline = startMs + 30_000;
181
185
  while (Date.now() < deadline) {
182
186
  const wall = Date.now();
183
187
  const mainBuf = bufferSize(node, MAIN_1A);
@@ -327,7 +331,8 @@ test("1b: a stalled chain still halts production, but the fast chain's buffer st
327
331
  let fastPauses = 0;
328
332
  try {
329
333
  const startMs = Date.now();
330
- const deadline = startMs + 15_000;
334
+ // Generous for slow CI; exits early once the stall + capped sibling are captured.
335
+ const deadline = startMs + 30_000;
331
336
  let lastHeight = -1;
332
337
  let lastChange = Date.now();
333
338
  while (Date.now() < deadline) {
@@ -387,3 +392,259 @@ test("1b: a stalled chain still halts production, but the fast chain's buffer st
387
392
  // ...and the cap actually fired on the fast chain (observability metric populated).
388
393
  expect(fastPauses).toBeGreaterThan(0);
389
394
  }, 60_000);
395
+
396
+ // ── 1c — skip-ahead: merge-demand exemption keeps the cap from deadlocking ────────
397
+ //
398
+ // The root (main clock) starts far ahead of a parallel chain that starts at block 1.
399
+ // To produce the first root block at τ (main page SKIP_START) the merge needs the
400
+ // parallel page PAST τ — so the fetcher must scan ~SKIP_START blocks, more than the
401
+ // cap. The exemption lifts the cap while the merge is gated on this chain, so it
402
+ // reaches τ and production proceeds (without it the node wedges on the first block).
403
+
404
+ const MAIN_1C = "mainClock1c";
405
+ const PAR_1C = "parallelP1c";
406
+ // Main genesis page (skip-ahead); cap far below it so the parallel chain must buffer
407
+ // past the cap to reach τ.
408
+ const SKIP_START = 300;
409
+ const SMALL_STEP = 50;
410
+ const SMALL_CAP = 100;
411
+ // Liveness: past the first non-empty block at SKIP_START.
412
+ const LIVE_TARGET_1C = SKIP_START + 10;
413
+ // Bounded by necessity: at most the skip window + one in-flight chunk.
414
+ const SKIP_BUFFER_BOUND = SKIP_START + SMALL_STEP + 200;
415
+
416
+ function buildConfig1c() {
417
+ return new ConfigBuilder()
418
+ .setNamespace((b) => b.setSecurityNamespace("sync-buffering-1c"))
419
+ .buildNetworks((b) =>
420
+ b
421
+ .addNetwork({
422
+ name: "clock",
423
+ type: ConfigNetworkType.TEST,
424
+ startTime: START_TIME,
425
+ blockTimeMS: BLOCK_TIME_MS,
426
+ })
427
+ .addNetwork({
428
+ name: "chainP",
429
+ type: ConfigNetworkType.TEST,
430
+ startTime: START_TIME,
431
+ blockTimeMS: BLOCK_TIME_MS,
432
+ })
433
+ )
434
+ .buildDeployments((b) => b)
435
+ .buildSyncProtocols((b) =>
436
+ b
437
+ .addMain(
438
+ (n) => n.clock,
439
+ () => ({
440
+ name: MAIN_1C,
441
+ type: ConfigSyncProtocolType.TEST_MAIN,
442
+ // Skipped far ahead of the parallel chain (the recovery skip-ahead).
443
+ startBlockHeight: SKIP_START,
444
+ pollingInterval: 30,
445
+ stepSize: SMALL_STEP,
446
+ maxBufferedPages: SMALL_CAP,
447
+ }),
448
+ )
449
+ .addParallel(
450
+ (n) => (n as any).chainP,
451
+ () => ({
452
+ name: PAR_1C,
453
+ type: ConfigSyncProtocolType.TEST_PARALLEL,
454
+ startBlockHeight: 1,
455
+ pollingInterval: 30,
456
+ stepSize: SMALL_STEP,
457
+ delayMs: 0,
458
+ events: [],
459
+ maxBufferedPages: SMALL_CAP,
460
+ }),
461
+ )
462
+ )
463
+ .buildPrimitives((b) =>
464
+ b.addPrimitive(
465
+ (sp) => (sp as any)[PAR_1C],
466
+ () => ({ name: "noEvt1c", type: TEST_PRIMITIVE_TYPE, startBlockHeight: 1 }),
467
+ )
468
+ )
469
+ .build();
470
+ }
471
+
472
+ test("1c: a skipped-ahead root does NOT deadlock the merge on the first non-empty block", async () => {
473
+ // Main skipped to SKIP_START; parallel starts at 1 with a high tip.
474
+ TestChainControl.setTip(MAIN_1C, SKIP_START + 100);
475
+ TestChainControl.setTip(PAR_1C, 10_000);
476
+
477
+ const node = await h.runNode({ config: buildConfig1c(), apiPort: 19143 });
478
+ const samples: BufSample[] = [];
479
+ let peakPar = 0;
480
+ let lastApplied = 0;
481
+ try {
482
+ const startMs = Date.now();
483
+ // Generous for slow CI; exits early once the liveness target is reached.
484
+ const deadline = startMs + 30_000;
485
+ while (Date.now() < deadline) {
486
+ const wall = Date.now();
487
+ const mainBuf = bufferSize(node, MAIN_1C);
488
+ const parBuf = bufferSize(node, PAR_1C);
489
+ const appliedBlock = await latestFinalizedHeight(h.pool);
490
+ samples.push({
491
+ t: wall - startMs,
492
+ wall,
493
+ mainBuf,
494
+ evmBuf: parBuf,
495
+ mainOwnBlock: ownBlock(node, MAIN_1C),
496
+ evmOwnBlock: ownBlock(node, PAR_1C),
497
+ rss: process.memoryUsage().rss,
498
+ appliedBlock,
499
+ });
500
+ peakPar = Math.max(peakPar, parBuf);
501
+ lastApplied = appliedBlock ?? lastApplied;
502
+ // Got past the first non-empty block → the deadlock is broken; stop early.
503
+ if (lastApplied >= LIVE_TARGET_1C) break;
504
+ await sleep(50);
505
+ }
506
+ } finally {
507
+ writeArtifact("buffering-1c", samples, {
508
+ mode: "1c-skip-ahead-deadlock",
509
+ stepSize: SMALL_STEP,
510
+ skipStart: SKIP_START,
511
+ cap: SMALL_CAP,
512
+ liveTarget: LIVE_TARGET_1C,
513
+ peakParBuf: peakPar,
514
+ lastApplied,
515
+ });
516
+ await node.stop();
517
+ }
518
+
519
+ // Liveness: production advanced past the first non-empty block (no wedge).
520
+ expect(lastApplied).toBeGreaterThanOrEqual(LIVE_TARGET_1C);
521
+ // Bounded by necessity: only the skip window + one chunk, not the whole tip.
522
+ expect(peakPar).toBeLessThanOrEqual(SKIP_BUFFER_BOUND);
523
+ }, 60_000);
524
+
525
+ // ── 1d — density: same exemption when a chain is finer-grained than the cap ───────
526
+ //
527
+ // Same circular wait without a skip-ahead: a parallel chain far finer than the root
528
+ // (100 parallel blocks per root slot). Each root block needs the parallel page past
529
+ // the slot boundary (~PER_SLOT buffered items), but the cap is below that. The
530
+ // exemption lets the per-slot window buffer and drain each slot (without it the node
531
+ // wedges on the first block).
532
+
533
+ const MAIN_1D = "mainClock1d";
534
+ const PAR_1D = "parallelP1d";
535
+ const DENSE_BLOCK_MS = 10; // 100 parallel blocks per 1000ms root slot
536
+ const PER_SLOT = BLOCK_TIME_MS / DENSE_BLOCK_MS; // 100 parallel items per root slot
537
+ // Step + cap small enough that cap + one overshoot chunk (~40) stays below PER_SLOT
538
+ // (100), so the fetcher pauses before its page can cross the slot boundary.
539
+ const DENSE_STEP = 10;
540
+ const CAP_1D = 30;
541
+ const LIVE_TARGET_1D = 20;
542
+ // Bounded by the per-slot need plus one chunk — not the whole tip.
543
+ const DENSE_BUFFER_BOUND = PER_SLOT + DENSE_STEP + 150;
544
+
545
+ function buildConfig1d() {
546
+ return new ConfigBuilder()
547
+ .setNamespace((b) => b.setSecurityNamespace("sync-buffering-1d"))
548
+ .buildNetworks((b) =>
549
+ b
550
+ .addNetwork({
551
+ name: "clock",
552
+ type: ConfigNetworkType.TEST,
553
+ startTime: START_TIME,
554
+ blockTimeMS: BLOCK_TIME_MS,
555
+ })
556
+ .addNetwork({
557
+ name: "chainDense",
558
+ type: ConfigNetworkType.TEST,
559
+ startTime: START_TIME,
560
+ blockTimeMS: DENSE_BLOCK_MS,
561
+ })
562
+ )
563
+ .buildDeployments((b) => b)
564
+ .buildSyncProtocols((b) =>
565
+ b
566
+ .addMain(
567
+ (n) => n.clock,
568
+ () => ({
569
+ name: MAIN_1D,
570
+ type: ConfigSyncProtocolType.TEST_MAIN,
571
+ startBlockHeight: 1,
572
+ pollingInterval: 30,
573
+ stepSize: DENSE_STEP,
574
+ maxBufferedPages: 200,
575
+ }),
576
+ )
577
+ .addParallel(
578
+ (n) => (n as any).chainDense,
579
+ () => ({
580
+ name: PAR_1D,
581
+ type: ConfigSyncProtocolType.TEST_PARALLEL,
582
+ startBlockHeight: 1,
583
+ pollingInterval: 30,
584
+ stepSize: DENSE_STEP,
585
+ delayMs: 0,
586
+ events: [],
587
+ maxBufferedPages: CAP_1D,
588
+ }),
589
+ )
590
+ )
591
+ .buildPrimitives((b) =>
592
+ b.addPrimitive(
593
+ (sp) => (sp as any)[PAR_1D],
594
+ () => ({ name: "noEvt1d", type: TEST_PRIMITIVE_TYPE, startBlockHeight: 1 }),
595
+ )
596
+ )
597
+ .build();
598
+ }
599
+
600
+ test("1d: a parallel chain denser than the cap does NOT deadlock the merge", async () => {
601
+ TestChainControl.setTip(MAIN_1D, 60);
602
+ TestChainControl.setTip(PAR_1D, 10_000);
603
+
604
+ const node = await h.runNode({ config: buildConfig1d(), apiPort: 19144 });
605
+ const samples: BufSample[] = [];
606
+ let peakPar = 0;
607
+ let lastApplied = 0;
608
+ try {
609
+ const startMs = Date.now();
610
+ // Generous for slow CI; exits early once the liveness target is reached.
611
+ const deadline = startMs + 30_000;
612
+ while (Date.now() < deadline) {
613
+ const wall = Date.now();
614
+ const mainBuf = bufferSize(node, MAIN_1D);
615
+ const parBuf = bufferSize(node, PAR_1D);
616
+ const appliedBlock = await latestFinalizedHeight(h.pool);
617
+ samples.push({
618
+ t: wall - startMs,
619
+ wall,
620
+ mainBuf,
621
+ evmBuf: parBuf,
622
+ mainOwnBlock: ownBlock(node, MAIN_1D),
623
+ evmOwnBlock: ownBlock(node, PAR_1D),
624
+ rss: process.memoryUsage().rss,
625
+ appliedBlock,
626
+ });
627
+ peakPar = Math.max(peakPar, parBuf);
628
+ lastApplied = appliedBlock ?? lastApplied;
629
+ if (lastApplied >= LIVE_TARGET_1D) break;
630
+ await sleep(50);
631
+ }
632
+ } finally {
633
+ writeArtifact("buffering-1d", samples, {
634
+ mode: "1d-density-deadlock",
635
+ stepSize: SMALL_STEP,
636
+ denseBlockMs: DENSE_BLOCK_MS,
637
+ perSlot: PER_SLOT,
638
+ cap: CAP_1D,
639
+ liveTarget: LIVE_TARGET_1D,
640
+ peakParBuf: peakPar,
641
+ lastApplied,
642
+ });
643
+ await node.stop();
644
+ }
645
+
646
+ // Liveness: the node produces blocks (no wedge on block 1).
647
+ expect(lastApplied).toBeGreaterThanOrEqual(LIVE_TARGET_1D);
648
+ // Bounded by the per-slot need: ~one slot of parallel data plus one chunk.
649
+ expect(peakPar).toBeLessThanOrEqual(DENSE_BUFFER_BOUND);
650
+ }, 60_000);