@@ -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 */
2930type 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
5787export 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
0 commit comments