Skip to content

Commit a5811ec

Browse files
committed
refactor(fast-inbox): store inbox buckets as complete units in the archiver (A-1379)
The message store used to snapshot a bucket incrementally, once per absorbed message, threading the in-progress bucket state across insertion batches. Every bucket is opened and closed within a single L1 block and the synchronizer retrieves Inbox logs in whole-L1-block ranges, so a bucket's messages always arrive together: group the batch by bucket sequence and derive one snapshot per bucket from its complete message set instead. The completeness that makes this correct is now enforced rather than assumed. A batch that continues a stored bucket from the middle, or that opens a bucket older than the newest stored one, is rejected: either would produce a snapshot disagreeing with the messages it covers. Re-delivering a stored bucket from its first message stays allowed, since an L1 reorg replaces a bucket's tail and the re-sync that follows replays the whole L1 block. Renames `addL1ToL2Messages` to `addL1ToL2MessageBuckets` to say what the method now requires of its caller, drops `isOpen` from `InboxBucket` (it had no consumers outside the store that produced it), and records each bucket's L1 block number and first message index in the snapshot for rollbacks and range queries to work off bucket records alone.
1 parent d4afda5 commit a5811ec

7 files changed

Lines changed: 224 additions & 99 deletions

File tree

yarn-project/archiver/src/archiver-sync.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,9 +276,7 @@ describe('Archiver Sync', () => {
276276
msgCount: 3,
277277
totalMsgCount: 9n,
278278
timestamp: t3,
279-
isOpen: true,
280279
});
281-
expect((await archiver.getInboxBucket(1n))!.isOpen).toBe(false);
282280

283281
// At-or-before lookups resolve the latest bucket not opened after the given timestamp.
284282
expect((await archiver.getLatestInboxBucketAtOrBefore(t3))!.seq).toEqual(3n);

yarn-project/archiver/src/modules/l1_synchronizer.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,10 @@ export class ArchiverL1Synchronizer implements Traceable {
495495
);
496496
}
497497

498-
/** Retrieves L1 to L2 messages from L1 in batches and stores them. */
498+
/**
499+
* Retrieves L1 to L2 messages from L1 in batches and stores them. Batches must span whole L1 blocks so that every
500+
* message of an Inbox bucket is stored in a single call, which the message store requires.
501+
*/
499502
private async retrieveAndStoreMessages(fromL1Block: bigint, toL1Block: bigint): Promise<void> {
500503
let searchStartBlock: bigint = 0n;
501504
let searchEndBlock: bigint = fromL1Block;
@@ -508,7 +511,7 @@ export class ArchiverL1Synchronizer implements Traceable {
508511
this.log.trace(`Retrieving L1 to L2 messages in L1 blocks ${searchStartBlock}-${searchEndBlock}`);
509512
const messages = await retrieveL1ToL2Messages(this.inbox, searchStartBlock, searchEndBlock);
510513
const timer = new Timer();
511-
await this.stores.messages.addL1ToL2Messages(messages);
514+
await this.stores.messages.addL1ToL2MessageBuckets(messages);
512515
const perMsg = timer.ms() / messages.length;
513516
this.instrumentation.processNewMessages(messages.length, perMsg);
514517
for (const msg of messages) {

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

Lines changed: 88 additions & 48 deletions
Large diffs are not rendered by default.

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

Lines changed: 125 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,17 @@ import {
2323
} from '../structs/inbox_message.js';
2424

2525
/**
26-
* Persisted snapshot of an Inbox rolling-hash bucket. Mirrors the fields the on-chain Inbox tracks per bucket,
27-
* plus the last absorbed message index so the between-buckets query can range-scan messages directly.
26+
* Persisted snapshot of an Inbox rolling-hash bucket. Mirrors the fields the on-chain Inbox tracks per bucket, plus
27+
* the L1 block the bucket was opened in and the index span of its messages, so rollbacks and range queries can work
28+
* off bucket records alone without scanning messages.
2829
*/
2930
type BucketSnapshot = {
3031
inboxRollingHash: Fr;
3132
totalMsgCount: bigint;
3233
timestamp: bigint;
34+
l1BlockNumber: bigint;
3335
msgCount: number;
36+
firstMessageIndex: bigint;
3437
lastMessageIndex: bigint;
3538
};
3639

@@ -39,7 +42,9 @@ function serializeBucketSnapshot(snapshot: BucketSnapshot): Buffer {
3942
snapshot.inboxRollingHash,
4043
bigintToUInt64BE(snapshot.totalMsgCount),
4144
bigintToUInt64BE(snapshot.timestamp),
45+
bigintToUInt64BE(snapshot.l1BlockNumber),
4246
numToUInt32BE(snapshot.msgCount),
47+
bigintToUInt64BE(snapshot.firstMessageIndex),
4348
bigintToUInt64BE(snapshot.lastMessageIndex),
4449
]);
4550
}
@@ -49,9 +54,34 @@ function deserializeBucketSnapshot(buffer: Buffer): BucketSnapshot {
4954
const inboxRollingHash = reader.readObject(Fr);
5055
const totalMsgCount = reader.readUInt64();
5156
const timestamp = reader.readUInt64();
57+
const l1BlockNumber = reader.readUInt64();
5258
const msgCount = reader.readNumber();
59+
const firstMessageIndex = reader.readUInt64();
5360
const lastMessageIndex = reader.readUInt64();
54-
return { inboxRollingHash, totalMsgCount, timestamp, msgCount, lastMessageIndex };
61+
return { inboxRollingHash, totalMsgCount, timestamp, l1BlockNumber, msgCount, firstMessageIndex, lastMessageIndex };
62+
}
63+
64+
/** The messages of a single Inbox bucket within an incoming batch, in insertion order. */
65+
type IncomingBucket = {
66+
seq: bigint;
67+
messages: InboxMessage[];
68+
};
69+
70+
/**
71+
* Splits an incoming batch of messages into per-bucket groups, in delivery order. Messages arrive ordered by index
72+
* and a bucket's messages are contiguous within that order, so a group ends as soon as the bucket sequence changes.
73+
*/
74+
function groupMessagesByBucket(messages: InboxMessage[]): IncomingBucket[] {
75+
const buckets: IncomingBucket[] = [];
76+
for (const message of messages) {
77+
const current = buckets.at(-1);
78+
if (current !== undefined && current.seq === message.bucketSeq) {
79+
current.messages.push(message);
80+
} else {
81+
buckets.push({ seq: message.bucketSeq, messages: [message] });
82+
}
83+
}
84+
return buckets;
5585
}
5686

5787
export class MessageStoreError extends Error {
@@ -137,11 +167,20 @@ export class MessageStore {
137167
}
138168

139169
/**
140-
* Append L1 to L2 messages to the store.
141-
* Requires new messages to be in order and strictly after the last message added.
142-
* Throws if out of order messages are added or if the rolling hash is invalid.
170+
* Appends L1 to L2 messages to the store, one whole Inbox bucket at a time.
171+
*
172+
* A bucket is opened and closed within a single L1 block, and callers retrieve Inbox logs in whole-L1-block ranges,
173+
* so every message of a bucket reaches this method in the same call — including the rollover buckets a full block
174+
* spills into. The bucket snapshots are derived from that: the batch is split per bucket sequence and each bucket
175+
* gets a single snapshot built from its complete message set. Delivering only part of a bucket already held in the
176+
* store is rejected, since the snapshot would then undercount the bucket. Delivering a stored bucket again from its
177+
* first message is allowed: an L1 reorg can replace a bucket's tail, and the re-sync that follows replays the whole
178+
* L1 block it lives in.
179+
*
180+
* Requires messages to be ordered by index and to continue the stored chain. Throws a `MessageStoreError` if
181+
* messages arrive out of order, if the rolling hash chain breaks, or if a bucket arrives incomplete.
143182
*/
144-
public addL1ToL2Messages(messages: InboxMessage[]): Promise<void> {
183+
public addL1ToL2MessageBuckets(messages: InboxMessage[]): Promise<void> {
145184
if (messages.length === 0) {
146185
return Promise.resolve();
147186
}
@@ -150,13 +189,8 @@ export class MessageStore {
150189
let lastMessage = await this.getLastMessage();
151190
let messageCount = 0;
152191

153-
// Running cumulative message count and in-progress bucket state, threaded across the batch so we can snapshot
154-
// each Inbox bucket as its messages are inserted. Seeded from the last stored message so a bucket that spans
155-
// two batches keeps accumulating.
156-
let cumulativeTotal = await this.getTotalL1ToL2MessageCount();
157-
let currentBucketSeq: bigint | undefined = lastMessage?.bucketSeq;
158-
let currentBucketMsgCount =
159-
currentBucketSeq !== undefined ? ((await this.getBucketSnapshotBySeq(currentBucketSeq))?.msgCount ?? 0) : 0;
192+
const incomingBuckets = groupMessagesByBucket(messages);
193+
await this.assertIncomingBucketsAreComplete(incomingBuckets);
160194

161195
for (const message of messages) {
162196
// Check messages are inserted in increasing order, but allow reinserting messages.
@@ -241,31 +275,74 @@ export class MessageStore {
241275
await this.#l1ToL2MessageIndices.set(this.leafToIndexKey(message.leaf), message.index);
242276
messageCount++;
243277

244-
// Snapshot the bucket this message was absorbed into. A message opens a new bucket whenever its bucket
245-
// sequence differs from the one currently being accumulated; otherwise it extends the current bucket.
246-
cumulativeTotal += 1n;
247-
if (currentBucketSeq === undefined || message.bucketSeq !== currentBucketSeq) {
248-
currentBucketSeq = message.bucketSeq;
249-
currentBucketMsgCount = 0;
250-
}
251-
currentBucketMsgCount += 1;
252-
await this.writeBucketSnapshot(message.bucketSeq, {
253-
inboxRollingHash: message.inboxRollingHash,
254-
totalMsgCount: cumulativeTotal,
255-
timestamp: message.bucketTimestamp,
256-
msgCount: currentBucketMsgCount,
257-
lastMessageIndex: message.index,
258-
});
259-
260278
this.#log.trace(`Inserted L1 to L2 message ${message.leaf} with index ${message.index} into the store`);
261279
lastMessage = message;
262280
}
263281

282+
await this.writeIncomingBucketSnapshots(incomingBuckets);
283+
264284
// Update total message count with the number of inserted messages.
265285
await this.increaseTotalMessageCount(messageCount);
266286
});
267287
}
268288

289+
/**
290+
* Rejects a batch that delivers an Inbox bucket the store already holds without replaying it from its first message,
291+
* or that opens a bucket older than the newest one stored. Either would produce a snapshot that disagrees with the
292+
* messages it covers, since each snapshot is derived from the batch's messages for that bucket alone.
293+
*/
294+
private async assertIncomingBucketsAreComplete(incomingBuckets: IncomingBucket[]): Promise<void> {
295+
const newestStoredSeq = await this.getNewestBucketSeq();
296+
let previousSeq: bigint | undefined;
297+
for (const bucket of incomingBuckets) {
298+
if (previousSeq !== undefined && bucket.seq <= previousSeq) {
299+
throw new MessageStoreError(
300+
`Inbox bucket ${bucket.seq} arrives after bucket ${previousSeq} in the same batch`,
301+
bucket.messages[0],
302+
);
303+
}
304+
previousSeq = bucket.seq;
305+
306+
const stored = await this.getBucketSnapshotBySeq(bucket.seq);
307+
if (stored === undefined) {
308+
if (newestStoredSeq !== undefined && bucket.seq <= newestStoredSeq) {
309+
throw new MessageStoreError(
310+
`Cannot open Inbox bucket ${bucket.seq} after bucket ${newestStoredSeq} has been stored`,
311+
bucket.messages[0],
312+
);
313+
}
314+
} else if (stored.firstMessageIndex !== bucket.messages[0].index) {
315+
throw new MessageStoreError(
316+
`Incomplete Inbox bucket ${bucket.seq}: stored messages start at index ${stored.firstMessageIndex} ` +
317+
`but the batch starts at index ${bucket.messages[0].index}`,
318+
bucket.messages[0],
319+
);
320+
}
321+
}
322+
}
323+
324+
/**
325+
* Writes one snapshot per bucket in the batch, each derived from the bucket's complete message set. Cumulative
326+
* totals thread forward from the bucket preceding the batch, so a bucket re-delivered with extra messages shifts
327+
* the totals of the buckets after it within the same batch.
328+
*/
329+
private async writeIncomingBucketSnapshots(incomingBuckets: IncomingBucket[]): Promise<void> {
330+
let cumulativeTotal = await this.getTotalMsgCountBeforeBucket(incomingBuckets[0].seq);
331+
for (const { seq, messages } of incomingBuckets) {
332+
const lastInBucket = messages.at(-1)!;
333+
cumulativeTotal += BigInt(messages.length);
334+
await this.writeBucketSnapshot(seq, {
335+
inboxRollingHash: lastInBucket.inboxRollingHash,
336+
totalMsgCount: cumulativeTotal,
337+
timestamp: lastInBucket.bucketTimestamp,
338+
l1BlockNumber: lastInBucket.l1BlockNumber,
339+
msgCount: messages.length,
340+
firstMessageIndex: messages[0].index,
341+
lastMessageIndex: lastInBucket.index,
342+
});
343+
}
344+
}
345+
269346
/**
270347
* Gets the L1 to L2 message index in the L1 to L2 message tree.
271348
* @param l1ToL2Message - The L1 to L2 message.
@@ -391,11 +468,14 @@ export class MessageStore {
391468
}
392469
msgCount += 1;
393470
}
471+
const stored = await this.getBucketSnapshotBySeq(boundarySeq);
394472
await this.writeBucketSnapshot(boundarySeq, {
395473
inboxRollingHash: lastRemaining.inboxRollingHash,
396474
totalMsgCount: await this.getTotalL1ToL2MessageCount(),
397475
timestamp: lastRemaining.bucketTimestamp,
476+
l1BlockNumber: stored?.l1BlockNumber ?? lastRemaining.l1BlockNumber,
398477
msgCount,
478+
firstMessageIndex: stored?.firstMessageIndex ?? lastRemaining.index - BigInt(msgCount) + 1n,
399479
lastMessageIndex: lastRemaining.index,
400480
});
401481
}
@@ -466,16 +546,28 @@ export class MessageStore {
466546
await this.#bucketTimestampToSeq.set(this.timestampToKey(snapshot.timestamp), this.bucketSeqToKey(seq));
467547
}
468548

469-
private async toInboxBucket(seq: bigint, snapshot: BucketSnapshot): Promise<InboxBucket> {
470-
const lastMessage = await this.getLastMessage();
549+
/** Returns the sequence number of the newest stored bucket, or undefined if none has been stored yet. */
550+
private async getNewestBucketSeq(): Promise<bigint | undefined> {
551+
const [seqKey] = await toArray(this.#inboxBuckets.keysAsync({ reverse: true, limit: 1 }));
552+
return seqKey === undefined ? undefined : BigInt(seqKey);
553+
}
554+
555+
/** Returns the cumulative Inbox message count through the newest stored bucket before the given sequence number. */
556+
private async getTotalMsgCountBeforeBucket(seq: bigint): Promise<bigint> {
557+
const [snapBuffer] = await toArray(
558+
this.#inboxBuckets.valuesAsync({ end: this.bucketSeqToKey(seq) - 1, reverse: true, limit: 1 }),
559+
);
560+
return snapBuffer === undefined ? 0n : deserializeBucketSnapshot(snapBuffer).totalMsgCount;
561+
}
562+
563+
private toInboxBucket(seq: bigint, snapshot: BucketSnapshot): InboxBucket {
471564
return {
472565
seq,
473566
inboxRollingHash: snapshot.inboxRollingHash,
474567
totalMsgCount: snapshot.totalMsgCount,
475568
timestamp: snapshot.timestamp,
476569
msgCount: snapshot.msgCount,
477570
lastMessageIndex: snapshot.lastMessageIndex,
478-
isOpen: lastMessage?.bucketSeq === seq,
479571
};
480572
}
481573

yarn-project/stdlib/src/interfaces/archiver.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,12 +216,12 @@ describe('ArchiverApiSchema', () => {
216216

217217
it('getLatestInboxBucketAtOrBefore', async () => {
218218
const result = await context.client.getLatestInboxBucketAtOrBefore(123n);
219-
expect(result).toMatchObject({ seq: 1n, msgCount: 3, totalMsgCount: 3n, isOpen: false });
219+
expect(result).toMatchObject({ seq: 1n, msgCount: 3, totalMsgCount: 3n });
220220
});
221221

222222
it('getInboxBucket', async () => {
223223
const result = await context.client.getInboxBucket(2n);
224-
expect(result).toMatchObject({ seq: 2n, msgCount: 3, isOpen: true });
224+
expect(result).toMatchObject({ seq: 2n, msgCount: 3 });
225225
});
226226

227227
it('getL1ToL2MessagesBetweenBuckets', async () => {
@@ -576,7 +576,6 @@ class MockArchiver implements ArchiverApi {
576576
timestamp: 100n,
577577
msgCount: 3,
578578
lastMessageIndex: 2n,
579-
isOpen: false,
580579
});
581580
}
582581
getInboxBucket(seq: bigint): Promise<InboxBucket | undefined> {
@@ -588,7 +587,6 @@ class MockArchiver implements ArchiverApi {
588587
timestamp: 100n,
589588
msgCount: 3,
590589
lastMessageIndex: 2n,
591-
isOpen: true,
592590
});
593591
}
594592
getL1ToL2MessagesBetweenBuckets(fromExclusive: bigint, toInclusive: bigint): Promise<Fr[]> {

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

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,6 @@ export type InboxBucket = {
2525
msgCount: number;
2626
/** Global leaf index of the last message absorbed into this bucket. */
2727
lastMessageIndex: bigint;
28-
/**
29-
* Whether this is the latest bucket the archiver has synced. A latest bucket may still grow as more messages
30-
* arrive on L1; earlier buckets are complete. Consumers that need a settled bucket apply a lag on the timestamp.
31-
*/
32-
isOpen: boolean;
3328
};
3429

3530
export const InboxBucketSchema = z.object({
@@ -39,5 +34,4 @@ export const InboxBucketSchema = z.object({
3934
timestamp: schemas.BigInt,
4035
msgCount: schemas.Integer,
4136
lastMessageIndex: schemas.BigInt,
42-
isOpen: z.boolean(),
4337
}) satisfies z.ZodType<InboxBucket>;

yarn-project/validator-client/src/validator.integration.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -460,8 +460,8 @@ describe('ValidatorClient Integration', () => {
460460
it('validates and attests with txs anchored to proposed blocks and non-empty l1-to-l2 messages', async () => {
461461
// Create l1 to l2 messages and seed them into the archivers
462462
const l1ToL2Messages = makeInboxMessages(4, { messagesPerCheckpoint: 4 });
463-
await proposer.archiver.dataStores.messages.addL1ToL2Messages(l1ToL2Messages);
464-
await attestor.archiver.dataStores.messages.addL1ToL2Messages(l1ToL2Messages);
463+
await proposer.archiver.dataStores.messages.addL1ToL2MessageBuckets(l1ToL2Messages);
464+
await attestor.archiver.dataStores.messages.addL1ToL2MessageBuckets(l1ToL2Messages);
465465

466466
// Build txs anchored to the previously proposed block
467467
const { blocks, proposal } = await buildCheckpoint(
@@ -660,10 +660,10 @@ describe('ValidatorClient Integration', () => {
660660

661661
it('refuses block proposal with mismatching l1 to l2 messages', async () => {
662662
const l1ToL2Messages = makeInboxMessages(4, { messagesPerCheckpoint: 4 });
663-
await proposer.archiver.dataStores.messages.addL1ToL2Messages(l1ToL2Messages);
663+
await proposer.archiver.dataStores.messages.addL1ToL2MessageBuckets(l1ToL2Messages);
664664

665665
const otherL1ToL2Messages = makeInboxMessages(4, { messagesPerCheckpoint: 4 });
666-
await attestor.archiver.dataStores.messages.addL1ToL2Messages(otherL1ToL2Messages);
666+
await attestor.archiver.dataStores.messages.addL1ToL2MessageBuckets(otherL1ToL2Messages);
667667

668668
const { blocks } = await buildCheckpoint(
669669
CheckpointNumber(1),

0 commit comments

Comments
 (0)