@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/sync",
3
- "version": "0.100.25",
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.25",
17
- "@effectstream/db": "0.100.25",
18
- "@effectstream/log": "0.100.25",
19
- "@effectstream/utils": "0.100.25",
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",
@@ -34,6 +34,7 @@ export type ChainBlock = {
34
34
  >
35
35
  & { source: string }
36
36
  )[];
37
+ coalescedCount?: number;
37
38
  };
38
39
 
39
40
  export const chainPageRelation: PageRelation<ChainPage> = {
@@ -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, subscriptionURL: string, networkId?: string, requestTimeoutMs = 30_000) {
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
- const indexerWs =
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
- client: MidnightClient,
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
- yield* finalizedBlockStream.send(newBlock);
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
- for (const fn of cleanup) {
57
- fn();
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
  }