@effectstream/runtime 0.100.22 → 0.100.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/conciseinputhash-determinism.md +114 -0
- package/package.json +12 -12
- package/src/api/apply-status.ts +30 -0
- package/src/api/http-server.ts +68 -7
- package/src/api/stream-status.ts +35 -0
- package/src/coalesce.ts +291 -0
- package/src/finalized-stream.ts +73 -0
- package/src/main.ts +111 -89
- package/src/process-blocks.ts +126 -27
- package/src/types.ts +7 -0
- package/test/coalesce.unit.test.ts +69 -0
- package/test/reproduction/buffering.test.ts +650 -0
- package/test/reproduction/coalesce-memory.test.ts +181 -0
- package/test/reproduction/coalesce.test.ts +133 -0
- package/test/reproduction/consistency-snapshot.ts +168 -0
- package/test/reproduction/consistency.test.ts +197 -0
- package/test/reproduction/harness.ts +573 -270
- package/test/reproduction/node-runner.ts +154 -0
- package/test/reproduction/poll.ts +160 -0
- package/test/reproduction/resume.test.ts +77 -0
- package/test/reproduction/sanity.test.ts +40 -91
- package/test/reproduction/scenario.ts +126 -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`.
|
|
@@ -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.24",
|
|
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.24",
|
|
17
|
+
"@effectstream/config": "0.100.24",
|
|
18
|
+
"@effectstream/coroutine": "0.100.24",
|
|
19
|
+
"@effectstream/crypto": "0.100.24",
|
|
20
|
+
"@effectstream/db": "0.100.24",
|
|
21
|
+
"@effectstream/event-client": "0.100.24",
|
|
22
|
+
"@effectstream/event-server": "0.100.24",
|
|
23
|
+
"@effectstream/log": "0.100.24",
|
|
24
|
+
"@effectstream/sm": "0.100.24",
|
|
25
|
+
"@effectstream/sync": "0.100.24",
|
|
26
|
+
"@effectstream/utils": "0.100.24",
|
|
27
27
|
"@fastify/cors": "^11.0.1",
|
|
28
28
|
"@metamask/json-rpc-engine": "^10.0.3",
|
|
29
29
|
"pg": "8.21.0",
|
|
@@ -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,7 @@
|
|
|
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
|
+
import { finalizedStreamStatus } from "./stream-status.ts";
|
|
3
5
|
import type { Pool } from "pg";
|
|
4
6
|
import cors from "@fastify/cors";
|
|
5
7
|
import { run, until } from "effection";
|
|
@@ -13,6 +15,7 @@ import {
|
|
|
13
15
|
getTableSchema,
|
|
14
16
|
type IGetAllAddressesResult,
|
|
15
17
|
type IGetAllTableNamesResult,
|
|
18
|
+
poolErrors,
|
|
16
19
|
releaseDBMutex,
|
|
17
20
|
runPreparedQuery,
|
|
18
21
|
waitUntilFree,
|
|
@@ -364,19 +367,29 @@ export const startHttpServer = function* (
|
|
|
364
367
|
yield* until(apiRouter(server, dbConn));
|
|
365
368
|
}
|
|
366
369
|
|
|
370
|
+
const HealthDbSchema = Type.Object({
|
|
371
|
+
consecutive: Type.Number(),
|
|
372
|
+
firstFailureAt: Type.Number(),
|
|
373
|
+
sustainedDurationMs: Type.Number(),
|
|
374
|
+
sustained: Type.Boolean(),
|
|
375
|
+
});
|
|
376
|
+
const HealthResponseSchema = Type.Object({
|
|
377
|
+
status: Type.String(),
|
|
378
|
+
db: HealthDbSchema,
|
|
379
|
+
});
|
|
367
380
|
server.get("/health", {
|
|
368
381
|
schema: {
|
|
369
382
|
tags: ["status"],
|
|
370
383
|
response: {
|
|
371
|
-
200:
|
|
372
|
-
|
|
373
|
-
}),
|
|
384
|
+
200: HealthResponseSchema,
|
|
385
|
+
503: HealthResponseSchema,
|
|
374
386
|
},
|
|
375
387
|
},
|
|
376
|
-
}, () => {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
388
|
+
}, (_req, reply) => {
|
|
389
|
+
const db = poolErrors.state();
|
|
390
|
+
return reply
|
|
391
|
+
.status(db.sustained ? 503 : 200)
|
|
392
|
+
.send({ status: db.sustained ? "db-unreachable" : "ok", db });
|
|
380
393
|
});
|
|
381
394
|
|
|
382
395
|
server.get("/addresses", {
|
|
@@ -469,6 +482,54 @@ export const startHttpServer = function* (
|
|
|
469
482
|
});
|
|
470
483
|
|
|
471
484
|
if (ENV.ENABLE_DEV_AND_DEBUG_ENDPOINTS) {
|
|
485
|
+
server.get("/debug/metrics", {
|
|
486
|
+
schema: {
|
|
487
|
+
tags: ["developer"],
|
|
488
|
+
response: {
|
|
489
|
+
200: Type.Object({}, { additionalProperties: true }),
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
}, () => {
|
|
493
|
+
const appliedTs = appliedBlockStatus.timestamp;
|
|
494
|
+
return {
|
|
495
|
+
timestamp: Date.now(),
|
|
496
|
+
uptimeSeconds: process.uptime(),
|
|
497
|
+
memory: process.memoryUsage(),
|
|
498
|
+
protocols: syncProtocols.map((p) => ({
|
|
499
|
+
name: p.name,
|
|
500
|
+
buf: p.bufferedData.size(),
|
|
501
|
+
ownBlockNumber: p.lastPage?.ownBlockNumber ?? null,
|
|
502
|
+
// Backpressure observability
|
|
503
|
+
cap: p.bufferCap,
|
|
504
|
+
bufHighWater: p.bufferHighWater,
|
|
505
|
+
pausedNow: p.pausedNow,
|
|
506
|
+
pauses: p.backpressurePauses,
|
|
507
|
+
pausedMs: p.backpressurePausedMs,
|
|
508
|
+
// Merge-demand exemption: when true the merge is gated on this chain's
|
|
509
|
+
// page and the cap is lifted.
|
|
510
|
+
mergeWaiting: p.mergeWaitingForPage,
|
|
511
|
+
mergeDemandRoot: p.mergeDemandRoot ?? null,
|
|
512
|
+
})),
|
|
513
|
+
// Finalized-block stream backlog: blocks the merge has produced but the
|
|
514
|
+
// apply loop hasn't drained. `inFlight` is the otherwise-unobservable
|
|
515
|
+
// subscriber-queue depth, bounded by backpressure (sync/CLAUDE.md Finding #1).
|
|
516
|
+
// `coalesced` = empty blocks folded away during catch-up.
|
|
517
|
+
finalizedStream: {
|
|
518
|
+
inFlight: finalizedStreamStatus.produced - finalizedStreamStatus.consumed,
|
|
519
|
+
coalesced: finalizedStreamStatus.coalesced,
|
|
520
|
+
},
|
|
521
|
+
// Apply-stage lag: how old the last-applied block is. Unlike `buf` (fetch
|
|
522
|
+
// backlog) this stays high when the node is write/apply-bound.
|
|
523
|
+
applied: {
|
|
524
|
+
blockNumber: appliedBlockStatus.blockNumber,
|
|
525
|
+
timestamp: appliedTs,
|
|
526
|
+
lagSeconds: appliedTs != null
|
|
527
|
+
? Math.round((Date.now() - appliedTs) / 100) / 10
|
|
528
|
+
: null,
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
});
|
|
532
|
+
|
|
472
533
|
server.get("/debug/sync-protocols", {
|
|
473
534
|
schema: {
|
|
474
535
|
tags: ["developer"],
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finalized-block stream backlog for /debug/metrics.
|
|
3
|
+
*
|
|
4
|
+
* `finalized-stream.ts` bumps `produced` when the merge sends a block and `consumed`
|
|
5
|
+
* when the apply loop drains one; their difference is the live subscriber-queue
|
|
6
|
+
* depth that drives backpressure and is surfaced as `inFlight` on /debug/metrics
|
|
7
|
+
* (the one structure flagged unbounded in sync/CLAUDE.md Finding #1). `coalesced`
|
|
8
|
+
* counts empty blocks folded away, so a catch-up test can prove coalescing fired.
|
|
9
|
+
*
|
|
10
|
+
* Process-global: one node runs per process, so a module-level singleton avoids
|
|
11
|
+
* threading a ref through startMerge and the coalescer.
|
|
12
|
+
*/
|
|
13
|
+
export type FinalizedStreamStatus = {
|
|
14
|
+
produced: number;
|
|
15
|
+
consumed: number;
|
|
16
|
+
coalesced: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const finalizedStreamStatus: FinalizedStreamStatus = {
|
|
20
|
+
produced: 0,
|
|
21
|
+
consumed: 0,
|
|
22
|
+
coalesced: 0,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function recordProduced(): void {
|
|
26
|
+
finalizedStreamStatus.produced++;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function recordConsumed(n: number): void {
|
|
30
|
+
finalizedStreamStatus.consumed += n;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function recordCoalesced(n: number): void {
|
|
34
|
+
finalizedStreamStatus.coalesced += n;
|
|
35
|
+
}
|
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
|
+
}
|