@effectstream/sync 0.100.18 → 0.100.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md ADDED
@@ -0,0 +1,206 @@
1
+ # `@effectstream/sync` — how the sync service works
2
+
3
+ This package fetches data from every configured chain, interleaves it into a single
4
+ deterministic stream of **effectstream blocks** ordered by time, and hands that stream
5
+ to the runtime for state-machine execution.
6
+
7
+ > **Naming gotcha:** despite the project name and an `effect` (Effect-TS) entry in the
8
+ > root `package.json`, the sync service uses **[`effection`](https://frontside.com/effection)**,
9
+ > a generator-based structured-concurrency library. Effect-TS is **not imported anywhere**
10
+ > in source. Everywhere below, an "effect" is an `effection` `Operation<T>` — a lazy,
11
+ > cancellable computation written as a `function*` and composed with `yield*`.
12
+
13
+ ## effection in 30 seconds
14
+
15
+ | effection | rough analogy |
16
+ |---|---|
17
+ | `Operation<T>` | a lazy, cancellable `async` function |
18
+ | `function*` / `yield*` | `async` / `await` |
19
+ | `spawn(op)` | fork a child task (dies with its parent scope) |
20
+ | `call(() => promise)` / `until(promise)` | `await` a Promise inside an Operation |
21
+ | `all([...ops])` | `Promise.all` |
22
+ | `createChannel()` + `each(ch)` | an async stream / `for await` |
23
+ | `conditionVariable()` (`@effectstream/utils`, `concurrency/condVar.ts`) | a monitor wait/wake |
24
+
25
+ Because every loop is `spawn`ed under the runtime's `start()` scope, cancelling the parent
26
+ tears down every fetcher, the merge, the heartbeat, and the HTTP server cleanly.
27
+
28
+ ## The pipeline at a glance
29
+
30
+ ```
31
+ config (syncProtocols.main + syncProtocols.parallel)
32
+ │ genSyncProtocols() → one SyncState per chain, main at index 0
33
+ ▼
34
+ ┌──────────────────────────────────────────────────────────────────────┐
35
+ │ PER-CHAIN FETCH LOOPS one spawn per protocol │
36
+ │ startSync(state): stateToInput → readData → updateState │
37
+ │ → push Output to bufferedData (Deque) + wake newData/newPageCondVar│
38
+ │ → lastPage kept IN-MEMORY only (no DB write; runtime owns resume) │
39
+ │ → producerChannel.send(...) ← NOTE: nothing consumes this today │
40
+ └────────────────────────────────────────┬─────────────────────────────┘
41
+ ▼
42
+ ┌──────────────────────────────────────────────────────────────────────┐
43
+ │ MERGE LOOP startMerge() one spawn, produces ONE ChainBlock/iter │
44
+ │ i=0 main: toRootOutput(front datum) → new root block @ timestamp T│
45
+ │ i>0 parallel: wait page>T, drain buffered data ≤T via mergeDatum() │
46
+ │ → finalizedBlockStream.send(block); then run cleanups (pop Deques) │
47
+ └────────────────────────────────────────┬─────────────────────────────┘
48
+ ▼
49
+ runtime/src/main.ts: for (block of each(finalizedBlockStream))
50
+ acquire DB mutex → processFinalizedBlock (STF)
51
+ → upsertPage(block.resumePages) + removePages(< committed) [in txn] → COMMIT
52
+ → fire-and-forget MQTT events → each.next()
53
+ ```
54
+
55
+ ## The three per-chain abstractions (`src/sync-protocols/<chain>/`)
56
+
57
+ 1. **Fetcher** — `BaseDataFetcher` subclass (`base/fetcher.ts`). `readData(input)` fetches a
58
+ page-range of raw chain data → `DataFetched { output[], lastPage }`. Implements
59
+ `PaginatedFetcher` (`getLatestPage`, `nextInterval`, `previousInterval`,
60
+ `intervalFromStart`) for chunked pagination by `stepSize`, and optionally
61
+ `PrimitiveFetcher` (`base/primitive.ts`: `readPrimitives` / `groupByPage`) to extract
62
+ app-relevant primitives from raw blocks.
63
+
64
+ 2. **State** — `SyncState` subclass (`base/state.ts`). Holds `bufferedData` (a `denque`
65
+ Deque of fetched-not-yet-merged data, `state.ts:50`), `lastPage` (high-water mark,
66
+ `state.ts:70`), two CondVars (`newDataCondVar` / `newPageCondVar`, `state.ts:57-63`),
67
+ and error/health counters. Defines the chain↔root contract:
68
+ - `stateToInput()` — compute the next page-range to fetch (via `common/page-helpers.ts:genInputRange`)
69
+ - `toRootPage()` — map a block → millisecond timestamp (the **merge key**)
70
+ - `toRootOutput()` — **main only** — produce a root `ChainBlock`
71
+ - `mergeDatum()` — **parallel only** — fold this chain's data into the root
72
+ - `updateState()` (`state.ts:109`) — push outputs to the Deque, wake CondVars, and
73
+ persist the page (`upsertPage`, `state.ts:132`).
74
+
75
+ 3. **types.ts** — `Input` / `Output` / `Page` aliases (+ a `Client.ts` per chain where an
76
+ RPC client is needed).
77
+
78
+ **main vs parallel** is structural in config — `toSyncProtocolWithNetwork`
79
+ (`@effectstream/config`, `config/utils.ts`) builds the array **main-first**, then parallels —
80
+ but enforced at runtime only by thrown errors: e.g. `evm/state.ts` `toRootOutput` throws
81
+ "Only main chains create root outputs"; `ntp/state.ts` `mergeDatum` throws "Only parallel
82
+ chains merge into root". **The main chain must be at index 0.**
83
+
84
+ ## The two driving loops
85
+
86
+ **`startSync(state)`** (`src/sync-protocols/orchestration/sync.ts`) spawns:
87
+ - `startAsync()` — optional background producer (websocket subs for utxorpc/midnight;
88
+ no-op for NTP/EVM).
89
+ - the **polling loop**: `stateToInput()` → `readData()` → `updateState()` →
90
+ `producerChannel.send()` (`sync.ts:70`). Errors are swallowed via `tryYield`, bump
91
+ `consecutiveErrors`, log, sleep `pollingInterval`, continue. The loop only sleeps when
92
+ `stateToInput` returns `undefined` (caught up); while behind it fetches chunks
93
+ back-to-back.
94
+
95
+ **`startMerge(syncProtocols, finalizedBlockStream)`** (`orchestration/merge.ts`) loops; per
96
+ iteration it walks protocols in order:
97
+ - **main (i=0, root not yet set):** wait for first page, peek the front buffered datum,
98
+ `toRootOutput` → a fresh `ChainBlock` whose `timestamp` becomes the slot boundary `T`.
99
+ - **parallel (i>0):** block on `newPageCondVar` until *that chain's page has advanced past
100
+ `T`* (proving it scanned all data up to `T`), then drain every buffered datum with
101
+ timestamp ≤ `T` into the root via `mergeDatum`.
102
+ - send the block downstream, **then** run cleanups that pop consumed data from each Deque.
103
+
104
+ ## Key design ideas
105
+
106
+ 1. **Time is the universal merge key.** Every chain maps blocks → ms timestamp. The main
107
+ chain (usually NTP, a wall-clock heartbeat at `blockTimeMS`) emits empty root blocks at a
108
+ fixed cadence; parallel chains' data is bucketed into those slots.
109
+ 2. **Page ≠ Data.** `lastPage` advances even on an empty query, so a parallel chain can
110
+ signal "scanned up to `T`, found nothing" and let the merge proceed. `newPageCondVar`
111
+ = progress; `newDataCondVar` = content.
112
+ 3. **Confirmation depth as a time shift.** `common/utils.ts:applyDelay` *adds* `delayMs` to
113
+ a parallel chain's timestamp so older blocks merge into more-recent root slots.
114
+ 4. **Mutate-after-commit.** `OutputAndCleanup` keeps data in the Deque until the block is
115
+ safely produced; the consumer commits to Postgres *before* emitting events.
116
+ 5. **Durable, block-accurate resume queue.** *(Single source of truth for how restart/resume
117
+ works — code comments point here; see also Finding #2 for the bug this fixes.)*
118
+
119
+ `effectstream.sync_protocol_pagination` is keyed `(protocol_name, page_number)` and the
120
+ **runtime is the sole writer** of the resume position (Fix D). End to end:
121
+ - **Fetch loop** (`base/state.ts:updateState`) keeps `lastPage` in memory only — it does
122
+ **not** persist (it runs ahead of commits, so its chunk-end page is not a safe resume
123
+ point).
124
+ - **Merge** (`orchestration/merge.ts`) tags every `ChainBlock` with `resumePages`: one
125
+ marker per protocol = `outputToLastPage(lastConsumed)`, the `LastPage`
126
+ (`{ own, ownBlockNumber, root }`) of the **highest datum merged into the block**. Each
127
+ protocol implements `outputToLastPage` (`base/state.ts` + per-chain `state.ts`) because
128
+ `own` is a structured page for object-paged chains and can't be rebuilt from the block
129
+ number. Pending data above the committed timestamp is **not** marked, so it is always
130
+ re-fetched, never skipped.
131
+ - **Commit** (`runtime/process-blocks.ts`, STEP 7) writes each marker **inside the block's
132
+ transaction**: `upsertPage(page_number = ownBlockNumber)` then
133
+ `removePages(page_number < ownBlockNumber)` — leaving exactly one row per protocol = the
134
+ committed watermark, atomic with the block.
135
+ - **Boot** (`restoreState`) reads `getPage` = `ORDER BY page_number ASC LIMIT 1` and
136
+ resumes from `nextInterval(own).from` = the next uncommitted block.
137
+
138
+ **Re-scan on restart is bounded by `stepSize`, not a chain's whole quiet tail.** Every
139
+ fetched chunk's boundary page (`data.to`) is buffered and consumed even when empty, so the
140
+ marker advances to within one `stepSize` of the committed frontier — and reaches the
141
+ fetched tip exactly at full catch-up. So a long-quiet chain re-fetches at most ~`stepSize`
142
+ blocks after a restart, regardless of how long ago its last data was. Trade-off:
143
+ `getSyncAndLastPage` now reports `synced_page == fetched_page`.
144
+
145
+ ---
146
+
147
+ ## Findings (open issues, as of this investigation)
148
+
149
+ Both bite hardest during **deep catch-up** — e.g. DB synced 3 days ago with a 0.25s chain
150
+ (~1.04M blocks behind) and a 6s chain (~43k behind), NTP main at `blockTimeMS: 1000`.
151
+
152
+ ### 1. Unbounded buffering + head-of-line blocking (performance / OOM)
153
+
154
+ The fetch loop has **no backpressure** and **no clamp** relative to the merge or other
155
+ chains: `genInputRange` (`common/page-helpers.ts`) clamps each fetch only to the chain's own
156
+ tip and `nextPage + stepSize`. A `grep` confirms the only reads of `bufferedData.size()` are
157
+ the merge's first-datum wait (`merge.ts:110`) and the heartbeat log (`runtime/main.ts:106`) —
158
+ nothing pauses fetching when the Deque grows.
159
+
160
+ Consequences during catch-up:
161
+ - Each chain races to its own tip, filling its in-memory Deque toward the **entire backlog**
162
+ (~1.04M block objects for the 0.25s chain; NTP fills ~259k near-instantly since it's pure
163
+ arithmetic). Drain happens only at serial-replay speed (one DB txn + STF per block in
164
+ `processFinalizedBlock`), which is far slower than fetch → peak memory ≈ whole backlog →
165
+ **OOM risk**. No cap exists.
166
+ - The merge is gated per timestamp `T` on the slowest chain's *page* to advance
167
+ (`mergeIntoRoot`, `merge.ts`), so a slow/stalled chain stalls all block production while
168
+ the others keep ballooning (head-of-line blocking).
169
+
170
+ → Fix: **(C) backpressure** — cap `bufferedData` and pause the fetch loop until the merge drains.
171
+
172
+ ### 2. Silent data gap on restart (correctness) — ✅ FIXED (D)
173
+
174
+ **The bug (historical).** The page queue was **chunk-granular** but commits are
175
+ **single-block-granular**: the fetch loop persisted `upsertPage(page_number = chunk_end)`,
176
+ commit deleted `removePages(page_number < merged_block_N)`, and restart resumed from
177
+ `getPage` = oldest remaining chunk at `nextInterval(own).from = chunk_end + 1`. During
178
+ catch-up chunks are full `stepSize`, so the committed watermark `N` sat **inside** the
179
+ oldest retained chunk `[start, end]` (`end ≥ N ⇒ not deleted`); resume jumped to `end + 1`,
180
+ **silently skipping `(N, end]`** → missing primitives → divergent app state, with no error.
181
+
182
+ **The fix (D) — block-accurate resume, runtime is the sole writer.** The runtime persists a
183
+ block-accurate marker per protocol on commit instead of the fetch loop persisting chunk-end
184
+ pages. Full mechanism in **design idea #5** above. Regression test: the
185
+ `consistency.test.ts` "mid-chunk restart" case (Postgres-only).
186
+
187
+ ### 3. Serial replay is the catch-up bottleneck
188
+
189
+ Block cadence is the main chain's granularity (1/s for NTP@1000), so 3 days ≈ 259,200
190
+ effectstream blocks, each a separate DB transaction + STF run. Most are empty during
191
+ catch-up.
192
+
193
+ → Fix: **(E) faster replay** (conservative, flag-gated) — coalesce consecutive empty
194
+ catch-up blocks into fewer transactions while preserving steady-state semantics and the
195
+ block-hash chain.
196
+
197
+ > This PR adds the synthetic `test` chain (`src/sync-protocols/test/`) and an
198
+ > in-process harness (`packages/node-sdk/runtime/test/reproduction/`, in the
199
+ > runtime package because it boots the full `start()`, which sync must not depend
200
+ > on). `sanity.test.ts` proves the chain syncs, processes a configured event, and
201
+ > survives a restart. See `docs/ADDING-A-SYNC-PROTOCOL.md` for how to add a chain
202
+ > (with `test` as the worked example).
203
+ >
204
+ > Deterministic reproductions of #1/#2/#3 and their fixes land alongside each fix:
205
+ > **#2 → block-accurate resume is DONE** (`consistency.test.ts`). Still in follow-up
206
+ > PRs: #1 → fetch backpressure; #3 → opt-in empty-block commit batching during catch-up.
package/README.md CHANGED
@@ -46,6 +46,55 @@ const protocols = await genSyncProtocols(config);
46
46
  // protocols.parallelEvmRPC_fast.runOne() // poll one block
47
47
  ```
48
48
 
49
+ ## Backpressure (`maxBufferedPages`)
50
+
51
+ During deep catch-up a chain's fetch loop races to its tip far faster than the
52
+ merge can drain (the merge applies one block per DB transaction). Without a bound
53
+ the in-memory buffer (`SyncState.bufferedData`) grows toward the **entire backlog**
54
+ — hundreds of thousands of block objects — which is an OOM risk.
55
+
56
+ **The cap.** Every chain's `stateToInput` calls `bufferAtCap(state, syncProtocol)`
57
+ first (`sync-protocols/common/page-helpers.ts`): when
58
+ `bufferedData.size() >= maxBufferedPages` it returns `undefined`, so the chain stops
59
+ fetching — exactly as if it had caught up to the tip — and the polling loop sleeps
60
+ `pollingInterval` and retries. The merge keeps draining the buffer; once it drops
61
+ below the cap the next poll resumes. Peak in-memory buffering is therefore bounded
62
+ to **≈ `maxBufferedPages + stepSize`** per chain (one in-flight chunk can overshoot
63
+ the cap), instead of the whole backlog.
64
+
65
+ **Config.** `maxBufferedPages` is an optional field on every sync-protocol config
66
+ (declared once on the shared `PollingSyncProtocol` schema). When unset it defaults to
67
+ **`4 × stepSize`** (the `MAX_BUFFER_MULTIPLE` constant in `page-helpers.ts`), always
68
+ clamped to **`≥ stepSize + 1`** so a chain can always fetch at least one chunk to feed
69
+ the merge. ~4 chunks of look-ahead never starves the merge while keeping memory
70
+ bounded; raise it to trade memory for more fetch look-ahead. The cap only bites during
71
+ catch-up — in steady state the buffer sits near zero and the cap is never reached.
72
+
73
+ **Deadlock-safety.** Pausing a full chain cannot deadlock: a chain's page only
74
+ advances via a fetch (`updateState`), and the merge drains a chain's buffered data
75
+ *before* it waits on that chain's page — so the paused (full) chain is never the one
76
+ the merge is blocked on without a path to resume.
77
+
78
+ **Scope.** The guard runs in **every** chain's `stateToInput`, so all sync chains are
79
+ covered: EVM, NTP, Bitcoin, Avail, Celestia, NEAR, Midnight, Cardano (UTXO-RPC), and
80
+ the synthetic `test` chain. Two notes:
81
+ - **Cardano (UTXO-RPC)** has no `stepSize` (it streams one block per pass), so the cap
82
+ falls back to a default chunk size of 1000 (⇒ default cap 4000); set `maxBufferedPages`
83
+ explicitly to tune it.
84
+ - The cap bounds `SyncState.bufferedData` (the merge-facing Deque the backpressure
85
+ feature protects). The UTXO-RPC fetcher additionally keeps its own internal FollowTip stream
86
+ buffer; pausing `stateToInput` stops draining it into `bufferedData`, but bounding
87
+ that lower-level stream is a separate, fetcher-specific concern.
88
+
89
+ **Observability.** Each `SyncState` tracks, and the runtime's `/debug/metrics`
90
+ endpoint reports per protocol: `cap` (resolved `maxBufferedPages`), `buf` (current
91
+ size), `bufHighWater` (peak since boot — catches spikes between samples), `pausedNow`,
92
+ `pauses` (rising-edge count — **the "backpressure engaged" signal**), and `pausedMs`
93
+ (total time paused). `pauses > 0` means the cap actively bounded memory; `0` means it
94
+ was never needed in that run. Steady-state these sit at `0`; during a real deep
95
+ catch-up (or under the perf harness's `PERF_APPLY_DELAY_MS` drain throttle) they climb
96
+ as `buf` pins to `cap`.
97
+
49
98
  ## Key exports
50
99
 
51
100
  - `genSyncProtocols(dbConn, syncInfo)` - Effection generator that instantiates a runtime fetcher + state pair for every protocol in `syncInfo` (from `config.syncProtocols`). Called from the runtime's process-blocks loop.
@@ -0,0 +1,164 @@
1
+ # Adding a new chain / sync protocol
2
+
3
+ This is the end-to-end checklist for registering a new chain, distilled from
4
+ adding the synthetic **`test`** chain (its files are the worked example — search
5
+ for `TEST_MAIN` / `TEST_PARALLEL` / `ConfigNetworkType.TEST`). Read
6
+ `packages/node-sdk/sync/CLAUDE.md` first for how the sync service works.
7
+
8
+ A chain needs a **network type** (`ConfigNetworkType`) and one or more
9
+ **sync-protocol types** (`ConfigSyncProtocolType`). A network may have several
10
+ sync protocols (e.g. Cardano has CARP + UTXORPC; the `test` chain has a `main`
11
+ clock variant and a `parallel` variant). Each sync protocol is either **main**
12
+ (produces root blocks via `toRootOutput`) or **parallel** (folds data into the
13
+ root via `mergeDatum`).
14
+
15
+ > TypeScript is your friend here: several registries are
16
+ > `satisfies Record<ConfigSyncProtocolType, …>`, so the compiler forces you to
17
+ > add entries. But note this repo does **not** typecheck cleanly under
18
+ > `bunx tsc` (it runs via bun's loaders); validate by running
19
+ > `bun test ./packages` and a config that uses the new chain.
20
+
21
+ ## Pick an archetype to copy
22
+
23
+ Start from the closest existing chain rather than from scratch:
24
+
25
+ | Your chain | Copy | Notes |
26
+ |---|---|---|
27
+ | Synthetic / arithmetic clock (no RPC) | `ntp/`, `test/` | blocks computed from `startTime`+`blockTimeMS`; main role |
28
+ | RPC, polled, emits primitives (parallel) | `evm/` | `getLatestPage` from RPC; `readPrimitives` filters logs; needs a viem client |
29
+ | Bitcoin-style RPC | `bitcoin/` | custom `Client.ts` (`BitcoinRpcClient`), small `stepSize` (25) |
30
+ | Streaming / subscription (push) | `utxorpc/`, `midnight/` | override **`startAsync`**; a `Client.ts` + a multiplexer/buffer feed the channel |
31
+ | Substrate-based | `avail/`, `midnight/` | substrate network schema + genesis hash |
32
+ | NEAR-style | `near/` | `Client.ts`, event-standard filters |
33
+
34
+ `stepSize` (chunk size) defaults vary by chain: 1000 (EVM/NTP/Avail), 25
35
+ (Bitcoin), 10 (Midnight/Celestia/NEAR) — pick what the RPC can serve per call.
36
+
37
+ ## 1. Config (`@effectstream/config`, `src/schema/`)
38
+
39
+ - **`network/types.ts`** — add to the `ConfigNetworkType` enum.
40
+ - **`network/<chain>.ts`** (new) — a `new ConfigSchema({ required, optional })`
41
+ describing the network (mirror `network/ntp.ts`). Optional fields **must** have
42
+ a `default`.
43
+ - **`network/all.ts`** — import the schema and add it to `networkTypes`. (This is
44
+ all that's needed for the network to appear in `NetworkConfig` — it's derived
45
+ from `networkTypes`.)
46
+ - **`network/utils.ts`** — add a `case` to `caip2PrefixFor` (the trailing
47
+ `throw` means an un-handled type fails at runtime).
48
+ - **`sync-protocols/types.ts`** —
49
+ - add to the `ConfigSyncProtocolType` enum;
50
+ - add to `SyncProtocolToNetwork` (**exhaustive** `satisfies Record`);
51
+ - add a `…Primitive` type and an entry in `ProtocolPrimitiveMap`.
52
+ - **`sync-protocols/<chain>/rpc.ts`** (new) — `ConfigSchema`s for each variant +
53
+ a `CommonResponse…` per variant (mirror `ntp/rpc.ts` for main,
54
+ `evm/rpc.ts` for parallel). Shared fields come from `NameField`,
55
+ `PollingSyncProtocol`, `StartStopBlockheight` via `.cloneMerge(...)`.
56
+ - **`sync-protocols/all.ts`** — register each variant in `mainSyncProtocolTypes`
57
+ / `parallelSyncProtocolTypes`, and its `CommonResponse` in
58
+ `syncProtocolCommonResponse` (**exhaustive** `satisfies Record`).
59
+ - **`primitive/types.ts`** — add a payload type + entry in `ProtocolPayloadMap`
60
+ (used by `FlattenSyncProtocolIOFor`).
61
+
62
+ `mod.ts` re-exports are optional for the rpc files (NTP/NEAR/test rpc are *not*
63
+ re-exported; `all.ts` imports them directly). `CommonResponse` has **no
64
+ node-sdk runtime consumer** — it's config-side type derivation — so you have
65
+ latitude on its exact shape.
66
+
67
+ ## 2. Sync (`@effectstream/sync`, `src/`)
68
+
69
+ - **`sync-protocols/<chain>/types.ts`** — `Page`, `Input` (`PageSyncRange<Page>`),
70
+ `Output` (must include `primitives: PrimitiveType[]` if the chain emits any),
71
+ `PrimitiveType = FlattenSyncProtocolIOFor<…>`, `toMsTimestamp`.
72
+ - **`sync-protocols/<chain>/fetcher.ts`** — extend `BaseDataFetcher`; implement
73
+ `readData` (+ `PaginatedFetcher`: `getLatestPage`/`nextInterval`/
74
+ `previousInterval`/`intervalFromStart`, and `PrimitiveFetcher` if it emits
75
+ primitives). `lastPage.own`/`ownBlockNumber` = the **chunk end** (`data.to`).
76
+ Page-request helpers live in `base/page.ts`
77
+ (`genImmediatePageRequests`/`genOnDemandPageRequests`).
78
+ - **`sync-protocols/<chain>/Client.ts`** (new, only if it talks to a node) — the
79
+ RPC/stream client (e.g. `bitcoin/fetcher.ts:BitcoinRpcClient`,
80
+ `near/NearClient.ts`). EVM-family chains instead use a viem client built with
81
+ `createViemPublicClient` + `getViemNetwork` (declare the network via the
82
+ builder's `addViemNetwork`).
83
+ - **`sync-protocols/<chain>/state.ts`** — extend `SyncState`; implement
84
+ `stateToInput` (via `common/page-helpers.ts:genInputRange`), `toRootPage`
85
+ (→ ms timestamp; use `applyDelay` for confirmation depth), `toRootOutput`
86
+ (main), `mergeDatum` (parallel), `getNamespace`, and a static `restoreState`
87
+ that reads `getPage`. For **streaming** chains, override **`startAsync()`** to
88
+ run the subscription/producer in the background (it feeds `bufferedData`);
89
+ polled chains leave it as the no-op default. (A `maxBufferedData` backpressure
90
+ cap on the constructor is a planned follow-up — see CLAUDE.md finding #1.)
91
+ - **`syncProtocolFactory.ts`** — add an `else if (entry.networkType === …)`
92
+ branch constructing the client + fetcher + `restoreState`.
93
+ - **`sync-protocols/types.ts`** — add the state class to the `AllSyncProtocols`
94
+ union.
95
+ - **`sync-protocols/mod.ts`** — export the new fetcher/state.
96
+
97
+ ## 3. Runtime (`@effectstream/runtime`, `src/`)
98
+
99
+ - **`api/http-server.ts`** — add a `case` to the `switch (networkType)` in the
100
+ blocks endpoint (import the fetcher).
101
+ - **`config-snapshot.ts`** — `extractImmutableConfig` **throws** for un-handled
102
+ protocol types. Add the chain to the right branch (NTP/TEST share
103
+ `startTime`+`blockTimeMS`; most parallel chains use `startBlockHeight`).
104
+ - **`main.ts` clock detection** (only if your chain can be the **main** clock) —
105
+ the lag threshold is derived from the main clock's `blockTimeMS` via a
106
+ `syncInfo.find(...)` that today keys on `NTP_MAIN`. If your chain is a new main
107
+ clock and you want lag logging (and the planned catch-up batching) to engage,
108
+ add its `*_MAIN` type to that predicate.
109
+
110
+ ## 4. Primitives (only if the chain emits data)
111
+
112
+ A fetched primitive's `primitive` field must equal a **registered primitive's
113
+ instanceName**, which is the config primitive's `id` (= its `name`). The fetcher
114
+ typically tags emitted primitives with `this.config.primitives[i].id`. The
115
+ primitive class (built-in in `@effectstream/sm` or supplied via
116
+ `StartConfig.userDefinedPrimitives`) turns raw data into `primitive_accounting`
117
+ rows and optional scheduled STF inputs.
118
+
119
+ ## Gotchas / learnings
120
+
121
+ - **One network → many sync protocols** is allowed (Cardano, `test`). `FlipObject`
122
+ unions them, so `addMain`/`addParallel` narrow correctly.
123
+ - **Resume is currently chunk-granular** (a known issue — CLAUDE.md finding #2):
124
+ on restart a chain resumes from the *end* of its oldest retained page chunk,
125
+ which can silently skip an un-committed tail. A block-accurate-resume fix is
126
+ planned; until then, avoid relying on exact restart positions mid-chunk.
127
+ - **Merge boundary gate**: a parallel chain only merges into a root block at
128
+ timestamp `T` once its own page is *strictly* past `T`. If the main clock runs
129
+ to the same height as a parallel chain's fetched tip, the merge stalls at that
130
+ boundary — keep the main clock at/behind the parallel tips.
131
+ - **`finalizedBlockStream` is unbuffered**: a message sent with no active
132
+ subscriber is dropped. The merge is spawned before the consumer loop
133
+ subscribes, so a very fast producer could in principle drop early blocks
134
+ (RPC-paced chains don't hit this); a subscribe-before-spawn hardening is a
135
+ planned follow-up.
136
+ - **`isPresync`** is currently ~always false (`genInputRange` TODO) — the
137
+ historical-presync path may not trigger as intended.
138
+
139
+ ## Verify
140
+
141
+ Build a `ConfigBuilder` config that declares the chain (see
142
+ `packages/node-sdk/runtime/test/reproduction/sanity.test.ts` for the `test`-chain
143
+ config), then `toSyncProtocolWithNetwork(config)` + `start({...})`, or run
144
+ `bun test packages/node-sdk/runtime/test/reproduction/`.
145
+
146
+ ## Testing a new chain (in-process harness)
147
+
148
+ `packages/node-sdk/runtime/test/reproduction/harness.ts` is the reusable
149
+ template — it boots the real `start()` in-process against an in-process PGLite
150
+ server and the synthetic `test` chain. Reuse its patterns for any chain:
151
+
152
+ - **Env is lazy** (`ENV.*` reads `process.env` at access), so set
153
+ `PGLITE`/`DB_*`/`EFFECTSTREAM_API_PORT` per run before `start()`. With multiple
154
+ harnesses in one file, re-assert DB env in `runNode` (shared `process.env`).
155
+ - **Determinism**: drive the synthetic chain's tip via the in-memory
156
+ `TestChainControl` registry (no real clock); poll with
157
+ `waitForHeight`/`waitUntilStable` (fixed tips → a well-defined stable state).
158
+ - **Observe live state** via the test-only `StartConfig.dev.onStarted` hook
159
+ (exposes the `AllSyncProtocols[]`, e.g. for `bufferedData.size()` assertions)
160
+ and assert effects against DB tables (`primitive_accounting`,
161
+ `rollup_input_result`, `effectstream_blocks`, `sync_protocol_pagination`).
162
+ - **Simulate a restart** by halting the node task while keeping PGLite alive
163
+ (state persists), then booting again.
164
+ - Set `MQTT_BROKER=false` (it binds fixed ports that aren't freed on halt).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/sync",
3
- "version": "0.100.18",
3
+ "version": "0.100.20",
4
4
  "license": "MIT",
5
5
  "homepage": "https://effectstream.github.io/docs/",
6
6
  "repository": {
@@ -13,10 +13,10 @@
13
13
  },
14
14
  "exports": "./src/mod.ts",
15
15
  "dependencies": {
16
- "@effectstream/config": "0.100.18",
17
- "@effectstream/db": "0.100.18",
18
- "@effectstream/log": "0.100.18",
19
- "@effectstream/utils": "0.100.18",
16
+ "@effectstream/config": "0.100.20",
17
+ "@effectstream/db": "0.100.20",
18
+ "@effectstream/log": "0.100.20",
19
+ "@effectstream/utils": "0.100.20",
20
20
  "@midnight-ntwrk/ledger-v8": "8.0.3",
21
21
  "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.2",
22
22
  "@midnight-ntwrk/onchain-runtime": "npm:@midnight-ntwrk/onchain-runtime-v3@3.0.0",
@@ -10,6 +10,7 @@ import type { ConfigNetworkType, SyncProtocolWithNetwork } from "@effectstream/c
10
10
  import { getPage } from "@effectstream/db";
11
11
  import { AvailClient } from "./AvailClient.ts";
12
12
  import { applyDelay } from "../common/utils.ts";
13
+ import { bufferAtCap } from "../common/page-helpers.ts";
13
14
 
14
15
  export class AvailSyncState extends SyncState<
15
16
  Input,
@@ -63,6 +64,7 @@ export class AvailSyncState extends SyncState<
63
64
 
64
65
  @bound
65
66
  override *stateToInput(): Operation<Input | undefined> {
67
+ if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
66
68
  const latestHeight = yield* call(() => this.client.getLatestBlockHeight());
67
69
  const startHeight = this.lastPage?.own.height ??
68
70
  this.config.syncProtocol.startBlockHeight - 1;
@@ -99,6 +101,15 @@ export class AvailSyncState extends SyncState<
99
101
  rootOutput.primitives.push(...primitives);
100
102
  }
101
103
 
104
+ @bound
105
+ override outputToLastPage(data: Output): LastPage<Page, RootPage> {
106
+ return {
107
+ own: { height: data.raw.number, hash: data.raw.hash },
108
+ ownBlockNumber: data.raw.number,
109
+ root: this.toRootPage(data),
110
+ };
111
+ }
112
+
102
113
  @bound
103
114
  override getNamespace(): string[] {
104
115
  return [this.config.network.name, this.config.syncProtocol.name];
@@ -1,4 +1,4 @@
1
- import { type Operation, until } from "effection";
1
+ import { type Operation } from "effection";
2
2
  import Deque from "denque";
3
3
  import {
4
4
  type BlockNumber,
@@ -11,7 +11,6 @@ import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
11
11
  import type { BaseDataFetcher, DataFetched } from "./fetcher.ts";
12
12
  import type { PageRelation } from "./page.ts";
13
13
  import type { PoolClient } from "pg";
14
- import { acquireDBMutex, releaseDBMutex, upsertPage } from "@effectstream/db";
15
14
 
16
15
  export type LastPage<Page, RootPage> = {
17
16
  own: Page;
@@ -75,6 +74,20 @@ export abstract class SyncState<
75
74
  /** Timestamp of the most recent error, or 0 if no error has occurred. */
76
75
  public lastErrorTimestamp: number = 0;
77
76
 
77
+ // ── Backpressure observability (see common/page-helpers.ts + README) ──
78
+ /** Resolved fetch cap (`maxBufferedPages`); set on each backpressure check, 0 until the first. */
79
+ public bufferCap: number = 0;
80
+ /** Peak `bufferedData.size()` seen since boot — catches spikes the periodic sampler misses. */
81
+ public bufferHighWater: number = 0;
82
+ /** Whether fetching is currently paused because the buffer is at/over the cap. */
83
+ public pausedNow: boolean = false;
84
+ /** How many times the cap engaged (rising edges) — the "backpressure fired" counter. */
85
+ public backpressurePauses: number = 0;
86
+ /** Total time fetching has been paused by the cap, in ms. */
87
+ public backpressurePausedMs: number = 0;
88
+ /** Start of the current pause, for accumulating `backpressurePausedMs`. */
89
+ private pauseStartMs: number = 0;
90
+
78
91
  constructor(
79
92
  public readonly name: string,
80
93
  lastPage: undefined | LastPage<Page, RootPage>,
@@ -106,6 +119,24 @@ export abstract class SyncState<
106
119
  */
107
120
  abstract mergeDatum(ourOutput: Output, rootOutput: RootOutput): void;
108
121
 
122
+ recordBackpressure(atCap: boolean, cap: number): void {
123
+ this.bufferCap = cap;
124
+ const now = Date.now();
125
+ if (atCap && !this.pausedNow) {
126
+ this.pausedNow = true;
127
+ this.pauseStartMs = now;
128
+ this.backpressurePauses += 1;
129
+ } else if (!atCap && this.pausedNow) {
130
+ this.pausedNow = false;
131
+ this.backpressurePausedMs += now - this.pauseStartMs;
132
+ }
133
+ }
134
+ /**
135
+ * Build the resume marker for a single output: the {@link LastPage} to resume
136
+ * fetching *after* it. See CLAUDE.md design idea #5 (restart/resume).
137
+ */
138
+ abstract outputToLastPage(data: Output): LastPage<Page, RootPage>;
139
+
109
140
  @bound
110
141
  *updateState(
111
142
  _input: Input,
@@ -115,6 +146,8 @@ export abstract class SyncState<
115
146
  for (const datum of data.output) {
116
147
  this.bufferedData.push(datum);
117
148
  }
149
+ // Peak right after a push — the local max before the merge drains it.
150
+ this.bufferHighWater = Math.max(this.bufferHighWater, this.bufferedData.size());
118
151
  this.newDataCondVar.wake();
119
152
  }
120
153
 
@@ -129,13 +162,8 @@ export abstract class SyncState<
129
162
  ),
130
163
  );
131
164
 
132
- yield* acquireDBMutex(`update-state-${this.name}`);
133
- yield* until(upsertPage.run({
134
- protocol_name: this.name,
135
- page_number: data.lastPage.ownBlockNumber,
136
- page: JSON.stringify(data.lastPage),
137
- }, this.dbConn as any)); // Client,
138
- releaseDBMutex(`update-state-${this.name}`);
165
+ // lastPage is in-memory only; the runtime is the sole writer of the
166
+ // persisted resume marker (see CLAUDE.md design idea #5).
139
167
  this.lastPage = data.lastPage;
140
168
  this.newPageCondVar.wake();
141
169
  }
@@ -16,7 +16,7 @@ import {
16
16
  type Output,
17
17
  type Page,
18
18
  } from "./types.ts";
19
- import { genInputRange } from "../common/page-helpers.ts";
19
+ import { bufferAtCap, genInputRange } from "../common/page-helpers.ts";
20
20
  import type {
21
21
  ConfigNetworkType,
22
22
  SyncProtocolWithNetwork,
@@ -58,6 +58,7 @@ export class BitcoinSyncState extends SyncState<
58
58
  block_number: Number(data.raw.height),
59
59
  blockHash: hash,
60
60
  })),
61
+ resumePages: [],
61
62
  primitives: data.primitives.map((primitive) => ({
62
63
  ...primitive,
63
64
  source: this.config.syncProtocol.name,
@@ -75,6 +76,7 @@ export class BitcoinSyncState extends SyncState<
75
76
 
76
77
  @bound
77
78
  override *stateToInput(): Operation<Input | undefined> {
79
+ if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
78
80
  return yield* genInputRange(
79
81
  this as BitcoinSyncState,
80
82
  this.config.syncProtocol.startBlockHeight as Page,
@@ -112,6 +114,16 @@ export class BitcoinSyncState extends SyncState<
112
114
  rootOutput.blockInfo.push(...blockInfo);
113
115
  }
114
116
 
117
+ @bound
118
+ override outputToLastPage(data: Output): LastPage<Page, RootPage> {
119
+ const block = Number(data.raw.height);
120
+ return {
121
+ own: block,
122
+ ownBlockNumber: block,
123
+ root: this.toRootPage(data),
124
+ };
125
+ }
126
+
115
127
  @bound
116
128
  override getNamespace(): string[] {
117
129
  return [this.config.network.name, this.config.syncProtocol.name];
@@ -10,6 +10,7 @@ import type { ConfigNetworkType, SyncProtocolWithNetwork } from "@effectstream/c
10
10
  import { getPage } from "@effectstream/db";
11
11
  import { CelestiaClient } from "./CelestiaClient.ts";
12
12
  import { applyDelay } from "../common/utils.ts";
13
+ import { bufferAtCap } from "../common/page-helpers.ts";
13
14
 
14
15
  export class CelestiaSyncState extends SyncState<
15
16
  Input,
@@ -61,6 +62,7 @@ export class CelestiaSyncState extends SyncState<
61
62
 
62
63
  @bound
63
64
  override *stateToInput(): Operation<Input | undefined> {
65
+ if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
64
66
  const latestHeight = yield* call(() =>
65
67
  this.client.getLatestBlockHeight()
66
68
  );
@@ -99,6 +101,15 @@ export class CelestiaSyncState extends SyncState<
99
101
  rootOutput.primitives.push(...primitives);
100
102
  }
101
103
 
104
+ @bound
105
+ override outputToLastPage(data: Output): LastPage<Page, RootPage> {
106
+ return {
107
+ own: { height: data.height, hash: data.hash },
108
+ ownBlockNumber: data.height,
109
+ root: this.toRootPage(data),
110
+ };
111
+ }
112
+
102
113
  @bound
103
114
  override getNamespace(): string[] {
104
115
  return [this.config.network.name, this.config.syncProtocol.name];