@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 +16 -3
- package/README.md +19 -6
- package/package.json +6 -6
- package/src/sync-protocols/base/state.ts +12 -0
- package/src/sync-protocols/common/page-helpers.ts +5 -1
- package/src/sync-protocols/midnight/fetcher.ts +13 -9
- package/src/sync-protocols/midnight/state.ts +1 -6
- package/src/sync-protocols/orchestration/merge.ts +7 -0
- package/src/sync-protocols/utxorpc/WatchMultiplexer.ts +1 -3
- package/src/sync-protocols/utxorpc/utils.ts +19 -12
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** —
|
|
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`)
|
|
206
|
-
>
|
|
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.**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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**),
|
|
93
|
-
(total time paused)
|
|
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.
|
|
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.
|
|
17
|
-
"@effectstream/db": "0.100.
|
|
18
|
-
"@effectstream/log": "0.100.
|
|
19
|
-
"@effectstream/utils": "0.100.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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}"
|
|
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
|
|
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
|
}
|