@effectstream/runtime 0.100.19 → 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/README.md CHANGED
@@ -69,6 +69,40 @@ exercised when this loop is running. The `StartConfig` shape determines
69
69
  which DB migrations apply, which state machines fire, and whether the
70
70
  runtime exposes a Fastify router.
71
71
 
72
+ ## Empty-block coalescing (catch-up)
73
+
74
+ The runtime replays one Effectstream block per main-clock tick, each in its
75
+ own DB transaction. During a deep catch-up over an idle gap (e.g. an NTP main
76
+ chain at one block/second across days), most of those blocks are **empty** — no
77
+ on-chain content, no scheduled input, no migration — yet still cost a full
78
+ `BEGIN…COMMIT` round-trip.
79
+ Empty-block coalescing folds a run of consecutive empty blocks into a **single
80
+ transaction** that writes only the run's endpoint. It is **opt-in** and off by
81
+ default:
82
+
83
+ ```bash
84
+ EFFECTSTREAM_COALESCE_EMPTY_BLOCKS=true
85
+ ```
86
+
87
+ Behavior and guarantees:
88
+
89
+ - **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.
92
+ - **Identical database.** A coalesced sync produces a byte-for-byte identical
93
+ database to a non-coalesced one. Empty blocks already never advance the block
94
+ hash or RNG seed, so only the endpoint's block row and the per-protocol resume
95
+ watermark are written — exactly as the normal path would leave them.
96
+ - **Never skips work.** A run is flushed before any block that has on-chain
97
+ content, a migration at its height, or a due block-height/timestamp-scheduled
98
+ input — so nothing that mutates state is ever folded away.
99
+ - **Tip stays visible.** If the producer goes quiet on an empty tail, the run is
100
+ flushed on a short idle timeout so the latest height still commits.
101
+ - A run is also capped (`MAX_EMPTY_RUN`) to bound transaction size and recovery.
102
+
103
+ Implementation: [`src/coalesce.ts`](./src/coalesce.ts) (the `createEmptyBlockCoalescer`
104
+ coordinator the main loop drives).
105
+
72
106
  ## Key exports
73
107
 
74
108
  - `init()` - `Operation<void>`. One-shot setup: OpenTelemetry, config validation, version pinning. Call before `start`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/runtime",
3
- "version": "0.100.19",
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.19",
17
- "@effectstream/config": "0.100.19",
18
- "@effectstream/coroutine": "0.100.19",
19
- "@effectstream/crypto": "0.100.19",
20
- "@effectstream/db": "0.100.19",
21
- "@effectstream/event-client": "0.100.19",
22
- "@effectstream/event-server": "0.100.19",
23
- "@effectstream/log": "0.100.19",
24
- "@effectstream/sm": "0.100.19",
25
- "@effectstream/sync": "0.100.19",
26
- "@effectstream/utils": "0.100.19",
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.
@@ -0,0 +1,291 @@
1
+ /** Empty-block coalescing during deep catch-up: fold consecutive no-op blocks into one transaction. */
2
+ import type { ChainBlock } from "@effectstream/sync";
3
+ import { call, type Operation, race, sleep, until } from "effection";
4
+ import type { Pool, PoolClient } from "pg";
5
+ import {
6
+ acquireDBMutex,
7
+ blockHeightDone,
8
+ deleteEmptyBlocks,
9
+ getEarliestScheduledBlockHeight,
10
+ getEarliestScheduledTimestamp,
11
+ getMigrationsForBlockHeight,
12
+ isTransientPgError,
13
+ poolErrors,
14
+ releaseDBMutex,
15
+ removePages,
16
+ saveLastBlock,
17
+ upsertPage,
18
+ } from "@effectstream/db";
19
+ import { Buffer } from "node:buffer";
20
+ import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
21
+ import { generateEffectstreamBlockHash, Prando } from "@effectstream/crypto";
22
+ import type { EffectstreamBlockHash } from "@effectstream/utils";
23
+ import type { DBMigrations } from "@effectstream/runtime";
24
+
25
+ /** Scheduled-input boundaries sampled once at run start (stable until the next non-empty block). */
26
+ export type EmptyRunBoundaries = {
27
+ minScheduledBlockHeight: number | null;
28
+ minScheduledTimestampMs: number | null;
29
+ };
30
+
31
+ export const MAX_EMPTY_RUN = 5_000;
32
+
33
+ const MAX_RETRY_BACKOFF_MS = 5_000;
34
+
35
+ export function* loadEmptyRunBoundaries(
36
+ dbConn: Pool,
37
+ ): Operation<EmptyRunBoundaries> {
38
+ const [bh] = yield* until(getEarliestScheduledBlockHeight.run(undefined, dbConn));
39
+ const [ts] = yield* until(getEarliestScheduledTimestamp.run(undefined, dbConn));
40
+ return {
41
+ minScheduledBlockHeight: bh?.min_block_height ?? null,
42
+ minScheduledTimestampMs: ts?.min_timestamp != null
43
+ ? new Date(ts.min_timestamp).getTime()
44
+ : null,
45
+ };
46
+ }
47
+
48
+ /** Mirrors the empty-block checks in process-blocks.ts without DB/STF work. */
49
+ export function isCoalescableEmptyBlock(
50
+ value: ChainBlock,
51
+ boundaries: EmptyRunBoundaries,
52
+ migrations: DBMigrations[] | undefined,
53
+ ): boolean {
54
+ if (value.primitives.length > 0) return false;
55
+ if (
56
+ getMigrationsForBlockHeight(migrations, value.blockNumber, value.blockNumber)
57
+ .length > 0
58
+ ) {
59
+ return false;
60
+ }
61
+ if (
62
+ boundaries.minScheduledBlockHeight != null &&
63
+ value.blockNumber >= boundaries.minScheduledBlockHeight
64
+ ) {
65
+ return false;
66
+ }
67
+ if (
68
+ boundaries.minScheduledTimestampMs != null &&
69
+ boundaries.minScheduledTimestampMs <= value.timestamp
70
+ ) {
71
+ return false;
72
+ }
73
+ return true;
74
+ }
75
+
76
+ export function* processCoalescedEmptyRun(
77
+ endpoint: ChainBlock,
78
+ pool: Pool,
79
+ previousBlockHash: EffectstreamBlockHash | null,
80
+ ): Operation<void> {
81
+ const lockName = `processing-blocks:${endpoint.blockNumber}`;
82
+ let attempt = 0;
83
+ while (true) {
84
+ let dbClient: PoolClient | undefined;
85
+ let transientErr: unknown;
86
+ try {
87
+ yield* acquireDBMutex(lockName);
88
+ dbClient = yield* until(pool.connect());
89
+ yield* writeEndpoint(
90
+ endpoint,
91
+ dbClient as unknown as Pool,
92
+ previousBlockHash,
93
+ );
94
+ return;
95
+ } catch (err) {
96
+ if (!isTransientPgError(err)) throw err;
97
+ transientErr = err;
98
+ } finally {
99
+ releaseDBMutex(lockName);
100
+ if (dbClient) {
101
+ try {
102
+ dbClient.release(transientErr as Error | undefined);
103
+ } catch {
104
+ /* already released/destroyed */
105
+ }
106
+ }
107
+ }
108
+
109
+ attempt++;
110
+ poolErrors.log(transientErr, ["sync-write"]);
111
+ const backoffMs = Math.min(500 * 2 ** (attempt - 1), MAX_RETRY_BACKOFF_MS);
112
+ log.local(
113
+ ComponentNames.EFFECTSTREAM_SYNC,
114
+ "block-processing",
115
+ SeverityNumber.WARN,
116
+ (l) =>
117
+ l(
118
+ `transient db error on coalesced run → block ${endpoint.blockNumber}; retry #${attempt} in ${backoffMs}ms: ${
119
+ String(transientErr)
120
+ }`,
121
+ ),
122
+ );
123
+ yield* sleep(backoffMs);
124
+ }
125
+ }
126
+
127
+ /** One transaction for the run endpoint; matches process-blocks.ts empty-block writes. */
128
+ function* writeEndpoint(
129
+ endpoint: ChainBlock,
130
+ dbConn: Pool,
131
+ previousBlockHash: EffectstreamBlockHash | null,
132
+ ): Operation<void> {
133
+ const blockHash = generateEffectstreamBlockHash(endpoint, previousBlockHash);
134
+ const seed = new Prando(blockHash).seed.toString();
135
+ try {
136
+ yield* until(dbConn.query("BEGIN"));
137
+
138
+ yield* call(() =>
139
+ saveLastBlock.run({
140
+ block_height: endpoint.blockNumber,
141
+ ver: 0,
142
+ main_chain_block_hash: Buffer.from(endpoint.blockNumber.toString()),
143
+ seed,
144
+ ms_timestamp: new Date(endpoint.timestamp),
145
+ }, dbConn)
146
+ );
147
+
148
+ yield* call(() => deleteEmptyBlocks.run(undefined, dbConn));
149
+ yield* call(() =>
150
+ blockHeightDone.run({
151
+ block_hash: Buffer.from("0x0"),
152
+ block_height: endpoint.blockNumber,
153
+ }, dbConn)
154
+ );
155
+
156
+ for (const { protocol_name, lastPage } of endpoint.resumePages) {
157
+ yield* until(
158
+ upsertPage.run({
159
+ protocol_name,
160
+ page_number: lastPage.ownBlockNumber,
161
+ page: JSON.stringify(lastPage),
162
+ }, dbConn),
163
+ );
164
+ yield* until(
165
+ removePages.run({
166
+ protocol_name,
167
+ page_number: lastPage.ownBlockNumber,
168
+ }, dbConn),
169
+ );
170
+ }
171
+
172
+ yield* until(dbConn.query("COMMIT"));
173
+ poolErrors.markHealthy();
174
+ } catch (err) {
175
+ try {
176
+ yield* until(dbConn.query("ROLLBACK"));
177
+ } catch (rollbackErr) {
178
+ log.remote(
179
+ ComponentNames.EFFECTSTREAM_SYNC,
180
+ "block-processing",
181
+ SeverityNumber.WARN,
182
+ (l) =>
183
+ l(
184
+ `ROLLBACK failed for coalesced run → block ${endpoint.blockNumber} (connection likely dead): ${
185
+ String(rollbackErr)
186
+ }`,
187
+ ),
188
+ );
189
+ }
190
+ throw err;
191
+ }
192
+ }
193
+
194
+ export type FinalizedBlockSubscription = {
195
+ next(): Operation<IteratorResult<ChainBlock, void>>;
196
+ };
197
+
198
+ function* readFinalizedBlock(
199
+ subscription: FinalizedBlockSubscription,
200
+ ): Operation<{ tag: "item"; value: ChainBlock } | { tag: "done" }> {
201
+ const n = yield* subscription.next();
202
+ if (n.done) return { tag: "done" };
203
+ // IteratorResult value type does not narrow on `done` under non-strict tsconfig.
204
+ return { tag: "item", value: n.value as ChainBlock };
205
+ }
206
+
207
+ export type EmptyBlockCoalescerOptions = {
208
+ enabled: boolean;
209
+ subscription: FinalizedBlockSubscription;
210
+ pool: Pool;
211
+ migrations: DBMigrations[] | undefined;
212
+ /** Coalesce only while `now - block.timestamp` exceeds this. Undefined disables lag gating. */
213
+ lagThresholdMs: number | undefined;
214
+ getPreviousBlockHash: () => EffectstreamBlockHash | null;
215
+ onFlush: (endpoint: ChainBlock, length: number) => void;
216
+ idleFlushMs?: number;
217
+ };
218
+
219
+ export type EmptyBlockCoalescer = {
220
+ advance(): Operation<ChainBlock | undefined>;
221
+ };
222
+
223
+ export function createEmptyBlockCoalescer(
224
+ opts: EmptyBlockCoalescerOptions,
225
+ ): EmptyBlockCoalescer {
226
+ const idleFlushMs = opts.idleFlushMs ?? 250;
227
+ let pendingRun:
228
+ | { endpoint: ChainBlock; boundaries: EmptyRunBoundaries; length: number }
229
+ | null = null;
230
+
231
+ function* flush(): Operation<void> {
232
+ if (pendingRun == null) return;
233
+ const { endpoint, length } = pendingRun;
234
+ yield* processCoalescedEmptyRun(
235
+ endpoint,
236
+ opts.pool,
237
+ opts.getPreviousBlockHash(),
238
+ );
239
+ pendingRun = null;
240
+ opts.onFlush(endpoint, length);
241
+ }
242
+
243
+ function* nextItem(): Operation<
244
+ { tag: "item"; value: ChainBlock } | { tag: "done" } | { tag: "idle" }
245
+ > {
246
+ if (pendingRun == null) return yield* readFinalizedBlock(opts.subscription);
247
+ return yield* race([
248
+ readFinalizedBlock(opts.subscription),
249
+ (function* () {
250
+ yield* sleep(idleFlushMs);
251
+ return { tag: "idle" as const };
252
+ })(),
253
+ ]);
254
+ }
255
+
256
+ function* advance(): Operation<ChainBlock | undefined> {
257
+ if (!opts.enabled) {
258
+ const n = yield* readFinalizedBlock(opts.subscription);
259
+ return n.tag === "done" ? undefined : n.value;
260
+ }
261
+ while (true) {
262
+ const next = yield* nextItem();
263
+ if (next.tag === "idle") {
264
+ yield* flush();
265
+ continue;
266
+ }
267
+ if (next.tag === "done") {
268
+ yield* flush();
269
+ return undefined;
270
+ }
271
+ const value = next.value;
272
+ const behindTip = opts.lagThresholdMs != null &&
273
+ Date.now() - value.timestamp > opts.lagThresholdMs;
274
+ if (behindTip && value.primitives.length === 0) {
275
+ const boundaries = pendingRun?.boundaries ??
276
+ (yield* loadEmptyRunBoundaries(opts.pool));
277
+ if (isCoalescableEmptyBlock(value, boundaries, opts.migrations)) {
278
+ pendingRun = pendingRun
279
+ ? { boundaries, endpoint: value, length: pendingRun.length + 1 }
280
+ : { boundaries, endpoint: value, length: 1 };
281
+ if (pendingRun.length >= MAX_EMPTY_RUN) yield* flush();
282
+ continue;
283
+ }
284
+ }
285
+ yield* flush();
286
+ return value;
287
+ }
288
+ }
289
+
290
+ return { advance };
291
+ }
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";
@@ -32,6 +34,7 @@ import {
32
34
  type PendingEvent,
33
35
  processFinalizedBlockWithRetry,
34
36
  } from "./process-blocks.ts";
37
+ import { createEmptyBlockCoalescer } from "./coalesce.ts";
35
38
  import { startHttpServer } from "./api/http-server.ts";
36
39
  import { recordAppliedBlock } from "./api/apply-status.ts";
37
40
  import type { StartConfig } from "./types.ts";
@@ -92,19 +95,21 @@ export function* start(config: StartConfig): Operation<void> {
92
95
  );
93
96
  });
94
97
 
98
+ // 10× main clock block time (NTP if present, else protocol 0). Undefined disables lag gating.
95
99
  const ntpConfig = syncInfo.find(s => s.networkType === ConfigNetworkType.NTP);
96
- const ntpBlockTimeMS = (ntpConfig?.network as { blockTimeMS: number } | undefined)?.blockTimeMS;
97
- const lagThresholdMs = ntpBlockTimeMS != null ? ntpBlockTimeMS * 10 : undefined;
100
+ const clockBlockTimeMS =
101
+ (ntpConfig?.network as { blockTimeMS?: number } | undefined)?.blockTimeMS ??
102
+ (syncInfo[0]?.network as { blockTimeMS?: number } | undefined)?.blockTimeMS;
103
+ const lagThresholdMs = clockBlockTimeMS != null ? clockBlockTimeMS * 10 : undefined;
98
104
 
99
105
  const finalizedBlockStream = createChannel<ChainBlock>();
100
106
 
101
107
  // Subscribe BEFORE spawning the merge: effection channels drop sends with no
102
- // active subscriber, so a fast restart (in-memory state, no RPC wait) could
103
- // 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.
104
110
  const finalizedBlocks = yield* finalizedBlockStream;
105
- // NOTE: this subscriber's queue is unbounded. During deep catch-up the merge
106
- // can outpace the per-block processing below and grow it without bound — the
107
- // 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).
108
113
 
109
114
  yield* spawn(() => startMerge(syncProtocols, finalizedBlockStream));
110
115
 
@@ -148,13 +153,39 @@ export function* start(config: StartConfig): Operation<void> {
148
153
  yield* spawn(() => runSnapshotLoop(config.snapshotConfig!));
149
154
  }
150
155
 
151
- let nextBlock = yield* finalizedBlocks.next();
152
- for (; !nextBlock.done; nextBlock = yield* finalizedBlocks.next()) {
153
- const value = nextBlock.value;
154
- // processFinalizedBlockWithRetry owns connection checkout, the per-block
155
- // DB mutex (PGLite), and transient-pg retry/backoff. App events it
156
- // collects are flushed below ONLY after it returns — i.e. strictly after
157
- // the block's COMMIT.
156
+ const coalescer = createEmptyBlockCoalescer({
157
+ enabled: ENV.EFFECTSTREAM_COALESCE_EMPTY_BLOCKS,
158
+ subscription: finalizedBlocks,
159
+ pool: dbConn as any, // Pool,
160
+ migrations: config.migrations,
161
+ lagThresholdMs,
162
+ getPreviousBlockHash: () => blockHash,
163
+ onFlush: (endpoint, length) => {
164
+ recordAppliedBlock(endpoint);
165
+ emitLatestBlocks(
166
+ endpoint.blockNumber,
167
+ endpoint.timestamp,
168
+ getRangesForSyncProtocols(endpoint),
169
+ );
170
+ log.local(
171
+ ComponentNames.EFFECTSTREAM_SYNC,
172
+ "block-merge",
173
+ SeverityNumber.INFO,
174
+ (l) =>
175
+ l(
176
+ `coalesced ${length} empty block(s) → block ${endpoint.blockNumber}`,
177
+ ),
178
+ );
179
+ },
180
+ });
181
+
182
+ while (true) {
183
+ const value = yield* coalescer.advance();
184
+ if (value === undefined) break;
185
+
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.
158
189
  const result = yield* processFinalizedBlockWithRetry(
159
190
  value,
160
191
  config,
@@ -171,21 +202,14 @@ export function* start(config: StartConfig): Operation<void> {
171
202
  // Used to emit & log the block range for each protocol.
172
203
  const contentBlocksForProtocol = getRangesForSyncProtocols(value);
173
204
 
174
- // 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).
175
209
  //
176
- // Opifex's TcpClient assigns packet IDs synchronously in client.publish()
177
- // and writes to the socket via Node's net.Socket, which has its own write
178
- // queue — so concurrent publish() calls land on the wire in call order.
179
- // MQTT 3.1.1 §4.6 then guarantees in-order delivery to each subscriber
180
- // per (publisher, topic, QoS). We don't need a wrapper queue.
181
- //
182
- // TODO(scaling): the broker runs in-process (see `new EventBroker(...)`
183
- // above). Fan-out to N subscribers consumes the same event loop as block
184
- // processing. At ~thousands of subscribers, consider:
185
- // 1) moving the broker to a worker / separate process,
186
- // 2) a publisher-side circuit breaker if ctx.unresolvedPublish.size > N
187
- // (drop events with a counter for observability).
188
- // 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.
189
213
  emitLatestBlocks(
190
214
  value.blockNumber,
191
215
  value.timestamp,
@@ -237,13 +261,9 @@ function getRangesForSyncProtocols(value: ChainBlock): Record<string, [number, n
237
261
  }
238
262
 
239
263
  /**
240
- * Publish built-in block-level events.
241
- *
242
- * Fire-and-forget for the same reasons as the app-event flush above
243
- * (do not stall the sync loop; ordering preserved by Opifex + MQTT 3.1.1).
244
- *
245
- * Errors are caught locally so they don't escape to the global
246
- * `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.
247
267
  */
248
268
  function emitLatestBlocks(
249
269
  rollUpBlockHeight: number,
@@ -294,41 +314,48 @@ function* startup(
294
314
  });
295
315
  });
296
316
 
297
-
298
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
+ }
299
325
 
300
- // Dev-only reset of user-owned public tables
301
- if (config.dev?.resetPublicData) {
302
- yield* resetPublicTables(dbConn as any); // Client,
303
- }
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
+ );
304
335
 
305
- // When the node is started, we apply system migrations.
306
- // Either system initial migrations, or migrations given a Paima Engine Update.
307
- yield* applySystemMigrations(
308
- config.appVersion,
309
- versionInfo,
310
- lastBlockHeight,
311
- dbConn,
312
- config.migrations,
313
- );
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);
314
340
 
315
- // Validate that immutable config fields (e.g. NTP startTime, startBlockHeight)
316
- // have not changed since the last run. Persists a snapshot on first start.
317
- // Must run after system migrations so the snapshot table exists.
318
- yield* validateAndSnapshotConfig(syncInfo, dbConn);
341
+ const syncProtocols = yield* genSyncProtocols(dbConn as any, // Client,
342
+ syncInfo);
319
343
 
320
- const syncProtocols = yield* genSyncProtocols(dbConn as any, // Client,
321
- syncInfo);
344
+ const capabilities = yield* until(detectCapabilities(dbConn as any));
345
+ const viewStrategy = selectViewStrategy(capabilities);
322
346
 
323
- yield* createDynamicTables(
324
- versionInfo,
325
- lastBlockHeight,
326
- dbConn as any, // Client,
327
- syncProtocols,
328
- );
347
+ yield* createDynamicTables(
348
+ versionInfo,
349
+ lastBlockHeight,
350
+ dbConn as any, // Client,
351
+ syncProtocols,
352
+ viewStrategy,
353
+ );
329
354
 
330
- releaseDBMutex(`startup-node`);
331
- return syncProtocols;
355
+ return syncProtocols;
356
+ } finally {
357
+ releaseDBMutex(`startup-node`);
358
+ }
332
359
  }
333
360
 
334
361
  // Convert the primitive config to the final primitive instance
@@ -0,0 +1,69 @@
1
+ import { expect, test } from "bun:test";
2
+ import {
3
+ type EmptyRunBoundaries,
4
+ isCoalescableEmptyBlock,
5
+ } from "../src/coalesce.ts";
6
+
7
+ const NO_BOUNDARIES: EmptyRunBoundaries = {
8
+ minScheduledBlockHeight: null,
9
+ minScheduledTimestampMs: null,
10
+ };
11
+
12
+ function block(
13
+ blockNumber: number,
14
+ timestamp: number,
15
+ primitives: unknown[] = [],
16
+ ): any {
17
+ return { blockNumber, timestamp, blockInfo: [], resumePages: [], primitives };
18
+ }
19
+
20
+ test("empty block with no boundaries is coalescable", () => {
21
+ expect(isCoalescableEmptyBlock(block(10, 10_000), NO_BOUNDARIES, undefined))
22
+ .toBe(true);
23
+ });
24
+
25
+ test("block with on-chain content is NOT coalescable", () => {
26
+ expect(
27
+ isCoalescableEmptyBlock(block(10, 10_000, [{}]), NO_BOUNDARIES, undefined),
28
+ ).toBe(false);
29
+ });
30
+
31
+ test("block where a migration fires is NOT coalescable", () => {
32
+ const migrations = [{ name: "m1", sql: "", blockHeight: 10 }];
33
+ expect(isCoalescableEmptyBlock(block(10, 10_000), NO_BOUNDARIES, migrations))
34
+ .toBe(false);
35
+ expect(isCoalescableEmptyBlock(block(9, 9_000), NO_BOUNDARIES, migrations))
36
+ .toBe(true);
37
+ });
38
+
39
+ test("block at/after a pending block-height-scheduled input is NOT coalescable", () => {
40
+ const boundaries: EmptyRunBoundaries = {
41
+ minScheduledBlockHeight: 10,
42
+ minScheduledTimestampMs: null,
43
+ };
44
+ expect(isCoalescableEmptyBlock(block(9, 9_000), boundaries, undefined)).toBe(
45
+ true,
46
+ );
47
+ expect(isCoalescableEmptyBlock(block(10, 10_000), boundaries, undefined)).toBe(
48
+ false,
49
+ );
50
+ expect(isCoalescableEmptyBlock(block(11, 11_000), boundaries, undefined)).toBe(
51
+ false,
52
+ );
53
+ });
54
+
55
+ test("block at/after a due timestamp-scheduled input is NOT coalescable", () => {
56
+ const boundaries: EmptyRunBoundaries = {
57
+ minScheduledBlockHeight: null,
58
+ minScheduledTimestampMs: 10_000,
59
+ };
60
+ expect(isCoalescableEmptyBlock(block(9, 9_999), boundaries, undefined)).toBe(
61
+ true,
62
+ );
63
+ expect(isCoalescableEmptyBlock(block(10, 10_000), boundaries, undefined)).toBe(
64
+ false,
65
+ );
66
+ expect(isCoalescableEmptyBlock(block(11, 10_001), boundaries, undefined)).toBe(
67
+ false,
68
+ );
69
+ });
@@ -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);
@@ -0,0 +1,133 @@
1
+ /** E2E: coalesced vs non-coalesced syncs produce the same DB; resume through coalescing matches a clean run. */
2
+ import { expect, test } from "bun:test";
3
+ import { setupHarness } from "./harness.ts";
4
+ import {
5
+ compareConsistencySnapshots,
6
+ type ConsistencySnapshot,
7
+ dumpConsistencySnapshot,
8
+ type SnapshotDiff,
9
+ type SnapshotOpts,
10
+ SYNC_BOOKKEEPING_TABLES,
11
+ } from "./consistency-snapshot.ts";
12
+
13
+ function summarize(snap: ConsistencySnapshot): string {
14
+ let rows = 0;
15
+ for (const f of snap.values()) rows += f.rowCount;
16
+ return `${snap.size} tables, ${rows} rows`;
17
+ }
18
+
19
+ function formatDiffs(diffs: SnapshotDiff[]): string {
20
+ return diffs
21
+ .map((d) => `${d.table} [${d.reason} A=${d.aRows ?? "—"} B=${d.bRows ?? "—"}]`)
22
+ .join(", ");
23
+ }
24
+
25
+ function logComparison(
26
+ tag: string,
27
+ aLabel: string,
28
+ a: ConsistencySnapshot,
29
+ bLabel: string,
30
+ b: ConsistencySnapshot,
31
+ diffs: SnapshotDiff[],
32
+ ): void {
33
+ console.log(tag);
34
+ console.log(`${tag} ${aLabel}: ${summarize(a)}`);
35
+ console.log(`${tag} ${bLabel}: ${summarize(b)}`);
36
+ console.log(
37
+ diffs.length === 0
38
+ ? `${tag} → IDENTICAL (0 differences)`
39
+ : `${tag} → ${diffs.length} diff(s): ${formatDiffs(diffs)}`,
40
+ );
41
+ }
42
+
43
+ test(
44
+ "coalesced and non-coalesced from-scratch syncs produce identical databases",
45
+ async () => {
46
+ const events = [
47
+ { atBlock: 25, marker: "e25" },
48
+ { atBlock: 50, marker: "e50" },
49
+ { atBlock: 75, marker: "e75" },
50
+ ];
51
+
52
+ const runOnce = async (
53
+ coalesce: boolean,
54
+ apiPort: number,
55
+ ): Promise<ConsistencySnapshot> => {
56
+ const h = await setupHarness();
57
+ try {
58
+ await h.runToHeight({
59
+ events,
60
+ tips: { mainClock: 100, parallelP: 200 },
61
+ target: 100,
62
+ apiPort,
63
+ coalesce,
64
+ });
65
+ return await dumpConsistencySnapshot(h.pool);
66
+ } finally {
67
+ await h.teardown();
68
+ }
69
+ };
70
+
71
+ const off = await runOnce(false, 19160);
72
+ const on = await runOnce(true, 19161);
73
+
74
+ const { diffs } = compareConsistencySnapshots(off, on);
75
+ logComparison("[coalesce A] from-scratch sync to height 100:", "coalesce OFF", off, "coalesce ON", on, diffs);
76
+ expect(diffs).toEqual([]);
77
+ },
78
+ 120_000,
79
+ );
80
+
81
+ test(
82
+ "a coalesced sync that resumes matches a clean coalesced sync",
83
+ async () => {
84
+ const events = [{ atBlock: 20, marker: "gap20" }];
85
+ const parallelStepSize = 1000;
86
+ const dumpOpts: SnapshotOpts = { excludeTables: SYNC_BOOKKEEPING_TABLES };
87
+
88
+ const ref = await setupHarness();
89
+ let refSnap: ConsistencySnapshot;
90
+ try {
91
+ await ref.runToHeight({
92
+ events,
93
+ parallelStepSize,
94
+ tips: { mainClock: 30, parallelP: 50 },
95
+ target: 30,
96
+ apiPort: 19170,
97
+ coalesce: true,
98
+ });
99
+ refSnap = await dumpConsistencySnapshot(ref.pool, dumpOpts);
100
+ } finally {
101
+ await ref.teardown();
102
+ }
103
+
104
+ const resumed = await setupHarness();
105
+ let resumeSnap: ConsistencySnapshot;
106
+ try {
107
+ await resumed.runToHeight({
108
+ events,
109
+ parallelStepSize,
110
+ tips: { mainClock: 10, parallelP: 50 },
111
+ target: 10,
112
+ apiPort: 19171,
113
+ coalesce: true,
114
+ });
115
+ await resumed.runToHeight({
116
+ events,
117
+ parallelStepSize,
118
+ tips: { mainClock: 30, parallelP: 50 },
119
+ target: 30,
120
+ apiPort: 19172,
121
+ coalesce: true,
122
+ });
123
+ resumeSnap = await dumpConsistencySnapshot(resumed.pool, dumpOpts);
124
+ } finally {
125
+ await resumed.teardown();
126
+ }
127
+
128
+ const { diffs } = compareConsistencySnapshots(refSnap, resumeSnap);
129
+ logComparison("[coalesce B] clean vs resumed coalesced sync to height 30:", "clean ref", refSnap, "resumed", resumeSnap, diffs);
130
+ expect(diffs).toEqual([]);
131
+ },
132
+ 120_000,
133
+ );
@@ -305,6 +305,8 @@ export type RunToHeightOpts = {
305
305
  /** Optional per-protocol page watermarks to also wait for before exiting. */
306
306
  waitPages?: Record<string, number>;
307
307
  securityNamespace?: string;
308
+ /** Enable/disable empty-block coalescing. */
309
+ coalesce?: boolean;
308
310
  };
309
311
 
310
312
  const RUNNER = join(import.meta.dir, "node-runner.ts");
@@ -329,6 +331,7 @@ function makeRunToHeight(
329
331
  target: opts.target,
330
332
  apiPort: opts.apiPort,
331
333
  waitPages: opts.waitPages,
334
+ coalesce: opts.coalesce,
332
335
  };
333
336
  return new Promise<void>((resolve, reject) => {
334
337
  const child = spawn("bun", [RUNNER, JSON.stringify(spec)], {
@@ -48,6 +48,7 @@ export type RunSpec = {
48
48
  * ahead and persisted its page row before we kill the process.
49
49
  */
50
50
  waitPages?: Record<string, number>;
51
+ coalesce?: boolean;
51
52
  };
52
53
 
53
54
  const noopStf: StartConfigGameStateTransitions = function* () {};
@@ -60,6 +61,7 @@ async function main() {
60
61
  // broker (it binds fixed ports and isn't freed on halt) and a per-run API port.
61
62
  process.env.MQTT_BROKER = "false";
62
63
  process.env.EFFECTSTREAM_API_PORT = String(spec.apiPort);
64
+ process.env.EFFECTSTREAM_COALESCE_EMPTY_BLOCKS = spec.coalesce ? "true" : "false";
63
65
  const isPglite = process.env.PGLITE === "true";
64
66
 
65
67
  for (const [name, tip] of Object.entries(spec.tips)) {