Skip to content

Commit 4b322d9

Browse files
committed
fix(fast-inbox): fail loudly when an inbox bucket range names an unsynced bucket (A-1379)
`getL1ToL2MessagesBetweenBuckets` returned an empty array when either bound was missing from the store, which callers could not tell apart from a range that genuinely holds no messages — and every caller asks because it wants the range's messages. Throw `InboxBucketNotSyncedError` instead, so a node that is behind on L1 sync surfaces that rather than deriving an empty message bundle, and reject an inverted range outright.
1 parent a5811ec commit 4b322d9

4 files changed

Lines changed: 54 additions & 24 deletions

File tree

yarn-project/archiver/src/errors.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,17 @@ export class L1ToL2MessagesNotReadyError extends Error {
116116
}
117117
}
118118

119+
/**
120+
* Thrown when a query names an Inbox bucket this archiver has not synced. Distinguishes "not synced yet, retry once
121+
* L1 sync catches up" from a genuinely empty result.
122+
*/
123+
export class InboxBucketNotSyncedError extends Error {
124+
constructor(public readonly bucketSeq: bigint) {
125+
super(`Inbox bucket ${bucketSeq} has not been synced`);
126+
this.name = 'InboxBucketNotSyncedError';
127+
}
128+
}
129+
119130
/** Thrown when a proposed checkpoint number is stale (already processed). */
120131
export class ProposedCheckpointStaleError extends Error {
121132
constructor(

yarn-project/archiver/src/store/message_store.test.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Checkpoint, type PublishedCheckpoint } from '@aztec/stdlib/checkpoint';
88
import { updateInboxRollingHash } from '@aztec/stdlib/messaging';
99
import '@aztec/stdlib/testing/jest';
1010

11-
import { L1ToL2MessagesNotReadyError } from '../errors.js';
11+
import { InboxBucketNotSyncedError, L1ToL2MessagesNotReadyError } from '../errors.js';
1212
import { type InboxMessage, updateRollingHash } from '../structs/inbox_message.js';
1313
import {
1414
makeInboxMessage,
@@ -478,10 +478,18 @@ describe('MessageStore', () => {
478478
expect(await messageStore.getL1ToL2MessagesBetweenBuckets(2n, 3n)).toEqual(leaves.slice(5));
479479
// An empty (fromExclusive, toInclusive] range yields no messages.
480480
expect(await messageStore.getL1ToL2MessagesBetweenBuckets(3n, 3n)).toEqual([]);
481-
// Unknown upper bucket yields no messages.
482-
expect(await messageStore.getL1ToL2MessagesBetweenBuckets(0n, 9n)).toEqual([]);
483-
// An unknown nonzero lower bucket is treated as unavailable, not as genesis.
484-
expect(await messageStore.getL1ToL2MessagesBetweenBuckets(4n, 3n)).toEqual([]);
481+
});
482+
483+
it('throws when a bucket bound has not been synced', async () => {
484+
const msgs = makeBucketedMessages(threeBucketSpec);
485+
await messageStore.addL1ToL2MessageBuckets(msgs);
486+
487+
// An unsynced bound is reported rather than collapsing into an empty range.
488+
await expect(messageStore.getL1ToL2MessagesBetweenBuckets(0n, 9n)).rejects.toThrow(InboxBucketNotSyncedError);
489+
await expect(messageStore.getL1ToL2MessagesBetweenBuckets(9n, 12n)).rejects.toThrow(InboxBucketNotSyncedError);
490+
// A nonzero lower bound is never treated as genesis.
491+
await expect(messageStore.getL1ToL2MessagesBetweenBuckets(4n, 5n)).rejects.toThrow(InboxBucketNotSyncedError);
492+
await expect(messageStore.getL1ToL2MessagesBetweenBuckets(4n, 3n)).rejects.toThrow(/Invalid Inbox bucket range/);
485493
});
486494

487495
it('rewinds buckets when messages are removed', async () => {

yarn-project/archiver/src/store/message_store.ts

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
} from '@aztec/kv-store';
1515
import { type InboxBucket, InboxLeaf, updateInboxRollingHash } from '@aztec/stdlib/messaging';
1616

17-
import { L1ToL2MessagesNotReadyError } from '../errors.js';
17+
import { InboxBucketNotSyncedError, L1ToL2MessagesNotReadyError } from '../errors.js';
1818
import {
1919
type InboxMessage,
2020
deserializeInboxMessage,
@@ -505,27 +505,27 @@ export class MessageStore {
505505
}
506506

507507
/**
508-
* Returns the message leaves absorbed into buckets in the range `(fromExclusive, toInclusive]`, in insertion
509-
* order (AZIP-22 Fast Inbox). Returns an empty array if the upper bucket has not been synced.
508+
* Returns the message leaves absorbed into buckets in the range `(fromExclusive, toInclusive]`, in insertion order
509+
* (AZIP-22 Fast Inbox). Both bounds must name buckets this archiver has synced, so that an empty result means the
510+
* range holds no messages rather than hiding an unsynced bound; callers route the
511+
* `InboxBucketNotSyncedError` to their own catch-up handling. Sequence 0 is the genesis base case and always
512+
* resolves: the range then starts at the first message of the Inbox.
510513
*/
511514
public async getL1ToL2MessagesBetweenBuckets(fromExclusive: bigint, toInclusive: bigint): Promise<Fr[]> {
512-
const toBucket = await this.getBucketSnapshotBySeq(toInclusive);
513-
if (toBucket === undefined) {
514-
return [];
515+
if (fromExclusive > toInclusive) {
516+
throw new Error(`Invalid Inbox bucket range (${fromExclusive}, ${toInclusive}]`);
515517
}
516-
// A nonzero lower bound must reference a synced bucket; otherwise the range is unavailable (rather than
517-
// defaulting to genesis, which would silently over-return). Sequence 0 is the genesis base case: start from
518-
// the beginning of the Inbox.
519-
let startIndex = 0n;
520-
if (fromExclusive > 0n) {
521-
const fromBucket = await this.getBucketSnapshotBySeq(fromExclusive);
522-
if (fromBucket === undefined) {
523-
return [];
524-
}
525-
startIndex = fromBucket.lastMessageIndex + 1n;
518+
if (toInclusive === 0n) {
519+
return [];
526520
}
527-
const endIndexExclusive = toBucket.lastMessageIndex + 1n;
521+
const endIndexExclusive = (await this.getSyncedBucketSnapshot(toInclusive)).lastMessageIndex + 1n;
522+
const startIndex =
523+
fromExclusive === 0n ? 0n : (await this.getSyncedBucketSnapshot(fromExclusive)).lastMessageIndex + 1n;
524+
return this.getMessageLeavesInIndexRange(startIndex, endIndexExclusive);
525+
}
528526

527+
/** Collects the message leaves in the global index range `[startIndex, endIndexExclusive)`, in insertion order. */
528+
private async getMessageLeavesInIndexRange(startIndex: bigint, endIndexExclusive: bigint): Promise<Fr[]> {
529529
const leaves: Fr[] = [];
530530
for await (const msgBuffer of this.#l1ToL2Messages.valuesAsync({
531531
start: this.indexToKey(startIndex),
@@ -541,6 +541,15 @@ export class MessageStore {
541541
return buffer && deserializeBucketSnapshot(buffer);
542542
}
543543

544+
/** Reads a bucket snapshot, failing loudly if the archiver has not synced that bucket. */
545+
private async getSyncedBucketSnapshot(seq: bigint): Promise<BucketSnapshot> {
546+
const snapshot = await this.getBucketSnapshotBySeq(seq);
547+
if (snapshot === undefined) {
548+
throw new InboxBucketNotSyncedError(seq);
549+
}
550+
return snapshot;
551+
}
552+
544553
private async writeBucketSnapshot(seq: bigint, snapshot: BucketSnapshot): Promise<void> {
545554
await this.#inboxBuckets.set(this.bucketSeqToKey(seq), serializeBucketSnapshot(snapshot));
546555
await this.#bucketTimestampToSeq.set(this.timestampToKey(snapshot.timestamp), this.bucketSeqToKey(seq));

yarn-project/stdlib/src/messaging/l1_to_l2_message_source.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@ export interface L1ToL2MessageSource {
4141

4242
/**
4343
* Returns the message leaves absorbed into buckets in the range `(fromExclusive, toInclusive]`, in insertion
44-
* order, for streaming message-bundle derivation (AZIP-22 Fast Inbox). Returns an empty array if the upper
45-
* bucket has not been synced.
44+
* order, for streaming message-bundle derivation (AZIP-22 Fast Inbox). Both bounds must name buckets the source
45+
* has synced; it throws otherwise, so that an empty result means the range holds no messages instead of hiding an
46+
* unsynced bound. Callers that can tolerate an unsynced source resolve both bounds first, or map the failure to
47+
* their own catch-up handling.
4648
* @param fromExclusive - The lower bucket sequence bound, exclusive (0 means from the start of the Inbox).
4749
* @param toInclusive - The upper bucket sequence bound, inclusive.
4850
*/

0 commit comments

Comments
 (0)