Skip to content

Commit 562c5aa

Browse files
committed
refactor(fast-inbox): index inbox buckets by timestamp through a multimap (A-1379)
The bucket rewind after a message removal deleted the timestamp index entry of every bucket it dropped, which also unindexed the rollover siblings sharing that timestamp, and then relied on rewriting the surviving boundary bucket to put the entry back. Give each bucket its own entry by making the index a multimap, so deletions touch only the bucket being removed and nothing has to be restored. Recency lookups now take the highest sequence among the entries at the newest timestamp at-or-before the requested one, and a bucket re-delivered from an L1 block re-mined at a different timestamp drops its stale entry. Also documents why the boundary bucket still has to be rebuilt: a removal can cut inside a bucket, since the synchronizer rolls back to the last message it still shares with L1 and that message need not be a bucket's last.
1 parent 4b322d9 commit 562c5aa

2 files changed

Lines changed: 87 additions & 35 deletions

File tree

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,39 @@ describe('MessageStore', () => {
529529
expect((await messageStore.getLatestInboxBucketAtOrBefore(200n))!.seq).toEqual(2n);
530530
});
531531

532+
it('keeps rollover siblings indexed when a bucket sharing their timestamp is removed', async () => {
533+
// Three buckets rolling over within one L1 block, so all three share timestamp 100.
534+
const msgs = makeBucketedMessages([
535+
{ seq: 1n, timestamp: 100n },
536+
{ seq: 2n, timestamp: 100n },
537+
{ seq: 3n, timestamp: 100n },
538+
]);
539+
await messageStore.addL1ToL2MessageBuckets(msgs);
540+
541+
await messageStore.removeL1ToL2Messages(msgs[2].index);
542+
543+
expect(await messageStore.getInboxBucket(3n)).toBeUndefined();
544+
expect(await messageStore.getInboxBucket(1n)).toMatchObject({ msgCount: 1, totalMsgCount: 1n });
545+
expect((await messageStore.getLatestInboxBucketAtOrBefore(100n))!.seq).toEqual(2n);
546+
});
547+
548+
it('reindexes a bucket re-delivered from an L1 block with a different timestamp', async () => {
549+
const msgs = makeBucketedMessages(threeBucketSpec);
550+
await messageStore.addL1ToL2MessageBuckets(msgs.slice(0, 3));
551+
await messageStore.removeL1ToL2Messages(msgs[2].index);
552+
553+
// The reorged L1 block holding bucket 1 was re-mined at a later timestamp.
554+
const replayed = [msgs[0], msgs[1], makeNextMessage(msgs[1], { seq: 1n, timestamp: 100n })].map(msg => ({
555+
...msg,
556+
bucketTimestamp: 150n,
557+
}));
558+
await messageStore.addL1ToL2MessageBuckets(replayed);
559+
560+
expect(await messageStore.getInboxBucket(1n)).toMatchObject({ timestamp: 150n, msgCount: 3 });
561+
expect(await messageStore.getLatestInboxBucketAtOrBefore(100n)).toBeUndefined();
562+
expect((await messageStore.getLatestInboxBucketAtOrBefore(150n))!.seq).toEqual(1n);
563+
});
564+
532565
it('clears all buckets when every message is removed', async () => {
533566
const msgs = makeBucketedMessages(threeBucketSpec);
534567
await messageStore.addL1ToL2MessageBuckets(msgs);

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

Lines changed: 54 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { BufferReader, bigintToUInt64BE, numToUInt32BE, serializeToBuffer } from
88
import {
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

Comments
 (0)