@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 +34 -0
- package/package.json +13 -13
- package/src/api/http-server.ts +4 -0
- package/src/coalesce.ts +291 -0
- package/src/main.ts +90 -63
- package/test/coalesce.unit.test.ts +69 -0
- package/test/reproduction/buffering.test.ts +263 -2
- package/test/reproduction/coalesce.test.ts +133 -0
- package/test/reproduction/harness.ts +3 -0
- package/test/reproduction/node-runner.ts +2 -0
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.
|
|
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.
|
|
17
|
-
"@effectstream/config": "0.100.
|
|
18
|
-
"@effectstream/coroutine": "0.100.
|
|
19
|
-
"@effectstream/crypto": "0.100.
|
|
20
|
-
"@effectstream/db": "0.100.
|
|
21
|
-
"@effectstream/event-client": "0.100.
|
|
22
|
-
"@effectstream/event-server": "0.100.
|
|
23
|
-
"@effectstream/log": "0.100.
|
|
24
|
-
"@effectstream/sm": "0.100.
|
|
25
|
-
"@effectstream/sync": "0.100.
|
|
26
|
-
"@effectstream/utils": "0.100.
|
|
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.
|
|
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",
|
package/src/api/http-server.ts
CHANGED
|
@@ -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/coalesce.ts
ADDED
|
@@ -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
|
|
97
|
-
|
|
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
|
-
//
|
|
103
|
-
//
|
|
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
|
|
106
|
-
// can outpace
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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:
|
|
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
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
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
|
-
*
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
yield*
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
321
|
-
|
|
344
|
+
const capabilities = yield* until(detectCapabilities(dbConn as any));
|
|
345
|
+
const viewStrategy = selectViewStrategy(capabilities);
|
|
322
346
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
347
|
+
yield* createDynamicTables(
|
|
348
|
+
versionInfo,
|
|
349
|
+
lastBlockHeight,
|
|
350
|
+
dbConn as any, // Client,
|
|
351
|
+
syncProtocols,
|
|
352
|
+
viewStrategy,
|
|
353
|
+
);
|
|
329
354
|
|
|
330
|
-
|
|
331
|
-
|
|
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 +
|
|
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
|
-
|
|
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)) {
|