@effectstream/runtime 0.100.17 → 0.100.19
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/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/config-snapshot.ts +12 -4
- package/src/main.ts +35 -32
- package/src/process-blocks.ts +126 -27
- package/src/types.ts +14 -0
- package/test/reproduction/buffering.test.ts +389 -0
- package/test/reproduction/consistency-snapshot.ts +168 -0
- package/test/reproduction/consistency.test.ts +197 -0
- package/test/reproduction/harness.ts +613 -0
- package/test/reproduction/node-runner.ts +152 -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
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Bug Report: non-deterministic `effectstream_tx_hash` (`conciseInputHash`)
|
|
2
|
+
|
|
3
|
+
**Package:** `@effectstream/node-sdk` — runtime
|
|
4
|
+
**Location:** `runtime/src/process-blocks.ts` (scheduled-input loop, STEP 5)
|
|
5
|
+
**Severity:** Medium — silent cross-node state divergence in a derived column; not (currently) consensus-relevant
|
|
6
|
+
**Status:** Open
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Summary
|
|
11
|
+
|
|
12
|
+
When the runtime records the result of a **scheduled** STF input, it fabricates the
|
|
13
|
+
`effectstream_tx_hash` with `Math.random()`. The value is therefore different on every run
|
|
14
|
+
and on every node, so two nodes that replay the exact same source blocks produce **different
|
|
15
|
+
`rollup_input_result.effectstream_tx_hash` values** for the same input. For a framework whose
|
|
16
|
+
core promise is "the database is a pure function of the source blocks," this is a determinism
|
|
17
|
+
hole.
|
|
18
|
+
|
|
19
|
+
## The code
|
|
20
|
+
|
|
21
|
+
`runtime/src/process-blocks.ts`, inside the loop over `scheduledData` (STEP 5):
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
const conciseInputHash = `0x${
|
|
25
|
+
Array(64).fill(0).map(() => Math.floor(Math.random() * 16).toString(16)).join("")
|
|
26
|
+
}`;
|
|
27
|
+
yield* until(
|
|
28
|
+
insertGameInputResult.run({
|
|
29
|
+
id: data.id,
|
|
30
|
+
success,
|
|
31
|
+
effectstream_tx_hash: Buffer.from(conciseInputHash),
|
|
32
|
+
index_in_block,
|
|
33
|
+
block_height: value.blockNumber,
|
|
34
|
+
}, dbConn),
|
|
35
|
+
);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This runs only for **scheduled** inputs (engine-queued timers / derived inputs that have no
|
|
39
|
+
real on-chain transaction), so the engine has to synthesize a hash. It does so randomly.
|
|
40
|
+
|
|
41
|
+
## Why it matters / why it's currently masked
|
|
42
|
+
|
|
43
|
+
- **Block hash is unaffected.** `generateEffectstreamBlockHash` (`@effectstream/crypto`) hashes
|
|
44
|
+
`blockInfo[].blockHash` (source-chain hashes) + the previous block hash — never
|
|
45
|
+
`effectstream_tx_hash`. So the block-hash chain stays deterministic; the divergence is
|
|
46
|
+
confined to the `rollup_input_result.effectstream_tx_hash` column and anything that reads it.
|
|
47
|
+
- **Masked in tests.** The reproduction tests use a no-op STF (`stateMachinePayload: null`),
|
|
48
|
+
which schedules no STF inputs, so this branch never executes. That is exactly why
|
|
49
|
+
`consistency-snapshot.ts` lists `effectstream_tx_hash` in `VOLATILE_COLUMNS` and excludes it
|
|
50
|
+
— the snapshot would otherwise diff on every run once real STFs are involved.
|
|
51
|
+
|
|
52
|
+
## Two existing quirks to fix alongside
|
|
53
|
+
|
|
54
|
+
1. **Encoding bug.** `Buffer.from(conciseInputHash)` is given the *string* `"0x" + 64 hex`
|
|
55
|
+
with no encoding, so it stores 66 **UTF-8** bytes, not the 32 decoded bytes. Whatever
|
|
56
|
+
derivation replaces `Math.random()`, encode it deliberately (`Buffer.from(hex, "hex")`, or
|
|
57
|
+
commit to the `0x`-string form consistently).
|
|
58
|
+
|
|
59
|
+
2. **Ordering prerequisite (the real root).** The hash inputs that give uniqueness
|
|
60
|
+
(`index_in_block`, and the STF execution order itself) are only deterministic if the
|
|
61
|
+
`scheduledData` order is deterministic. Today it is not fully:
|
|
62
|
+
```ts
|
|
63
|
+
// TODO What should be the order of the scheduled data - per id?
|
|
64
|
+
const scheduledData = [...scheduledData1, ...scheduledData2];
|
|
65
|
+
```
|
|
66
|
+
- `scheduledData1` (block-scheduled) is `ORDER BY id ASC` → deterministic given a
|
|
67
|
+
deterministic scheduling history.
|
|
68
|
+
- `scheduledData2` (timestamp-scheduled) is `ORDER BY future_ms_timestamp ASC` **only** —
|
|
69
|
+
**same-timestamp rows have no tiebreak**, so their order is arbitrary.
|
|
70
|
+
|
|
71
|
+
This is bigger than the hash: the same order drives **STF execution** and the
|
|
72
|
+
**`randomGenerator` (Prando) draw sequence**. If it isn't pinned, game state itself can
|
|
73
|
+
diverge; the random tx-hash is just the most visible symptom.
|
|
74
|
+
|
|
75
|
+
## Suggested fixes (in order)
|
|
76
|
+
|
|
77
|
+
### 1. Pin the scheduled-input order (prerequisite, do first)
|
|
78
|
+
Give the queries a total deterministic sort and a deterministic merge:
|
|
79
|
+
- timestamp list → `ORDER BY future_ms_timestamp ASC, id ASC` (break ties by `id`);
|
|
80
|
+
- keep block-scheduled before timestamp-scheduled (or merge both by a single deterministic
|
|
81
|
+
key). This is required for deterministic STFs regardless of the hash, and it makes
|
|
82
|
+
`index_in_block` deterministic.
|
|
83
|
+
|
|
84
|
+
### 2. Derive the hash deterministically (recommended)
|
|
85
|
+
Replace `Math.random()` with a content-addressed hash over the input's identity + position,
|
|
86
|
+
reusing the `sha512` already used for the block hash:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
effectstream_tx_hash =
|
|
90
|
+
sha512(block_height ‖ index_in_block ‖ input_data ‖ from_address ‖ from_address_type)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- `block_height` + `index_in_block` give **uniqueness** (one result per position; needs fix #1).
|
|
94
|
+
- `input_data` + `from_address` + `from_address_type` make it **content-addressed**
|
|
95
|
+
(tx-hash-like, collision-resistant). All are non-null columns already on the scheduled row.
|
|
96
|
+
|
|
97
|
+
### Alternatives considered
|
|
98
|
+
- **Reuse the seeded `Prando` (`new Prando(blockHash)`):** smallest change and deterministic,
|
|
99
|
+
but the shared `randomGenerator` is also consumed by the STFs — drawing hash bytes from it
|
|
100
|
+
would shift the RNG stream every later STF sees. Would require a *separate*
|
|
101
|
+
`new Prando(blockHash + ":txhash")`. Produces opaque (non-content) hashes.
|
|
102
|
+
- **`H(data.id, block_height)`:** `id` is a global SERIAL counter — a weaker determinism
|
|
103
|
+
guarantee than the block-scoped `index_in_block`, and `block_height` adds nothing once `id`
|
|
104
|
+
is unique. Prefer `index_in_block`.
|
|
105
|
+
- **Provenance: `H(origin_tx_hash, …)`:** ties to the originating real tx, but
|
|
106
|
+
`origin_tx_hash` is `null` for timestamp-scheduled inputs, so it needs a fallback.
|
|
107
|
+
|
|
108
|
+
## Verification
|
|
109
|
+
- Add a determinism test with a **real** STF (one that schedules a derived input) and assert
|
|
110
|
+
two from-scratch syncs produce identical `rollup_input_result.effectstream_tx_hash` —
|
|
111
|
+
i.e. drop `effectstream_tx_hash` from `VOLATILE_COLUMNS` once fixed and confirm
|
|
112
|
+
`consistency.test.ts` Test A still passes.
|
|
113
|
+
- Cover the timestamp-tie case explicitly: schedule ≥2 inputs at the same `future_ms_timestamp`
|
|
114
|
+
and assert identical ordering / hashes across runs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effectstream/runtime",
|
|
3
|
-
"version": "0.100.
|
|
3
|
+
"version": "0.100.19",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"homepage": "https://effectstream.github.io/docs/",
|
|
6
6
|
"repository": {
|
|
@@ -13,17 +13,17 @@
|
|
|
13
13
|
},
|
|
14
14
|
"exports": "./src/mod.ts",
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@effectstream/concise": "0.100.
|
|
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.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",
|
|
27
27
|
"@fastify/cors": "^11.0.1",
|
|
28
28
|
"@metamask/json-rpc-engine": "^10.0.3",
|
|
29
29
|
"pg": "8.17.2",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply-stage liveness for /debug/metrics.
|
|
3
|
+
*
|
|
4
|
+
* The serial finalized-block loop in main.ts calls recordAppliedBlock() right
|
|
5
|
+
* after each block COMMITs; the /debug/metrics handler reads `appliedBlockStatus`
|
|
6
|
+
* to report true lag (now − applied block timestamp). Unlike a protocol's `buf`
|
|
7
|
+
* (fetch backlog), this stays high when the node is write/apply-bound.
|
|
8
|
+
*
|
|
9
|
+
* Process-global: one node runs per process, so a module-level singleton keeps
|
|
10
|
+
* main.ts to a single call instead of threading a ref through startHttpServer.
|
|
11
|
+
*/
|
|
12
|
+
export type AppliedBlockStatus = {
|
|
13
|
+
blockNumber: number | null;
|
|
14
|
+
timestamp: number | null;
|
|
15
|
+
appliedAtMs: number | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const appliedBlockStatus: AppliedBlockStatus = {
|
|
19
|
+
blockNumber: null,
|
|
20
|
+
timestamp: null,
|
|
21
|
+
appliedAtMs: null,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function recordAppliedBlock(
|
|
25
|
+
block: { blockNumber: number | bigint; timestamp: number | bigint },
|
|
26
|
+
): void {
|
|
27
|
+
appliedBlockStatus.blockNumber = Number(block.blockNumber);
|
|
28
|
+
appliedBlockStatus.timestamp = Number(block.timestamp);
|
|
29
|
+
appliedBlockStatus.appliedAtMs = Date.now();
|
|
30
|
+
}
|
package/src/api/http-server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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";
|
|
3
4
|
import type { Pool } from "pg";
|
|
4
5
|
import cors from "@fastify/cors";
|
|
5
6
|
import { run, until } from "effection";
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
getTableSchema,
|
|
14
15
|
type IGetAllAddressesResult,
|
|
15
16
|
type IGetAllTableNamesResult,
|
|
17
|
+
poolErrors,
|
|
16
18
|
releaseDBMutex,
|
|
17
19
|
runPreparedQuery,
|
|
18
20
|
waitUntilFree,
|
|
@@ -25,6 +27,7 @@ import type {
|
|
|
25
27
|
EvmFetcher,
|
|
26
28
|
MidnightFetcher,
|
|
27
29
|
NtpFetcher,
|
|
30
|
+
TestFetcher,
|
|
28
31
|
UtxoRpcFetcher,
|
|
29
32
|
} from "@effectstream/sync";
|
|
30
33
|
import fastifySwagger, {
|
|
@@ -297,6 +300,24 @@ export const startHttpServer = function* (
|
|
|
297
300
|
}
|
|
298
301
|
}
|
|
299
302
|
break;
|
|
303
|
+
case ConfigNetworkType.TEST:
|
|
304
|
+
{
|
|
305
|
+
const testFetcher = fetcher as TestFetcher;
|
|
306
|
+
const cfg = testFetcher.config.network;
|
|
307
|
+
if (!cfg?.startTime || !cfg?.blockTimeMS) {
|
|
308
|
+
return reply.status(500).send({ error: "TEST config missing" });
|
|
309
|
+
}
|
|
310
|
+
for (let n = from; n <= to; n++) {
|
|
311
|
+
const timestamp = BigInt(cfg.startTime) +
|
|
312
|
+
BigInt(cfg.blockTimeMS) * BigInt(n);
|
|
313
|
+
blocks.push({
|
|
314
|
+
blockNumber: n,
|
|
315
|
+
timestamp,
|
|
316
|
+
hash: `0x${timestamp.toString(16)}`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
break;
|
|
300
321
|
case ConfigNetworkType.CARDANO: {
|
|
301
322
|
const cardanoFetcher = fetcher as UtxoRpcFetcher;
|
|
302
323
|
const res = cardanoFetcher.client.fetchBlocks(from, to);
|
|
@@ -345,19 +366,29 @@ export const startHttpServer = function* (
|
|
|
345
366
|
yield* until(apiRouter(server, dbConn));
|
|
346
367
|
}
|
|
347
368
|
|
|
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
|
+
});
|
|
348
379
|
server.get("/health", {
|
|
349
380
|
schema: {
|
|
350
381
|
tags: ["status"],
|
|
351
382
|
response: {
|
|
352
|
-
200:
|
|
353
|
-
|
|
354
|
-
}),
|
|
383
|
+
200: HealthResponseSchema,
|
|
384
|
+
503: HealthResponseSchema,
|
|
355
385
|
},
|
|
356
386
|
},
|
|
357
|
-
}, () => {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
387
|
+
}, (_req, reply) => {
|
|
388
|
+
const db = poolErrors.state();
|
|
389
|
+
return reply
|
|
390
|
+
.status(db.sustained ? 503 : 200)
|
|
391
|
+
.send({ status: db.sustained ? "db-unreachable" : "ok", db });
|
|
361
392
|
});
|
|
362
393
|
|
|
363
394
|
server.get("/addresses", {
|
|
@@ -450,6 +481,42 @@ export const startHttpServer = function* (
|
|
|
450
481
|
});
|
|
451
482
|
|
|
452
483
|
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
|
+
|
|
453
520
|
server.get("/debug/sync-protocols", {
|
|
454
521
|
schema: {
|
|
455
522
|
tags: ["developer"],
|
package/src/config-snapshot.ts
CHANGED
|
@@ -23,11 +23,19 @@ import { getEnv } from "@effectstream/utils/runtime";
|
|
|
23
23
|
* Bitcoin, Celestia → syncProtocol.startBlockHeight
|
|
24
24
|
*/
|
|
25
25
|
function extractImmutableConfig(protocol: SyncProtocolWithNetwork): Record<string, unknown> {
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// NTP and the synthetic TEST chain both derive blocks arithmetically from
|
|
27
|
+
// network.startTime + network.blockTimeMS, so those are the immutable fields.
|
|
28
|
+
if (
|
|
29
|
+
protocol.networkType === ConfigNetworkType.NTP ||
|
|
30
|
+
protocol.networkType === ConfigNetworkType.TEST
|
|
31
|
+
) {
|
|
32
|
+
const arithmeticNetwork = protocol.network as {
|
|
33
|
+
startTime: number;
|
|
34
|
+
blockTimeMS: number;
|
|
35
|
+
};
|
|
28
36
|
return {
|
|
29
|
-
startTime:
|
|
30
|
-
blockTimeMS:
|
|
37
|
+
startTime: arithmeticNetwork.startTime,
|
|
38
|
+
blockTimeMS: arithmeticNetwork.blockTimeMS,
|
|
31
39
|
};
|
|
32
40
|
}
|
|
33
41
|
|
package/src/main.ts
CHANGED
|
@@ -22,15 +22,18 @@ 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";
|
|
33
35
|
import { startHttpServer } from "./api/http-server.ts";
|
|
36
|
+
import { recordAppliedBlock } from "./api/apply-status.ts";
|
|
34
37
|
import type { StartConfig } from "./types.ts";
|
|
35
38
|
import type { Client } from "pg";
|
|
36
39
|
import type { EffectstreamBlockHash } from "@effectstream/utils";
|
|
@@ -62,6 +65,9 @@ export function* start(config: StartConfig): Operation<void> {
|
|
|
62
65
|
const syncProtocols = yield* startup(dbConn as any, // Client,
|
|
63
66
|
syncInfo, config);
|
|
64
67
|
|
|
68
|
+
// Test-only: surface live sync protocols (e.g. for buffer-size assertions).
|
|
69
|
+
config.dev?.onStarted?.({ syncProtocols });
|
|
70
|
+
|
|
65
71
|
log.remote(
|
|
66
72
|
ComponentNames.EFFECTSTREAM_RUNTIME,
|
|
67
73
|
[],
|
|
@@ -92,6 +98,14 @@ export function* start(config: StartConfig): Operation<void> {
|
|
|
92
98
|
|
|
93
99
|
const finalizedBlockStream = createChannel<ChainBlock>();
|
|
94
100
|
|
|
101
|
+
// 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.
|
|
104
|
+
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).
|
|
108
|
+
|
|
95
109
|
yield* spawn(() => startMerge(syncProtocols, finalizedBlockStream));
|
|
96
110
|
|
|
97
111
|
const heartbeatIntervalMs = 60_000;
|
|
@@ -134,37 +148,26 @@ export function* start(config: StartConfig): Operation<void> {
|
|
|
134
148
|
yield* spawn(() => runSnapshotLoop(config.snapshotConfig!));
|
|
135
149
|
}
|
|
136
150
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
dbClient as any, // Client,
|
|
154
|
-
blockHash,
|
|
155
|
-
);
|
|
156
|
-
const resultHash = result.blockHash;
|
|
157
|
-
blockAppEvents = result.events;
|
|
158
|
-
if (resultHash !== "0x0") {
|
|
159
|
-
blockHash = resultHash;
|
|
160
|
-
}
|
|
161
|
-
} finally {
|
|
162
|
-
releaseDBMutex(`processing-blocks:${value.blockNumber}`);
|
|
163
|
-
if (dbClient) {
|
|
164
|
-
(dbClient as any).release(); // Client,
|
|
165
|
-
}
|
|
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.
|
|
158
|
+
const result = yield* processFinalizedBlockWithRetry(
|
|
159
|
+
value,
|
|
160
|
+
config,
|
|
161
|
+
dbConn as any, // Pool,
|
|
162
|
+
blockHash,
|
|
163
|
+
);
|
|
164
|
+
const blockAppEvents: PendingEvent[] = result.events;
|
|
165
|
+
if (result.blockHash !== "0x0") {
|
|
166
|
+
blockHash = result.blockHash;
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
recordAppliedBlock(value); // apply-stage liveness for /debug/metrics
|
|
170
|
+
|
|
168
171
|
// Used to emit & log the block range for each protocol.
|
|
169
172
|
const contentBlocksForProtocol = getRangesForSyncProtocols(value);
|
|
170
173
|
|
|
@@ -215,7 +218,7 @@ export function* start(config: StartConfig): Operation<void> {
|
|
|
215
218
|
}...${lagSuffix} | ${JSON.stringify(contentBlocksForProtocol)}`,
|
|
216
219
|
),
|
|
217
220
|
);
|
|
218
|
-
yield*
|
|
221
|
+
if (config.dev?.applyDelayMs) yield* sleep(config.dev.applyDelayMs);
|
|
219
222
|
}
|
|
220
223
|
}
|
|
221
224
|
|
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
|
};
|