@effectstream/sync 0.101.1 → 0.103.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 (41) 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/MidnightClient.ts +11 -3
  17. package/src/sync-protocols/midnight/fetcher.ts +18 -11
  18. package/src/sync-protocols/midnight/types.ts +1 -1
  19. package/src/sync-protocols/midnight/zswap-decoder.ts +99 -101
  20. package/src/sync-protocols/near/NearClient.ts +16 -6
  21. package/src/sync-protocols/near/fetcher.ts +5 -1
  22. package/src/sync-protocols/near/state.ts +5 -1
  23. package/src/sync-protocols/orchestration/sync.ts +101 -13
  24. package/src/sync-protocols/solana/SolanaClient.ts +188 -0
  25. package/src/sync-protocols/solana/fetcher.ts +456 -0
  26. package/src/sync-protocols/solana/program-logs.test.ts +113 -0
  27. package/src/sync-protocols/solana/program-logs.ts +84 -0
  28. package/src/sync-protocols/solana/read-data-retry.test.ts +153 -0
  29. package/src/sync-protocols/solana/read-primitives.test.ts +435 -0
  30. package/src/sync-protocols/solana/root-page.test.ts +66 -0
  31. package/src/sync-protocols/solana/state.ts +167 -0
  32. package/src/sync-protocols/solana/types.ts +58 -0
  33. package/src/sync-protocols/types.ts +2 -0
  34. package/src/sync-protocols/utxorpc/WatchMultiplexer.ts +52 -2
  35. package/src/sync-protocols/utxorpc/state.ts +3 -0
  36. package/src/syncProtocolFactory.ts +15 -0
  37. package/test/poll-loop-spin.test.ts +112 -0
  38. package/test/rpc-timeout.test.ts +150 -0
  39. package/test/spin-probe.ts +102 -0
  40. package/test/start-async-supervision.test.ts +139 -0
  41. package/test/zswap-decoder.test.ts +74 -0
@@ -1,3 +1,5 @@
1
+ import { DEFAULT_REQUEST_TIMEOUT_MS, fetchWithTimeout } from "../common/http.ts";
2
+
1
3
  // ============================
2
4
  // NEAR JSON-RPC type responses
3
5
  // ============================
@@ -107,17 +109,25 @@ export function parseNep297Log(log: string): Nep297Event | null {
107
109
 
108
110
  export class NearClient {
109
111
  private readonly rpcUrl: string;
112
+ /** Per-request deadline; see `sync-protocols/common/http.ts`. */
113
+ private readonly requestTimeoutMs: number;
110
114
 
111
- constructor(rpcUrl: string) {
115
+ constructor(rpcUrl: string, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
112
116
  this.rpcUrl = rpcUrl;
117
+ this.requestTimeoutMs = requestTimeoutMs;
113
118
  }
114
119
 
115
120
  private async rpc<T>(method: string, params: Record<string, unknown>): Promise<T> {
116
- const res = await fetch(this.rpcUrl, {
117
- method: "POST",
118
- headers: { "Content-Type": "application/json" },
119
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
120
- });
121
+ const res = await fetchWithTimeout(
122
+ this.rpcUrl,
123
+ {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/json" },
126
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
127
+ },
128
+ `NEAR ${method}`,
129
+ this.requestTimeoutMs,
130
+ );
121
131
 
122
132
  const json = await res.json();
123
133
  if (json.error) {
@@ -4,6 +4,7 @@ import {
4
4
  } from "@effectstream/config";
5
5
  import { BaseDataFetcher } from "../base/fetcher.ts";
6
6
  import type { DataFetched } from "../base/fetcher.ts";
7
+ import { requestTimeoutOf } from "../common/http.ts";
7
8
  import type {
8
9
  LastPage,
9
10
  OutputAndCleanup,
@@ -39,7 +40,10 @@ export class NearFetcher extends BaseDataFetcher<
39
40
  readonly config: ConfigType,
40
41
  ) {
41
42
  super(config.syncProtocol.name);
42
- this.client = new NearClient(config.network.rpcUrl);
43
+ this.client = new NearClient(
44
+ config.network.rpcUrl,
45
+ requestTimeoutOf(config.syncProtocol),
46
+ );
43
47
  }
44
48
 
45
49
  @bound
@@ -11,6 +11,7 @@ import { getPage } from "@effectstream/db";
11
11
  import { NearClient } from "./NearClient.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 NearSyncState extends SyncState<
16
17
  Input,
@@ -134,7 +135,10 @@ export class NearSyncState extends SyncState<
134
135
  page,
135
136
  config,
136
137
  fetcher,
137
- new NearClient(config.network.rpcUrl),
138
+ new NearClient(
139
+ config.network.rpcUrl,
140
+ requestTimeoutOf(config.syncProtocol),
141
+ ),
138
142
  dbConn,
139
143
  );
140
144
  }
@@ -4,19 +4,113 @@ import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
4
4
  import type { AllSyncProtocols, ISyncProtocol } from "../types.ts";
5
5
  import { tryYield } from "@effectstream/utils";
6
6
 
7
+ /**
8
+ * Used when a protocol config carries no `pollingInterval`.
9
+ *
10
+ * Every polling protocol declares one via `PollingSyncProtocol`, so this should
11
+ * be unreachable — it exists because the cost of getting it wrong is not a slow
12
+ * loop but a dead node. A pass through this loop that neither fetches nor
13
+ * sleeps never yields to the macrotask queue, which starves the entire process:
14
+ * no timers, no HTTP server, no other chain's fetch loop, and (for streaming
15
+ * chains) none of the stream callbacks that would let this loop make progress.
16
+ * See `sync/test/poll-loop-spin.test.ts`.
17
+ */
18
+ const FALLBACK_POLLING_INTERVAL_MS = 1_000;
19
+
20
+ /** This protocol's polling interval, or the fallback above. */
21
+ function pollingIntervalOf(config: { syncProtocol: unknown }): number {
22
+ const syncProtocol = config.syncProtocol as { pollingInterval?: number };
23
+ return syncProtocol?.pollingInterval ?? FALLBACK_POLLING_INTERVAL_MS;
24
+ }
25
+
26
+ /** Backoff bounds for restarting a dead streaming producer. */
27
+ const PRODUCER_MIN_BACKOFF_MS = 1_000;
28
+ const PRODUCER_MAX_BACKOFF_MS = 30_000;
29
+
7
30
  export function* startSync(
8
31
  state: AllSyncProtocols,
9
32
  ): Operation<void> {
10
33
  const iState: ISyncProtocol = state as ISyncProtocol;
11
34
 
12
- // spawn async task
35
+ // Resolve once and publish it, so `/health` and anything else reads the same
36
+ // value this loop paces itself with instead of re-deriving it.
37
+ state.pollingIntervalMs = pollingIntervalOf(state.fetcher.config);
38
+
13
39
  yield* spawn(function* () {
14
- yield* iState.startAsync();
40
+ if (!iState.hasAsyncProducer) {
41
+ // Polled chain: startAsync is the base-class no-op. Run it once, as
42
+ // before — supervising it would mean restarting a no-op forever.
43
+ yield* iState.startAsync();
44
+ return;
45
+ }
46
+
47
+ // Streaming chain: all of this protocol's data arrives through the
48
+ // producer, so if it dies the chain goes silent and the merge blocks on its
49
+ // page forever. Both of its exit modes used to be unhandled — a throw tore
50
+ // down the enclosing scope (in production, `start()`, taking every other
51
+ // chain with it), and a clean return went entirely unnoticed. Supervise
52
+ // both. See `sync/test/start-async-supervision.test.ts`.
53
+ let attempt = 0;
54
+ while (true) {
55
+ const startedAt = Date.now();
56
+ const result = yield* tryYield(iState.startAsync());
57
+ const ranForMs = Date.now() - startedAt;
58
+
59
+ if (result.error != null) {
60
+ // Producer failures are tracked separately from `consecutiveErrors`.
61
+ // That counter belongs to the fetch loop and is only cleared by a
62
+ // successful `readData`, so bumping it here left an idle streaming chain
63
+ // reporting `erroring` until its next block arrived — and conversely, a
64
+ // successful poll would erase the record of a flapping producer.
65
+ state.producerErrors++;
66
+ state.lastProducerErrorMs = Date.now();
67
+ log.remote(
68
+ ComponentNames.EFFECTSTREAM_SYNC,
69
+ [...state.getNamespace(), "startAsync"],
70
+ SeverityNumber.ERROR,
71
+ (l) => l(result.error),
72
+ );
73
+ } else {
74
+ // Returning is a failure for a producer: its stream ended. Treated the
75
+ // same as a throw, because the observable effect is identical — no
76
+ // more data.
77
+ log.remote(
78
+ ComponentNames.EFFECTSTREAM_SYNC,
79
+ [...state.getNamespace(), "startAsync"],
80
+ SeverityNumber.WARN,
81
+ (l) => l("producer returned: stream ended, restarting"),
82
+ );
83
+ }
84
+
85
+ state.producerRestarts++;
86
+
87
+ // De-escalate after a producer that clearly worked. Without this `attempt`
88
+ // only ever grows, so a stream that ran healthily for a day between
89
+ // incidents would still be waiting the 30s cap on its next restart —
90
+ // penalising a healthy chain for ancient history. (It also keeps
91
+ // `2 ** attempt` from reaching Infinity after ~1024 restarts, which
92
+ // `Math.min` currently absorbs silently.)
93
+ if (ranForMs >= PRODUCER_MAX_BACKOFF_MS) {
94
+ attempt = 0;
95
+ }
96
+
97
+ const backoff = Math.min(
98
+ PRODUCER_MIN_BACKOFF_MS * 2 ** attempt,
99
+ PRODUCER_MAX_BACKOFF_MS,
100
+ );
101
+ attempt++;
102
+ yield* sleep(backoff);
103
+ }
15
104
  });
16
105
 
17
106
  // spawn polling task
18
107
  yield* spawn(function* () {
19
108
  while (true) {
109
+ // Liveness heartbeat: stamped every pass regardless of outcome, so a
110
+ // stale value means the loop is wedged inside a call that never returns
111
+ // rather than merely idle. See SyncState.lastPollAtMs.
112
+ state.lastPollAtMs = Date.now();
113
+
20
114
  const inputResult = yield* tryYield(iState.stateToInput());
21
115
  if (inputResult.error != null) {
22
116
  state.consecutiveErrors++;
@@ -27,18 +121,14 @@ export function* startSync(
27
121
  SeverityNumber.ERROR,
28
122
  (log) => log(inputResult.error),
29
123
  );
30
- const { config } = state.fetcher;
31
- if ("pollingInterval" in config.syncProtocol) {
32
- yield* sleep(config.syncProtocol.pollingInterval);
33
- }
124
+ yield* sleep(state.pollingIntervalMs);
34
125
  continue;
35
126
  }
36
127
  const input = inputResult.data;
37
- const { config } = state.fetcher;
38
128
  if (input == null) {
39
- if ("pollingInterval" in config.syncProtocol) {
40
- yield* sleep(config.syncProtocol.pollingInterval);
41
- }
129
+ // Caught up. This is the ordinary steady state, and the pass that must
130
+ // never skip its sleep — see FALLBACK_POLLING_INTERVAL_MS.
131
+ yield* sleep(state.pollingIntervalMs);
42
132
  continue;
43
133
  }
44
134
 
@@ -52,9 +142,7 @@ export function* startSync(
52
142
  SeverityNumber.ERROR,
53
143
  (l) => l(result.error),
54
144
  );
55
- if ("pollingInterval" in config.syncProtocol) {
56
- yield* sleep(config.syncProtocol.pollingInterval);
57
- }
145
+ yield* sleep(state.pollingIntervalMs);
58
146
  continue;
59
147
  }
60
148
  state.consecutiveErrors = 0;
@@ -0,0 +1,188 @@
1
+ import {
2
+ DEFAULT_REQUEST_TIMEOUT_MS,
3
+ fetchWithTimeout,
4
+ } from "../common/http.ts";
5
+
6
+ // ==========================
7
+ // Solana JSON-RPC type defs
8
+ // ==========================
9
+
10
+ export type SolanaBlock = {
11
+ blockhash: string;
12
+ blockTime: number | null;
13
+ blockHeight: number | null;
14
+ parentSlot: number;
15
+ previousBlockhash: string;
16
+ transactions: SolanaTransaction[];
17
+ };
18
+
19
+ export type SolanaTransaction = {
20
+ transaction: {
21
+ message: {
22
+ accountKeys: string[];
23
+ instructions: SolanaInstruction[];
24
+ };
25
+ signatures: string[];
26
+ };
27
+ /**
28
+ * Null when the RPC could not decode the transaction (e.g. a version newer
29
+ * than `maxSupportedTransactionVersion`). Callers must null-check.
30
+ */
31
+ meta: {
32
+ err: unknown | null;
33
+ logMessages: string[] | null;
34
+ preBalances: number[];
35
+ postBalances: number[];
36
+ /**
37
+ * Addresses a versioned (v0) transaction pulled in through an address
38
+ * lookup table. These are NOT in `message.accountKeys`, which carries only
39
+ * static keys — but `pre`/`postBalances` are indexed over the full list.
40
+ * See {@link resolveAccountKeys} for the ordering.
41
+ */
42
+ loadedAddresses?: {
43
+ writable: string[];
44
+ readonly: string[];
45
+ } | null;
46
+ /**
47
+ * SPL token balances after the transaction, one record per (token account,
48
+ * mint) pair it touched. Returned by `getBlock` with
49
+ * `transactionDetails: "full"`; absent on transactions that touched no token
50
+ * account, and on validators old enough not to report them.
51
+ *
52
+ * `accountIndex` indexes the SAME resolved list as `pre`/`postBalances` — see
53
+ * {@link resolveAccountKeys} — so a token account reached through a lookup
54
+ * table is only findable after resolution.
55
+ */
56
+ postTokenBalances?: SolanaTokenBalance[] | null;
57
+ /** Pre-state counterpart of {@link postTokenBalances}. */
58
+ preTokenBalances?: SolanaTokenBalance[] | null;
59
+ } | null;
60
+ };
61
+
62
+ export type SolanaTokenBalance = {
63
+ /** Index into the resolved account list, NOT into `message.accountKeys` alone. */
64
+ accountIndex: number;
65
+ mint: string;
66
+ /** Optional: older validators omit it. */
67
+ owner?: string;
68
+ /** The owning token program, i.e. SPL Token or Token-2022. Optional for the same reason. */
69
+ programId?: string;
70
+ uiTokenAmount: {
71
+ /** Raw u64 in base units, as a string. */
72
+ amount: string;
73
+ decimals: number;
74
+ uiAmount: number | null;
75
+ uiAmountString?: string;
76
+ };
77
+ };
78
+
79
+ /**
80
+ * The account list `pre`/`postBalances` are indexed against: static message
81
+ * keys first, then lookup-table writable addresses, then lookup-table readonly
82
+ * ones. Legacy transactions have no `loadedAddresses`, so this is just the
83
+ * static keys.
84
+ */
85
+ export function resolveAccountKeys(
86
+ accountKeys: string[],
87
+ loadedAddresses?: { writable: string[]; readonly: string[] } | null,
88
+ ): string[] {
89
+ if (!loadedAddresses) return accountKeys;
90
+ return [
91
+ ...accountKeys,
92
+ ...(loadedAddresses.writable ?? []),
93
+ ...(loadedAddresses.readonly ?? []),
94
+ ];
95
+ }
96
+
97
+ export type SolanaInstruction = {
98
+ programId: string;
99
+ accounts: string[];
100
+ data: string;
101
+ };
102
+
103
+ // ===========
104
+ // RPC Client
105
+ // ===========
106
+
107
+ export class SolanaClient {
108
+ private readonly rpcUrl: string;
109
+ /** Per-request deadline; see `sync-protocols/common/http.ts`. */
110
+ private readonly requestTimeoutMs: number;
111
+
112
+ constructor(rpcUrl: string, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
113
+ this.rpcUrl = rpcUrl;
114
+ this.requestTimeoutMs = requestTimeoutMs;
115
+ }
116
+
117
+ private async rpc<T>(
118
+ method: string,
119
+ params: unknown[] = [],
120
+ ): Promise<T> {
121
+ // Bounded: a blackholed endpoint would otherwise hang readData forever,
122
+ // freezing block production with every health counter clean (sync
123
+ // CLAUDE.md finding #4).
124
+ const res = await fetchWithTimeout(
125
+ this.rpcUrl,
126
+ {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify({
130
+ jsonrpc: "2.0",
131
+ id: 1,
132
+ method,
133
+ params,
134
+ }),
135
+ },
136
+ `Solana ${method}`,
137
+ this.requestTimeoutMs,
138
+ );
139
+
140
+ const json = await res.json();
141
+ if (json.error) {
142
+ const err = new Error(
143
+ `[Solana] RPC error [${method}]: ${json.error.message ?? JSON.stringify(json.error)}`,
144
+ ) as Error & { rpcCode?: number };
145
+ // Preserve the JSON-RPC code so callers can branch on it instead of
146
+ // pattern-matching human-readable messages.
147
+ err.rpcCode = typeof json.error.code === "number" ? json.error.code : undefined;
148
+ throw err;
149
+ }
150
+ return json.result as T;
151
+ }
152
+
153
+ async getSlot(): Promise<number> {
154
+ return this.rpc<number>("getSlot", [
155
+ { commitment: "confirmed" },
156
+ ]);
157
+ }
158
+
159
+ async getBlock(
160
+ slot: number,
161
+ ): Promise<SolanaBlock | null> {
162
+ try {
163
+ return await this.rpc<SolanaBlock | null>("getBlock", [
164
+ slot,
165
+ {
166
+ encoding: "json",
167
+ transactionDetails: "full",
168
+ rewards: false,
169
+ maxSupportedTransactionVersion: 0,
170
+ commitment: "confirmed",
171
+ },
172
+ ]);
173
+ } catch (e) {
174
+ // A skipped slot is normal on Solana: no block was produced. Branch on the
175
+ // JSON-RPC code rather than the message text —
176
+ // -32007 SLOT_SKIPPED, -32009 LONG_TERM_STORAGE_SLOT_SKIPPED.
177
+ // Deliberately NOT -32004 (block not available yet): that is a transient
178
+ // "ask again" and must keep throwing so the fetcher retries rather than
179
+ // treating the slot as permanently empty.
180
+ const code = (e as { rpcCode?: number }).rpcCode;
181
+ if (code === -32007 || code === -32009) return null;
182
+ // Fall back to the message for RPCs that omit or remap the code.
183
+ if (e instanceof Error && e.message.includes("was skipped")) return null;
184
+ throw e;
185
+ }
186
+ }
187
+
188
+ }