@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.
- 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/MidnightClient.ts +11 -3
- package/src/sync-protocols/midnight/fetcher.ts +18 -11
- package/src/sync-protocols/midnight/types.ts +1 -1
- package/src/sync-protocols/midnight/zswap-decoder.ts +99 -101
- 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 +188 -0
- package/src/sync-protocols/solana/fetcher.ts +456 -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 +435 -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
- package/test/zswap-decoder.test.ts +74 -0
|
@@ -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);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
import { decodeZswapEvent } from "../src/sync-protocols/midnight/zswap-decoder.ts";
|
|
4
|
+
|
|
5
|
+
// Synthetic events built per the real midnight-ledger 8.x wire format:
|
|
6
|
+
// "midnight:event[v9]:" tag + TopoSortedNodes envelope wrapping the event
|
|
7
|
+
// struct. Both blobs round-trip through the native ledger-v8 deserializer.
|
|
8
|
+
|
|
9
|
+
function compactU32(n: number): Buffer {
|
|
10
|
+
if (n < 64) return Buffer.from([n << 2]);
|
|
11
|
+
if (n < 16384) {
|
|
12
|
+
const v = (n << 2) | 0b01;
|
|
13
|
+
return Buffer.from([v & 0xff, (v >> 8) & 0xff]);
|
|
14
|
+
}
|
|
15
|
+
throw new Error("value too large for this helper");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TX_HASH = Buffer.alloc(32, 0xaa);
|
|
19
|
+
const BODY32 = Buffer.alloc(32, 0xbb);
|
|
20
|
+
// logical=3, physical=1
|
|
21
|
+
const SEGMENTS = Buffer.from([0x03, 0x00, 0x01, 0x00]);
|
|
22
|
+
const CONTRACT_NONE = Buffer.from([0x01]);
|
|
23
|
+
|
|
24
|
+
function wrapEnvelope(data: Buffer): string {
|
|
25
|
+
return Buffer.concat([
|
|
26
|
+
Buffer.from("midnight:event[v9]:", "ascii"),
|
|
27
|
+
compactU32(1), // node count
|
|
28
|
+
compactU32(0), // child count of the single node
|
|
29
|
+
compactU32(data.length),
|
|
30
|
+
data,
|
|
31
|
+
]).toString("hex");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("decodeZswapEvent", () => {
|
|
35
|
+
test("decodes a ZswapInput (nullifier) event", () => {
|
|
36
|
+
const raw = wrapEnvelope(Buffer.concat([
|
|
37
|
+
TX_HASH,
|
|
38
|
+
SEGMENTS,
|
|
39
|
+
Buffer.from([0x00]), // variant: ZswapInput
|
|
40
|
+
BODY32, // nullifier
|
|
41
|
+
CONTRACT_NONE,
|
|
42
|
+
]));
|
|
43
|
+
expect(decodeZswapEvent(raw)).toEqual({
|
|
44
|
+
kind: "nullifier",
|
|
45
|
+
nullifier: "bb".repeat(32),
|
|
46
|
+
txHash: "aa".repeat(32),
|
|
47
|
+
logicalSegment: 3,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("decodes a ZswapOutput (commitment) event", () => {
|
|
52
|
+
const raw = wrapEnvelope(Buffer.concat([
|
|
53
|
+
TX_HASH,
|
|
54
|
+
SEGMENTS,
|
|
55
|
+
Buffer.from([0x01]), // variant: ZswapOutput
|
|
56
|
+
BODY32, // commitment
|
|
57
|
+
Buffer.from([0x02]), // ZswapPreimageEvidence::None
|
|
58
|
+
CONTRACT_NONE,
|
|
59
|
+
compactU32(7), // mt_index
|
|
60
|
+
]));
|
|
61
|
+
expect(decodeZswapEvent(raw)).toEqual({
|
|
62
|
+
kind: "commitment",
|
|
63
|
+
commitment: "bb".repeat(32),
|
|
64
|
+
mtIndex: "7",
|
|
65
|
+
txHash: "aa".repeat(32),
|
|
66
|
+
logicalSegment: 3,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("returns null for undecodable input", () => {
|
|
71
|
+
expect(decodeZswapEvent("deadbeef")).toBeNull();
|
|
72
|
+
expect(decodeZswapEvent("")).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
});
|