@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,167 @@
|
|
|
1
|
+
import { call, type Operation } from "effection";
|
|
2
|
+
import { bound } from "@effectstream/utils";
|
|
3
|
+
import type { PoolClient } from "pg";
|
|
4
|
+
import { type LastPage, SyncState } from "../base/state.ts";
|
|
5
|
+
import type { RootOutput, RootPage } from "../types.ts";
|
|
6
|
+
import type { Input, Output, Page } from "./types.ts";
|
|
7
|
+
import { toMsTimestamp } from "./types.ts";
|
|
8
|
+
import { blockNumberRelation } from "../common/utils.ts";
|
|
9
|
+
import type { SolanaFetcher } from "./fetcher.ts";
|
|
10
|
+
import type {
|
|
11
|
+
ConfigNetworkType,
|
|
12
|
+
SyncProtocolWithNetwork,
|
|
13
|
+
} from "@effectstream/config";
|
|
14
|
+
import { getPage } from "@effectstream/db";
|
|
15
|
+
import { SolanaClient } from "./SolanaClient.ts";
|
|
16
|
+
import { applyDelay } from "../common/utils.ts";
|
|
17
|
+
import { bufferAtCap } from "../common/page-helpers.ts";
|
|
18
|
+
import { requestTimeoutOf } from "../common/http.ts";
|
|
19
|
+
|
|
20
|
+
export class SolanaSyncState extends SyncState<
|
|
21
|
+
Input,
|
|
22
|
+
Output,
|
|
23
|
+
Page,
|
|
24
|
+
RootOutput,
|
|
25
|
+
RootPage,
|
|
26
|
+
SolanaFetcher
|
|
27
|
+
> {
|
|
28
|
+
constructor(
|
|
29
|
+
lastPage: LastPage<Page, RootPage> | undefined,
|
|
30
|
+
readonly config: Extract<
|
|
31
|
+
SyncProtocolWithNetwork,
|
|
32
|
+
{ networkType: ConfigNetworkType.SOLANA }
|
|
33
|
+
>,
|
|
34
|
+
fetcher: SolanaFetcher,
|
|
35
|
+
public readonly client: SolanaClient,
|
|
36
|
+
dbConn: PoolClient,
|
|
37
|
+
) {
|
|
38
|
+
super(
|
|
39
|
+
config.syncProtocol.name,
|
|
40
|
+
lastPage,
|
|
41
|
+
fetcher,
|
|
42
|
+
blockNumberRelation,
|
|
43
|
+
dbConn,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@bound
|
|
48
|
+
override toPage(_input: Input, data: Output[]): Page {
|
|
49
|
+
const lastBlock = data[data.length - 1];
|
|
50
|
+
return lastBlock.slot as Page;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@bound
|
|
54
|
+
override toRootPage(data: Output): RootPage {
|
|
55
|
+
// Solana blockTime is monotonically non-decreasing (enforced by the
|
|
56
|
+
// cluster) — exactly what the merge gate needs: it pulls buffered parallel
|
|
57
|
+
// outputs in slot order while their root timestamp is <= the main chain's,
|
|
58
|
+
// so it disambiguates duplicate blockTimes by buffer/slot order, NOT by
|
|
59
|
+
// this value. We therefore must NOT add a slot-derived offset: the previous
|
|
60
|
+
// `(slot % 1000) * 0.001` was unnecessary AND broke monotonicity (it wrapped
|
|
61
|
+
// every 1000 slots, so slot 1000's root could sort before slot 999's).
|
|
62
|
+
return applyDelay(
|
|
63
|
+
toMsTimestamp(data.blockTime),
|
|
64
|
+
this.config.syncProtocol.delayMs,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@bound
|
|
69
|
+
override toRootOutput(_data: Output): RootOutput {
|
|
70
|
+
throw new Error("Only main chains create root outputs");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@bound
|
|
74
|
+
override *stateToInput(): Operation<Input | undefined> {
|
|
75
|
+
// Pause fetching while the merge drains our buffer (CLAUDE.md finding #1).
|
|
76
|
+
// Every other protocol gates on this first; skipping it lets the Deque grow
|
|
77
|
+
// toward the whole backlog during catch-up.
|
|
78
|
+
if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
|
|
79
|
+
// Query the chain tip directly (like NearSyncState) rather than via
|
|
80
|
+
// genInputRange, which requires the fetcher to implement PaginatedFetcher
|
|
81
|
+
// (getLatestPage/nextInterval/...) — SolanaFetcher does not.
|
|
82
|
+
const latestSlot = yield* call(() => this.client.getSlot());
|
|
83
|
+
const finalizedSlot = latestSlot - this.config.syncProtocol.confirmationDepth;
|
|
84
|
+
|
|
85
|
+
const lastSlot = this.lastPage?.own
|
|
86
|
+
?? ((this.config.syncProtocol.startBlockHeight as Page) - 1);
|
|
87
|
+
|
|
88
|
+
if (lastSlot >= finalizedSlot) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const from = (lastSlot + 1) as Page;
|
|
93
|
+
const to = Math.min(
|
|
94
|
+
Number(lastSlot) + this.config.syncProtocol.stepSize,
|
|
95
|
+
finalizedSlot,
|
|
96
|
+
) as Page;
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
from,
|
|
100
|
+
to,
|
|
101
|
+
isPresync: false,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@bound
|
|
106
|
+
override mergeDatum(ourOutput: Output, rootOutput: RootOutput): void {
|
|
107
|
+
const primitives = ourOutput.primitives.map((p) => ({
|
|
108
|
+
...p,
|
|
109
|
+
source: this.config.syncProtocol.name,
|
|
110
|
+
}));
|
|
111
|
+
const blockInfo = [{
|
|
112
|
+
protocol_name: this.config.syncProtocol.name,
|
|
113
|
+
block_number: ourOutput.slot,
|
|
114
|
+
blockHash: ourOutput.blockhash,
|
|
115
|
+
}];
|
|
116
|
+
rootOutput.blockInfo.push(...blockInfo);
|
|
117
|
+
rootOutput.primitives.push(...primitives);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resume marker for a single slot. `Page` is a flat slot number, so `own` and
|
|
122
|
+
* `ownBlockNumber` are the same value (cf. EvmSyncState). Declared `abstract`
|
|
123
|
+
* on SyncState and called unconditionally by the merge for every protocol that
|
|
124
|
+
* contributes data to a block — see CLAUDE.md design idea #5.
|
|
125
|
+
*/
|
|
126
|
+
@bound
|
|
127
|
+
override outputToLastPage(data: Output): LastPage<Page, RootPage> {
|
|
128
|
+
return {
|
|
129
|
+
own: data.slot as Page,
|
|
130
|
+
ownBlockNumber: data.slot as Page,
|
|
131
|
+
root: this.toRootPage(data),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@bound
|
|
136
|
+
override getNamespace(): string[] {
|
|
137
|
+
return [this.config.network.name, this.config.syncProtocol.name];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
static *restoreState(
|
|
141
|
+
dbConn: PoolClient,
|
|
142
|
+
config: Extract<
|
|
143
|
+
SyncProtocolWithNetwork,
|
|
144
|
+
{ networkType: ConfigNetworkType.SOLANA }
|
|
145
|
+
>,
|
|
146
|
+
fetcher: SolanaFetcher,
|
|
147
|
+
): Operation<SolanaSyncState> {
|
|
148
|
+
const [result] = yield* call(async () =>
|
|
149
|
+
await getPage.run({
|
|
150
|
+
protocol_name: config.syncProtocol.name,
|
|
151
|
+
}, dbConn)
|
|
152
|
+
);
|
|
153
|
+
const page = result
|
|
154
|
+
? result.page as unknown as LastPage<Page, RootPage>
|
|
155
|
+
: undefined;
|
|
156
|
+
return new SolanaSyncState(
|
|
157
|
+
page,
|
|
158
|
+
config,
|
|
159
|
+
fetcher,
|
|
160
|
+
new SolanaClient(
|
|
161
|
+
config.network.rpcUrl,
|
|
162
|
+
requestTimeoutOf(config.syncProtocol),
|
|
163
|
+
),
|
|
164
|
+
dbConn,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BlockNumber,
|
|
3
|
+
TimestampMs,
|
|
4
|
+
} from "@effectstream/utils";
|
|
5
|
+
import type { PageSyncRange } from "../common/page-helpers.ts";
|
|
6
|
+
import type {
|
|
7
|
+
ConfigNetworkType,
|
|
8
|
+
ConfigSyncProtocolType,
|
|
9
|
+
FlattenSyncProtocolIOFor,
|
|
10
|
+
PrimitiveEntry,
|
|
11
|
+
SyncProtocolWithNetwork,
|
|
12
|
+
} from "@effectstream/config";
|
|
13
|
+
|
|
14
|
+
/** Solana page type: a slot number (block height) */
|
|
15
|
+
export type Page = BlockNumber;
|
|
16
|
+
|
|
17
|
+
export type ConfigType = Extract<
|
|
18
|
+
SyncProtocolWithNetwork,
|
|
19
|
+
{ networkType: ConfigNetworkType.SOLANA }
|
|
20
|
+
>;
|
|
21
|
+
|
|
22
|
+
export type PrimitiveEntryType = Extract<
|
|
23
|
+
PrimitiveEntry,
|
|
24
|
+
{ syncProtocol: ConfigSyncProtocolType.SOLANA_RPC_PARALLEL }
|
|
25
|
+
>;
|
|
26
|
+
|
|
27
|
+
export type PrimitiveType = FlattenSyncProtocolIOFor<
|
|
28
|
+
ConfigSyncProtocolType.SOLANA_RPC_PARALLEL
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
export type Input = PageSyncRange<Page>;
|
|
32
|
+
|
|
33
|
+
export type SolanaTransactionMeta = {
|
|
34
|
+
err: unknown | null;
|
|
35
|
+
logMessages: string[] | null;
|
|
36
|
+
preBalances: number[];
|
|
37
|
+
postBalances: number[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type Output = {
|
|
41
|
+
slot: number;
|
|
42
|
+
blockhash: string;
|
|
43
|
+
/**
|
|
44
|
+
* Unix seconds. Non-null by construction: the fetcher resolves the RPC's
|
|
45
|
+
* nullable `blockTime` before building an Output, because a block with no
|
|
46
|
+
* timestamp cannot be placed in the time-ordered merge.
|
|
47
|
+
*/
|
|
48
|
+
blockTime: number;
|
|
49
|
+
blockHeight: number | null;
|
|
50
|
+
parentSlot: number;
|
|
51
|
+
transactions: SolanaTransactionMeta[];
|
|
52
|
+
primitives: PrimitiveType[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Convert a Solana blockTime (unix seconds) to milliseconds. */
|
|
56
|
+
export function toMsTimestamp(blockTime: number): TimestampMs {
|
|
57
|
+
return blockTime * 1000 as TimestampMs;
|
|
58
|
+
}
|
|
@@ -10,6 +10,7 @@ import type { AvailSyncState } from "./avail/state.ts";
|
|
|
10
10
|
import type { BitcoinSyncState } from "./bitcoin/state.ts";
|
|
11
11
|
import type { CelestiaSyncState } from "./celestia/state.ts";
|
|
12
12
|
import type { NearSyncState } from "./near/state.ts";
|
|
13
|
+
import type { SolanaSyncState } from "./solana/state.ts";
|
|
13
14
|
import type { TestSyncState } from "./test/state.ts";
|
|
14
15
|
|
|
15
16
|
// TODO: move folders
|
|
@@ -26,6 +27,7 @@ export type AllSyncProtocols =
|
|
|
26
27
|
| BitcoinSyncState
|
|
27
28
|
| CelestiaSyncState
|
|
28
29
|
| NearSyncState
|
|
30
|
+
| SolanaSyncState
|
|
29
31
|
| TestSyncState;
|
|
30
32
|
export type ISyncProtocol = UnionToIntersection<AllSyncProtocols>;
|
|
31
33
|
|
|
@@ -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);
|