@effectstream/runtime 0.100.26 → 0.100.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -84,11 +84,21 @@ default:
84
84
  EFFECTSTREAM_COALESCE_EMPTY_BLOCKS=true
85
85
  ```
86
86
 
87
+ The lag threshold that defines "behind the chain tip" defaults to **20× the
88
+ main clock's block time** (falling back to 60 s when no `blockTimeMS` is
89
+ exposed by the network config). Override it with
90
+ `EFFECTSTREAM_LAG_THRESHOLD_MS` (milliseconds), e.g. for chains whose main
91
+ clock fires faster than the runtime can keep up with one transaction per block:
92
+
93
+ ```bash
94
+ EFFECTSTREAM_LAG_THRESHOLD_MS=30000 # engage coalescing past 30 s of lag
95
+ ```
96
+
87
97
  Behavior and guarantees:
88
98
 
89
99
  - **Catch-up only.** Coalescing engages only while behind the chain tip
90
- (`now - block.timestamp` greater than 10× the main clock's block time) and
91
- disengages at the tip, so steady-state stays one block per transaction.
100
+ (`now - block.timestamp` greater than the lag threshold) and disengages at
101
+ the tip, so steady-state stays one block per transaction.
92
102
  - **Identical database.** A coalesced sync produces a byte-for-byte identical
93
103
  database to a non-coalesced one. Empty blocks already never advance the block
94
104
  hash or RNG seed, so only the endpoint's block row and the per-protocol resume
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/runtime",
3
- "version": "0.100.26",
3
+ "version": "0.100.28",
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.26",
17
- "@effectstream/config": "0.100.26",
18
- "@effectstream/coroutine": "0.100.26",
19
- "@effectstream/crypto": "0.100.26",
20
- "@effectstream/db": "0.100.26",
21
- "@effectstream/event-client": "0.100.26",
22
- "@effectstream/event-server": "0.100.26",
23
- "@effectstream/log": "0.100.26",
24
- "@effectstream/sm": "0.100.26",
25
- "@effectstream/sync": "0.100.26",
26
- "@effectstream/utils": "0.100.26",
16
+ "@effectstream/concise": "0.100.28",
17
+ "@effectstream/config": "0.100.28",
18
+ "@effectstream/coroutine": "0.100.28",
19
+ "@effectstream/crypto": "0.100.28",
20
+ "@effectstream/db": "0.100.28",
21
+ "@effectstream/event-client": "0.100.28",
22
+ "@effectstream/event-server": "0.100.28",
23
+ "@effectstream/log": "0.100.28",
24
+ "@effectstream/sm": "0.100.28",
25
+ "@effectstream/sync": "0.100.28",
26
+ "@effectstream/utils": "0.100.28",
27
27
  "@fastify/cors": "^11.0.1",
28
28
  "@metamask/json-rpc-engine": "^10.0.3",
29
29
  "pg": "8.21.0",
package/src/coalesce.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Empty-block coalescing during deep catch-up: fold consecutive no-op blocks into one transaction. */
2
2
  import type { ChainBlock } from "@effectstream/sync";
3
3
  import { call, type Operation, race, sleep, until } from "effection";
4
- import type { Pool, PoolClient } from "pg";
4
+ import type { Client, Pool, PoolClient } from "pg";
5
5
  import {
6
6
  acquireDBMutex,
7
7
  blockHeightDone,
@@ -21,6 +21,7 @@ import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
21
21
  import { generateEffectstreamBlockHash, Prando } from "@effectstream/crypto";
22
22
  import type { EffectstreamBlockHash } from "@effectstream/utils";
23
23
  import type { DBMigrations } from "@effectstream/runtime";
24
+ import { mergeCoalescingBoundaries } from "@effectstream/sync";
24
25
 
25
26
  /** Scheduled-input boundaries sampled once at run start (stable until the next non-empty block). */
26
27
  export type EmptyRunBoundaries = {
@@ -38,20 +39,63 @@ export function* loadEmptyRunBoundaries(
38
39
  ): Operation<EmptyRunBoundaries> {
39
40
  const [bh] = yield* until(getEarliestScheduledBlockHeight.run(undefined, dbConn));
40
41
  const [ts] = yield* until(getEarliestScheduledTimestamp.run(undefined, dbConn));
41
- // Ignore scheduled block heights already in the past: a stale unprocessed row
42
- // (e.g. from a DB reset) would otherwise permanently suppress coalescing for
43
- // all current blocks, since every block number >= the stale height.
42
+ // getEarliestScheduledBlockHeight now excludes heights at/below the last applied
43
+ // block in SQL, so this is always an upcoming schedule (or null when none pends).
44
+ // The `>= fromBlockNumber` check is a belt-and-suspenders guard. BEWARE the old
45
+ // shape: that query returned the global MIN, and a stale below-head row (e.g. an
46
+ // input that was already skipped) collapsed the boundary to null *here* — which
47
+ // disables coalescing's scheduled-input guard entirely, so every later scheduled
48
+ // input inside an empty run gets coalesced over. One orphan permanently broke it.
44
49
  const minScheduledBlockHeight = bh?.min_block_height != null && bh.min_block_height >= fromBlockNumber
45
50
  ? bh.min_block_height
46
51
  : null;
52
+ const minScheduledTimestampMs = ts?.min_timestamp != null
53
+ ? new Date(ts.min_timestamp).getTime()
54
+ : null;
55
+
56
+ mergeCoalescingBoundaries.minScheduledBlockHeight = minScheduledBlockHeight;
57
+ mergeCoalescingBoundaries.minScheduledTimestampMs = minScheduledTimestampMs;
58
+
47
59
  return {
48
60
  minScheduledBlockHeight,
49
- minScheduledTimestampMs: ts?.min_timestamp != null
50
- ? new Date(ts.min_timestamp).getTime()
51
- : null,
61
+ minScheduledTimestampMs,
52
62
  };
53
63
  }
54
64
 
65
+ /**
66
+ * Seed {@link mergeCoalescingBoundaries} once at startup so the merge loop knows
67
+ * which empty blocks it may coalesce before the runtime has applied any block.
68
+ * `loadEmptyRunBoundaries` keeps these in sync afterwards on each non-empty block.
69
+ *
70
+ * @param fromBlockNumber the next block height to apply (lastBlockHeight + 1);
71
+ * scheduled heights below this are stale and ignored (see {@link loadEmptyRunBoundaries}).
72
+ */
73
+ export function* initMergeCoalescingBoundaries(
74
+ dbConn: Pool | Client,
75
+ fromBlockNumber: number,
76
+ migrations: DBMigrations[] | undefined,
77
+ ): Operation<void> {
78
+ const [bh] = yield* until(getEarliestScheduledBlockHeight.run(undefined, dbConn));
79
+ const [ts] = yield* until(getEarliestScheduledTimestamp.run(undefined, dbConn));
80
+ const minScheduledBlockHeight = bh?.min_block_height != null && bh.min_block_height >= fromBlockNumber
81
+ ? bh.min_block_height
82
+ : null;
83
+ const minScheduledTimestampMs = ts?.min_timestamp != null
84
+ ? new Date(ts.min_timestamp).getTime()
85
+ : null;
86
+
87
+ const migrationBlockHeights: number[] = [];
88
+ for (const m of migrations ?? []) {
89
+ if (m.blockHeight != null) {
90
+ migrationBlockHeights.push(m.blockHeight);
91
+ }
92
+ }
93
+
94
+ mergeCoalescingBoundaries.minScheduledBlockHeight = minScheduledBlockHeight;
95
+ mergeCoalescingBoundaries.minScheduledTimestampMs = minScheduledTimestampMs;
96
+ mergeCoalescingBoundaries.migrationBlockHeights = migrationBlockHeights;
97
+ }
98
+
55
99
  /** Mirrors the empty-block checks in process-blocks.ts without DB/STF work. */
56
100
  export function isCoalescableEmptyBlock(
57
101
  value: ChainBlock,
@@ -20,6 +20,7 @@ import {
20
20
  finalizedStreamStatus,
21
21
  recordConsumed,
22
22
  recordProduced,
23
+ recordCoalesced,
23
24
  } from "./api/stream-status.ts";
24
25
  import type { FinalizedBlockSubscription } from "./coalesce.ts";
25
26
 
@@ -52,6 +53,9 @@ export function* createBoundedFinalizedStream(
52
53
  yield* drained.wait();
53
54
  }
54
55
  recordProduced();
56
+ if (block.coalescedCount && block.coalescedCount > 1) {
57
+ recordCoalesced(block.coalescedCount - 1);
58
+ }
55
59
  yield* channel.send(block);
56
60
  },
57
61
  };
package/src/main.ts CHANGED
@@ -33,7 +33,10 @@ import {
33
33
  type PendingEvent,
34
34
  processFinalizedBlockWithRetry,
35
35
  } from "./process-blocks.ts";
36
- import { createEmptyBlockCoalescer } from "./coalesce.ts";
36
+ import {
37
+ createEmptyBlockCoalescer,
38
+ initMergeCoalescingBoundaries,
39
+ } from "./coalesce.ts";
37
40
  import { startHttpServer } from "./api/http-server.ts";
38
41
  import { recordAppliedBlock } from "./api/apply-status.ts";
39
42
  import { recordCoalesced } from "./api/stream-status.ts";
@@ -96,14 +99,15 @@ export function* start(config: StartConfig): Operation<void> {
96
99
  );
97
100
  });
98
101
 
99
- // 10× main clock block time (NTP if present, else protocol 0).
102
+ // 20× main clock block time (NTP if present, else protocol 0).
100
103
  // Falls back to 60 s so coalescing is not silently disabled for chains that
101
104
  // don't expose a blockTimeMS on their network config (e.g. Midnight-as-main).
102
105
  const ntpConfig = syncInfo.find(s => s.networkType === ConfigNetworkType.NTP);
103
106
  const clockBlockTimeMS =
104
107
  (ntpConfig?.network as { blockTimeMS?: number } | undefined)?.blockTimeMS ??
105
108
  (syncInfo[0]?.network as { blockTimeMS?: number } | undefined)?.blockTimeMS;
106
- const lagThresholdMs = clockBlockTimeMS != null ? clockBlockTimeMS * 10 : 60_000;
109
+ const lagThresholdMs = ENV.EFFECTSTREAM_LAG_THRESHOLD_MS ??
110
+ (clockBlockTimeMS != null ? clockBlockTimeMS * 20 : 60_000);
107
111
 
108
112
  // Bounded hand-off queue between the merge and the apply loop. Backpressure caps
109
113
  // the in-memory queue so deep catch-up can't grow it toward the whole backlog
@@ -355,6 +359,9 @@ function* startup(
355
359
  viewStrategy,
356
360
  );
357
361
 
362
+ // Seed the merge loop's coalescing boundaries before any block is produced.
363
+ yield* initMergeCoalescingBoundaries(dbConn, lastBlockHeight + 1, config.migrations);
364
+
358
365
  return syncProtocols;
359
366
  } finally {
360
367
  releaseDBMutex(`startup-node`);