@@ -8,6 +8,7 @@ import { BufferReader, bigintToUInt64BE, numToUInt32BE, serializeToBuffer } from
88import {
99 type AztecAsyncKVStore ,
1010 type AztecAsyncMap ,
11+ type AztecAsyncMultiMap ,
1112 type AztecAsyncSingleton ,
1213 type CustomRange ,
1314 mapRange ,
@@ -109,8 +110,12 @@ export class MessageStore {
109110 #messagesFinalizedL1Block: AztecAsyncSingleton < Buffer > ;
110111 /** Maps from Inbox bucket sequence number to its serialized snapshot. */
111112 #inboxBuckets: AztecAsyncMap < number , Buffer > ;
112- /** Maps from a bucket's L1 timestamp (key) to the highest bucket sequence number opened at that timestamp. */
113- #bucketTimestampToSeq: AztecAsyncMap < number , number > ;
113+ /**
114+ * Maps from a bucket's L1 timestamp (key) to the sequence numbers of the buckets opened at that timestamp. Holds
115+ * several sequences per timestamp when a full bucket rolls over within one L1 block, so that deleting one bucket
116+ * leaves its rollover siblings indexed.
117+ */
118+ #bucketTimestampToSeq: AztecAsyncMultiMap < number , number > ;
114119
115120 #log = createLogger ( 'archiver:message_store' ) ;
116121
@@ -122,7 +127,7 @@ export class MessageStore {
122127 this . #inboxTreeInProgress = db . openSingleton ( 'archiver_inbox_tree_in_progress' ) ;
123128 this . #messagesFinalizedL1Block = db . openSingleton ( 'archiver_messages_finalized_l1_block' ) ;
124129 this . #inboxBuckets = db . openMap ( 'archiver_inbox_buckets' ) ;
125- this . #bucketTimestampToSeq = db . openMap ( 'archiver_inbox_bucket_timestamps' ) ;
130+ this . #bucketTimestampToSeq = db . openMultiMap ( 'archiver_inbox_bucket_timestamps' ) ;
126131 }
127132
128133 public async getTotalL1ToL2MessageCount ( ) : Promise < bigint > {
@@ -441,44 +446,44 @@ export class MessageStore {
441446 }
442447
443448 /**
444- * Rewinds the Inbox bucket snapshots to match the messages remaining after a removal. Buckets whose messages
445- * were all removed are deleted, and the boundary bucket (the one holding the last surviving message) is
446- * recomputed from its remaining messages, since a checkpoint-aligned removal can split a bucket. Must run
447- * inside the removal transaction, after the total message count has been updated.
449+ * Rewinds the Inbox bucket snapshots to match the messages left after a removal. Each bucket past the surviving tip
450+ * is deleted together with its own timestamp index entry, leaving the entries of the rollover siblings it shares a
451+ * timestamp with in place.
452+ *
453+ * The bucket holding the last surviving message is rewritten from the messages it has left, because a removal can
454+ * cut inside a bucket: the synchronizer rolls back to the last message it still shares with L1 after a reorg, and
455+ * that message need not be the last of its bucket. Must run inside the removal transaction, after the total message
456+ * count has been updated.
448457 */
449458 private async rewindBucketsAfterRemoval ( ) : Promise < void > {
450459 const lastRemaining = await this . getLastMessage ( ) ;
451460 const boundarySeq = lastRemaining ?. bucketSeq ;
452461
453- // Delete snapshots (and their timestamp index entries) for buckets entirely past the surviving tip.
454462 const deleteFromKey = boundarySeq === undefined ? 0 : this . bucketSeqToKey ( boundarySeq ) + 1 ;
455463 for await ( const [ seqKey , snapBuffer ] of this . #inboxBuckets. entriesAsync ( { start : deleteFromKey } ) ) {
456464 const snapshot = deserializeBucketSnapshot ( snapBuffer ) ;
457- await this . #bucketTimestampToSeq. delete ( this . timestampToKey ( snapshot . timestamp ) ) ;
465+ await this . #bucketTimestampToSeq. deleteValue ( this . timestampToKey ( snapshot . timestamp ) , seqKey ) ;
458466 await this . #inboxBuckets. delete ( seqKey ) ;
459467 }
460468
461- // Recompute the boundary bucket from its surviving messages. This also restores its timestamp index entry if a
462- // just-deleted rollover bucket shared the timestamp.
463- if ( lastRemaining !== undefined && boundarySeq !== undefined ) {
464- let msgCount = 0 ;
465- for await ( const msg of this . iterateL1ToL2Messages ( { reverse : true } ) ) {
466- if ( msg . bucketSeq !== boundarySeq ) {
467- break ;
468- }
469- msgCount += 1 ;
469+ if ( lastRemaining === undefined || boundarySeq === undefined ) {
470+ return ;
471+ }
472+ let msgCount = 0 ;
473+ for await ( const msg of this . iterateL1ToL2Messages ( { reverse : true } ) ) {
474+ if ( msg . bucketSeq !== boundarySeq ) {
475+ break ;
470476 }
471- const stored = await this . getBucketSnapshotBySeq ( boundarySeq ) ;
472- await this . writeBucketSnapshot ( boundarySeq , {
473- inboxRollingHash : lastRemaining . inboxRollingHash ,
474- totalMsgCount : await this . getTotalL1ToL2MessageCount ( ) ,
475- timestamp : lastRemaining . bucketTimestamp ,
476- l1BlockNumber : stored ?. l1BlockNumber ?? lastRemaining . l1BlockNumber ,
477- msgCount,
478- firstMessageIndex : stored ?. firstMessageIndex ?? lastRemaining . index - BigInt ( msgCount ) + 1n ,
479- lastMessageIndex : lastRemaining . index ,
480- } ) ;
477+ msgCount += 1 ;
481478 }
479+ const stored = await this . getSyncedBucketSnapshot ( boundarySeq ) ;
480+ await this . writeBucketSnapshot ( boundarySeq , {
481+ ...stored ,
482+ inboxRollingHash : lastRemaining . inboxRollingHash ,
483+ totalMsgCount : await this . getTotalL1ToL2MessageCount ( ) ,
484+ msgCount,
485+ lastMessageIndex : lastRemaining . index ,
486+ } ) ;
482487 }
483488
484489 /**
@@ -495,13 +500,22 @@ export class MessageStore {
495500 * was opened strictly after it (AZIP-22 Fast Inbox).
496501 */
497502 public async getLatestInboxBucketAtOrBefore ( timestamp : bigint ) : Promise < InboxBucket | undefined > {
498- // Bucket timestamps are non-decreasing in sequence number, and the index holds the highest sequence per
499- // timestamp. A reverse scan bounded above (inclusively) by the requested timestamp yields, first, the value
500- // at the largest timestamp at-or-before it — the bucket sequence we want.
501- const [ seq ] = await toArray (
502- this . #bucketTimestampToSeq. valuesAsync ( { end : this . timestampToKey ( timestamp ) , reverse : true , limit : 1 } ) ,
503- ) ;
504- return seq === undefined ? undefined : this . getInboxBucket ( BigInt ( seq ) ) ;
503+ // Bucket timestamps are non-decreasing in sequence number, so the bucket we want is the highest sequence indexed
504+ // at the largest timestamp at-or-before the requested one. A reverse scan bounded above (inclusively) by that
505+ // timestamp visits it first; rollover buckets share a timestamp, so keep the highest of its sequences.
506+ let latestTimestampKey : number | undefined ;
507+ let latestSeqKey : number | undefined ;
508+ for await ( const [ timestampKey , seqKey ] of this . #bucketTimestampToSeq. entriesAsync ( {
509+ end : this . timestampToKey ( timestamp ) ,
510+ reverse : true ,
511+ } ) ) {
512+ if ( latestTimestampKey !== undefined && timestampKey !== latestTimestampKey ) {
513+ break ;
514+ }
515+ latestTimestampKey = timestampKey ;
516+ latestSeqKey = latestSeqKey === undefined ? seqKey : Math . max ( latestSeqKey , seqKey ) ;
517+ }
518+ return latestSeqKey === undefined ? undefined : this . getInboxBucket ( BigInt ( latestSeqKey ) ) ;
505519 }
506520
507521 /**
@@ -551,6 +565,11 @@ export class MessageStore {
551565 }
552566
553567 private async writeBucketSnapshot ( seq : bigint , snapshot : BucketSnapshot ) : Promise < void > {
568+ // A reorg can re-deliver a bucket from an L1 block mined at a different timestamp, so drop the stale index entry.
569+ const stored = await this . getBucketSnapshotBySeq ( seq ) ;
570+ if ( stored !== undefined && stored . timestamp !== snapshot . timestamp ) {
571+ await this . #bucketTimestampToSeq. deleteValue ( this . timestampToKey ( stored . timestamp ) , this . bucketSeqToKey ( seq ) ) ;
572+ }
554573 await this . #inboxBuckets. set ( this . bucketSeqToKey ( seq ) , serializeBucketSnapshot ( snapshot ) ) ;
555574 await this . #bucketTimestampToSeq. set ( this . timestampToKey ( snapshot . timestamp ) , this . bucketSeqToKey ( seq ) ) ;
556575 }
0 commit comments