@aztec/aztec-node 5.3.0-nightly.20260821 → 5.3.0-nightly.20260823

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.
@@ -0,0 +1,199 @@
1
+ import { type Logger, createLogger } from '@aztec/foundation/log';
2
+ import { InterruptibleSleep } from '@aztec/foundation/sleep';
3
+ import { Timer } from '@aztec/foundation/timer';
4
+ import {
5
+ type BlockData,
6
+ type BlockHash,
7
+ type L2Block,
8
+ type L2BlockSource,
9
+ type L2BlockSourceEventEmitter,
10
+ L2BlockSourceEvents,
11
+ type NormalizedBlockParameter,
12
+ getBlockSourceEmitter,
13
+ inspectBlockParameter,
14
+ } from '@aztec/stdlib/block';
15
+
16
+ /**
17
+ * Longest a held request sleeps before re-reading the block source anyway. Held requests are woken by the source
18
+ * reporting an update, so this only bounds how long one waits when it misses the update that added its block: the
19
+ * source can commit between a request's read and its sleep, and a request that is not sleeping yet is not woken.
20
+ */
21
+ const MAX_SLEEP_MS = 1000;
22
+
23
+ /** Max requests held simultaneously. Beyond this, misses fail fast as if the hold-off were disabled. */
24
+ export const MAX_CONCURRENT_HOLDS = 100;
25
+
26
+ /** Wait budgets for {@link UnseenBlockHoldOff}, in milliseconds. Zero (or negative) disables a budget. */
27
+ export type UnseenBlockHoldOffConfig = {
28
+ /** Budget for a query anchored on the block number right after the node's proposed tip. */
29
+ byNumberWaitMs: number;
30
+ /** Budget for a query anchored on a block hash or archive root the node does not know. */
31
+ byHashWaitMs: number;
32
+ };
33
+
34
+ /** Options for a single {@link UnseenBlockHoldOff} query. */
35
+ export type UnseenBlockHoldOffOptions = {
36
+ /** Set to false to resolve without ever waiting (used by callers that already spent their budget). */
37
+ holdOff?: boolean;
38
+ };
39
+
40
+ /**
41
+ * Resolves RPC block anchors against the block source, briefly holding a request whose anchor the node is about to
42
+ * see instead of failing it immediately.
43
+ *
44
+ * Behind a load balancer a client can sync to block N+1 through one node and then anchor follow-up queries against
45
+ * another node that is still at block N. Failing those queries aborts a whole client flow over a skew that resolves
46
+ * in under a block time, so a miss on an anchor that plausibly lies just ahead of the tip is retried for a bounded
47
+ * budget. Everything else — a tag, a number far past the tip, a budget of zero, or too many requests already held —
48
+ * resolves exactly as the bare block source would.
49
+ *
50
+ * Held requests do not poll at a rate of their own choosing: they all sleep on one wake-up that the block source
51
+ * triggers when it reports it moved, so the source is read when there is something new to read and, failing that, at
52
+ * most once per {@link MAX_SLEEP_MS}. A source that reports no updates cannot wake them, so it never holds anything
53
+ * off.
54
+ */
55
+ export class UnseenBlockHoldOff {
56
+ private activeHolds = 0;
57
+ private readonly log: Logger;
58
+ /** Shared by every held request; interrupted on a source update. Undefined for a source that reports none. */
59
+ private readonly wakeup: InterruptibleSleep | undefined;
60
+
61
+ constructor(
62
+ private readonly blockSource: L2BlockSource | L2BlockSourceEventEmitter,
63
+ private readonly config: UnseenBlockHoldOffConfig,
64
+ log?: Logger,
65
+ ) {
66
+ this.log = log ?? createLogger('node:unseen-block-hold-off');
67
+ const emitter = getBlockSourceEmitter(blockSource);
68
+ if (emitter === undefined) {
69
+ this.log.verbose(`Block source reports no updates, queries for unseen blocks will not be held off`);
70
+ } else {
71
+ const wakeup = new InterruptibleSleep();
72
+ this.wakeup = wakeup;
73
+ emitter.on(L2BlockSourceEvents.L2BlockSourceUpdated, () => wakeup.interrupt());
74
+ }
75
+ }
76
+
77
+ /** Number of requests currently held waiting for their anchor block. Exposed for tests and diagnostics. */
78
+ public get holds(): number {
79
+ return this.activeHolds;
80
+ }
81
+
82
+ /**
83
+ * Resolves `query` to block metadata, holding off briefly when it references a block the node is about to see.
84
+ * Returns undefined on a miss, so callers keep whatever behavior they had before (throwing or returning
85
+ * undefined) — the hold-off only delays that outcome.
86
+ */
87
+ public getBlockData(
88
+ query: NormalizedBlockParameter,
89
+ opts: UnseenBlockHoldOffOptions = {},
90
+ ): Promise<BlockData | undefined> {
91
+ return this.#readWithHoldOff(query, q => this.blockSource.getBlockData(q), opts);
92
+ }
93
+
94
+ /** Resolves `query` to a full block with its transactions, holding off as {@link getBlockData} does. */
95
+ public getBlock(query: NormalizedBlockParameter, opts: UnseenBlockHoldOffOptions = {}): Promise<L2Block | undefined> {
96
+ return this.#readWithHoldOff(query, q => this.blockSource.getBlock(q), opts);
97
+ }
98
+
99
+ /**
100
+ * Reads `query` through `read`, and on a miss waits for the block it names before reading once more. Subject to the
101
+ * concurrent-hold cap: once it is saturated a miss resolves without waiting, as it would with the hold-off
102
+ * disabled.
103
+ */
104
+ async #readWithHoldOff<T>(
105
+ query: NormalizedBlockParameter,
106
+ read: (query: NormalizedBlockParameter) => Promise<T | undefined>,
107
+ opts: UnseenBlockHoldOffOptions,
108
+ ): Promise<T | undefined> {
109
+ const value = await read(query);
110
+ if (value !== undefined || opts.holdOff === false) {
111
+ return value;
112
+ }
113
+ const arrived = await this.#waitForBlock(query, await this.#resolveWaitBudgetMs(query));
114
+ return arrived ? await read(query) : undefined;
115
+ }
116
+
117
+ /**
118
+ * Waits for the block `query` names to show up on the block source, for at most `waitMs`, and reports whether it
119
+ * did. Returns false without waiting when the source reports no updates, when the budget is empty, or when the
120
+ * concurrent-hold cap is saturated, so a miss fails as fast as it would with the hold-off disabled.
121
+ *
122
+ * Arrival is checked on block metadata rather than through the caller's read, so a held request costs a metadata
123
+ * read per wake-up whatever it asked for: a held `getBlock` would otherwise reconstruct a whole block with its
124
+ * transactions on every wake-up only to find it is not the one it is waiting for.
125
+ *
126
+ * A budget is approximate: the deadline is only re-checked after a sleep, so the actual wait can exceed it by the
127
+ * read's own latency.
128
+ */
129
+ async #waitForBlock(query: NormalizedBlockParameter, waitMs: number): Promise<boolean> {
130
+ const wakeup = this.wakeup;
131
+ if (wakeup === undefined || !Number.isFinite(waitMs) || waitMs <= 0) {
132
+ return false;
133
+ }
134
+ const blockParameter = inspectBlockParameter(query);
135
+ if (this.activeHolds >= MAX_CONCURRENT_HOLDS) {
136
+ this.log.verbose(`Not holding off query for unseen block, too many requests already held`, {
137
+ blockParameter,
138
+ holds: this.activeHolds,
139
+ });
140
+ return false;
141
+ }
142
+
143
+ this.activeHolds++;
144
+ const timer = new Timer();
145
+ try {
146
+ this.log.verbose(`Holding off query for unseen block`, { blockParameter, waitMs, holds: this.activeHolds });
147
+ while (timer.ms() < waitMs) {
148
+ await wakeup.sleep(Math.min(waitMs - timer.ms(), MAX_SLEEP_MS));
149
+ const data = await this.blockSource.getBlockData(query);
150
+ if (data !== undefined) {
151
+ this.log.verbose(`Unseen block arrived after ${timer.ms()}ms`, {
152
+ blockParameter,
153
+ blockNumber: data.header.getBlockNumber(),
154
+ elapsedMs: timer.ms(),
155
+ });
156
+ return true;
157
+ }
158
+ }
159
+ this.log.verbose(`Gave up waiting for unseen block after ${timer.ms()}ms`, {
160
+ blockParameter,
161
+ waitMs,
162
+ elapsedMs: timer.ms(),
163
+ });
164
+ return false;
165
+ } finally {
166
+ this.activeHolds--;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Budget for waiting on a missing anchor, decided once on entry rather than per wake-up. A tag always resolves
172
+ * against the current tip, so a tag miss is never a skew. A number is only worth waiting on when it is the very
173
+ * next block: further ahead the client is not merely one block in front, and at or below the tip the block was
174
+ * pruned or reorged away. A hash or archive root carries no height, so "one ahead" and "reorged away" are
175
+ * indistinguishable and both get the shorter hash budget.
176
+ *
177
+ * The genesis block hash is the one hash never worth waiting on: a client anchors on it before it has synced any
178
+ * block (as a PXE does for its first tagged-log queries), and the block is synthetic, so a source that does not
179
+ * answer for it now never will.
180
+ */
181
+ async #resolveWaitBudgetMs(query: NormalizedBlockParameter): Promise<number> {
182
+ if ('tag' in query) {
183
+ return 0;
184
+ }
185
+ if ('number' in query) {
186
+ const tip = await this.blockSource.getBlockNumber();
187
+ return query.number === tip + 1 ? this.config.byNumberWaitMs : 0;
188
+ }
189
+ if ('hash' in query && this.#isGenesisBlockHash(query.hash)) {
190
+ return 0;
191
+ }
192
+ return this.config.byHashWaitMs;
193
+ }
194
+
195
+ /** True when `hash` names the synthetic genesis block, which never arrives and so is never waited for. */
196
+ #isGenesisBlockHash(hash: BlockHash): boolean {
197
+ return hash.equals(this.blockSource.getGenesisBlockHash());
198
+ }
199
+ }