@effectstream/runtime 0.100.25 → 0.100.27

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.25",
3
+ "version": "0.100.27",
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.25",
17
- "@effectstream/config": "0.100.25",
18
- "@effectstream/coroutine": "0.100.25",
19
- "@effectstream/crypto": "0.100.25",
20
- "@effectstream/db": "0.100.25",
21
- "@effectstream/event-client": "0.100.25",
22
- "@effectstream/event-server": "0.100.25",
23
- "@effectstream/log": "0.100.25",
24
- "@effectstream/sm": "0.100.25",
25
- "@effectstream/sync": "0.100.25",
26
- "@effectstream/utils": "0.100.25",
16
+ "@effectstream/concise": "0.100.27",
17
+ "@effectstream/config": "0.100.27",
18
+ "@effectstream/coroutine": "0.100.27",
19
+ "@effectstream/crypto": "0.100.27",
20
+ "@effectstream/db": "0.100.27",
21
+ "@effectstream/event-client": "0.100.27",
22
+ "@effectstream/event-server": "0.100.27",
23
+ "@effectstream/log": "0.100.27",
24
+ "@effectstream/sm": "0.100.27",
25
+ "@effectstream/sync": "0.100.27",
26
+ "@effectstream/utils": "0.100.27",
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 = {
@@ -34,17 +35,63 @@ const MAX_RETRY_BACKOFF_MS = 5_000;
34
35
 
35
36
  export function* loadEmptyRunBoundaries(
36
37
  dbConn: Pool,
38
+ fromBlockNumber: number,
37
39
  ): Operation<EmptyRunBoundaries> {
38
40
  const [bh] = yield* until(getEarliestScheduledBlockHeight.run(undefined, dbConn));
39
41
  const [ts] = yield* until(getEarliestScheduledTimestamp.run(undefined, dbConn));
42
+ // Ignore scheduled block heights already in the past: a stale unprocessed row
43
+ // (e.g. from a DB reset) would otherwise permanently suppress coalescing for
44
+ // all current blocks, since every block number >= the stale height.
45
+ const minScheduledBlockHeight = bh?.min_block_height != null && bh.min_block_height >= fromBlockNumber
46
+ ? bh.min_block_height
47
+ : null;
48
+ const minScheduledTimestampMs = ts?.min_timestamp != null
49
+ ? new Date(ts.min_timestamp).getTime()
50
+ : null;
51
+
52
+ mergeCoalescingBoundaries.minScheduledBlockHeight = minScheduledBlockHeight;
53
+ mergeCoalescingBoundaries.minScheduledTimestampMs = minScheduledTimestampMs;
54
+
40
55
  return {
41
- minScheduledBlockHeight: bh?.min_block_height ?? null,
42
- minScheduledTimestampMs: ts?.min_timestamp != null
43
- ? new Date(ts.min_timestamp).getTime()
44
- : null,
56
+ minScheduledBlockHeight,
57
+ minScheduledTimestampMs,
45
58
  };
46
59
  }
47
60
 
61
+ /**
62
+ * Seed {@link mergeCoalescingBoundaries} once at startup so the merge loop knows
63
+ * which empty blocks it may coalesce before the runtime has applied any block.
64
+ * `loadEmptyRunBoundaries` keeps these in sync afterwards on each non-empty block.
65
+ *
66
+ * @param fromBlockNumber the next block height to apply (lastBlockHeight + 1);
67
+ * scheduled heights below this are stale and ignored (see {@link loadEmptyRunBoundaries}).
68
+ */
69
+ export function* initMergeCoalescingBoundaries(
70
+ dbConn: Pool | Client,
71
+ fromBlockNumber: number,
72
+ migrations: DBMigrations[] | undefined,
73
+ ): Operation<void> {
74
+ const [bh] = yield* until(getEarliestScheduledBlockHeight.run(undefined, dbConn));
75
+ const [ts] = yield* until(getEarliestScheduledTimestamp.run(undefined, dbConn));
76
+ const minScheduledBlockHeight = bh?.min_block_height != null && bh.min_block_height >= fromBlockNumber
77
+ ? bh.min_block_height
78
+ : null;
79
+ const minScheduledTimestampMs = ts?.min_timestamp != null
80
+ ? new Date(ts.min_timestamp).getTime()
81
+ : null;
82
+
83
+ const migrationBlockHeights: number[] = [];
84
+ for (const m of migrations ?? []) {
85
+ if (m.blockHeight != null) {
86
+ migrationBlockHeights.push(m.blockHeight);
87
+ }
88
+ }
89
+
90
+ mergeCoalescingBoundaries.minScheduledBlockHeight = minScheduledBlockHeight;
91
+ mergeCoalescingBoundaries.minScheduledTimestampMs = minScheduledTimestampMs;
92
+ mergeCoalescingBoundaries.migrationBlockHeights = migrationBlockHeights;
93
+ }
94
+
48
95
  /** Mirrors the empty-block checks in process-blocks.ts without DB/STF work. */
49
96
  export function isCoalescableEmptyBlock(
50
97
  value: ChainBlock,
@@ -274,7 +321,7 @@ export function createEmptyBlockCoalescer(
274
321
  Date.now() - value.timestamp > opts.lagThresholdMs;
275
322
  if (behindTip && value.primitives.length === 0) {
276
323
  const boundaries = pendingRun?.boundaries ??
277
- (yield* loadEmptyRunBoundaries(opts.pool));
324
+ (yield* loadEmptyRunBoundaries(opts.pool, value.blockNumber));
278
325
  if (isCoalescableEmptyBlock(value, boundaries, opts.migrations)) {
279
326
  pendingRun = pendingRun
280
327
  ? { boundaries, endpoint: value, length: pendingRun.length + 1 }
@@ -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,12 +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). Undefined disables lag gating.
102
+ // 20× main clock block time (NTP if present, else protocol 0).
103
+ // Falls back to 60 s so coalescing is not silently disabled for chains that
104
+ // don't expose a blockTimeMS on their network config (e.g. Midnight-as-main).
100
105
  const ntpConfig = syncInfo.find(s => s.networkType === ConfigNetworkType.NTP);
101
106
  const clockBlockTimeMS =
102
107
  (ntpConfig?.network as { blockTimeMS?: number } | undefined)?.blockTimeMS ??
103
108
  (syncInfo[0]?.network as { blockTimeMS?: number } | undefined)?.blockTimeMS;
104
- const lagThresholdMs = clockBlockTimeMS != null ? clockBlockTimeMS * 10 : undefined;
109
+ const lagThresholdMs = ENV.EFFECTSTREAM_LAG_THRESHOLD_MS ??
110
+ (clockBlockTimeMS != null ? clockBlockTimeMS * 20 : 60_000);
105
111
 
106
112
  // Bounded hand-off queue between the merge and the apply loop. Backpressure caps
107
113
  // the in-memory queue so deep catch-up can't grow it toward the whole backlog
@@ -353,6 +359,9 @@ function* startup(
353
359
  viewStrategy,
354
360
  );
355
361
 
362
+ // Seed the merge loop's coalescing boundaries before any block is produced.
363
+ yield* initMergeCoalescingBoundaries(dbConn, lastBlockHeight + 1, config.migrations);
364
+
356
365
  return syncProtocols;
357
366
  } finally {
358
367
  releaseDBMutex(`startup-node`);