@effectstream/runtime 0.100.18 → 0.100.20
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 +34 -0
- package/conciseinputhash-determinism.md +114 -0
- package/package.json +12 -12
- package/src/api/apply-status.ts +30 -0
- package/src/api/http-server.ts +74 -7
- package/src/coalesce.ts +291 -0
- package/src/config-snapshot.ts +12 -4
- package/src/main.ts +67 -33
- package/src/process-blocks.ts +126 -27
- package/src/types.ts +14 -0
- package/test/coalesce.unit.test.ts +69 -0
- package/test/reproduction/buffering.test.ts +389 -0
- package/test/reproduction/coalesce.test.ts +133 -0
- package/test/reproduction/consistency-snapshot.ts +168 -0
- package/test/reproduction/consistency.test.ts +197 -0
- package/test/reproduction/harness.ts +616 -0
- package/test/reproduction/node-runner.ts +154 -0
- package/test/reproduction/poll.ts +160 -0
- package/test/reproduction/resume.test.ts +77 -0
- package/test/reproduction/sanity.test.ts +74 -0
- package/test/reproduction/scenario.ts +126 -0
package/src/main.ts
CHANGED
|
@@ -22,15 +22,19 @@ import { startMerge, startSync } from "@effectstream/sync";
|
|
|
22
22
|
import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
|
|
23
23
|
import {
|
|
24
24
|
createChannel,
|
|
25
|
-
each,
|
|
26
25
|
type Operation,
|
|
27
26
|
sleep,
|
|
28
27
|
spawn,
|
|
29
28
|
until,
|
|
30
29
|
} from "effection";
|
|
31
30
|
import { initTelemetry } from "./telemetry.ts";
|
|
32
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
type PendingEvent,
|
|
33
|
+
processFinalizedBlockWithRetry,
|
|
34
|
+
} from "./process-blocks.ts";
|
|
35
|
+
import { createEmptyBlockCoalescer } from "./coalesce.ts";
|
|
33
36
|
import { startHttpServer } from "./api/http-server.ts";
|
|
37
|
+
import { recordAppliedBlock } from "./api/apply-status.ts";
|
|
34
38
|
import type { StartConfig } from "./types.ts";
|
|
35
39
|
import type { Client } from "pg";
|
|
36
40
|
import type { EffectstreamBlockHash } from "@effectstream/utils";
|
|
@@ -62,6 +66,9 @@ export function* start(config: StartConfig): Operation<void> {
|
|
|
62
66
|
const syncProtocols = yield* startup(dbConn as any, // Client,
|
|
63
67
|
syncInfo, config);
|
|
64
68
|
|
|
69
|
+
// Test-only: surface live sync protocols (e.g. for buffer-size assertions).
|
|
70
|
+
config.dev?.onStarted?.({ syncProtocols });
|
|
71
|
+
|
|
65
72
|
log.remote(
|
|
66
73
|
ComponentNames.EFFECTSTREAM_RUNTIME,
|
|
67
74
|
[],
|
|
@@ -86,12 +93,23 @@ export function* start(config: StartConfig): Operation<void> {
|
|
|
86
93
|
);
|
|
87
94
|
});
|
|
88
95
|
|
|
96
|
+
// 10× main clock block time (NTP if present, else protocol 0). Undefined disables lag gating.
|
|
89
97
|
const ntpConfig = syncInfo.find(s => s.networkType === ConfigNetworkType.NTP);
|
|
90
|
-
const
|
|
91
|
-
|
|
98
|
+
const clockBlockTimeMS =
|
|
99
|
+
(ntpConfig?.network as { blockTimeMS?: number } | undefined)?.blockTimeMS ??
|
|
100
|
+
(syncInfo[0]?.network as { blockTimeMS?: number } | undefined)?.blockTimeMS;
|
|
101
|
+
const lagThresholdMs = clockBlockTimeMS != null ? clockBlockTimeMS * 10 : undefined;
|
|
92
102
|
|
|
93
103
|
const finalizedBlockStream = createChannel<ChainBlock>();
|
|
94
104
|
|
|
105
|
+
// 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
|
+
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).
|
|
112
|
+
|
|
95
113
|
yield* spawn(() => startMerge(syncProtocols, finalizedBlockStream));
|
|
96
114
|
|
|
97
115
|
const heartbeatIntervalMs = 60_000;
|
|
@@ -134,37 +152,53 @@ export function* start(config: StartConfig): Operation<void> {
|
|
|
134
152
|
yield* spawn(() => runSnapshotLoop(config.snapshotConfig!));
|
|
135
153
|
}
|
|
136
154
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
+
const coalescer = createEmptyBlockCoalescer({
|
|
156
|
+
enabled: ENV.EFFECTSTREAM_COALESCE_EMPTY_BLOCKS,
|
|
157
|
+
subscription: finalizedBlocks,
|
|
158
|
+
pool: dbConn as any, // Pool,
|
|
159
|
+
migrations: config.migrations,
|
|
160
|
+
lagThresholdMs,
|
|
161
|
+
getPreviousBlockHash: () => blockHash,
|
|
162
|
+
onFlush: (endpoint, length) => {
|
|
163
|
+
recordAppliedBlock(endpoint);
|
|
164
|
+
emitLatestBlocks(
|
|
165
|
+
endpoint.blockNumber,
|
|
166
|
+
endpoint.timestamp,
|
|
167
|
+
getRangesForSyncProtocols(endpoint),
|
|
168
|
+
);
|
|
169
|
+
log.local(
|
|
170
|
+
ComponentNames.EFFECTSTREAM_SYNC,
|
|
171
|
+
"block-merge",
|
|
172
|
+
SeverityNumber.INFO,
|
|
173
|
+
(l) =>
|
|
174
|
+
l(
|
|
175
|
+
`coalesced ${length} empty block(s) → block ${endpoint.blockNumber}`,
|
|
176
|
+
),
|
|
155
177
|
);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
while (true) {
|
|
182
|
+
const value = yield* coalescer.advance();
|
|
183
|
+
if (value === undefined) break;
|
|
184
|
+
|
|
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.
|
|
189
|
+
const result = yield* processFinalizedBlockWithRetry(
|
|
190
|
+
value,
|
|
191
|
+
config,
|
|
192
|
+
dbConn as any, // Pool,
|
|
193
|
+
blockHash,
|
|
194
|
+
);
|
|
195
|
+
const blockAppEvents: PendingEvent[] = result.events;
|
|
196
|
+
if (result.blockHash !== "0x0") {
|
|
197
|
+
blockHash = result.blockHash;
|
|
166
198
|
}
|
|
167
199
|
|
|
200
|
+
recordAppliedBlock(value); // apply-stage liveness for /debug/metrics
|
|
201
|
+
|
|
168
202
|
// Used to emit & log the block range for each protocol.
|
|
169
203
|
const contentBlocksForProtocol = getRangesForSyncProtocols(value);
|
|
170
204
|
|
|
@@ -215,7 +249,7 @@ export function* start(config: StartConfig): Operation<void> {
|
|
|
215
249
|
}...${lagSuffix} | ${JSON.stringify(contentBlocksForProtocol)}`,
|
|
216
250
|
),
|
|
217
251
|
);
|
|
218
|
-
yield*
|
|
252
|
+
if (config.dev?.applyDelayMs) yield* sleep(config.dev.applyDelayMs);
|
|
219
253
|
}
|
|
220
254
|
}
|
|
221
255
|
|
package/src/process-blocks.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ChainBlock } from "@effectstream/sync";
|
|
2
|
-
import { call, type Operation, until } from "effection";
|
|
3
|
-
import type { Pool } from "pg";
|
|
2
|
+
import { call, type Operation, sleep, until } from "effection";
|
|
3
|
+
import type { Pool, PoolClient } from "pg";
|
|
4
4
|
import { type BaseStfInput, type EmitFn, primitiveTransitionFunction } from "@effectstream/sm";
|
|
5
5
|
import { PreparedQuery } from "@pgtyped/runtime";
|
|
6
6
|
import type {
|
|
@@ -9,19 +9,24 @@ import type {
|
|
|
9
9
|
SyncStateUpdateStream,
|
|
10
10
|
} from "@effectstream/coroutine";
|
|
11
11
|
import {
|
|
12
|
+
acquireDBMutex,
|
|
12
13
|
blockHeightDone,
|
|
13
14
|
deleteEmptyBlocks,
|
|
14
15
|
deleteScheduled,
|
|
15
16
|
getFutureGameInputByBlockHeight,
|
|
16
17
|
getFutureGameInputByMaxTimestamp,
|
|
17
18
|
insertGameInputResult,
|
|
19
|
+
isTransientPgError,
|
|
20
|
+
poolErrors,
|
|
21
|
+
releaseDBMutex,
|
|
18
22
|
removePages,
|
|
19
23
|
saveLastBlock,
|
|
24
|
+
upsertPage,
|
|
20
25
|
} from "@effectstream/db";
|
|
21
26
|
import { Buffer } from "node:buffer";
|
|
22
27
|
import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
|
|
23
28
|
import type { StartConfig } from "./types.ts";
|
|
24
|
-
import type {
|
|
29
|
+
import type { EffectstreamBlockHash } from "@effectstream/utils";
|
|
25
30
|
import { generateEffectstreamBlockHash, Prando } from "@effectstream/crypto";
|
|
26
31
|
import { applyUserMigrations } from "./version-migrations.ts";
|
|
27
32
|
import type { EventPathAndDef } from "@effectstream/event-client";
|
|
@@ -308,36 +313,53 @@ export function* processFinalizedBlock(
|
|
|
308
313
|
}, dbConn)
|
|
309
314
|
);
|
|
310
315
|
|
|
311
|
-
/* STEP 7:
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
316
|
+
/* STEP 7: Persist the block-accurate resume marker per protocol, inside this
|
|
317
|
+
* block's transaction (the runtime is the sole writer of the resume
|
|
318
|
+
* position). Upsert at page_number = ownBlockNumber, then prune
|
|
319
|
+
* everything below → one row per protocol = the committed watermark.
|
|
320
|
+
* Full mechanism: @effectstream/sync CLAUDE.md, design idea #5.
|
|
315
321
|
*/
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
)
|
|
324
|
-
latestBlocks[blockInfo.protocol_name] = blockInfo.block_number;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
for (const [protocolName, blockNumber] of Object.entries(latestBlocks)) {
|
|
322
|
+
for (const { protocol_name, lastPage } of value.resumePages) {
|
|
323
|
+
yield* until(
|
|
324
|
+
upsertPage.run({
|
|
325
|
+
protocol_name,
|
|
326
|
+
page_number: lastPage.ownBlockNumber,
|
|
327
|
+
page: JSON.stringify(lastPage),
|
|
328
|
+
}, dbConn),
|
|
329
|
+
);
|
|
329
330
|
yield* until(
|
|
330
331
|
removePages.run({
|
|
331
|
-
protocol_name
|
|
332
|
-
page_number:
|
|
332
|
+
protocol_name,
|
|
333
|
+
page_number: lastPage.ownBlockNumber,
|
|
333
334
|
}, dbConn),
|
|
334
335
|
);
|
|
335
336
|
}
|
|
336
337
|
|
|
337
338
|
/* STEP 8: Commit the transaction. */
|
|
338
339
|
yield* until(dbConn.query("COMMIT"));
|
|
340
|
+
// A successful COMMIT proves the DB is reachable end-to-end — reset the
|
|
341
|
+
// pool failure clock so the write path also feeds the health tracker
|
|
342
|
+
// (it otherwise only sees floating pool errors and the read-side API).
|
|
343
|
+
poolErrors.markHealthy();
|
|
339
344
|
} catch (err) {
|
|
340
|
-
|
|
345
|
+
// Best-effort rollback. If the socket is dead the ROLLBACK throws too;
|
|
346
|
+
// swallow that so it doesn't mask the original error — the caller's retry
|
|
347
|
+
// loop keys off the original (transient) error, not a rollback failure.
|
|
348
|
+
try {
|
|
349
|
+
yield* until(dbConn.query("ROLLBACK"));
|
|
350
|
+
} catch (rollbackErr) {
|
|
351
|
+
log.remote(
|
|
352
|
+
ComponentNames.EFFECTSTREAM_SYNC,
|
|
353
|
+
"block-processing",
|
|
354
|
+
SeverityNumber.WARN,
|
|
355
|
+
(log) =>
|
|
356
|
+
log(
|
|
357
|
+
`ROLLBACK failed for block ${value.blockNumber} (connection likely dead): ${
|
|
358
|
+
String(rollbackErr)
|
|
359
|
+
}`,
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
}
|
|
341
363
|
log.remote(
|
|
342
364
|
ComponentNames.EFFECTSTREAM_SYNC,
|
|
343
365
|
"block-processing",
|
|
@@ -345,12 +367,89 @@ export function* processFinalizedBlock(
|
|
|
345
367
|
(log) =>
|
|
346
368
|
log(`Error processing block ${value.blockNumber}: ${String(err)}`),
|
|
347
369
|
);
|
|
348
|
-
//
|
|
370
|
+
// Re-throw: `processFinalizedBlockWithRetry` decides whether this is a
|
|
371
|
+
// retryable transient pg failure or a fatal bug.
|
|
349
372
|
throw err;
|
|
350
373
|
}
|
|
351
374
|
|
|
352
|
-
return {
|
|
353
|
-
blockHash: hasOperations ? blockHash : "0x0" as PaimaBlockHash,
|
|
354
|
-
events: blockEvents
|
|
375
|
+
return {
|
|
376
|
+
blockHash: hasOperations ? blockHash : "0x0" as PaimaBlockHash,
|
|
377
|
+
events: blockEvents
|
|
355
378
|
};
|
|
356
379
|
}
|
|
380
|
+
|
|
381
|
+
/** Caps the exponential backoff between block-retry attempts. */
|
|
382
|
+
const MAX_RETRY_BACKOFF_MS = 5_000;
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Run {@link processFinalizedBlock} against a freshly checked-out pool client,
|
|
386
|
+
* retrying the whole block on transient pg failures (Neon cold-starts,
|
|
387
|
+
* idle-socket evictions, TLS blips).
|
|
388
|
+
*
|
|
389
|
+
* The previous socket is dead after such a failure, so each attempt
|
|
390
|
+
* re-acquires a client and reprocesses the block from `BEGIN` — safe because
|
|
391
|
+
* the failed tx never committed and the caller only advances the block stream
|
|
392
|
+
* after this returns.
|
|
393
|
+
*
|
|
394
|
+
* `poolErrors` governs escalation: it logs WARN→ERROR as the outage persists
|
|
395
|
+
* and calls `process.exit(1)` once it crosses the fatal threshold, letting the
|
|
396
|
+
* orchestrator restart us. A non-transient error (a real SQL/logic bug) is
|
|
397
|
+
* re-thrown and crashes, as before.
|
|
398
|
+
*
|
|
399
|
+
* Owns the per-block DB mutex (for PGLite) and client lifecycle so the caller
|
|
400
|
+
* doesn't have to.
|
|
401
|
+
*/
|
|
402
|
+
export function* processFinalizedBlockWithRetry(
|
|
403
|
+
value: ChainBlock,
|
|
404
|
+
config: StartConfig,
|
|
405
|
+
pool: Pool,
|
|
406
|
+
previousBlockHash: EffectstreamBlockHash | null,
|
|
407
|
+
): Operation<ProcessFinalizedBlockResult> {
|
|
408
|
+
const lockName = `processing-blocks:${value.blockNumber}`;
|
|
409
|
+
let attempt = 0;
|
|
410
|
+
while (true) {
|
|
411
|
+
let dbClient: PoolClient | undefined;
|
|
412
|
+
let transientErr: unknown;
|
|
413
|
+
try {
|
|
414
|
+
yield* acquireDBMutex(lockName);
|
|
415
|
+
dbClient = yield* until(pool.connect());
|
|
416
|
+
return yield* processFinalizedBlock(
|
|
417
|
+
value,
|
|
418
|
+
config,
|
|
419
|
+
dbClient as unknown as Pool, // a checked-out client, used as the tx conn
|
|
420
|
+
previousBlockHash,
|
|
421
|
+
);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
if (!isTransientPgError(err)) throw err;
|
|
424
|
+
transientErr = err;
|
|
425
|
+
} finally {
|
|
426
|
+
releaseDBMutex(lockName);
|
|
427
|
+
if (dbClient) {
|
|
428
|
+
// Passing the error destroys the client instead of recycling it, so
|
|
429
|
+
// the pool never hands the dead socket back out. On success
|
|
430
|
+
// `transientErr` is undefined and the client returns to the pool.
|
|
431
|
+
try {
|
|
432
|
+
dbClient.release(transientErr as Error | undefined);
|
|
433
|
+
} catch {
|
|
434
|
+
/* already released/destroyed */
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
attempt++;
|
|
440
|
+
poolErrors.log(transientErr, ["sync-write"]);
|
|
441
|
+
const backoffMs = Math.min(500 * 2 ** (attempt - 1), MAX_RETRY_BACKOFF_MS);
|
|
442
|
+
log.local(
|
|
443
|
+
ComponentNames.EFFECTSTREAM_SYNC,
|
|
444
|
+
"block-processing",
|
|
445
|
+
SeverityNumber.WARN,
|
|
446
|
+
(l) =>
|
|
447
|
+
l(
|
|
448
|
+
`transient db error on block ${value.blockNumber}; retry #${attempt} in ${backoffMs}ms: ${
|
|
449
|
+
String(transientErr)
|
|
450
|
+
}`,
|
|
451
|
+
),
|
|
452
|
+
);
|
|
453
|
+
yield* sleep(backoffMs);
|
|
454
|
+
}
|
|
455
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Operation } from "effection";
|
|
2
|
+
import type { AllSyncProtocols } from "@effectstream/sync";
|
|
2
3
|
import type { SyncProtocolWithNetwork } from "@effectstream/config";
|
|
3
4
|
import type { AppEvents, BaseStfInput, BaseStfOutput, Primitive } from "@effectstream/sm";
|
|
4
5
|
import type { FastifyInstance } from "fastify";
|
|
@@ -72,5 +73,18 @@ export type StartConfig = {
|
|
|
72
73
|
dev?: {
|
|
73
74
|
/** Reset public-schema tables on each sync reset. For local testing only. */
|
|
74
75
|
resetPublicData?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Test-only hook invoked once the sync protocols are instantiated (after
|
|
78
|
+
* `genSyncProtocols`, before the merge starts). Lets tests observe live
|
|
79
|
+
* state such as `bufferedData.size()`. No effect in production.
|
|
80
|
+
*/
|
|
81
|
+
onStarted?: (ctx: { syncProtocols: AllSyncProtocols[] }) => void;
|
|
82
|
+
/**
|
|
83
|
+
* Test/diagnostic only: sleep this many ms after each applied block, slowing
|
|
84
|
+
* the drain so the per-chain fetch buffers fill to their cap and the
|
|
85
|
+
* backpressure fix becomes observable (otherwise drain ≈ fetch and the buffer
|
|
86
|
+
* never grows). No effect in production (leave unset / 0).
|
|
87
|
+
*/
|
|
88
|
+
applyDelayMs?: number;
|
|
75
89
|
};
|
|
76
90
|
};
|
|
@@ -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
|
+
});
|