@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.
- package/CLAUDE.md +82 -18
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +63 -4
- package/docs/ADDING-A-SYNC-PROTOCOL.md +16 -9
- package/package.json +8 -8
- package/src/sync-protocols/avail/AvailClient.ts +34 -6
- package/src/sync-protocols/avail/fetcher.ts +2 -0
- package/src/sync-protocols/avail/state.ts +2 -0
- package/src/sync-protocols/base/state.ts +51 -0
- package/src/sync-protocols/bitcoin/fetcher.ts +18 -10
- package/src/sync-protocols/celestia/CelestiaClient.ts +16 -2
- package/src/sync-protocols/celestia/fetcher.ts +5 -1
- package/src/sync-protocols/celestia/state.ts +5 -1
- package/src/sync-protocols/common/http.ts +77 -0
- package/src/sync-protocols/midnight/fetcher.ts +2 -0
- package/src/sync-protocols/midnight/types.ts +1 -1
- package/src/sync-protocols/near/NearClient.ts +16 -6
- package/src/sync-protocols/near/fetcher.ts +5 -1
- package/src/sync-protocols/near/state.ts +5 -1
- package/src/sync-protocols/orchestration/sync.ts +101 -13
- package/src/sync-protocols/solana/SolanaClient.ts +158 -0
- package/src/sync-protocols/solana/fetcher.ts +358 -0
- package/src/sync-protocols/solana/program-logs.test.ts +113 -0
- package/src/sync-protocols/solana/program-logs.ts +84 -0
- package/src/sync-protocols/solana/read-data-retry.test.ts +153 -0
- package/src/sync-protocols/solana/read-primitives.test.ts +188 -0
- package/src/sync-protocols/solana/root-page.test.ts +66 -0
- package/src/sync-protocols/solana/state.ts +167 -0
- package/src/sync-protocols/solana/types.ts +58 -0
- package/src/sync-protocols/types.ts +2 -0
- package/src/sync-protocols/utxorpc/WatchMultiplexer.ts +52 -2
- package/src/sync-protocols/utxorpc/state.ts +3 -0
- package/src/syncProtocolFactory.ts +15 -0
- package/test/poll-loop-spin.test.ts +112 -0
- package/test/rpc-timeout.test.ts +150 -0
- package/test/spin-probe.ts +102 -0
- 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(
|
|
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);
|