@effectstream/sync 0.100.25 → 0.100.27
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/package.json +5 -5
- package/src/sync-protocols/common/root.ts +1 -0
- package/src/sync-protocols/midnight/MidnightClient.ts +1 -52
- package/src/sync-protocols/midnight/fetcher.ts +11 -18
- package/src/sync-protocols/midnight/state.ts +26 -7
- package/src/sync-protocols/orchestration/merge.ts +145 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effectstream/sync",
|
|
3
|
-
"version": "0.100.
|
|
3
|
+
"version": "0.100.27",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"homepage": "https://effectstream.github.io/docs/",
|
|
6
6
|
"repository": {
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
},
|
|
14
14
|
"exports": "./src/mod.ts",
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@effectstream/config": "0.100.
|
|
17
|
-
"@effectstream/db": "0.100.
|
|
18
|
-
"@effectstream/log": "0.100.
|
|
19
|
-
"@effectstream/utils": "0.100.
|
|
16
|
+
"@effectstream/config": "0.100.27",
|
|
17
|
+
"@effectstream/db": "0.100.27",
|
|
18
|
+
"@effectstream/log": "0.100.27",
|
|
19
|
+
"@effectstream/utils": "0.100.27",
|
|
20
20
|
"@midnight-ntwrk/ledger-v8": "8.0.3",
|
|
21
21
|
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.4",
|
|
22
22
|
"@midnight-ntwrk/onchain-runtime": "npm:@midnight-ntwrk/onchain-runtime-v3@3.0.0",
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { indexerPublicDataProvider } from "@midnight-ntwrk/midnight-js-indexer-public-data-provider";
|
|
2
1
|
import type { Block } from "./types.ts";
|
|
3
2
|
import type { ExecutionResult } from "graphql-ws/client";
|
|
4
3
|
|
|
@@ -67,24 +66,16 @@ export interface BlockFetchOptions {
|
|
|
67
66
|
tokenMints?: boolean;
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
type PublicDataProvider = ReturnType<typeof indexerPublicDataProvider>;
|
|
71
69
|
export class MidnightClient {
|
|
72
70
|
private readonly queryURL: string;
|
|
73
|
-
private readonly subscriptionURL: string;
|
|
74
|
-
private publicDataProvider: PublicDataProvider;
|
|
75
71
|
private readonly networkId?: string;
|
|
76
72
|
/** Timeout in milliseconds for individual HTTP requests to the indexer. */
|
|
77
73
|
private readonly requestTimeoutMs: number;
|
|
78
74
|
|
|
79
|
-
constructor(queryURL: string,
|
|
75
|
+
constructor(queryURL: string, networkId?: string, requestTimeoutMs = 30_000) {
|
|
80
76
|
this.queryURL = queryURL;
|
|
81
|
-
this.subscriptionURL = subscriptionURL;
|
|
82
77
|
this.networkId = networkId;
|
|
83
78
|
this.requestTimeoutMs = requestTimeoutMs;
|
|
84
|
-
this.publicDataProvider = indexerPublicDataProvider(
|
|
85
|
-
queryURL,
|
|
86
|
-
subscriptionURL,
|
|
87
|
-
);
|
|
88
79
|
console.log(
|
|
89
80
|
`[MidnightClient] Using indexer ${queryURL} (network: ${
|
|
90
81
|
networkId ?? "unknown"
|
|
@@ -92,48 +83,6 @@ export class MidnightClient {
|
|
|
92
83
|
);
|
|
93
84
|
}
|
|
94
85
|
|
|
95
|
-
/**
|
|
96
|
-
* Recreate the publicDataProvider to recover from stale internal state.
|
|
97
|
-
* The Midnight JS library can get into a state where all queryContractState
|
|
98
|
-
* calls fail with {"json":{}} until the provider is recreated.
|
|
99
|
-
*/
|
|
100
|
-
resetProvider(): void {
|
|
101
|
-
console.log(
|
|
102
|
-
`[MidnightClient] Resetting publicDataProvider for ${this.queryURL}`,
|
|
103
|
-
);
|
|
104
|
-
this.publicDataProvider = indexerPublicDataProvider(
|
|
105
|
-
this.queryURL,
|
|
106
|
-
this.subscriptionURL,
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async fetchContractState(contractAddress: string, blockHeight: number) {
|
|
111
|
-
try {
|
|
112
|
-
const state = await Promise.race([
|
|
113
|
-
this.publicDataProvider.queryContractState(
|
|
114
|
-
contractAddress,
|
|
115
|
-
{
|
|
116
|
-
type: "blockHeight",
|
|
117
|
-
blockHeight,
|
|
118
|
-
},
|
|
119
|
-
),
|
|
120
|
-
new Promise<never>((_, reject) => {
|
|
121
|
-
setTimeout(
|
|
122
|
-
() => reject(new Error(`fetchContractState timed out after ${this.requestTimeoutMs}ms (contract=${contractAddress}, height=${blockHeight})`)),
|
|
123
|
-
this.requestTimeoutMs,
|
|
124
|
-
);
|
|
125
|
-
}),
|
|
126
|
-
]);
|
|
127
|
-
return state;
|
|
128
|
-
} catch (error) {
|
|
129
|
-
// The Midnight JS publicDataProvider can enter a broken state where all
|
|
130
|
-
// subsequent calls fail (e.g. {"json":{}}). Recreate it so the next
|
|
131
|
-
// retry starts with a fresh connection.
|
|
132
|
-
this.resetProvider();
|
|
133
|
-
throw error;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
86
|
async gqlQuery(query: string, signal?: AbortSignal): Promise<any> {
|
|
138
87
|
const timeoutSignal = AbortSignal.timeout(this.requestTimeoutMs);
|
|
139
88
|
const combinedSignal = signal
|
|
@@ -35,16 +35,9 @@ export class MidnightFetcher extends BaseDataFetcher<
|
|
|
35
35
|
) {
|
|
36
36
|
super(config.syncProtocol.name);
|
|
37
37
|
const indexerHttp = config.syncProtocol.indexer;
|
|
38
|
-
|
|
39
|
-
(config.syncProtocol as any).indexerWS ??
|
|
40
|
-
(config.syncProtocol as any).indexerWs;
|
|
41
|
-
if (!indexerHttp || !indexerWs) {
|
|
38
|
+
if (!indexerHttp) {
|
|
42
39
|
throw new Error(
|
|
43
|
-
`Midnight sync protocol "${
|
|
44
|
-
config.syncProtocol.name
|
|
45
|
-
}" requires both indexer and indexerWS URLs. Received indexer=${
|
|
46
|
-
indexerHttp ?? "undefined"
|
|
47
|
-
}, indexerWS=${indexerWs ?? "undefined"}.`,
|
|
40
|
+
`Midnight sync protocol "${config.syncProtocol.name}" requires an indexer URL.`,
|
|
48
41
|
);
|
|
49
42
|
}
|
|
50
43
|
this.networkId = config.network?.networkId ??
|
|
@@ -65,7 +58,6 @@ export class MidnightFetcher extends BaseDataFetcher<
|
|
|
65
58
|
|
|
66
59
|
this.client = new MidnightClient(
|
|
67
60
|
indexerHttp,
|
|
68
|
-
indexerWs,
|
|
69
61
|
this.networkId,
|
|
70
62
|
);
|
|
71
63
|
}
|
|
@@ -385,19 +377,17 @@ export class MidnightFetcher extends BaseDataFetcher<
|
|
|
385
377
|
@bound
|
|
386
378
|
*fetchContractState(
|
|
387
379
|
height: number,
|
|
388
|
-
|
|
380
|
+
_client: MidnightClient,
|
|
389
381
|
primitiveEntry: PrimitiveEntryType,
|
|
390
382
|
block: MidnightGqlBlockState,
|
|
391
383
|
): Operation<PrimitiveType[]> {
|
|
392
384
|
const contractAddress = primitiveEntry.primitive.contractAddress!;
|
|
393
|
-
const blockFinalState = yield* call(() =>
|
|
394
|
-
client.fetchContractState(contractAddress, height)
|
|
395
|
-
);
|
|
396
|
-
// The state returns null if the contract was not called in the block height
|
|
397
|
-
if (!blockFinalState) {
|
|
398
|
-
return [];
|
|
399
|
-
}
|
|
400
385
|
|
|
386
|
+
// Determine whether the contract was called in this block by inspecting
|
|
387
|
+
// the contractActions already present in the fetched block data — no extra
|
|
388
|
+
// network round-trip needed. The per-transaction state is also read from
|
|
389
|
+
// contractActions below, so queryContractState would be redundant and its
|
|
390
|
+
// accumulating internal WS subscriptions caused saturation during catch-up.
|
|
401
391
|
const transactions = block.block.transactions.filter((t) => {
|
|
402
392
|
return (t.contractActions ?? []).some((c) => {
|
|
403
393
|
const longest = Math.max(contractAddress.length, c.address.length);
|
|
@@ -406,6 +396,9 @@ export class MidnightFetcher extends BaseDataFetcher<
|
|
|
406
396
|
});
|
|
407
397
|
});
|
|
408
398
|
|
|
399
|
+
if (transactions.length === 0) {
|
|
400
|
+
return [];
|
|
401
|
+
}
|
|
409
402
|
|
|
410
403
|
return transactions.map(t => {
|
|
411
404
|
// TODO: What does it mean if t.contractActions has more than one element?
|
|
@@ -12,11 +12,6 @@ import { MidnightClient } from "./MidnightClient.ts";
|
|
|
12
12
|
import { applyDelay } from "../common/utils.ts";
|
|
13
13
|
import { bufferAtCap } from "../common/page-helpers.ts";
|
|
14
14
|
|
|
15
|
-
type LatestBlock = {
|
|
16
|
-
block: {
|
|
17
|
-
height: number;
|
|
18
|
-
};
|
|
19
|
-
};
|
|
20
15
|
|
|
21
16
|
export class MidnightSyncState extends SyncState<
|
|
22
17
|
Input,
|
|
@@ -27,6 +22,13 @@ export class MidnightSyncState extends SyncState<
|
|
|
27
22
|
MidnightFetcher
|
|
28
23
|
> {
|
|
29
24
|
private readonly url: string;
|
|
25
|
+
private cachedLatestHeight: number | undefined;
|
|
26
|
+
private cacheTimestampMs: number = 0;
|
|
27
|
+
// Midnight produces ~1 block every 6 s. Re-query the real tip once this many
|
|
28
|
+
// estimated chain blocks have elapsed, giving periodic lag info without a
|
|
29
|
+
// round-trip on every batch.
|
|
30
|
+
private static readonly CHAIN_BLOCK_TIME_MS = 6_000;
|
|
31
|
+
private static readonly REFRESH_EVERY_CHAIN_BLOCKS = 100;
|
|
30
32
|
|
|
31
33
|
constructor(
|
|
32
34
|
lastPage: LastPage<Page, RootPage> | undefined,
|
|
@@ -74,12 +76,29 @@ export class MidnightSyncState extends SyncState<
|
|
|
74
76
|
@bound
|
|
75
77
|
override *stateToInput(): Operation<Input | undefined> {
|
|
76
78
|
if (bufferAtCap(this, this.config.syncProtocol)) return undefined;
|
|
77
|
-
const latestBlockResult = yield* call(() => this.client.fetchLatestBlock());
|
|
78
|
-
const latestHeight = latestBlockResult.block.height;
|
|
79
79
|
|
|
80
80
|
const startHeight = this.lastPage?.own.height ??
|
|
81
81
|
this.config.syncProtocol.startBlockHeight - 1;
|
|
82
82
|
|
|
83
|
+
// Extrapolate how many new chain blocks have likely arrived since the last
|
|
84
|
+
// real query, then re-query only when that estimate exceeds the threshold.
|
|
85
|
+
// This bounds indexer round-trips to O(catch-up_time / (REFRESH * block_time))
|
|
86
|
+
// rather than one per batch, while still refreshing periodically so lag
|
|
87
|
+
// metrics stay accurate.
|
|
88
|
+
const estimatedNewBlocks = this.cachedLatestHeight !== undefined
|
|
89
|
+
? Math.floor((Date.now() - this.cacheTimestampMs) / MidnightSyncState.CHAIN_BLOCK_TIME_MS)
|
|
90
|
+
: 0;
|
|
91
|
+
if (
|
|
92
|
+
this.cachedLatestHeight === undefined ||
|
|
93
|
+
startHeight >= this.cachedLatestHeight ||
|
|
94
|
+
estimatedNewBlocks >= MidnightSyncState.REFRESH_EVERY_CHAIN_BLOCKS
|
|
95
|
+
) {
|
|
96
|
+
const latestBlockResult = yield* call(() => this.client.fetchLatestBlock());
|
|
97
|
+
this.cachedLatestHeight = latestBlockResult.block.height;
|
|
98
|
+
this.cacheTimestampMs = Date.now();
|
|
99
|
+
}
|
|
100
|
+
const latestHeight = this.cachedLatestHeight;
|
|
101
|
+
|
|
83
102
|
if (startHeight >= latestHeight) {
|
|
84
103
|
return undefined;
|
|
85
104
|
}
|
|
@@ -11,11 +11,98 @@ import type Deque from "denque";
|
|
|
11
11
|
import type { ChainBlock } from "../common/root.ts";
|
|
12
12
|
import { chainPageRelation } from "../common/root.ts";
|
|
13
13
|
import type { CacheCleanup, OutputAndCleanup } from "../base/state.ts";
|
|
14
|
+
import { ENV } from "@effectstream/utils/node-env";
|
|
15
|
+
|
|
16
|
+
export const mergeCoalescingBoundaries = {
|
|
17
|
+
minScheduledBlockHeight: null as number | null,
|
|
18
|
+
minScheduledTimestampMs: null as number | null,
|
|
19
|
+
migrationBlockHeights: [] as number[],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function isCoalescableEmpty(block: ChainBlock): boolean {
|
|
23
|
+
if (block.primitives.length > 0) return false;
|
|
24
|
+
|
|
25
|
+
const bh = block.blockNumber;
|
|
26
|
+
if (mergeCoalescingBoundaries.migrationBlockHeights.includes(bh)) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (
|
|
31
|
+
mergeCoalescingBoundaries.minScheduledBlockHeight != null &&
|
|
32
|
+
bh >= mergeCoalescingBoundaries.minScheduledBlockHeight
|
|
33
|
+
) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (
|
|
38
|
+
mergeCoalescingBoundaries.minScheduledTimestampMs != null &&
|
|
39
|
+
block.timestamp >= mergeCoalescingBoundaries.minScheduledTimestampMs
|
|
40
|
+
) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function mergeBlockInfo(
|
|
48
|
+
target: ChainBlock["blockInfo"],
|
|
49
|
+
source: ChainBlock["blockInfo"],
|
|
50
|
+
): ChainBlock["blockInfo"] {
|
|
51
|
+
const map = new Map<string, ChainBlock["blockInfo"][number]>();
|
|
52
|
+
for (const item of target) {
|
|
53
|
+
map.set(item.protocol_name, item);
|
|
54
|
+
}
|
|
55
|
+
for (const item of source) {
|
|
56
|
+
map.set(item.protocol_name, item);
|
|
57
|
+
}
|
|
58
|
+
return Array.from(map.values());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function mergeResumePages(
|
|
62
|
+
target: ChainBlock["resumePages"],
|
|
63
|
+
source: ChainBlock["resumePages"],
|
|
64
|
+
): ChainBlock["resumePages"] {
|
|
65
|
+
const map = new Map<string, ChainBlock["resumePages"][number]>();
|
|
66
|
+
for (const item of target) {
|
|
67
|
+
map.set(item.protocol_name, item);
|
|
68
|
+
}
|
|
69
|
+
for (const item of source) {
|
|
70
|
+
map.set(item.protocol_name, item);
|
|
71
|
+
}
|
|
72
|
+
return Array.from(map.values());
|
|
73
|
+
}
|
|
14
74
|
|
|
15
75
|
export function* startMerge(
|
|
16
76
|
syncProtocols: AllSyncProtocols[],
|
|
17
77
|
finalizedBlockStream: Channel<ChainBlock, void>,
|
|
18
78
|
): Operation<ChainBlock> {
|
|
79
|
+
let pendingEmptyRun: {
|
|
80
|
+
endpoint: ChainBlock;
|
|
81
|
+
count: number;
|
|
82
|
+
} | null = null;
|
|
83
|
+
|
|
84
|
+
const flushPendingRun = function* (): Operation<void> {
|
|
85
|
+
if (pendingEmptyRun == null) return;
|
|
86
|
+
const { endpoint } = pendingEmptyRun;
|
|
87
|
+
endpoint.coalescedCount = pendingEmptyRun.count;
|
|
88
|
+
|
|
89
|
+
log.remote(
|
|
90
|
+
ComponentNames.EFFECTSTREAM_SYNC,
|
|
91
|
+
"block-merge",
|
|
92
|
+
SeverityNumber.INFO,
|
|
93
|
+
(log) =>
|
|
94
|
+
log(
|
|
95
|
+
`producing coalesced empty block run of length ${pendingEmptyRun!.count} ending at block ${endpoint.blockNumber}`,
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
yield* finalizedBlockStream.send(endpoint);
|
|
100
|
+
pendingEmptyRun = null;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const enabled = ENV.EFFECTSTREAM_COALESCE_EMPTY_BLOCKS;
|
|
104
|
+
const MAX_PRODUCER_COALESCE = 1000;
|
|
105
|
+
|
|
19
106
|
while (true) {
|
|
20
107
|
let newBlock: ChainBlock | undefined = undefined;
|
|
21
108
|
|
|
@@ -44,17 +131,66 @@ export function* startMerge(
|
|
|
44
131
|
}
|
|
45
132
|
}
|
|
46
133
|
if (newBlock == null) continue;
|
|
47
|
-
log.remote(
|
|
48
|
-
ComponentNames.EFFECTSTREAM_SYNC,
|
|
49
|
-
"block-merge",
|
|
50
|
-
SeverityNumber.INFO,
|
|
51
|
-
(log) => log(`producing block ${newBlock?.blockNumber}`),
|
|
52
|
-
);
|
|
53
134
|
|
|
54
|
-
|
|
135
|
+
if (enabled && isCoalescableEmpty(newBlock)) {
|
|
136
|
+
if (pendingEmptyRun == null) {
|
|
137
|
+
pendingEmptyRun = {
|
|
138
|
+
endpoint: newBlock,
|
|
139
|
+
count: 1,
|
|
140
|
+
};
|
|
141
|
+
} else {
|
|
142
|
+
pendingEmptyRun.endpoint.blockNumber = newBlock.blockNumber;
|
|
143
|
+
pendingEmptyRun.endpoint.timestamp = newBlock.timestamp;
|
|
144
|
+
pendingEmptyRun.endpoint.blockInfo = mergeBlockInfo(
|
|
145
|
+
pendingEmptyRun.endpoint.blockInfo,
|
|
146
|
+
newBlock.blockInfo,
|
|
147
|
+
);
|
|
148
|
+
pendingEmptyRun.endpoint.resumePages = mergeResumePages(
|
|
149
|
+
pendingEmptyRun.endpoint.resumePages,
|
|
150
|
+
newBlock.resumePages,
|
|
151
|
+
);
|
|
152
|
+
pendingEmptyRun.count++;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Run cleanups eagerly so the consumed datum leaves the Deque now, instead
|
|
156
|
+
// of deferring to flush. This (a) keeps the main chain's Deque from growing
|
|
157
|
+
// by one entry per coalesced block across a long run, and (b) makes the
|
|
158
|
+
// tip check below meaningful: with the consumed datum removed,
|
|
159
|
+
// bufferedData.size() === 0 means the buffer is drained and we're at the
|
|
160
|
+
// tip. Safe because, during an empty run, every cleanup is a no-op — the
|
|
161
|
+
// main (NTP) datum's cleanup is `() => {}` and parallel chains merge
|
|
162
|
+
// nothing (cleanups.length === 0). Resume durability is unaffected: the
|
|
163
|
+
// runtime is the sole writer of the persisted resume marker, which only
|
|
164
|
+
// lands when the coalesced block commits downstream (CLAUDE.md #5).
|
|
165
|
+
for (const fn of cleanup) {
|
|
166
|
+
fn();
|
|
167
|
+
}
|
|
55
168
|
|
|
56
|
-
|
|
57
|
-
|
|
169
|
+
// At the tip (steady state) the main chain produces one slot per
|
|
170
|
+
// blockTimeMS, so the Deque drains to empty after each one — flush
|
|
171
|
+
// immediately so trickle-in blocks aren't held back. While catching up the
|
|
172
|
+
// Deque stays non-empty and the run keeps accumulating until the cap.
|
|
173
|
+
const ntpState = syncProtocols[0];
|
|
174
|
+
const atTip = ntpState.bufferedData.size() === 0;
|
|
175
|
+
if (pendingEmptyRun.count >= MAX_PRODUCER_COALESCE || atTip) {
|
|
176
|
+
yield* flushPendingRun();
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
if (pendingEmptyRun != null) {
|
|
180
|
+
yield* flushPendingRun();
|
|
181
|
+
}
|
|
182
|
+
log.remote(
|
|
183
|
+
ComponentNames.EFFECTSTREAM_SYNC,
|
|
184
|
+
"block-merge",
|
|
185
|
+
SeverityNumber.INFO,
|
|
186
|
+
(log) => log(`producing block ${newBlock?.blockNumber}`),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
yield* finalizedBlockStream.send(newBlock);
|
|
190
|
+
|
|
191
|
+
for (const fn of cleanup) {
|
|
192
|
+
fn();
|
|
193
|
+
}
|
|
58
194
|
}
|
|
59
195
|
}
|
|
60
196
|
}
|