@effectstream/sync 0.101.1 → 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
@@ -31,6 +31,21 @@ export class WatchMultiplexer {
31
31
  private highestConfirmedHeight: number | undefined = undefined;
32
32
  private lowestIncompleteHeight: number = 0;
33
33
  private slotOriginTimestamp: bigint | undefined = undefined;
34
+ /**
35
+ * Incremented by every {@link start} call so events from a previous
36
+ * generation's streams can be identified and dropped.
37
+ *
38
+ * `start` can now be called more than once: `startSync` supervises the
39
+ * producer and restarts it when the stream dies. All multiplexer state lives
40
+ * on this instance, and the gRPC streams of the previous generation are not
41
+ * cancellable from here — a `Promise.all` rejection from ONE watcher aborts
42
+ * `start` while its siblings keep streaming. Without this guard a restart
43
+ * would run two live streams per predicate into the same `pendingBlocks`,
44
+ * pushing each matched tx twice. Duplicate primitives in a block mean
45
+ * duplicate scheduled STF inputs, i.e. non-deterministic state — the exact
46
+ * failure the whole pipeline is built to avoid.
47
+ */
48
+ private generation = 0;
34
49
 
35
50
  constructor(
36
51
  private readonly clientOptions: ClientBuilderOptions,
@@ -88,14 +103,39 @@ export class WatchMultiplexer {
88
103
  }
89
104
  }
90
105
 
106
+ const generation = ++this.generation;
107
+
108
+ // Drop half-assembled work from the previous generation. A block is only
109
+ // completed once EVERY watcher has passed it, so at the moment a stream
110
+ // died some heights had been seen by some watchers and not others. The new
111
+ // stream re-delivers those heights from the resume point, and merging the
112
+ // two views would double their txs. Heights below `lowestIncompleteHeight`
113
+ // are already completed and are never revisited, so this discards exactly
114
+ // the in-flight window.
115
+ if (generation > 1) {
116
+ for (const [, watcher] of this.watchers) {
117
+ for (const [height] of watcher.pendingBlocks) {
118
+ if (height >= this.lowestIncompleteHeight) {
119
+ watcher.pendingBlocks.delete(height);
120
+ }
121
+ }
122
+ watcher.highestSeenHeight = Math.max(
123
+ this.lowestIncompleteHeight - 1,
124
+ 0,
125
+ );
126
+ }
127
+ }
128
+
91
129
  const intersect = point ? [point] : [];
92
130
  const promises: Promise<void>[] = [];
93
131
  for (const [key, watcher] of this.watchers) {
94
132
  const watchClient = new CardanoWatchClient(this.clientOptions);
95
- promises.push(this.runWatcher(key, watcher, watchClient, intersect));
133
+ promises.push(this.runWatcher(key, watcher, watchClient, intersect, generation));
96
134
  }
97
135
 
98
- console.log(`[WatchMultiplexer] Started ${this.watchers.size} watcher(s)`);
136
+ console.log(
137
+ `[WatchMultiplexer] Started ${this.watchers.size} watcher(s) (generation ${generation})`,
138
+ );
99
139
  await Promise.all(promises);
100
140
  }
101
141
 
@@ -104,9 +144,19 @@ export class WatchMultiplexer {
104
144
  watcher: WatcherState,
105
145
  watchClient: CardanoWatchClient,
106
146
  intersect: ChainPoint[],
147
+ generation: number,
107
148
  ): Promise<void> {
108
149
  const stream = watchClient.watchTxByPredicate(watcher.sdkPredicate, intersect);
109
150
  for await (const event of stream) {
151
+ // A superseded generation must contribute nothing. Breaking here also
152
+ // closes the underlying stream via the async iterator's return path,
153
+ // which is the only way to stop it from this side.
154
+ if (generation !== this.generation) {
155
+ console.warn(
156
+ `[WatchMultiplexer] Watcher "${key}" from generation ${generation} superseded by ${this.generation}; closing`,
157
+ );
158
+ return;
159
+ }
110
160
  this.handleEvent(key, watcher, event);
111
161
  }
112
162
  console.warn(`[WatchMultiplexer] Watcher "${key}" stream ended unexpectedly`);
@@ -35,6 +35,9 @@ export class UtxoRpcSyncState extends SyncState<
35
35
  );
36
36
  }
37
37
 
38
+ /** All utxorpc data arrives via the watch stream in {@link startAsync}. */
39
+ public override readonly hasAsyncProducer = true;
40
+
38
41
  @bound
39
42
  override *startAsync(): Operation<void> {
40
43
  if (this.lastPage == null) {
@@ -31,8 +31,11 @@ import { CelestiaFetcher } from "./sync-protocols/celestia/fetcher.ts";
31
31
  import { CelestiaSyncState } from "./sync-protocols/celestia/state.ts";
32
32
  import { NearFetcher } from "./sync-protocols/near/fetcher.ts";
33
33
  import { NearSyncState } from "./sync-protocols/near/state.ts";
34
+ import { SolanaFetcher } from "./sync-protocols/solana/fetcher.ts";
35
+ import { SolanaSyncState } from "./sync-protocols/solana/state.ts";
34
36
  import { TestFetcher } from "./sync-protocols/test/fetcher.ts";
35
37
  import { TestSyncState } from "./sync-protocols/test/state.ts";
38
+ import { requestTimeoutOf } from "./sync-protocols/common/http.ts";
36
39
 
37
40
  export function* genSyncProtocols(
38
41
  dbConn: PoolClient,
@@ -56,6 +59,7 @@ export function* genSyncProtocols(
56
59
  entry,
57
60
  createViemPublicClient(viemNetwork, {
58
61
  cacheTime: entry.syncProtocol.pollingInterval,
62
+ timeout: requestTimeoutOf(entry.syncProtocol),
59
63
  }),
60
64
  );
61
65
  const state = yield* EvmSyncState.restoreState(
@@ -123,6 +127,7 @@ export function* genSyncProtocols(
123
127
  url: entry.network.rpcUrl,
124
128
  username: entry.network.rpcAuth?.username ?? null,
125
129
  password: entry.network.rpcAuth?.password ?? null,
130
+ requestTimeoutMs: requestTimeoutOf(entry.syncProtocol),
126
131
  });
127
132
  const fetcher = new BitcoinFetcher(entry, rpcClient);
128
133
  const state = yield* BitcoinSyncState.restoreState(
@@ -151,6 +156,16 @@ export function* genSyncProtocols(
151
156
  fetcher,
152
157
  );
153
158
  result.push(state);
159
+ } else if (
160
+ entry.networkType === ConfigNetworkType.SOLANA
161
+ ) {
162
+ const fetcher = new SolanaFetcher(entry);
163
+ const state = yield* SolanaSyncState.restoreState(
164
+ dbConn,
165
+ entry,
166
+ fetcher,
167
+ );
168
+ result.push(state);
154
169
  } else if (
155
170
  entry.networkType === ConfigNetworkType.TEST
156
171
  ) {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Regression guard for sync/CLAUDE.md Finding #3 (unpaced fetch loop).
3
+ *
4
+ * `orchestration/sync.ts` used to guard all three of its `sleep`s with
5
+ * `if ("pollingInterval" in config.syncProtocol)`, so a protocol whose config
6
+ * lacked that key never slept — not on error, and not in the ordinary "caught
7
+ * up" pass where `stateToInput` returns `undefined`.
8
+ *
9
+ * The Cardano/utxorpc schema was exactly that case: the only sync protocol that
10
+ * did not merge `PollingSyncProtocol`. Every config in this repo happened to
11
+ * pass `pollingInterval` anyway as an undeclared extra field, which is the only
12
+ * reason Cardano sync worked at all; a config built strictly from the schema
13
+ * produced a node that froze.
14
+ *
15
+ * "Froze", not "span": the probe showed the loop starving the event loop
16
+ * outright — a `setTimeout` scheduled for 500ms had still not fired after 4s of
17
+ * looping. Nothing else in the process runs in that state: not the HTTP server,
18
+ * not the other chains' fetch loops, and not the stream callbacks that would
19
+ * advance utxorpc's own tip and let the loop escape.
20
+ *
21
+ * Fixed on both sides: the utxorpc schema now merges `PollingSyncProtocol`, and
22
+ * `startSync` sleeps unconditionally with a fallback interval so no protocol
23
+ * can hot-loop regardless of what its schema declares. Both halves are asserted
24
+ * below — the second is what makes the first non-load-bearing.
25
+ */
26
+ import { expect, test } from "bun:test";
27
+ import { join } from "node:path";
28
+ import {
29
+ ConfigSyncProtocolSchemaCardanoUtxoRpcParallel,
30
+ ConfigSyncProtocolSchemaEvmParallel,
31
+ } from "@effectstream/config";
32
+
33
+ const PROBE = join(import.meta.dir, "spin-probe.ts");
34
+
35
+ /** Every property a protocol config may carry, required + optional. */
36
+ function schemaKeys(schema: unknown): string[] {
37
+ const config = (schema as { config: { required: any; optional: any } }).config;
38
+ return [
39
+ ...Object.keys(config.required.properties ?? {}),
40
+ ...Object.keys(config.optional.properties ?? {}),
41
+ ];
42
+ }
43
+
44
+ type ProbeResult = {
45
+ reason: "limit" | "window";
46
+ iterations: number;
47
+ elapsedMs: number;
48
+ };
49
+
50
+ async function runProbe(spec: {
51
+ polling: number | null;
52
+ limit: number;
53
+ windowMs: number;
54
+ mode: "sync" | "call";
55
+ }): Promise<ProbeResult> {
56
+ const proc = Bun.spawn(["bun", PROBE, JSON.stringify(spec)], {
57
+ stdout: "pipe",
58
+ stderr: "pipe",
59
+ });
60
+ const stdout = await new Response(proc.stdout).text();
61
+ await proc.exited;
62
+ const line = stdout.trim().split("\n").filter(Boolean).at(-1);
63
+ if (!line) {
64
+ const stderr = await new Response(proc.stderr).text();
65
+ throw new Error(`probe produced no result\n${stderr}`);
66
+ }
67
+ return JSON.parse(line) as ProbeResult;
68
+ }
69
+
70
+ test("every polling protocol declares pollingInterval, utxorpc included", () => {
71
+ const utxorpc = schemaKeys(ConfigSyncProtocolSchemaCardanoUtxoRpcParallel);
72
+ const evm = schemaKeys(ConfigSyncProtocolSchemaEvmParallel);
73
+
74
+ // The runtime fetch loop keys its sleeps off this exact property name.
75
+ expect(utxorpc).toContain("pollingInterval");
76
+ expect(evm).toContain("pollingInterval");
77
+ // Merged in from PollingSyncProtocol along with it.
78
+ expect(utxorpc).toContain("requestTimeoutMs");
79
+ });
80
+
81
+ test("with a pollingInterval the fetch loop is paced", async () => {
82
+ const result = await runProbe({
83
+ polling: 20,
84
+ limit: 200_000,
85
+ windowMs: 500,
86
+ mode: "call",
87
+ });
88
+
89
+ // The window timer fired, so the event loop stayed responsive.
90
+ expect(result.reason).toBe("window");
91
+ // ~500ms / 20ms ≈ 25 passes. Generous bound; the point is it's tens, not tens
92
+ // of thousands.
93
+ expect(result.iterations).toBeLessThan(200);
94
+ }, 30_000);
95
+
96
+ test("without a pollingInterval the fetch loop still paces itself", async () => {
97
+ const WINDOW_MS = 1_500;
98
+ const result = await runProbe({
99
+ polling: null,
100
+ limit: 200_000,
101
+ // Comfortably longer than one FALLBACK_POLLING_INTERVAL_MS (1s) so the
102
+ // paced loop gets at least one pass in before the window closes.
103
+ windowMs: WINDOW_MS,
104
+ mode: "call",
105
+ });
106
+
107
+ // Before the fix this reported `reason: "limit"` with 200k iterations and a
108
+ // 500ms timer that never fired at all.
109
+ expect(result.reason).toBe("window");
110
+ // One pass per fallback interval — single digits over this window.
111
+ expect(result.iterations).toBeLessThan(10);
112
+ }, 30_000);
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Repro for sync/CLAUDE.md Finding #4 (missing RPC timeouts).
3
+ *
4
+ * Every per-chain client is pointed at a **blackhole**: a TCP server that
5
+ * completes the handshake and then never writes a byte. This is the realistic
6
+ * production failure (a load balancer that drops the backend, a half-open
7
+ * socket after a NAT rebind) — and the one plain `fetch` cannot see, because
8
+ * `fetch` has no default timeout in Bun/Node. The connection is *up*, so there
9
+ * is no ECONNREFUSED, no DNS error, nothing to catch.
10
+ *
11
+ * Consequence in production: `readData` never returns, so the fetch loop in
12
+ * `orchestration/sync.ts` never reaches its `catch` → `consecutiveErrors` stays
13
+ * 0, `lastSuccessfulFetchMs` freezes, and the merge blocks on that chain's page
14
+ * forever. The node stops producing blocks and reports nothing.
15
+ *
16
+ * Every client now bounds a single request with `AbortSignal.timeout` via
17
+ * `sync-protocols/common/http.ts:fetchWithTimeout`, configured per protocol by
18
+ * the optional `requestTimeoutMs` schema field. Retry is deliberately left to
19
+ * the fetch loop, which already re-runs the same page range and counts the
20
+ * failure in `consecutiveErrors` — retrying inside the client would hide the
21
+ * error from the health endpoint.
22
+ *
23
+ * These tests are the permanent regression guard: each client must REJECT
24
+ * against a blackhole, never hang.
25
+ */
26
+ import { afterAll, beforeAll, expect, test } from "bun:test";
27
+ import net from "node:net";
28
+ import { BitcoinRpcClient } from "../src/sync-protocols/bitcoin/fetcher.ts";
29
+ import { NearClient } from "../src/sync-protocols/near/NearClient.ts";
30
+ import { SolanaClient } from "../src/sync-protocols/solana/SolanaClient.ts";
31
+ import { AvailClient } from "../src/sync-protocols/avail/AvailClient.ts";
32
+ import { MidnightClient } from "../src/sync-protocols/midnight/MidnightClient.ts";
33
+
34
+ /** How long we watch a call before declaring it hung. */
35
+ const OBSERVE_MS = 1_500;
36
+ /** Timeout handed to clients that support one, kept well under OBSERVE_MS. */
37
+ const CLIENT_TIMEOUT_MS = 300;
38
+
39
+ let server: net.Server;
40
+ let port: number;
41
+ /** Held so the accepted sockets aren't garbage-collected mid-test. */
42
+ const openSockets: net.Socket[] = [];
43
+
44
+ beforeAll(async () => {
45
+ server = net.createServer((socket) => {
46
+ // Accept and go silent: the client sees a healthy connection and waits
47
+ // for a response that never comes.
48
+ openSockets.push(socket);
49
+ });
50
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
51
+ port = (server.address() as net.AddressInfo).port;
52
+ });
53
+
54
+ afterAll(async () => {
55
+ for (const socket of openSockets) socket.destroy();
56
+ await new Promise<void>((resolve) => server.close(() => resolve()));
57
+ });
58
+
59
+ type Settlement = "pending" | "resolved" | "rejected";
60
+
61
+ /**
62
+ * Watch `p` for `ms` and report whether it settled. The promise is left with a
63
+ * no-op catch attached so a late rejection (after the test ends) can't surface
64
+ * as an unhandled rejection and fail an unrelated test.
65
+ */
66
+ async function settlementWithin(
67
+ p: Promise<unknown>,
68
+ ms: number,
69
+ ): Promise<Settlement> {
70
+ const pending = Symbol("pending");
71
+ let outcome: Settlement = "pending";
72
+ const tracked = p.then(
73
+ () => {
74
+ outcome = "resolved";
75
+ },
76
+ () => {
77
+ outcome = "rejected";
78
+ },
79
+ );
80
+ const winner = await Promise.race([
81
+ tracked.then(() => "settled" as const),
82
+ new Promise<typeof pending>((resolve) => setTimeout(() => resolve(pending), ms)),
83
+ ]);
84
+ return winner === pending ? "pending" : outcome;
85
+ }
86
+
87
+ test("Bitcoin RPC rejects on a blackholed endpoint", async () => {
88
+ const client = new BitcoinRpcClient({
89
+ url: `http://127.0.0.1:${port}`,
90
+ username: null,
91
+ password: null,
92
+ requestTimeoutMs: CLIENT_TIMEOUT_MS,
93
+ });
94
+
95
+ const settlement = await settlementWithin(client.getBlockCount(), OBSERVE_MS);
96
+
97
+ expect(settlement).toBe("rejected");
98
+ }, OBSERVE_MS + 5_000);
99
+
100
+ test("NEAR RPC rejects on a blackholed endpoint", async () => {
101
+ const client = new NearClient(`http://127.0.0.1:${port}`, CLIENT_TIMEOUT_MS);
102
+
103
+ const settlement = await settlementWithin(
104
+ client.getBlock({ finality: "final" }),
105
+ OBSERVE_MS,
106
+ );
107
+
108
+ expect(settlement).toBe("rejected");
109
+ }, OBSERVE_MS + 5_000);
110
+
111
+ test("Solana RPC rejects on a blackholed endpoint", async () => {
112
+ const client = new SolanaClient(`http://127.0.0.1:${port}`, CLIENT_TIMEOUT_MS);
113
+
114
+ const settlement = await settlementWithin(client.getSlot(), OBSERVE_MS);
115
+
116
+ expect(settlement).toBe("rejected");
117
+ }, OBSERVE_MS + 5_000);
118
+
119
+ test("Avail light-client HTTP rejects on a blackholed endpoint", async () => {
120
+ // Built without the constructor on purpose: `new AvailClient(...)` eagerly
121
+ // calls `SDK.New(nodeUrl)`, whose websocket provider keeps reconnect timers
122
+ // alive and would outlive the test process. `getStatus()` only touches the
123
+ // light-client HTTP url, which is the code path under test.
124
+ const client: AvailClient = Object.create(AvailClient.prototype);
125
+ Object.assign(client, {
126
+ url: `http://127.0.0.1:${port}`,
127
+ requestTimeoutMs: CLIENT_TIMEOUT_MS,
128
+ });
129
+
130
+ const settlement = await settlementWithin(client.getStatus(), OBSERVE_MS);
131
+
132
+ expect(settlement).toBe("rejected");
133
+ }, OBSERVE_MS + 5_000);
134
+
135
+ test("CONTROL: Midnight rejects on a blackholed endpoint (has a request timeout)", async () => {
136
+ const client = new MidnightClient(
137
+ `http://127.0.0.1:${port}`,
138
+ undefined,
139
+ CLIENT_TIMEOUT_MS,
140
+ );
141
+
142
+ const settlement = await settlementWithin(
143
+ client.gqlQuery("query { __typename }"),
144
+ OBSERVE_MS,
145
+ );
146
+
147
+ // MidnightClient.gqlQuery() passes AbortSignal.timeout(requestTimeoutMs).
148
+ // This is the shape the three clients above are missing.
149
+ expect(settlement).toBe("rejected");
150
+ }, OBSERVE_MS + 5_000);
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Child process for `poll-loop-spin.test.ts` (sync/CLAUDE.md Finding #3).
3
+ *
4
+ * Drives the REAL `startSync` fetch loop with a fake `SyncState` that is always
5
+ * "caught up" (`stateToInput` → `undefined`) — the steady state of any protocol
6
+ * whose tip advances slowly. The only variable is whether the protocol config
7
+ * carries a `pollingInterval`, because `orchestration/sync.ts` guards all three
8
+ * of its `sleep`s with `if ("pollingInterval" in config.syncProtocol)`.
9
+ *
10
+ * Runs in its own process because a loop with no sleep can starve the event
11
+ * loop outright, which would hang the test runner rather than fail it. The two
12
+ * exit paths below discriminate the outcomes:
13
+ *
14
+ * - `reason: "limit"` — the loop reached `limit` iterations. Reported via
15
+ * `writeSync` + `process.exit`, which work even when
16
+ * the event loop is starved.
17
+ * - `reason: "window"` — a `setTimeout` fired first, so the loop is paced and
18
+ * the event loop is healthy.
19
+ *
20
+ * Usage: bun spin-probe.ts '{"polling":20,"limit":50000,"windowMs":500,"mode":"call"}'
21
+ */
22
+ import { writeSync } from "node:fs";
23
+ import { call, type Operation, run } from "effection";
24
+ import { startSync } from "../src/sync-protocols/orchestration/sync.ts";
25
+
26
+ type ProbeSpec = {
27
+ /** `pollingInterval` for the fake protocol config; `null` omits the key entirely. */
28
+ polling: number | null;
29
+ /** Iteration count at which we declare the loop unpaced and bail out. */
30
+ limit: number;
31
+ /** Wall-clock budget; if this timer fires, the loop is paced. */
32
+ windowMs: number;
33
+ /**
34
+ * How `stateToInput` returns:
35
+ * - "sync" — returns `undefined` with no suspension at all (worst case).
36
+ * - "call" — `yield* call(() => <sync value>)` first, mirroring utxorpc's
37
+ * `stateToInput`, which calls the in-memory `fetcher.lastHeight()`
38
+ * before deciding it is caught up. This is the production shape.
39
+ */
40
+ mode: "sync" | "call";
41
+ };
42
+
43
+ const spec: ProbeSpec = JSON.parse(process.argv[2]);
44
+
45
+ const startedAt = Date.now();
46
+ let iterations = 0;
47
+
48
+ function report(reason: "limit" | "window"): never {
49
+ writeSync(
50
+ 1,
51
+ JSON.stringify({
52
+ reason,
53
+ iterations,
54
+ elapsedMs: Date.now() - startedAt,
55
+ }) + "\n",
56
+ );
57
+ process.exit(0);
58
+ }
59
+
60
+ // Fires only if the loop leaves room for macrotasks.
61
+ setTimeout(() => report("window"), spec.windowMs);
62
+
63
+ const syncProtocol: Record<string, unknown> = { name: "probe" };
64
+ if (spec.polling != null) syncProtocol.pollingInterval = spec.polling;
65
+
66
+ /**
67
+ * Minimal stand-in for a `SyncState`. Only the members `startSync` actually
68
+ * touches are implemented; it is cast at the call site the same way the real
69
+ * states are (`startSync` takes `AllSyncProtocols` and narrows to `ISyncProtocol`).
70
+ */
71
+ const fakeState = {
72
+ name: "probe",
73
+ consecutiveErrors: 0,
74
+ lastErrorTimestamp: 0,
75
+ lastSuccessfulFetchMs: 0,
76
+ getNamespace: () => ["probe", "probe"],
77
+ *startAsync(): Operation<void> {},
78
+ *stateToInput(): Operation<undefined> {
79
+ iterations++;
80
+ if (iterations >= spec.limit) report("limit");
81
+ if (spec.mode === "call") {
82
+ // utxorpc reads an in-memory tip here — a `call` over a synchronous value.
83
+ yield* call(() => 0);
84
+ }
85
+ // "caught up": nothing to fetch this pass.
86
+ return undefined;
87
+ },
88
+ fetcher: {
89
+ config: { syncProtocol },
90
+ producerChannel: { *send(): Operation<void> {} },
91
+ *readData(): Operation<never> {
92
+ throw new Error("unreachable: stateToInput always returns undefined");
93
+ },
94
+ },
95
+ *updateState(): Operation<void> {},
96
+ };
97
+
98
+ await run(function* () {
99
+ yield* startSync(fakeState as never);
100
+ // Park forever; one of the two exit paths above ends the process.
101
+ yield* call(() => new Promise<void>(() => {}));
102
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Regression guard for sync/CLAUDE.md Finding #5 (unsupervised `startAsync`).
3
+ *
4
+ * `orchestration/sync.ts` used to start the streaming producer with a bare
5
+ * `yield* spawn(function* () { yield* iState.startAsync(); })` — no try/catch,
6
+ * no restart, no liveness check. Streaming chains (utxorpc today, midnight if
7
+ * it moves to subscriptions) put ALL of their data on that path, so both of its
8
+ * exit modes were unhandled:
9
+ *
10
+ * 5a — the stream ENDS CLEANLY. `WatchMultiplexer.runWatcher` logs
11
+ * "stream ended unexpectedly" and returns; the spawned task completed
12
+ * normally. The chain then received nothing forever while every health
13
+ * counter stayed clean, and the merge blocked on its page — a total stall
14
+ * with nothing reporting an error.
15
+ *
16
+ * 5b — the stream THROWS. The error escaped the spawned task and tore down the
17
+ * enclosing scope, which in production is `start()`: one flaky gRPC
18
+ * connection took down every unrelated chain with it.
19
+ *
20
+ * Now supervised — both modes are treated as failure and restarted with capped
21
+ * exponential backoff, counted in `producerRestarts`. A returning producer
22
+ * counts as failure because the observable effect is identical: no more data.
23
+ *
24
+ * Supervision is gated on `hasAsyncProducer` so polled chains, whose
25
+ * `startAsync` is the base-class no-op, are still run exactly once (5c).
26
+ */
27
+ import { expect, test } from "bun:test";
28
+ import { type Operation, run, sleep } from "effection";
29
+ import { startSync } from "../src/sync-protocols/orchestration/sync.ts";
30
+
31
+ const POLL_MS = 20;
32
+
33
+ /**
34
+ * Minimal stand-in for a streaming `SyncState`. Only the members `startSync`
35
+ * touches are implemented. `stateToInput` always reports "caught up", which is
36
+ * what a streaming chain does while it waits for its producer to deliver.
37
+ */
38
+ function makeStreamingState(
39
+ startAsyncBody: () => Operation<void>,
40
+ hasAsyncProducer = true,
41
+ ) {
42
+ const counters = { startAsyncCalls: 0, pollPasses: 0 };
43
+ const state = {
44
+ name: "streamer",
45
+ consecutiveErrors: 0,
46
+ lastErrorTimestamp: 0,
47
+ lastSuccessfulFetchMs: 0,
48
+ hasAsyncProducer,
49
+ producerRestarts: 0,
50
+ producerErrors: 0,
51
+ lastProducerErrorMs: 0,
52
+ pollingIntervalMs: POLL_MS,
53
+ getNamespace: () => ["streamer", "streamer"],
54
+ *startAsync(): Operation<void> {
55
+ counters.startAsyncCalls++;
56
+ yield* startAsyncBody();
57
+ },
58
+ *stateToInput(): Operation<undefined> {
59
+ counters.pollPasses++;
60
+ return undefined;
61
+ },
62
+ fetcher: {
63
+ config: { syncProtocol: { name: "streamer", pollingInterval: POLL_MS } },
64
+ producerChannel: { *send(): Operation<void> {} },
65
+ *readData(): Operation<never> {
66
+ throw new Error("unreachable: stateToInput always returns undefined");
67
+ },
68
+ },
69
+ *updateState(): Operation<void> {},
70
+ };
71
+ return { state, counters };
72
+ }
73
+
74
+ test("5a: a producer that ends cleanly is restarted and counted", async () => {
75
+ // The multiplexer's `for await (const event of stream)` falling through —
76
+ // the stream closed and `runWatcher` returned.
77
+ const { state, counters } = makeStreamingState(function* () {
78
+ // returns immediately
79
+ });
80
+
81
+ await run(function* () {
82
+ yield* startSync(state as never);
83
+ // Long enough for the first restart (1s backoff) plus room to spare.
84
+ yield* sleep(1_500);
85
+ });
86
+
87
+ // Restarted rather than abandoned...
88
+ expect(counters.startAsyncCalls).toBeGreaterThan(1);
89
+ // ...and the restart is visible to health instead of being silent.
90
+ expect(state.producerRestarts).toBeGreaterThan(0);
91
+ // A clean return is not an *error*, so only the restart counter moves.
92
+ expect(state.producerErrors).toBe(0);
93
+ // The sibling polling loop is unaffected.
94
+ expect(counters.pollPasses).toBeGreaterThan(1);
95
+ }, 30_000);
96
+
97
+ test("5b: a producer that throws is restarted and does not kill the scope", async () => {
98
+ const { state, counters } = makeStreamingState(function* () {
99
+ yield* sleep(50);
100
+ throw new Error("gRPC stream died");
101
+ });
102
+
103
+ let rejection: unknown;
104
+ await run(function* () {
105
+ yield* startSync(state as never);
106
+ yield* sleep(1_500);
107
+ }).catch((err) => {
108
+ rejection = err;
109
+ });
110
+
111
+ // The scope survived: no error escaped to tear down `start()`.
112
+ expect(rejection).toBeUndefined();
113
+ // The throw was caught, counted, and the producer restarted.
114
+ expect(counters.startAsyncCalls).toBeGreaterThan(1);
115
+ expect(state.producerRestarts).toBeGreaterThan(0);
116
+ expect(state.producerErrors).toBeGreaterThan(0);
117
+ expect(state.lastProducerErrorMs).toBeGreaterThan(0);
118
+ // Counted on the PRODUCER's own tally, not the fetch loop's. Sharing
119
+ // `consecutiveErrors` conflated two signals: only a successful `readData`
120
+ // clears it, so an idle streaming chain stayed `erroring` long after its
121
+ // producer recovered, and a successful poll erased the record of a flap.
122
+ expect(state.consecutiveErrors).toBe(0);
123
+ // And the sibling polling loop kept running throughout.
124
+ expect(counters.pollPasses).toBeGreaterThan(1);
125
+ }, 30_000);
126
+
127
+ test("5c: a polled chain's no-op producer is run once, not supervised", async () => {
128
+ const { state, counters } = makeStreamingState(function* () {}, false);
129
+
130
+ await run(function* () {
131
+ yield* startSync(state as never);
132
+ yield* sleep(1_500);
133
+ });
134
+
135
+ // Without this gate, every polled chain would "restart" its no-op forever.
136
+ expect(counters.startAsyncCalls).toBe(1);
137
+ expect(state.producerRestarts).toBe(0);
138
+ expect(counters.pollPasses).toBeGreaterThan(1);
139
+ }, 30_000);