@effectstream/sync 0.101.0 → 0.102.0

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.
Files changed (38) hide show
  1. package/CLAUDE.md +82 -18
  2. package/LICENSE-APACHE +201 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +63 -4
  5. package/docs/ADDING-A-SYNC-PROTOCOL.md +16 -9
  6. package/package.json +8 -8
  7. package/src/sync-protocols/avail/AvailClient.ts +34 -6
  8. package/src/sync-protocols/avail/fetcher.ts +2 -0
  9. package/src/sync-protocols/avail/state.ts +2 -0
  10. package/src/sync-protocols/base/state.ts +51 -0
  11. package/src/sync-protocols/bitcoin/fetcher.ts +18 -10
  12. package/src/sync-protocols/celestia/CelestiaClient.ts +16 -2
  13. package/src/sync-protocols/celestia/fetcher.ts +5 -1
  14. package/src/sync-protocols/celestia/state.ts +5 -1
  15. package/src/sync-protocols/common/http.ts +77 -0
  16. package/src/sync-protocols/midnight/fetcher.ts +2 -0
  17. package/src/sync-protocols/midnight/types.ts +1 -1
  18. package/src/sync-protocols/near/NearClient.ts +16 -6
  19. package/src/sync-protocols/near/fetcher.ts +5 -1
  20. package/src/sync-protocols/near/state.ts +5 -1
  21. package/src/sync-protocols/orchestration/sync.ts +101 -13
  22. package/src/sync-protocols/solana/SolanaClient.ts +158 -0
  23. package/src/sync-protocols/solana/fetcher.ts +358 -0
  24. package/src/sync-protocols/solana/program-logs.test.ts +113 -0
  25. package/src/sync-protocols/solana/program-logs.ts +84 -0
  26. package/src/sync-protocols/solana/read-data-retry.test.ts +153 -0
  27. package/src/sync-protocols/solana/read-primitives.test.ts +188 -0
  28. package/src/sync-protocols/solana/root-page.test.ts +66 -0
  29. package/src/sync-protocols/solana/state.ts +167 -0
  30. package/src/sync-protocols/solana/types.ts +58 -0
  31. package/src/sync-protocols/types.ts +2 -0
  32. package/src/sync-protocols/utxorpc/WatchMultiplexer.ts +52 -2
  33. package/src/sync-protocols/utxorpc/state.ts +3 -0
  34. package/src/syncProtocolFactory.ts +15 -0
  35. package/test/poll-loop-spin.test.ts +112 -0
  36. package/test/rpc-timeout.test.ts +150 -0
  37. package/test/spin-probe.ts +102 -0
  38. package/test/start-async-supervision.test.ts +139 -0
package/CLAUDE.md CHANGED
@@ -69,8 +69,10 @@ config (syncProtocols.main + syncProtocols.parallel)
69
69
  - `toRootPage()` — map a block → millisecond timestamp (the **merge key**)
70
70
  - `toRootOutput()` — **main only** — produce a root `ChainBlock`
71
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`).
72
+ - `updateState()` — push outputs to the Deque and wake the CondVars. It does **not**
73
+ persist anything: `lastPage` is in-memory only, because the fetch loop runs ahead of
74
+ commits and its chunk-end page is not a safe resume point. The runtime is the sole
75
+ writer of the persisted resume marker (design idea #5).
74
76
 
75
77
  3. **types.ts** — `Input` / `Output` / `Page` aliases (+ a `Client.ts` per chain where an
76
78
  RPC client is needed).
@@ -84,12 +86,16 @@ chains merge into root". **The main chain must be at index 0.**
84
86
  ## The two driving loops
85
87
 
86
88
  **`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
+ - `startAsync()` — optional background producer (websocket/gRPC subs for utxorpc; no-op
90
+ for polled chains). **Supervised** when the state sets `hasAsyncProducer`: both a throw
91
+ and a clean return count as failure (a producer that returns has ended its stream) and
92
+ are restarted with capped backoff, counted in `producerRestarts`. Polled chains run
93
+ their no-op once.
89
94
  - 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
95
+ `producerChannel.send()`. Errors are swallowed via `tryYield`, bump
96
+ `consecutiveErrors`, log, sleep, continue. Every branch sleeps
97
+ `pollingInterval` (falling back to 1 s if a config somehow lacks one) — a pass that
98
+ neither fetches nor sleeps starves the whole event loop. While behind it fetches chunks
93
99
  back-to-back.
94
100
 
95
101
  **`startMerge(syncProtocols, finalizedBlockStream)`** (`orchestration/merge.ts`) loops; per
@@ -214,15 +220,73 @@ catch-up.
214
220
  catch-up blocks into fewer transactions while preserving steady-state semantics and the
215
221
  block-hash chain.
216
222
 
217
- > This PR adds the synthetic `test` chain (`src/sync-protocols/test/`) and an
218
- > in-process harness (`packages/node-sdk/runtime/test/reproduction/`, in the
219
- > runtime package because it boots the full `start()`, which sync must not depend
220
- > on). `sanity.test.ts` proves the chain syncs, processes a configured event, and
221
- > survives a restart. See `docs/ADDING-A-SYNC-PROTOCOL.md` for how to add a chain
222
- > (with `test` as the worked example).
223
+ ### 4. No RPC timeouts — a hung fetch stalled the node silently (correctness) — ✅ FIXED
224
+
225
+ `fetch` has no default timeout. Bitcoin, NEAR and Avail used it bare, so a blackholed
226
+ endpoint (dropped LB backend, half-open socket) hung `readData` forever. That is the worst
227
+ shape of failure here: the fetch loop never reaches its `catch`, so `consecutiveErrors`
228
+ stays 0 and `lastSuccessfulFetchMs` freezes while the merge blocks on that chain's page.
229
+ Block production stopped and nothing reported it.
230
+
231
+ → Fix: `common/http.ts:fetchWithTimeout` (AbortSignal.timeout) on every client, configured
232
+ per protocol by `requestTimeoutMs` on `PollingSyncProtocol` (default 15 s). Timeout-only,
233
+ no internal retry — the fetch loop already retries the same page range idempotently, and
234
+ retrying inside the client would hide the failure from the health endpoint. Regression
235
+ test: `sync/test/rpc-timeout.test.ts` (each client vs a blackholed socket).
236
+
237
+ ### 5. An unpaced fetch loop starved the event loop (correctness) — ✅ FIXED
238
+
239
+ `startSync` guarded its sleeps with `if ("pollingInterval" in config.syncProtocol)`, and
240
+ the Cardano/utxorpc schema was the only sync protocol not merging `PollingSyncProtocol` —
241
+ so that property was undeclared, untyped and undefaulted while the loop keyed its pacing
242
+ off it. Cardano worked only because every config passed it anyway as an undeclared extra
243
+ field; a config built strictly from the schema froze the node.
244
+
245
+ Froze rather than span: a pass that neither fetches nor sleeps never yields to the
246
+ macrotask queue. Measured 200k iterations in ~4 s while a 500 ms `setTimeout` never fired
247
+ once — no HTTP server, no other chain's fetch loop, and for a streaming chain none of the
248
+ callbacks that would let it escape.
249
+
250
+ → Fix: utxorpc merges `PollingSyncProtocol` like every other protocol, **and** the sleep is
251
+ unconditional with a 1 s fallback. Regression test: `sync/test/poll-loop-spin.test.ts`.
252
+
253
+ ### 6. The streaming producer was unsupervised (correctness) — ✅ FIXED
254
+
255
+ `startAsync` was spawned bare. A stream that **ended cleanly** left the chain receiving
256
+ nothing forever with every health counter clean — a total, silent, unrecoverable stall. A
257
+ stream that **threw** tore down the enclosing scope, i.e. `start()`, taking every unrelated
258
+ chain with it.
259
+
260
+ → Fix: both treated as failure and restarted with capped backoff, counted in
261
+ `producerRestarts`. Gated on `hasAsyncProducer` so polled chains still run their no-op
262
+ once. Regression test: `sync/test/start-async-supervision.test.ts`.
263
+
264
+ ### 7. `/health` reported only database reachability (operability) — ✅ FIXED
265
+
266
+ Every failure above leaves the database perfectly healthy, so a node that had not applied a
267
+ block in an hour still answered `200 {"status":"ok"}`.
268
+
269
+ → Fix: `runtime/src/api/health.ts` reports per-protocol state — most of which `SyncState`
270
+ already tracked and never exposed — keyed on wall-clock time since the last *applied*
271
+ block rather than block-time lag (a node replaying history is legitimately far behind in
272
+ block time while healthy). `blockingMerge` names the chain the merge is waiting on. 503 is
273
+ reserved for `db-unreachable` / `stalled` / `starting`; a chain erroring while blocks still
274
+ flow is `degraded` + 200. Regression test: `runtime/test/reproduction/health.test.ts`.
275
+
276
+ ---
277
+
278
+ > The synthetic `test` chain (`src/sync-protocols/test/`) and the in-process harness
279
+ > (`packages/node-sdk/runtime/test/reproduction/`, in the runtime package because it boots
280
+ > the full `start()`, which sync must not depend on) back all of these. `sanity.test.ts`
281
+ > proves the chain syncs, processes a configured event, and survives a restart. See
282
+ > `docs/ADDING-A-SYNC-PROTOCOL.md` for how to add a chain (with `test` as the worked
283
+ > example).
284
+ >
285
+ > Every finding above has a deterministic reproduction that was written to fail first.
286
+ > Run them all, isolated from other suites, with:
287
+ >
288
+ > ```
289
+ > bun run e2e/sync-repro/run-tests.ts --docker
290
+ > ```
223
291
  >
224
- > Deterministic reproductions of #1/#2/#3 and their fixes land alongside each fix:
225
- > **#2 → block-accurate resume is DONE** (`consistency.test.ts`); **#1 → fetch
226
- > backpressure is DONE** (`buffering.test.ts`, incl. 1c/1d for the merge-demand
227
- > exemption). Still in follow-up PRs: #3 → opt-in empty-block commit batching during
228
- > catch-up.
292
+ > Still open: #3 → opt-in empty-block commit batching during catch-up.
package/LICENSE-APACHE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2024-2026 Midnight Foundation
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/LICENSE-MIT ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Midnight Foundation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  The blockchain-sync service inside an EffectStream node. Reads finalized
4
4
  blocks from every chain you've configured (EVM, Bitcoin, Cardano, Midnight,
5
- Avail, Celestia, NEAR…), normalizes them into a single rollup ordering,
5
+ Avail, Celestia, NEAR, Solana…), normalizes them into a single rollup ordering,
6
6
  and stages the inputs the state machine consumes.
7
7
 
8
8
  - Blockchain-sync service: reads finalized blocks from every configured chain.
9
9
  - Normalises into a single rollup ordering and stages inputs for the state machine.
10
- - Drop-in fetchers: EVM, Bitcoin, Cardano UTXO-RPC, Midnight, Avail, Celestia, NEAR, NTP.
10
+ - Drop-in fetchers: EVM, Bitcoin, Cardano UTXO-RPC, Midnight, Avail, Celestia, NEAR, Solana, NTP.
11
11
  - `genSyncProtocols(config)` is what the runtime calls during boot.
12
12
 
13
13
  ## Install
@@ -87,7 +87,7 @@ re-engages. A chain that is merely tip-limited (caught up, not gated by the merg
87
87
  `buffering.test.ts` 1c (skip-ahead) and 1d (density).
88
88
 
89
89
  **Scope.** The guard runs in **every** chain's `stateToInput`, so all sync chains are
90
- covered: EVM, NTP, Bitcoin, Avail, Celestia, NEAR, Midnight, Cardano (UTXO-RPC), and
90
+ covered: EVM, NTP, Bitcoin, Avail, Celestia, NEAR, Solana, Midnight, Cardano (UTXO-RPC), and
91
91
  the synthetic `test` chain. Two notes:
92
92
  - **Cardano (UTXO-RPC)** has no `stepSize` (it streams one block per pass), so the cap
93
93
  falls back to a default chunk size of 1000 (⇒ default cap 4000); set `maxBufferedPages`
@@ -108,6 +108,65 @@ was never needed in that run. Steady-state these sit at `0`; during a real deep
108
108
  catch-up (or under the perf harness's `PERF_APPLY_DELAY_MS` drain throttle) they climb
109
109
  as `buf` pins to `cap`.
110
110
 
111
+ ## Request timeouts (`requestTimeoutMs`)
112
+
113
+ `fetch` has no default timeout, so an RPC endpoint that accepts the connection and
114
+ then never answers — a load balancer that dropped its backend, a socket left
115
+ half-open by a NAT rebind — would hang the fetch indefinitely. That is worse than
116
+ an error: the fetch loop never reaches its `catch`, so no error is counted, while
117
+ the merge blocks on that chain's page and block production stops.
118
+
119
+ **Config.** `requestTimeoutMs` is an optional field on every polling sync-protocol
120
+ config, defaulting to **15 s**. It bounds a single request; retry is the fetch
121
+ loop's job (it re-runs the same page range and counts the failure, which is what
122
+ `/health` reports on).
123
+
124
+ **Coverage.** Honoured by EVM (as the viem transport timeout), Bitcoin, NEAR,
125
+ Avail (light-client HTTP), Midnight and Celestia. Two exceptions:
126
+
127
+ - **Celestia** retries internally, so the value bounds each *attempt* rather than
128
+ the whole call. It still falls back to `CELESTIA_RPC_TIMEOUT_MS` when no
129
+ protocol-level value is set.
130
+ - **Cardano / utxorpc** reads its data from a gRPC stream rather than
131
+ request/response RPCs, so the field is inert there. That path is instead
132
+ protected by producer supervision — a stream that dies or ends is restarted
133
+ with backoff (see below).
134
+
135
+ NTP and the synthetic `test` chain make no network calls at all.
136
+
137
+ ```ts
138
+ .addParallel((n) => n.myChain, () => ({
139
+ name: "myChain",
140
+ type: ConfigSyncProtocolType.EVM_RPC_PARALLEL,
141
+ pollingInterval: 1000,
142
+ requestTimeoutMs: 30_000, // slow archive node
143
+ // ...
144
+ }))
145
+ ```
146
+
147
+ ## Producer supervision (streaming chains)
148
+
149
+ Chains whose data arrives over a subscription rather than by polling (utxorpc
150
+ today) set `hasAsyncProducer` on their `SyncState`, and `startSync` supervises
151
+ that producer instead of spawning it bare.
152
+
153
+ Both of its exit modes count as failure and trigger a restart with capped
154
+ exponential backoff (1s → 30s), including a **clean return** — a producer that
155
+ returns has ended its stream, and the observable effect is identical to a throw:
156
+ no more data. Left unsupervised, a stream that simply ended left the chain silent
157
+ forever with every health counter clean, and a stream that threw tore down the
158
+ whole node.
159
+
160
+ The backoff de-escalates: a producer that ran longer than the maximum backoff
161
+ before dying resets the delay, so an occasional incident over a long-lived stream
162
+ does not permanently penalise it.
163
+
164
+ Restarts are counted in `producerRestarts` and failures in `producerErrors`, both
165
+ reported per protocol on `/health`. They are tracked separately from the fetch
166
+ loop's `consecutiveErrors` on purpose: that counter is only cleared by a
167
+ successful `readData`, so sharing it left an idle streaming chain reporting
168
+ errors long after its producer recovered.
169
+
111
170
  ## Key exports
112
171
 
113
172
  - `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.
@@ -116,7 +175,7 @@ as `buf` pins to `cap`.
116
175
 
117
176
  Per-chain `Fetcher` / `SyncState` classes (`EvmFetcher`,
118
177
  `BitcoinFetcher`, `MidnightFetcher`, `AvailFetcher`, `UtxoRpcFetcher`,
119
- `NtpFetcher`, `CelestiaFetcher`, `NearFetcher`, and matching `*SyncState`
178
+ `NtpFetcher`, `CelestiaFetcher`, `NearFetcher`, `SolanaFetcher`, and matching `*SyncState`
120
179
  classes) are exported but are internal to the factory wiring -
121
180
  application code drives them through `genSyncProtocols` rather than
122
181
  instantiating them directly. Reach for them only if you're writing a
@@ -73,6 +73,9 @@ latitude on its exact shape.
73
73
  `readData` (+ `PaginatedFetcher`: `getLatestPage`/`nextInterval`/
74
74
  `previousInterval`/`intervalFromStart`, and `PrimitiveFetcher` if it emits
75
75
  primitives). `lastPage.own`/`ownBlockNumber` = the **chunk end** (`data.to`).
76
+ **Bound every RPC call** with `common/http.ts:fetchWithTimeout` (or the client
77
+ library's own timeout) — a bare `fetch` against a blackholed endpoint hangs
78
+ `readData` forever, which silently stalls the whole node (CLAUDE.md #4).
76
79
  Page-request helpers live in `base/page.ts`
77
80
  (`genImmediatePageRequests`/`genOnDemandPageRequests`).
78
81
  - **`sync-protocols/<chain>/Client.ts`** (new, only if it talks to a node) — the
@@ -120,19 +123,23 @@ rows and optional scheduled STF inputs.
120
123
 
121
124
  - **One network → many sync protocols** is allowed (Cardano, `test`). `FlipObject`
122
125
  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.
126
+ - **Resume is block-accurate** (CLAUDE.md finding #2, fixed): the runtime is the
127
+ sole writer of the persisted marker, written inside the block's transaction.
128
+ Your `state.ts` must implement `outputToLastPage`; `updateState` must not
129
+ persist anything.
130
+ - **Declare `pollingInterval`** by merging `PollingSyncProtocol` into your schema.
131
+ It is not optional in practice — the fetch loop paces itself with it, and a
132
+ protocol without one used to starve the entire event loop (CLAUDE.md #5).
133
+ - **Set `hasAsyncProducer = true`** on the state if your chain's data arrives via
134
+ `startAsync` rather than polling, so the producer is supervised and restarted
135
+ (CLAUDE.md #6).
127
136
  - **Merge boundary gate**: a parallel chain only merges into a root block at
128
137
  timestamp `T` once its own page is *strictly* past `T`. If the main clock runs
129
138
  to the same height as a parallel chain's fetched tip, the merge stalls at that
130
139
  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.
140
+ - **`finalizedBlockStream` subscribes before the merge is spawned**
141
+ (`runtime/src/finalized-stream.ts`), because an effection channel drops sends
142
+ with no active subscriber.
136
143
  - **`isPresync`** is currently ~always false (`genInputRange` TODO) — the
137
144
  historical-presync path may not trigger as intended.
138
145
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@effectstream/sync",
3
- "version": "0.101.0",
4
- "license": "MIT",
3
+ "version": "0.102.0",
4
+ "license": "(MIT OR Apache-2.0)",
5
5
  "homepage": "https://effectstream.github.io/docs/",
6
6
  "repository": {
7
7
  "type": "git",
@@ -13,12 +13,12 @@
13
13
  },
14
14
  "exports": "./src/mod.ts",
15
15
  "dependencies": {
16
- "@effectstream/config": "0.101.0",
17
- "@effectstream/db": "0.101.0",
18
- "@effectstream/log": "0.101.0",
19
- "@effectstream/utils": "0.101.0",
20
- "@midnight-ntwrk/ledger-v8": "8.0.3",
21
- "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.4",
16
+ "@effectstream/config": "0.102.0",
17
+ "@effectstream/db": "0.102.0",
18
+ "@effectstream/log": "0.102.0",
19
+ "@effectstream/utils": "0.102.0",
20
+ "@midnight-ntwrk/ledger-v8": "8.1.0",
21
+ "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.1.1",
22
22
  "@midnight-ntwrk/onchain-runtime": "npm:@midnight-ntwrk/onchain-runtime-v3@3.0.0",
23
23
  "effection": "3.5.0",
24
24
  "graphql-ws": "^6.0.6",
@@ -1,4 +1,5 @@
1
1
  import { SDK } from "avail-js-sdk";
2
+ import { DEFAULT_REQUEST_TIMEOUT_MS, fetchWithTimeout } from "../common/http.ts";
2
3
  import type {
3
4
  AvailBlock,
4
5
  AvailBlockDataItem,
@@ -12,13 +13,25 @@ const AVAIL_NODE_DEFAULT_URL = "ws://localhost:9955/ws";
12
13
  export class AvailClient {
13
14
  private readonly url: string;
14
15
  private readonly sdk: Promise<SDK>;
15
- constructor(nodeUrl: string, lightClientUrl: string) {
16
+ /** Per-request deadline for light-client HTTP; see `common/http.ts`. */
17
+ private readonly requestTimeoutMs: number;
18
+ constructor(
19
+ nodeUrl: string,
20
+ lightClientUrl: string,
21
+ requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
22
+ ) {
16
23
  this.url = lightClientUrl ?? AVAIL_CLIENT_DEFAULT_URL;
17
24
  this.sdk = SDK.New(nodeUrl ?? AVAIL_NODE_DEFAULT_URL);
25
+ this.requestTimeoutMs = requestTimeoutMs;
18
26
  }
19
27
 
20
28
  async getStatus(): Promise<AvailStatus> {
21
- const response = await fetch(`${this.url}/v2/status`);
29
+ const response = await fetchWithTimeout(
30
+ `${this.url}/v2/status`,
31
+ {},
32
+ "Avail v2/status",
33
+ this.requestTimeoutMs,
34
+ );
22
35
  if (!response.ok) {
23
36
  throw new Error(
24
37
  `Failed to get status from light client, status: ${response.status}`,
@@ -32,9 +45,14 @@ export class AvailClient {
32
45
  }
33
46
 
34
47
  async getLatestBlockHeight(): Promise<number> {
35
- const api = (await this.sdk).client.api;
48
+ // Deliberately does NOT await `this.sdk`. The height comes from the light
49
+ // client below; the websocket SDK was only needed by the commented-out
50
+ // `api.rpc.chain.getHeader()` call, and awaiting it here reintroduced an
51
+ // unbounded wait on the sync path — if the node's websocket never connects,
52
+ // this never returns and the chain stalls silently. That is the exact
53
+ // failure `requestTimeoutMs` exists to prevent.
36
54
  // Block from avail node is slightly above the light client
37
- // const header = await api.rpc.chain.getHeader();
55
+ // const header = await (await this.sdk).client.api.rpc.chain.getHeader();
38
56
  const status: AvailStatus = await this.getStatus();
39
57
 
40
58
  // If the available field is present, this is the real latest block
@@ -80,7 +98,12 @@ export class AvailClient {
80
98
  }
81
99
  }
82
100
 
83
- const response = await fetch(`${this.url}/v2/blocks/${height}/header`);
101
+ const response = await fetchWithTimeout(
102
+ `${this.url}/v2/blocks/${height}/header`,
103
+ {},
104
+ `Avail v2/blocks/${height}/header`,
105
+ this.requestTimeoutMs,
106
+ );
84
107
  if (!response.ok) {
85
108
  throw new Error(
86
109
  `Failed to get block header from height ${height}, status: ${response.status}`,
@@ -103,7 +126,12 @@ export class AvailClient {
103
126
  return { block_number: height, data_transactions: [] }
104
127
  }
105
128
 
106
- const response = await fetch(`${this.url}/v2/blocks/${height}/data`);
129
+ const response = await fetchWithTimeout(
130
+ `${this.url}/v2/blocks/${height}/data`,
131
+ {},
132
+ `Avail v2/blocks/${height}/data`,
133
+ this.requestTimeoutMs,
134
+ );
107
135
  if (!response.ok) {
108
136
  throw new Error(
109
137
  `Failed to get block data from height ${height}, status: ${response.status}`,
@@ -5,6 +5,7 @@ import {
5
5
  } from "@effectstream/config";
6
6
  import { BaseDataFetcher } from "../base/fetcher.ts";
7
7
  import type { DataFetched } from "../base/fetcher.ts";
8
+ import { requestTimeoutOf } from "../common/http.ts";
8
9
  import type {
9
10
  LastPage,
10
11
  OutputAndCleanup,
@@ -38,6 +39,7 @@ export class AvailFetcher extends BaseDataFetcher<
38
39
  this.client = new AvailClient(
39
40
  config.syncProtocol.rpc,
40
41
  config.syncProtocol.lightClient,
42
+ requestTimeoutOf(config.syncProtocol),
41
43
  );
42
44
  }
43
45
 
@@ -11,6 +11,7 @@ import { getPage } from "@effectstream/db";
11
11
  import { AvailClient } from "./AvailClient.ts";
12
12
  import { applyDelay } from "../common/utils.ts";
13
13
  import { bufferAtCap } from "../common/page-helpers.ts";
14
+ import { requestTimeoutOf } from "../common/http.ts";
14
15
 
15
16
  export class AvailSyncState extends SyncState<
16
17
  Input,
@@ -138,6 +139,7 @@ export class AvailSyncState extends SyncState<
138
139
  new AvailClient(
139
140
  config.syncProtocol.rpc,
140
141
  config.syncProtocol.lightClient,
142
+ requestTimeoutOf(config.syncProtocol),
141
143
  ),
142
144
  dbConn,
143
145
  );