@effectstream/sync 0.100.19 → 0.100.21

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 CHANGED
@@ -167,7 +167,18 @@ Consequences during catch-up:
167
167
  (`mergeIntoRoot`, `merge.ts`), so a slow/stalled chain stalls all block production while
168
168
  the others keep ballooning (head-of-line blocking).
169
169
 
170
- → Fix: **(C) backpressure** — cap `bufferedData` and pause the fetch loop until the merge drains.
170
+ → Fix: **(C) backpressure** — ✅ SHIPPED. `bufferAtCap` (`common/page-helpers.ts`) caps
171
+ `bufferedData` and pauses the fetch loop (each chain's `stateToInput` returns `undefined`)
172
+ until the merge drains. See README "Backpressure (`maxBufferedPages`)".
173
+
174
+ > **Merge-demand exemption (safeguard).** A naive cap can deadlock the merge: it drains a
175
+ > parallel chain's buffer only *after* its page passes the root timestamp `τ`, so if the cap
176
+ > pauses the fetcher while its page is still `≤ τ` (a far skip-ahead, or a chain finer-grained
177
+ > than the cap), merge waits for the page ↔ the page waits for a fetch ↔ the fetch waits for
178
+ > the buffer to drain — circular. Guard: `mergeIntoRoot` sets `SyncState.mergeWaitingForPage`
179
+ > while blocked on a chain's page, and `bufferAtCap` returns `false` for that chain while the
180
+ > flag is set — so the fetcher advances past `τ`. Bounded by necessity: only
181
+ > `(lastPage.root, τ]` is buffered above the cap. Repros: `buffering.test.ts` 1c/1d.
171
182
 
172
183
  ### 2. Silent data gap on restart (correctness) — ✅ FIXED (D)
173
184
 
@@ -202,5 +213,7 @@ block-hash chain.
202
213
  > (with `test` as the worked example).
203
214
  >
204
215
  > Deterministic reproductions of #1/#2/#3 and their fixes land alongside each fix:
205
- > **#2 → block-accurate resume is DONE** (`consistency.test.ts`). Still in follow-up
206
- > PRs: #1 → fetch backpressure; #3 → opt-in empty-block commit batching during catch-up.
216
+ > **#2 → block-accurate resume is DONE** (`consistency.test.ts`); **#1 → fetch
217
+ > backpressure is DONE** (`buffering.test.ts`, incl. 1c/1d for the merge-demand
218
+ > exemption). Still in follow-up PRs: #3 → opt-in empty-block commit batching during
219
+ > catch-up.
package/README.md CHANGED
@@ -70,10 +70,21 @@ the merge. ~4 chunks of look-ahead never starves the merge while keeping memory
70
70
  bounded; raise it to trade memory for more fetch look-ahead. The cap only bites during
71
71
  catch-up — in steady state the buffer sits near zero and the cap is never reached.
72
72
 
73
- **Deadlock-safety.** Pausing a full chain cannot deadlock: a chain's page only
74
- advances via a fetch (`updateState`), and the merge drains a chain's buffered data
75
- *before* it waits on that chain's page — so the paused (full) chain is never the one
76
- the merge is blocked on without a path to resume.
73
+ **Deadlock-safety (merge-demand exemption).** A naive cap can deadlock the merge: it
74
+ produces a root block at timestamp `τ` only once a parallel chain's page passes `τ`, and
75
+ drains that chain's buffer only *after* the page passes `τ`. If the cap pauses the
76
+ fetcher while its page is still `≤ τ` (the data up to `τ` exceeds the cap — e.g. a far
77
+ skip-ahead or a parallel chain finer-grained than the cap), neither side can proceed: a
78
+ circular wait, not a lost wakeup.
79
+
80
+ The safeguard is a **merge-demand exemption**: while the merge is blocked on a chain's
81
+ page (`mergeIntoRoot` sets `SyncState.mergeWaitingForPage`), `bufferAtCap` returns
82
+ `false` for that chain so the fetcher advances until its page crosses `τ`. Memory stays
83
+ **bounded by necessity** — only `(lastPage.root, τ]` is buffered above the cap, exactly
84
+ what the merge must hold to build block `τ`; once the page passes `τ` the normal cap
85
+ re-engages. A chain that is merely tip-limited (caught up, not gated by the merge) is
86
+ *not* exempted, so steady-state and head-of-line bounds are unaffected. Reproductions:
87
+ `buffering.test.ts` 1c (skip-ahead) and 1d (density).
77
88
 
78
89
  **Scope.** The guard runs in **every** chain's `stateToInput`, so all sync chains are
79
90
  covered: EVM, NTP, Bitcoin, Avail, Celestia, NEAR, Midnight, Cardano (UTXO-RPC), and
@@ -89,8 +100,10 @@ the synthetic `test` chain. Two notes:
89
100
  **Observability.** Each `SyncState` tracks, and the runtime's `/debug/metrics`
90
101
  endpoint reports per protocol: `cap` (resolved `maxBufferedPages`), `buf` (current
91
102
  size), `bufHighWater` (peak since boot — catches spikes between samples), `pausedNow`,
92
- `pauses` (rising-edge count — **the "backpressure engaged" signal**), and `pausedMs`
93
- (total time paused). `pauses > 0` means the cap actively bounded memory; `0` means it
103
+ `pauses` (rising-edge count — **the "backpressure engaged" signal**), `pausedMs`
104
+ (total time paused), and `mergeWaiting`/`mergeDemandRoot` (merge-demand exemption:
105
+ `mergeWaiting=true` means the merge is gated on this chain's page and the cap is lifted).
106
+ `pauses > 0` means the cap actively bounded memory; `0` means it
94
107
  was never needed in that run. Steady-state these sit at `0`; during a real deep
95
108
  catch-up (or under the perf harness's `PERF_APPLY_DELAY_MS` drain throttle) they climb
96
109
  as `buf` pins to `cap`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/sync",
3
- "version": "0.100.19",
3
+ "version": "0.100.21",
4
4
  "license": "MIT",
5
5
  "homepage": "https://effectstream.github.io/docs/",
6
6
  "repository": {
@@ -13,12 +13,12 @@
13
13
  },
14
14
  "exports": "./src/mod.ts",
15
15
  "dependencies": {
16
- "@effectstream/config": "0.100.19",
17
- "@effectstream/db": "0.100.19",
18
- "@effectstream/log": "0.100.19",
19
- "@effectstream/utils": "0.100.19",
16
+ "@effectstream/config": "0.100.21",
17
+ "@effectstream/db": "0.100.21",
18
+ "@effectstream/log": "0.100.21",
19
+ "@effectstream/utils": "0.100.21",
20
20
  "@midnight-ntwrk/ledger-v8": "8.0.3",
21
- "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.2",
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",
23
23
  "effection": "3.5.0",
24
24
  "graphql-ws": "^6.0.6",
@@ -88,6 +88,18 @@ export abstract class SyncState<
88
88
  /** Start of the current pause, for accumulating `backpressurePausedMs`. */
89
89
  private pauseStartMs: number = 0;
90
90
 
91
+ // ── Merge-demand exemption (see common/page-helpers.ts + README) ──
92
+ /**
93
+ * True while the merge is blocked waiting for THIS chain's page to pass the
94
+ * current root timestamp. The cap must not pause the fetcher here: the merge
95
+ * drains this chain's buffer only after its page passes the target, so pausing
96
+ * would be a circular wait. While exempt the fetcher buffers only the data the
97
+ * merge must hold to build that block anyway (bounded by necessity).
98
+ */
99
+ public mergeWaitingForPage: boolean = false;
100
+ /** Root page (ms timestamp) the merge is waiting to exceed — observability only. */
101
+ public mergeDemandRoot: RootPage | undefined = undefined;
102
+
91
103
  constructor(
92
104
  public readonly name: string,
93
105
  lastPage: undefined | LastPage<Page, RootPage>,
@@ -33,11 +33,15 @@ export function bufferAtCap(
33
33
  state: {
34
34
  bufferedData: { size(): number };
35
35
  recordBackpressure(atCap: boolean, cap: number): void;
36
+ mergeWaitingForPage: boolean;
36
37
  },
37
38
  syncProtocol: { maxBufferedPages?: number; stepSize?: number },
38
39
  ): boolean {
39
40
  const cap = bufferCapFor(syncProtocol);
40
- const atCap = state.bufferedData.size() >= cap;
41
+ // Exempt this chain from the cap while the merge is blocked on its page (pausing
42
+ // there would deadlock — see SyncState.mergeWaitingForPage). Reporting
43
+ // atCap=false while exempt is correct: the chain is genuinely not paused.
44
+ const atCap = !state.mergeWaitingForPage && state.bufferedData.size() >= cap;
41
45
  state.recordBackpressure(atCap, cap);
42
46
  return atCap;
43
47
  }
@@ -275,17 +275,21 @@ export class MidnightFetcher extends BaseDataFetcher<
275
275
  // Addresses length can be padded by 0's
276
276
  return c.address.padStart(longest, '0') === contractAddress.padStart(longest, '0');
277
277
  })!.state!;
278
- const byteState = new Uint8Array(rawState.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
279
278
  const primitive = primitiveEntry.primitive;
280
- const contractState = ContractState.deserialize(byteState);
281
- const stateValue = contractState.data.state;
282
- // Parse additional fields from the schema (defined per-contract on the primitive)
283
- const additionalFields = primitive.parseAdditionalLedgerFields?.(stateValue) ?? {};
284
- // Keep generated ledger fields, but let schema-parsed fields override.
285
- // This is important for map-like fields where contract.ledger() exposes
286
- // iterable wrappers that are not directly JSON-serializable.
287
279
  const f = primitive.contract ?? { ledger: (_: StateValue): Record<string, any> => ({}) };
288
- const state = { ...f.ledger(stateValue), ...additionalFields };
280
+ const ledgerFromTxStateHex = (f as {
281
+ ledgerFromTxStateHex?: (rawHexState: string) => Record<string, any>;
282
+ }).ledgerFromTxStateHex;
283
+ let state: Record<string, any>;
284
+ if (typeof ledgerFromTxStateHex === "function") {
285
+ state = ledgerFromTxStateHex(rawState);
286
+ } else {
287
+ const byteState = new Uint8Array(rawState.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
288
+ const contractState = ContractState.deserialize(byteState);
289
+ const stateValue = contractState.data.state;
290
+ const additionalFields = primitive.parseAdditionalLedgerFields?.(stateValue) ?? {};
291
+ state = { ...f.ledger(stateValue), ...additionalFields };
292
+ }
289
293
  return {
290
294
  syncProtocol: {
291
295
  name: primitiveEntry.syncProtocol,
@@ -146,12 +146,7 @@ export class MidnightSyncState extends SyncState<
146
146
  page,
147
147
  config,
148
148
  fetcher,
149
- // TODO: The urls should be part of the config.
150
- new MidnightClient(
151
- config.syncProtocol.indexer,
152
- config.syncProtocol.indexerWS ??
153
- "ws://127.0.0.1:8088/api/v3/graphql/ws",
154
- ),
149
+ fetcher.client,
155
150
  dbConn,
156
151
  );
157
152
  }
@@ -146,6 +146,11 @@ export function* mergeIntoRoot<SyncProtocol extends AllSyncProtocols>(
146
146
  rootInfo.toPage(rootInfo.value),
147
147
  ) <= 0
148
148
  ) {
149
+ // Publish the demand before waiting so the cap exempts this chain while the
150
+ // merge is gated on its page (see SyncState.mergeWaitingForPage). No lock
151
+ // needed: the fetch loop re-polls stateToInput every pollingInterval.
152
+ state.mergeWaitingForPage = true;
153
+ state.mergeDemandRoot = rootInfo.toPage(rootInfo.value);
149
154
  log.remote(
150
155
  ComponentNames.EFFECTSTREAM_SYNC,
151
156
  state.getNamespace(),
@@ -160,6 +165,8 @@ export function* mergeIntoRoot<SyncProtocol extends AllSyncProtocols>(
160
165
  (log) => log("Merge unblocked (missing parallel data): got data"),
161
166
  );
162
167
  }
168
+ state.mergeWaitingForPage = false;
169
+ state.mergeDemandRoot = undefined;
163
170
  const cleanups = getFromBuffer(
164
171
  {
165
172
  data: iState.bufferedData,
@@ -40,15 +40,13 @@ export class WatchMultiplexer {
40
40
  ) {
41
41
  for (const [key, { predicate }] of primitivesByPredicate) {
42
42
  const effective = isEffectiveServerPredicate(predicate);
43
- // TODO(dolos): when Dolos implements has_certificate, remove this fallback
44
43
  const sdkPredicate = effective
45
44
  ? configPredicateToSdkPredicate(predicate)
46
45
  : {};
47
46
 
48
47
  if (!effective) {
49
48
  console.warn(
50
- `[WatchMultiplexer] Predicate for key "${key}" cannot be server-side filtered ` +
51
- `(Dolos doesn't support has_certificate). Falling back to match-all with client-side filtering.`
49
+ `[WatchMultiplexer] Predicate for key "${key}" has no server-filterable fields. Falling back to match-all with client-side filtering.`
52
50
  );
53
51
  }
54
52
 
@@ -1,5 +1,5 @@
1
1
  import type { cardano } from '@utxorpc/spec';
2
- import type { UtxorpcAddressPattern, UtxorpcAnyChainTxPattern, UtxorpcAssetPattern, UtxorpcTxOutputPattern, UtxorpcTxPredicate } from "@effectstream/config";
2
+ import type { UtxorpcAddressPattern, UtxorpcAnyChainTxPattern, UtxorpcAssetPattern, UtxorpcCertificatePattern, UtxorpcTxOutputPattern, UtxorpcTxPredicate } from "@effectstream/config";
3
3
  import { hexStringToUint8Array } from "@effectstream/utils";
4
4
  import { Cardano } from '@cardano-sdk/core';
5
5
  import { HexBlob } from '@cardano-sdk/util';
@@ -111,14 +111,24 @@ function convertAssetPattern(pattern: UtxorpcAssetPattern): Record<string, Uint8
111
111
  return result;
112
112
  }
113
113
 
114
+ function convertCertificatePattern(pattern: boolean | UtxorpcCertificatePattern): Record<string, unknown> {
115
+ if (typeof pattern === "boolean") return {};
116
+ if (pattern.stake_delegation) {
117
+ const value: Record<string, unknown> = {};
118
+ if (pattern.stake_delegation.pool_keyhash) {
119
+ value.poolKeyhash = hexStringToUint8Array(pattern.stake_delegation.pool_keyhash);
120
+ }
121
+ return { certificateType: { case: "stakeDelegation", value } };
122
+ }
123
+ if (pattern.any_pool_keyhash) {
124
+ return { certificateType: { case: "anyPoolKeyhash", value: hexStringToUint8Array(pattern.any_pool_keyhash) } };
125
+ }
126
+ return {};
127
+ }
128
+
114
129
  /**
115
130
  * Convert a config-layer UtxorpcTxPredicate (snake_case) to the SDK's TxPredicate (camelCase).
116
- *
117
- * NOTE: `has_certificate` is in the UTxORPC spec but Dolos does NOT implement server-side
118
- * filtering for it. Predicates using has_certificate will be sent to the server but silently
119
- * ignored — client-side filtering in matchesPredicate() still catches them.
120
131
  */
121
- // TODO(dolos): when Dolos implements has_certificate filtering, this conversion will just work
122
132
  export function configPredicateToSdkPredicate(config: UtxorpcTxPredicate): TxPredicate {
123
133
  const result: TxPredicate = {};
124
134
 
@@ -128,7 +138,7 @@ export function configPredicateToSdkPredicate(config: UtxorpcTxPredicate): TxPre
128
138
  if (c.has_address) match.hasAddress = convertAddressPattern(c.has_address);
129
139
  if (c.moves_asset) match.movesAsset = convertAssetPattern(c.moves_asset);
130
140
  if (c.mints_asset) match.mintsAsset = convertAssetPattern(c.mints_asset);
131
- if (c.has_certificate) match.hasCertificate = {};
141
+ if (c.has_certificate) match.hasCertificate = convertCertificatePattern(c.has_certificate);
132
142
  result.match = match as TxPredicate["match"];
133
143
  }
134
144
 
@@ -148,13 +158,10 @@ export function predicateKey(predicate: UtxorpcTxPredicate): string {
148
158
  }
149
159
 
150
160
  /**
151
- * Returns true if this predicate can be effectively filtered server-side by Dolos.
152
- * Currently Dolos Watch ignores `has_certificate`, so predicates relying solely on it
153
- * are not effective and should fall back to match-all.
161
+ * Returns true if this predicate can be effectively filtered server-side.
154
162
  */
155
- // TODO(dolos): remove this when Dolos implements has_certificate filtering
156
163
  export function isEffectiveServerPredicate(predicate: UtxorpcTxPredicate): boolean {
157
164
  if (!predicate.match?.cardano) return false;
158
165
  const c = predicate.match.cardano;
159
- return !!(c.has_address || c.moves_asset || c.mints_asset);
166
+ return !!(c.has_address || c.moves_asset || c.mints_asset || c.has_certificate);
160
167
  }