@effectstream/runtime 0.100.20 → 0.100.22
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 +0 -34
- package/package.json +13 -13
- package/src/api/http-server.ts +7 -55
- package/src/main.ts +39 -64
- package/src/process-blocks.ts +27 -126
- package/src/types.ts +0 -7
- package/test/reproduction/harness.ts +270 -559
- package/test/reproduction/sanity.test.ts +91 -40
- package/conciseinputhash-determinism.md +0 -114
- package/src/api/apply-status.ts +0 -30
- package/src/coalesce.ts +0 -291
- package/test/coalesce.unit.test.ts +0 -69
- package/test/reproduction/buffering.test.ts +0 -389
- package/test/reproduction/coalesce.test.ts +0 -133
- package/test/reproduction/consistency-snapshot.ts +0 -168
- package/test/reproduction/consistency.test.ts +0 -197
- package/test/reproduction/node-runner.ts +0 -154
- package/test/reproduction/poll.ts +0 -160
- package/test/reproduction/resume.test.ts +0 -77
- package/test/reproduction/scenario.ts +0 -126
package/README.md
CHANGED
|
@@ -69,40 +69,6 @@ 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
|
-
|
|
106
72
|
## Key exports
|
|
107
73
|
|
|
108
74
|
- `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.22",
|
|
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.22",
|
|
17
|
+
"@effectstream/config": "0.100.22",
|
|
18
|
+
"@effectstream/coroutine": "0.100.22",
|
|
19
|
+
"@effectstream/crypto": "0.100.22",
|
|
20
|
+
"@effectstream/db": "0.100.22",
|
|
21
|
+
"@effectstream/event-client": "0.100.22",
|
|
22
|
+
"@effectstream/event-server": "0.100.22",
|
|
23
|
+
"@effectstream/log": "0.100.22",
|
|
24
|
+
"@effectstream/sm": "0.100.22",
|
|
25
|
+
"@effectstream/sync": "0.100.22",
|
|
26
|
+
"@effectstream/utils": "0.100.22",
|
|
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
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
|
2
2
|
import { evmRpcEngine } from "./rpc-evm/eip1193.ts";
|
|
3
|
-
import { appliedBlockStatus } from "./apply-status.ts";
|
|
4
3
|
import type { Pool } from "pg";
|
|
5
4
|
import cors from "@fastify/cors";
|
|
6
5
|
import { run, until } from "effection";
|
|
@@ -14,7 +13,6 @@ import {
|
|
|
14
13
|
getTableSchema,
|
|
15
14
|
type IGetAllAddressesResult,
|
|
16
15
|
type IGetAllTableNamesResult,
|
|
17
|
-
poolErrors,
|
|
18
16
|
releaseDBMutex,
|
|
19
17
|
runPreparedQuery,
|
|
20
18
|
waitUntilFree,
|
|
@@ -366,29 +364,19 @@ export const startHttpServer = function* (
|
|
|
366
364
|
yield* until(apiRouter(server, dbConn));
|
|
367
365
|
}
|
|
368
366
|
|
|
369
|
-
const HealthDbSchema = Type.Object({
|
|
370
|
-
consecutive: Type.Number(),
|
|
371
|
-
firstFailureAt: Type.Number(),
|
|
372
|
-
sustainedDurationMs: Type.Number(),
|
|
373
|
-
sustained: Type.Boolean(),
|
|
374
|
-
});
|
|
375
|
-
const HealthResponseSchema = Type.Object({
|
|
376
|
-
status: Type.String(),
|
|
377
|
-
db: HealthDbSchema,
|
|
378
|
-
});
|
|
379
367
|
server.get("/health", {
|
|
380
368
|
schema: {
|
|
381
369
|
tags: ["status"],
|
|
382
370
|
response: {
|
|
383
|
-
200:
|
|
384
|
-
|
|
371
|
+
200: Type.Object({
|
|
372
|
+
status: Type.String(),
|
|
373
|
+
}),
|
|
385
374
|
},
|
|
386
375
|
},
|
|
387
|
-
}, (
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
.send({ status: db.sustained ? "db-unreachable" : "ok", db });
|
|
376
|
+
}, () => {
|
|
377
|
+
return {
|
|
378
|
+
status: "ok",
|
|
379
|
+
};
|
|
392
380
|
});
|
|
393
381
|
|
|
394
382
|
server.get("/addresses", {
|
|
@@ -481,42 +469,6 @@ export const startHttpServer = function* (
|
|
|
481
469
|
});
|
|
482
470
|
|
|
483
471
|
if (ENV.ENABLE_DEV_AND_DEBUG_ENDPOINTS) {
|
|
484
|
-
server.get("/debug/metrics", {
|
|
485
|
-
schema: {
|
|
486
|
-
tags: ["developer"],
|
|
487
|
-
response: {
|
|
488
|
-
200: Type.Object({}, { additionalProperties: true }),
|
|
489
|
-
},
|
|
490
|
-
},
|
|
491
|
-
}, () => {
|
|
492
|
-
const appliedTs = appliedBlockStatus.timestamp;
|
|
493
|
-
return {
|
|
494
|
-
timestamp: Date.now(),
|
|
495
|
-
uptimeSeconds: process.uptime(),
|
|
496
|
-
memory: process.memoryUsage(),
|
|
497
|
-
protocols: syncProtocols.map((p) => ({
|
|
498
|
-
name: p.name,
|
|
499
|
-
buf: p.bufferedData.size(),
|
|
500
|
-
ownBlockNumber: p.lastPage?.ownBlockNumber ?? null,
|
|
501
|
-
// Backpressure observability
|
|
502
|
-
cap: p.bufferCap,
|
|
503
|
-
bufHighWater: p.bufferHighWater,
|
|
504
|
-
pausedNow: p.pausedNow,
|
|
505
|
-
pauses: p.backpressurePauses,
|
|
506
|
-
pausedMs: p.backpressurePausedMs,
|
|
507
|
-
})),
|
|
508
|
-
// Apply-stage lag: how old the last-applied block is. Unlike `buf` (fetch
|
|
509
|
-
// backlog) this stays high when the node is write/apply-bound.
|
|
510
|
-
applied: {
|
|
511
|
-
blockNumber: appliedBlockStatus.blockNumber,
|
|
512
|
-
timestamp: appliedTs,
|
|
513
|
-
lagSeconds: appliedTs != null
|
|
514
|
-
? Math.round((Date.now() - appliedTs) / 100) / 10
|
|
515
|
-
: null,
|
|
516
|
-
},
|
|
517
|
-
};
|
|
518
|
-
});
|
|
519
|
-
|
|
520
472
|
server.get("/debug/sync-protocols", {
|
|
521
473
|
schema: {
|
|
522
474
|
tags: ["developer"],
|
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";
|
|
@@ -22,19 +24,15 @@ import { startMerge, startSync } from "@effectstream/sync";
|
|
|
22
24
|
import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
|
|
23
25
|
import {
|
|
24
26
|
createChannel,
|
|
27
|
+
each,
|
|
25
28
|
type Operation,
|
|
26
29
|
sleep,
|
|
27
30
|
spawn,
|
|
28
31
|
until,
|
|
29
32
|
} from "effection";
|
|
30
33
|
import { initTelemetry } from "./telemetry.ts";
|
|
31
|
-
import {
|
|
32
|
-
type PendingEvent,
|
|
33
|
-
processFinalizedBlockWithRetry,
|
|
34
|
-
} from "./process-blocks.ts";
|
|
35
|
-
import { createEmptyBlockCoalescer } from "./coalesce.ts";
|
|
34
|
+
import { type PendingEvent, processFinalizedBlock } from "./process-blocks.ts";
|
|
36
35
|
import { startHttpServer } from "./api/http-server.ts";
|
|
37
|
-
import { recordAppliedBlock } from "./api/apply-status.ts";
|
|
38
36
|
import type { StartConfig } from "./types.ts";
|
|
39
37
|
import type { Client } from "pg";
|
|
40
38
|
import type { EffectstreamBlockHash } from "@effectstream/utils";
|
|
@@ -93,23 +91,12 @@ export function* start(config: StartConfig): Operation<void> {
|
|
|
93
91
|
);
|
|
94
92
|
});
|
|
95
93
|
|
|
96
|
-
// 10× main clock block time (NTP if present, else protocol 0). Undefined disables lag gating.
|
|
97
94
|
const ntpConfig = syncInfo.find(s => s.networkType === ConfigNetworkType.NTP);
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
(syncInfo[0]?.network as { blockTimeMS?: number } | undefined)?.blockTimeMS;
|
|
101
|
-
const lagThresholdMs = clockBlockTimeMS != null ? clockBlockTimeMS * 10 : undefined;
|
|
95
|
+
const ntpBlockTimeMS = (ntpConfig?.network as { blockTimeMS: number } | undefined)?.blockTimeMS;
|
|
96
|
+
const lagThresholdMs = ntpBlockTimeMS != null ? ntpBlockTimeMS * 10 : undefined;
|
|
102
97
|
|
|
103
98
|
const finalizedBlockStream = createChannel<ChainBlock>();
|
|
104
99
|
|
|
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
|
-
|
|
113
100
|
yield* spawn(() => startMerge(syncProtocols, finalizedBlockStream));
|
|
114
101
|
|
|
115
102
|
const heartbeatIntervalMs = 60_000;
|
|
@@ -152,53 +139,37 @@ export function* start(config: StartConfig): Operation<void> {
|
|
|
152
139
|
yield* spawn(() => runSnapshotLoop(config.snapshotConfig!));
|
|
153
140
|
}
|
|
154
141
|
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
(l) =>
|
|
174
|
-
l(
|
|
175
|
-
`coalesced ${length} empty block(s) → block ${endpoint.blockNumber}`,
|
|
176
|
-
),
|
|
142
|
+
for (const value of yield* each(finalizedBlockStream)) {
|
|
143
|
+
// We request a dbClient for a non-shared dbConn object.
|
|
144
|
+
// For PGLite, this is not enough, as the can only be one connection at a time.
|
|
145
|
+
// So we request a DBMutex as well.
|
|
146
|
+
let dbClient: Client | undefined;
|
|
147
|
+
// Custom app events emitted by STFs during this block. Collected by
|
|
148
|
+
// processFinalizedBlock and flushed below ONLY after it returns — i.e.
|
|
149
|
+
// strictly after the block's COMMIT. See plan invariant I1.
|
|
150
|
+
let blockAppEvents: PendingEvent[] = [];
|
|
151
|
+
try {
|
|
152
|
+
yield* acquireDBMutex(`processing-blocks:${value.blockNumber}`);
|
|
153
|
+
dbClient = yield* until((dbConn as any).connect()); // Client,
|
|
154
|
+
|
|
155
|
+
const result = yield* processFinalizedBlock(
|
|
156
|
+
value,
|
|
157
|
+
config,
|
|
158
|
+
dbClient as any, // Client,
|
|
159
|
+
blockHash,
|
|
177
160
|
);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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;
|
|
161
|
+
const resultHash = result.blockHash;
|
|
162
|
+
blockAppEvents = result.events;
|
|
163
|
+
if (resultHash !== "0x0") {
|
|
164
|
+
blockHash = resultHash;
|
|
165
|
+
}
|
|
166
|
+
} finally {
|
|
167
|
+
releaseDBMutex(`processing-blocks:${value.blockNumber}`);
|
|
168
|
+
if (dbClient) {
|
|
169
|
+
(dbClient as any).release(); // Client,
|
|
170
|
+
}
|
|
198
171
|
}
|
|
199
172
|
|
|
200
|
-
recordAppliedBlock(value); // apply-stage liveness for /debug/metrics
|
|
201
|
-
|
|
202
173
|
// Used to emit & log the block range for each protocol.
|
|
203
174
|
const contentBlocksForProtocol = getRangesForSyncProtocols(value);
|
|
204
175
|
|
|
@@ -249,7 +220,7 @@ export function* start(config: StartConfig): Operation<void> {
|
|
|
249
220
|
}...${lagSuffix} | ${JSON.stringify(contentBlocksForProtocol)}`,
|
|
250
221
|
),
|
|
251
222
|
);
|
|
252
|
-
|
|
223
|
+
yield* each.next();
|
|
253
224
|
}
|
|
254
225
|
}
|
|
255
226
|
|
|
@@ -351,11 +322,15 @@ function* startup(
|
|
|
351
322
|
const syncProtocols = yield* genSyncProtocols(dbConn as any, // Client,
|
|
352
323
|
syncInfo);
|
|
353
324
|
|
|
325
|
+
const capabilities = yield* until(detectCapabilities(dbConn as any));
|
|
326
|
+
const viewStrategy = selectViewStrategy(capabilities);
|
|
327
|
+
|
|
354
328
|
yield* createDynamicTables(
|
|
355
329
|
versionInfo,
|
|
356
330
|
lastBlockHeight,
|
|
357
331
|
dbConn as any, // Client,
|
|
358
332
|
syncProtocols,
|
|
333
|
+
viewStrategy,
|
|
359
334
|
);
|
|
360
335
|
|
|
361
336
|
releaseDBMutex(`startup-node`);
|
package/src/process-blocks.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ChainBlock } from "@effectstream/sync";
|
|
2
|
-
import { call, type Operation,
|
|
3
|
-
import type { Pool
|
|
2
|
+
import { call, type Operation, until } from "effection";
|
|
3
|
+
import type { Pool } 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,24 +9,19 @@ import type {
|
|
|
9
9
|
SyncStateUpdateStream,
|
|
10
10
|
} from "@effectstream/coroutine";
|
|
11
11
|
import {
|
|
12
|
-
acquireDBMutex,
|
|
13
12
|
blockHeightDone,
|
|
14
13
|
deleteEmptyBlocks,
|
|
15
14
|
deleteScheduled,
|
|
16
15
|
getFutureGameInputByBlockHeight,
|
|
17
16
|
getFutureGameInputByMaxTimestamp,
|
|
18
17
|
insertGameInputResult,
|
|
19
|
-
isTransientPgError,
|
|
20
|
-
poolErrors,
|
|
21
|
-
releaseDBMutex,
|
|
22
18
|
removePages,
|
|
23
19
|
saveLastBlock,
|
|
24
|
-
upsertPage,
|
|
25
20
|
} from "@effectstream/db";
|
|
26
21
|
import { Buffer } from "node:buffer";
|
|
27
22
|
import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
|
|
28
23
|
import type { StartConfig } from "./types.ts";
|
|
29
|
-
import type { EffectstreamBlockHash } from "@effectstream/utils";
|
|
24
|
+
import type { BlockNumber, EffectstreamBlockHash } from "@effectstream/utils";
|
|
30
25
|
import { generateEffectstreamBlockHash, Prando } from "@effectstream/crypto";
|
|
31
26
|
import { applyUserMigrations } from "./version-migrations.ts";
|
|
32
27
|
import type { EventPathAndDef } from "@effectstream/event-client";
|
|
@@ -313,53 +308,36 @@ export function* processFinalizedBlock(
|
|
|
313
308
|
}, dbConn)
|
|
314
309
|
);
|
|
315
310
|
|
|
316
|
-
/* STEP 7:
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* Full mechanism: @effectstream/sync CLAUDE.md, design idea #5.
|
|
311
|
+
/* STEP 7: Update page state for all sync protocols consumed
|
|
312
|
+
* This is to mark what blocks where consumed by this specific
|
|
313
|
+
* block, to avoid re-processing the same blocks more than once
|
|
314
|
+
* if the sync protocol is restarted.
|
|
321
315
|
*/
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
)
|
|
316
|
+
|
|
317
|
+
// Get latest block for each sync protocol.
|
|
318
|
+
const latestBlocks: Record<string, BlockNumber> = {};
|
|
319
|
+
for (const blockInfo of value.blockInfo) {
|
|
320
|
+
if (
|
|
321
|
+
!latestBlocks[blockInfo.protocol_name] ||
|
|
322
|
+
latestBlocks[blockInfo.protocol_name] < blockInfo.block_number
|
|
323
|
+
) {
|
|
324
|
+
latestBlocks[blockInfo.protocol_name] = blockInfo.block_number;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
for (const [protocolName, blockNumber] of Object.entries(latestBlocks)) {
|
|
330
329
|
yield* until(
|
|
331
330
|
removePages.run({
|
|
332
|
-
protocol_name,
|
|
333
|
-
page_number:
|
|
331
|
+
protocol_name: protocolName,
|
|
332
|
+
page_number: blockNumber,
|
|
334
333
|
}, dbConn),
|
|
335
334
|
);
|
|
336
335
|
}
|
|
337
336
|
|
|
338
337
|
/* STEP 8: Commit the transaction. */
|
|
339
338
|
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();
|
|
344
339
|
} catch (err) {
|
|
345
|
-
|
|
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
|
-
}
|
|
340
|
+
yield* until(dbConn.query("ROLLBACK"));
|
|
363
341
|
log.remote(
|
|
364
342
|
ComponentNames.EFFECTSTREAM_SYNC,
|
|
365
343
|
"block-processing",
|
|
@@ -367,89 +345,12 @@ export function* processFinalizedBlock(
|
|
|
367
345
|
(log) =>
|
|
368
346
|
log(`Error processing block ${value.blockNumber}: ${String(err)}`),
|
|
369
347
|
);
|
|
370
|
-
//
|
|
371
|
-
// retryable transient pg failure or a fatal bug.
|
|
348
|
+
// We cannot recover from this error.
|
|
372
349
|
throw err;
|
|
373
350
|
}
|
|
374
351
|
|
|
375
|
-
return {
|
|
376
|
-
blockHash: hasOperations ? blockHash : "0x0" as PaimaBlockHash,
|
|
377
|
-
events: blockEvents
|
|
352
|
+
return {
|
|
353
|
+
blockHash: hasOperations ? blockHash : "0x0" as PaimaBlockHash,
|
|
354
|
+
events: blockEvents
|
|
378
355
|
};
|
|
379
356
|
}
|
|
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
|
@@ -79,12 +79,5 @@ export type StartConfig = {
|
|
|
79
79
|
* state such as `bufferedData.size()`. No effect in production.
|
|
80
80
|
*/
|
|
81
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;
|
|
89
82
|
};
|
|
90
83
|
};
|