@effectstream/sync 0.100.26 → 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.26",
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.26",
17
- "@effectstream/db": "0.100.26",
18
- "@effectstream/log": "0.100.26",
19
- "@effectstream/utils": "0.100.26",
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> = {
@@ -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
  }