From 375952923af529c3fc046d20d758d99949029f0c Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Fri, 21 Aug 2026 10:35:30 -0300 Subject: [PATCH 1/2] refactor(pxe): clean up the operation and staged-write lifecycle (#35) * refactor(pxe): clean up the operation and staged-write lifecycle * fix(pxe): keep an operation's outcome when a contributor's end notification throws * refactor(pxe): clarify staged-write coordinator docs and naming (cherry picked from commit 3850908ac99f57206222639bc6e9e1463569160b) --- .../block_synchronizer.test.ts | 64 +-- yarn-project/pxe/src/config/index.ts | 2 +- .../src/contract/contract_call_graph.test.ts | 73 ++-- .../pxe/src/contract/contract_call_graph.ts | 66 +-- .../contract/contract_sync_service.test.ts | 169 ++++---- .../pxe/src/contract/contract_sync_service.ts | 123 +++--- .../contract_function_simulator.ts | 17 +- .../oracle/oracle_version_is_checked.test.ts | 4 +- .../oracle/private_execution.test.ts | 4 +- .../oracle/private_execution_oracle.test.ts | 2 +- .../oracle/private_execution_oracle.ts | 10 +- .../oracle/utility_execution.test.ts | 20 +- .../oracle/utility_execution_oracle.ts | 43 +- yarn-project/pxe/src/debug/pxe_debug_utils.ts | 21 +- .../pxe/src/entrypoints/server/index.ts | 3 +- .../pxe/src/events/event_service.test.ts | 8 +- yarn-project/pxe/src/events/event_service.ts | 7 +- .../job_coordinator/job_coordinator.test.ts | 208 ---------- .../src/job_coordinator/job_coordinator.ts | 180 -------- yarn-project/pxe/src/logs/log_service.ts | 5 +- .../pxe/src/notes/note_service.test.ts | 39 +- yarn-project/pxe/src/notes/note_service.ts | 13 +- .../pxe/src/operation_lifecycle.test.ts | 151 +++++++ yarn-project/pxe/src/operation_lifecycle.ts | 93 +++++ yarn-project/pxe/src/operation_queue.test.ts | 90 ++++ yarn-project/pxe/src/operation_queue.ts | 149 +++++++ yarn-project/pxe/src/pxe.test.ts | 4 +- yarn-project/pxe/src/pxe.ts | 211 ++++------ .../schema_tests.ts | 90 ++-- .../capsule_store/capsule_service.test.ts | 78 ++-- .../storage/capsule_store/capsule_service.ts | 40 +- .../capsule_store/capsule_store.test.ts | 129 +++--- .../storage/capsule_store/capsule_store.ts | 165 ++++---- .../storage/fact_store/fact_service.test.ts | 38 +- .../src/storage/fact_store/fact_service.ts | 17 +- .../src/storage/fact_store/fact_store.test.ts | 334 ++++++++------- .../pxe/src/storage/fact_store/fact_store.ts | 93 +++-- .../src/storage/note_store/note_store.test.ts | 387 ++++++++++-------- .../pxe/src/storage/note_store/note_store.ts | 168 ++++---- .../private_event_store.test.ts | 81 ++-- .../private_event_store.ts | 112 ++--- .../storage/staged_write_coordinator.test.ts | 144 +++++++ .../src/storage/staged_write_coordinator.ts | 153 +++++++ .../recipient_tagging_store.test.ts | 84 ++-- .../tagging_store/recipient_tagging_store.ts | 101 ++--- .../sender_tagging_store.test.ts | 87 ++-- .../tagging_store/sender_tagging_store.ts | 180 ++++---- .../persist_sender_tagging_index_ranges.ts | 8 +- .../sync_tagged_private_logs.bench.test.ts | 8 +- .../sync_tagged_private_logs.test.ts | 74 ++-- .../sync_tagged_private_logs.ts | 25 +- .../sync_sender_tagging_indexes.ts | 27 +- .../load_and_store_new_tagging_indexes.ts | 9 +- yarn-project/pxe/src/test_utils.ts | 2 + yarn-project/txe/src/oracle/interfaces.ts | 12 +- .../oracle/txe_oracle_top_level_context.ts | 36 +- yarn-project/txe/src/txe_session.test.ts | 5 +- yarn-project/txe/src/txe_session.ts | 98 +++-- 58 files changed, 2540 insertions(+), 2024 deletions(-) delete mode 100644 yarn-project/pxe/src/job_coordinator/job_coordinator.test.ts delete mode 100644 yarn-project/pxe/src/job_coordinator/job_coordinator.ts create mode 100644 yarn-project/pxe/src/operation_lifecycle.test.ts create mode 100644 yarn-project/pxe/src/operation_lifecycle.ts create mode 100644 yarn-project/pxe/src/operation_queue.test.ts create mode 100644 yarn-project/pxe/src/operation_queue.ts create mode 100644 yarn-project/pxe/src/storage/staged_write_coordinator.test.ts create mode 100644 yarn-project/pxe/src/storage/staged_write_coordinator.ts create mode 100644 yarn-project/pxe/src/test_utils.ts diff --git a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts index 9ccfe8ed8f73..08c63c5e74e3 100644 --- a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts +++ b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts @@ -99,7 +99,7 @@ describe('BlockSynchronizer', () => { const noteAt = (contract: AztecAddress, block: L2BlockId): Promise => NoteDao.random({ contractAddress: contract, l2BlockNumber: block.number, l2BlockHash: block.hash }); - // Stores one private event anchored to the given block id under the 'event-job' (caller commits). + // Stores one private event anchored to the given block id under the 'event-change-set' (caller commits). const storeEvent = (contract: AztecAddress, scope: AztecAddress, eventId: Fr, block: L2BlockId) => privateEventStore.storePrivateEventLog( EventSelector.random(), @@ -115,7 +115,7 @@ describe('BlockSynchronizer', () => { txIndexInBlock: 0, eventIndexInTx: 0, }, - 'event-job', + 'event-change-set', ); beforeEach(async () => { @@ -251,8 +251,8 @@ describe('BlockSynchronizer', () => { const noteAt3 = await noteAt(contract, await blockId(forkBlock)); const noteAt4 = await noteAt(contract, block4); const noteAt5 = await noteAt(contract, block5); - await noteStore.addNotes([noteAt3, noteAt4, noteAt5], scope, 'note-job'); - await noteStore.commit('note-job'); + await noteStore.addNotes([noteAt3, noteAt4, noteAt5], scope, 'note-change-set'); + await noteStore.commitStaged('note-change-set'); // Seed an event at each block. const eventIdAt3 = Fr.random(); @@ -261,7 +261,7 @@ describe('BlockSynchronizer', () => { await storeEvent(contract, scope, eventIdAt3, await blockId(forkBlock)); await storeEvent(contract, scope, eventIdAt4, block4); await storeEvent(contract, scope, eventIdAt5, block5); - await privateEventStore.commit('event-job'); + await privateEventStore.commitStaged('event-change-set'); // Set the anchor to block 5 so the prune guard passes. const anchorBlock5 = await L2Block.random(BlockNumber(5)); @@ -304,14 +304,14 @@ describe('BlockSynchronizer', () => { const block9 = makeL2BlockId(BlockNumber(9), Fr.random().toString()); const note8 = await noteAt(contract, block8); const note9 = await noteAt(contract, block9); - await noteStore.addNotes([note8, note9], scope, 'note-job'); - await noteStore.commit('note-job'); + await noteStore.addNotes([note8, note9], scope, 'note-change-set'); + await noteStore.commitStaged('note-change-set'); const eventId8 = Fr.random(); const eventId9 = Fr.random(); await storeEvent(contract, scope, eventId8, block8); await storeEvent(contract, scope, eventId9, block9); - await privateEventStore.commit('event-job'); + await privateEventStore.commitStaged('event-change-set'); await synchronizer.handleBlockStreamEvent({ type: 'chain-finalized', @@ -327,7 +327,7 @@ describe('BlockSynchronizer', () => { }); it('chain-pruned retracts facts at pruned block heights or above, dropping collections left empty', async () => { - const jobId = 'fact-job'; + const changeSetId = 'fact-change-set'; // Block 5 will be the fork point: the prune keeps it and abandons only blocks strictly above it. const lastSurvivingBlock = await L2Block.random(BlockNumber(5)); @@ -351,7 +351,7 @@ describe('BlockSynchronizer', () => { Fr.random(), [Fr.random()], { blockNumber: lastSurvivingBlock.number, blockHash: (await lastSurvivingBlock.hash()).toFr() }, - jobId, + changeSetId, ); // A collection whose only fact is retractable and originates just above the fork (block 6): the prune deletes the @@ -368,15 +368,15 @@ describe('BlockSynchronizer', () => { Fr.random(), [Fr.random()], { blockNumber: lastSurvivingBlock.number + 1, blockHash: Fr.random() }, - jobId, + changeSetId, ); - await store.transactionAsync(() => factStore.commit(jobId)); + await store.transactionAsync(() => factStore.commitStaged(changeSetId)); // Both collections must be present before the prune. - expect(await factStore.getFactCollectionsByType(typeKey, jobId)).toHaveLength(2); - // Release the read job so the prune's rollback is not blocked by an in-flight job. - await factStore.discardStaged(jobId); + expect(await factStore.getFactCollectionsByType(typeKey, changeSetId)).toHaveLength(2); + // Release the read change set so the prune's rollback is not blocked by an in-flight change set. + await factStore.discardStaged(changeSetId); // Some blocks later... const anchorBlock10 = await L2Block.random(BlockNumber(10)); @@ -400,15 +400,15 @@ describe('BlockSynchronizer', () => { }); // Only the fork-point collection survives. The one whose sole fact originated above the fork is gone. - const collections = await factStore.getFactCollectionsByType(typeKey, jobId); + const collections = await factStore.getFactCollectionsByType(typeKey, changeSetId); expect(collections).toHaveLength(1); expect(collections[0].key.factCollectionId.equals(survivingCollectionId)).toBe(true); - expect(await factStore.getFactCollection(retractedCollectionKey, jobId)).toBeUndefined(); - expect((await factStore.getFactCollection(survivingCollectionKey, jobId))!.facts).toHaveLength(1); + expect(await factStore.getFactCollection(retractedCollectionKey, changeSetId)).toBeUndefined(); + expect((await factStore.getFactCollection(survivingCollectionKey, changeSetId))!.facts).toHaveLength(1); }); it('chain-pruned keeps a collection and its facts up to the fork point, deleting only those above it', async () => { - const jobId = 'fact-job'; + const changeSetId = 'fact-change-set'; // Block 5 is the fork point: the prune keeps it and abandons only blocks strictly above it. const lastSurvivingBlock = await L2Block.random(BlockNumber(5)); @@ -428,28 +428,28 @@ describe('BlockSynchronizer', () => { // A collection carrying three facts: a non-retractable one, a retractable one anchored to the fork point (block // 5), and a retractable one originating just above it (block 6). The prune must keep the collection, its // non-retractable fact, and the fork-point fact, deleting only the orphaned fact. - await factStore.recordFact(collectionKey, nonRetractableFactType, [Fr.random()], undefined, jobId); + await factStore.recordFact(collectionKey, nonRetractableFactType, [Fr.random()], undefined, changeSetId); await factStore.recordFact( collectionKey, forkPointFactType, [], { blockNumber: lastSurvivingBlock.number, blockHash: (await lastSurvivingBlock.hash()).toFr() }, - jobId, + changeSetId, ); await factStore.recordFact( collectionKey, retractedFactType, [], { blockNumber: lastSurvivingBlock.number + 1, blockHash: Fr.random() }, - jobId, + changeSetId, ); - await store.transactionAsync(() => factStore.commit(jobId)); + await store.transactionAsync(() => factStore.commitStaged(changeSetId)); // The collection and all three facts must be present before the prune. - expect(await factStore.getFactCollectionsByType(typeKey, jobId)).toHaveLength(1); - expect((await factStore.getFactCollection(collectionKey, jobId))!.facts).toHaveLength(3); - // Release the read job so the prune's rollback is not blocked by an in-flight job. - await factStore.discardStaged(jobId); + expect(await factStore.getFactCollectionsByType(typeKey, changeSetId)).toHaveLength(1); + expect((await factStore.getFactCollection(collectionKey, changeSetId))!.facts).toHaveLength(3); + // Release the read change set so the prune's rollback is not blocked by an in-flight change set. + await factStore.discardStaged(changeSetId); // Some blocks later... const anchorBlock10 = await L2Block.random(BlockNumber(10)); @@ -474,11 +474,11 @@ describe('BlockSynchronizer', () => { // The collection survives, keeping its non-retractable fact and the fork-point fact. Only the fact originating // above the fork is gone. - const collections = await factStore.getFactCollectionsByType(typeKey, jobId); + const collections = await factStore.getFactCollectionsByType(typeKey, changeSetId); expect(collections).toHaveLength(1); expect(collections[0].key.factCollectionId.equals(factCollectionId)).toBe(true); - const remainingFactTypes = (await factStore.getFactCollection(collectionKey, jobId))!.facts.map( + const remainingFactTypes = (await factStore.getFactCollection(collectionKey, changeSetId))!.facts.map( fact => fact.factTypeId, ); expect(remainingFactTypes).toHaveLength(2); @@ -499,8 +499,8 @@ describe('BlockSynchronizer', () => { const noteAt1 = await noteAt(contract, await blockId(forkBlock)); const noteAt2 = await noteAt(contract, block2); const noteAt3 = await noteAt(contract, block3); - await noteStore.addNotes([noteAt1, noteAt2, noteAt3], scope, 'note-job'); - await noteStore.commit('note-job'); + await noteStore.addNotes([noteAt1, noteAt2, noteAt3], scope, 'note-change-set'); + await noteStore.commitStaged('note-change-set'); // Anchor at block 3. const anchorBlock3 = await L2Block.random(BlockNumber(3)); @@ -531,7 +531,7 @@ describe('BlockSynchronizer', () => { expect(await noteStore.nullifiersOfNotesAtBlock(1)).toEqual([noteAt1.siloedNullifier.toString()]); const found = await noteStore.getNotes( { contractAddress: contract, scopes: [scope], status: NoteStatus.ACTIVE }, - 'read-job', + 'read-change-set', ); expect(found).toHaveLength(1); expect(found[0].siloedNullifier.equals(noteAt1.siloedNullifier)).toBe(true); diff --git a/yarn-project/pxe/src/config/index.ts b/yarn-project/pxe/src/config/index.ts index f5129a76e78d..b197fa7873ae 100644 --- a/yarn-project/pxe/src/config/index.ts +++ b/yarn-project/pxe/src/config/index.ts @@ -43,7 +43,7 @@ export interface ContractSyncConfig { /** * Whether PXE speculatively syncs contracts it predicts will follow the one requested, running them concurrently * with it instead of waiting for execution to reach them. When enabled, repeated flows sync faster, but a wrong - * prediction spends unnecessary node requests syncing contracts the job never uses. + * prediction spends unnecessary node requests syncing contracts the operation never uses. * * Experimental, off by default. */ diff --git a/yarn-project/pxe/src/contract/contract_call_graph.test.ts b/yarn-project/pxe/src/contract/contract_call_graph.test.ts index c3b41b2062f9..cf595fe68be9 100644 --- a/yarn-project/pxe/src/contract/contract_call_graph.test.ts +++ b/yarn-project/pxe/src/contract/contract_call_graph.test.ts @@ -1,6 +1,7 @@ import { FunctionSelector } from '@aztec/stdlib/abi'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import type { ChangeSetId } from '../storage/staged_write_coordinator.js'; import { ContractCallGraph, type ContractFunction, @@ -25,8 +26,8 @@ describe('ContractCallGraph', () => { expect(calleesOf(accountEntrypoint)).toEqual([]); }); - it('does not predict a callee until enough committed jobs observe the call', () => { - runJobs({ + it('does not predict a callee until enough committed change sets observe the call', () => { + runChangeSets({ count: PREDICTION_THRESHOLD - 1, calls: [ { caller: accountEntrypoint, callee: tokenTransfer }, @@ -37,8 +38,8 @@ describe('ContractCallGraph', () => { expect(calleesOf(accountEntrypoint)).toEqual([]); }); - it('predicts a callee once enough committed jobs observe the call', () => { - runJobs({ + it('predicts a callee once enough committed change sets observe the call', () => { + runChangeSets({ count: PREDICTION_THRESHOLD, calls: [ { caller: accountEntrypoint, callee: tokenTransfer }, @@ -50,7 +51,7 @@ describe('ContractCallGraph', () => { }); it('predicts only direct callees, not callees of callees', () => { - runJobs({ + runChangeSets({ count: PREDICTION_THRESHOLD, calls: [ { caller: accountEntrypoint, callee: fpcFee }, @@ -63,57 +64,57 @@ describe('ContractCallGraph', () => { }); it('keys calls per function, so a sibling function of the same contract predicts nothing', () => { - runJobs({ count: PREDICTION_THRESHOLD, calls: [{ caller: accountEntrypoint, callee: tokenTransfer }] }); + runChangeSets({ count: PREDICTION_THRESHOLD, calls: [{ caller: accountEntrypoint, callee: tokenTransfer }] }); expect(calleesOf(accountEntrypoint)).toEqual(callKeys([tokenTransfer])); expect(calleesOf(accountClaim)).toEqual([]); }); it("predicts a function's callees even when its own callers rarely call it", () => { - runJob({ - jobId: 'rare', + runChangeSet({ + changeSetId: 'rare', calls: [{ caller: accountEntrypoint, callee: tokenTransfer }], }); - runJobs({ count: PREDICTION_THRESHOLD, calls: [{ caller: tokenTransfer, callee: fpcFee }] }); + runChangeSets({ count: PREDICTION_THRESHOLD, calls: [{ caller: tokenTransfer, callee: fpcFee }] }); expect(calleesOf(accountEntrypoint)).toEqual([]); expect(calleesOf(tokenTransfer)).toEqual(callKeys([fpcFee])); }); it('ignores same-contract calls', () => { - runJobs({ count: PREDICTION_THRESHOLD, calls: [{ caller: tokenTransfer, callee: tokenBalance }] }); + runChangeSets({ count: PREDICTION_THRESHOLD, calls: [{ caller: tokenTransfer, callee: tokenBalance }] }); expect(calleesOf(tokenTransfer)).toEqual([]); }); - it('does not learn from discarded jobs', () => { - runJobs({ count: PREDICTION_THRESHOLD - 1, calls: [{ caller: accountEntrypoint, callee: tokenTransfer }] }); - callGraph.recordCall({ jobId: 'discarded', caller: accountEntrypoint, callee: tokenTransfer }); - callGraph.discardJob('discarded'); + it('does not learn from discarded change sets', () => { + runChangeSets({ count: PREDICTION_THRESHOLD - 1, calls: [{ caller: accountEntrypoint, callee: tokenTransfer }] }); + callGraph.recordCall({ changeSetId: 'discarded', caller: accountEntrypoint, callee: tokenTransfer }); + callGraph.discard('discarded'); expect(calleesOf(accountEntrypoint)).toEqual([]); }); - it('leaves confidence untouched by jobs in which the caller makes no calls', () => { - runJobs({ count: PREDICTION_THRESHOLD, calls: [{ caller: accountEntrypoint, callee: tokenTransfer }] }); + it('leaves confidence untouched by change sets in which the caller makes no calls', () => { + runChangeSets({ count: PREDICTION_THRESHOLD, calls: [{ caller: accountEntrypoint, callee: tokenTransfer }] }); - // The account calls no one in these jobs, so the confidence of the callees it did not call is unaffected. - for (const jobId of ['read1', 'read2']) { - callGraph.commitJob(jobId); + // The account calls no one in these change sets, so the confidence of the callees it did not call is unaffected. + for (const changeSetId of ['read1', 'read2']) { + callGraph.learn(changeSetId); } expect(calleesOf(accountEntrypoint)).toEqual(callKeys([tokenTransfer])); }); it('keeps predicting a callee at full confidence through every miss it tolerates', () => { - runJobs({ + runChangeSets({ count: MAX_CONFIDENCE, calls: [ { caller: accountEntrypoint, callee: tokenTransfer }, { caller: accountEntrypoint, callee: fpcFee }, ], }); - runJobs({ + runChangeSets({ count: MAX_CONFIDENCE - PREDICTION_THRESHOLD, calls: [{ caller: accountEntrypoint, callee: tokenTransfer }], }); @@ -122,14 +123,14 @@ describe('ContractCallGraph', () => { }); it('caps confidence, so a heavily called callee stops being predicted one miss past that tolerance', () => { - runJobs({ + runChangeSets({ count: MAX_CONFIDENCE * 2, calls: [ { caller: accountEntrypoint, callee: tokenTransfer }, { caller: accountEntrypoint, callee: fpcFee }, ], }); - runJobs({ + runChangeSets({ count: MAX_CONFIDENCE - PREDICTION_THRESHOLD + 1, calls: [{ caller: accountEntrypoint, callee: tokenTransfer }], }); @@ -138,7 +139,7 @@ describe('ContractCallGraph', () => { }); it('drops a callee below the threshold on a miss and predicts it again after one hit', () => { - runJobs({ + runChangeSets({ count: PREDICTION_THRESHOLD, calls: [ { caller: accountEntrypoint, callee: tokenTransfer }, @@ -147,11 +148,11 @@ describe('ContractCallGraph', () => { }); expect(calleesOf(accountEntrypoint)).toEqual(callKeys([tokenTransfer, fpcFee])); - runJob({ jobId: 'miss', calls: [{ caller: accountEntrypoint, callee: tokenTransfer }] }); + runChangeSet({ changeSetId: 'miss', calls: [{ caller: accountEntrypoint, callee: tokenTransfer }] }); expect(calleesOf(accountEntrypoint)).toEqual(callKeys([tokenTransfer])); - runJob({ - jobId: 'refresh', + runChangeSet({ + changeSetId: 'refresh', calls: [ { caller: accountEntrypoint, callee: tokenTransfer }, { caller: accountEntrypoint, callee: fpcFee }, @@ -163,7 +164,7 @@ describe('ContractCallGraph', () => { it('never records calls when disabled', () => { callGraph = new ContractCallGraph(false); - runJobs({ + runChangeSets({ count: PREDICTION_THRESHOLD, calls: [ { caller: accountEntrypoint, callee: tokenTransfer }, @@ -174,19 +175,19 @@ describe('ContractCallGraph', () => { expect(calleesOf(accountEntrypoint)).toEqual([]); }); - /** Runs `count` whole jobs, each observing the given direct calls. */ - function runJobs({ count, calls }: { count: number; calls: Call[] }) { + /** Runs `count` whole change sets, each observing the given direct calls. */ + function runChangeSets({ count, calls }: { count: number; calls: Call[] }) { for (let i = 0; i < count; i++) { - runJob({ jobId: `job${i}`, calls }); + runChangeSet({ changeSetId: `change-set-${i}`, calls }); } } - /** Runs a whole job: records each direct call and commits. */ - function runJob({ jobId, calls }: { jobId: string; calls: Call[] }) { + /** Runs a whole change set: records each direct call and learns from it as committed. */ + function runChangeSet({ changeSetId, calls }: { changeSetId: ChangeSetId; calls: Call[] }) { for (const { caller, callee } of calls) { - callGraph.recordCall({ jobId, caller, callee }); + callGraph.recordCall({ changeSetId, caller, callee }); } - callGraph.commitJob(jobId); + callGraph.learn(changeSetId); } /** Returns the direct callees predicted for the given function, as sorted `address:selector` strings. */ @@ -195,7 +196,7 @@ describe('ContractCallGraph', () => { } }); -/** A direct call observed by a job. */ +/** A direct call observed by a change set. */ type Call = { caller: ContractFunction; callee: ContractFunction }; function fn(contractIndex: number, functionIndex: number): ContractFunction { diff --git a/yarn-project/pxe/src/contract/contract_call_graph.ts b/yarn-project/pxe/src/contract/contract_call_graph.ts index b66ee96cd82e..b43a946dd08a 100644 --- a/yarn-project/pxe/src/contract/contract_call_graph.ts +++ b/yarn-project/pxe/src/contract/contract_call_graph.ts @@ -1,49 +1,59 @@ import { FunctionSelector } from '@aztec/stdlib/abi'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import type { ChangeSetId } from '../storage/staged_write_coordinator.js'; + /** Confidence a call must reach to be predicted. */ export const PREDICTION_THRESHOLD = 2; -/** Cap on a call's confidence, so a function called by many jobs is still dropped within a few missed ones. */ +/** Cap on a call's confidence, so a function called by many operations is still dropped within a few missed ones. */ export const MAX_CONFIDENCE = 5; /** - * A call graph over contract functions - who calls whom - learned from the direct calls observed in past jobs, so + * A call graph over contract functions - who calls whom - learned from the direct calls observed in past operations, so * a function's predicted callees can sync their contracts before execution reaches them. * - * A function's direct calls tend to repeat across jobs: constrained delivery calls the handshake registry, a transfer - * may call an authwit, an AMM calls its tokens. The same function does not always make the same calls, though: they - * can depend on context or storage state. A call must therefore repeat often enough to earn confidence before it is - * predicted. Calls are keyed per function, not per contract, since different functions of a contract call different - * contracts. See {@link commitJob} for how each call's confidence is learned from committed jobs. + * A function's direct calls tend to repeat across operations: constrained delivery calls the handshake registry, a + * transfer may call an authwit, an AMM calls its tokens. The same function does not always make the same calls, though: + * they can depend on context or storage state. A call must therefore repeat often enough to earn confidence before it + * is predicted. Calls are keyed per function, not per contract, since different functions of a contract call different + * contracts. See {@link learn} for how each call's confidence is learned from committed operations. * * Purely in-memory bookkeeping: the graph is lost when PXE is rebuilt (e.g. on restart). */ export class ContractCallGraph { - // job -> caller function -> functions it called directly - private readonly activeJobs: Map>> = new Map(); + // change set -> caller function -> functions it called directly + private readonly activeChangeSets: Map>> = new Map(); // caller function -> function it calls directly -> confidence score private readonly callConfidence: Map> = new Map(); constructor(private readonly enabled: boolean) {} - /** Records that `caller` directly called `callee` in the given job. */ - recordCall({ jobId, caller, callee }: { jobId: JobId; caller: ContractFunction; callee: ContractFunction }): void { + /** Records that `caller` directly called `callee` in the given change set. */ + recordCall({ + changeSetId, + caller, + callee, + }: { + changeSetId: ChangeSetId; + caller: ContractFunction; + callee: ContractFunction; + }): void { // Same-contract calls are ignored: our goal is to warm a callee's contract ahead of use, and the target of such // a call is already warm. if (!this.enabled || caller.address.equals(callee.address)) { return; } - let callsInJob = this.activeJobs.get(jobId); - if (!callsInJob) { - callsInJob = new Map(); - this.activeJobs.set(jobId, callsInJob); + let callsInChangeSet = this.activeChangeSets.get(changeSetId); + if (!callsInChangeSet) { + callsInChangeSet = new Map(); + this.activeChangeSets.set(changeSetId, callsInChangeSet); } - let callees = callsInJob.get(toCallKey(caller)); + let callees = callsInChangeSet.get(toCallKey(caller)); if (!callees) { callees = new Set(); - callsInJob.set(toCallKey(caller), callees); + callsInChangeSet.set(toCallKey(caller), callees); } callees.add(toCallKey(callee)); } @@ -57,17 +67,17 @@ export class ContractCallGraph { } /** - * Commits the job so the calls it observed are recorded and learned from. A function that called nothing keeps its - * callees untouched, so read-only uses (e.g. reading notes or events) erode nothing. + * Learns from a committed change set: the calls it observed update each caller's confidence. A function that called + * nothing keeps its callees untouched, so read-only uses (e.g. reading notes or events) erode nothing. */ - commitJob(jobId: JobId): void { - const callsInJob = this.activeJobs.get(jobId); - this.activeJobs.delete(jobId); - if (!callsInJob) { + learn(changeSetId: ChangeSetId): void { + const callsInChangeSet = this.activeChangeSets.get(changeSetId); + this.activeChangeSets.delete(changeSetId); + if (!callsInChangeSet) { return; } - for (const [caller, observed] of callsInJob) { + for (const [caller, observed] of callsInChangeSet) { const callees = this.callConfidence.get(caller) ?? new Map(); for (const [callee, confidence] of callees) { const delta = observed.has(callee) ? 1 : -1; @@ -84,9 +94,9 @@ export class ContractCallGraph { } } - /** Drops a discarded job without learning. */ - discardJob(jobId: JobId): void { - this.activeJobs.delete(jobId); + /** Drops a discarded change set without learning. */ + discard(changeSetId: ChangeSetId): void { + this.activeChangeSets.delete(changeSetId); } } @@ -98,8 +108,6 @@ export type ContractFunction = { selector: FunctionSelector; }; -type JobId = string; - /** A {@link ContractFunction} flattened to a `contractAddress:selector` string, so maps can key on it. */ export type CallKey = `0x${string}:${string}`; diff --git a/yarn-project/pxe/src/contract/contract_sync_service.test.ts b/yarn-project/pxe/src/contract/contract_sync_service.test.ts index a2a80464c14d..5fb072bfb325 100644 --- a/yarn-project/pxe/src/contract/contract_sync_service.test.ts +++ b/yarn-project/pxe/src/contract/contract_sync_service.test.ts @@ -13,6 +13,7 @@ import { mock } from 'jest-mock-extended'; import type { ContractStore } from '../storage/contract_store/contract_store.js'; import type { NoteStore } from '../storage/note_store/note_store.js'; +import { tick } from '../test_utils.js'; import { type ContractFunction, PREDICTION_THRESHOLD } from './contract_call_graph.js'; import type { ContractClassService } from './contract_class_service.js'; import { ContractSyncService, MAX_CONCURRENT_SCOPE_SYNCS, SYNC_STATE_SELECTOR } from './contract_sync_service.js'; @@ -28,7 +29,7 @@ describe('ContractSyncService', () => { const contractAddress = AztecAddress.fromBigIntUnsafe(100n); const scopeA = AztecAddress.fromBigIntUnsafe(200n); const scopeB = AztecAddress.fromBigIntUnsafe(201n); - const jobId = 'job-1'; + const changeSetId = 'change-set-1'; const anchorBlockHeader = makeBlockHeader(0); const classId = Fr.fromHexString('0xdeadbeef'); @@ -78,7 +79,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -91,7 +92,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -101,7 +102,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -114,7 +115,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA, scopeB], triggeredBy: undefined, }); @@ -127,7 +128,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -136,7 +137,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeB], triggeredBy: undefined, }); @@ -149,7 +150,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -158,7 +159,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA, scopeB], triggeredBy: undefined, }); @@ -172,7 +173,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [], triggeredBy: undefined, }); @@ -185,7 +186,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -194,7 +195,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA, scopeB], triggeredBy: undefined, }); @@ -207,7 +208,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -216,7 +217,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -230,7 +231,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -239,7 +240,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeB], triggeredBy: undefined, }); @@ -268,7 +269,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes, triggeredBy: undefined, }); @@ -282,7 +283,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }), @@ -316,7 +317,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes, triggeredBy: undefined, }); @@ -344,7 +345,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }), @@ -356,7 +357,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -372,7 +373,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }), @@ -387,17 +388,17 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); - await service.commit(jobId); + service.onOperationEnd(changeSetId, 'committed'); await service.ensureContractSynced({ contract: contractAddress, functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -413,17 +414,17 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); - await service.discardStaged(jobId); + service.onOperationEnd(changeSetId, 'discarded'); await service.ensureContractSynced({ contract: contractAddress, functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -439,14 +440,14 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA, scopeB], triggeredBy: undefined, }); expect(noteStore.getNotes).toHaveBeenCalledTimes(1); expect(noteStore.getNotes).toHaveBeenCalledWith( expect.objectContaining({ contractAddress, scopes: [scopeA, scopeB] }), - jobId, + changeSetId, ); }); @@ -456,14 +457,14 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); expect(noteStore.getNotes).toHaveBeenCalledTimes(1); expect(noteStore.getNotes).toHaveBeenCalledWith( expect.objectContaining({ contractAddress, scopes: [scopeA] }), - jobId, + changeSetId, ); noteStore.getNotes.mockClear(); @@ -472,7 +473,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA, scopeB], triggeredBy: undefined, }); @@ -480,7 +481,7 @@ describe('ContractSyncService', () => { expect(noteStore.getNotes).toHaveBeenCalledTimes(1); expect(noteStore.getNotes).toHaveBeenCalledWith( expect.objectContaining({ contractAddress, scopes: [scopeB] }), - jobId, + changeSetId, ); }); @@ -490,7 +491,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA, scopeB], triggeredBy: undefined, }); @@ -502,7 +503,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA, scopeB], triggeredBy: undefined, }); @@ -510,7 +511,7 @@ describe('ContractSyncService', () => { expect(noteStore.getNotes).toHaveBeenCalledTimes(1); expect(noteStore.getNotes).toHaveBeenCalledWith( expect.objectContaining({ contractAddress, scopes: [scopeA] }), - jobId, + changeSetId, ); }); }); @@ -524,7 +525,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA, scopeB], triggeredBy: undefined, }); @@ -537,7 +538,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA, scopeB], triggeredBy: undefined, }); @@ -551,7 +552,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA, scopeB], triggeredBy: undefined, }); @@ -564,7 +565,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA, scopeB], triggeredBy: undefined, }); @@ -578,7 +579,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA, scopeB], triggeredBy: undefined, }); @@ -590,7 +591,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -605,7 +606,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -617,7 +618,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA, scopeB], triggeredBy: undefined, }); @@ -630,7 +631,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA, scopeB], triggeredBy: undefined, }); @@ -644,7 +645,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA, scopeB], triggeredBy: undefined, }); @@ -657,7 +658,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -666,7 +667,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -679,7 +680,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -688,7 +689,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes: [scopeA], triggeredBy: undefined, }); @@ -724,13 +725,13 @@ describe('ContractSyncService', () => { ], }); - // A new job requests only contractAddress: its callee syncs, and so does its callee's callee. + // A new change set requests only contractAddress: its callee syncs, and so does its callee's callee. await service.ensureContractSynced({ contract: contractAddress, functionToInvokeAfterSync: entryFn.selector, utilityExecutor, anchorBlockHeader, - jobId: 'job-3', + changeSetId: 'change-set-3', scopes: [scopeA], triggeredBy: undefined, }); @@ -753,7 +754,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: entryFn.selector, utilityExecutor, anchorBlockHeader, - jobId: 'job-3', + changeSetId: 'change-set-3', scopes: [scopeA], triggeredBy: undefined, }); @@ -762,7 +763,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: secondFn.selector, utilityExecutor, anchorBlockHeader, - jobId: 'job-3', + changeSetId: 'change-set-3', scopes: [scopeA], triggeredBy: undefined, }); @@ -785,7 +786,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId: 'job-3', + changeSetId: 'change-set-3', scopes: [scopeA], triggeredBy: undefined, }); @@ -812,7 +813,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: entryFn.selector, utilityExecutor, anchorBlockHeader, - jobId: 'job-3', + changeSetId: 'change-set-3', scopes: [scopeA], triggeredBy: undefined, }); @@ -829,14 +830,14 @@ describe('ContractSyncService', () => { ], }); - // Each contract syncs exactly once: the job's set of already-speculated functions stops the recursion when the - // predicted graph loops back to a function it already speculated from. + // Each contract syncs exactly once: the change set's set of already-speculated functions stops the recursion when + // the predicted graph loops back to a function it already speculated from. await service.ensureContractSynced({ contract: contractAddress, functionToInvokeAfterSync: entryFn.selector, utilityExecutor, anchorBlockHeader, - jobId: 'job-3', + changeSetId: 'change-set-3', scopes: [scopeA], triggeredBy: undefined, }); @@ -856,13 +857,13 @@ describe('ContractSyncService', () => { }); // Each contract syncs exactly once: when the chain loops back to an already-syncing contract, the warm cache - // and the job's already-speculated set stop it. + // and the change set's already-speculated set stop it. await service.ensureContractSynced({ contract: contractAddress, functionToInvokeAfterSync: null, utilityExecutor, anchorBlockHeader, - jobId: 'job-3', + changeSetId: 'change-set-3', scopes: [scopeA], triggeredBy: undefined, }); @@ -902,19 +903,19 @@ describe('ContractSyncService', () => { return Promise.resolve(); }); - // The job only requests contractAddress, so nothing awaits otherContract's speculative sync. + // The change set only requests contractAddress, so nothing awaits otherContract's speculative sync. await service.ensureContractSynced({ contract: contractAddress, functionToInvokeAfterSync: entryFn.selector, utilityExecutor, anchorBlockHeader, - jobId: 'job-3', + changeSetId: 'change-set-3', scopes: [scopeA], triggeredBy: undefined, }); let settled = false; - const settlePromise = service.settle('job-3').then(() => { + const settlePromise = service.settle('change-set-3').then(() => { settled = true; }); await tick(); @@ -937,9 +938,9 @@ describe('ContractSyncService', () => { ], }); - // otherContract's speculative sync is held until the job is already settling. When released, its sync_state - // makes a nested call to lateContract, whose predicted callee (thirdContract) fires a fresh speculative sync - // mid-drain, hanging until released. + // otherContract's speculative sync is held until the change set is already settling. When released, its + // sync_state makes a nested call to lateContract, whose predicted callee (thirdContract) fires a fresh + // speculative sync mid-drain, hanging until released. const { promise: otherGate, resolve: releaseOther } = promiseWithResolvers(); const { promise: thirdSync, resolve: releaseThird } = promiseWithResolvers(); utilityExecutor.mockImplementation(async call => { @@ -950,7 +951,7 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: lateFn.selector, utilityExecutor, anchorBlockHeader, - jobId: 'job-3', + changeSetId: 'change-set-3', scopes: [scopeA], triggeredBy: otherFn, }); @@ -964,13 +965,13 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: entryFn.selector, utilityExecutor, anchorBlockHeader, - jobId: 'job-3', + changeSetId: 'change-set-3', scopes: [scopeA], triggeredBy: undefined, }); let settled = false; - const settlePromise = service.settle('job-3').then(() => { + const settlePromise = service.settle('change-set-3').then(() => { settled = true; }); releaseOther(); @@ -981,8 +982,8 @@ describe('ContractSyncService', () => { await settlePromise; }); - it('resolves immediately when the job started no syncs', async () => { - await expect(service.settle('unknown-job')).resolves.toBeUndefined(); + it('resolves immediately when the change set started no syncs', async () => { + await expect(service.settle('unknown-change-set')).resolves.toBeUndefined(); }); it('rejects when a speculative sync failed, even though no request observed the failure', async () => { @@ -995,19 +996,20 @@ describe('ContractSyncService', () => { call.to.equals(otherContract) ? Promise.reject(new Error('speculative boom')) : Promise.resolve(), ); - // The job only requests contractAddress, so the failed speculative sync of otherContract rejects no request. + // The change set only requests contractAddress, so the failed speculative sync of otherContract rejects no + // request. await service.ensureContractSynced({ contract: contractAddress, functionToInvokeAfterSync: entryFn.selector, utilityExecutor, anchorBlockHeader, - jobId: 'job-3', + changeSetId: 'change-set-3', scopes: [scopeA], triggeredBy: undefined, }); await tick(); - const settleError = await service.settle('job-3').then( + const settleError = await service.settle('change-set-3').then( () => undefined, (err: AggregateError) => err, ); @@ -1018,8 +1020,8 @@ describe('ContractSyncService', () => { }); /** - * Runs `count` committed jobs, each using the first caller as the entry and observing the given direct calls, then - * wipes the sync cache (as an anchor block change would) so the next job's syncs run for real. + * Runs `count` committed change sets, each using the first caller as the entry and observing the given direct calls, + * then wipes the sync cache (as an anchor block change would) so the next change set's syncs run for real. */ const learnDependencies = async ({ count, calls }: { count: number; calls: Call[] }) => { const sync = (id: string, { address, selector }: ContractFunction, triggeredBy: ContractFunction | undefined) => @@ -1028,17 +1030,17 @@ describe('ContractSyncService', () => { functionToInvokeAfterSync: selector, utilityExecutor, anchorBlockHeader, - jobId: id, + changeSetId: id, scopes: [scopeA], triggeredBy, }); for (let i = 0; i < count; i++) { - const id = `learn-job-${i}`; + const id = `learn-change-set-${i}`; await sync(id, calls[0].caller, undefined); for (const { caller, callee } of calls) { await sync(id, callee, caller); } - await service.commit(id); + service.onOperationEnd(id, 'committed'); } service.wipe(); utilityExecutor.mockClear(); @@ -1064,9 +1066,6 @@ describe('ContractSyncService', () => { }; const expectNoSync = () => expect(utilityExecutor).not.toHaveBeenCalled(); - - /** Yields to the macrotask queue, draining all pending microtasks (semaphore acquires/releases) in between. */ - const tick = () => new Promise(resolve => setImmediate(resolve)); }); describe('SYNC_STATE_SELECTOR', () => { @@ -1080,5 +1079,5 @@ describe('SYNC_STATE_SELECTOR', () => { }); }); -/** A direct call observed by a job. */ +/** A direct call observed by a change set. */ type Call = { caller: ContractFunction; callee: ContractFunction }; diff --git a/yarn-project/pxe/src/contract/contract_sync_service.ts b/yarn-project/pxe/src/contract/contract_sync_service.ts index 64190afb29bf..ad1863526b2d 100644 --- a/yarn-project/pxe/src/contract/contract_sync_service.ts +++ b/yarn-project/pxe/src/contract/contract_sync_service.ts @@ -8,10 +8,11 @@ import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import type { BlockHeader } from '@aztec/stdlib/tx'; import type { ContractSyncConfig } from '../config/index.js'; -import type { StagedStore } from '../job_coordinator/job_coordinator.js'; import { NoteService } from '../notes/note_service.js'; +import type { OperationContributor } from '../operation_lifecycle.js'; import type { ContractStore } from '../storage/contract_store/contract_store.js'; import type { NoteStore } from '../storage/note_store/note_store.js'; +import type { ChangeSetId } from '../storage/staged_write_coordinator.js'; import { type CallKey, ContractCallGraph, type ContractFunction, toCallKey } from './contract_call_graph.js'; import type { ContractClassService } from './contract_class_service.js'; import { syncScope } from './helpers.js'; @@ -32,19 +33,18 @@ export const SYNC_STATE_SELECTOR = FunctionSelector.fromString('0x418ef5da'); * Service for syncing the private state of contracts. It uses a cache to avoid redundant sync operations - the cache * is wiped when the anchor block changes. * - * TODO: The StagedStore naming is broken here. Figure out a better name. + * Contributes to every synced operation (see {@link OperationContributor}): its syncs write into the operation's change + * set, so it settles them before the change set is decided and releases its per-change-set state on the outcome. */ -export class ContractSyncService implements StagedStore { - readonly storeName = 'contract_sync'; - +export class ContractSyncService implements OperationContributor { // Tracks contracts synced since last wipe. The cache is keyed per individual scope address // (`contractAddress:scopeAddress`). The value is a promise that resolves when the contract is synced. private readonly syncedContracts: Map> = new Map(); - // Per-job speculation state, dropped when the job commits or discards. - private readonly speculationByJob: Map = new Map(); + // Per-change-set speculation state, dropped when the change set commits or discards. + private readonly speculationByChangeSet: Map = new Map(); - // Predicts a function's callees from the calls observed in past jobs, driving speculative sync. + // Predicts a function's callees from the calls observed in past operations, driving speculative sync. private readonly callGraph: ContractCallGraph; constructor( @@ -67,14 +67,14 @@ export class ContractSyncService implements StagedStore { functionToInvokeAfterSync, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes, triggeredBy, }: ContractSyncRequest): Promise { // A call is recorded only when both functions are known: the invoked callee and the caller that triggered it. if (functionToInvokeAfterSync && triggeredBy) { this.callGraph.recordCall({ - jobId, + changeSetId, caller: triggeredBy, callee: { address: contract, selector: functionToInvokeAfterSync }, }); @@ -85,20 +85,20 @@ export class ContractSyncService implements StagedStore { functionToInvokeAfterSync, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes, ); } /** - * Waits until every speculative sync the job fired has finished, then rejects if any failed, so the job discards - * instead of committing. This is needed because a sync that fails midway can leave partial staged writes, and a - * speculative failure might not be surfaced by any request. + * Waits until every speculative sync the change set fired has finished, then rejects if any failed, so the change set + * discards instead of committing. This is needed because a sync that fails midway can leave partial staged writes, + * and a speculative failure might not be surfaced by any request. */ - async settle(jobId: JobId): Promise { + async settle(changeSetId: ChangeSetId): Promise { // A speculative sync's execution can fire more speculative syncs mid-drain, so loop until no new promises // appear, and only escalate once nothing is still writing. - const { syncs } = this.#speculationForJob(jobId); + const { syncs } = this.#speculationForChangeSet(changeSetId); const failures: unknown[] = []; while (syncs.length > 0) { const results = await Promise.allSettled(syncs.splice(0)); @@ -107,7 +107,7 @@ export class ContractSyncService implements StagedStore { if (failures.length > 0) { throw new AggregateError( failures, - 'Speculative syncs failed, so the job must discard its staged writes instead of committing', + 'Speculative syncs failed, so the operation must discard its staged writes instead of committing', ); } } @@ -126,19 +126,16 @@ export class ContractSyncService implements StagedStore { this.syncedContracts.clear(); } - commit(jobId: JobId): Promise { - this.callGraph.commitJob(jobId); - this.speculationByJob.delete(jobId); - return Promise.resolve(); - } - - discardStaged(jobId: JobId): Promise { - // We clear the synced contracts cache here because, when the job is discarded, any associated database writes from - // the sync are also undone. - this.syncedContracts.clear(); - this.callGraph.discardJob(jobId); - this.speculationByJob.delete(jobId); - return Promise.resolve(); + onOperationEnd(changeSetId: ChangeSetId, outcome: 'committed' | 'discarded'): void { + if (outcome === 'committed') { + this.callGraph.learn(changeSetId); + } else { + // We clear the synced contracts cache here because, when the change set is discarded, any associated database + // writes from the sync are also undone. + this.syncedContracts.clear(); + this.callGraph.discard(changeSetId); + } + this.speculationByChangeSet.delete(changeSetId); } /** @@ -150,21 +147,26 @@ export class ContractSyncService implements StagedStore { * {@link #speculativelySync}). * @returns A promise that resolves once every requested scope is synced, including syncs already in flight from * concurrent calls. Speculative syncs are not included: those are only awaited by a later request that needs - * their contract, or by the job's {@link settle}. + * their contract, or by the change set's {@link settle}. */ async #startSyncIfNeeded( contractAddress: AztecAddress, functionToInvokeAfterSync: FunctionSelector | null, utilityExecutor: (call: FunctionCall, scopes: AztecAddress[]) => Promise, anchorBlockHeader: BlockHeader, - jobId: JobId, + changeSetId: ChangeSetId, scopes: AztecAddress[], ): Promise { const scopesToSync = scopes.filter(scope => !this.syncedContracts.has(toKey(contractAddress, scope))); if (scopesToSync.length > 0) { this.log.debug(`Syncing contract ${contractAddress} for ${scopesToSync.length} scope(s)`); - const syncNullifiersPromise = this.#syncNoteNullifiers(contractAddress, anchorBlockHeader, jobId, scopesToSync); + const syncNullifiersPromise = this.#syncNoteNullifiers( + contractAddress, + anchorBlockHeader, + changeSetId, + scopesToSync, + ); // We build a new semaphore for each sync call, so it rate-limits the scopes within that single call. We do // this so that if these scope syncs trigger nested syncs, the nested ones can execute without causing a deadlock. @@ -197,13 +199,20 @@ export class ContractSyncService implements StagedStore { // `sync_state` itself calls other contracts (e.g. most contract syncs query the handshake registry), so its // predicted callees start syncing alongside the contract's own syncs. - this.#speculativelySync(contractAddress, SYNC_STATE_SELECTOR, utilityExecutor, anchorBlockHeader, jobId, scopes); + this.#speculativelySync( + contractAddress, + SYNC_STATE_SELECTOR, + utilityExecutor, + anchorBlockHeader, + changeSetId, + scopes, + ); this.#speculativelySync( contractAddress, functionToInvokeAfterSync, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes, ); @@ -215,25 +224,25 @@ export class ContractSyncService implements StagedStore { * predictions are learned). Each started sync speculates from its own function in turn, so the whole predicted call * tree syncs in parallel with the contract instead of one contract at a time as execution reaches it. * - * A wrong prediction is cheap: the extra node requests are batched into round trips the job already makes, and the - * synced data simply goes unused. + * A wrong prediction is cheap: the extra node requests are batched into round trips the operation already makes, and + * the synced data simply goes unused. */ #speculativelySync( contractAddress: AztecAddress, functionToInvokeAfterSync: FunctionSelector | null, utilityExecutor: (call: FunctionCall, scopes: AztecAddress[]) => Promise, anchorBlockHeader: BlockHeader, - jobId: JobId, + changeSetId: ChangeSetId, scopes: AztecAddress[], ): void { // Without a function there is no key to predict from (the request is a direct read). if (!functionToInvokeAfterSync) { return; } - const speculation = this.#speculationForJob(jobId); + const speculation = this.#speculationForChangeSet(changeSetId); const caller: ContractFunction = { address: contractAddress, selector: functionToInvokeAfterSync }; for (const callee of this.callGraph.predictDirectCallees(caller)) { - // The job's set of already-speculated functions stops the recursion when the predicted graph has a cycle. + // The change set's set of already-speculated functions stops the recursion when the predicted graph has a cycle. if (speculation.speculated.has(toCallKey(callee))) { continue; } @@ -243,14 +252,14 @@ export class ContractSyncService implements StagedStore { callee.selector, utilityExecutor, anchorBlockHeader, - jobId, + changeSetId, scopes, ); speculation.syncs.push(syncPromise); - // `settle` only escalates these failures at the end of the job: catch here so one does not become an unhandled - // rejection before then, and log it. + // `settle` only escalates these failures at the end of the change set: catch here so one does not become an + // unhandled rejection before then, and log it. syncPromise.catch(err => { - this.log.warn(`Speculative sync of ${callee.address} failed`, { jobId, error: err?.message }); + this.log.warn(`Speculative sync of ${callee.address} failed`, { changeSetId, error: err?.message }); }); } } @@ -259,7 +268,7 @@ export class ContractSyncService implements StagedStore { async #syncNoteNullifiers( contractAddress: AztecAddress, anchorBlockHeader: BlockHeader, - jobId: JobId, + changeSetId: ChangeSetId, scopes: AztecAddress[], ): Promise { // Protocol contracts don't have private state to sync @@ -268,15 +277,15 @@ export class ContractSyncService implements StagedStore { } // This runs in parallel with per-scope sync (which also writes to the note store). That's safe because // the note store handles concurrent operations. - const noteService = new NoteService(this.noteStore, this.aztecNode, anchorBlockHeader, jobId); + const noteService = new NoteService(this.noteStore, this.aztecNode, anchorBlockHeader, changeSetId); await noteService.syncNoteNullifiers(contractAddress, scopes); } - #speculationForJob(jobId: JobId): JobSpeculation { - let speculation = this.speculationByJob.get(jobId); + #speculationForChangeSet(changeSetId: ChangeSetId): ChangeSetSpeculation { + let speculation = this.speculationByChangeSet.get(changeSetId); if (!speculation) { speculation = { speculated: new Set(), syncs: [] }; - this.speculationByJob.set(jobId, speculation); + this.speculationByChangeSet.set(changeSetId, speculation); } return speculation; } @@ -303,24 +312,22 @@ type ContractSyncRequest = { utilityExecutor: (call: FunctionCall, scopes: AztecAddress[]) => Promise; /** The anchor block to sync at. */ anchorBlockHeader: BlockHeader; - /** The job requesting the sync. */ - jobId: JobId; + /** The change set requesting the sync. */ + changeSetId: ChangeSetId; /** Access scopes to pass through to the utility executor (affects whose account's private state is discovered). */ scopes: AztecAddress[]; /** - * The function whose execution triggered this sync request, or undefined when the request is a job's top-level use - * (an entry call or a direct read) rather than a nested call. + * The function whose execution triggered this sync request, or undefined when the request is a change set's top-level + * use (an entry call or a direct read) rather than a nested call. */ triggeredBy: ContractFunction | undefined; }; -type JobId = string; - -/** A job's speculation state. */ -type JobSpeculation = { +/** A change set's speculation state. */ +type ChangeSetSpeculation = { /** Functions prediction already ran for, so the recursion stops on cycles in the predicted graph. */ speculated: Set; - /** Every sync fired by prediction, awaited by {@link settle} before the job commits or discards. */ + /** Every sync fired by prediction, awaited by {@link settle} before the change set commits or discards. */ syncs: Promise[]; }; diff --git a/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts b/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts index cd3c179e3fda..7f412f1441e7 100644 --- a/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts +++ b/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts @@ -105,6 +105,7 @@ import { FactService } from '../storage/fact_store/index.js'; import type { FactStore } from '../storage/fact_store/index.js'; import type { NoteStore } from '../storage/note_store/note_store.js'; import type { PrivateEventStore } from '../storage/private_event_store/private_event_store.js'; +import type { ChangeSetId } from '../storage/staged_write_coordinator.js'; import type { RecipientTaggingStore } from '../storage/tagging_store/recipient_tagging_store.js'; import type { SenderTaggingStore } from '../storage/tagging_store/sender_tagging_store.js'; import type { TaggingSecretSourcesStore } from '../storage/tagging_store/tagging_secret_sources_store.js'; @@ -128,8 +129,8 @@ export type ContractSimulatorRunOpts = { senderForTags?: AztecAddress; /** The accounts whose notes we can access in this call. */ scopes: AztecAddress[]; - /** The job ID for staged writes. */ - jobId: string; + /** The change set ID for staged writes. */ + changeSetId: ChangeSetId; }; /** Args for ContractFunctionSimulator constructor. */ @@ -212,7 +213,7 @@ export class ContractFunctionSimulator { anchorBlockHeader, senderForTags, scopes, - jobId, + changeSetId, }: ContractSimulatorRunOpts, ): Promise { const simulatorSetupTimer = new Timer(); @@ -267,7 +268,7 @@ export class ContractFunctionSimulator { callContext, anchorBlockHeader, utilityExecutor: async (call, execScopes) => { - await this.runUtility(call, [], anchorBlockHeader, execScopes, jobId); + await this.runUtility(call, [], anchorBlockHeader, execScopes, changeSetId); }, authWitnesses: request.authWitnesses, capsules: request.capsules, @@ -287,7 +288,7 @@ export class ContractFunctionSimulator { privateEventStore: this.privateEventStore, txResolver: this.txResolver, contractSyncService: this.contractSyncService, - jobId, + changeSetId, totalPublicCalldataCount: 0, sideEffectCounter: startSideEffectCounter, scopes, @@ -355,7 +356,7 @@ export class ContractFunctionSimulator { authwits: AuthWitness[], anchorBlockHeader: BlockHeader, scopes: AztecAddress[], - jobId: string, + changeSetId: ChangeSetId, ): Promise<{ result: Fr[]; offchainEffects: OffchainEffect[] }> { const anchoredContractData = new AnchoredContractData( this.contractStore, @@ -377,7 +378,7 @@ export class ContractFunctionSimulator { } const utilityExecutor = async (syncCall: FunctionCall, execScopes: AztecAddress[]) => { - await this.runUtility(syncCall, [], anchorBlockHeader, execScopes, jobId); + await this.runUtility(syncCall, [], anchorBlockHeader, execScopes, changeSetId); }; const oracle = new UtilityExecutionOracle({ @@ -403,7 +404,7 @@ export class ContractFunctionSimulator { txResolver: this.txResolver, contractSyncService: this.contractSyncService, l2TipsStore: this.l2TipsStore, - jobId, + changeSetId, scopes, simulator: this.simulator, hooks: this.hooks, diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts index 196be1813ccd..3a5cf03e5efc 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts @@ -168,7 +168,7 @@ describe('Oracle Version Check test suite', () => { msgSender, anchorBlockHeader, senderForTags, - jobId: 'test', + changeSetId: 'test', scopes: [], }); @@ -226,7 +226,7 @@ describe('Oracle Version Check test suite', () => { privateEventStore, txResolver, contractSyncService, - jobId: 'test', + changeSetId: 'test', scopes: [], l2TipsStore, simulator, diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts index 0eab86fe71d5..77ecb6bade71 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts @@ -139,7 +139,7 @@ describe('Private Execution test suite', () => { let recipientIvskM: GrumpkinScalar; let senderForTagsIvskM: GrumpkinScalar; - const TEST_JOB_ID = 'test-job-id'; + const TEST_CHANGE_SET_ID = 'test-change-set-id'; const treeNameToId: { [name: string]: MerkleTreeId } = { noteHash: MerkleTreeId.NOTE_HASH_TREE, @@ -213,7 +213,7 @@ describe('Private Execution test suite', () => { msgSender, anchorBlockHeader, senderForTags, - jobId: TEST_JOB_ID, + changeSetId: TEST_CHANGE_SET_ID, scopes: [owner, senderForTags], }); }; diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts index 5c36893a0895..4cb7383cc9e6 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.test.ts @@ -326,7 +326,7 @@ describe('PrivateExecutionOracle', () => { txResolver: mock(), contractSyncService: mock(), l2TipsStore: mock(), - jobId: 'test', + changeSetId: 'test', scopes: [], simulator: new WASMSimulator(), transientArrayService: new TransientArrayService(), diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts index d68d95f5fe1b..4df9e5025ff1 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts @@ -386,10 +386,10 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP this.senderTaggingStore, finalized.block.number, anchor, - this.jobId, + this.changeSetId, ); - const lastUsedIndex = await this.senderTaggingStore.getLastUsedIndex(secret, this.jobId); + const lastUsedIndex = await this.senderTaggingStore.getLastUsedIndex(secret, this.changeSetId); // If lastUsedIndex is undefined, we've never used this secret, so start from 0 // Otherwise, the next index to use is one past the last used index return lastUsedIndex === undefined ? 0 : lastUsedIndex + 1; @@ -480,7 +480,7 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP const pendingNullifiers = this.noteCache.getNullifiers(this.callContext.contractAddress); - const noteService = new NoteService(this.noteStore, this.aztecNode, this.anchorBlockHeader, this.jobId); + const noteService = new NoteService(this.noteStore, this.aztecNode, this.anchorBlockHeader, this.changeSetId); const dbNotes = await noteService.getNotes( this.callContext.contractAddress, owner.value, @@ -666,7 +666,7 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP functionToInvokeAfterSync: functionSelector, utilityExecutor: this.utilityExecutor, anchorBlockHeader: this.anchorBlockHeader, - jobId: this.jobId, + changeSetId: this.changeSetId, scopes: this.scopes, triggeredBy: { address: this.callContext.contractAddress, selector: this.callContext.functionSelector }, }); @@ -711,7 +711,7 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP privateEventStore: this.privateEventStore, txResolver: this.txResolver, contractSyncService: this.contractSyncService, - jobId: this.jobId, + changeSetId: this.changeSetId, totalPublicCalldataCount: this.totalPublicCalldataCount, sideEffectCounter, log: this.logger, diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts index f22cd6106787..6e8500d5ea21 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts @@ -263,7 +263,7 @@ describe('Utility Execution test suite', () => { [], anchorBlockHeader, [], - 'test-job-id', + 'test-change-set-id', ); expect(result).toEqual([new Fr(9)]); @@ -351,7 +351,7 @@ describe('Utility Execution test suite', () => { const results = await Promise.all( Array.from({ length: N }, (_, i) => - acirSimulator.runUtility(execRequest, [], anchorBlockHeader, [], `reentrance-job-${i}`), + acirSimulator.runUtility(execRequest, [], anchorBlockHeader, [], `reentrance-change set-${i}`), ), ); @@ -401,15 +401,21 @@ describe('Utility Execution test suite', () => { utilityExecutionOracle.deleteCapsule(contractAddress, slot, scope); await utilityExecutionOracle.copyCapsule(contractAddress, srcSlot, dstSlot, 1, scope); - expect(capsuleStore.setCapsule).toHaveBeenCalledWith(contractAddress, slot, capsule, 'test-job-id', scope); - expect(capsuleStore.getCapsule).toHaveBeenCalledWith(contractAddress, slot, 'test-job-id', scope); - expect(capsuleStore.deleteCapsule).toHaveBeenCalledWith(contractAddress, slot, 'test-job-id', scope); + expect(capsuleStore.setCapsule).toHaveBeenCalledWith( + contractAddress, + slot, + capsule, + 'test-change-set-id', + scope, + ); + expect(capsuleStore.getCapsule).toHaveBeenCalledWith(contractAddress, slot, 'test-change-set-id', scope); + expect(capsuleStore.deleteCapsule).toHaveBeenCalledWith(contractAddress, slot, 'test-change-set-id', scope); expect(capsuleStore.copyCapsule).toHaveBeenCalledWith( contractAddress, srcSlot, dstSlot, 1, - 'test-job-id', + 'test-change-set-id', scope, ); }); @@ -1052,7 +1058,7 @@ describe('Utility Execution test suite', () => { privateEventStore, txResolver, contractSyncService, - jobId: 'test-job-id', + changeSetId: 'test-change-set-id', scopes, l2TipsStore, simulator, diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts index 9925839d4ac5..29b9fae6783b 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts @@ -59,6 +59,7 @@ import { FactCollectionKey, FactCollectionTypeKey, anchoredTipBlockNumbers } fro import type { FactService, OriginBlock } from '../../storage/fact_store/index.js'; import type { NoteStore } from '../../storage/note_store/note_store.js'; import type { PrivateEventStore } from '../../storage/private_event_store/private_event_store.js'; +import type { ChangeSetId } from '../../storage/staged_write_coordinator.js'; import type { RecipientTaggingStore } from '../../storage/tagging_store/recipient_tagging_store.js'; import type { TaggingSecretSourcesStore } from '../../storage/tagging_store/tagging_secret_sources_store.js'; import type { AnchoredContractData } from '../anchored_contract_data.js'; @@ -103,7 +104,7 @@ export type UtilityExecutionOracleArgs = { txResolver: TxResolverService; contractSyncService: ContractSyncService; l2TipsStore: L2TipsProvider; - jobId: string; + changeSetId: ChangeSetId; log?: ReturnType; scopes: AztecAddress[]; simulator: CircuitSimulator; @@ -155,7 +156,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra protected readonly txResolver: TxResolverService; protected readonly contractSyncService: ContractSyncService; protected readonly l2TipsStore: L2TipsProvider; - protected readonly jobId: string; + protected readonly changeSetId: ChangeSetId; protected logger: ReturnType; protected readonly scopes: AztecAddress[]; protected readonly simulator: CircuitSimulator; @@ -180,7 +181,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra this.txResolver = args.txResolver; this.contractSyncService = args.contractSyncService; this.l2TipsStore = args.l2TipsStore; - this.jobId = args.jobId; + this.changeSetId = args.changeSetId; this.logger = args.log ?? createLogger('simulator:client_view_context'); this.scopes = args.scopes; this.simulator = args.simulator; @@ -473,7 +474,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra maxNotes: number, packedHintedNoteLength: number, ): Promise> { - const noteService = new NoteService(this.noteStore, this.aztecNode, this.anchorBlockHeader, this.jobId); + const noteService = new NoteService(this.noteStore, this.aztecNode, this.anchorBlockHeader, this.changeSetId); const dbNotes = await noteService.getNotes(this.contractAddress, owner.value, storageSlot, status, this.scopes); const picked = pickNotes(dbNotes, { @@ -562,12 +563,12 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra async #getContractLogger(): Promise { if (!this.contractLogger) { // Purpose of instanceId is to distinguish logs from different instances of the same component. It makes sense - // to re-use jobId as instanceId here as executions of different PXE jobs are isolated. + // to re-use changeSetId as instanceId here as executions of different PXE operations are isolated. this.contractLogger = await createContractLogger( this.contractAddress, addr => this.anchoredContractData.getDebugContractName(addr), 'user', - { instanceId: this.jobId }, + { instanceId: this.changeSetId }, ); } return this.contractLogger; @@ -579,12 +580,12 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra async #getAztecnrLogger(): Promise { if (!this.aztecnrLogger) { // Purpose of instanceId is to distinguish logs from different instances of the same component. It makes sense - // to re-use jobId as instanceId here as executions of different PXE jobs are isolated. + // to re-use changeSetId as instanceId here as executions of different PXE operations are isolated. this.aztecnrLogger = await createContractLogger( this.contractAddress, addr => this.anchoredContractData.getDebugContractName(addr), 'aztecnr', - { instanceId: this.jobId }, + { instanceId: this.changeSetId }, ); } return this.aztecnrLogger; @@ -628,7 +629,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra this.taggingSecretSourcesStore, this.addressStore, this.scopes, - this.jobId, + this.changeSetId, this.logger.getBindings(), ); } @@ -655,8 +656,8 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra ...eventValidationRequests.map(r => r.txHash), ]); - const noteService = new NoteService(this.noteStore, this.aztecNode, this.anchorBlockHeader, this.jobId); - const eventService = new EventService(this.anchorBlockHeader, this.aztecNode, this.privateEventStore, this.jobId); + const noteService = new NoteService(this.noteStore, this.aztecNode, this.anchorBlockHeader, this.changeSetId); + const eventService = new EventService(this.anchorBlockHeader, this.privateEventStore, this.changeSetId); await allToCompletion([ noteService.validateAndStoreNotes(noteValidationRequests, scope, validationTxData), @@ -726,7 +727,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra public setCapsule(contractAddress: AztecAddress, slot: Fr, capsule: Fr[], scope: AztecAddress): void { this.#assertOwnContract(contractAddress); - this.capsuleService.setCapsule(contractAddress, slot, capsule, this.jobId, scope); + this.capsuleService.setCapsule(contractAddress, slot, capsule, this.changeSetId, scope); } public async getCapsule( @@ -736,13 +737,13 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra scope: AztecAddress, ): Promise> { this.#assertOwnContract(contractAddress); - const values = await this.capsuleService.getCapsule(contractAddress, slot, this.jobId, scope, this.capsules); + const values = await this.capsuleService.getCapsule(contractAddress, slot, this.changeSetId, scope, this.capsules); return values ? Option.some(values) : Option.none({ length: tSize }); } public deleteCapsule(contractAddress: AztecAddress, slot: Fr, scope: AztecAddress): void { this.#assertOwnContract(contractAddress); - this.capsuleService.deleteCapsule(contractAddress, slot, this.jobId, scope); + this.capsuleService.deleteCapsule(contractAddress, slot, this.changeSetId, scope); } public copyCapsule( @@ -753,7 +754,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra scope: AztecAddress, ): Promise { this.#assertOwnContract(contractAddress); - return this.capsuleService.copyCapsule(contractAddress, srcSlot, dstSlot, numEntries, this.jobId, scope); + return this.capsuleService.copyCapsule(contractAddress, srcSlot, dstSlot, numEntries, this.changeSetId, scope); } /** @@ -784,7 +785,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra factTypeId, payload.readAll(this.ephemeralArrayService), originBlock.isSome() ? originBlock.value : undefined, - this.jobId, + this.changeSetId, ); } @@ -800,7 +801,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra this.#assertOwnContract(contractAddress); return this.factService.deleteFactCollection( new FactCollectionKey(contractAddress, scope, factCollectionTypeId, factCollectionId), - this.jobId, + this.changeSetId, ); } @@ -818,7 +819,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra const collection = await this.factService.getFactCollection( new FactCollectionKey(contractAddress, scope, factCollectionTypeId, factCollectionId), tips, - this.jobId, + this.changeSetId, ); return collection ? Option.some( @@ -845,7 +846,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra const collections = await this.factService.getFactCollectionsByType( new FactCollectionTypeKey(contractAddress, scope, factCollectionTypeId), tips, - this.jobId, + this.changeSetId, ); return EphemeralArray.fromValues( this.ephemeralArrayService, @@ -1061,7 +1062,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra functionToInvokeAfterSync: functionSelector, utilityExecutor: this.utilityExecutor, anchorBlockHeader: this.anchorBlockHeader, - jobId: this.jobId, + changeSetId: this.changeSetId, scopes: this.scopes, triggeredBy: { address: this.contractAddress, selector: this.callContext.functionSelector }, }); @@ -1094,7 +1095,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra txResolver: this.txResolver, contractSyncService: this.contractSyncService, l2TipsStore: this.l2TipsStore, - jobId: this.jobId, + changeSetId: this.changeSetId, scopes: this.scopes, simulator: this.simulator, hooks: this.hooks, diff --git a/yarn-project/pxe/src/debug/pxe_debug_utils.ts b/yarn-project/pxe/src/debug/pxe_debug_utils.ts index f04050e8969e..2dc1ecadcb5f 100644 --- a/yarn-project/pxe/src/debug/pxe_debug_utils.ts +++ b/yarn-project/pxe/src/debug/pxe_debug_utils.ts @@ -7,15 +7,16 @@ import type { BlockHeader, ContractOverrides } from '@aztec/stdlib/tx'; import type { ContractSyncService } from '../contract/contract_sync_service.js'; import type { ContractFunctionSimulator } from '../contract_function_simulator/contract_function_simulator.js'; import type { NotesFilter } from '../notes_filter.js'; -import type { SyncedJobContext } from '../pxe.js'; +import type { SyncedOperationContext } from '../operation_queue.js'; import type { NoteStore } from '../storage/note_store/note_store.js'; +import type { ChangeSetId } from '../storage/staged_write_coordinator.js'; /** * Methods provided by this class might help debugging but must not be used in production. * No backwards compatibility or API stability should be expected. Use at your own risk. */ export class PXEDebugUtils { - #syncedJob!: (job: (ctx: SyncedJobContext) => Promise) => Promise; + #runSyncedOperation!: (operation: (ctx: SyncedOperationContext) => Promise) => Promise; #getSimulatorForTx!: (overrides?: { contracts?: ContractOverrides }) => ContractFunctionSimulator; #executeUtility!: ( contractFunctionSimulator: ContractFunctionSimulator, @@ -23,7 +24,7 @@ export class PXEDebugUtils { authWitnesses: AuthWitness[] | undefined, scopes: AztecAddress[], anchorBlockHeader: BlockHeader, - jobId: string, + changeSetId: ChangeSetId, ) => Promise; constructor( @@ -33,7 +34,7 @@ export class PXEDebugUtils { /** Not injected through constructor since they're are co-dependant */ public setPXEHelpers( - syncedJob: (job: (ctx: SyncedJobContext) => Promise) => Promise, + runSyncedOperation: (operation: (ctx: SyncedOperationContext) => Promise) => Promise, getSimulatorForTx: (overrides?: { contracts?: ContractOverrides }) => ContractFunctionSimulator, executeUtility: ( contractFunctionSimulator: ContractFunctionSimulator, @@ -41,10 +42,10 @@ export class PXEDebugUtils { authWitnesses: AuthWitness[] | undefined, scopes: AztecAddress[], anchorBlockHeader: BlockHeader, - jobId: string, + changeSetId: ChangeSetId, ) => Promise, ) { - this.#syncedJob = syncedJob; + this.#runSyncedOperation = runSyncedOperation; this.#getSimulatorForTx = getSimulatorForTx; this.#executeUtility = executeUtility; } @@ -61,7 +62,7 @@ export class PXEDebugUtils { * @returns The requested notes. */ public getNotes(filter: NotesFilter): Promise { - return this.#syncedJob(async ({ jobId, anchorBlockHeader }) => { + return this.#runSyncedOperation(async ({ changeSetId, anchorBlockHeader }) => { const contractFunctionSimulator = this.#getSimulatorForTx(); await this.contractSyncService.ensureContractSynced({ @@ -74,15 +75,15 @@ export class PXEDebugUtils { [], execScopes, anchorBlockHeader, - jobId, + changeSetId, ), anchorBlockHeader, - jobId, + changeSetId, scopes: filter.scopes, triggeredBy: undefined, }); - return this.noteStore.getNotes(filter, jobId); + return this.noteStore.getNotes(filter, changeSetId); }); } } diff --git a/yarn-project/pxe/src/entrypoints/server/index.ts b/yarn-project/pxe/src/entrypoints/server/index.ts index aaefc18a5a8b..44f08b83ed48 100644 --- a/yarn-project/pxe/src/entrypoints/server/index.ts +++ b/yarn-project/pxe/src/entrypoints/server/index.ts @@ -10,7 +10,8 @@ export * from './store.js'; export { NoteService } from '../../notes/note_service.js'; export { ORACLE_VERSION_MAJOR, ORACLE_VERSION_MINOR } from '../../oracle_version.js'; export { type PXECreationOptions } from '../pxe_creation_options.js'; -export { JobCoordinator } from '../../job_coordinator/job_coordinator.js'; +export { type ChangeSetId, StagedWriteCoordinator } from '../../storage/staged_write_coordinator.js'; +export { runOperation, type OperationContributor } from '../../operation_lifecycle.js'; export { ContractSyncService } from '../../contract/contract_sync_service.js'; export { ContractClassService } from '../../contract/contract_class_service.js'; export { AnchoredContractData } from '../../contract_function_simulator/anchored_contract_data.js'; diff --git a/yarn-project/pxe/src/events/event_service.test.ts b/yarn-project/pxe/src/events/event_service.test.ts index d65303b75cd8..5ed50e2e9b39 100644 --- a/yarn-project/pxe/src/events/event_service.test.ts +++ b/yarn-project/pxe/src/events/event_service.test.ts @@ -6,7 +6,6 @@ import { EventSelector } from '@aztec/stdlib/abi'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { BlockHash } from '@aztec/stdlib/block'; import { computePrivateEventCommitment, siloNullifier } from '@aztec/stdlib/hash'; -import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import { makeBlockHeader } from '@aztec/stdlib/testing'; import { TxEffect } from '@aztec/stdlib/tx'; @@ -29,7 +28,6 @@ describe('validateAndStoreEvents', () => { let recipient: AztecAddress; let privateEventStore: PrivateEventStore; - let aztecNode: ReturnType>; let logger: ReturnType>; let eventService: EventService; @@ -40,8 +38,6 @@ describe('validateAndStoreEvents', () => { const store = await openTmpStore('test'); privateEventStore = new PrivateEventStore(store); - aztecNode = mock(); - contractAddress = await AztecAddress.random(); recipient = await AztecAddress.random(); @@ -73,7 +69,7 @@ describe('validateAndStoreEvents', () => { const anchorBlockHeader = makeBlockHeader(0, { blockNumber }); logger = mock(); - eventService = new EventService(anchorBlockHeader, aztecNode, privateEventStore, 'test', logger); + eventService = new EventService(anchorBlockHeader, privateEventStore, 'test', logger); }); async function runStoreEvent( @@ -95,7 +91,7 @@ describe('validateAndStoreEvents', () => { const map = overrides.validationTxDataMap ?? defaultValidationTxDataMap(); await eventService.validateAndStoreEvents([request], recipient, map); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); } it('should throw when tx does not exist or has no effects', async () => { diff --git a/yarn-project/pxe/src/events/event_service.ts b/yarn-project/pxe/src/events/event_service.ts index f427c47e1430..5620ff2088b9 100644 --- a/yarn-project/pxe/src/events/event_service.ts +++ b/yarn-project/pxe/src/events/event_service.ts @@ -4,18 +4,17 @@ import { allToCompletion } from '@aztec/foundation/promise'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { InBlock } from '@aztec/stdlib/block'; import { computePrivateEventCommitment, siloNullifier } from '@aztec/stdlib/hash'; -import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import type { BlockHeader } from '@aztec/stdlib/tx'; import type { EventValidationRequest } from '../contract_function_simulator/noir-structs/event_validation_request.js'; import { PrivateEventStore } from '../storage/private_event_store/private_event_store.js'; +import type { ChangeSetId } from '../storage/staged_write_coordinator.js'; export class EventService { constructor( private readonly anchorBlockHeader: BlockHeader, - private readonly aztecNode: AztecNode, private readonly privateEventStore: PrivateEventStore, - private readonly jobId: string, + private readonly changeSetId: ChangeSetId, private readonly log = createLogger('pxe:event_service'), ) {} @@ -118,7 +117,7 @@ export class EventService { txIndexInBlock: txData.txIndexInBlock, eventIndexInTx, }, - this.jobId, + this.changeSetId, ); } } diff --git a/yarn-project/pxe/src/job_coordinator/job_coordinator.test.ts b/yarn-project/pxe/src/job_coordinator/job_coordinator.test.ts deleted file mode 100644 index 697fa91952c0..000000000000 --- a/yarn-project/pxe/src/job_coordinator/job_coordinator.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { promiseWithResolvers } from '@aztec/foundation/promise'; -import type { AztecAsyncKVStore } from '@aztec/kv-store'; -import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; - -import { jest } from '@jest/globals'; - -import { JobCoordinator, type StagedStore } from './job_coordinator.js'; - -describe('JobCoordinator', () => { - let store: AztecAsyncKVStore; - let coordinator: JobCoordinator; - - beforeEach(async () => { - store = await openTmpStore('job_coordinator_test'); - coordinator = new JobCoordinator(store); - }); - - describe('beginJob', () => { - it('creates a new job id', () => { - const jobId = coordinator.beginJob(); - - expect(typeof jobId).toBe('string'); - expect(jobId.length).toBeGreaterThan(0); - }); - - // Note: we could eventually be relax this if we want more concurrency, - // but it's good to start with this guardrail - it('throws if job already in progress', () => { - coordinator.beginJob(); - expect(() => coordinator.beginJob()).toThrow(/already in progress/); - }); - - it('tracks job in progress', () => { - coordinator.beginJob(); - expect(coordinator.hasJobInProgress()).toBe(true); - }); - }); - - describe('commitJob', () => { - it('clears job marker on commit', async () => { - const jobId = coordinator.beginJob(); - await coordinator.commitJob(jobId); - expect(coordinator.hasJobInProgress()).toBe(false); - }); - - it('throws if no matching job in progress', async () => { - const jobId = coordinator.beginJob(); - await coordinator.commitJob(jobId); - await expect(coordinator.commitJob(jobId)).rejects.toThrow(/no matching job/); - }); - - it('calls commit on registered stores', async () => { - const commitMock = jest.fn<() => Promise>().mockResolvedValue(undefined); - const discardStagedMock = jest.fn<() => Promise>().mockResolvedValue(undefined); - const mockStore: StagedStore = { - storeName: 'mock_store', - commit: commitMock, - discardStaged: discardStagedMock, - }; - - coordinator.registerStore(mockStore); - - const jobId = coordinator.beginJob(); - - await coordinator.commitJob(jobId); - - expect(commitMock).toHaveBeenCalledWith(jobId); - }); - - it('waits for stores to settle before committing any of them', async () => { - const { promise: settling, resolve: finishSettling } = promiseWithResolvers(); - const commitMock = jest.fn<() => Promise>().mockResolvedValue(undefined); - coordinator.registerStore({ - storeName: 'settling_store', - commit: () => Promise.resolve(), - discardStaged: () => Promise.resolve(), - settle: () => settling, - }); - coordinator.registerStore({ - storeName: 'other_store', - commit: commitMock, - discardStaged: () => Promise.resolve(), - }); - - const jobId = coordinator.beginJob(); - const commitPromise = coordinator.commitJob(jobId); - await tick(); - expect(commitMock).not.toHaveBeenCalled(); - - finishSettling(); - await commitPromise; - expect(commitMock).toHaveBeenCalledWith(jobId); - }); - - it('propagates a settle rejection without committing any store', async () => { - const commitMock = jest.fn<() => Promise>().mockResolvedValue(undefined); - coordinator.registerStore({ - storeName: 'failing_store', - commit: () => Promise.resolve(), - discardStaged: () => Promise.resolve(), - settle: () => Promise.reject(new Error('settle failed')), - }); - coordinator.registerStore({ - storeName: 'other_store', - commit: commitMock, - discardStaged: () => Promise.resolve(), - }); - - const jobId = coordinator.beginJob(); - await expect(coordinator.commitJob(jobId)).rejects.toThrow('settle failed'); - expect(commitMock).not.toHaveBeenCalled(); - }); - }); - - describe('abortJob', () => { - it('clears job marker on abort', async () => { - const jobId = coordinator.beginJob(); - - await coordinator.abortJob(jobId); - - expect(coordinator.hasJobInProgress()).toBe(false); - }); - - it('calls discardStaged on all registered stores', async () => { - const commitMock = jest.fn<() => Promise>().mockResolvedValue(undefined); - const discardStagedMock = jest.fn<() => Promise>().mockResolvedValue(undefined); - const mockStore: StagedStore = { - storeName: 'mock_store', - commit: commitMock, - discardStaged: discardStagedMock, - }; - - coordinator.registerStore(mockStore); - - const jobId = coordinator.beginJob(); - - await coordinator.abortJob(jobId); - - expect(discardStagedMock).toHaveBeenCalledWith(jobId); - }); - - it('waits for stores to settle before discarding any of them', async () => { - const { promise: settling, resolve: finishSettling } = promiseWithResolvers(); - const discardStagedMock = jest.fn<() => Promise>().mockResolvedValue(undefined); - coordinator.registerStore({ - storeName: 'settling_store', - commit: () => Promise.resolve(), - discardStaged: () => Promise.resolve(), - settle: () => settling, - }); - coordinator.registerStore({ - storeName: 'other_store', - commit: () => Promise.resolve(), - discardStaged: discardStagedMock, - }); - - const jobId = coordinator.beginJob(); - const abortPromise = coordinator.abortJob(jobId); - await tick(); - expect(discardStagedMock).not.toHaveBeenCalled(); - - finishSettling(); - await abortPromise; - expect(discardStagedMock).toHaveBeenCalledWith(jobId); - }); - - it('discards all stores even when a settle rejects', async () => { - const failingDiscardMock = jest.fn<() => Promise>().mockResolvedValue(undefined); - const otherDiscardMock = jest.fn<() => Promise>().mockResolvedValue(undefined); - coordinator.registerStore({ - storeName: 'failing_store', - commit: () => Promise.resolve(), - discardStaged: failingDiscardMock, - settle: () => Promise.reject(new Error('settle failed')), - }); - coordinator.registerStore({ - storeName: 'other_store', - commit: () => Promise.resolve(), - discardStaged: otherDiscardMock, - }); - - const jobId = coordinator.beginJob(); - await coordinator.abortJob(jobId); - - expect(failingDiscardMock).toHaveBeenCalledWith(jobId); - expect(otherDiscardMock).toHaveBeenCalledWith(jobId); - }); - }); - - describe('registerStore', () => { - it('throws on duplicate registration', () => { - const commitMock = jest.fn<() => Promise>().mockResolvedValue(undefined); - const discardStagedMock = jest.fn<() => Promise>().mockResolvedValue(undefined); - const mockStore: StagedStore = { - storeName: 'mock_store', - commit: commitMock, - discardStaged: discardStagedMock, - }; - - coordinator.registerStore(mockStore); - - expect(() => coordinator.registerStore(mockStore)).toThrow(/already registered/); - }); - }); - - /** Yields to the macrotask queue, draining all pending microtasks in between. */ - const tick = () => new Promise(resolve => setImmediate(resolve)); -}); diff --git a/yarn-project/pxe/src/job_coordinator/job_coordinator.ts b/yarn-project/pxe/src/job_coordinator/job_coordinator.ts deleted file mode 100644 index c5a0769a2081..000000000000 --- a/yarn-project/pxe/src/job_coordinator/job_coordinator.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { randomBytes } from '@aztec/foundation/crypto/random'; -import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; -import { allToCompletion } from '@aztec/foundation/promise'; -import type { AztecAsyncKVStore } from '@aztec/kv-store'; - -/** - * Interface that stores must implement to support staged writes. - */ -export interface StagedStore { - /** Unique name identifying this store (used for tracking staged stores from JobCoordinator) */ - readonly storeName: string; - - /** - * Commits staged data to main storage. - * Should be called within a transaction for atomicity. - * - * @param jobId - The job identifier - */ - commit(jobId: string): Promise; - - /** - * Discards staged data without committing. - * Called on abort. - * - * @param jobId - The job identifier - */ - discardStaged(jobId: string): Promise; - - /** - * A store may have pending work that must finish before the job's staged writes are committed or discarded, yet - * commits run inside a transaction that cannot wait for it. Such stores implement this method: it is called before - * every commit and discard, outside the transaction. If settling fails, the commit is cancelled, but a discard - * proceeds. - * - * @param jobId - The job identifier - */ - settle?(jobId: string): Promise; -} - -/** - * JobCoordinator manages job lifecycle and provides crash resilience for PXE operations. - * - * It uses a staged writes pattern: - * 1. When a job begins, a unique job ID is created - * 2. During the job, all writes go to staging (keyed by job ID) - * 3. On commit, staging is promoted to main storage - * 4. On abort, staged data is discarded - * - * Note: jobs must be serialized — beginJob throws if one is already in progress. We still key staging by job ID - * because aborting a job doesn't cancel its in-flight async work: a late write from an aborted job lands under its - * old job ID and is never promoted, instead of leaking into the next job's staging. - */ -export class JobCoordinator { - private readonly log: Logger; - - /** The underlying KV store */ - kvStore: AztecAsyncKVStore; - - #currentJobId: string | undefined; - #stores: Map = new Map(); - - constructor(kvStore: AztecAsyncKVStore, bindings?: LoggerBindings) { - this.kvStore = kvStore; - this.log = createLogger('pxe:job_coordinator', bindings); - } - - /** - * Registers a staged store. - * Must be called during initialization for all stores that need staging support. - */ - registerStore(store: StagedStore): void { - if (this.#stores.has(store.storeName)) { - throw new Error(`Store "${store.storeName}" is already registered`); - } - this.#stores.set(store.storeName, store); - this.log.debug(`Registered staged store: ${store.storeName}`); - } - - /** - * Registers multiple staged stores. - */ - registerStores(stores: StagedStore[]): void { - for (const store of stores) { - this.registerStore(store); - } - } - - /** - * Begins a new job and returns a job ID for staged writes. - * - * @returns Job ID to pass to store operations - */ - beginJob(): string { - if (this.#currentJobId) { - throw new Error( - `Cannot begin job: job ${this.#currentJobId} is already in progress. ` + - `This should not happen - ensure jobs are properly committed or aborted.`, - ); - } - - const jobId = randomBytes(8).toString('hex'); - this.#currentJobId = jobId; - - this.log.debug(`Started job ${jobId}`); - return jobId; - } - - /** - * Commits a job by promoting all staged data to main storage. - * - * @param jobId - The job ID returned from beginJob - */ - async commitJob(jobId: string): Promise { - if (!this.#currentJobId || this.#currentJobId !== jobId) { - throw new Error( - `Cannot commit job ${jobId}: no matching job in progress. ` + `Current job: ${this.#currentJobId ?? 'none'}`, - ); - } - - this.log.debug(`Committing job ${jobId}`); - - // Settling must stay outside the transaction: it can take arbitrarily long. - await allToCompletion([...this.#stores.values()].map(store => store.settle?.(jobId))); - - // Commit all stores atomically in a single transaction. - // Each store's commit is a no-op if it has no staged data (but that's up to each store to handle). - await this.kvStore.transactionAsync(async () => { - for (const store of this.#stores.values()) { - await store.commit(jobId); - } - }); - - this.#currentJobId = undefined; - this.log.debug(`Job ${jobId} committed successfully`); - } - - /** - * Aborts a job by discarding all staged data. - * - * @param jobId - The job ID returned from beginJob - */ - async abortJob(jobId: string): Promise { - if (!this.#currentJobId || this.#currentJobId !== jobId) { - // Job may have already been aborted or never started properly - this.log.warn(`Abort called for job ${jobId} but current job is ${this.#currentJobId ?? 'none'}`); - } - - this.log.debug(`Aborting job ${jobId}`); - - await this.#settleStoresLoggingFailures(jobId); - - for (const store of this.#stores.values()) { - await store.discardStaged(jobId); - } - - this.#currentJobId = undefined; - this.log.debug(`Job ${jobId} aborted`); - } - - /** - * Checks if there's a job currently in progress. - */ - hasJobInProgress(): boolean { - return this.#currentJobId !== undefined; - } - - /** - * Settles every store, logging failures instead of propagating them. The abort must run to completion no matter what, - * so a store that fails to settle cannot stop the others from discarding or mask the error that aborted the job. - */ - async #settleStoresLoggingFailures(jobId: string): Promise { - await allToCompletion( - [...this.#stores.values()].map(store => - store.settle?.(jobId).catch(err => { - this.log.warn(`Store ${store.storeName} failed to settle while aborting job ${jobId}`, { jobId, err }); - }), - ), - ); - } -} diff --git a/yarn-project/pxe/src/logs/log_service.ts b/yarn-project/pxe/src/logs/log_service.ts index 4071a6718d42..d80e7791bf11 100644 --- a/yarn-project/pxe/src/logs/log_service.ts +++ b/yarn-project/pxe/src/logs/log_service.ts @@ -25,6 +25,7 @@ import { import type { TxOnchainContext } from '../messages/tx_resolver_service.js'; import { AddressStore } from '../storage/address_store/address_store.js'; import { assertAllowedScope } from '../storage/allowed_scopes.js'; +import type { ChangeSetId } from '../storage/staged_write_coordinator.js'; import type { RecipientTaggingStore } from '../storage/tagging_store/recipient_tagging_store.js'; import type { TaggingSecretSourcesStore } from '../storage/tagging_store/tagging_secret_sources_store.js'; import { @@ -51,7 +52,7 @@ export class LogService { private readonly taggingSecretSourcesStore: TaggingSecretSourcesStore, private readonly addressStore: AddressStore, private readonly scopes: AztecAddress[], - private readonly jobId: string, + private readonly changeSetId: ChangeSetId, bindings?: LoggerBindings, ) { this.log = createLogger('pxe:log_service', bindings); @@ -216,7 +217,7 @@ export class LogService { this.recipientTaggingStore, this.anchorBlockHeader, l2Tips.finalized.block.number, - this.jobId, + this.changeSetId, ); return logs.map(log => LogService.#toRetrievedTaggedLog(log)); diff --git a/yarn-project/pxe/src/notes/note_service.test.ts b/yarn-project/pxe/src/notes/note_service.test.ts index 6273ed3bb728..b5f6c5ed66d3 100644 --- a/yarn-project/pxe/src/notes/note_service.test.ts +++ b/yarn-project/pxe/src/notes/note_service.test.ts @@ -18,6 +18,7 @@ import { mock } from 'jest-mock-extended'; import type { NoteValidationRequest } from '../contract_function_simulator/noir-structs/note_validation_request.js'; import { NoteStore } from '../storage/note_store/note_store.js'; +import type { ChangeSetId } from '../storage/staged_write_coordinator.js'; import { NoteService, type NoteValidationTxData } from './note_service.js'; describe('NoteService', () => { @@ -82,16 +83,16 @@ describe('NoteService', () => { // Verify the note was removed by checking the spy expect(noteStore.applyNullifiers).toHaveBeenCalledTimes(1); - // Verify that the changes persist after job completion + // Verify that the changes persist after change set commit { - await noteStore.commit('test'); + await noteStore.commitStaged('test'); const remainingNotes = await noteStore.getNotes( { contractAddress, status: NoteStatus.ACTIVE, scopes: [recipient.address], }, - 'fresh-job', + 'fresh-change-set', ); expect(remainingNotes).toHaveLength(0); } @@ -118,16 +119,16 @@ describe('NoteService', () => { expect(remainingNotes).toHaveLength(1); expect(remainingNotes[0]).toEqual(noteDao); - // Verify that the changes persist after job completion + // Verify that the changes persist after change set commit { - await noteStore.commit('test'); + await noteStore.commitStaged('test'); const remainingNotes = await noteStore.getNotes( { contractAddress, status: NoteStatus.ACTIVE, scopes: [recipient.address], }, - 'fresh-job', + 'fresh-change-set', ); expect(remainingNotes).toHaveLength(1); expect(remainingNotes[0]).toEqual(noteDao); @@ -164,16 +165,16 @@ describe('NoteService', () => { expect(remainingNotes).toHaveLength(1); expect(remainingNotes[0]).toEqual(noteDao); - // Verify that the changes persist after job completion + // Verify that the changes persist after change set commit { - await noteStore.commit('test'); + await noteStore.commitStaged('test'); const remainingNotes = await noteStore.getNotes( { contractAddress, status: NoteStatus.ACTIVE, scopes: [recipient.address], }, - 'fresh-job', + 'fresh-change-set', ); expect(remainingNotes).toHaveLength(1); expect(remainingNotes[0]).toEqual(noteDao); @@ -193,7 +194,7 @@ describe('NoteService', () => { // Verify applyNullifiers was called once for all accounts expect(getNotesSpy).toHaveBeenCalledTimes(1); - // Verify getNotes was called with the correct contract address and jobId + // Verify getNotes was called with the correct contract address and changeSetId expect(getNotesSpy).toHaveBeenCalledWith(expect.objectContaining({ contractAddress }), 'test'); }); @@ -280,11 +281,11 @@ describe('NoteService', () => { expect(notes).toHaveLength(1); expect(notes[0].noteHash.equals(noteHash)).toBe(true); - // Verify note is still stored after committing job + // Verify note is still stored after committing the change set { - await noteStore.commit('test'); + await noteStore.commitStaged('test'); - const notes = await noteStore.getNotes({ contractAddress, scopes: [recipient.address] }, 'fresh-job'); + const notes = await noteStore.getNotes({ contractAddress, scopes: [recipient.address] }, 'fresh-change-set'); expect(notes).toHaveLength(1); expect(notes[0].noteHash.equals(noteHash)).toBe(true); @@ -365,7 +366,7 @@ describe('NoteService', () => { await noteService.validateAndStoreNotes([buildRequest()], recipient.address, defaultValidationTxDataMap()); - const verifyNoteNullifiedInJobContext = async (jobId: string) => { + const verifyNoteNullifiedInChangeSetContext = async (changeSetId: ChangeSetId) => { // Now we verify that the note is stored as nullified by checking it can be retrieved only with // the ACTIVE_OR_NULLIFIED status on the input. const allNotes = await noteStore.getNotes( @@ -374,7 +375,7 @@ describe('NoteService', () => { scopes: [recipient.address], status: NoteStatus.ACTIVE_OR_NULLIFIED, }, - jobId, + changeSetId, ); expect(allNotes).toHaveLength(1); expect(allNotes[0].noteHash.equals(noteHash)).toBe(true); @@ -385,15 +386,15 @@ describe('NoteService', () => { scopes: [recipient.address], status: NoteStatus.ACTIVE, }, - jobId, + changeSetId, ); expect(activeNotes).toHaveLength(0); }; // Verify store behaves correctly pre and post commit - await verifyNoteNullifiedInJobContext('test'); - await noteStore.commit('test'); - await verifyNoteNullifiedInJobContext('fresh-job'); + await verifyNoteNullifiedInChangeSetContext('test'); + await noteStore.commitStaged('test'); + await verifyNoteNullifiedInChangeSetContext('fresh-change-set'); }); function defaultValidationTxDataMap() { diff --git a/yarn-project/pxe/src/notes/note_service.ts b/yarn-project/pxe/src/notes/note_service.ts index 52f940903ff6..4327af51b202 100644 --- a/yarn-project/pxe/src/notes/note_service.ts +++ b/yarn-project/pxe/src/notes/note_service.ts @@ -11,13 +11,14 @@ import type { BlockHeader } from '@aztec/stdlib/tx'; import type { NoteValidationRequest } from '../contract_function_simulator/noir-structs/note_validation_request.js'; import type { NoteStore } from '../storage/note_store/note_store.js'; +import type { ChangeSetId } from '../storage/staged_write_coordinator.js'; export class NoteService { constructor( private readonly noteStore: NoteStore, private readonly aztecNode: AztecNode, private readonly anchorBlockHeader: BlockHeader, - private readonly jobId: string, + private readonly changeSetId: ChangeSetId, ) {} /** @@ -44,7 +45,7 @@ export class NoteService { status, scopes, }, - this.jobId, + this.changeSetId, ); return noteDaos.map( ({ contractAddress, owner, storageSlot, randomness, noteNonce, note, noteHash, siloedNullifier }) => ({ @@ -76,7 +77,7 @@ export class NoteService { public async syncNoteNullifiers(contractAddress: AztecAddress, scopes: AztecAddress[]): Promise { const anchorBlockHash = await this.anchorBlockHeader.hash(); - const contractNotes = await this.noteStore.getNotes({ contractAddress, scopes }, this.jobId); + const contractNotes = await this.noteStore.getNotes({ contractAddress, scopes }, this.changeSetId); if (contractNotes.length === 0) { return; @@ -93,7 +94,7 @@ export class NoteService { }) .filter(nullifier => nullifier !== undefined) as DataInBlock[]; - await this.noteStore.applyNullifiers(foundNullifiers, this.jobId); + await this.noteStore.applyNullifiers(foundNullifiers, this.changeSetId); } /** @@ -213,10 +214,10 @@ export class NoteService { } } - await this.noteStore.addNotes(noteDaos, scope, this.jobId); + await this.noteStore.addNotes(noteDaos, scope, this.changeSetId); if (foundNullifiers.length > 0) { - await this.noteStore.applyNullifiers(foundNullifiers, this.jobId); + await this.noteStore.applyNullifiers(foundNullifiers, this.changeSetId); } } diff --git a/yarn-project/pxe/src/operation_lifecycle.test.ts b/yarn-project/pxe/src/operation_lifecycle.test.ts new file mode 100644 index 000000000000..9831536d904e --- /dev/null +++ b/yarn-project/pxe/src/operation_lifecycle.test.ts @@ -0,0 +1,151 @@ +import type { Logger } from '@aztec/foundation/log'; +import { promiseWithResolvers } from '@aztec/foundation/promise'; +import type { AztecAsyncKVStore } from '@aztec/kv-store'; +import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; + +import { mock } from 'jest-mock-extended'; + +import { type OperationContributor, runOperation } from './operation_lifecycle.js'; +import { type ChangeSetId, type StagedStore, StagedWriteCoordinator } from './storage/staged_write_coordinator.js'; +import { tick } from './test_utils.js'; + +describe('runOperation', () => { + let store: AztecAsyncKVStore; + let coordinator: StagedWriteCoordinator; + + /** A staged store whose committed/discarded change sets are observable. */ + let committed: ChangeSetId[]; + let discarded: ChangeSetId[]; + + beforeEach(async () => { + store = await openTmpStore('operation_lifecycle_test'); + + committed = []; + discarded = []; + const recordingStore: StagedStore = { + storeName: 'recording_store', + commitStaged: id => { + committed.push(id); + return Promise.resolve(); + }, + discardStaged: id => { + discarded.push(id); + return Promise.resolve(); + }, + }; + coordinator = new StagedWriteCoordinator({ kvStore: store, stagedStores: [recordingStore] }); + }); + + it('commits the change set and returns the result when the operation succeeds', async () => { + const { changeSetId, operation } = run([], () => Promise.resolve('result')); + + await expect(operation).resolves.toEqual('result'); + expect(committed).toEqual([changeSetId]); + expect(discarded).toEqual([]); + }); + + it('discards the change set when the operation rejects', async () => { + const { changeSetId, operation } = run([], () => Promise.reject(new Error('operation failed'))); + + await expect(operation).rejects.toThrow('operation failed'); + expect(committed).toEqual([]); + expect(discarded).toEqual([changeSetId]); + }); + + it('waits for contributors to settle before committing', async () => { + const { promise: settling, resolve: finishSettling } = promiseWithResolvers(); + + const { operation } = run([{ settle: () => settling, onOperationEnd: () => {} }], () => Promise.resolve()); + await tick(); + expect(committed).toEqual([]); + + finishSettling(); + await operation; + expect(committed).toHaveLength(1); + }); + + it('discards instead of committing when a contributor fails to settle', async () => { + const outcomes: string[] = []; + const contributor: OperationContributor = { + settle: () => Promise.reject(new Error('settle failed')), + onOperationEnd: (_, outcome) => { + outcomes.push(outcome); + }, + }; + + const { operation } = run([contributor], () => Promise.resolve()); + + await expect(operation).rejects.toThrow('settle failed'); + expect(committed).toEqual([]); + expect(discarded).toHaveLength(1); + expect(outcomes).toEqual(['discarded']); + }); + + it('drains contributors before discarding on abort, even when settling fails', async () => { + const { promise: settling, reject: failSettling } = promiseWithResolvers(); + + const { operation } = run([{ settle: () => settling, onOperationEnd: () => {} }], () => + Promise.reject(new Error('operation failed')), + ); + await tick(); + expect(discarded).toEqual([]); + + failSettling(new Error('settle failed')); + // The operation's own error surfaces, not the drain failure. + await expect(operation).rejects.toThrow('operation failed'); + expect(discarded).toHaveLength(1); + }); + + it('notifies contributors of the outcome after the change set is decided', async () => { + const events: string[] = []; + const contributor: OperationContributor = { + onOperationEnd: (id, outcome) => { + const decided = (outcome === 'committed' ? committed : discarded).includes(id); + events.push(`${outcome}:${decided ? 'after' : 'before'}`); + }, + }; + + await run([contributor], () => Promise.resolve()).operation; + await expect(run([contributor], () => Promise.reject(new Error('fail'))).operation).rejects.toThrow('fail'); + + expect(events).toEqual(['committed:after', 'discarded:after']); + }); + + it('keeps the decided outcome when a contributor fails to handle the end of the operation', async () => { + const failing: OperationContributor = { + onOperationEnd: () => { + throw new Error('notification failed'); + }, + }; + const notified: string[] = []; + const contributors: OperationContributor[] = [ + failing, + { + onOperationEnd: (_, outcome) => { + notified.push(outcome); + }, + }, + ]; + + await expect(run(contributors, () => Promise.resolve('result')).operation).resolves.toEqual('result'); + await expect(run(contributors, () => Promise.reject(new Error('operation failed'))).operation).rejects.toThrow( + 'operation failed', + ); + + expect(committed).toHaveLength(1); + expect(discarded).toHaveLength(1); + expect(notified).toEqual(['committed', 'discarded']); + }); + + /** Begins a change set and runs `fn` as an operation over it. */ + function run(contributors: OperationContributor[], fn: () => Promise) { + const changeSetId = coordinator.begin(); + const operation = runOperation( + { stagedWriteCoordinator: coordinator, contributors, changeSetId, log: mockLog }, + fn, + ); + return { changeSetId, operation }; + } + + const mockLog = mock(); +}); diff --git a/yarn-project/pxe/src/operation_lifecycle.ts b/yarn-project/pxe/src/operation_lifecycle.ts new file mode 100644 index 000000000000..e3ba83c1e72e --- /dev/null +++ b/yarn-project/pxe/src/operation_lifecycle.ts @@ -0,0 +1,93 @@ +import type { Logger } from '@aztec/foundation/log'; +import { allToCompletion } from '@aztec/foundation/promise'; + +import type { ChangeSetId, StagedWriteCoordinator } from './storage/staged_write_coordinator.js'; + +/** + * Contributes work to every synced operation (e.g. writes into its change set). The operation waits for a + * contributor's work to settle before deciding its change set, and informs it of the outcome. + */ +export interface OperationContributor { + /** + * Waits for any work the contributor still has in flight. Awaited before the operation's change set is decided, so + * that no contributor is still writing when it is committed or discarded. A rejection causes the operation to + * discard instead of commit. + */ + settle?(changeSetId: ChangeSetId): Promise; + + /** + * Called once the operation's change set has been committed or discarded. A throw is logged and swallowed: the outcome is + * already decided by this point, so it cannot change it. + */ + onOperationEnd(changeSetId: ChangeSetId, outcome: 'committed' | 'discarded'): void; +} + +/** + * Runs `fn` as the operation's work and decides its change set. + * + * On success: + * 1. Waits for every contributor to settle. A rejection vetoes the commit and the failure path below runs instead. + * 2. Commits the change set. + * 3. Notifies contributors of the outcome. + * + * On failure: + * 1. Drains contributors, logging failures instead of propagating them so the discard runs to completion and the + * error that aborted the operation is not masked. + * 2. Aborts the change set. + * 3. Notifies contributors of the outcome. + * 4. Rethrows. + */ +export async function runOperation(args: RunOperationArgs, fn: () => Promise): Promise { + const { stagedWriteCoordinator, contributors, changeSetId, log } = args; + try { + const result = await fn(); + + // Settling must stay outside the commit transaction: it can take arbitrarily long. + await allToCompletion(contributors.map(contributor => contributor.settle?.(changeSetId))); + log.verbose(`Committing operation ${changeSetId}`, { changeSetId }); + + await stagedWriteCoordinator.commit(changeSetId); + notifyOperationEnd(args, 'committed'); + return result; + } catch (err) { + log.verbose(`Aborting operation ${changeSetId}`, { changeSetId }); + await settleContributorsLoggingFailures(args); + await stagedWriteCoordinator.abort(changeSetId); + notifyOperationEnd(args, 'discarded'); + throw err; + } +} + +function notifyOperationEnd( + { contributors, changeSetId, log }: RunOperationArgs, + outcome: 'committed' | 'discarded', +): void { + for (const contributor of contributors) { + // The change set has already been decided at this point, so a failed notification must not turn a committed + // operation into a rejected one, nor mask the error that caused a discard. + try { + contributor.onOperationEnd(changeSetId, outcome); + } catch (err) { + log.warn(`Contributor failed to handle the end of operation ${changeSetId}`, { changeSetId, outcome, err }); + } + } +} + +async function settleContributorsLoggingFailures({ contributors, changeSetId, log }: RunOperationArgs): Promise { + await allToCompletion( + contributors.map(contributor => + contributor.settle?.(changeSetId).catch(err => { + log.warn(`Contributor failed to settle while discarding operation ${changeSetId}`, { changeSetId, err }); + }), + ), + ); +} + +/** What {@link runOperation} needs to decide an operation's change set. */ +type RunOperationArgs = { + stagedWriteCoordinator: StagedWriteCoordinator; + contributors: OperationContributor[]; + /** The change set the operation's writes are staged under, from {@link StagedWriteCoordinator.begin}. */ + changeSetId: ChangeSetId; + log: Logger; +}; diff --git a/yarn-project/pxe/src/operation_queue.test.ts b/yarn-project/pxe/src/operation_queue.test.ts new file mode 100644 index 000000000000..0b29a96ddba9 --- /dev/null +++ b/yarn-project/pxe/src/operation_queue.test.ts @@ -0,0 +1,90 @@ +import type { Logger } from '@aztec/foundation/log'; +import type { AztecAsyncKVStore } from '@aztec/kv-store'; +import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; +import { BlockHeader } from '@aztec/stdlib/tx'; + +import { type MockProxy, mock } from 'jest-mock-extended'; + +import type { BlockSynchronizer } from './block_synchronizer/index.js'; +import type { Recording } from './node/benchmarked_node.js'; +import type { CachingAztecNode } from './node/caching_aztec_node.js'; +import { OperationQueue } from './operation_queue.js'; +import type { AnchorBlockStore } from './storage/anchor_block_store/anchor_block_store.js'; +import { type ChangeSetId, type StagedStore, StagedWriteCoordinator } from './storage/staged_write_coordinator.js'; + +describe('OperationQueue', () => { + let store: AztecAsyncKVStore; + let coordinator: StagedWriteCoordinator; + let node: MockProxy; + let synchronizer: MockProxy; + let anchorBlockStore: MockProxy; + + /** A staged store whose committed/discarded change sets are observable. */ + let committed: ChangeSetId[]; + let discarded: ChangeSetId[]; + + beforeEach(async () => { + store = await openTmpStore('operation_queue_test'); + + committed = []; + discarded = []; + const recordingStore: StagedStore = { + storeName: 'recording_store', + commitStaged: id => { + committed.push(id); + return Promise.resolve(); + }, + discardStaged: id => { + discarded.push(id); + return Promise.resolve(); + }, + }; + coordinator = new StagedWriteCoordinator({ kvStore: store, stagedStores: [recordingStore] }); + + node = mock(); + node.startRecording.mockReturnValue(mock()); + synchronizer = mock(); + anchorBlockStore = mock(); + anchorBlockStore.getBlockHeader.mockResolvedValue(BlockHeader.empty()); + }); + + it('commits the change set when the operation succeeds', async () => { + const queue = makeQueue(); + + const result = await queue.runSynced(({ changeSetId }) => Promise.resolve(changeSetId)); + + expect(committed).toEqual([result]); + expect(discarded).toEqual([]); + }); + + it('discards the change set when the operation rejects', async () => { + const queue = makeQueue(); + let id: ChangeSetId; + + await expect( + queue.runSynced(({ changeSetId }) => { + id = changeSetId; + return Promise.reject(new Error('operation failed')); + }), + ).rejects.toThrow('operation failed'); + + expect(committed).toEqual([]); + expect(discarded).toEqual([id!]); + }); + + function makeQueue() { + const queue = new OperationQueue({ + node, + synchronizer, + anchorBlockStore, + stagedWriteCoordinator: coordinator, + contributors: [], + autoSync: false, + log: mockLog, + }); + queue.start(); + return queue; + } + + const mockLog = mock(); +}); diff --git a/yarn-project/pxe/src/operation_queue.ts b/yarn-project/pxe/src/operation_queue.ts new file mode 100644 index 000000000000..71f61c164d3f --- /dev/null +++ b/yarn-project/pxe/src/operation_queue.ts @@ -0,0 +1,149 @@ +import type { Logger } from '@aztec/foundation/log'; +import { SerialQueue } from '@aztec/foundation/queue'; +import { Timer } from '@aztec/foundation/timer'; +import { SimulationError } from '@aztec/stdlib/errors'; +import type { BlockHeader } from '@aztec/stdlib/tx'; + +import type { BlockSynchronizer } from './block_synchronizer/index.js'; +import type { Recording } from './node/benchmarked_node.js'; +import type { CachingAztecNode } from './node/caching_aztec_node.js'; +import { type OperationContributor, runOperation } from './operation_lifecycle.js'; +import type { AnchorBlockStore } from './storage/anchor_block_store/anchor_block_store.js'; +import type { ChangeSetId, StagedWriteCoordinator } from './storage/staged_write_coordinator.js'; + +/** What a synced operation receives: its change set id, the anchor its sync established, and its instrumentation. */ +export type SyncedOperationContext = { + /** + * The change set the operation's writes are staged under. A synced operation runs inside exactly one change set, + * which is committed if it succeeds and discarded if it fails, so this ID doubles as the operation's identity: + * bookkeeping that must be kept or thrown away along with the operation is keyed on it, whether or not it lives in + * a store. + */ + changeSetId: ChangeSetId; + /** Duration of the sync, for timing stats. */ + syncTime: number; + anchorBlockHeader: BlockHeader; + /** Open recording of the node RPC calls made so far in this operation; `stats()` snapshots them for reporting. */ + recording: Recording; + /** The operation's duration so far, including the sync. */ + totalMs: () => number; +}; + +/** + * Serializes the PXE's operations, since concurrent execution is not supported: operations execute oracles that read + * and write the PXE stores, and concurrent runs would interfere with one another. + * + * Synced operations additionally run after a sync with the node and inside a staged-write session (see + * {@link StagedWriteCoordinator}): staged writes are committed if the operation succeeds and discarded if it throws. + */ +export class OperationQueue { + private readonly queue = new SerialQueue(); + private readonly node: CachingAztecNode; + private readonly synchronizer: BlockSynchronizer; + private readonly anchorBlockStore: AnchorBlockStore; + private readonly stagedWriteCoordinator: StagedWriteCoordinator; + private readonly contributors: OperationContributor[]; + private readonly autoSync: boolean; + private readonly log: Logger; + + constructor(args: OperationQueueArgs) { + this.node = args.node; + this.synchronizer = args.synchronizer; + this.anchorBlockStore = args.anchorBlockStore; + this.stagedWriteCoordinator = args.stagedWriteCoordinator; + this.contributors = args.contributors; + this.autoSync = args.autoSync; + this.log = args.log; + } + + public start(): void { + this.queue.start(); + } + + public async stop(): Promise { + await this.queue.end(); + } + + /** + * Runs an operation once no other operations are running. Returns a promise that will resolve once the operation + * is complete. + * + * Useful for tasks that cannot run concurrently, such as contract function simulation. + */ + public run(fn: () => Promise): Promise { + // TODO(#12636): relax the conditions under which we forbid concurrency. + if (this.queue.length() != 0) { + this.log.warn( + `PXE is already processing ${this.queue.length()} operations, concurrent execution is not supported. Will run once those are complete.`, + ); + } + + return this.queue.put(fn); + } + + /** + * Runs an operation (`fn`) after a sync with the node (skipped when the `autoSync` config flag is disabled, unless + * `forceSync` is set). If the operation succeeds, then all staged writes are committed. If it rejects, then all + * staged writes are discarded. + */ + public runSynced( + fn: (ctx: SyncedOperationContext) => Promise, + { errorContext, forceSync = false }: { errorContext?: () => string[]; forceSync?: boolean } = {}, + ): Promise { + return this.run(async () => { + const totalTimer = new Timer(); + const recording = this.node.startRecording(); + try { + const syncTimer = new Timer(); + if (forceSync || this.autoSync) { + await this.synchronizer.sync(); + } + const syncTime = syncTimer.ms(); + const anchorBlockHeader = await this.anchorBlockStore.getBlockHeader(); + + const changeSetId = this.stagedWriteCoordinator.begin(); + this.log.verbose(`Beginning operation ${changeSetId}`, { changeSetId, syncMs: syncTime }); + + const operationArgs = { + stagedWriteCoordinator: this.stagedWriteCoordinator, + contributors: this.contributors, + changeSetId, + log: this.log, + }; + return await runOperation(operationArgs, () => + fn({ changeSetId, syncTime, anchorBlockHeader, recording, totalMs: () => totalTimer.ms() }), + ); + } catch (err: any) { + throw errorContext ? this.#contextualizeError(err, ...errorContext()) : err; + } finally { + recording.stop(); + } + }); + } + + #contextualizeError(err: Error, ...context: string[]): Error { + let contextStr = ''; + if (context.length > 0) { + contextStr = `\nContext:\n${context.join('\n')}`; + } + if (err instanceof SimulationError) { + err.setAztecContext(contextStr); + } else { + this.log.error(err.name, err); + this.log.debug(contextStr); + } + return err; + } +} + +/** Dependencies of the {@link OperationQueue}. */ +type OperationQueueArgs = { + node: CachingAztecNode; + synchronizer: BlockSynchronizer; + anchorBlockStore: AnchorBlockStore; + stagedWriteCoordinator: StagedWriteCoordinator; + contributors: OperationContributor[]; + /** Whether synced operations sync with the node before running (see {@link OperationQueue.runSynced}). */ + autoSync: boolean; + log: Logger; +}; diff --git a/yarn-project/pxe/src/pxe.test.ts b/yarn-project/pxe/src/pxe.test.ts index 6b5d0922c1f4..92b7ae68610a 100644 --- a/yarn-project/pxe/src/pxe.test.ts +++ b/yarn-project/pxe/src/pxe.test.ts @@ -467,7 +467,7 @@ describe('PXE', () => { // Store a couple of events to exercise `getPrivateEvents` const event1 = await storeEvent(); const event2 = await storeEvent(); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); const events = await pxe.getPrivateEvents(eventSelector, { contractAddress, @@ -506,7 +506,7 @@ describe('PXE', () => { // Events in not-yet-synced blocks; stored only to verify they are filtered out. await Promise.all([storeEvent(lastKnownBlockNumber + 1), storeEvent(lastKnownBlockNumber + 1)]); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); }); it('filters by txHash', async () => { diff --git a/yarn-project/pxe/src/pxe.ts b/yarn-project/pxe/src/pxe.ts index db266bfc040d..65bce0b92dc1 100644 --- a/yarn-project/pxe/src/pxe.ts +++ b/yarn-project/pxe/src/pxe.ts @@ -4,7 +4,6 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { Point } from '@aztec/foundation/curves/grumpkin'; import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; import { allToCompletion } from '@aztec/foundation/promise'; -import { SerialQueue } from '@aztec/foundation/queue'; import { Timer } from '@aztec/foundation/timer'; import { KeyStore } from '@aztec/key-store'; import type { AccountPrivacyKeys, AccountPrivacySecretKeys } from '@aztec/key-store'; @@ -72,10 +71,9 @@ import { PXEDebugUtils } from './debug/pxe_debug_utils.js'; import { enrichPublicSimulationError, enrichSimulationError } from './error_enriching.js'; import { PrivateEventFilterValidator } from './events/private_event_filter_validator.js'; import type { ExecutionHooks } from './hooks/index.js'; -import { JobCoordinator } from './job_coordinator/job_coordinator.js'; import { TxResolverService } from './messages/tx_resolver_service.js'; -import type { Recording } from './node/benchmarked_node.js'; import { type CachingAztecNode, withCache } from './node/caching_aztec_node.js'; +import { OperationQueue } from './operation_queue.js'; import { PrivateKernelExecutionProver, type PrivateKernelExecutionProverConfig, @@ -89,6 +87,7 @@ import { FactStore } from './storage/fact_store/index.js'; import { NoteStore } from './storage/note_store/note_store.js'; import { openPxeStores } from './storage/open_pxe_stores.js'; import { PrivateEventStore } from './storage/private_event_store/private_event_store.js'; +import { type ChangeSetId, StagedWriteCoordinator } from './storage/staged_write_coordinator.js'; import { RecipientTaggingStore } from './storage/tagging_store/recipient_tagging_store.js'; import { SenderTaggingStore } from './storage/tagging_store/sender_tagging_store.js'; import { @@ -253,13 +252,11 @@ export class PXE { private l2TipsStore: L2TipsProvider, private simulator: CircuitSimulator, private proverEnabled: boolean, - private autoSync: boolean, private proofCreator: PrivateKernelProver, private protocolContractsProvider: ProtocolContractsProvider, private preloadedContractsProvider: PreloadedContractsProvider, private log: Logger, - private queue: SerialQueue, - private jobCoordinator: JobCoordinator, + private operationQueue: OperationQueue, public debug: PXEDebugUtils, private hooks: ExecutionHooks | undefined, ) {} @@ -345,20 +342,23 @@ export class PXE { bindings, ); - const jobCoordinator = new JobCoordinator(store, bindings); - jobCoordinator.registerStores([ - capsuleStore, - senderTaggingStore, - recipientTaggingStore, - privateEventStore, - noteStore, - factStore, - contractSyncService, - ]); + const stagedWriteCoordinator = new StagedWriteCoordinator({ + kvStore: store, + stagedStores: [capsuleStore, senderTaggingStore, recipientTaggingStore, privateEventStore, noteStore, factStore], + bindings, + }); const debugUtils = new PXEDebugUtils(contractSyncService, noteStore); - const queue = new SerialQueue(); + const operationQueue = new OperationQueue({ + node: readCachedNode, + synchronizer, + anchorBlockStore, + stagedWriteCoordinator, + contributors: [contractSyncService], + autoSync: config.autoSync, + log, + }); const pxe = new PXE( readCachedNode, @@ -382,24 +382,22 @@ export class PXE { l2TipsStore, simulator, proverEnabled, - config.autoSync, proofCreator, protocolContractsProvider, preloadedContractsProvider, log, - queue, - jobCoordinator, + operationQueue, debugUtils, hooks, ); debugUtils.setPXEHelpers( - fn => pxe.#syncedJob(fn, { forceSync: true }), + fn => operationQueue.runSynced(fn, { forceSync: true }), pxe.#getSimulatorForTx.bind(pxe), pxe.#executeUtility.bind(pxe), ); - pxe.queue.start(); + operationQueue.start(); await allToCompletion([pxe.#registerProtocolContracts(), pxe.#registerPreloadedContracts()]); log.info(`Started PXE connected to chain ${info.l1ChainId} version ${info.rollupVersion}`); @@ -455,79 +453,6 @@ export class PXE { } } - #contextualizeError(err: Error, ...context: string[]): Error { - let contextStr = ''; - if (context.length > 0) { - contextStr = `\nContext:\n${context.join('\n')}`; - } - if (err instanceof SimulationError) { - err.setAztecContext(contextStr); - } else { - this.log.error(err.name, err); - this.log.debug(contextStr); - } - return err; - } - - /** - * Enqueues an operation for execution once no other operations are running. Returns a promise that will resolve - * once the operation is complete. - * - * Useful for tasks that cannot run concurrently, such as contract function simulation. - */ - #enqueue(fn: () => Promise): Promise { - // TODO(#12636): relax the conditions under which we forbid concurrency. - if (this.queue.length() != 0) { - this.log.warn( - `PXE is already processing ${this.queue.length()} operations, concurrent execution is not supported. Will run once those are complete.`, - ); - } - - return this.queue.put(fn); - } - - /** - * Enqueues a job (`fn`) that runs after a sync with the node (skipped when the `autoSync` config flag is disabled, - * unless `forceSync` is set). If the job run is successful, then all staged writes are committed. If the job - * rejects, then all staged writes are discarded. - */ - #syncedJob( - fn: (ctx: SyncedJobContext) => Promise, - { errorContext, forceSync = false }: { errorContext?: () => string[]; forceSync?: boolean } = {}, - ): Promise { - return this.#enqueue(async () => { - const totalTimer = new Timer(); - const recording = this.node.startRecording(); - try { - const syncTimer = new Timer(); - if (forceSync || this.autoSync) { - await this.blockStateSynchronizer.sync(); - } - const syncTime = syncTimer.ms(); - - const jobId = this.jobCoordinator.beginJob(); - this.log.verbose(`Beginning job ${jobId}`, { syncMs: syncTime }); - - try { - const anchorBlockHeader = await this.anchorBlockStore.getBlockHeader(); - const result = await fn({ jobId, syncTime, anchorBlockHeader, recording, totalMs: () => totalTimer.ms() }); - this.log.verbose(`Committing job ${jobId}`); - - await this.jobCoordinator.commitJob(jobId); - return result; - } catch (err) { - this.log.verbose(`Aborting job ${jobId}`); - await this.jobCoordinator.abortJob(jobId); - throw err; - } - } catch (err: any) { - throw errorContext ? this.#contextualizeError(err, ...errorContext()) : err; - } finally { - recording.stop(); - } - }); - } - async #registerProtocolContracts() { const registered = Object.fromEntries( await allToCompletion( @@ -565,14 +490,14 @@ export class PXE { txRequest, anchorBlockHeader, scopes, - jobId, + changeSetId, senderForTags, }: { contractFunctionSimulator: ContractFunctionSimulator; txRequest: TxExecutionRequest; anchorBlockHeader: BlockHeader; scopes: AztecAddress[]; - jobId: string; + changeSetId: ChangeSetId; senderForTags?: AztecAddress; }): Promise { const { origin: contractAddress, functionSelector } = txRequest; @@ -582,9 +507,16 @@ export class PXE { contract: contractAddress, functionToInvokeAfterSync: functionSelector, utilityExecutor: (privateSyncCall, execScopes) => - this.#executeUtility(contractFunctionSimulator, privateSyncCall, [], execScopes, anchorBlockHeader, jobId), + this.#executeUtility( + contractFunctionSimulator, + privateSyncCall, + [], + execScopes, + anchorBlockHeader, + changeSetId, + ), anchorBlockHeader, - jobId, + changeSetId, scopes, triggeredBy: undefined, }); @@ -592,7 +524,7 @@ export class PXE { const result = await contractFunctionSimulator.run(txRequest, { anchorBlockHeader, scopes, - jobId, + changeSetId, senderForTags, }); this.log.debug(`Private simulation completed for ${contractAddress.toString()}:${functionSelector}`); @@ -612,8 +544,8 @@ export class PXE { * @param authWitnesses - Authentication witnesses required for the function call. * @param scopes - Optional array of account addresses whose notes can be accessed in this call. Defaults to all * accounts if not specified. - * @param anchorBlockHeader - The anchor block header established by the enclosing job. - * @param jobId - The job ID for staged writes. + * @param anchorBlockHeader - The anchor block header established by the enclosing operation. + * @param changeSetId - The change set ID for staged writes. * @returns The execution result containing the outputs of the utility function. */ async #executeUtility( @@ -622,7 +554,7 @@ export class PXE { authWitnesses: AuthWitness[] | undefined, scopes: AztecAddress[], anchorBlockHeader: BlockHeader, - jobId: string, + changeSetId: ChangeSetId, ) { try { const { result, offchainEffects } = await contractFunctionSimulator.runUtility( @@ -630,7 +562,7 @@ export class PXE { authWitnesses ?? [], anchorBlockHeader, scopes, - jobId, + changeSetId, ); return { result, offchainEffects }; } catch (err) { @@ -718,7 +650,7 @@ export class PXE { * instead of one per inner PXE call). Serialized through the queue. */ public sync(): Promise { - return this.#enqueue(() => this.blockStateSynchronizer.sync()); + return this.operationQueue.run(() => this.blockStateSynchronizer.sync()); } /** @@ -726,7 +658,7 @@ export class PXE { * @returns The synced block header */ public getSyncedBlockHeader(): Promise { - return this.#enqueue(() => { + return this.operationQueue.run(() => { return this.anchorBlockStore.getBlockHeader(); }); } @@ -828,8 +760,8 @@ export class PXE { } if (wasAdded) { - // Queued to avoid wiping while a job is in flight. - await this.#enqueue(() => Promise.resolve(this.contractSyncService.wipe())); + // Queued to avoid wiping while an operation is in flight. + await this.operationQueue.run(() => Promise.resolve(this.contractSyncService.wipe())); } } @@ -1007,7 +939,7 @@ export class PXE { */ public registerContract(instance: ContractInstancePreimage): Promise { // Run inside the queue so we can't race a concurrent simulation while writing the instance to the store. - return this.#enqueue(async () => { + return this.operationQueue.run(async () => { const address = await computeContractAddressFromInstance(instance); await this.contractStore.addContractInstance({ ...instance, address }); this.log.info(`Added contract at ${address}`, { address }); @@ -1037,15 +969,15 @@ export class PXE { let privateExecutionResult: PrivateExecutionResult; // We disable proving concurrently mostly out of caution, since it accesses some of our stores. Proving is so // computationally demanding that it'd be rare for someone to try to do it concurrently regardless. - return this.#syncedJob( - async ({ jobId, syncTime, anchorBlockHeader, recording, totalMs }) => { + return this.operationQueue.runSynced( + async ({ changeSetId, syncTime, anchorBlockHeader, recording, totalMs }) => { const contractFunctionSimulator = this.#getSimulatorForTx(); privateExecutionResult = await this.#executePrivate({ contractFunctionSimulator, txRequest, anchorBlockHeader, scopes, - jobId, + changeSetId, senderForTags, }); @@ -1095,7 +1027,7 @@ export class PXE { privateExecutionResult.entrypoint.taggingIndexRanges, publicInputs, () => txProvingResult.getTxHash(), - jobId, + changeSetId, this.log, ); @@ -1116,8 +1048,8 @@ export class PXE { { profileMode, skipProofGeneration = true, scopes, senderForTags }: ProfileTxOpts, ): Promise { // We disable concurrent profiles for consistency with simulateTx. - return this.#syncedJob( - async ({ jobId, syncTime, anchorBlockHeader, recording, totalMs }) => { + return this.operationQueue.runSynced( + async ({ changeSetId, syncTime, anchorBlockHeader, recording, totalMs }) => { const txInfo = { origin: txRequest.origin, functionSelector: txRequest.functionSelector, @@ -1137,7 +1069,7 @@ export class PXE { txRequest, anchorBlockHeader, scopes, - jobId, + changeSetId, senderForTags, }); @@ -1217,8 +1149,8 @@ export class PXE { // We disable concurrent simulations since those might execute oracles which read and write to the PXE stores (e.g. // to the capsules), and we need to prevent concurrent runs from interfering with one another (e.g. attempting to // delete the same read value, or reading values that another simulation is currently modifying). - return this.#syncedJob( - async ({ jobId, syncTime, anchorBlockHeader, recording, totalMs }) => { + return this.operationQueue.runSynced( + async ({ changeSetId, syncTime, anchorBlockHeader, recording, totalMs }) => { const txInfo = { origin: txRequest.origin, functionSelector: txRequest.functionSelector, @@ -1245,7 +1177,7 @@ export class PXE { txRequest, anchorBlockHeader, scopes, - jobId, + changeSetId, senderForTags, }); @@ -1360,8 +1292,8 @@ export class PXE { // We disable concurrent executions since those might execute oracles which read and write to the PXE stores (e.g. // to the capsules), and we need to prevent concurrent runs from interfering with one another (e.g. attempting to // delete the same read value, or reading values that another execution is currently modifying). - return this.#syncedJob( - async ({ jobId, syncTime, anchorBlockHeader, recording, totalMs }) => { + return this.operationQueue.runSynced( + async ({ changeSetId, syncTime, anchorBlockHeader, recording, totalMs }) => { const functionTimer = new Timer(); const contractFunctionSimulator = this.#getSimulatorForTx(); @@ -1369,9 +1301,16 @@ export class PXE { contract: call.to, functionToInvokeAfterSync: call.selector, utilityExecutor: (privateSyncCall, execScopes) => - this.#executeUtility(contractFunctionSimulator, privateSyncCall, [], execScopes, anchorBlockHeader, jobId), + this.#executeUtility( + contractFunctionSimulator, + privateSyncCall, + [], + execScopes, + anchorBlockHeader, + changeSetId, + ), anchorBlockHeader, - jobId, + changeSetId, scopes, triggeredBy: undefined, }); @@ -1382,7 +1321,7 @@ export class PXE { authwits ?? [], scopes, anchorBlockHeader, - jobId, + changeSetId, ); const functionTime = functionTimer.ms(); @@ -1436,7 +1375,7 @@ export class PXE { ): Promise { let anchorBlockNumber: BlockNumber; - await this.#syncedJob(async ({ jobId, anchorBlockHeader }) => { + await this.operationQueue.runSynced(async ({ changeSetId, anchorBlockHeader }) => { anchorBlockNumber = anchorBlockHeader.getBlockNumber(); const contractFunctionSimulator = this.#getSimulatorForTx(); @@ -1451,16 +1390,16 @@ export class PXE { [], execScopes, anchorBlockHeader, - jobId, + changeSetId, ), anchorBlockHeader, - jobId, + changeSetId, scopes: filter.scopes, triggeredBy: undefined, }); }); - // anchorBlockNumber is set during the job and fixed to whatever it is after a block sync + // anchorBlockNumber is set during the operation and fixed to whatever it is after a block sync const sanitizedFilter = new PrivateEventFilterValidator(anchorBlockNumber!).validate(filter); this.log.debug( @@ -1471,23 +1410,11 @@ export class PXE { } /** - * Stops the PXE's queue and closes the backing store. + * Stops the PXE's operation queue and closes the backing store. */ public async stop(): Promise { - await this.queue.end(); + await this.operationQueue.stop(); await this.blockStateSynchronizer.stop(); await this.db.close(); } } - -/** What a synced job receives: its id, the anchor its sync established, and the operation's instrumentation. */ -export type SyncedJobContext = { - jobId: string; - /** Duration of the sync, for timing stats. */ - syncTime: number; - anchorBlockHeader: BlockHeader; - /** Open recording of the node RPC calls made so far in this job; `stats()` snapshots them for reporting. */ - recording: Recording; - /** The operation's duration so far, including the sync. */ - totalMs: () => number; -}; diff --git a/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts b/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts index a8561507d41d..70cc676a46f4 100644 --- a/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts +++ b/yarn-project/pxe/src/storage/backwards_compatibility_tests/schema_tests.ts @@ -128,15 +128,15 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ writeToStore: async kvStore => { const capsuleStore = new CapsuleStore(kvStore); - const jobId = 'fixture-job'; + const changeSetId = 'fixture-change-set'; const contractAddress = AztecAddress.fromBigIntUnsafe(2n); const scope = AztecAddress.fromBigIntUnsafe(3n); // Three setCapsule calls (2-element, 1-element, 0-element value vector) pin every value-encoding length case. - capsuleStore.setCapsule(contractAddress, new Fr(5n), [new Fr(7n), new Fr(11n)], jobId, scope); - capsuleStore.setCapsule(contractAddress, new Fr(13n), [new Fr(17n)], jobId, scope); - capsuleStore.setCapsule(contractAddress, new Fr(19n), [], jobId, scope); - await kvStore.transactionAsync(() => capsuleStore.commit(jobId)); + capsuleStore.setCapsule(contractAddress, new Fr(5n), [new Fr(7n), new Fr(11n)], changeSetId, scope); + capsuleStore.setCapsule(contractAddress, new Fr(13n), [new Fr(17n)], changeSetId, scope); + capsuleStore.setCapsule(contractAddress, new Fr(19n), [], changeSetId, scope); + await kvStore.transactionAsync(() => capsuleStore.commitStaged(changeSetId)); }, snapshotStore: async kvStore => ({ capsules: await snapshotMap(kvStore.openMap('capsules')), @@ -222,7 +222,7 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ name: 'FactStore', writeToStore: async kvStore => { const factStore = new FactStore(kvStore); - const jobId = 'fixture-job'; + const changeSetId = 'fixture-change-set'; const contract = AztecAddress.fromBigIntUnsafe(100n); const scope = AztecAddress.fromBigIntUnsafe(1n); const factCollectionTypeId = new Fr(7n); @@ -239,11 +239,17 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ factCollectionId: new Fr(0xbbn), }); // A collection whose only fact is retractable (origin block 6): pruned on a reorg above block 6. - await factStore.recordFact(keyA, new Fr(3n), [new Fr(5n)], { blockNumber: 6, blockHash: new Fr(2n) }, jobId); + await factStore.recordFact( + keyA, + new Fr(3n), + [new Fr(5n)], + { blockNumber: 6, blockHash: new Fr(2n) }, + changeSetId, + ); // A collection with a non-retractable and a retractable fact. - await factStore.recordFact(keyB, new Fr(1n), [new Fr(9n)], undefined, jobId); - await factStore.recordFact(keyB, new Fr(2n), [], { blockNumber: 5, blockHash: new Fr(1n) }, jobId); - await kvStore.transactionAsync(() => factStore.commit(jobId)); + await factStore.recordFact(keyB, new Fr(1n), [new Fr(9n)], undefined, changeSetId); + await factStore.recordFact(keyB, new Fr(2n), [], { blockNumber: 5, blockHash: new Fr(1n) }, changeSetId); + await kvStore.transactionAsync(() => factStore.commitStaged(changeSetId)); }, snapshotStore: async kvStore => ({ facts: await snapshotMap(kvStore.openMap('facts')), @@ -327,7 +333,7 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ writeToStore: async kvStore => { const noteStore = new NoteStore(kvStore); - const jobId = 'fixture-job'; + const changeSetId = 'fixture-change-set'; // Two contracts so `note_nullifiers_by_contract` exhibits both a multi-value row (contractA → {n1, n2}) and a // single-value row (contractB → {n3}). @@ -391,19 +397,19 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ // Adding note1 twice with different scopes triggers `addScope` on the staged StoredNote, producing a 2-element // scope vector in the committed buffer. - await noteStore.addNotes([note1], scopeX, jobId); - await noteStore.addNotes([note1], scopeY, jobId); - await noteStore.addNotes([note2], scopeX, jobId); - await noteStore.addNotes([note3], scopeX, jobId); + await noteStore.addNotes([note1], scopeX, changeSetId); + await noteStore.addNotes([note1], scopeY, changeSetId); + await noteStore.addNotes([note2], scopeX, changeSetId); + await noteStore.addNotes([note3], scopeX, changeSetId); - // Nullify note3 within the same job. `applyNullifiers` stages the emission block number for the note; `commit` - // then flushes it to disk into `note_nullifications_by_nullifier`. + // Nullify note3 within the same change set. `applyNullifiers` stages the emission block number for the note; + // `commit` then flushes it to disk into `note_nullifications_by_nullifier`. await noteStore.applyNullifiers( [{ data: note3.siloedNullifier, l2BlockNumber: BlockNumber(223), l2BlockHash: BlockHash.ZERO }], - jobId, + changeSetId, ); - await kvStore.transactionAsync(() => noteStore.commit(jobId)); + await kvStore.transactionAsync(() => noteStore.commitStaged(changeSetId)); }, snapshotStore: async kvStore => ({ notes: await snapshotMap(kvStore.openMap('notes')), @@ -425,7 +431,7 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ writeToStore: async kvStore => { const privateEventStore = new PrivateEventStore(kvStore); - const jobId = 'fixture-job'; + const changeSetId = 'fixture-change-set'; // Two (contract, selector) pairs and two block numbers so each multimap exhibits both a multi-value row // (contractA/selectorA → {e1, e2} and blockN1 → {e1, e2}) and a contrasting single-value row. @@ -455,7 +461,7 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ txIndexInBlock: 53, eventIndexInTx: 59, }, - jobId, + changeSetId, ); // Same eventId, different scope: takes the `existing.addScope(...)` path in `storePrivateEventLog`. @@ -473,7 +479,7 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ txIndexInBlock: 53, eventIndexInTx: 59, }, - jobId, + changeSetId, ); // event2: same (contract, selector) and same block as event1 → multi-value rows in both multimaps. @@ -491,7 +497,7 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ txIndexInBlock: 97, eventIndexInTx: 101, }, - jobId, + changeSetId, ); // event3: distinct (contract, selector) and block → contrasting single-value multimap rows. @@ -509,10 +515,10 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ txIndexInBlock: 139, eventIndexInTx: 149, }, - jobId, + changeSetId, ); - await kvStore.transactionAsync(() => privateEventStore.commit(jobId)); + await kvStore.transactionAsync(() => privateEventStore.commitStaged(changeSetId)); }, snapshotStore: async kvStore => ({ private_event_logs: await snapshotMap(kvStore.openMap('private_event_logs')), @@ -528,7 +534,7 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ writeToStore: async kvStore => { const recipientTaggingStore = new RecipientTaggingStore(kvStore); - const jobId = 'fixture-job'; + const changeSetId = 'fixture-change-set'; const secretA = new AppTaggingSecret(new Fr(2n), AztecAddress.fromBigIntUnsafe(3n)); const secretB = new AppTaggingSecret(new Fr(5n), AztecAddress.fromBigIntUnsafe(7n)); // A constrained secret keys under the `constrained:` prefix, so the snapshot pins both kinds side by side. @@ -538,12 +544,12 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ AppTaggingSecretKind.CONSTRAINED, ); - await recipientTaggingStore.updateHighestFinalizedIndex(secretA, 11, jobId); - await recipientTaggingStore.updateHighestAgedIndex(secretA, 13, jobId); - await recipientTaggingStore.updateHighestFinalizedIndex(secretB, 17, jobId); - await recipientTaggingStore.updateHighestFinalizedIndex(secretConstrained, 11, jobId); - await recipientTaggingStore.updateHighestAgedIndex(secretConstrained, 13, jobId); - await kvStore.transactionAsync(() => recipientTaggingStore.commit(jobId)); + await recipientTaggingStore.updateHighestFinalizedIndex(secretA, 11, changeSetId); + await recipientTaggingStore.updateHighestAgedIndex(secretA, 13, changeSetId); + await recipientTaggingStore.updateHighestFinalizedIndex(secretB, 17, changeSetId); + await recipientTaggingStore.updateHighestFinalizedIndex(secretConstrained, 11, changeSetId); + await recipientTaggingStore.updateHighestAgedIndex(secretConstrained, 13, changeSetId); + await kvStore.transactionAsync(() => recipientTaggingStore.commitStaged(changeSetId)); }, snapshotStore: async kvStore => ({ highest_aged_index: await snapshotMap(kvStore.openMap('highest_aged_index')), @@ -589,7 +595,7 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ writeToStore: async kvStore => { const senderTaggingStore = new SenderTaggingStore(kvStore); - const jobId = 'fixture-job'; + const changeSetId = 'fixture-change-set'; const secretA = new AppTaggingSecret(new Fr(2n), AztecAddress.fromBigIntUnsafe(3n)); const secretB = new AppTaggingSecret(new Fr(5n), AztecAddress.fromBigIntUnsafe(7n)); const secretC = new AppTaggingSecret(new Fr(11n), AztecAddress.fromBigIntUnsafe(13n)); @@ -612,12 +618,12 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ { extendedSecret: secretA, lowestIndex: 1, highestIndex: 3 }, { extendedSecret: secretB, lowestIndex: 1, highestIndex: 5 }, ]; - await senderTaggingStore.storePendingIndexes(txHashARanges, txHashA, jobId); + await senderTaggingStore.storePendingIndexes(txHashARanges, txHashA, changeSetId); await senderTaggingStore.storePendingIndexes( [{ extendedSecret: secretA, lowestIndex: 4, highestIndex: 7 }], txHashB, - jobId, + changeSetId, ); // Re-store the exact same (secret, txHash, range). Exercises the "exact duplicate — skip" branch at @@ -625,13 +631,13 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ await senderTaggingStore.storePendingIndexes( [{ extendedSecret: secretA, lowestIndex: 4, highestIndex: 7 }], txHashB, - jobId, + changeSetId, ); await senderTaggingStore.storePendingIndexes( [{ extendedSecret: secretA, lowestIndex: 8, highestIndex: 11 }], txHashC, - jobId, + changeSetId, ); // secretC's range is never finalized, so it survives commit as a single-element pending array (contrast with @@ -639,7 +645,7 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ await senderTaggingStore.storePendingIndexes( [{ extendedSecret: secretC, lowestIndex: 1, highestIndex: 9 }], txHashD, - jobId, + changeSetId, ); // secretConstrained gets a finalized range (txHashE) plus a surviving higher pending range (txHashF), so the @@ -647,17 +653,17 @@ export const SCHEMA_TESTS: readonly SchemaTest[] = [ await senderTaggingStore.storePendingIndexes( [{ extendedSecret: secretConstrained, lowestIndex: 1, highestIndex: 3 }], txHashE, - jobId, + changeSetId, ); await senderTaggingStore.storePendingIndexes( [{ extendedSecret: secretConstrained, lowestIndex: 4, highestIndex: 7 }], txHashF, - jobId, + changeSetId, ); - await senderTaggingStore.finalizePendingIndexes([txHashA, txHashE], jobId); + await senderTaggingStore.finalizePendingIndexes([txHashA, txHashE], changeSetId); - await kvStore.transactionAsync(() => senderTaggingStore.commit(jobId)); + await kvStore.transactionAsync(() => senderTaggingStore.commitStaged(changeSetId)); }, snapshotStore: async kvStore => ({ pending_indexes: await snapshotMap(kvStore.openMap('pending_indexes')), diff --git a/yarn-project/pxe/src/storage/capsule_store/capsule_service.test.ts b/yarn-project/pxe/src/storage/capsule_store/capsule_service.test.ts index 616c907de454..be60026409bd 100644 --- a/yarn-project/pxe/src/storage/capsule_store/capsule_service.test.ts +++ b/yarn-project/pxe/src/storage/capsule_store/capsule_service.test.ts @@ -12,7 +12,7 @@ describe('CapsuleService', () => { let capsuleStore: CapsuleStore; let capsuleService: CapsuleService; - const jobId = 'test'; + const changeSetId = 'test'; beforeEach(async () => { contract = await AztecAddress.random(); @@ -27,43 +27,43 @@ describe('CapsuleService', () => { const capsule = [new Fr(42)]; it('setCapsule rejects a disallowed scope', () => { - expect(() => capsuleService.setCapsule(contract, slot, capsule, jobId, disallowedScope)).toThrow( + expect(() => capsuleService.setCapsule(contract, slot, capsule, changeSetId, disallowedScope)).toThrow( 'is not in the allowed scopes list', ); }); it('getCapsule rejects a disallowed scope', async () => { - await expect(capsuleService.getCapsule(contract, slot, jobId, disallowedScope)).rejects.toThrow( + await expect(capsuleService.getCapsule(contract, slot, changeSetId, disallowedScope)).rejects.toThrow( 'is not in the allowed scopes list', ); }); it('deleteCapsule rejects a disallowed scope', () => { - expect(() => capsuleService.deleteCapsule(contract, slot, jobId, disallowedScope)).toThrow( + expect(() => capsuleService.deleteCapsule(contract, slot, changeSetId, disallowedScope)).toThrow( 'is not in the allowed scopes list', ); }); it('copyCapsule rejects a disallowed scope', () => { - expect(() => capsuleService.copyCapsule(contract, slot, new Fr(5), 1, jobId, disallowedScope)).toThrow( + expect(() => capsuleService.copyCapsule(contract, slot, new Fr(5), 1, changeSetId, disallowedScope)).toThrow( 'is not in the allowed scopes list', ); }); it('appendToCapsuleArray rejects a disallowed scope', () => { - expect(() => capsuleService.appendToCapsuleArray(contract, slot, [capsule], jobId, disallowedScope)).toThrow( - 'is not in the allowed scopes list', - ); + expect(() => + capsuleService.appendToCapsuleArray(contract, slot, [capsule], changeSetId, disallowedScope), + ).toThrow('is not in the allowed scopes list'); }); it('readCapsuleArray rejects a disallowed scope', () => { - expect(() => capsuleService.readCapsuleArray(contract, slot, jobId, disallowedScope)).toThrow( + expect(() => capsuleService.readCapsuleArray(contract, slot, changeSetId, disallowedScope)).toThrow( 'is not in the allowed scopes list', ); }); it('setCapsuleArray rejects a disallowed scope', () => { - expect(() => capsuleService.setCapsuleArray(contract, slot, [capsule], jobId, disallowedScope)).toThrow( + expect(() => capsuleService.setCapsuleArray(contract, slot, [capsule], changeSetId, disallowedScope)).toThrow( 'is not in the allowed scopes list', ); }); @@ -72,54 +72,54 @@ describe('CapsuleService', () => { const scope = allowedScope; // setCapsule + getCapsule - capsuleService.setCapsule(contract, slot, capsule, jobId, scope); - expect(await capsuleService.getCapsule(contract, slot, jobId, scope)).toEqual(capsule); + capsuleService.setCapsule(contract, slot, capsule, changeSetId, scope); + expect(await capsuleService.getCapsule(contract, slot, changeSetId, scope)).toEqual(capsule); // deleteCapsule - capsuleService.deleteCapsule(contract, slot, jobId, scope); - expect(await capsuleService.getCapsule(contract, slot, jobId, scope)).toBeNull(); + capsuleService.deleteCapsule(contract, slot, changeSetId, scope); + expect(await capsuleService.getCapsule(contract, slot, changeSetId, scope)).toBeNull(); // copyCapsule - capsuleService.setCapsule(contract, slot, capsule, jobId, scope); - await capsuleService.copyCapsule(contract, slot, new Fr(5), 1, jobId, scope); - expect(await capsuleService.getCapsule(contract, new Fr(5), jobId, scope)).toEqual(capsule); + capsuleService.setCapsule(contract, slot, capsule, changeSetId, scope); + await capsuleService.copyCapsule(contract, slot, new Fr(5), 1, changeSetId, scope); + expect(await capsuleService.getCapsule(contract, new Fr(5), changeSetId, scope)).toEqual(capsule); // appendToCapsuleArray + readCapsuleArray const baseSlot = new Fr(10); - await capsuleService.appendToCapsuleArray(contract, baseSlot, [capsule], jobId, scope); - expect(await capsuleService.readCapsuleArray(contract, baseSlot, jobId, scope)).toEqual([capsule]); + await capsuleService.appendToCapsuleArray(contract, baseSlot, [capsule], changeSetId, scope); + expect(await capsuleService.readCapsuleArray(contract, baseSlot, changeSetId, scope)).toEqual([capsule]); // setCapsuleArray const newArray = [capsule, capsule]; - await capsuleService.setCapsuleArray(contract, baseSlot, newArray, jobId, scope); - expect(await capsuleService.readCapsuleArray(contract, baseSlot, jobId, scope)).toEqual(newArray); + await capsuleService.setCapsuleArray(contract, baseSlot, newArray, changeSetId, scope); + expect(await capsuleService.readCapsuleArray(contract, baseSlot, changeSetId, scope)).toEqual(newArray); }); it('address zero is always allowed even if not in the scopes list', async () => { const scope = AztecAddress.ZERO; // setCapsule + getCapsule - capsuleService.setCapsule(contract, slot, capsule, jobId, scope); - expect(await capsuleService.getCapsule(contract, slot, jobId, scope)).toEqual(capsule); + capsuleService.setCapsule(contract, slot, capsule, changeSetId, scope); + expect(await capsuleService.getCapsule(contract, slot, changeSetId, scope)).toEqual(capsule); // deleteCapsule - capsuleService.deleteCapsule(contract, slot, jobId, scope); - expect(await capsuleService.getCapsule(contract, slot, jobId, scope)).toBeNull(); + capsuleService.deleteCapsule(contract, slot, changeSetId, scope); + expect(await capsuleService.getCapsule(contract, slot, changeSetId, scope)).toBeNull(); // copyCapsule - capsuleService.setCapsule(contract, slot, capsule, jobId, scope); - await capsuleService.copyCapsule(contract, slot, new Fr(5), 1, jobId, scope); - expect(await capsuleService.getCapsule(contract, new Fr(5), jobId, scope)).toEqual(capsule); + capsuleService.setCapsule(contract, slot, capsule, changeSetId, scope); + await capsuleService.copyCapsule(contract, slot, new Fr(5), 1, changeSetId, scope); + expect(await capsuleService.getCapsule(contract, new Fr(5), changeSetId, scope)).toEqual(capsule); // appendToCapsuleArray + readCapsuleArray const baseSlot = new Fr(10); - await capsuleService.appendToCapsuleArray(contract, baseSlot, [capsule], jobId, scope); - expect(await capsuleService.readCapsuleArray(contract, baseSlot, jobId, scope)).toEqual([capsule]); + await capsuleService.appendToCapsuleArray(contract, baseSlot, [capsule], changeSetId, scope); + expect(await capsuleService.readCapsuleArray(contract, baseSlot, changeSetId, scope)).toEqual([capsule]); // setCapsuleArray const newArray = [capsule, capsule]; - await capsuleService.setCapsuleArray(contract, baseSlot, newArray, jobId, scope); - expect(await capsuleService.readCapsuleArray(contract, baseSlot, jobId, scope)).toEqual(newArray); + await capsuleService.setCapsuleArray(contract, baseSlot, newArray, changeSetId, scope); + expect(await capsuleService.readCapsuleArray(contract, baseSlot, changeSetId, scope)).toEqual(newArray); }); it('empty allowed scopes rejects requests', async () => { @@ -127,13 +127,13 @@ describe('CapsuleService', () => { const scope = allowedScope; const err = 'is not in the allowed scopes list'; - expect(() => noScopesService.setCapsule(contract, slot, capsule, jobId, scope)).toThrow(err); - await expect(noScopesService.getCapsule(contract, slot, jobId, scope)).rejects.toThrow(err); - expect(() => noScopesService.deleteCapsule(contract, slot, jobId, scope)).toThrow(err); - expect(() => noScopesService.copyCapsule(contract, slot, new Fr(5), 1, jobId, scope)).toThrow(err); - expect(() => noScopesService.appendToCapsuleArray(contract, slot, [capsule], jobId, scope)).toThrow(err); - expect(() => noScopesService.readCapsuleArray(contract, slot, jobId, scope)).toThrow(err); - expect(() => noScopesService.setCapsuleArray(contract, slot, [capsule], jobId, scope)).toThrow(err); + expect(() => noScopesService.setCapsule(contract, slot, capsule, changeSetId, scope)).toThrow(err); + await expect(noScopesService.getCapsule(contract, slot, changeSetId, scope)).rejects.toThrow(err); + expect(() => noScopesService.deleteCapsule(contract, slot, changeSetId, scope)).toThrow(err); + expect(() => noScopesService.copyCapsule(contract, slot, new Fr(5), 1, changeSetId, scope)).toThrow(err); + expect(() => noScopesService.appendToCapsuleArray(contract, slot, [capsule], changeSetId, scope)).toThrow(err); + expect(() => noScopesService.readCapsuleArray(contract, slot, changeSetId, scope)).toThrow(err); + expect(() => noScopesService.setCapsuleArray(contract, slot, [capsule], changeSetId, scope)).toThrow(err); }); }); }); diff --git a/yarn-project/pxe/src/storage/capsule_store/capsule_service.ts b/yarn-project/pxe/src/storage/capsule_store/capsule_service.ts index 1c3276247cab..79e094cd0480 100644 --- a/yarn-project/pxe/src/storage/capsule_store/capsule_service.ts +++ b/yarn-project/pxe/src/storage/capsule_store/capsule_service.ts @@ -3,6 +3,7 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { Capsule } from '@aztec/stdlib/tx'; import { assertAllowedScope } from '../allowed_scopes.js'; +import type { ChangeSetId } from '../staged_write_coordinator.js'; import type { CapsuleStore } from './capsule_store.js'; /** @@ -20,15 +21,15 @@ export class CapsuleService { this.allowedScopes = [...allowedScopes, AztecAddress.ZERO]; } - setCapsule(contractAddress: AztecAddress, slot: Fr, capsule: Fr[], jobId: string, scope: AztecAddress) { + setCapsule(contractAddress: AztecAddress, slot: Fr, capsule: Fr[], changeSetId: ChangeSetId, scope: AztecAddress) { assertAllowedScope(scope, this.allowedScopes); - this.capsuleStore.setCapsule(contractAddress, slot, capsule, jobId, scope); + this.capsuleStore.setCapsule(contractAddress, slot, capsule, changeSetId, scope); } async getCapsule( contractAddress: AztecAddress, slot: Fr, - jobId: string, + changeSetId: ChangeSetId, scope: AztecAddress, transientCapsules?: Capsule[], ): Promise { @@ -42,12 +43,12 @@ export class CapsuleService { (c.scope ?? AztecAddress.ZERO).equals(scope), )?.data; - return maybeTransientCapsule ?? (await this.capsuleStore.getCapsule(contractAddress, slot, jobId, scope)); + return maybeTransientCapsule ?? (await this.capsuleStore.getCapsule(contractAddress, slot, changeSetId, scope)); } - deleteCapsule(contractAddress: AztecAddress, slot: Fr, jobId: string, scope: AztecAddress) { + deleteCapsule(contractAddress: AztecAddress, slot: Fr, changeSetId: ChangeSetId, scope: AztecAddress) { assertAllowedScope(scope, this.allowedScopes); - this.capsuleStore.deleteCapsule(contractAddress, slot, jobId, scope); + this.capsuleStore.deleteCapsule(contractAddress, slot, changeSetId, scope); } copyCapsule( @@ -55,31 +56,42 @@ export class CapsuleService { srcSlot: Fr, dstSlot: Fr, numEntries: number, - jobId: string, + changeSetId: ChangeSetId, scope: AztecAddress, ): Promise { assertAllowedScope(scope, this.allowedScopes); - return this.capsuleStore.copyCapsule(contractAddress, srcSlot, dstSlot, numEntries, jobId, scope); + return this.capsuleStore.copyCapsule(contractAddress, srcSlot, dstSlot, numEntries, changeSetId, scope); } appendToCapsuleArray( contractAddress: AztecAddress, baseSlot: Fr, content: Fr[][], - jobId: string, + changeSetId: ChangeSetId, scope: AztecAddress, ): Promise { assertAllowedScope(scope, this.allowedScopes); - return this.capsuleStore.appendToCapsuleArray(contractAddress, baseSlot, content, jobId, scope); + return this.capsuleStore.appendToCapsuleArray(contractAddress, baseSlot, content, changeSetId, scope); } - readCapsuleArray(contractAddress: AztecAddress, baseSlot: Fr, jobId: string, scope: AztecAddress): Promise { + readCapsuleArray( + contractAddress: AztecAddress, + baseSlot: Fr, + changeSetId: ChangeSetId, + scope: AztecAddress, + ): Promise { assertAllowedScope(scope, this.allowedScopes); - return this.capsuleStore.readCapsuleArray(contractAddress, baseSlot, jobId, scope); + return this.capsuleStore.readCapsuleArray(contractAddress, baseSlot, changeSetId, scope); } - setCapsuleArray(contractAddress: AztecAddress, baseSlot: Fr, content: Fr[][], jobId: string, scope: AztecAddress) { + setCapsuleArray( + contractAddress: AztecAddress, + baseSlot: Fr, + content: Fr[][], + changeSetId: ChangeSetId, + scope: AztecAddress, + ) { assertAllowedScope(scope, this.allowedScopes); - return this.capsuleStore.setCapsuleArray(contractAddress, baseSlot, content, jobId, scope); + return this.capsuleStore.setCapsuleArray(contractAddress, baseSlot, content, changeSetId, scope); } } diff --git a/yarn-project/pxe/src/storage/capsule_store/capsule_store.test.ts b/yarn-project/pxe/src/storage/capsule_store/capsule_store.test.ts index 3090ec0b0295..0a64f65fb679 100644 --- a/yarn-project/pxe/src/storage/capsule_store/capsule_store.test.ts +++ b/yarn-project/pxe/src/storage/capsule_store/capsule_store.test.ts @@ -4,6 +4,7 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { AztecLMDBStoreV2, openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import type { ChangeSetId } from '../staged_write_coordinator.js'; import { CapsuleStore } from './capsule_store.js'; describe('capsule data provider', () => { @@ -388,7 +389,7 @@ describe('capsule data provider', () => { ); await store.transactionAsync(async () => { - await capsuleStore.commit('test'); + await capsuleStore.commitStaged('test'); }); }, TEST_TIMEOUT_MS, @@ -406,7 +407,7 @@ describe('capsule data provider', () => { ); await store.transactionAsync(async () => { - await capsuleStore.commit('test'); + await capsuleStore.commitStaged('test'); }); }, TEST_TIMEOUT_MS, @@ -424,7 +425,7 @@ describe('capsule data provider', () => { ); await store.transactionAsync(async () => { - await capsuleStore.commit('test'); + await capsuleStore.commitStaged('test'); }); // Append a single element @@ -437,7 +438,7 @@ describe('capsule data provider', () => { ); await store.transactionAsync(async () => { - await capsuleStore.commit('test'); + await capsuleStore.commitStaged('test'); }); }, TEST_TIMEOUT_MS, @@ -455,14 +456,14 @@ describe('capsule data provider', () => { ); await store.transactionAsync(async () => { - await capsuleStore.commit('test'); + await capsuleStore.commitStaged('test'); }); // We just move the entire thing one slot. await capsuleStore.copyCapsule(contract, new Fr(0), new Fr(1), NUMBER_OF_ITEMS, 'test', scope); await store.transactionAsync(async () => { - await capsuleStore.commit('test'); + await capsuleStore.commitStaged('test'); }); }, TEST_TIMEOUT_MS, @@ -480,13 +481,13 @@ describe('capsule data provider', () => { ); await store.transactionAsync(async () => { - await capsuleStore.commit('test'); + await capsuleStore.commitStaged('test'); }); await capsuleStore.readCapsuleArray(contract, new Fr(0), 'test', scope); await store.transactionAsync(async () => { - await capsuleStore.commit('test'); + await capsuleStore.commitStaged('test'); }); }, TEST_TIMEOUT_MS, @@ -504,13 +505,13 @@ describe('capsule data provider', () => { ); await store.transactionAsync(async () => { - await capsuleStore.commit('test'); + await capsuleStore.commitStaged('test'); }); await capsuleStore.setCapsuleArray(contract, new Fr(0), [], 'test', scope); await store.transactionAsync(async () => { - await capsuleStore.commit('test'); + await capsuleStore.commitStaged('test'); }); }, TEST_TIMEOUT_MS, @@ -519,109 +520,105 @@ describe('capsule data provider', () => { describe('staged writes', () => { it('commit does not hold zombie data', async () => { - // This test tries to reproduce a scenario where - // we fail to clear a job's data after commit. - // The effect of such an incorrect behavior would be perceived - // if we re-used a jobId we had previously committed, - // which should not happen given we generate random job id's, - // but it's good to keep things clean and consistent. + // This test tries to reproduce a scenario where we fail to clear a change set's data after commit. The effect of + // such an incorrect behavior would be perceived if we re-used a changeSetId we had previously committed, which + // should not happen given we generate random change set ids, but it's good to keep things clean and consistent. const slot = Fr.random(); const committedValues1 = [Fr.random()]; const committedValues2 = [Fr.random()]; - capsuleStore.setCapsule(contract, slot, committedValues1, 'job-1', scope); + capsuleStore.setCapsule(contract, slot, committedValues1, 'change-set-1', scope); - // After this commit, 'job-1' should logically be reset + // After this commit, 'change-set-1' should logically be reset // Any read of contract-slot after this should see committedValues1 - await capsuleStore.commit('job-1'); + await capsuleStore.commitStaged('change-set-1'); - // Any read of contract-slot should see job2committedValues - capsuleStore.setCapsule(contract, slot, committedValues2, 'job-2', scope); - await capsuleStore.commit('job-2'); + // Any read of contract-slot should see committedValues2 + capsuleStore.setCapsule(contract, slot, committedValues2, 'change-set-2', scope); + await capsuleStore.commitStaged('change-set-2'); - // If we failed to properly dispose 'job-1's staged writes on commit, - // Instead of reading committedValues2 (as we should), we would end - // up reading committedValues1 (which would be wrong) - expect(await capsuleStore.getCapsule(contract, slot, 'job-1', scope)).toEqual(committedValues2); + // If we failed to properly dispose 'change-set-1's staged writes on commit, Instead of reading committedValues2 + // (as we should), we would end up reading committedValues1 (which would be wrong) + expect(await capsuleStore.getCapsule(contract, slot, 'change-set-1', scope)).toEqual(committedValues2); }); - it('writes to job view are isolated from another job view', async () => { + it('writes to one change set view are isolated from another change set view', async () => { const slot = Fr.random(); const committedValues = [Fr.random()]; const stagedValues = [Fr.random()]; - const commitJobId: string = 'commit-job'; - const stagedJob1: string = 'staged-job-1'; - const stagedJob2: string = 'staged-job-2'; + const commitChangeSetId: ChangeSetId = 'commit-change-set'; + const stagedWrites1: string = 'staged-writes-1'; + const stagedWrites2: string = 'staged-writes-2'; - // First set a committed capsule (using a different job that we commit) - capsuleStore.setCapsule(contract, slot, committedValues, commitJobId, scope); - await capsuleStore.commit(commitJobId); + // First set a committed capsule (using a different change set that we commit) + capsuleStore.setCapsule(contract, slot, committedValues, commitChangeSetId, scope); + await capsuleStore.commitStaged(commitChangeSetId); // Then set a staged capsule (not committed) - capsuleStore.setCapsule(contract, slot, stagedValues, stagedJob1, scope); + capsuleStore.setCapsule(contract, slot, stagedValues, stagedWrites1, scope); - // With jobId=1, should get staged capsule - expect(await capsuleStore.getCapsule(contract, slot, stagedJob1, scope)).toEqual(stagedValues); + // With changeSetId=1, should get staged capsule + expect(await capsuleStore.getCapsule(contract, slot, stagedWrites1, scope)).toEqual(stagedValues); - // With jobId=2, should get committed capsule - expect(await capsuleStore.getCapsule(contract, slot, stagedJob2, scope)).toEqual(committedValues); + // With changeSetId=2, should get committed capsule + expect(await capsuleStore.getCapsule(contract, slot, stagedWrites2, scope)).toEqual(committedValues); }); it('staged deletions hide committed data', async () => { const slot = Fr.random(); const committedValues = [Fr.random()]; - const commitJobId: string = 'commit-job'; - const stagedJob1: string = 'staged-job-1'; - const stagedJob2: string = 'staged-job-2'; + const commitChangeSetId: ChangeSetId = 'commit-change-set'; + const stagedWrites1: string = 'staged-writes-1'; + const stagedWrites2: string = 'staged-writes-2'; // First set a committed capsule - capsuleStore.setCapsule(contract, slot, committedValues, commitJobId, scope); - await capsuleStore.commit(commitJobId); + capsuleStore.setCapsule(contract, slot, committedValues, commitChangeSetId, scope); + await capsuleStore.commitStaged(commitChangeSetId); - // Delete in staging (not committed) - capsuleStore.deleteCapsule(contract, slot, stagedJob1, scope); + // Delete in change set (not committed) + capsuleStore.deleteCapsule(contract, slot, stagedWrites1, scope); - // Without jobId=2, should still see committed capsule - expect(await capsuleStore.getCapsule(contract, slot, stagedJob2, scope)).toEqual(committedValues); + // Without changeSetId=2, should still see committed capsule + expect(await capsuleStore.getCapsule(contract, slot, stagedWrites2, scope)).toEqual(committedValues); - // With jobId=1, should see null (deleted in staging) - expect(await capsuleStore.getCapsule(contract, slot, stagedJob1, scope)).toBeNull(); + // With changeSetId=1, should see null (deleted in change set) + expect(await capsuleStore.getCapsule(contract, slot, stagedWrites1, scope)).toBeNull(); }); it('commit applies staged deletions', async () => { const slot = Fr.random(); const committedValues = [Fr.random()]; - const commitJobId: string = 'commit-job'; - const deleteJobId: string = 'delete-job'; + const commitChangeSetId: ChangeSetId = 'commit-change-set'; + const deleteChangeSetId: ChangeSetId = 'delete-change-set'; - capsuleStore.setCapsule(contract, slot, committedValues, commitJobId, scope); - await capsuleStore.commit(commitJobId); - capsuleStore.deleteCapsule(contract, slot, deleteJobId, scope); + capsuleStore.setCapsule(contract, slot, committedValues, commitChangeSetId, scope); + await capsuleStore.commitStaged(commitChangeSetId); + capsuleStore.deleteCapsule(contract, slot, deleteChangeSetId, scope); - await capsuleStore.commit(deleteJobId); + await capsuleStore.commitStaged(deleteChangeSetId); - // Now any job should see this null (deleted) - expect(await capsuleStore.getCapsule(contract, slot, 'any-job-sees-this', scope)).toBeNull(); + // Now any change set should see this null (deleted) + expect(await capsuleStore.getCapsule(contract, slot, 'any-change-set-sees-this', scope)).toBeNull(); }); it('discardStaged removes staged data without affecting main', async () => { const slot = Fr.random(); const committedValues = [Fr.random()]; const stagedValues = [Fr.random()]; - const commitJobId: string = 'commit-job'; - const stagingJobId: string = 'staging-job'; + const commitChangeSetId: ChangeSetId = 'commit-change-set'; + const stagedChangeSetId: ChangeSetId = 'staged'; - capsuleStore.setCapsule(contract, slot, committedValues, commitJobId, scope); - await capsuleStore.commit(commitJobId); - capsuleStore.setCapsule(contract, slot, stagedValues, stagingJobId, scope); + capsuleStore.setCapsule(contract, slot, committedValues, commitChangeSetId, scope); + await capsuleStore.commitStaged(commitChangeSetId); + capsuleStore.setCapsule(contract, slot, stagedValues, stagedChangeSetId, scope); - await capsuleStore.discardStaged(stagingJobId); + await capsuleStore.discardStaged(stagedChangeSetId); // Should still get committed capsule - expect(await capsuleStore.getCapsule(contract, slot, 'any-job', scope)).toEqual(committedValues); + expect(await capsuleStore.getCapsule(contract, slot, 'any-change-set', scope)).toEqual(committedValues); - // With stagingJobId should fall back to committed since staging was discarded - expect(await capsuleStore.getCapsule(contract, slot, stagingJobId, scope)).toEqual(committedValues); + // With stagedChangeSetId should fall back to committed since change set was discarded + expect(await capsuleStore.getCapsule(contract, slot, stagedChangeSetId, scope)).toEqual(committedValues); }); }); }); diff --git a/yarn-project/pxe/src/storage/capsule_store/capsule_store.ts b/yarn-project/pxe/src/storage/capsule_store/capsule_store.ts index 944bbee6e70c..6b369ea9cbde 100644 --- a/yarn-project/pxe/src/storage/capsule_store/capsule_store.ts +++ b/yarn-project/pxe/src/storage/capsule_store/capsule_store.ts @@ -3,7 +3,7 @@ import { type Logger, createLogger } from '@aztec/foundation/log'; import type { AztecAsyncKVStore, AztecAsyncMap } from '@aztec/kv-store'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import type { StagedStore } from '../../job_coordinator/job_coordinator.js'; +import type { ChangeSetId, StagedStore } from '../staged_write_coordinator.js'; export class CapsuleStore implements StagedStore { readonly storeName = 'capsule'; @@ -14,10 +14,10 @@ export class CapsuleStore implements StagedStore { // address for the global scope. #capsules: AztecAsyncMap; - // jobId => `${contractAddress}:${scope}:${key}` => capsule data - // when `#stagedCapsules.get('some-job-id').get('${some-contract-address}:${some-scope}:${some-key}') === null`, - // it signals that the capsule was deleted during the job, so it needs to be deleted on commit - #stagedCapsules: Map>; + // changeSetId => `${contractAddress}:${scope}:${key}` => capsule data + // when `#stagedCapsules.get('some-change-set-id').get('${some-contract-address}:${some-scope}:${some-key}')` is + // null, it signals that the capsule was deleted during the change set, so it needs to be deleted on commit + #stagedCapsules: Map>; logger: Logger; @@ -32,35 +32,34 @@ export class CapsuleStore implements StagedStore { } /** - * Given a job denoted by `jobId`, it returns the - * capsules that said job has interacted with. + * Given a change set denoted by `changeSetId`, it returns the capsules that said change set has interacted with. * * Capsules that haven't been committed to persistence KV storage * are kept in-memory in `#stagedCapsules`, this method provides a convenient * way to access that in-memory collection of data. * - * @param jobId + * @param changeSetId * @returns */ - #getJobStagedCapsules(jobId: string): Map { - let jobStagedCapsules = this.#stagedCapsules.get(jobId); - if (!jobStagedCapsules) { - jobStagedCapsules = new Map(); - this.#stagedCapsules.set(jobId, jobStagedCapsules); + #getStagedCapsules(changeSetId: ChangeSetId): Map { + let stagedCapsules = this.#stagedCapsules.get(changeSetId); + if (!stagedCapsules) { + stagedCapsules = new Map(); + this.#stagedCapsules.set(changeSetId, stagedCapsules); } - return jobStagedCapsules; + return stagedCapsules; } /** - * Reads a capsule's slot from the staged version of the data associated to the given jobId. + * Reads a capsule's slot from the staged version of the data associated to the given changeSetId. * * If it is not there, it reads it from the KV store. */ - async #getFromStage(jobId: string, dbSlotKey: string): Promise { - const jobStagedCapsules = this.#getJobStagedCapsules(jobId); - const staged: Buffer | null | undefined = jobStagedCapsules.get(dbSlotKey); + async #getFromStage(changeSetId: ChangeSetId, dbSlotKey: string): Promise { + const stagedCapsules = this.#getStagedCapsules(changeSetId); + const staged: Buffer | null | undefined = stagedCapsules.get(dbSlotKey); - // Always issue DB read to keep IndexedDB transaction alive, even if the value is in the job staged data. This + // Always issue DB read to keep IndexedDB transaction alive, even if the value is in the staged data. This // keeps IndexedDB transactions alive (they auto-commit when a new micro-task starts and there are no pending read // requests). The staged value still takes precedence if it exists (including null for deletions). const dbValue = await this.#loadCapsuleFromDb(dbSlotKey); @@ -69,18 +68,18 @@ export class CapsuleStore implements StagedStore { } /** - * Writes a capsule to the stage of a job. + * Writes a capsule to the staging area. */ - #setOnStage(jobId: string, dbSlotKey: string, capsuleData: Buffer) { - this.#getJobStagedCapsules(jobId).set(dbSlotKey, capsuleData); + #setOnStage(changeSetId: ChangeSetId, dbSlotKey: string, capsuleData: Buffer) { + this.#getStagedCapsules(changeSetId).set(dbSlotKey, capsuleData); } /** - * Deletes a capsule on the stage of a job. Note the capsule will still - * exist in storage until the job is committed. + * Deletes a capsule on the staging area. Note the capsule will still + * exist in storage until the change set is committed. */ - #deleteOnStage(jobId: string, dbSlotKey: string) { - this.#getJobStagedCapsules(jobId).set(dbSlotKey, null); + #deleteOnStage(changeSetId: ChangeSetId, dbSlotKey: string) { + this.#getStagedCapsules(changeSetId).set(dbSlotKey, null); } async #loadCapsuleFromDb(dbSlotKey: string): Promise { @@ -93,16 +92,15 @@ export class CapsuleStore implements StagedStore { } /** - * Commits staged data to main storage. - * Called by JobCoordinator when a job completes successfully. - * Note: JobCoordinator wraps all commits in a single transaction, so we don't - * need our own transactionAsync here (and using one would deadlock on IndexedDB). - * @param jobId - The jobId identifying which staged data to commit + * Commits staged data to main storage. Called by StagedWriteCoordinator when an operation completes successfully. + * Note: StagedWriteCoordinator wraps all commits in a single transaction, so we don't need our own transactionAsync + * here (and using one would deadlock on IndexedDB). + * @param changeSetId - The changeSetId identifying which staged data to commit */ - async commit(jobId: string): Promise { - const jobStagedCapsules = this.#getJobStagedCapsules(jobId); + async commitStaged(changeSetId: ChangeSetId): Promise { + const stagedCapsules = this.#getStagedCapsules(changeSetId); - for (const [key, value] of jobStagedCapsules) { + for (const [key, value] of stagedCapsules) { // In the write stage, we represent deleted capsules with null // (as opposed to undefined, which denotes there was never a capsule there to begin with). // So we delete from actual KV store here. @@ -113,14 +111,14 @@ export class CapsuleStore implements StagedStore { } } - this.#stagedCapsules.delete(jobId); + this.#stagedCapsules.delete(changeSetId); } /** * Discards staged data without committing. */ - discardStaged(jobId: string): Promise { - this.#stagedCapsules.delete(jobId); + discardStaged(changeSetId: ChangeSetId): Promise { + this.#stagedCapsules.delete(changeSetId); return Promise.resolve(); } @@ -130,16 +128,17 @@ export class CapsuleStore implements StagedStore { * @param contractAddress - The contract address to scope the data under. * @param slot - The slot in the database in which to store the value. Slots need not be contiguous. * @param capsule - An array of field elements representing the capsule. - * @param jobId - The context in which this store will be visible until PXE decides to persist it to underlying KV store + * @param changeSetId - The context in which this store will be visible until PXE decides to persist it to underlying + * KV store * @remarks A capsule is a "blob" of data that is passed to the contract through an oracle. It works similarly * to public contract storage in that it's indexed by the contract address and storage slot but instead of the global * network state it's backed by local PXE db. */ - setCapsule(contractAddress: AztecAddress, slot: Fr, capsule: Fr[], jobId: string, scope: AztecAddress) { + setCapsule(contractAddress: AztecAddress, slot: Fr, capsule: Fr[], changeSetId: ChangeSetId, scope: AztecAddress) { const dbSlotKey = dbSlotToKey(contractAddress, slot, scope); // A store overrides any pre-existing data on the slot - this.#setOnStage(jobId, dbSlotKey, Buffer.concat(capsule.map(value => value.toBuffer()))); + this.#setOnStage(changeSetId, dbSlotKey, Buffer.concat(capsule.map(value => value.toBuffer()))); } /** @@ -148,18 +147,23 @@ export class CapsuleStore implements StagedStore { * @param slot - The slot in the database to read. * @returns The stored data or `null` if no data is stored under the slot. */ - getCapsule(contractAddress: AztecAddress, slot: Fr, jobId: string, scope: AztecAddress): Promise { - return this.#store.transactionAsync(() => this.#getCapsuleInternal(contractAddress, slot, jobId, scope)); + getCapsule( + contractAddress: AztecAddress, + slot: Fr, + changeSetId: ChangeSetId, + scope: AztecAddress, + ): Promise { + return this.#store.transactionAsync(() => this.#getCapsuleInternal(contractAddress, slot, changeSetId, scope)); } /** Same as getCapsule but without its own transaction, for use inside an existing transactionAsync. */ async #getCapsuleInternal( contractAddress: AztecAddress, slot: Fr, - jobId: string, + changeSetId: ChangeSetId, scope: AztecAddress, ): Promise { - const dataBuffer = await this.#getFromStage(jobId, dbSlotToKey(contractAddress, slot, scope)); + const dataBuffer = await this.#getFromStage(changeSetId, dbSlotToKey(contractAddress, slot, scope)); if (!dataBuffer) { this.logger.trace(`Data not found for contract ${contractAddress.toString()} and slot ${slot.toString()}`); return null; @@ -176,9 +180,9 @@ export class CapsuleStore implements StagedStore { * @param contractAddress - The contract address under which the data is scoped. * @param slot - The slot in the database to delete. */ - deleteCapsule(contractAddress: AztecAddress, slot: Fr, jobId: string, scope: AztecAddress) { + deleteCapsule(contractAddress: AztecAddress, slot: Fr, changeSetId: ChangeSetId, scope: AztecAddress) { // When we commit this, we will interpret null as a deletion, so we'll propagate the delete to the KV store - this.#deleteOnStage(jobId, dbSlotToKey(contractAddress, slot, scope)); + this.#deleteOnStage(changeSetId, dbSlotToKey(contractAddress, slot, scope)); } /** @@ -197,7 +201,7 @@ export class CapsuleStore implements StagedStore { srcSlot: Fr, dstSlot: Fr, numEntries: number, - jobId: string, + changeSetId: ChangeSetId, scope: AztecAddress, ): Promise { // This transactional context gives us "copy atomicity": @@ -218,12 +222,12 @@ export class CapsuleStore implements StagedStore { const currentSrcSlot = dbSlotToKey(contractAddress, srcSlot.add(new Fr(i)), scope); const currentDstSlot = dbSlotToKey(contractAddress, dstSlot.add(new Fr(i)), scope); - const toCopy = await this.#getFromStage(jobId, currentSrcSlot); + const toCopy = await this.#getFromStage(changeSetId, currentSrcSlot); if (!toCopy) { throw new Error(`Attempted to copy empty slot ${currentSrcSlot} for contract ${contractAddress.toString()}`); } - this.#setOnStage(jobId, currentDstSlot, toCopy); + this.#setOnStage(changeSetId, currentDstSlot, toCopy); } }); } @@ -240,7 +244,7 @@ export class CapsuleStore implements StagedStore { contractAddress: AztecAddress, baseSlot: Fr, content: Fr[][], - jobId: string, + changeSetId: ChangeSetId, scope: AztecAddress, ): Promise { // We wrap this in a transaction to serialize concurrent calls from allToCompletion. @@ -250,37 +254,46 @@ export class CapsuleStore implements StagedStore { // and not using a transaction here would heavily impact performance. return this.#store.transactionAsync(async () => { // Load current length, defaulting to 0 if not found - const lengthData = await this.#getCapsuleInternal(contractAddress, baseSlot, jobId, scope); + const lengthData = await this.#getCapsuleInternal(contractAddress, baseSlot, changeSetId, scope); const currentLength = lengthData ? lengthData[0].toNumber() : 0; // Store each capsule at consecutive slots after baseSlot + 1 + currentLength for (let i = 0; i < content.length; i++) { const nextSlot = arraySlot(baseSlot, currentLength + i); - this.setCapsule(contractAddress, nextSlot, content[i], jobId, scope); + this.setCapsule(contractAddress, nextSlot, content[i], changeSetId, scope); } // Update length to include all new capsules const newLength = currentLength + content.length; - this.setCapsule(contractAddress, baseSlot, [new Fr(newLength)], jobId, scope); + this.setCapsule(contractAddress, baseSlot, [new Fr(newLength)], changeSetId, scope); }); } - readCapsuleArray(contractAddress: AztecAddress, baseSlot: Fr, jobId: string, scope: AztecAddress): Promise { - // I'm leaving this transactional context here though because I'm assuming this - // gives us "read array atomicity": there shouldn't be concurrent writes to what's being copied - // here. - // This is one point we should revisit in the future if we want to relax the concurrency - // of jobs: different calls running concurrently on the same contract may cause trouble. + readCapsuleArray( + contractAddress: AztecAddress, + baseSlot: Fr, + changeSetId: ChangeSetId, + scope: AztecAddress, + ): Promise { + // I'm leaving this transactional context here though because I'm assuming this gives us "read array atomicity": + // there shouldn't be concurrent writes to what's being copied here. This is one point we should revisit in the + // future if we want to relax the concurrency of change sets: different calls running concurrently on the same + // contract may cause trouble. return this.#store.transactionAsync(async () => { // Load length, defaulting to 0 if not found - const maybeLength = await this.#getCapsuleInternal(contractAddress, baseSlot, jobId, scope); + const maybeLength = await this.#getCapsuleInternal(contractAddress, baseSlot, changeSetId, scope); const length = maybeLength ? maybeLength[0].toBigInt() : 0n; const values: Fr[][] = []; // Read each capsule at consecutive slots after baseSlot for (let i = 0; i < length; i++) { - const currentValue = await this.#getCapsuleInternal(contractAddress, arraySlot(baseSlot, i), jobId, scope); + const currentValue = await this.#getCapsuleInternal( + contractAddress, + arraySlot(baseSlot, i), + changeSetId, + scope, + ); if (currentValue == undefined) { throw new Error( `Expected non-empty value at capsule array in base slot ${baseSlot} at index ${i} for contract ${contractAddress}`, @@ -294,31 +307,35 @@ export class CapsuleStore implements StagedStore { }); } - setCapsuleArray(contractAddress: AztecAddress, baseSlot: Fr, content: Fr[][], jobId: string, scope: AztecAddress) { - // This transactional context in theory isn't so critical now because we aren't - // writing to DB so if there's exceptions midway and it blows up, no visible impact - // to persistent storage will happen. - // I'm leaving this transactional context here though because I'm assuming this - // gives us "write array atomicity": there shouldn't be concurrent writes to what's being copied - // here. - // This is one point we should revisit in the future if we want to relax the concurrency - // of jobs: different calls running concurrently on the same contract may cause trouble. + setCapsuleArray( + contractAddress: AztecAddress, + baseSlot: Fr, + content: Fr[][], + changeSetId: ChangeSetId, + scope: AztecAddress, + ) { + // This transactional context in theory isn't so critical now because we aren't writing to DB so if there's + // exceptions midway and it blows up, no visible impact to persistent storage will happen. I'm leaving this + // transactional context here though because I'm assuming this gives us "write array atomicity": there shouldn't be + // concurrent writes to what's being copied here. This is one point we should revisit in the future if we want to + // relax the concurrency of change sets: different calls running concurrently on the same contract may cause + // trouble. return this.#store.transactionAsync(async () => { // Load current length, defaulting to 0 if not found - const maybeLength = await this.#getCapsuleInternal(contractAddress, baseSlot, jobId, scope); + const maybeLength = await this.#getCapsuleInternal(contractAddress, baseSlot, changeSetId, scope); const originalLength = maybeLength ? maybeLength[0].toNumber() : 0; // Set the new length - this.setCapsule(contractAddress, baseSlot, [new Fr(content.length)], jobId, scope); + this.setCapsule(contractAddress, baseSlot, [new Fr(content.length)], changeSetId, scope); // Store the new content, possibly overwriting existing values for (let i = 0; i < content.length; i++) { - this.setCapsule(contractAddress, arraySlot(baseSlot, i), content[i], jobId, scope); + this.setCapsule(contractAddress, arraySlot(baseSlot, i), content[i], changeSetId, scope); } // Clear any stragglers for (let i = content.length; i < originalLength; i++) { - this.deleteCapsule(contractAddress, arraySlot(baseSlot, i), jobId, scope); + this.deleteCapsule(contractAddress, arraySlot(baseSlot, i), changeSetId, scope); } }); } diff --git a/yarn-project/pxe/src/storage/fact_store/fact_service.test.ts b/yarn-project/pxe/src/storage/fact_store/fact_service.test.ts index af356769c266..2f25cbac20e8 100644 --- a/yarn-project/pxe/src/storage/fact_store/fact_service.test.ts +++ b/yarn-project/pxe/src/storage/fact_store/fact_service.test.ts @@ -17,7 +17,7 @@ describe('FactService', () => { let kv: AztecAsyncKVStore; let store: FactStore; - const jobId = 'job-1'; + const changeSetId = 'change-set-1'; const contract = AztecAddress.fromFieldUnsafe(new Fr(1)); const allowedScope = AztecAddress.fromFieldUnsafe(new Fr(2)); const disallowedScope = AztecAddress.fromFieldUnsafe(new Fr(3)); @@ -38,17 +38,17 @@ describe('FactService', () => { it('delegates record+get for an allowed scope', async () => { const service = new FactService(store, [allowedScope]); - await service.recordFact(factCollectionKey, factTypeId, [factPayload], undefined, jobId); + await service.recordFact(factCollectionKey, factTypeId, [factPayload], undefined, changeSetId); - const collection = await service.getFactCollection(factCollectionKey, makeTips(0, 0), jobId); + const collection = await service.getFactCollection(factCollectionKey, makeTips(0, 0), changeSetId); expect(collection?.facts).toEqual([{ factTypeId, payload: [factPayload], originBlock: undefined }]); }); it('delegates getFactCollectionsByType for an allowed scope', async () => { const service = new FactService(store, [allowedScope]); - await service.recordFact(factCollectionKey, factTypeId, [factPayload], undefined, jobId); + await service.recordFact(factCollectionKey, factTypeId, [factPayload], undefined, changeSetId); - const collections = await service.getFactCollectionsByType(factCollectionTypeKey, makeTips(0, 0), jobId); + const collections = await service.getFactCollectionsByType(factCollectionTypeKey, makeTips(0, 0), changeSetId); expect(collections).toEqual([ { key: factCollectionKey, facts: [{ factTypeId, payload: [factPayload], originBlock: undefined }] }, ]); @@ -56,44 +56,46 @@ describe('FactService', () => { it('delegates deleteFactCollection for an allowed scope', async () => { const service = new FactService(store, [allowedScope]); - await service.recordFact(factCollectionKey, factTypeId, [factPayload], undefined, jobId); - await service.deleteFactCollection(factCollectionKey, jobId); + await service.recordFact(factCollectionKey, factTypeId, [factPayload], undefined, changeSetId); + await service.deleteFactCollection(factCollectionKey, changeSetId); - expect(await service.getFactCollection(factCollectionKey, makeTips(0, 0), jobId)).toBeUndefined(); + expect(await service.getFactCollection(factCollectionKey, makeTips(0, 0), changeSetId)).toBeUndefined(); }); it('rejects a disallowed scope on recordFact', () => { const service = new FactService(store, [allowedScope]); - expect(() => service.recordFact(disallowedCollectionKey, factTypeId, [factPayload], undefined, jobId)).toThrow( - /not in the allowed scopes/, - ); + expect(() => + service.recordFact(disallowedCollectionKey, factTypeId, [factPayload], undefined, changeSetId), + ).toThrow(/not in the allowed scopes/); }); it('rejects a disallowed scope on deleteFactCollection', () => { const service = new FactService(store, [allowedScope]); - expect(() => service.deleteFactCollection(disallowedCollectionKey, jobId)).toThrow(/not in the allowed scopes/); + expect(() => service.deleteFactCollection(disallowedCollectionKey, changeSetId)).toThrow( + /not in the allowed scopes/, + ); }); it('rejects a disallowed scope on getFactCollection', async () => { const service = new FactService(store, [allowedScope]); - await expect(service.getFactCollection(disallowedCollectionKey, makeTips(0, 0), jobId)).rejects.toThrow( + await expect(service.getFactCollection(disallowedCollectionKey, makeTips(0, 0), changeSetId)).rejects.toThrow( /not in the allowed scopes/, ); }); it('rejects a disallowed scope on getFactCollectionsByType', async () => { const service = new FactService(store, [allowedScope]); - await expect(service.getFactCollectionsByType(disallowedCollectionTypeKey, makeTips(0, 0), jobId)).rejects.toThrow( - /not in the allowed scopes/, - ); + await expect( + service.getFactCollectionsByType(disallowedCollectionTypeKey, makeTips(0, 0), changeSetId), + ).rejects.toThrow(/not in the allowed scopes/); }); it('annotates a retractable fact with its origin block state', async () => { const service = new FactService(store, [allowedScope]); const blockHash = new Fr(123); - await service.recordFact(factCollectionKey, factTypeId, [factPayload], { blockNumber: 4, blockHash }, jobId); + await service.recordFact(factCollectionKey, factTypeId, [factPayload], { blockNumber: 4, blockHash }, changeSetId); - const collection = await service.getFactCollection(factCollectionKey, makeTips(5, 10), jobId); + const collection = await service.getFactCollection(factCollectionKey, makeTips(5, 10), changeSetId); expect(collection?.facts).toEqual([ { factTypeId, diff --git a/yarn-project/pxe/src/storage/fact_store/fact_service.ts b/yarn-project/pxe/src/storage/fact_store/fact_service.ts index 8f4485cce7a3..b9b8eec21c0f 100644 --- a/yarn-project/pxe/src/storage/fact_store/fact_service.ts +++ b/yarn-project/pxe/src/storage/fact_store/fact_service.ts @@ -2,6 +2,7 @@ import type { Fr } from '@aztec/foundation/curves/bn254'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import { assertAllowedScope } from '../allowed_scopes.js'; +import type { ChangeSetId } from '../staged_write_coordinator.js'; import type { FactStore } from './fact_store.js'; import type { FactCollectionKey, FactCollectionTypeKey, OriginBlock } from './fact_store_keys.js'; import { @@ -29,24 +30,24 @@ export class FactService { factTypeId: Fr, payload: Fr[], originBlock: OriginBlock | undefined, - jobId: string, + changeSetId: ChangeSetId, ): Promise { assertAllowedScope(factCollectionKey.scope, this.allowedScopes); - return this.factStore.recordFact(factCollectionKey, factTypeId, payload, originBlock, jobId); + return this.factStore.recordFact(factCollectionKey, factTypeId, payload, originBlock, changeSetId); } - deleteFactCollection(factCollectionKey: FactCollectionKey, jobId: string): Promise { + deleteFactCollection(factCollectionKey: FactCollectionKey, changeSetId: ChangeSetId): Promise { assertAllowedScope(factCollectionKey.scope, this.allowedScopes); - return this.factStore.deleteFactCollection(factCollectionKey, jobId); + return this.factStore.deleteFactCollection(factCollectionKey, changeSetId); } async getFactCollection( factCollectionKey: FactCollectionKey, tips: TipBlockNumbers, - jobId: string, + changeSetId: ChangeSetId, ): Promise { assertAllowedScope(factCollectionKey.scope, this.allowedScopes); - const collection = await this.factStore.getFactCollection(factCollectionKey, jobId); + const collection = await this.factStore.getFactCollection(factCollectionKey, changeSetId); if (!collection) { return undefined; } @@ -56,10 +57,10 @@ export class FactService { async getFactCollectionsByType( factCollectionTypeKey: FactCollectionTypeKey, tips: TipBlockNumbers, - jobId: string, + changeSetId: ChangeSetId, ): Promise { assertAllowedScope(factCollectionTypeKey.scope, this.allowedScopes); - const collections = await this.factStore.getFactCollectionsByType(factCollectionTypeKey, jobId); + const collections = await this.factStore.getFactCollectionsByType(factCollectionTypeKey, changeSetId); return collections.map(collection => ({ key: collection.key, facts: this.#annotate(collection.facts, tips) })); } diff --git a/yarn-project/pxe/src/storage/fact_store/fact_store.test.ts b/yarn-project/pxe/src/storage/fact_store/fact_store.test.ts index a21ff297ed86..ea1c2f306090 100644 --- a/yarn-project/pxe/src/storage/fact_store/fact_store.test.ts +++ b/yarn-project/pxe/src/storage/fact_store/fact_store.test.ts @@ -20,7 +20,7 @@ describe('FactStore', () => { let collectionKey1ScopeB: FactCollectionKey; let typeKey: FactCollectionTypeKey; let typeKeyScopeB: FactCollectionTypeKey; - const JOB = 'fact-store-test-job'; + const CHANGE_SET = 'fact-store-test-change-set'; let kv: AztecAsyncKVStore; let store: FactStore; @@ -67,33 +67,33 @@ describe('FactStore', () => { describe('recording and reading', () => { it('records facts and reads a collection back after commit (implicit collection creation)', async () => { - await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, JOB); - await store.recordFact(collectionKey1, factTypeB, [], { blockNumber: 5, blockHash: Fr.random() }, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, CHANGE_SET); + await store.recordFact(collectionKey1, factTypeB, [], { blockNumber: 5, blockHash: Fr.random() }, CHANGE_SET); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - const { facts } = (await store.getFactCollection(collectionKey1, JOB))!; + const { facts } = (await store.getFactCollection(collectionKey1, CHANGE_SET))!; expect(hexSet(facts.map(f => f.factTypeId))).toEqual(hexSet([factTypeA, factTypeB])); }); it('getFactCollection returns undefined when no collection exists', async () => { - expect(await store.getFactCollection(collectionKey1, JOB)).toBeUndefined(); + expect(await store.getFactCollection(collectionKey1, CHANGE_SET)).toBeUndefined(); }); it('lists collections via getFactCollectionsByType', async () => { - await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, JOB); - await store.recordFact(collectionKey2, factTypeA, [Fr.random()], undefined, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, CHANGE_SET); + await store.recordFact(collectionKey2, factTypeA, [Fr.random()], undefined, CHANGE_SET); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - const collections = await store.getFactCollectionsByType(typeKey, JOB); + const collections = await store.getFactCollectionsByType(typeKey, CHANGE_SET); expect(hexSet(collectionIdsOf(collections))).toEqual(hexSet([collectionId1, collectionId2])); }); it('getFactCollectionsByType returns each collection complete with its facts', async () => { - await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, JOB); - await store.recordFact(collectionKey1, factTypeB, [Fr.random()], undefined, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, CHANGE_SET); + await store.recordFact(collectionKey1, factTypeB, [Fr.random()], undefined, CHANGE_SET); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - const collections = await store.getFactCollectionsByType(typeKey, JOB); + const collections = await store.getFactCollectionsByType(typeKey, CHANGE_SET); expect(collections).toHaveLength(1); expect(hexSet(collections[0].facts.map(f => f.factTypeId))).toEqual(hexSet([factTypeA, factTypeB])); }); @@ -102,141 +102,157 @@ describe('FactStore', () => { describe('idempotency and dedup', () => { it('dedups identical (collection, factType, payload, originBlock) fact records', async () => { const payload = Fr.random(); - await store.recordFact(collectionKey1, factTypeA, [payload], undefined, JOB); - await store.recordFact(collectionKey1, factTypeA, [payload], undefined, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact(collectionKey1, factTypeA, [payload], undefined, CHANGE_SET); + await store.recordFact(collectionKey1, factTypeA, [payload], undefined, CHANGE_SET); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - expect((await store.getFactCollection(collectionKey1, JOB))!.facts).toHaveLength(1); + expect((await store.getFactCollection(collectionKey1, CHANGE_SET))!.facts).toHaveLength(1); }); it('the same payload at a different origin block is a distinct fact', async () => { const payload = Fr.random(); - await store.recordFact(collectionKey1, factTypeA, [payload], { blockNumber: 5, blockHash: Fr.random() }, JOB); - await store.recordFact(collectionKey1, factTypeA, [payload], { blockNumber: 10, blockHash: Fr.random() }, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact( + collectionKey1, + factTypeA, + [payload], + { blockNumber: 5, blockHash: Fr.random() }, + CHANGE_SET, + ); + await store.recordFact( + collectionKey1, + factTypeA, + [payload], + { blockNumber: 10, blockHash: Fr.random() }, + CHANGE_SET, + ); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - const { facts } = (await store.getFactCollection(collectionKey1, JOB))!; + const { facts } = (await store.getFactCollection(collectionKey1, CHANGE_SET))!; expect(facts).toHaveLength(2); expect(new Set(facts.map(f => f.originBlock?.blockNumber))).toEqual(new Set([5, 10])); }); - it('re-recording an identical fact across jobs is a no-op', async () => { + it('re-recording an identical fact across change sets is a no-op', async () => { const payload = Fr.random(); - await store.recordFact(collectionKey1, factTypeA, [payload], undefined, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact(collectionKey1, factTypeA, [payload], undefined, CHANGE_SET); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - const JOB2 = 'rerecord-job'; - await store.recordFact(collectionKey1, factTypeA, [payload], undefined, JOB2); - await kv.transactionAsync(() => store.commit(JOB2)); + const CHANGE_SET_2 = 'rerecord-change-set'; + await store.recordFact(collectionKey1, factTypeA, [payload], undefined, CHANGE_SET_2); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET_2)); - expect((await store.getFactCollection(collectionKey1, JOB))!.facts).toHaveLength(1); + expect((await store.getFactCollection(collectionKey1, CHANGE_SET))!.facts).toHaveLength(1); }); }); describe('scope isolation', () => { it('a collection recorded under one scope is a different collection under another scope', async () => { - await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, CHANGE_SET); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - expect(await store.getFactCollection(collectionKey1, JOB)).toBeDefined(); - expect(await store.getFactCollection(collectionKey1ScopeB, JOB)).toBeUndefined(); - expect(await store.getFactCollectionsByType(typeKeyScopeB, JOB)).toHaveLength(0); + expect(await store.getFactCollection(collectionKey1, CHANGE_SET)).toBeDefined(); + expect(await store.getFactCollection(collectionKey1ScopeB, CHANGE_SET)).toBeUndefined(); + expect(await store.getFactCollectionsByType(typeKeyScopeB, CHANGE_SET)).toHaveLength(0); }); it('the same (contract, type, id) under two scopes are independent collections', async () => { const payload = Fr.random(); const origin = { blockNumber: 5, blockHash: Fr.random() }; - await store.recordFact(collectionKey1, factTypeA, [payload], origin, JOB); - await store.recordFact(collectionKey1ScopeB, factTypeB, [payload], origin, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact(collectionKey1, factTypeA, [payload], origin, CHANGE_SET); + await store.recordFact(collectionKey1ScopeB, factTypeB, [payload], origin, CHANGE_SET); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - expect((await store.getFactCollection(collectionKey1, JOB))!.facts.map(f => f.factTypeId)).toEqual([factTypeA]); - expect((await store.getFactCollection(collectionKey1ScopeB, JOB))!.facts.map(f => f.factTypeId)).toEqual([ + expect((await store.getFactCollection(collectionKey1, CHANGE_SET))!.facts.map(f => f.factTypeId)).toEqual([ + factTypeA, + ]); + expect((await store.getFactCollection(collectionKey1ScopeB, CHANGE_SET))!.facts.map(f => f.factTypeId)).toEqual([ factTypeB, ]); }); it('getFactCollectionsByType only returns collections for the queried scope', async () => { - await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, JOB); - await store.recordFact(collectionKey1ScopeB, factTypeA, [Fr.random()], undefined, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, CHANGE_SET); + await store.recordFact(collectionKey1ScopeB, factTypeA, [Fr.random()], undefined, CHANGE_SET); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - expect(collectionIdsOf(await store.getFactCollectionsByType(typeKey, JOB))).toEqual([collectionId1]); - expect(collectionIdsOf(await store.getFactCollectionsByType(typeKeyScopeB, JOB))).toEqual([collectionId1]); + expect(collectionIdsOf(await store.getFactCollectionsByType(typeKey, CHANGE_SET))).toEqual([collectionId1]); + expect(collectionIdsOf(await store.getFactCollectionsByType(typeKeyScopeB, CHANGE_SET))).toEqual([collectionId1]); }); }); describe('read-your-writes', () => { - it("reflects a job's own staged facts before commit; other jobs do not see them", async () => { - await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, JOB); + it("reflects a change set's own staged facts before commit; other change sets do not see them", async () => { + await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, CHANGE_SET); - expect((await store.getFactCollection(collectionKey1, JOB))!.facts.map(f => f.factTypeId)).toEqual([factTypeA]); - expect(collectionIdsOf(await store.getFactCollectionsByType(typeKey, JOB))).toEqual([collectionId1]); + expect((await store.getFactCollection(collectionKey1, CHANGE_SET))!.facts.map(f => f.factTypeId)).toEqual([ + factTypeA, + ]); + expect(collectionIdsOf(await store.getFactCollectionsByType(typeKey, CHANGE_SET))).toEqual([collectionId1]); - expect(await store.getFactCollection(collectionKey1, 'other-job')).toBeUndefined(); - expect(await store.getFactCollectionsByType(typeKey, 'other-job')).toHaveLength(0); + expect(await store.getFactCollection(collectionKey1, 'other-change-set')).toBeUndefined(); + expect(await store.getFactCollectionsByType(typeKey, 'other-change-set')).toHaveLength(0); }); it('staged facts combine with committed ones', async () => { const payloads = Array.from({ length: 4 }, () => Fr.random()); - await store.recordFact(collectionKey1, factTypeA, [payloads[0]], undefined, JOB); - await store.recordFact(collectionKey1, factTypeA, [payloads[1]], undefined, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact(collectionKey1, factTypeA, [payloads[0]], undefined, CHANGE_SET); + await store.recordFact(collectionKey1, factTypeA, [payloads[1]], undefined, CHANGE_SET); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - const JOB2 = 'staged-job'; - await store.recordFact(collectionKey1, factTypeA, [payloads[2]], undefined, JOB2); - await store.recordFact(collectionKey1, factTypeA, [payloads[3]], undefined, JOB2); + const CHANGE_SET_2 = 'staged-change-set'; + await store.recordFact(collectionKey1, factTypeA, [payloads[2]], undefined, CHANGE_SET_2); + await store.recordFact(collectionKey1, factTypeA, [payloads[3]], undefined, CHANGE_SET_2); - const { facts } = (await store.getFactCollection(collectionKey1, JOB2))!; + const { facts } = (await store.getFactCollection(collectionKey1, CHANGE_SET_2))!; expect(hexSet(facts.map(f => f.payload[0]))).toEqual(hexSet(payloads)); }); }); describe('deleteFactCollection', () => { it('deletes the collection and leaves neighbouring collections untouched', async () => { - await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, JOB); - await store.recordFact(collectionKey2, factTypeA, [Fr.random()], undefined, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, CHANGE_SET); + await store.recordFact(collectionKey2, factTypeA, [Fr.random()], undefined, CHANGE_SET); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - const DEL = 'delete-job'; + const DEL = 'delete-change-set'; await store.deleteFactCollection(collectionKey1, DEL); - await kv.transactionAsync(() => store.commit(DEL)); + await kv.transactionAsync(() => store.commitStaged(DEL)); - expect(await store.getFactCollection(collectionKey1, JOB)).toBeUndefined(); - expect(collectionIdsOf(await store.getFactCollectionsByType(typeKey, JOB))).toEqual([collectionId2]); + expect(await store.getFactCollection(collectionKey1, CHANGE_SET)).toBeUndefined(); + expect(collectionIdsOf(await store.getFactCollectionsByType(typeKey, CHANGE_SET))).toEqual([collectionId2]); }); it('only deletes the queried scope: the same (contract,type,id) under another scope survives', async () => { - await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, JOB); - await store.recordFact(collectionKey1ScopeB, factTypeB, [Fr.random()], undefined, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, CHANGE_SET); + await store.recordFact(collectionKey1ScopeB, factTypeB, [Fr.random()], undefined, CHANGE_SET); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - const DEL = 'delete-job'; + const DEL = 'delete-change-set'; await store.deleteFactCollection(collectionKey1, DEL); - await kv.transactionAsync(() => store.commit(DEL)); + await kv.transactionAsync(() => store.commitStaged(DEL)); - expect(await store.getFactCollection(collectionKey1, JOB)).toBeUndefined(); - expect((await store.getFactCollection(collectionKey1ScopeB, JOB))!.facts.map(f => f.factTypeId)).toEqual([ + expect(await store.getFactCollection(collectionKey1, CHANGE_SET)).toBeUndefined(); + expect((await store.getFactCollection(collectionKey1ScopeB, CHANGE_SET))!.facts.map(f => f.factTypeId)).toEqual([ factTypeB, ]); }); it('is a no-op for a collection that does not exist', async () => { - await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, CHANGE_SET); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - const DEL = 'delete-job'; + const DEL = 'delete-change-set'; await store.deleteFactCollection(collectionKey2, DEL); - await kv.transactionAsync(() => store.commit(DEL)); + await kv.transactionAsync(() => store.commitStaged(DEL)); - expect((await store.getFactCollection(collectionKey1, JOB))!.facts).toHaveLength(1); + expect((await store.getFactCollection(collectionKey1, CHANGE_SET))!.facts).toHaveLength(1); }); - it('hides a collection from its own job after a staged delete, even over committed facts', async () => { - await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + it('hides a collection from its own change set after a staged delete, even over committed facts', async () => { + await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, CHANGE_SET); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - const DEL = 'delete-job'; + const DEL = 'delete-change-set'; await store.deleteFactCollection(collectionKey1, DEL); expect(await store.getFactCollection(collectionKey1, DEL)).toBeUndefined(); @@ -244,30 +260,30 @@ describe('FactStore', () => { expect((await store.getFactCollection(collectionKey1, 'reader'))!.facts).toHaveLength(1); }); - it('a staged delete-then-record re-creates the collection within the same job', async () => { - await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + it('a staged delete-then-record re-creates the collection within the same change set', async () => { + await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, CHANGE_SET); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - const JOB2 = 'recreate-job'; - await store.deleteFactCollection(collectionKey1, JOB2); - await store.recordFact(collectionKey1, factTypeB, [Fr.random()], undefined, JOB2); + const CHANGE_SET_2 = 'recreate-change-set'; + await store.deleteFactCollection(collectionKey1, CHANGE_SET_2); + await store.recordFact(collectionKey1, factTypeB, [Fr.random()], undefined, CHANGE_SET_2); - const { facts } = (await store.getFactCollection(collectionKey1, JOB2))!; + const { facts } = (await store.getFactCollection(collectionKey1, CHANGE_SET_2))!; expect(facts.map(f => f.factTypeId)).toEqual([factTypeB]); - await kv.transactionAsync(() => store.commit(JOB2)); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET_2)); expect((await store.getFactCollection(collectionKey1, 'reader'))!.facts.map(f => f.factTypeId)).toEqual([ factTypeB, ]); }); it('a staged record-then-delete leaves the collection deleted', async () => { - await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, JOB); - await store.deleteFactCollection(collectionKey1, JOB); + await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, CHANGE_SET); + await store.deleteFactCollection(collectionKey1, CHANGE_SET); - expect(await store.getFactCollection(collectionKey1, JOB)).toBeUndefined(); + expect(await store.getFactCollection(collectionKey1, CHANGE_SET)).toBeUndefined(); - await kv.transactionAsync(() => store.commit(JOB)); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); expect(await store.getFactCollection(collectionKey1, 'reader')).toBeUndefined(); }); }); @@ -276,57 +292,81 @@ describe('FactStore', () => { it('removes retractable facts above the target block and keeps non-retractable ones', async () => { const nonRetractable = Fr.random(); const retractable = Fr.random(); - await store.recordFact(collectionKey1, factTypeA, [nonRetractable], undefined, JOB); - await store.recordFact(collectionKey1, factTypeB, [retractable], { blockNumber: 6, blockHash: Fr.random() }, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact(collectionKey1, factTypeA, [nonRetractable], undefined, CHANGE_SET); + await store.recordFact( + collectionKey1, + factTypeB, + [retractable], + { blockNumber: 6, blockHash: Fr.random() }, + CHANGE_SET, + ); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); await kv.transactionAsync(() => store.rollback(5)); - const { facts } = (await store.getFactCollection(collectionKey1, JOB))!; + const { facts } = (await store.getFactCollection(collectionKey1, CHANGE_SET))!; expect(hexSet(facts.map(f => f.payload[0]))).toEqual(hexSet([nonRetractable])); }); it('a collection left with no facts after retraction disappears', async () => { - await store.recordFact(collectionKey1, factTypeA, [Fr.random()], { blockNumber: 6, blockHash: Fr.random() }, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact( + collectionKey1, + factTypeA, + [Fr.random()], + { blockNumber: 6, blockHash: Fr.random() }, + CHANGE_SET, + ); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); await kv.transactionAsync(() => store.rollback(5)); - expect(await store.getFactCollection(collectionKey1, JOB)).toBeUndefined(); - expect(await store.getFactCollectionsByType(typeKey, JOB)).toHaveLength(0); + expect(await store.getFactCollection(collectionKey1, CHANGE_SET)).toBeUndefined(); + expect(await store.getFactCollectionsByType(typeKey, CHANGE_SET)).toHaveLength(0); }); it('the same payload at two origin blocks yields independent facts pruned per block', async () => { const payload = Fr.random(); - await store.recordFact(collectionKey1, factTypeA, [payload], { blockNumber: 5, blockHash: Fr.random() }, JOB); - await store.recordFact(collectionKey1, factTypeA, [payload], { blockNumber: 10, blockHash: Fr.random() }, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact( + collectionKey1, + factTypeA, + [payload], + { blockNumber: 5, blockHash: Fr.random() }, + CHANGE_SET, + ); + await store.recordFact( + collectionKey1, + factTypeA, + [payload], + { blockNumber: 10, blockHash: Fr.random() }, + CHANGE_SET, + ); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); await kv.transactionAsync(() => store.rollback(7)); - expect((await store.getFactCollection(collectionKey1, JOB))!.facts.map(f => f.originBlock?.blockNumber)).toEqual([ - 5, - ]); - await store.discardStaged(JOB); + expect( + (await store.getFactCollection(collectionKey1, CHANGE_SET))!.facts.map(f => f.originBlock?.blockNumber), + ).toEqual([5]); + await store.discardStaged(CHANGE_SET); await kv.transactionAsync(() => store.rollback(4)); - expect(await store.getFactCollection(collectionKey1, JOB)).toBeUndefined(); + expect(await store.getFactCollection(collectionKey1, CHANGE_SET)).toBeUndefined(); }); - it('rollback throws while a job has staged writes', async () => { - await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, 'uncommitted-job'); + it('rollback throws while a change set has staged writes', async () => { + await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, 'uncommitted-change-set'); await expect(kv.transactionAsync(() => store.rollback(0))).rejects.toThrow( - 'PXE fact store rollback is not allowed while jobs are running', + 'PXE fact store rollback is not allowed while staged writes are pending', ); - await store.discardStaged('uncommitted-job'); + await store.discardStaged('uncommitted-change-set'); await expect(kv.transactionAsync(() => store.rollback(0))).resolves.not.toThrow(); }); - it('a job that has only read still blocks rollback until it is discarded', async () => { - await store.getFactCollection(collectionKey1, 'reader-job'); + it('a change set that has only read still blocks rollback until it is discarded', async () => { + await store.getFactCollection(collectionKey1, 'reader-change-set'); await expect(kv.transactionAsync(() => store.rollback(0))).rejects.toThrow( - 'PXE fact store rollback is not allowed while jobs are running', + 'PXE fact store rollback is not allowed while staged writes are pending', ); - await store.discardStaged('reader-job'); + await store.discardStaged('reader-change-set'); await expect(kv.transactionAsync(() => store.rollback(0))).resolves.not.toThrow(); }); }); @@ -335,7 +375,7 @@ describe('FactStore', () => { it('collections under different contracts and types are isolated', async () => { const contract2 = await AztecAddress.random(); const type2 = Fr.random(); - await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, JOB); + await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, CHANGE_SET); await store.recordFact( FactCollectionKey.from({ contractAddress: contract2, @@ -346,7 +386,7 @@ describe('FactStore', () => { factTypeA, [Fr.random()], undefined, - JOB, + CHANGE_SET, ); await store.recordFact( FactCollectionKey.from({ @@ -358,70 +398,70 @@ describe('FactStore', () => { factTypeA, [Fr.random()], undefined, - JOB, + CHANGE_SET, ); - await kv.transactionAsync(() => store.commit(JOB)); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - expect(await store.getFactCollectionsByType(typeKey, JOB)).toHaveLength(1); + expect(await store.getFactCollectionsByType(typeKey, CHANGE_SET)).toHaveLength(1); expect( await store.getFactCollectionsByType( FactCollectionTypeKey.from({ contractAddress: contract2, scope, factCollectionTypeId }), - JOB, + CHANGE_SET, ), ).toHaveLength(1); expect( await store.getFactCollectionsByType( FactCollectionTypeKey.from({ contractAddress: contract, scope, factCollectionTypeId: type2 }), - JOB, + CHANGE_SET, ), ).toHaveLength(1); }); }); - describe('cross-job behavior', () => { - it("commit persists only the given job's facts", async () => { + describe('cross-change set behavior', () => { + it("commit persists only the given change set's facts", async () => { const payloads = [Fr.random(), Fr.random()]; - await store.recordFact(collectionKey1, factTypeA, [payloads[0]], undefined, JOB); - const JOB2 = 'second-job'; - await store.recordFact(collectionKey1, factTypeA, [payloads[1]], undefined, JOB2); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact(collectionKey1, factTypeA, [payloads[0]], undefined, CHANGE_SET); + const CHANGE_SET_2 = 'second-change-set'; + await store.recordFact(collectionKey1, factTypeA, [payloads[1]], undefined, CHANGE_SET_2); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); expect((await store.getFactCollection(collectionKey1, 'reader'))!.facts.map(f => f.payload[0])).toEqual([ payloads[0], ]); - expect(hexSet((await store.getFactCollection(collectionKey1, JOB2))!.facts.map(f => f.payload[0]))).toEqual( - hexSet(payloads), - ); + expect( + hexSet((await store.getFactCollection(collectionKey1, CHANGE_SET_2))!.facts.map(f => f.payload[0])), + ).toEqual(hexSet(payloads)); - await kv.transactionAsync(() => store.commit(JOB2)); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET_2)); expect(hexSet((await store.getFactCollection(collectionKey1, 'reader'))!.facts.map(f => f.payload[0]))).toEqual( hexSet(payloads), ); }); it('discardStaged drops staged writes without touching committed state', async () => { - await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, JOB); - await kv.transactionAsync(() => store.commit(JOB)); + await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, CHANGE_SET); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - const JOB2 = 'discarded-job'; - await store.recordFact(collectionKey2, factTypeA, [Fr.random()], undefined, JOB2); - await store.recordFact(collectionKey1, factTypeB, [Fr.random()], undefined, JOB2); - await store.discardStaged(JOB2); + const CHANGE_SET_2 = 'discarded-change-set'; + await store.recordFact(collectionKey2, factTypeA, [Fr.random()], undefined, CHANGE_SET_2); + await store.recordFact(collectionKey1, factTypeB, [Fr.random()], undefined, CHANGE_SET_2); + await store.discardStaged(CHANGE_SET_2); - expect(collectionIdsOf(await store.getFactCollectionsByType(typeKey, JOB2))).toEqual([collectionId1]); - await kv.transactionAsync(() => store.commit(JOB2)); + expect(collectionIdsOf(await store.getFactCollectionsByType(typeKey, CHANGE_SET_2))).toEqual([collectionId1]); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET_2)); expect((await store.getFactCollection(collectionKey1, 'reader'))!.facts).toHaveLength(1); expect(await store.getFactCollection(collectionKey2, 'reader')).toBeUndefined(); }); - it('a fact recorded by two jobs racing to the same collection dedups on commit', async () => { + it('a fact recorded by two change sets racing to the same collection dedups on commit', async () => { const payload = Fr.random(); - const JOB2 = 'racing-job'; - await store.recordFact(collectionKey1, factTypeA, [payload], undefined, JOB); - await store.recordFact(collectionKey1, factTypeA, [payload], undefined, JOB2); + const CHANGE_SET_2 = 'racing-change-set'; + await store.recordFact(collectionKey1, factTypeA, [payload], undefined, CHANGE_SET); + await store.recordFact(collectionKey1, factTypeA, [payload], undefined, CHANGE_SET_2); - await kv.transactionAsync(() => store.commit(JOB)); - await expect(kv.transactionAsync(() => store.commit(JOB2))).resolves.not.toThrow(); + await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); + await expect(kv.transactionAsync(() => store.commitStaged(CHANGE_SET_2))).resolves.not.toThrow(); expect((await store.getFactCollection(collectionKey1, 'reader'))!.facts).toHaveLength(1); }); diff --git a/yarn-project/pxe/src/storage/fact_store/fact_store.ts b/yarn-project/pxe/src/storage/fact_store/fact_store.ts index 9f0085bc0143..8c441d9e7f9d 100644 --- a/yarn-project/pxe/src/storage/fact_store/fact_store.ts +++ b/yarn-project/pxe/src/storage/fact_store/fact_store.ts @@ -4,11 +4,10 @@ import { allToCompletion } from '@aztec/foundation/promise'; import { Semaphore } from '@aztec/foundation/queue'; import type { AztecAsyncKVStore, AztecAsyncMap, AztecAsyncMultiMap } from '@aztec/kv-store'; -import type { StagedStore } from '../../job_coordinator/job_coordinator.js'; +import type { ChangeSetId, StagedStore } from '../staged_write_coordinator.js'; import { FactCollectionKey, type FactCollectionTypeKey, type OriginBlock } from './fact_store_keys.js'; import { type Fact, StoredFact, factKeyStrOf } from './stored_fact.js'; -type JobId = string; type BlockNum = number; type FactBuffer = Buffer; type FactCollectionTypeKeyStr = string; @@ -21,7 +20,7 @@ export type FactCollection = { key: FactCollectionKey; facts: Fact[] }; /** Internal auxiliary type assembling a collection. */ type CollectionWithFacts = { key: FactCollectionKey; facts: Map }; -/** A pending operation for a job: record a fact, or delete a fact collection. */ +/** A pending operation for a change set: record a fact, or delete a fact collection. */ type StagedOp = { kind: 'recordFact'; fact: StoredFact } | { kind: 'deleteFactCollection'; key: FactCollectionKey }; /** @@ -49,7 +48,7 @@ type StagedOp = { kind: 'recordFact'; fact: StoredFact } | { kind: 'deleteFactCo * provide the guarantees mentioned above. That way, concepts such as offchain delivery or partial notes are completely * defined by Aztec.nr, opening the door to further extension without the need for ad-hoc PXE support. * - * As with most other PXE stores, writes are staged per-job and flushed atomically on commit. + * As with most other PXE stores, writes are staged per change set ID and flushed atomically on commit. */ export class FactStore implements StagedStore { readonly storeName: string = 'fact'; @@ -65,11 +64,11 @@ export class FactStore implements StagedStore { /** Index for delete-on-prune of retractable facts (those with an origin block). */ #factsByBlock: AztecAsyncMultiMap; - /** Job uncommitted data */ - #opsForJob: Map; + /** Uncommitted data, keyed by change set ID */ + #opsForChangeSet: Map; - /** Per-job locks */ - #jobLocks: Map; + /** Per-change-set locks */ + #changeSetLocks: Map; logger = createLogger('fact_store'); @@ -78,8 +77,8 @@ export class FactStore implements StagedStore { this.#facts = store.openMap('facts'); this.#factsByCollection = store.openMultiMap('facts_by_collection'); this.#factsByBlock = store.openMultiMap('facts_by_block'); - this.#opsForJob = new Map(); - this.#jobLocks = new Map(); + this.#opsForChangeSet = new Map(); + this.#changeSetLocks = new Map(); } /** @@ -99,10 +98,10 @@ export class FactStore implements StagedStore { factTypeId: Fr, payload: Fr[], originBlock: OriginBlock | undefined, - jobId: string, + changeSetId: ChangeSetId, ): Promise { - return this.#withJobLock(jobId, () => { - this.#stagedOpsFor(jobId).push({ + return this.#withChangeSetLock(changeSetId, () => { + this.#stagedOpsFor(changeSetId).push({ kind: 'recordFact', fact: new StoredFact(factCollectionKey, factTypeId, payload, originBlock), }); @@ -115,9 +114,9 @@ export class FactStore implements StagedStore { * * Idempotent: deleting a collection that does not exist is a no-op. */ - deleteFactCollection(factCollectionKey: FactCollectionKey, jobId: string): Promise { - return this.#withJobLock(jobId, () => { - this.#stagedOpsFor(jobId).push({ kind: 'deleteFactCollection', key: factCollectionKey }); + deleteFactCollection(factCollectionKey: FactCollectionKey, changeSetId: ChangeSetId): Promise { + return this.#withChangeSetLock(changeSetId, () => { + this.#stagedOpsFor(changeSetId).push({ kind: 'deleteFactCollection', key: factCollectionKey }); return Promise.resolve(); }); } @@ -125,11 +124,14 @@ export class FactStore implements StagedStore { /** * Returns the fact collection for the (scope-qualified) key, or undefined if it has no facts. */ - async getFactCollection(factCollectionKey: FactCollectionKey, jobId: string): Promise { + async getFactCollection( + factCollectionKey: FactCollectionKey, + changeSetId: ChangeSetId, + ): Promise { const collectionKey = factCollectionKey.toString(); const committed = await this.#store.transactionAsync(() => this.#readCollectionsFromDb([factCollectionKey])); - const collection = this.#foldStagedOps(committed, jobId).get(collectionKey); + const collection = this.#foldStagedOps(committed, changeSetId).get(collectionKey); if (!collection) { return undefined; } @@ -142,27 +144,27 @@ export class FactStore implements StagedStore { */ async getFactCollectionsByType( factCollectionTypeKey: FactCollectionTypeKey, - jobId: string, + changeSetId: ChangeSetId, ): Promise { const typeKey = factCollectionTypeKey.toString(); const committed = await this.#readCollectionsFromDbByType(typeKey); - return Array.from(this.#foldStagedOps(committed, jobId, typeKey).values()) + return Array.from(this.#foldStagedOps(committed, changeSetId, typeKey).values()) .map(collection => ({ key: collection.key, facts: [...collection.facts.values()] })) .filter(collection => collection.facts.length > 0); } /** - * Commits all staged operations for the given job to persistent storage. + * Commits all staged operations for the given change set to persistent storage. * - * Must be called inside a transaction owned by the caller (JobCoordinator wraps all commits in a single + * Must be called inside a transaction owned by the caller (StagedWriteCoordinator wraps all commits in a single * transactionAsync, and IndexedDB does not support nested transactions). * - * DO NOT call `#withJobLock` here: awaiting the lock creates a microtask boundary that causes IndexedDB to + * DO NOT call `#withChangeSetLock` here: awaiting the lock creates a microtask boundary that causes IndexedDB to * auto-commit the outer transaction. */ - async commit(jobId: string): Promise { - for (const op of this.#stagedOpsFor(jobId)) { + async commitStaged(changeSetId: ChangeSetId): Promise { + for (const op of this.#stagedOpsFor(changeSetId)) { switch (op.kind) { case 'recordFact': await this.#commitFact(op.fact); @@ -176,12 +178,12 @@ export class FactStore implements StagedStore { } } } - this.#clearJobData(jobId); + this.#clearChangeSetData(changeSetId); } - /** Discards all staged operations for the given job without persisting them. */ - discardStaged(jobId: string): Promise { - this.#clearJobData(jobId); + /** Discards all staged operations for the given change set without persisting them. */ + discardStaged(changeSetId: ChangeSetId): Promise { + this.#clearChangeSetData(changeSetId); return Promise.resolve(); } @@ -191,12 +193,13 @@ export class FactStore implements StagedStore { * Non-retractable facts are untouched. Must run inside a caller-owned transaction (because it needs to share the * transaction with other stores and IndexedDB has no nested transactions). * - * Throws if any job is in flight (has accessed the store and not yet committed or discarded), since rolling back - * mid-job could re-introduce records originating from deleted blocks or change state underneath a job's view. + * Throws if any change set is in flight (has accessed the store and not yet committed or discarded), since rolling + * back mid-change-set could re-introduce records originating from deleted blocks or change state underneath a change + * set's view. */ async rollback(toBlock: BlockNum): Promise { - if (this.#opsForJob.size > 0) { - throw new Error('PXE fact store rollback is not allowed while jobs are running'); + if (this.#opsForChangeSet.size > 0) { + throw new Error('PXE fact store rollback is not allowed while staged writes are pending'); } const removedFacts = await this.#retractFacts(toBlock); @@ -331,7 +334,7 @@ export class FactStore implements StagedStore { */ #foldStagedOps( committed: Map, - jobId: string, + changeSetId: ChangeSetId, typeKey?: FactCollectionTypeKeyStr, ): Map { const result = new Map(); @@ -340,7 +343,7 @@ export class FactStore implements StagedStore { for (const [collectionKey, { key, facts }] of committed) { result.set(collectionKey, { key, facts: new Map(facts) }); } - for (const op of this.#stagedOpsFor(jobId)) { + for (const op of this.#stagedOpsFor(changeSetId)) { switch (op.kind) { case 'recordFact': this.#foldRecordFact(result, op, typeKey); @@ -456,27 +459,27 @@ export class FactStore implements StagedStore { } /** - * Returns the job's staged-ops array, creating it on first access. + * Returns the change set's staged-ops array, creating it on first access. * */ - #stagedOpsFor(jobId: string): StagedOp[] { - let ops = this.#opsForJob.get(jobId); + #stagedOpsFor(changeSetId: ChangeSetId): StagedOp[] { + let ops = this.#opsForChangeSet.get(changeSetId); if (ops === undefined) { ops = []; - this.#opsForJob.set(jobId, ops); + this.#opsForChangeSet.set(changeSetId, ops); } return ops; } - #clearJobData(jobId: string) { - this.#opsForJob.delete(jobId); - this.#jobLocks.delete(jobId); + #clearChangeSetData(changeSetId: ChangeSetId) { + this.#opsForChangeSet.delete(changeSetId); + this.#changeSetLocks.delete(changeSetId); } - async #withJobLock(jobId: string, fn: () => Promise): Promise { - let lock = this.#jobLocks.get(jobId); + async #withChangeSetLock(changeSetId: ChangeSetId, fn: () => Promise): Promise { + let lock = this.#changeSetLocks.get(changeSetId); if (!lock) { lock = new Semaphore(1); - this.#jobLocks.set(jobId, lock); + this.#changeSetLocks.set(changeSetId, lock); } await lock.acquire(); try { diff --git a/yarn-project/pxe/src/storage/note_store/note_store.test.ts b/yarn-project/pxe/src/storage/note_store/note_store.test.ts index 6c372345b902..be8709d25df7 100644 --- a/yarn-project/pxe/src/storage/note_store/note_store.test.ts +++ b/yarn-project/pxe/src/storage/note_store/note_store.test.ts @@ -5,6 +5,7 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { BlockHash, type DataInBlock } from '@aztec/stdlib/block'; import { NoteDao, NoteStatus } from '@aztec/stdlib/note'; +import type { ChangeSetId } from '../staged_write_coordinator.js'; import { NoteStore } from './note_store.js'; // ----------------------------------------------------------------------------- @@ -58,9 +59,9 @@ describe('NoteStore', () => { siloedNullifier: SILOED_NULLIFIER_3, }); - await noteStore.addNotes([note1, note2], SCOPE_1, 'before-each-test-job'); - await noteStore.addNotes([note3], SCOPE_2, 'before-each-test-job'); - await noteStore.commit('before-each-test-job'); + await noteStore.addNotes([note1, note2], SCOPE_1, 'before-each-test-change-set'); + await noteStore.addNotes([note3], SCOPE_2, 'before-each-test-change-set'); + await noteStore.commitStaged('before-each-test-change-set'); return { store, noteStore, note1, note2, note3 }; } @@ -80,17 +81,17 @@ describe('NoteStore', () => { } /** - * Runs the same function sequentially in the given list of jobId's. + * Runs the same function sequentially in the given list of changeSetId's. * Handy to assert that state is consistent pre and post commit. */ - async function verifyAndCommitForEachJob( - jobIds: string[], + async function verifyAndCommitForEachChangeSet( + changeSetIds: ChangeSetId[], noteStore: NoteStore, - fn: (jobId: string) => Promise, + fn: (changeSetId: ChangeSetId) => Promise, ) { - for (const jobId of jobIds) { - await fn(jobId); - await noteStore.commit(jobId); + for (const changeSetId of changeSetIds) { + await fn(changeSetId); + await noteStore.commitStaged(changeSetId); } } @@ -100,11 +101,18 @@ describe('NoteStore', () => { const store = await openTmpStore('note_store_fresh_store'); const noteStore = new NoteStore(store); - await verifyAndCommitForEachJob(['pre-commit', 'post-commit'], noteStore, async (jobId: string) => { - const notes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, jobId); - expect(Array.isArray(notes)).toBe(true); - expect(notes).toHaveLength(0); - }); + await verifyAndCommitForEachChangeSet( + ['pre-commit', 'post-commit'], + noteStore, + async (changeSetId: ChangeSetId) => { + const notes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, + changeSetId, + ); + expect(Array.isArray(notes)).toBe(true); + expect(notes).toHaveLength(0); + }, + ); await store.close(); }); @@ -119,18 +127,28 @@ describe('NoteStore', () => { const noteA = await mkNote({ contractAddress: CONTRACT_A, siloedNullifier: SILOED_NULLIFIER_1 }); const noteB = await mkNote({ contractAddress: CONTRACT_B, siloedNullifier: SILOED_NULLIFIER_2 }); await noteStore1.addNotes([noteA, noteB], FAKE_ADDRESS, 'first-store'); - await noteStore1.commit('first-store'); + await noteStore1.commitStaged('first-store'); } const noteStore2 = new NoteStore(store); - await verifyAndCommitForEachJob(['second-store', 'fresh-job'], noteStore2, async (jobId: string) => { - const notesA = await noteStore2.getNotes({ contractAddress: CONTRACT_A, scopes: [FAKE_ADDRESS] }, jobId); - const notesB = await noteStore2.getNotes({ contractAddress: CONTRACT_B, scopes: [FAKE_ADDRESS] }, jobId); - - expect(nullifierSet(notesA)).toEqual(nullifierSet([SILOED_NULLIFIER_1])); - expect(nullifierSet(notesB)).toEqual(nullifierSet([SILOED_NULLIFIER_2])); - }); + await verifyAndCommitForEachChangeSet( + ['second-store', 'fresh-change-set'], + noteStore2, + async (changeSetId: ChangeSetId) => { + const notesA = await noteStore2.getNotes( + { contractAddress: CONTRACT_A, scopes: [FAKE_ADDRESS] }, + changeSetId, + ); + const notesB = await noteStore2.getNotes( + { contractAddress: CONTRACT_B, scopes: [FAKE_ADDRESS] }, + changeSetId, + ); + + expect(nullifierSet(notesA)).toEqual(nullifierSet([SILOED_NULLIFIER_1])); + expect(nullifierSet(notesB)).toEqual(nullifierSet([SILOED_NULLIFIER_2])); + }, + ); await store.close(); }); @@ -443,27 +461,31 @@ describe('NoteStore', () => { await noteStore.applyNullifiers(nullifiers, 'test'); // Verify nullified note remains visible only within its original scope - await verifyAndCommitForEachJob(['test', 'after-job-commit'], noteStore, async (jobId: string) => { - const wrongScopeNotes = await noteStore.getNotes( - { - contractAddress: CONTRACT_A, - scopes: [SCOPE_2], - status: NoteStatus.ACTIVE_OR_NULLIFIED, - }, - jobId, - ); - expect(nullifierSet(wrongScopeNotes)).not.toContain(note1.siloedNullifier.toBigInt()); - - const correctScopeNotes = await noteStore.getNotes( - { - contractAddress: CONTRACT_A, - scopes: [SCOPE_1], - status: NoteStatus.ACTIVE_OR_NULLIFIED, - }, - jobId, - ); - expect(nullifierSet(correctScopeNotes)).toContain(note1.siloedNullifier.toBigInt()); - }); + await verifyAndCommitForEachChangeSet( + ['test', 'after-change-set-commit'], + noteStore, + async (changeSetId: ChangeSetId) => { + const wrongScopeNotes = await noteStore.getNotes( + { + contractAddress: CONTRACT_A, + scopes: [SCOPE_2], + status: NoteStatus.ACTIVE_OR_NULLIFIED, + }, + changeSetId, + ); + expect(nullifierSet(wrongScopeNotes)).not.toContain(note1.siloedNullifier.toBigInt()); + + const correctScopeNotes = await noteStore.getNotes( + { + contractAddress: CONTRACT_A, + scopes: [SCOPE_1], + status: NoteStatus.ACTIVE_OR_NULLIFIED, + }, + changeSetId, + ); + expect(nullifierSet(correctScopeNotes)).toContain(note1.siloedNullifier.toBigInt()); + }, + ); }); it('is atomic — a batch containing an unknown nullifier aborts without recording any emission', async () => { @@ -481,14 +503,18 @@ describe('NoteStore', () => { ); // The known nullifier (note2) must NOT have been recorded: the throw happens before any emission is staged, so - // both notes stay active across the staged job and after committing it. - await verifyAndCommitForEachJob(['test', 'after-job-commit'], noteStore, async (jobId: string) => { - const activeNotes = await noteStore.getNotes( - { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, - jobId, - ); - expect(nullifierSet(activeNotes)).toEqual(nullifierSet([note1, note2])); - }); + // both notes stay active across the change set and after committing it. + await verifyAndCommitForEachChangeSet( + ['test', 'after-change-set-commit'], + noteStore, + async (changeSetId: ChangeSetId) => { + const activeNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, + changeSetId, + ); + expect(nullifierSet(activeNotes)).toEqual(nullifierSet([note1, note2])); + }, + ); }); // This test ensures applyNullifiers is idempotent: the same nullifier can be applied multiple times @@ -496,7 +522,7 @@ describe('NoteStore', () => { // run concurrently in a Promise.all context without risking unnecessarily defensive checks failing. it('applying nullifier a second time is a no-op and returns no transitioned notes', async () => { await noteStore.applyNullifiers([mkNullifier(note1)], 'test'); - await noteStore.commit('test'); + await noteStore.commitStaged('test'); // Second application is idempotent: the emission is already recorded, so no note transitions to nullified. The // result is empty (only notes that flip active -> nullified in this call are returned) and visibility is @@ -504,16 +530,20 @@ describe('NoteStore', () => { const result = await noteStore.applyNullifiers([mkNullifier(note1)], 'test'); expect(result).toEqual([]); - await verifyAndCommitForEachJob(['test', 'after-job-commit'], noteStore, async (jobId: string) => { - const activeNotes = await noteStore.getNotes( - { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, - jobId, - ); - expect(nullifierSet(activeNotes)).toEqual(nullifierSet([note2])); - }); + await verifyAndCommitForEachChangeSet( + ['test', 'after-change-set-commit'], + noteStore, + async (changeSetId: ChangeSetId) => { + const activeNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, + changeSetId, + ); + expect(nullifierSet(activeNotes)).toEqual(nullifierSet([note2])); + }, + ); }); - it('can nullify a freshly added note in the same job without committing first', async () => { + it('can nullify a freshly added note in the same change set without committing first', async () => { // This test simulates the validateAndStoreNote flow where a note is added and immediately nullified // without committing first (when the note is discovered to already be nullified on chain) const freshNullifier = Fr.random(); @@ -524,26 +554,30 @@ describe('NoteStore', () => { }); // Add note to stage without committing - await noteStore.addNotes([freshNote], SCOPE_1, 'fresh-job'); + await noteStore.addNotes([freshNote], SCOPE_1, 'fresh-change-set'); - // Immediately nullify it in the same job (simulating validateAndStoreNote when nullifier exists on chain) + // Immediately nullify it in the same change set (simulating validateAndStoreNote when nullifier exists on chain) const nullifiers = [mkNullifier(freshNote)]; - await expect(noteStore.applyNullifiers(nullifiers, 'fresh-job')).resolves.toEqual([freshNote]); + await expect(noteStore.applyNullifiers(nullifiers, 'fresh-change-set')).resolves.toEqual([freshNote]); // Verify note is now in nullified state - await verifyAndCommitForEachJob(['fresh-job', 'after-job-commit'], noteStore, async (jobId: string) => { - const activeNotes = await noteStore.getNotes( - { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, - jobId, - ); - expect(nullifierSet(activeNotes)).not.toContain(freshNullifier.toBigInt()); - - const allNotes = await noteStore.getNotes( - { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, scopes: [SCOPE_1, SCOPE_2] }, - jobId, - ); - expect(nullifierSet(allNotes)).toContain(freshNullifier.toBigInt()); - }); + await verifyAndCommitForEachChangeSet( + ['fresh-change-set', 'after-change-set-commit'], + noteStore, + async (changeSetId: ChangeSetId) => { + const activeNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, + changeSetId, + ); + expect(nullifierSet(activeNotes)).not.toContain(freshNullifier.toBigInt()); + + const allNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, scopes: [SCOPE_1, SCOPE_2] }, + changeSetId, + ); + expect(nullifierSet(allNotes)).toContain(freshNullifier.toBigInt()); + }, + ); }); it('can handle concurrent note additions and nullifications (simulating Promise.all in validateAndStoreNote)', async () => { @@ -559,56 +593,63 @@ describe('NoteStore', () => { // Simulate concurrent validateAndStoreNote calls where each note is added and immediately nullified const concurrentStoreNoteCalls = notes.map(async note => { - await noteStore.addNotes([note], SCOPE_1, 'concurrent-job'); + await noteStore.addNotes([note], SCOPE_1, 'concurrent-change-set'); const nullifiers = [mkNullifier(note)]; - await noteStore.applyNullifiers(nullifiers, 'concurrent-job'); + await noteStore.applyNullifiers(nullifiers, 'concurrent-change-set'); return note; }); await expect(Promise.all(concurrentStoreNoteCalls)).resolves.toEqual(notes); // Verify all notes are nullified - await verifyAndCommitForEachJob(['concurrent-job', 'after-job-commit'], noteStore, async (jobId: string) => { - const activeNotes = await noteStore.getNotes( - { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, - jobId, - ); - const activeNullifiers = nullifierSet(activeNotes); - for (const nullifier of noteNullifiers) { - expect(activeNullifiers).not.toContain(nullifier.toBigInt()); - } - - const allNotes = await noteStore.getNotes( - { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, scopes: [SCOPE_1, SCOPE_2] }, - jobId, - ); - expect(nullifierSet(allNotes)).toEqual(nullifierSet([note1, note2, ...noteNullifiers])); - }); + await verifyAndCommitForEachChangeSet( + ['concurrent-change-set', 'after-change-set-commit'], + noteStore, + async (changeSetId: ChangeSetId) => { + const activeNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, + changeSetId, + ); + const activeNullifiers = nullifierSet(activeNotes); + for (const nullifier of noteNullifiers) { + expect(activeNullifiers).not.toContain(nullifier.toBigInt()); + } + + const allNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, scopes: [SCOPE_1, SCOPE_2] }, + changeSetId, + ); + expect(nullifierSet(allNotes)).toEqual(nullifierSet([note1, note2, ...noteNullifiers])); + }, + ); }); - it('handles nullification of a persisted note in a new job', async () => { - // Scenario: A note was persisted in the DB during a previous job, and we want to nullify it in a new job. - // This is the syncNoteNullifiers flow where existing notes are checked for nullification. + it('handles nullification of a persisted note in a new change set', async () => { + // Scenario: A note was persisted in the DB during a previous change set, and we want to nullify it in a new + // change set. This is the syncNoteNullifiers flow where existing notes are checked for nullification. - // note1 is from setup and committed (i.e.: it's persisted) - // We should be able to nullify it in a new job + // note1 is from setup and committed (i.e.: it's persisted) We should be able to nullify it in a new change set const nullifiers = [mkNullifier(note1)]; - await expect(noteStore.applyNullifiers(nullifiers, 'new-job')).resolves.toEqual([note1]); + await expect(noteStore.applyNullifiers(nullifiers, 'new-change-set')).resolves.toEqual([note1]); // Verify the note is in nullified state - await verifyAndCommitForEachJob(['new-job', 'after-job-commit'], noteStore, async (jobId: string) => { - const activeNotes = await noteStore.getNotes( - { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, - jobId, - ); - expect(nullifierSet(activeNotes)).not.toContain(note1.siloedNullifier.toBigInt()); - - const allNotes = await noteStore.getNotes( - { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, scopes: [SCOPE_1, SCOPE_2] }, - jobId, - ); - expect(nullifierSet(allNotes)).toContain(note1.siloedNullifier.toBigInt()); - }); + await verifyAndCommitForEachChangeSet( + ['new-change-set', 'after-change-set-commit'], + noteStore, + async (changeSetId: ChangeSetId) => { + const activeNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, + changeSetId, + ); + expect(nullifierSet(activeNotes)).not.toContain(note1.siloedNullifier.toBigInt()); + + const allNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, scopes: [SCOPE_1, SCOPE_2] }, + changeSetId, + ); + expect(nullifierSet(allNotes)).toContain(note1.siloedNullifier.toBigInt()); + }, + ); }); it('handles duplicate note storage requests gracefully (same note added and nullified twice)', async () => { @@ -622,15 +663,15 @@ describe('NoteStore', () => { }); // First attempt to store: add and nullify the note - await noteStore.addNotes([duplicateNote], SCOPE_1, 'duplicate-job'); - await noteStore.applyNullifiers([mkNullifier(duplicateNote)], 'duplicate-job'); + await noteStore.addNotes([duplicateNote], SCOPE_1, 'duplicate-change-set'); + await noteStore.applyNullifiers([mkNullifier(duplicateNote)], 'duplicate-change-set'); // Second attempt to store (duplicate): try to add the same note again - should not throw // This simulates what happens in concurrent validateAndStoreNote calls when the same note is processed twice - await noteStore.addNotes([duplicateNote], SCOPE_2, 'duplicate-job'); + await noteStore.addNotes([duplicateNote], SCOPE_2, 'duplicate-change-set'); const notesAfterSecondAttempt = await noteStore.getNotes( { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE, scopes: [SCOPE_1, SCOPE_2] }, - 'duplicate-job', + 'duplicate-change-set', ); // Check that the second attempt at calling validateAndStoreNote didn't accidentally overwrite the first one @@ -639,24 +680,28 @@ describe('NoteStore', () => { // The second applyNullifiers is a no-op: the emission is already staged, so nothing transitions to nullified and // visibility is unchanged. - const secondApply = await noteStore.applyNullifiers([mkNullifier(duplicateNote)], 'duplicate-job'); + const secondApply = await noteStore.applyNullifiers([mkNullifier(duplicateNote)], 'duplicate-change-set'); expect(secondApply).toEqual([]); // Verify the note is nullified and has both scopes - await verifyAndCommitForEachJob(['duplicate-job', 'after-job-commit'], noteStore, async (jobId: string) => { - const allNotes = await noteStore.getNotes( - { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, scopes: [SCOPE_1, SCOPE_2] }, - jobId, - ); - expect(nullifierSet(allNotes)).toContain(duplicateNullifier.toBigInt()); - }); + await verifyAndCommitForEachChangeSet( + ['duplicate-change-set', 'after-change-set-commit'], + noteStore, + async (changeSetId: ChangeSetId) => { + const allNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, scopes: [SCOPE_1, SCOPE_2] }, + changeSetId, + ); + expect(nullifierSet(allNotes)).toContain(duplicateNullifier.toBigInt()); + }, + ); }); }); - describe('commit, staging, and discard', () => { + describe('commit, change set, and discard', () => { let store: AztecLMDBStoreV2; let noteStore: NoteStore; - const JOB = 'note-store-test-job'; + const CHANGE_SET = 'note-store-test-change-set'; const activeFilter = { contractAddress: CONTRACT_A, scopes: [SCOPE_1], status: NoteStatus.ACTIVE }; beforeEach(async () => { @@ -670,52 +715,52 @@ describe('NoteStore', () => { it('shows a note as soon as it is committed', async () => { const note = await mkNote({ l2BlockNumber: BlockNumber(10) }); - await noteStore.addNotes([note], SCOPE_1, JOB); - await noteStore.commit(JOB); + await noteStore.addNotes([note], SCOPE_1, CHANGE_SET); + await noteStore.commitStaged(CHANGE_SET); - const found = await noteStore.getNotes(activeFilter, 'read-job'); + const found = await noteStore.getNotes(activeFilter, 'read-change-set'); expect(found).toHaveLength(1); expect(found[0].siloedNullifier.equals(note.siloedNullifier)).toBe(true); }); it('marks a note nullified once a nullification origin is recorded for it', async () => { const note = await mkNote({ l2BlockNumber: BlockNumber(10) }); - await noteStore.addNotes([note], SCOPE_1, JOB); + await noteStore.addNotes([note], SCOPE_1, CHANGE_SET); await noteStore.applyNullifiers( [{ data: note.siloedNullifier, l2BlockNumber: BlockNumber(11), l2BlockHash: BlockHash.random() }], - JOB, + CHANGE_SET, ); - await noteStore.commit(JOB); + await noteStore.commitStaged(CHANGE_SET); - expect(await noteStore.getNotes(activeFilter, 'read-job')).toHaveLength(0); + expect(await noteStore.getNotes(activeFilter, 'read-change-set')).toHaveLength(0); expect( - await noteStore.getNotes({ ...activeFilter, status: NoteStatus.ACTIVE_OR_NULLIFIED }, 'read-job'), + await noteStore.getNotes({ ...activeFilter, status: NoteStatus.ACTIVE_OR_NULLIFIED }, 'read-change-set'), ).toHaveLength(1); }); - it('layers staged writes over committed state within a job', async () => { + it('layers staged writes over committed state within a change set', async () => { const note = await mkNote({ l2BlockNumber: BlockNumber(10) }); - await noteStore.addNotes([note], SCOPE_1, JOB); - expect(await noteStore.getNotes(activeFilter, JOB)).toHaveLength(1); - expect(await noteStore.getNotes(activeFilter, 'other-job')).toHaveLength(0); + await noteStore.addNotes([note], SCOPE_1, CHANGE_SET); + expect(await noteStore.getNotes(activeFilter, CHANGE_SET)).toHaveLength(1); + expect(await noteStore.getNotes(activeFilter, 'other-change-set')).toHaveLength(0); }); it('discardStaged drops staged notes and nullifications', async () => { const note = await mkNote({ l2BlockNumber: BlockNumber(10) }); - await noteStore.addNotes([note], SCOPE_1, JOB); + await noteStore.addNotes([note], SCOPE_1, CHANGE_SET); await noteStore.applyNullifiers( [{ data: note.siloedNullifier, l2BlockNumber: BlockNumber(11), l2BlockHash: BlockHash.random() }], - JOB, + CHANGE_SET, ); - await noteStore.discardStaged(JOB); + await noteStore.discardStaged(CHANGE_SET); - // A fresh job sees nothing committed — both the note and the nullification were discarded. - expect(await noteStore.getNotes(activeFilter, 'fresh-job')).toHaveLength(0); + // A fresh change set sees nothing committed — both the note and the nullification were discarded. + expect(await noteStore.getNotes(activeFilter, 'fresh-change-set')).toHaveLength(0); expect( - await noteStore.getNotes({ ...activeFilter, status: NoteStatus.ACTIVE_OR_NULLIFIED }, 'fresh-job'), + await noteStore.getNotes({ ...activeFilter, status: NoteStatus.ACTIVE_OR_NULLIFIED }, 'fresh-change-set'), ).toHaveLength(0); }); }); @@ -723,7 +768,7 @@ describe('NoteStore', () => { describe('nullifiersOfNotesAtBlock', () => { let store: AztecLMDBStoreV2; let noteStore: NoteStore; - const JOB = 'note-store-test-job'; + const CHANGE_SET = 'note-store-test-change-set'; beforeEach(async () => { store = await openTmpStore('note_store_block_index'); @@ -736,8 +781,8 @@ describe('NoteStore', () => { it('indexes note nullifiers by creation block number', async () => { const note = await mkNote({ l2BlockNumber: BlockNumber(9) }); - await noteStore.addNotes([note], SCOPE_1, JOB); - await noteStore.commit(JOB); + await noteStore.addNotes([note], SCOPE_1, CHANGE_SET); + await noteStore.commitStaged(CHANGE_SET); const nullifiers = await noteStore.nullifiersOfNotesAtBlock(9); expect(nullifiers).toEqual([note.siloedNullifier.toString()]); }); @@ -745,8 +790,8 @@ describe('NoteStore', () => { it('indexes multiple notes created at the same block', async () => { const a = await mkNote({ l2BlockNumber: BlockNumber(9) }); const b = await mkNote({ l2BlockNumber: BlockNumber(9) }); - await noteStore.addNotes([a, b], SCOPE_1, JOB); - await noteStore.commit(JOB); + await noteStore.addNotes([a, b], SCOPE_1, CHANGE_SET); + await noteStore.commitStaged(CHANGE_SET); const nullifiers = await noteStore.nullifiersOfNotesAtBlock(9); expect(new Set(nullifiers)).toEqual(new Set([a.siloedNullifier.toString(), b.siloedNullifier.toString()])); }); @@ -754,7 +799,7 @@ describe('NoteStore', () => { }); describe('NoteStore.rollback', () => { - const JOB = 'note-store-test-job'; + const CHANGE_SET = 'note-store-test-change-set'; const scope = AztecAddress.fromBigIntUnsafe(1n); const contract = AztecAddress.fromBigIntUnsafe(100n); @@ -782,14 +827,14 @@ describe('NoteStore.rollback', () => { l2BlockNumber: BlockNumber(10), l2BlockHash: FIXED_BLOCK_HASH, }); - await store.addNotes([noteA, noteB], scope, JOB); + await store.addNotes([noteA, noteB], scope, CHANGE_SET); // Nullify B at block 11 (also above the target). const nullBlockHash = BlockHash.fromString(Fr.fromString('0x0b').toString()); await store.applyNullifiers( [{ data: noteB.siloedNullifier, l2BlockNumber: BlockNumber(11), l2BlockHash: nullBlockHash }], - JOB, + CHANGE_SET, ); - await store.commit(JOB); + await store.commitStaged(CHANGE_SET); await kv.transactionAsync(() => store.rollback(9)); @@ -798,7 +843,7 @@ describe('NoteStore.rollback', () => { expect(await store.nullifiersOfNotesAtBlock(10)).toHaveLength(0); expect(await store.nullifiersOfNotesAtBlock(11)).toHaveLength(0); - const found = await store.getNotes(activeFilter, 'read-job'); + const found = await store.getNotes(activeFilter, 'read-change-set'); expect(found).toHaveLength(1); expect(found[0].siloedNullifier.equals(noteA.siloedNullifier)).toBe(true); }); @@ -816,14 +861,14 @@ describe('NoteStore.rollback', () => { l2BlockNumber: BlockNumber(50), l2BlockHash: FIXED_BLOCK_HASH, }); - await store.addNotes([noteLow, noteHigh], scope, JOB); - await store.commit(JOB); + await store.addNotes([noteLow, noteHigh], scope, CHANGE_SET); + await store.commitStaged(CHANGE_SET); await kv.transactionAsync(() => store.rollback(9)); expect(await store.nullifiersOfNotesAtBlock(10)).toHaveLength(0); expect(await store.nullifiersOfNotesAtBlock(50)).toHaveLength(0); - expect(await store.getNotes(activeFilter, 'read-job')).toHaveLength(0); + expect(await store.getNotes(activeFilter, 'read-change-set')).toHaveLength(0); }); it('restores notes that were nullified after the rollback block', async () => { @@ -834,13 +879,13 @@ describe('NoteStore.rollback', () => { l2BlockNumber: BlockNumber(10), l2BlockHash: FIXED_BLOCK_HASH, }); - await store.addNotes([noteB], scope, JOB); + await store.addNotes([noteB], scope, CHANGE_SET); const nullBlockHash = BlockHash.fromString(Fr.fromString('0x14').toString()); await store.applyNullifiers( [{ data: noteB.siloedNullifier, l2BlockNumber: BlockNumber(20), l2BlockHash: nullBlockHash }], - JOB, + CHANGE_SET, ); - await store.commit(JOB); + await store.commitStaged(CHANGE_SET); await kv.transactionAsync(() => store.rollback(16)); @@ -848,7 +893,7 @@ describe('NoteStore.rollback', () => { expect(await store.nullifiersOfNotesAtBlock(10)).toEqual([noteB.siloedNullifier.toString()]); // The note should read back ACTIVE again (nullification row gone). - const found = await store.getNotes(activeFilter, 'read-job'); + const found = await store.getNotes(activeFilter, 'read-change-set'); expect(found).toHaveLength(1); expect(found[0].siloedNullifier.equals(noteB.siloedNullifier)).toBe(true); }); @@ -859,8 +904,8 @@ describe('NoteStore.rollback', () => { l2BlockNumber: BlockNumber(10), l2BlockHash: FIXED_BLOCK_HASH, }); - await store.addNotes([noteB], scope, JOB); - await store.commit(JOB); + await store.addNotes([noteB], scope, CHANGE_SET); + await store.commitStaged(CHANGE_SET); await kv.transactionAsync(() => store.rollback(9)); expect(await store.nullifiersOfNotesAtBlock(10)).toHaveLength(0); @@ -870,21 +915,21 @@ describe('NoteStore.rollback', () => { expect(await store.nullifiersOfNotesAtBlock(10)).toHaveLength(0); }); - it('throws when rollback is called while jobs are running', async () => { - // Stage a note under a job but never commit it, so the store still holds in-flight job data. Rolling back now - // could later let the job commit notes anchored to blocks the rollback just deleted. + it('throws when rollback is called while staged writes are pending', async () => { + // Stage a note under a change set but never commit it, so the store still holds in-flight staged data. Rolling back + // now could later let the change set commit notes anchored to blocks the rollback just deleted. const staged = await NoteDao.random({ contractAddress: contract, l2BlockNumber: BlockNumber(10), l2BlockHash: FIXED_BLOCK_HASH, }); - await store.addNotes([staged], scope, 'uncommitted-job'); + await store.addNotes([staged], scope, 'uncommitted-change-set'); await expect(kv.transactionAsync(() => store.rollback(0))).rejects.toThrow( - 'PXE note store rollback is not allowed while jobs are running', + 'PXE note store rollback is not allowed while staged writes are pending', ); - await store.discardStaged('uncommitted-job'); + await store.discardStaged('uncommitted-change-set'); await expect(kv.transactionAsync(() => store.rollback(0))).resolves.not.toThrow(); }); diff --git a/yarn-project/pxe/src/storage/note_store/note_store.ts b/yarn-project/pxe/src/storage/note_store/note_store.ts index 91f998443dce..2d7663b004fb 100644 --- a/yarn-project/pxe/src/storage/note_store/note_store.ts +++ b/yarn-project/pxe/src/storage/note_store/note_store.ts @@ -7,13 +7,12 @@ import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { DataInBlock } from '@aztec/stdlib/block'; import { NoteDao, NoteStatus } from '@aztec/stdlib/note'; -import type { StagedStore } from '../../job_coordinator/job_coordinator.js'; import type { NotesFilter } from '../../notes_filter.js'; +import type { ChangeSetId, StagedStore } from '../staged_write_coordinator.js'; import { StoredNote } from './stored_note.js'; /// Alias types for kv map readability type SiloedNullifier = string; -type JobId = string; type AddressStr = string; type BlockNum = number; type StoredNoteBuffer = Buffer; @@ -60,18 +59,15 @@ export class NoteStore implements StagedStore { // nullification block number => nullifier #nullifierEmissionsByBlockNumber: AztecAsyncMultiMap; - // In-memory changes performed during a not-yet committed job. When `commit` is called with said job's id, these - // changes are persisted in the DB maps specified above and cleared. - // jobId => nullifier => StoredNote - #notesForJob: Map>; + // In-memory changes performed during a not-yet committed change set. When `commit` is called with said change set's + // id, these changes are persisted in the DB maps specified above and cleared. changeSetId => nullifier => StoredNote + #notesForChangeSet: Map>; - // Staged nullifier emissions per job. - // jobId => nullifier => emission block number - #nullifierEmissionsForJob: Map>; + // Staged nullifier emissions per change set. changeSetId => nullifier => emission block number + #nullifierEmissionsForChangeSet: Map>; - // Per job locks to prevent multiple concurrent writes to affect each other. - // jobId => lock - #jobLocks: Map; + // Per-change-set locks to prevent multiple concurrent writes from affecting each other. changeSetId => lock + #changeSetLocks: Map; constructor(store: AztecAsyncKVStore) { this.#store = store; @@ -81,9 +77,9 @@ export class NoteStore implements StagedStore { this.#nullifierEmissions = store.openMap('note_nullifications_by_nullifier'); this.#nullifierEmissionsByBlockNumber = store.openMultiMap('note_nullifications_by_block'); - this.#jobLocks = new Map(); - this.#notesForJob = new Map(); - this.#nullifierEmissionsForJob = new Map(); + this.#changeSetLocks = new Map(); + this.#notesForChangeSet = new Map(); + this.#nullifierEmissionsForChangeSet = new Map(); } /** @@ -94,50 +90,50 @@ export class NoteStore implements StagedStore { * * @param notes - Notes to store * @param scope - The scope (user/account) under which to store the notes - * @param jobId - The job context for staged writes + * @param changeSetId - The change set to stage writes under */ - public addNotes(notes: NoteDao[], scope: AztecAddress, jobId: string): Promise { - return this.#withJobLock(jobId, () => + public addNotes(notes: NoteDao[], scope: AztecAddress, changeSetId: ChangeSetId): Promise { + return this.#withChangeSetLock(changeSetId, () => this.#store.transactionAsync(() => allToCompletion( notes.map(async note => { - const noteForJob = - (await this.#readNote(note.siloedNullifier.toString(), jobId)) ?? new StoredNote(note, new Set()); - noteForJob.addScope(scope.toString()); - this.#writeNote(noteForJob, jobId); + const noteForChangeSet = + (await this.#readNote(note.siloedNullifier.toString(), changeSetId)) ?? new StoredNote(note, new Set()); + noteForChangeSet.addScope(scope.toString()); + this.#writeNote(noteForChangeSet, changeSetId); }), ), ), ); } - async #readNote(nullifier: string, jobId: string): Promise { + async #readNote(nullifier: string, changeSetId: ChangeSetId): Promise { // Always issue DB read to keep IndexedDB transaction alive (they auto-commit when a new micro-task starts and there // are no pending read requests). The staged value still takes precedence if it exists. const noteBuffer = await this.#notes.getAsync(nullifier); - const noteForJob = this.#getNotesForJob(jobId).get(nullifier); - return noteForJob ?? (noteBuffer ? StoredNote.fromBuffer(noteBuffer) : undefined); + const noteForChangeSet = this.#getNotesForChangeSet(changeSetId).get(nullifier); + return noteForChangeSet ?? (noteBuffer ? StoredNote.fromBuffer(noteBuffer) : undefined); } - #writeNote(note: StoredNote, jobId: string) { - this.#getNotesForJob(jobId).set(note.noteDao.siloedNullifier.toString(), note); + #writeNote(note: StoredNote, changeSetId: ChangeSetId) { + this.#getNotesForChangeSet(changeSetId).set(note.noteDao.siloedNullifier.toString(), note); } /** - * Reads the block number at which a note's nullifier was emitted, layering the current job's staged emission over + * Reads the block number at which a note's nullifier was emitted, layering the current change set's emission over * committed state, the nullifier emission counterpart to {@link #readNote}. Returns the emission block number if the - * nullifier has been emitted (committed or staged in this job), or `undefined` if it has not. + * nullifier has been emitted (committed or currently staged), or `undefined` if it has not. */ - async #readNullifierEmission(nullifier: string, jobId: string): Promise { + async #readNullifierEmission(nullifier: string, changeSetId: ChangeSetId): Promise { // Always issue the DB read to keep the IndexedDB transaction alive (see #readNote); the staged emission still takes // precedence if present. const committed = await this.#nullifierEmissions.getAsync(nullifier); - const staged = this.#getNullifierEmissionsForJob(jobId).get(nullifier); + const staged = this.#getNullifierEmissionsForChangeSet(changeSetId).get(nullifier); return staged ?? committed; } - #writeNullifierEmission(nullifier: string, blockNumber: BlockNum, jobId: string): void { - this.#getNullifierEmissionsForJob(jobId).set(nullifier, blockNumber); + #writeNullifierEmission(nullifier: string, blockNumber: BlockNum, changeSetId: ChangeSetId): void { + this.#getNullifierEmissionsForChangeSet(changeSetId).set(nullifier, blockNumber); } /** @@ -149,10 +145,10 @@ export class NoteStore implements StagedStore { * * @param filter - Filter criteria including contractAddress (required), and optional owner, * storageSlot, status, scopes, and siloedNullifier. - * @param jobId - the job context to read from. + * @param changeSetId - the change set to read staged data from. * @returns Filtered and deduplicated notes (a note might be present in multiple scopes, but returned at most once) */ - getNotes(filter: NotesFilter, jobId: string): Promise { + getNotes(filter: NotesFilter, changeSetId: ChangeSetId): Promise { if (filter.scopes.length === 0) { return Promise.resolve([]); } @@ -171,19 +167,19 @@ export class NoteStore implements StagedStore { // Committed notes indexed by contract address for await (const nullifier of this.#notesByContractAddress.getValuesAsync(filter.contractAddress.toString())) { candidates.set(nullifier, { - notePromise: this.#readNote(nullifier, jobId), - nullificationPromise: this.#readNullifierEmission(nullifier, jobId), + notePromise: this.#readNote(nullifier, changeSetId), + nullificationPromise: this.#readNullifierEmission(nullifier, changeSetId), }); } - // Staged notes from the current job (not yet committed to the DB index) - for (const storedNote of this.#getNotesForJob(jobId).values()) { + // Staged notes from the current change set (not yet committed to the DB index) + for (const storedNote of this.#getNotesForChangeSet(changeSetId).values()) { if (storedNote.noteDao.contractAddress.equals(filter.contractAddress)) { const nullifier = storedNote.noteDao.siloedNullifier.toString(); if (!candidates.has(nullifier)) { candidates.set(nullifier, { notePromise: Promise.resolve(storedNote), - nullificationPromise: this.#readNullifierEmission(nullifier, jobId), + nullificationPromise: this.#readNullifierEmission(nullifier, changeSetId), }); } } @@ -206,12 +202,12 @@ export class NoteStore implements StagedStore { const foundNotes: Map = new Map(); for (const note of notes) { - // Defensive: hitting this case means we're mishandling contract indices or in-memory job data + // Defensive: hitting this case means we're mishandling contract indices or in-memory staged data if (!note) { throw new Error('PXE note database is corrupted.'); } - // A note is nullified once its nullifier emission has been recorded (committed or staged in this job). + // A note is nullified once its nullifier emission has been recorded (committed or currently staged). const nullified = emissionByNullifier.get(note.noteDao.siloedNullifier.toString()) !== undefined; if (targetStatus === NoteStatus.ACTIVE && nullified) { @@ -259,16 +255,16 @@ export class NoteStore implements StagedStore { * notes of scopes they track, and a note is always discovered before the nullifier that spends it, so a nullifier * with no matching note signals a bug (broken nonce/index discovery, a sync-ordering error, store corruption, etc). * - * `applyNullifiers` is idempotent: a nullifier whose emission is already recorded (committed or staged in this job) is + * `applyNullifiers` is idempotent: a nullifier whose emission is already recorded (committed or currently staged) is * skipped, so re-applying it neither re-writes the emission, changes note visibility, nor appears in the result. * * @param siloedNullifiers - Array of nullifiers with their block locations to record - * @param jobId - The job context for staged writes + * @param changeSetId - The change set to stage writes under * @returns The notes that transition from active to nullified in this call; already-nullified notes are skipped, so * a repeat application returns an empty array. * @throws If any nullifier has no matching note in this store, or was emitted at block 0. */ - applyNullifiers(siloedNullifiers: DataInBlock[], jobId: string): Promise { + applyNullifiers(siloedNullifiers: DataInBlock[], changeSetId: ChangeSetId): Promise { if (siloedNullifiers.length === 0) { return Promise.resolve([]); } @@ -277,7 +273,7 @@ export class NoteStore implements StagedStore { return Promise.reject(new Error('applyNullifiers: nullifiers cannot have been emitted at block 0')); } - return this.#withJobLock(jobId, () => + return this.#withChangeSetLock(changeSetId, () => this.#store.transactionAsync(async () => { // Kick off the note read and the existing-emission read together during the synchronous map so all are in // flight before the first await, which keeps the IndexedDB transaction alive. @@ -285,8 +281,8 @@ export class NoteStore implements StagedStore { siloedNullifiers.map(async nullifier => { const key = nullifier.data.toString(); const [storedNote, existingEmission] = await allToCompletion([ - this.#readNote(key, jobId), - this.#readNullifierEmission(key, jobId), + this.#readNote(key, changeSetId), + this.#readNullifierEmission(key, changeSetId), ]); if (!storedNote) { throw new Error(`Attempted to mark a note as nullified which does not exist in PXE DB: ${key}`); @@ -302,7 +298,7 @@ export class NoteStore implements StagedStore { if (alreadyEmitted) { continue; } - this.#writeNullifierEmission(nullifier.data.toString(), nullifier.l2BlockNumber, jobId); + this.#writeNullifierEmission(nullifier.data.toString(), nullifier.l2BlockNumber, changeSetId); affected.push(storedNote.noteDao); } @@ -312,51 +308,51 @@ export class NoteStore implements StagedStore { } /** - * Commits in-memory job data to persistent storage. + * Commits in-memory staged data to persistent storage. * - * Called by JobCoordinator when a job completes successfully. + * Called by StagedWriteCoordinator when an operation completes successfully. * - * Note: JobCoordinator wraps all commits in a single transaction, so we don't need our own transactionAsync here - * (and using one would throw on IndexedDB as it does not support nested txs). + * Note: StagedWriteCoordinator wraps all commits in a single transaction, so we don't need our own transactionAsync + * here (and using one would throw on IndexedDB as it does not support nested txs). * - * @param jobId - The jobId identifying which staged data to commit + * @param changeSetId - The changeSetId identifying which staged data to commit */ - async commit(jobId: string): Promise { - for (const [nullifier, storedNote] of this.#getNotesForJob(jobId)) { + async commitStaged(changeSetId: ChangeSetId): Promise { + for (const [nullifier, storedNote] of this.#getNotesForChangeSet(changeSetId)) { await this.#notes.set(nullifier, storedNote.toBuffer()); await this.#notesByContractAddress.set(storedNote.noteDao.contractAddress.toString(), nullifier); await this.#notesByBlockNumber.set(storedNote.noteDao.l2BlockNumber, nullifier); } - for (const [nullifier, blockNumber] of this.#getNullifierEmissionsForJob(jobId)) { + for (const [nullifier, blockNumber] of this.#getNullifierEmissionsForChangeSet(changeSetId)) { await this.#nullifierEmissions.set(nullifier, blockNumber); await this.#nullifierEmissionsByBlockNumber.set(blockNumber, nullifier); } - this.#clearJobData(jobId); + this.#clearChangeSetData(changeSetId); } - discardStaged(jobId: string): Promise { - this.#clearJobData(jobId); + discardStaged(changeSetId: ChangeSetId): Promise { + this.#clearChangeSetData(changeSetId); return Promise.resolve(); } - #clearJobData(jobId: string) { - this.#notesForJob.delete(jobId); - this.#nullifierEmissionsForJob.delete(jobId); - this.#jobLocks.delete(jobId); + #clearChangeSetData(changeSetId: ChangeSetId) { + this.#notesForChangeSet.delete(changeSetId); + this.#nullifierEmissionsForChangeSet.delete(changeSetId); + this.#changeSetLocks.delete(changeSetId); } /** - * Functions run withJobLock are forced to wait for each other, i.e. if they share a `jobId`, they run serially - * instead of concurrently. This is needed because staged data is stored in memory, and concurrent async operations - * (e.g., allToCompletion in `validateAndStoreNote`) could otherwise interleave and corrupt state. + * Functions run withChangeSetLock are forced to wait for each other, i.e. if they share a `changeSetId`, they run + * serially instead of concurrently. This is needed because staged data is stored in memory, and concurrent async + * operations (e.g., allToCompletion in `validateAndStoreNote`) could otherwise interleave and corrupt state. */ - async #withJobLock(jobId: string, fn: () => Promise): Promise { - let lock = this.#jobLocks.get(jobId); + async #withChangeSetLock(changeSetId: ChangeSetId, fn: () => Promise): Promise { + let lock = this.#changeSetLocks.get(changeSetId); if (!lock) { lock = new Semaphore(1); - this.#jobLocks.set(jobId, lock); + this.#changeSetLocks.set(changeSetId, lock); } await lock.acquire(); try { @@ -366,22 +362,22 @@ export class NoteStore implements StagedStore { } } - #getNotesForJob(jobId: string): Map { - let notesForJob = this.#notesForJob.get(jobId); - if (!notesForJob) { - notesForJob = new Map(); - this.#notesForJob.set(jobId, notesForJob); + #getNotesForChangeSet(changeSetId: ChangeSetId): Map { + let notesForChangeSet = this.#notesForChangeSet.get(changeSetId); + if (!notesForChangeSet) { + notesForChangeSet = new Map(); + this.#notesForChangeSet.set(changeSetId, notesForChangeSet); } - return notesForJob; + return notesForChangeSet; } - #getNullifierEmissionsForJob(jobId: string): Map { - let nullificationsForJob = this.#nullifierEmissionsForJob.get(jobId); - if (!nullificationsForJob) { - nullificationsForJob = new Map(); - this.#nullifierEmissionsForJob.set(jobId, nullificationsForJob); + #getNullifierEmissionsForChangeSet(changeSetId: ChangeSetId): Map { + let nullificationsForChangeSet = this.#nullifierEmissionsForChangeSet.get(changeSetId); + if (!nullificationsForChangeSet) { + nullificationsForChangeSet = new Map(); + this.#nullifierEmissionsForChangeSet.set(changeSetId, nullificationsForChangeSet); } - return nullificationsForJob; + return nullificationsForChangeSet; } /** Returns the nullifiers (note ids) of all notes created at the given block number. Used by delete-on-prune. */ @@ -400,12 +396,12 @@ export class NoteStore implements StagedStore { * Must be called inside a transaction owned by the caller (it issues no `transactionAsync` of its own, because the * reorg path wraps it together with other store operations, and IndexedDB has no nested transaction support). * - * Throws if any job has uncommitted staged writes, since rolling back mid-job could later re-introduce notes or - * nullifier emissions anchored to deleted blocks. + * Throws if any change set has uncommitted staged writes, since rolling back mid-change-set could later re-introduce + * notes or nullifier emissions anchored to deleted blocks. */ public async rollback(toBlock: number): Promise { - if (this.#notesForJob.size > 0 || this.#nullifierEmissionsForJob.size > 0) { - throw new Error('PXE note store rollback is not allowed while jobs are running'); + if (this.#notesForChangeSet.size > 0 || this.#nullifierEmissionsForChangeSet.size > 0) { + throw new Error('PXE note store rollback is not allowed while staged writes are pending'); } // Snapshot the orphaned (block, nullifier) pairs before mutating so we never delete from the cursor we are // iterating. Scanning from `toBlock + 1` upward covers everything above the rollback target without needing to know diff --git a/yarn-project/pxe/src/storage/private_event_store/private_event_store.test.ts b/yarn-project/pxe/src/storage/private_event_store/private_event_store.test.ts index c1c82dd21c9c..e4ca6fc01087 100644 --- a/yarn-project/pxe/src/storage/private_event_store/private_event_store.test.ts +++ b/yarn-project/pxe/src/storage/private_event_store/private_event_store.test.ts @@ -9,6 +9,7 @@ import { BlockHash } from '@aztec/stdlib/block'; import { TxHash } from '@aztec/stdlib/tx'; import type { PackedPrivateEvent } from '../../pxe.js'; +import type { ChangeSetId } from '../staged_write_coordinator.js'; import { PrivateEventStore } from './private_event_store.js'; const getRandomMsgContent = () => { @@ -73,7 +74,7 @@ describe('PrivateEventStore', () => { }, 'test', ); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); } const events = await privateEventStore.getPrivateEvents(eventSelector, { @@ -114,7 +115,7 @@ describe('PrivateEventStore', () => { metadata, 'test', ); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); const events = await privateEventStore.getPrivateEvents(eventSelector, { contractAddress, @@ -162,7 +163,7 @@ describe('PrivateEventStore', () => { }, 'test', ); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); } const events = await privateEventStore.getPrivateEvents(eventSelector, { @@ -231,7 +232,7 @@ describe('PrivateEventStore', () => { }, 'test', ); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); } const events = await privateEventStore.getPrivateEvents(eventSelector, { @@ -280,7 +281,7 @@ describe('PrivateEventStore', () => { }, 'test', ); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); } const events = await privateEventStore.getPrivateEvents(eventSelector, { @@ -318,7 +319,7 @@ describe('PrivateEventStore', () => { 'test', ); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); const filter = { contractAddress, fromBlock: l2BlockNumber, toBlock: l2BlockNumber + 1 }; @@ -412,7 +413,7 @@ describe('PrivateEventStore', () => { }, 'test', ); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); } const events = await privateEventStore.getPrivateEvents(eventSelector, { @@ -480,7 +481,7 @@ describe('PrivateEventStore', () => { }, 'test', ); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); } const events = await privateEventStore.getPrivateEvents(eventSelector, { @@ -549,7 +550,7 @@ describe('PrivateEventStore', () => { }, 'test', ); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); } const events = await privateEventStore.getPrivateEvents(eventSelector, { @@ -592,7 +593,7 @@ describe('PrivateEventStore', () => { await storeEventAt(eventAt9, 9, BLOCK_HASH_9); await storeEventAt(eventAt10, 10, BLOCK_HASH_10); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); await kvStore.transactionAsync(() => privateEventStore.rollback(9)); @@ -619,7 +620,7 @@ describe('PrivateEventStore', () => { await storeEventAt(eventAt9, 9, BLOCK_HASH_9); await storeEventAt(eventAt10, 10, BLOCK_HASH_10); await storeEventAt(eventAt12, 12, BLOCK_HASH_12); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); await kvStore.transactionAsync(() => privateEventStore.rollback(9)); @@ -635,7 +636,7 @@ describe('PrivateEventStore', () => { await storeEventAt(eventAt9, 9, BLOCK_HASH_9); await storeEventAt(eventAt10, 10, BLOCK_HASH_10); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); await kvStore.transactionAsync(() => privateEventStore.rollback(9)); // Re-running over the already-truncated tail must not throw and must not change anything. @@ -659,21 +660,21 @@ describe('PrivateEventStore', () => { }); await storeEventAt(commitment, 10, BLOCK_HASH_10); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); await kvStore.transactionAsync(() => privateEventStore.rollback(9)); expect(await readBack()).toHaveLength(0); // Re-add the same commitment, as happens when the tx is re-included after the reorg. await storeEventAt(commitment, 10, BLOCK_HASH_10); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); expect(await readBack()).toHaveLength(1); }); it('handles rollback with no events to remove', async () => { const eventAt10 = Fr.random(); await storeEventAt(eventAt10, 10, BLOCK_HASH_10); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); // Rolling back to a block above every stored event removes nothing. await kvStore.transactionAsync(() => privateEventStore.rollback(20)); @@ -681,8 +682,8 @@ describe('PrivateEventStore', () => { expect(await privateEventStore.eventIdsAtBlock(10)).toEqual([eventAt10.toString()]); }); - it('throws when rollback is called while jobs are running', async () => { - // Stage an event under a job but never commit it, so the store still holds in-flight job data. + it('throws when rollback is called while staged writes are pending', async () => { + // Stage an event under a change set but never commit it, so the store still holds in-flight staged data. await privateEventStore.storePrivateEventLog( eventSelector, randomness, @@ -697,14 +698,14 @@ describe('PrivateEventStore', () => { txIndexInBlock: 0, eventIndexInTx: 0, }, - 'uncommitted-job', + 'uncommitted-change-set', ); await expect(kvStore.transactionAsync(() => privateEventStore.rollback(0))).rejects.toThrow( - 'PXE private event store rollback is not allowed while jobs are running', + 'PXE private event store rollback is not allowed while staged writes are pending', ); - await privateEventStore.discardStaged('uncommitted-job'); + await privateEventStore.discardStaged('uncommitted-change-set'); await expect(kvStore.transactionAsync(() => privateEventStore.rollback(0))).resolves.not.toThrow(); }); @@ -728,7 +729,7 @@ describe('PrivateEventStore', () => { }, 'test', ); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); const ids = await privateEventStore.eventIdsAtBlock(l2BlockNumber); expect(ids).toContain(siloedEventCommitment.toString()); @@ -769,17 +770,17 @@ describe('PrivateEventStore', () => { }, 'test', ); - await privateEventStore.commit('test'); + await privateEventStore.commitStaged('test'); const ids = await privateEventStore.eventIdsAtBlock(l2BlockNumber); expect(new Set(ids)).toEqual(new Set([siloedEventCommitment.toString(), siloedEventCommitment2.toString()])); }); }); - describe('staging', () => { + describe('change-set', () => { it('stages events without affecting committed storage', async () => { - const commitJobId: string = 'commit-job'; - const stagingJobId: string = 'staging-job'; + const commitChangeSetId: ChangeSetId = 'commit-change-set'; + const stagedChangeSetId: ChangeSetId = 'staged'; const committedEventRandomness = Fr.random(); const stagedEventRandomness = Fr.random(); @@ -799,9 +800,9 @@ describe('PrivateEventStore', () => { txIndexInBlock: randomInt(100), eventIndexInTx: randomInt(100), }, - commitJobId, + commitChangeSetId, ); - await privateEventStore.commit(commitJobId); + await privateEventStore.commitStaged(commitChangeSetId); // Store staged event (not committed) const stagedMsgContent = getRandomMsgContent(); @@ -819,10 +820,10 @@ describe('PrivateEventStore', () => { txIndexInBlock: randomInt(100), eventIndexInTx: randomInt(100), }, - stagingJobId, + stagedChangeSetId, ); - // With a fresh jobId, should only see committed event + // With a fresh changeSetId, should only see committed event const events = await privateEventStore.getPrivateEvents(eventSelector, { contractAddress, fromBlock: l2BlockNumber, @@ -834,7 +835,7 @@ describe('PrivateEventStore', () => { }); it('commit promotes staged events to main storage', async () => { - const stagingJobId: string = 'staging-job'; + const stagedChangeSetId: ChangeSetId = 'staged'; const stagedEventRandomness = Fr.random(); const stagedMsgContent = getRandomMsgContent(); @@ -852,12 +853,12 @@ describe('PrivateEventStore', () => { txIndexInBlock: randomInt(100), eventIndexInTx: randomInt(100), }, - stagingJobId, + stagedChangeSetId, ); - await privateEventStore.commit(stagingJobId); + await privateEventStore.commitStaged(stagedChangeSetId); - // Now should see the event with a fresh jobId + // Now should see the event with a fresh changeSetId const events = await privateEventStore.getPrivateEvents(eventSelector, { contractAddress, fromBlock: l2BlockNumber, @@ -869,8 +870,8 @@ describe('PrivateEventStore', () => { }); it('discardStaged removes staged events without affecting main', async () => { - const commitJobId: string = 'commit-job'; - const stagingJobId: string = 'staging-job'; + const commitChangeSetId: ChangeSetId = 'commit-change-set'; + const stagedChangeSetId: ChangeSetId = 'staged'; const committedEventRandomness = Fr.random(); const stagedEventRandomness = Fr.random(); @@ -889,9 +890,9 @@ describe('PrivateEventStore', () => { txIndexInBlock: randomInt(100), eventIndexInTx: randomInt(100), }, - commitJobId, + commitChangeSetId, ); - await privateEventStore.commit(commitJobId); + await privateEventStore.commitStaged(commitChangeSetId); // Store staged event (not committed) const stagedMsgContent = getRandomMsgContent(); @@ -909,11 +910,11 @@ describe('PrivateEventStore', () => { txIndexInBlock: randomInt(100), eventIndexInTx: randomInt(100), }, - stagingJobId, + stagedChangeSetId, ); - // Discard staging - await privateEventStore.discardStaged(stagingJobId); + // Discard change set + await privateEventStore.discardStaged(stagedChangeSetId); // Should only see committed event const events = await privateEventStore.getPrivateEvents(eventSelector, { diff --git a/yarn-project/pxe/src/storage/private_event_store/private_event_store.ts b/yarn-project/pxe/src/storage/private_event_store/private_event_store.ts index 0c8fe99c5ca3..f7d551d58f94 100644 --- a/yarn-project/pxe/src/storage/private_event_store/private_event_store.ts +++ b/yarn-project/pxe/src/storage/private_event_store/private_event_store.ts @@ -8,8 +8,8 @@ import type { EventSelector } from '@aztec/stdlib/abi'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { InTx, TxHash } from '@aztec/stdlib/tx'; -import type { StagedStore } from '../../job_coordinator/job_coordinator.js'; import type { PackedPrivateEvent } from '../../pxe.js'; +import type { ChangeSetId, StagedStore } from '../staged_write_coordinator.js'; import { StoredPrivateEvent } from './stored_private_event.js'; export type PrivateEventStoreFilter = { @@ -31,7 +31,6 @@ type PrivateEventMetadata = InTx & { /// Alias types for kv map readability type EventId = string; // the siloedEventCommitment, stringified -type JobId = string; type ContractAndSelectorKey = string; type BlockNum = number; type StoredEventBuffer = Buffer; @@ -53,11 +52,11 @@ export class PrivateEventStore implements StagedStore { /** Multi-map from block number to siloedEventCommitment, for delete-on-prune. */ #eventsByBlockNumber: AztecAsyncMultiMap; - /** jobId => eventId (event siloed nullifier) => StoredPrivateEvent */ - #eventsForJob: Map>; + /** changeSetId => eventId (event siloed nullifier) => StoredPrivateEvent */ + #eventsForChangeSet: Map>; - /** Per-job locks to prevent concurrent writes from affecting each other. */ - #jobLocks: Map; + /** Per-change-set locks to prevent concurrent writes from affecting each other. */ + #changeSetLocks: Map; logger = createLogger('private_event_store'); @@ -67,8 +66,8 @@ export class PrivateEventStore implements StagedStore { this.#eventsByContractAndEventSelector = this.#store.openMultiMap('events_by_contract_selector'); this.#eventsByBlockNumber = this.#store.openMultiMap('events_by_block_number'); - this.#eventsForJob = new Map(); - this.#jobLocks = new Map(); + this.#eventsForChangeSet = new Map(); + this.#changeSetLocks = new Map(); } /** @@ -89,14 +88,14 @@ export class PrivateEventStore implements StagedStore { msgContent: Fr[], siloedEventCommitment: Fr, metadata: PrivateEventMetadata, - jobId: string, + changeSetId: ChangeSetId, ) { - return this.#withJobLock(jobId, () => + return this.#withChangeSetLock(changeSetId, () => this.#store.transactionAsync(async () => { const { contractAddress, scope, txHash, l2BlockNumber, l2BlockHash, txIndexInBlock, eventIndexInTx } = metadata; const eventId = siloedEventCommitment.toString(); - this.logger.verbose('storing private event log (job stage)', { + this.logger.verbose('storing private event log (staged)', { eventId, contractAddress, scope, @@ -104,12 +103,12 @@ export class PrivateEventStore implements StagedStore { l2BlockNumber, }); - const existing = await this.#readEvent(eventId, jobId); + const existing = await this.#readEvent(eventId, changeSetId); if (existing) { // If we already stored this event, we still want to make sure to track it for the given scope existing.addScope(scope.toString()); - this.#writeEvent(eventId, existing, jobId); + this.#writeEvent(eventId, existing, changeSetId); } else { this.#writeEvent( eventId, @@ -125,7 +124,7 @@ export class PrivateEventStore implements StagedStore { eventSelector, new Set([scope.toString()]), ), - jobId, + changeSetId, ); } }), @@ -241,13 +240,14 @@ export class PrivateEventStore implements StagedStore { * that block height ever happened. Used by the reorg (`chain-pruned`) path to truncate the orphaned tail. Scanning * from `toBlock + 1` upward covers everything above the rollback target without needing to know the chain tip. * - * Must be called inside a transaction owned by the caller (it issues no `transactionAsync` of its own, the reorg - * path wraps it together with the anchor update, and IndexedDB has no nested transactions). Throws if any job has - * uncommitted staged writes, since rolling back mid-job could later re-introduce events anchored to deleted blocks. + * Must be called inside a transaction owned by the caller (it issues no `transactionAsync` of its own, the reorg path + * wraps it together with the anchor update, and IndexedDB has no nested transactions). Throws if any change set has + * uncommitted staged writes, since rolling back mid-change-set could later re-introduce events anchored to deleted + * blocks. */ public async rollback(toBlock: number): Promise { - if (this.#eventsForJob.size > 0) { - throw new Error('PXE private event store rollback is not allowed while jobs are running'); + if (this.#eventsForChangeSet.size > 0) { + throw new Error('PXE private event store rollback is not allowed while staged writes are pending'); } // Snapshot before mutating so we never delete from the multimap we are iterating. const orphaned: { block: number; eventId: string }[] = []; @@ -273,20 +273,20 @@ export class PrivateEventStore implements StagedStore { } /** - * Commits in memory job data to persistent storage. + * Commits in-memory staged data to persistent storage. * - * Called by JobCoordinator when a job completes successfully. + * Called by StagedWriteCoordinator when an operation completes successfully. * - * Note: JobCoordinator wraps all commits in a single transaction, so we don't need our own transactionAsync here - * (and using one would throw on IndexedDB as it does not support nested txs). + * Note: StagedWriteCoordinator wraps all commits in a single transaction, so we don't need our own transactionAsync + * here (and using one would throw on IndexedDB as it does not support nested txs). * - * @param jobId - The jobId identifying which staged data to commit + * @param changeSetId - The changeSetId identifying which staged data to commit */ - async commit(jobId: string): Promise { - // Note: Don't use #withJobLock here - commit runs within JobCoordinator's transactionAsync, + async commitStaged(changeSetId: ChangeSetId): Promise { + // Note: Don't use #withChangeSetLock here - commit runs within StagedWriteCoordinator's transactionAsync, // and awaiting the lock would create a microtask boundary with no pending DB request, // causing IndexedDB to auto-commit the transaction. - for (const [eventId, entry] of this.#getEventsForJob(jobId).entries()) { + for (const [eventId, entry] of this.#getEventsForChangeSet(changeSetId).entries()) { const lookupKey = this.#keyFor(entry.contractAddress, entry.eventSelector); this.logger.verbose('storing private event log', { eventId, lookupKey }); @@ -297,72 +297,72 @@ export class PrivateEventStore implements StagedStore { ]); } - this.#clearJobData(jobId); + this.#clearChangeSetData(changeSetId); } /** - * Discards in memory job data without persisting it. + * Discards in-memory staged data without persisting it. */ - discardStaged(jobId: string): Promise { - this.#clearJobData(jobId); + discardStaged(changeSetId: ChangeSetId): Promise { + this.#clearChangeSetData(changeSetId); return Promise.resolve(); } /** - * Reads an event from in-memory job data first, falling back to persistent storage if not found. + * Reads an event from in-memory staged data first, falling back to persistent storage if not found. * * Returns undefined if the event does not exist in the store overall. */ - async #readEvent(eventId: string, jobId: string): Promise { + async #readEvent(eventId: string, changeSetId: ChangeSetId): Promise { // Always issue DB read to keep IndexedDB transaction alive (they auto-commit when a new micro-task starts and there // are no pending read requests). The staged value still takes precedence if it exists. const buffer = await this.#events.getAsync(eventId); - const eventForJob = this.#getEventsForJob(jobId).get(eventId); - return eventForJob ?? (buffer ? StoredPrivateEvent.fromBuffer(buffer) : undefined); + const eventForChangeSet = this.#getEventsForChangeSet(changeSetId).get(eventId); + return eventForChangeSet ?? (buffer ? StoredPrivateEvent.fromBuffer(buffer) : undefined); } /** - * Writes an event to in-memory job data. + * Writes an event to in-memory staged data. * - * Writes are only allowed in a job context. Events modified during a job will only be persisted when `commit` is - * called. + * Writes are only allowed in a change set context. Events modified while staged will only be persisted when `commit` + * is called. */ - #writeEvent(eventId: string, entry: StoredPrivateEvent, jobId: string) { - this.#getEventsForJob(jobId).set(eventId, entry); + #writeEvent(eventId: string, entry: StoredPrivateEvent, changeSetId: ChangeSetId) { + this.#getEventsForChangeSet(changeSetId).set(eventId, entry); } /** - * Get in-memory data only visible to @param jobId + * Get in-memory data only visible to @param changeSetId */ - #getEventsForJob(jobId: string): Map { - let eventsForJob = this.#eventsForJob.get(jobId); - if (eventsForJob === undefined) { - eventsForJob = new Map(); - this.#eventsForJob.set(jobId, eventsForJob); + #getEventsForChangeSet(changeSetId: ChangeSetId): Map { + let eventsForChangeSet = this.#eventsForChangeSet.get(changeSetId); + if (eventsForChangeSet === undefined) { + eventsForChangeSet = new Map(); + this.#eventsForChangeSet.set(changeSetId, eventsForChangeSet); } - return eventsForJob; + return eventsForChangeSet; } /** - * Clear data structures supporting a specific job. + * Clear data structures supporting a specific change set. */ - #clearJobData(jobId: string) { - this.#eventsForJob.delete(jobId); - this.#jobLocks.delete(jobId); + #clearChangeSetData(changeSetId: ChangeSetId) { + this.#eventsForChangeSet.delete(changeSetId); + this.#changeSetLocks.delete(changeSetId); } /** - * Ensures a function can only run once it acquires a unique per-job lock, and handles proper lock release after it - * runs. + * Ensures a function can only run once it acquires a unique per-change-set lock, and handles proper lock release + * after it runs. * * This primitive allows concurrent writes on this store without risking data corruption due to unsound write * interleaving. */ - async #withJobLock(jobId: string, fn: () => Promise): Promise { - let lock = this.#jobLocks.get(jobId); + async #withChangeSetLock(changeSetId: ChangeSetId, fn: () => Promise): Promise { + let lock = this.#changeSetLocks.get(changeSetId); if (!lock) { lock = new Semaphore(1); - this.#jobLocks.set(jobId, lock); + this.#changeSetLocks.set(changeSetId, lock); } await lock.acquire(); try { diff --git a/yarn-project/pxe/src/storage/staged_write_coordinator.test.ts b/yarn-project/pxe/src/storage/staged_write_coordinator.test.ts new file mode 100644 index 000000000000..a305b39be3a5 --- /dev/null +++ b/yarn-project/pxe/src/storage/staged_write_coordinator.test.ts @@ -0,0 +1,144 @@ +import type { AztecAsyncKVStore } from '@aztec/kv-store'; +import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; + +import { jest } from '@jest/globals'; + +import { type ChangeSetId, type StagedStore, StagedWriteCoordinator } from './staged_write_coordinator.js'; + +describe('StagedWriteCoordinator', () => { + let store: AztecAsyncKVStore; + let coordinator: StagedWriteCoordinator; + + beforeEach(async () => { + store = await openTmpStore('staged_write_coordinator_test'); + coordinator = new StagedWriteCoordinator({ kvStore: store, stagedStores: [] }); + }); + + describe('begin', () => { + it('creates a new change set id', () => { + const changeSetId = coordinator.begin(); + + expect(typeof changeSetId).toBe('string'); + expect(changeSetId.length).toBeGreaterThan(0); + }); + + it('throws if change set already active', () => { + coordinator.begin(); + expect(() => coordinator.begin()).toThrow(/already active/); + }); + }); + + describe('commit', () => { + it('clears change set marker on commit', async () => { + const changeSetId = coordinator.begin(); + await coordinator.commit(changeSetId); + expect(() => coordinator.begin()).not.toThrow(); + }); + + it('throws if no matching change set active', async () => { + const changeSetId = coordinator.begin(); + await coordinator.commit(changeSetId); + await expect(coordinator.commit(changeSetId)).rejects.toThrow(/no matching change set/); + }); + + it('throws if no change set was ever opened', async () => { + await expect(coordinator.commit('deadbeef')).rejects.toThrow(/no matching change set/); + }); + + it('throws if the change set id does not match the open one', async () => { + coordinator.begin(); + await expect(coordinator.commit('deadbeef')).rejects.toThrow(/no matching change set/); + }); + + it('calls commitStaged on its stores within a single kv transaction', async () => { + const realTransactionAsync = store.transactionAsync.bind(store); + let inTransaction = false; + jest.spyOn(store, 'transactionAsync').mockImplementation(async callback => { + inTransaction = true; + try { + return await realTransactionAsync(callback); + } finally { + inTransaction = false; + } + }); + + const committed: { changeSetId: ChangeSetId; inTransaction: boolean }[] = []; + const mockStore: StagedStore = { + storeName: 'mock_store', + commitStaged: changeSetId => { + committed.push({ changeSetId, inTransaction }); + return Promise.resolve(); + }, + discardStaged: () => Promise.resolve(), + }; + + coordinator = new StagedWriteCoordinator({ kvStore: store, stagedStores: [mockStore] }); + + const changeSetId = coordinator.begin(); + + await coordinator.commit(changeSetId); + + expect(committed).toEqual([{ changeSetId, inTransaction: true }]); + }); + }); + + describe('abort', () => { + it('clears change set marker on abort', async () => { + const changeSetId = coordinator.begin(); + + await coordinator.abort(changeSetId); + + expect(() => coordinator.begin()).not.toThrow(); + }); + + it('throws if no matching change set active', async () => { + const changeSetId = coordinator.begin(); + await coordinator.abort(changeSetId); + + await expect(coordinator.abort(changeSetId)).rejects.toThrow(/no matching change set/); + }); + + it('throws if no change set was ever opened', async () => { + await expect(coordinator.abort('deadbeef')).rejects.toThrow(/no matching change set/); + }); + + it('throws if the change set id does not match the open one', async () => { + coordinator.begin(); + await expect(coordinator.abort('deadbeef')).rejects.toThrow(/no matching change set/); + }); + + it('calls discardStaged on all its stores', async () => { + const commitMock = jest.fn<() => Promise>().mockResolvedValue(undefined); + const discardStagedMock = jest.fn<() => Promise>().mockResolvedValue(undefined); + const mockStore: StagedStore = { + storeName: 'mock_store', + commitStaged: commitMock, + discardStaged: discardStagedMock, + }; + + coordinator = new StagedWriteCoordinator({ kvStore: store, stagedStores: [mockStore] }); + + const changeSetId = coordinator.begin(); + + await coordinator.abort(changeSetId); + + expect(discardStagedMock).toHaveBeenCalledWith(changeSetId); + }); + }); + + describe('construction', () => { + it('throws on stores with duplicate names', () => { + const commitMock = jest.fn<() => Promise>().mockResolvedValue(undefined); + const discardStagedMock = jest.fn<() => Promise>().mockResolvedValue(undefined); + const mockStore: StagedStore = { + storeName: 'mock_store', + commitStaged: commitMock, + discardStaged: discardStagedMock, + }; + + expect(() => new StagedWriteCoordinator({ kvStore: store, stagedStores: [mockStore, mockStore] })).toThrow( + /already registered/, + ); + }); + }); +}); diff --git a/yarn-project/pxe/src/storage/staged_write_coordinator.ts b/yarn-project/pxe/src/storage/staged_write_coordinator.ts new file mode 100644 index 000000000000..ab52f108158e --- /dev/null +++ b/yarn-project/pxe/src/storage/staged_write_coordinator.ts @@ -0,0 +1,153 @@ +import { randomBytes } from '@aztec/foundation/crypto/random'; +import { type Logger, type LoggerBindings, createLogger } from '@aztec/foundation/log'; +import type { AztecAsyncKVStore } from '@aztec/kv-store'; + +/** + * Identifies a change set: the writes staged between a {@link StagedWriteCoordinator.begin} and its matching commit or + * abort, which are promoted to the database or dropped as a unit. + */ +export type ChangeSetId = string; + +/** + * A store that buffers its writes per change set instead of sending them straight to the database. + * + * Every read and write on such a store takes a change set ID. Writes are held under that ID; a read sees the database + * plus whatever its own change set has staged, never another's. + * + * {@link StagedWriteCoordinator} ends a change set by calling {@link commitStaged}, which promotes its staged writes + * to the database, or {@link discardStaged}, which throws them away. + */ +export interface StagedStore { + /** Unique name identifying this store (used for tracking staged stores from StagedWriteCoordinator) */ + readonly storeName: string; + + /** + * Commits staged data to persistent storage. Will be called within a db transaction for atomicity, alongside the + * writes of all other staged stores for the same change set. + * + * @param changeSetId - The change set identifier + */ + commitStaged(changeSetId: ChangeSetId): Promise; + + /** + * Discards staged data without committing. Called on abort. + * + * @param changeSetId - The change set identifier + */ + discardStaged(changeSetId: ChangeSetId): Promise; +} + +/** + * StagedWriteCoordinator simulates a database transaction across the PXE stores, which some underlying KV stores + * (e.g. IndexedDB) cannot provide on their own for long-running async operations. + * + * It uses a staged writes pattern: + * 1. When a change set is opened, a unique ID is created + * 2. While a change set is open, all writes are staged under its ID, and reads observe the staged data + * 3. On commit, the staged data is promoted to persistent storage + * 4. On abort, staged data is discarded + * + * Only one change set can be open at a time: {@link begin} throws if one already is. Supporting overlapping change + * sets would mean merging them when one of them commits — a problem in its own right, and one no caller needs solved. + * Avoiding that throw is up to the caller, which must serialize whatever opens change sets, e.g. with a queue. + * + * Staged data is nonetheless keyed by change set ID, because aborting a change set does not cancel the async work it + * started. An oracle that was mid-write when the operation failed still finishes writing afterwards, and stages its + * write under the aborted ID, which nothing will ever promote. Were staged data not keyed by ID, that late write + * would instead sit in the store and be promoted by whichever change set commits next. + */ +export class StagedWriteCoordinator { + readonly #kvStore: AztecAsyncKVStore; + readonly #stagedStores: Map = new Map(); + readonly #log: Logger; + + #currentChangeSetId: ChangeSetId | undefined; + + constructor(args: StagedWriteCoordinatorArgs) { + this.#kvStore = args.kvStore; + this.#log = createLogger('pxe:staged_write_coordinator', args.bindings); + for (const store of args.stagedStores) { + if (this.#stagedStores.has(store.storeName)) { + throw new Error(`Store "${store.storeName}" is already registered`); + } + this.#stagedStores.set(store.storeName, store); + } + } + + /** + * Opens a change set and returns its ID for staged writes. + * + * @returns Change set ID to pass to store operations + */ + begin(): ChangeSetId { + if (this.#currentChangeSetId) { + throw new Error( + `Cannot open change set: change set ${this.#currentChangeSetId} is already active. ` + + `This should not happen - ensure change sets are properly committed or aborted.`, + ); + } + + const changeSetId = randomBytes(8).toString('hex'); + this.#currentChangeSetId = changeSetId; + + this.#log.debug(`Opened change set ${changeSetId}`, { changeSetId }); + return changeSetId; + } + + /** + * Commits by promoting all staged data to persistent storage. + * + * @param changeSetId - The change set ID returned from begin + */ + async commit(changeSetId: ChangeSetId): Promise { + if (this.#currentChangeSetId !== changeSetId) { + throw new Error( + `Cannot commit change set ${changeSetId}: no matching change set active. ` + + `Current change set: ${this.#currentChangeSetId ?? 'none'}`, + ); + } + + this.#log.debug(`Committing change set ${changeSetId}`, { changeSetId }); + + // Commit all stores atomically in a single transaction. + // Each store's commit is a no-op if it has no staged data (but that's up to each store to handle). + await this.#kvStore.transactionAsync(async () => { + for (const store of this.#stagedStores.values()) { + await store.commitStaged(changeSetId); + } + }); + + this.#currentChangeSetId = undefined; + this.#log.debug(`Change set ${changeSetId} committed successfully`, { changeSetId }); + } + + /** + * Aborts by discarding all staged data. + * + * @param changeSetId - The change set ID returned from begin + */ + async abort(changeSetId: ChangeSetId): Promise { + if (this.#currentChangeSetId !== changeSetId) { + throw new Error( + `Cannot abort change set ${changeSetId}: no matching change set active. ` + + `Current change set: ${this.#currentChangeSetId ?? 'none'}`, + ); + } + + this.#log.debug(`Aborting change set ${changeSetId}`, { changeSetId }); + + for (const store of this.#stagedStores.values()) { + await store.discardStaged(changeSetId); + } + + this.#currentChangeSetId = undefined; + this.#log.debug(`Change set ${changeSetId} aborted`, { changeSetId }); + } +} + +/** Dependencies of the {@link StagedWriteCoordinator}. */ +type StagedWriteCoordinatorArgs = { + kvStore: AztecAsyncKVStore; + stagedStores: StagedStore[]; + bindings?: LoggerBindings; +}; diff --git a/yarn-project/pxe/src/storage/tagging_store/recipient_tagging_store.test.ts b/yarn-project/pxe/src/storage/tagging_store/recipient_tagging_store.test.ts index 766c460ece6d..3b735981fa29 100644 --- a/yarn-project/pxe/src/storage/tagging_store/recipient_tagging_store.test.ts +++ b/yarn-project/pxe/src/storage/tagging_store/recipient_tagging_store.test.ts @@ -17,72 +17,72 @@ describe('RecipientTaggingStore', () => { describe('staged writes', () => { it('persists staged highest aged index to the store', async () => { - await taggingStore.updateHighestAgedIndex(secret1, 5, 'job1'); + await taggingStore.updateHighestAgedIndex(secret1, 5, 'change-set-1'); - expect(await taggingStore.getHighestAgedIndex(secret1, 'job2')).toBeUndefined(); + expect(await taggingStore.getHighestAgedIndex(secret1, 'change-set-2')).toBeUndefined(); - await taggingStore.commit('job1'); + await taggingStore.commitStaged('change-set-1'); - expect(await taggingStore.getHighestAgedIndex(secret1, 'job2')).toBe(5); + expect(await taggingStore.getHighestAgedIndex(secret1, 'change-set-2')).toBe(5); }); it('persists staged highest finalized index to the store', async () => { - await taggingStore.updateHighestFinalizedIndex(secret1, 10, 'job1'); + await taggingStore.updateHighestFinalizedIndex(secret1, 10, 'change-set-1'); - expect(await taggingStore.getHighestFinalizedIndex(secret1, 'job2')).toBeUndefined(); + expect(await taggingStore.getHighestFinalizedIndex(secret1, 'change-set-2')).toBeUndefined(); - await taggingStore.commit('job1'); + await taggingStore.commitStaged('change-set-1'); - expect(await taggingStore.getHighestFinalizedIndex(secret1, 'job2')).toBe(10); + expect(await taggingStore.getHighestFinalizedIndex(secret1, 'change-set-2')).toBe(10); }); - it('persists multiple secrets for the same job', async () => { - await taggingStore.updateHighestAgedIndex(secret1, 5, 'job1'); - await taggingStore.updateHighestAgedIndex(secret2, 8, 'job1'); - await taggingStore.updateHighestFinalizedIndex(secret1, 3, 'job1'); - await taggingStore.updateHighestFinalizedIndex(secret2, 6, 'job1'); + it('persists multiple secrets for the same change set', async () => { + await taggingStore.updateHighestAgedIndex(secret1, 5, 'change-set-1'); + await taggingStore.updateHighestAgedIndex(secret2, 8, 'change-set-1'); + await taggingStore.updateHighestFinalizedIndex(secret1, 3, 'change-set-1'); + await taggingStore.updateHighestFinalizedIndex(secret2, 6, 'change-set-1'); - await taggingStore.commit('job1'); + await taggingStore.commitStaged('change-set-1'); - expect(await taggingStore.getHighestAgedIndex(secret1, 'job2')).toBe(5); - expect(await taggingStore.getHighestAgedIndex(secret2, 'job2')).toBe(8); - expect(await taggingStore.getHighestFinalizedIndex(secret1, 'job2')).toBe(3); - expect(await taggingStore.getHighestFinalizedIndex(secret2, 'job2')).toBe(6); + expect(await taggingStore.getHighestAgedIndex(secret1, 'change-set-2')).toBe(5); + expect(await taggingStore.getHighestAgedIndex(secret2, 'change-set-2')).toBe(8); + expect(await taggingStore.getHighestFinalizedIndex(secret1, 'change-set-2')).toBe(3); + expect(await taggingStore.getHighestFinalizedIndex(secret2, 'change-set-2')).toBe(6); }); it('clears staged data after commit', async () => { - await taggingStore.updateHighestAgedIndex(secret1, 5, 'job1'); - await taggingStore.commit('job1'); + await taggingStore.updateHighestAgedIndex(secret1, 5, 'change-set-1'); + await taggingStore.commitStaged('change-set-1'); - // Updating again with a higher value in the same job should work + // Updating again with a higher value in the same change set should work // (if staged data wasn't cleared, it would still have the old value cached) - await taggingStore.updateHighestAgedIndex(secret1, 10, 'job2'); - expect(await taggingStore.getHighestAgedIndex(secret1, 'job2')).toBe(10); - await taggingStore.commit('job2'); + await taggingStore.updateHighestAgedIndex(secret1, 10, 'change-set-2'); + expect(await taggingStore.getHighestAgedIndex(secret1, 'change-set-2')).toBe(10); + await taggingStore.commitStaged('change-set-2'); - expect(await taggingStore.getHighestAgedIndex(secret1, 'job1')).toBe(10); + expect(await taggingStore.getHighestAgedIndex(secret1, 'change-set-1')).toBe(10); }); - it('does not affect other jobs when committing', async () => { - await taggingStore.updateHighestAgedIndex(secret1, 5, 'job1'); - await taggingStore.updateHighestAgedIndex(secret1, 10, 'job2'); + it('does not affect other change sets when committing', async () => { + await taggingStore.updateHighestAgedIndex(secret1, 5, 'change-set-1'); + await taggingStore.updateHighestAgedIndex(secret1, 10, 'change-set-2'); - await taggingStore.commit('job2'); + await taggingStore.commitStaged('change-set-2'); - // job1's staged value should still be intact - expect(await taggingStore.getHighestAgedIndex(secret1, 'job1')).toBe(5); + // change-set-1's staged value should still be intact + expect(await taggingStore.getHighestAgedIndex(secret1, 'change-set-1')).toBe(5); }); it('discards staged highest aged index without persisting', async () => { - await taggingStore.updateHighestAgedIndex(secret1, 5, 'job1'); - await taggingStore.discardStaged('job1'); - expect(await taggingStore.getHighestAgedIndex(secret1, 'job1')).toBeUndefined(); + await taggingStore.updateHighestAgedIndex(secret1, 5, 'change-set-1'); + await taggingStore.discardStaged('change-set-1'); + expect(await taggingStore.getHighestAgedIndex(secret1, 'change-set-1')).toBeUndefined(); }); it('discards staged highest finalized index without persisting', async () => { - await taggingStore.updateHighestFinalizedIndex(secret1, 5, 'job1'); - await taggingStore.discardStaged('job1'); - expect(await taggingStore.getHighestFinalizedIndex(secret1, 'job1')).toBeUndefined(); + await taggingStore.updateHighestFinalizedIndex(secret1, 5, 'change-set-1'); + await taggingStore.discardStaged('change-set-1'); + expect(await taggingStore.getHighestFinalizedIndex(secret1, 'change-set-1')).toBeUndefined(); }); }); @@ -98,12 +98,12 @@ describe('RecipientTaggingStore', () => { AppTaggingSecretKind.CONSTRAINED, ); - await taggingStore.updateHighestFinalizedIndex(unconstrained, 4, 'job1'); - await taggingStore.updateHighestFinalizedIndex(constrained, 9, 'job1'); - await taggingStore.commit('job1'); + await taggingStore.updateHighestFinalizedIndex(unconstrained, 4, 'change-set-1'); + await taggingStore.updateHighestFinalizedIndex(constrained, 9, 'change-set-1'); + await taggingStore.commitStaged('change-set-1'); - expect(await taggingStore.getHighestFinalizedIndex(unconstrained, 'job2')).toBe(4); - expect(await taggingStore.getHighestFinalizedIndex(constrained, 'job2')).toBe(9); + expect(await taggingStore.getHighestFinalizedIndex(unconstrained, 'change-set-2')).toBe(4); + expect(await taggingStore.getHighestFinalizedIndex(constrained, 'change-set-2')).toBe(9); }); }); }); diff --git a/yarn-project/pxe/src/storage/tagging_store/recipient_tagging_store.ts b/yarn-project/pxe/src/storage/tagging_store/recipient_tagging_store.ts index dd9b5e65653a..c79351d0c363 100644 --- a/yarn-project/pxe/src/storage/tagging_store/recipient_tagging_store.ts +++ b/yarn-project/pxe/src/storage/tagging_store/recipient_tagging_store.ts @@ -1,7 +1,7 @@ import type { AztecAsyncKVStore, AztecAsyncMap } from '@aztec/kv-store'; import type { AppTaggingSecret } from '@aztec/stdlib/logs'; -import type { StagedStore } from '../../job_coordinator/job_coordinator.js'; +import type { ChangeSetId, StagedStore } from '../staged_write_coordinator.js'; /** * Data provider of tagging data used when syncing the logs as a recipient. The sender counterpart of this class @@ -19,11 +19,11 @@ export class RecipientTaggingStore implements StagedStore { #highestAgedIndex: AztecAsyncMap; #highestFinalizedIndex: AztecAsyncMap; - // jobId => secret => number - #highestAgedIndexForJob: Map>; + // changeSetId => secret => number + #highestAgedIndexForChangeSet: Map>; - // jobId => secret => number - #highestFinalizedIndexForJob: Map>; + // changeSetId => secret => number + #highestFinalizedIndexForChangeSet: Map>; constructor(store: AztecAsyncKVStore) { this.#store = store; @@ -31,109 +31,110 @@ export class RecipientTaggingStore implements StagedStore { this.#highestAgedIndex = this.#store.openMap('highest_aged_index'); this.#highestFinalizedIndex = this.#store.openMap('highest_finalized_index'); - this.#highestAgedIndexForJob = new Map(); - this.#highestFinalizedIndexForJob = new Map(); + this.#highestAgedIndexForChangeSet = new Map(); + this.#highestFinalizedIndexForChangeSet = new Map(); } - #getHighestAgedIndexForJob(jobId: string): Map { - let highestAgedIndexForJob = this.#highestAgedIndexForJob.get(jobId); - if (!highestAgedIndexForJob) { - highestAgedIndexForJob = new Map(); - this.#highestAgedIndexForJob.set(jobId, highestAgedIndexForJob); + #getHighestAgedIndexForChangeSet(changeSetId: ChangeSetId): Map { + let highestAgedIndexForChangeSet = this.#highestAgedIndexForChangeSet.get(changeSetId); + if (!highestAgedIndexForChangeSet) { + highestAgedIndexForChangeSet = new Map(); + this.#highestAgedIndexForChangeSet.set(changeSetId, highestAgedIndexForChangeSet); } - return highestAgedIndexForJob; + return highestAgedIndexForChangeSet; } - async #readHighestAgedIndex(jobId: string, secret: string): Promise { + async #readHighestAgedIndex(changeSetId: ChangeSetId, secret: string): Promise { // Always issue DB read to keep IndexedDB transaction alive (they auto-commit when a new micro-task starts and there // are no pending read requests). The staged value still takes precedence if it exists. const dbValue = await this.#highestAgedIndex.getAsync(secret); - const staged = this.#getHighestAgedIndexForJob(jobId).get(secret); + const staged = this.#getHighestAgedIndexForChangeSet(changeSetId).get(secret); return staged ?? dbValue; } - #writeHighestAgedIndex(jobId: string, secret: string, index: number) { - this.#getHighestAgedIndexForJob(jobId).set(secret, index); + #writeHighestAgedIndex(changeSetId: ChangeSetId, secret: string, index: number) { + this.#getHighestAgedIndexForChangeSet(changeSetId).set(secret, index); } - #getHighestFinalizedIndexForJob(jobId: string): Map { - let jobStagedHighestFinalizedIndex = this.#highestFinalizedIndexForJob.get(jobId); - if (!jobStagedHighestFinalizedIndex) { - jobStagedHighestFinalizedIndex = new Map(); - this.#highestFinalizedIndexForJob.set(jobId, jobStagedHighestFinalizedIndex); + #getHighestFinalizedIndexForChangeSet(changeSetId: ChangeSetId): Map { + let stagedHighestFinalizedIndex = this.#highestFinalizedIndexForChangeSet.get(changeSetId); + if (!stagedHighestFinalizedIndex) { + stagedHighestFinalizedIndex = new Map(); + this.#highestFinalizedIndexForChangeSet.set(changeSetId, stagedHighestFinalizedIndex); } - return jobStagedHighestFinalizedIndex; + return stagedHighestFinalizedIndex; } - async #readHighestFinalizedIndex(jobId: string, secret: string): Promise { + async #readHighestFinalizedIndex(changeSetId: ChangeSetId, secret: string): Promise { // Always issue DB read to keep IndexedDB transaction alive (they auto-commit when a new micro-task starts and there // are no pending read requests). The staged value still takes precedence if it exists. const dbValue = await this.#highestFinalizedIndex.getAsync(secret); - const staged = this.#getHighestFinalizedIndexForJob(jobId).get(secret); + const staged = this.#getHighestFinalizedIndexForChangeSet(changeSetId).get(secret); return staged ?? dbValue; } - #writeHighestFinalizedIndex(jobId: string, secret: string, index: number) { - this.#getHighestFinalizedIndexForJob(jobId).set(secret, index); + #writeHighestFinalizedIndex(changeSetId: ChangeSetId, secret: string, index: number) { + this.#getHighestFinalizedIndexForChangeSet(changeSetId).set(secret, index); } /** - * Writes all job-specific in-memory data to persistent storage. + * Writes all change set-specific in-memory data to persistent storage. * - * @remark This method must run in a DB transaction context. It's designed to be called from JobCoordinator#commitJob. + * @remark This method must run in a DB transaction context. It's designed to be called from + * {@link StagedWriteCoordinator.commit}. */ - async commit(jobId: string): Promise { - const highestAgedIndexForJob = this.#highestAgedIndexForJob.get(jobId); - if (highestAgedIndexForJob) { - for (const [secret, index] of highestAgedIndexForJob.entries()) { + async commitStaged(changeSetId: ChangeSetId): Promise { + const highestAgedIndexForChangeSet = this.#highestAgedIndexForChangeSet.get(changeSetId); + if (highestAgedIndexForChangeSet) { + for (const [secret, index] of highestAgedIndexForChangeSet.entries()) { await this.#highestAgedIndex.set(secret, index); } } - const highestFinalizedIndexForJob = this.#highestFinalizedIndexForJob.get(jobId); - if (highestFinalizedIndexForJob) { - for (const [secret, index] of highestFinalizedIndexForJob.entries()) { + const highestFinalizedIndexForChangeSet = this.#highestFinalizedIndexForChangeSet.get(changeSetId); + if (highestFinalizedIndexForChangeSet) { + for (const [secret, index] of highestFinalizedIndexForChangeSet.entries()) { await this.#highestFinalizedIndex.set(secret, index); } } - return this.discardStaged(jobId); + return this.discardStaged(changeSetId); } - discardStaged(jobId: string): Promise { - this.#highestAgedIndexForJob.delete(jobId); - this.#highestFinalizedIndexForJob.delete(jobId); + discardStaged(changeSetId: ChangeSetId): Promise { + this.#highestAgedIndexForChangeSet.delete(changeSetId); + this.#highestFinalizedIndexForChangeSet.delete(changeSetId); return Promise.resolve(); } - getHighestAgedIndex(secret: AppTaggingSecret, jobId: string): Promise { - return this.#store.transactionAsync(() => this.#readHighestAgedIndex(jobId, secret.toString())); + getHighestAgedIndex(secret: AppTaggingSecret, changeSetId: ChangeSetId): Promise { + return this.#store.transactionAsync(() => this.#readHighestAgedIndex(changeSetId, secret.toString())); } - updateHighestAgedIndex(secret: AppTaggingSecret, index: number, jobId: string): Promise { + updateHighestAgedIndex(secret: AppTaggingSecret, index: number, changeSetId: ChangeSetId): Promise { return this.#store.transactionAsync(async () => { - const currentIndex = await this.#readHighestAgedIndex(jobId, secret.toString()); + const currentIndex = await this.#readHighestAgedIndex(changeSetId, secret.toString()); if (currentIndex !== undefined && index <= currentIndex) { // Log sync should never set a lower highest aged index. throw new Error(`New highest aged index (${index}) must be higher than the current one (${currentIndex})`); } - this.#writeHighestAgedIndex(jobId, secret.toString(), index); + this.#writeHighestAgedIndex(changeSetId, secret.toString(), index); }); } - getHighestFinalizedIndex(secret: AppTaggingSecret, jobId: string): Promise { - return this.#store.transactionAsync(() => this.#readHighestFinalizedIndex(jobId, secret.toString())); + getHighestFinalizedIndex(secret: AppTaggingSecret, changeSetId: ChangeSetId): Promise { + return this.#store.transactionAsync(() => this.#readHighestFinalizedIndex(changeSetId, secret.toString())); } - updateHighestFinalizedIndex(secret: AppTaggingSecret, index: number, jobId: string): Promise { + updateHighestFinalizedIndex(secret: AppTaggingSecret, index: number, changeSetId: ChangeSetId): Promise { return this.#store.transactionAsync(async () => { - const currentIndex = await this.#readHighestFinalizedIndex(jobId, secret.toString()); + const currentIndex = await this.#readHighestFinalizedIndex(changeSetId, secret.toString()); if (currentIndex !== undefined && index < currentIndex) { // Log sync should never set a lower highest finalized index but it can happen that it would try to set the same // one because we are loading logs from highest aged index + 1 and not from the highest finalized index. throw new Error(`New highest finalized index (${index}) must be higher than the current one (${currentIndex})`); } - this.#writeHighestFinalizedIndex(jobId, secret.toString(), index); + this.#writeHighestFinalizedIndex(changeSetId, secret.toString(), index); }); } } diff --git a/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.test.ts b/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.test.ts index e02698ab6b29..ef1e09e6b26a 100644 --- a/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.test.ts +++ b/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.test.ts @@ -13,6 +13,7 @@ import { randomAppTaggingSecret } from '@aztec/stdlib/testing'; import { TxEffect, TxHash } from '@aztec/stdlib/tx'; import { UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN, unfinalizedTaggingIndexesWindowEnd } from '../../tagging/constants.js'; +import type { ChangeSetId } from '../staged_write_coordinator.js'; import { SenderTaggingStore, windowExceededError } from './sender_tagging_store.js'; /** Helper to create a single-index range (lowestIndex === highestIndex). */ @@ -765,79 +766,79 @@ describe('SenderTaggingStore', () => { }); describe('staged writes', () => { - it('writes of uncommitted jobs are not visible outside the job that makes them', async () => { + it('writes of uncommitted change sets are not visible outside the change set that makes them', async () => { const committedTxHash = TxHash.random(); { - const commitJobId: string = 'commit-job'; - await taggingStore.storePendingIndexes([range(secret1, 3)], committedTxHash, commitJobId); - await taggingStore.commit(commitJobId); + const commitChangeSetId: ChangeSetId = 'commit-change-set'; + await taggingStore.storePendingIndexes([range(secret1, 3)], committedTxHash, commitChangeSetId); + await taggingStore.commitStaged(commitChangeSetId); } const stagedTxHash = TxHash.random(); - const stagingJobId: string = 'staging-job'; - await taggingStore.storePendingIndexes([range(secret1, 5)], stagedTxHash, stagingJobId); + const stagedChangeSetId: ChangeSetId = 'staged'; + await taggingStore.storePendingIndexes([range(secret1, 5)], stagedTxHash, stagedChangeSetId); - // For a job without any staged data we should only get committed data - const txHashesWithoutJobId = await pendingTxHashes(secret1, 0, 10, 'no-data-job'); - expect(txHashesWithoutJobId).toHaveLength(1); - expect(txHashesWithoutJobId[0]).toEqual(committedTxHash); + // For a change set without any staged data we should only get committed data + const txHashesWithoutChangeSetId = await pendingTxHashes(secret1, 0, 10, 'no-data-change-set'); + expect(txHashesWithoutChangeSetId).toHaveLength(1); + expect(txHashesWithoutChangeSetId[0]).toEqual(committedTxHash); - // With stagingJobId, should get both committed and staged data - const txHashesWithJobId = await pendingTxHashes(secret1, 0, 10, stagingJobId); - expect(txHashesWithJobId).toHaveLength(2); - expect(txHashesWithJobId).toContainEqual(committedTxHash); - expect(txHashesWithJobId).toContainEqual(stagedTxHash); + // With stagedChangeSetId, should get both committed and staged data + const txHashesWithChangeSetId = await pendingTxHashes(secret1, 0, 10, stagedChangeSetId); + expect(txHashesWithChangeSetId).toHaveLength(2); + expect(txHashesWithChangeSetId).toContainEqual(committedTxHash); + expect(txHashesWithChangeSetId).toContainEqual(stagedTxHash); }); - it('job staged data is correctly isolated when storing and finalizing pending indexes', async () => { + it('staged data is correctly isolated when storing and finalizing pending indexes', async () => { const txHash1 = TxHash.random(); { - const commitJobId: string = 'commit-job'; - await taggingStore.storePendingIndexes([range(secret1, 3)], txHash1, commitJobId); - await taggingStore.finalizePendingIndexes([txHash1], commitJobId); - await taggingStore.commit(commitJobId); + const commitChangeSetId: ChangeSetId = 'commit-change-set'; + await taggingStore.storePendingIndexes([range(secret1, 3)], txHash1, commitChangeSetId); + await taggingStore.finalizePendingIndexes([txHash1], commitChangeSetId); + await taggingStore.commitStaged(commitChangeSetId); } const txHash2 = TxHash.random(); - const stagingJobId: string = 'staging-job'; + const stagedChangeSetId: ChangeSetId = 'staged'; // Stage a higher finalized index (not committed) - await taggingStore.storePendingIndexes([range(secret1, 7)], txHash2, stagingJobId); - await taggingStore.finalizePendingIndexes([txHash2], stagingJobId); + await taggingStore.storePendingIndexes([range(secret1, 7)], txHash2, stagedChangeSetId); + await taggingStore.finalizePendingIndexes([txHash2], stagedChangeSetId); - // With a different jobId, should get the committed finalized index - expect(await taggingStore.getLastFinalizedIndex(secret1, 'no-data-job')).toBe(3); + // With a different changeSetId, should get the committed finalized index + expect(await taggingStore.getLastFinalizedIndex(secret1, 'no-data-change-set')).toBe(3); - // With stagingJobId, should get the staged finalized index - expect(await taggingStore.getLastFinalizedIndex(secret1, stagingJobId)).toBe(7); + // With stagedChangeSetId, should get the staged finalized index + expect(await taggingStore.getLastFinalizedIndex(secret1, stagedChangeSetId)).toBe(7); }); it('discardStaged removes staged data without affecting persistent storage', async () => { { const txHash1 = TxHash.random(); const txHash2 = TxHash.random(); - const commitJobId: string = 'commit-job'; - await taggingStore.storePendingIndexes([range(secret1, 2)], txHash1, commitJobId); - await taggingStore.storePendingIndexes([range(secret1, 3)], txHash2, commitJobId); - await taggingStore.finalizePendingIndexes([txHash1], commitJobId); - await taggingStore.commit(commitJobId); + const commitChangeSetId: ChangeSetId = 'commit-change-set'; + await taggingStore.storePendingIndexes([range(secret1, 2)], txHash1, commitChangeSetId); + await taggingStore.storePendingIndexes([range(secret1, 3)], txHash2, commitChangeSetId); + await taggingStore.finalizePendingIndexes([txHash1], commitChangeSetId); + await taggingStore.commitStaged(commitChangeSetId); } - const stagingJobId: string = 'staging-job'; + const stagedChangeSetId: ChangeSetId = 'staged'; { const txHash3 = TxHash.random(); - await taggingStore.storePendingIndexes([range(secret1, 7)], txHash3, stagingJobId); - await taggingStore.finalizePendingIndexes([txHash3], stagingJobId); - await taggingStore.discardStaged(stagingJobId); + await taggingStore.storePendingIndexes([range(secret1, 7)], txHash3, stagedChangeSetId); + await taggingStore.finalizePendingIndexes([txHash3], stagedChangeSetId); + await taggingStore.discardStaged(stagedChangeSetId); } // Should still get the committed finalized index - expect(await taggingStore.getLastUsedIndex(secret1, 'no-data-job')).toBe(3); - expect(await taggingStore.getLastFinalizedIndex(secret1, 'no-data-job')).toBe(2); + expect(await taggingStore.getLastUsedIndex(secret1, 'no-data-change-set')).toBe(3); + expect(await taggingStore.getLastFinalizedIndex(secret1, 'no-data-change-set')).toBe(2); - // With stagingJobId should fall back to committed since staging was discarded - expect(await taggingStore.getLastUsedIndex(secret1, stagingJobId)).toBe(3); - expect(await taggingStore.getLastFinalizedIndex(secret1, stagingJobId)).toBe(2); + // With stagedChangeSetId should fall back to committed since change set was discarded + expect(await taggingStore.getLastUsedIndex(secret1, stagedChangeSetId)).toBe(3); + expect(await taggingStore.getLastFinalizedIndex(secret1, stagedChangeSetId)).toBe(2); }); }); @@ -846,9 +847,9 @@ describe('SenderTaggingStore', () => { secret: AppTaggingSecret, startIndex: number, endIndex: number, - jobId: string, + changeSetId: ChangeSetId, ): Promise { - const pendingTxs = await taggingStore.getPendingTxs(secret, startIndex, endIndex, jobId); + const pendingTxs = await taggingStore.getPendingTxs(secret, startIndex, endIndex, changeSetId); return pendingTxs.map(pendingTx => TxHash.fromString(pendingTx.txHash)); } }); diff --git a/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.ts b/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.ts index 0bf416569c18..7d88b4c84e45 100644 --- a/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.ts +++ b/yarn-project/pxe/src/storage/tagging_store/sender_tagging_store.ts @@ -3,8 +3,8 @@ import type { AztecAsyncKVStore, AztecAsyncMap } from '@aztec/kv-store'; import { AppTaggingSecret, SiloedTag, type TaggingIndexRange } from '@aztec/stdlib/logs'; import { TxEffect, TxHash } from '@aztec/stdlib/tx'; -import type { StagedStore } from '../../job_coordinator/job_coordinator.js'; import { UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN, unfinalizedTaggingIndexesWindowEnd } from '../../tagging/constants.js'; +import type { ChangeSetId, StagedStore } from '../staged_write_coordinator.js'; /** A tx still awaiting finalization, and the highest tagging index it used for one secret. */ export type PendingTx = { txHash: string; highestIndex: number }; @@ -33,8 +33,8 @@ export class SenderTaggingStore implements StagedStore { // directional app tagging secret => { lowestIndex, highestIndex, txHash }[] #pendingIndexes: AztecAsyncMap; - // jobId => directional app tagging secret => { lowestIndex, highestIndex, txHash }[] - #pendingIndexesForJob: Map>; + // changeSetId => directional app tagging secret => { lowestIndex, highestIndex, txHash }[] + #pendingIndexesForChangeSet: Map>; // Stores the last (highest) finalized index for each directional app tagging secret. We care only about the last // index because unlike the pending indexes, it will never happen that a finalized index would be removed and hence @@ -43,8 +43,8 @@ export class SenderTaggingStore implements StagedStore { // directional app tagging secret => highest finalized index #lastFinalizedIndexes: AztecAsyncMap; - // jobId => directional app tagging secret => highest finalized index - #lastFinalizedIndexesForJob: Map>; + // changeSetId => directional app tagging secret => highest finalized index + #lastFinalizedIndexesForChangeSet: Map>; constructor(store: AztecAsyncKVStore) { this.#store = store; @@ -52,61 +52,62 @@ export class SenderTaggingStore implements StagedStore { this.#pendingIndexes = this.#store.openMap('pending_indexes'); this.#lastFinalizedIndexes = this.#store.openMap('last_finalized_indexes'); - this.#pendingIndexesForJob = new Map(); - this.#lastFinalizedIndexesForJob = new Map(); + this.#pendingIndexesForChangeSet = new Map(); + this.#lastFinalizedIndexesForChangeSet = new Map(); } - #getPendingIndexesForJob(jobId: string): Map { - let pendingIndexesForJob = this.#pendingIndexesForJob.get(jobId); - if (!pendingIndexesForJob) { - pendingIndexesForJob = new Map(); - this.#pendingIndexesForJob.set(jobId, pendingIndexesForJob); + #getPendingIndexesForChangeSet(changeSetId: ChangeSetId): Map { + let pendingIndexesForChangeSet = this.#pendingIndexesForChangeSet.get(changeSetId); + if (!pendingIndexesForChangeSet) { + pendingIndexesForChangeSet = new Map(); + this.#pendingIndexesForChangeSet.set(changeSetId, pendingIndexesForChangeSet); } - return pendingIndexesForJob; + return pendingIndexesForChangeSet; } - #getLastFinalizedIndexesForJob(jobId: string): Map { - let jobStagedLastFinalizedIndexes = this.#lastFinalizedIndexesForJob.get(jobId); - if (!jobStagedLastFinalizedIndexes) { - jobStagedLastFinalizedIndexes = new Map(); - this.#lastFinalizedIndexesForJob.set(jobId, jobStagedLastFinalizedIndexes); + #getLastFinalizedIndexesForChangeSet(changeSetId: ChangeSetId): Map { + let stagedLastFinalizedIndexes = this.#lastFinalizedIndexesForChangeSet.get(changeSetId); + if (!stagedLastFinalizedIndexes) { + stagedLastFinalizedIndexes = new Map(); + this.#lastFinalizedIndexesForChangeSet.set(changeSetId, stagedLastFinalizedIndexes); } - return jobStagedLastFinalizedIndexes; + return stagedLastFinalizedIndexes; } - async #readPendingIndexes(jobId: string, secret: string): Promise { + async #readPendingIndexes(changeSetId: ChangeSetId, secret: string): Promise { // Always issue DB read to keep IndexedDB transaction alive (they auto-commit when a new micro-task starts and there // are no pending read requests). The staged value still takes precedence if it exists. const dbValue = await this.#pendingIndexes.getAsync(secret); - const staged = this.#getPendingIndexesForJob(jobId).get(secret); + const staged = this.#getPendingIndexesForChangeSet(changeSetId).get(secret); return staged !== undefined ? staged : (dbValue ?? []); } - #writePendingIndexes(jobId: string, secret: string, pendingIndexes: PendingIndexesEntry[]) { - this.#getPendingIndexesForJob(jobId).set(secret, pendingIndexes); + #writePendingIndexes(changeSetId: ChangeSetId, secret: string, pendingIndexes: PendingIndexesEntry[]) { + this.#getPendingIndexesForChangeSet(changeSetId).set(secret, pendingIndexes); } - async #readLastFinalizedIndex(jobId: string, secret: string): Promise { + async #readLastFinalizedIndex(changeSetId: ChangeSetId, secret: string): Promise { // Always issue DB read to keep IndexedDB transaction alive (they auto-commit when a new micro-task starts and there // are no pending read requests). The staged value still takes precedence if it exists. const dbValue = await this.#lastFinalizedIndexes.getAsync(secret); - const staged = this.#getLastFinalizedIndexesForJob(jobId).get(secret); + const staged = this.#getLastFinalizedIndexesForChangeSet(changeSetId).get(secret); return staged ?? dbValue; } - #writeLastFinalizedIndex(jobId: string, secret: string, lastFinalizedIndex: number) { - this.#getLastFinalizedIndexesForJob(jobId).set(secret, lastFinalizedIndex); + #writeLastFinalizedIndex(changeSetId: ChangeSetId, secret: string, lastFinalizedIndex: number) { + this.#getLastFinalizedIndexesForChangeSet(changeSetId).set(secret, lastFinalizedIndex); } /** - * Writes all job-specific in-memory data to persistent storage. + * Writes all change set-specific in-memory data to persistent storage. * - * @remark This method must run in a DB transaction context. It's designed to be called from JobCoordinator#commitJob. + * @remark This method must run in a DB transaction context. It's designed to be called from + * {@link StagedWriteCoordinator.commit}. */ - async commit(jobId: string): Promise { - const pendingIndexesForJob = this.#pendingIndexesForJob.get(jobId); - if (pendingIndexesForJob) { - for (const [secret, pendingIndexes] of pendingIndexesForJob.entries()) { + async commitStaged(changeSetId: ChangeSetId): Promise { + const pendingIndexesForChangeSet = this.#pendingIndexesForChangeSet.get(changeSetId); + if (pendingIndexesForChangeSet) { + for (const [secret, pendingIndexes] of pendingIndexesForChangeSet.entries()) { if (pendingIndexes.length === 0) { await this.#pendingIndexes.delete(secret); } else { @@ -115,19 +116,19 @@ export class SenderTaggingStore implements StagedStore { } } - const lastFinalizedIndexesForJob = this.#lastFinalizedIndexesForJob.get(jobId); - if (lastFinalizedIndexesForJob) { - for (const [secret, lastFinalizedIndex] of lastFinalizedIndexesForJob.entries()) { + const lastFinalizedIndexesForChangeSet = this.#lastFinalizedIndexesForChangeSet.get(changeSetId); + if (lastFinalizedIndexesForChangeSet) { + for (const [secret, lastFinalizedIndex] of lastFinalizedIndexesForChangeSet.entries()) { await this.#lastFinalizedIndexes.set(secret, lastFinalizedIndex); } } - return this.discardStaged(jobId); + return this.discardStaged(changeSetId); } - discardStaged(jobId: string): Promise { - this.#pendingIndexesForJob.delete(jobId); - this.#lastFinalizedIndexesForJob.delete(jobId); + discardStaged(changeSetId: ChangeSetId): Promise { + this.#pendingIndexesForChangeSet.delete(changeSetId); + this.#lastFinalizedIndexesForChangeSet.delete(changeSetId); return Promise.resolve(); } @@ -140,13 +141,14 @@ export class SenderTaggingStore implements StagedStore { * @param ranges - The tagging index ranges containing the directional app tagging secrets and the index ranges that are * to be stored in the db. * @param txHash - The tx in which the tagging indexes were used in private logs. - * @param jobId - job context for staged writes to this store. See `JobCoordinator` for more details. + * @param changeSetId - change set to stage this store's writes under. See {@link StagedWriteCoordinator} for more + * details. * @throws If the highestIndex is further than window length from the highest finalized index for the same secret. * @throws If the lowestIndex is lower than or equal to the last finalized index for the same secret. * @throws If a different range already exists for the same (secret, txHash) pair. */ - storePendingIndexes(ranges: TaggingIndexRange[], txHash: TxHash, jobId: string): Promise { - return this.#storePendingIndexes(ranges, txHash, jobId, false); + storePendingIndexes(ranges: TaggingIndexRange[], txHash: TxHash, changeSetId: ChangeSetId): Promise { + return this.#storePendingIndexes(ranges, txHash, changeSetId, false); } /** @@ -159,18 +161,19 @@ export class SenderTaggingStore implements StagedStore { * @param ranges - The tagging index ranges containing the directional app tagging secrets and the index ranges that are * to be stored in the db. * @param txHash - The tx in which the tagging indexes were used in private logs. - * @param jobId - job context for staged writes to this store. See `JobCoordinator` for more details. + * @param changeSetId - change set to stage this store's writes under. See {@link StagedWriteCoordinator} for more + * details. * @throws If the highestIndex is further than window length from the highest finalized index for the same secret. * @throws If the lowestIndex is lower than or equal to the last finalized index for the same secret. */ - mergePendingIndexes(ranges: TaggingIndexRange[], txHash: TxHash, jobId: string): Promise { - return this.#storePendingIndexes(ranges, txHash, jobId, true); + mergePendingIndexes(ranges: TaggingIndexRange[], txHash: TxHash, changeSetId: ChangeSetId): Promise { + return this.#storePendingIndexes(ranges, txHash, changeSetId, true); } #storePendingIndexes( ranges: TaggingIndexRange[], txHash: TxHash, - jobId: string, + changeSetId: ChangeSetId, mergeExisting: boolean, ): Promise { if (ranges.length === 0) { @@ -184,8 +187,8 @@ export class SenderTaggingStore implements StagedStore { const rangeReadPromises = ranges.map(range => ({ range, secretStr: range.extendedSecret.toString(), - pending: this.#readPendingIndexes(jobId, range.extendedSecret.toString()), - finalized: this.#readLastFinalizedIndex(jobId, range.extendedSecret.toString()), + pending: this.#readPendingIndexes(changeSetId, range.extendedSecret.toString()), + finalized: this.#readLastFinalizedIndex(changeSetId, range.extendedSecret.toString()), })); // Await all reads together @@ -248,7 +251,7 @@ export class SenderTaggingStore implements StagedStore { // mergeExisting): duplicate evidence, nothing to write. if (updatedPending) { - this.#writePendingIndexes(jobId, secretStr, updatedPending); + this.#writePendingIndexes(changeSetId, secretStr, updatedPending); } } }); @@ -263,9 +266,14 @@ export class SenderTaggingStore implements StagedStore { * @param startIndex - The lower bound of the index range (inclusive). * @param endIndex - The upper bound of the index range (exclusive). */ - getPendingTxs(secret: AppTaggingSecret, startIndex: number, endIndex: number, jobId: string): Promise { + getPendingTxs( + secret: AppTaggingSecret, + startIndex: number, + endIndex: number, + changeSetId: ChangeSetId, + ): Promise { return this.#store.transactionAsync(async () => { - const existing = await this.#readPendingIndexes(jobId, secret.toString()); + const existing = await this.#readPendingIndexes(changeSetId, secret.toString()); return existing .filter(entry => entry.highestIndex >= startIndex && entry.highestIndex < endIndex) .map(entry => ({ txHash: entry.txHash, highestIndex: entry.highestIndex })); @@ -277,8 +285,8 @@ export class SenderTaggingStore implements StagedStore { * @param secret - The secret to get the last finalized index for. * @returns The last (highest) finalized index for the given secret. */ - getLastFinalizedIndex(secret: AppTaggingSecret, jobId: string): Promise { - return this.#store.transactionAsync(() => this.#readLastFinalizedIndex(jobId, secret.toString())); + getLastFinalizedIndex(secret: AppTaggingSecret, changeSetId: ChangeSetId): Promise { + return this.#store.transactionAsync(() => this.#readLastFinalizedIndex(changeSetId, secret.toString())); } /** @@ -287,12 +295,12 @@ export class SenderTaggingStore implements StagedStore { * @param secret - The directional app tagging secret to query the last used index for. * @returns The last used index. */ - getLastUsedIndex(secret: AppTaggingSecret, jobId: string): Promise { + getLastUsedIndex(secret: AppTaggingSecret, changeSetId: ChangeSetId): Promise { const secretStr = secret.toString(); return this.#store.transactionAsync(async () => { - const pendingPromise = this.#readPendingIndexes(jobId, secretStr); - const finalizedPromise = this.#readLastFinalizedIndex(jobId, secretStr); + const pendingPromise = this.#readPendingIndexes(changeSetId, secretStr); + const finalizedPromise = this.#readLastFinalizedIndex(changeSetId, secretStr); const [pendingEntries, lastFinalized] = await allToCompletion([pendingPromise, finalizedPromise]); @@ -309,7 +317,7 @@ export class SenderTaggingStore implements StagedStore { /** * Drops all pending indexes corresponding to the given transaction hashes. */ - dropPendingIndexes(txHashes: TxHash[], jobId: string): Promise { + dropPendingIndexes(txHashes: TxHash[], changeSetId: ChangeSetId): Promise { if (txHashes.length === 0) { return Promise.resolve(); } @@ -321,13 +329,16 @@ export class SenderTaggingStore implements StagedStore { const secretReadPromises: Map> = new Map(); for await (const secret of this.#pendingIndexes.keysAsync()) { - secretReadPromises.set(secret, this.#readPendingIndexes(jobId, secret)); + secretReadPromises.set(secret, this.#readPendingIndexes(changeSetId, secret)); } // Add staged-only secrets (sync, no DB) - for (const secret of this.#getPendingIndexesForJob(jobId).keys()) { + for (const secret of this.#getPendingIndexesForChangeSet(changeSetId).keys()) { if (!secretReadPromises.has(secret)) { - secretReadPromises.set(secret, Promise.resolve(this.#getPendingIndexesForJob(jobId).get(secret) ?? [])); + secretReadPromises.set( + secret, + Promise.resolve(this.#getPendingIndexesForChangeSet(changeSetId).get(secret) ?? []), + ); } } @@ -343,10 +354,10 @@ export class SenderTaggingStore implements StagedStore { if (pendingData && pendingData.length > 0) { const filtered = pendingData.filter(item => !txHashStrings.has(item.txHash)); if (filtered.length === 0) { - this.#writePendingIndexes(jobId, secret, []); + this.#writePendingIndexes(changeSetId, secret, []); } else if (filtered.length !== pendingData.length) { // Some items were filtered out, so update the pending data - this.#writePendingIndexes(jobId, secret, filtered); + this.#writePendingIndexes(changeSetId, secret, filtered); } // else: No items were filtered out (txHashes not found for this secret) --> no-op } @@ -356,7 +367,7 @@ export class SenderTaggingStore implements StagedStore { /** Prefetches all pending and finalized index data for every secret (from both DB and staged writes). */ #getSecretsWithPendingData( - jobId: string, + changeSetId: ChangeSetId, ): Promise<{ secret: string; pendingData: PendingIndexesEntry[]; lastFinalized: number | undefined }[]> { return this.#store.transactionAsync(async () => { // Prefetch all data, start reads during iteration to keep IndexedDB transaction alive @@ -367,17 +378,17 @@ export class SenderTaggingStore implements StagedStore { for await (const secret of this.#pendingIndexes.keysAsync()) { secretDataPromises.set(secret, { - pending: this.#readPendingIndexes(jobId, secret), - finalized: this.#readLastFinalizedIndex(jobId, secret), + pending: this.#readPendingIndexes(changeSetId, secret), + finalized: this.#readLastFinalizedIndex(changeSetId, secret), }); } // Add staged-only secrets (sync, no DB) - for (const secret of this.#getPendingIndexesForJob(jobId).keys()) { + for (const secret of this.#getPendingIndexesForChangeSet(changeSetId).keys()) { if (!secretDataPromises.has(secret)) { secretDataPromises.set(secret, { - pending: Promise.resolve(this.#getPendingIndexesForJob(jobId).get(secret) ?? []), - finalized: Promise.resolve(this.#getLastFinalizedIndexesForJob(jobId).get(secret)), + pending: Promise.resolve(this.#getPendingIndexesForChangeSet(changeSetId).get(secret) ?? []), + finalized: Promise.resolve(this.#getLastFinalizedIndexesForChangeSet(changeSetId).get(secret)), }); } } @@ -401,8 +412,8 @@ export class SenderTaggingStore implements StagedStore { * indexes. Applies to every secret the txs used, so the caller must hold tx-level evidence that the whole tx * finalized. Callers holding evidence about a single secret must use {@link finalizePendingIndexesOfSecret} instead. */ - finalizePendingIndexes(txHashes: TxHash[], jobId: string): Promise { - return this.#finalizePendingIndexes(txHashes, jobId); + finalizePendingIndexes(txHashes: TxHash[], changeSetId: ChangeSetId): Promise { + return this.#finalizePendingIndexes(txHashes, changeSetId); } /** @@ -412,17 +423,21 @@ export class SenderTaggingStore implements StagedStore { * have all of one secret's tags onchain and none of another's, and the second secret's indexes must not be recorded * as finalized when they never reached the chain. */ - finalizePendingIndexesOfSecret(secret: AppTaggingSecret, txHashes: TxHash[], jobId: string): Promise { - return this.#finalizePendingIndexes(txHashes, jobId, secret.toString()); + finalizePendingIndexesOfSecret( + secret: AppTaggingSecret, + txHashes: TxHash[], + changeSetId: ChangeSetId, + ): Promise { + return this.#finalizePendingIndexes(txHashes, changeSetId, secret.toString()); } - async #finalizePendingIndexes(txHashes: TxHash[], jobId: string, onlySecret?: string): Promise { + async #finalizePendingIndexes(txHashes: TxHash[], changeSetId: ChangeSetId, onlySecret?: string): Promise { if (txHashes.length === 0) { return; } const txHashStrings = new Set(txHashes.map(tx => tx.toString())); - const secretsWithData = (await this.#getSecretsWithPendingData(jobId)).filter( + const secretsWithData = (await this.#getSecretsWithPendingData(changeSetId)).filter( ({ secret }) => onlySecret === undefined || secret === onlySecret, ); @@ -466,10 +481,10 @@ export class SenderTaggingStore implements StagedStore { // Write final state if changed if (currentFinalized !== lastFinalized) { - this.#writeLastFinalizedIndex(jobId, secret, currentFinalized!); + this.#writeLastFinalizedIndex(changeSetId, secret, currentFinalized!); } if (currentPending !== pendingData) { - this.#writePendingIndexes(jobId, secret, currentPending); + this.#writePendingIndexes(changeSetId, secret, currentPending); } } } @@ -480,15 +495,16 @@ export class SenderTaggingStore implements StagedStore { * TxEffect's private logs (i.e., which ones made it onchain). Those that survived are finalized; those that * didn't are dropped. * @param txEffect - The tx effect of the partially reverted transaction. - * @param jobId - job context for staged writes to this store. See `JobCoordinator` for more details. + * @param changeSetId - change set to stage this store's writes under. See {@link StagedWriteCoordinator} for more + * details. */ - async finalizePendingIndexesOfAPartiallyRevertedTx(txEffect: TxEffect, jobId: string): Promise { + async finalizePendingIndexesOfAPartiallyRevertedTx(txEffect: TxEffect, changeSetId: ChangeSetId): Promise { const txHashStr = txEffect.txHash.toString(); // Build a set of all siloed tag values that made it onchain (first field of each private log). const onChainTags = new Set(txEffect.privateLogs.map(log => log.fields[0].toString())); - const secretsWithData = await this.#getSecretsWithPendingData(jobId); + const secretsWithData = await this.#getSecretsWithPendingData(changeSetId); for (const { secret, pendingData, lastFinalized } of secretsWithData) { const matchingEntries = pendingData.filter(item => item.txHash === txHashStr); @@ -522,13 +538,13 @@ export class SenderTaggingStore implements StagedStore { if (highestSurvivingIndex !== undefined) { const newFinalized = Math.max(lastFinalized ?? 0, highestSurvivingIndex); - this.#writeLastFinalizedIndex(jobId, secret, newFinalized); + this.#writeLastFinalizedIndex(changeSetId, secret, newFinalized); // Prune pending indexes that are now <= the finalized index. currentPending = currentPending.filter(item => item.highestIndex > newFinalized); } - this.#writePendingIndexes(jobId, secret, currentPending); + this.#writePendingIndexes(changeSetId, secret, currentPending); } } } diff --git a/yarn-project/pxe/src/tagging/persist_sender_tagging_index_ranges.ts b/yarn-project/pxe/src/tagging/persist_sender_tagging_index_ranges.ts index 08c0ceca21d2..d9bd84c9f3ec 100644 --- a/yarn-project/pxe/src/tagging/persist_sender_tagging_index_ranges.ts +++ b/yarn-project/pxe/src/tagging/persist_sender_tagging_index_ranges.ts @@ -3,6 +3,7 @@ import type { PrivateKernelTailCircuitPublicInputs } from '@aztec/stdlib/kernel' import type { TaggingIndexRange } from '@aztec/stdlib/logs'; import type { TxHash } from '@aztec/stdlib/tx'; +import type { ChangeSetId } from '../storage/staged_write_coordinator.js'; import type { SenderTaggingStore } from '../storage/tagging_store/sender_tagging_store.js'; import { reconcileTaggingIndexRangesAgainstSurvivingTags } from './reconcile_tagging_index_ranges.js'; @@ -25,7 +26,8 @@ import { reconcileTaggingIndexRangesAgainstSurvivingTags } from './reconcile_tag * @param publicInputs - The final kernel public inputs, used to determine which private logs survived squashing. * @param getTxHash - Lazy accessor for the tx hash. Called only when there is something to persist, since computing * the tx hash is expensive. - * @param jobId - Job context for staged writes to the store. See `JobCoordinator` for more details. + * @param changeSetId - Change set context for staged writes to the store. See {@link StagedWriteCoordinator} for more + * details. * @param log - Logger. */ export async function persistSenderTaggingIndexRangesForTx( @@ -33,7 +35,7 @@ export async function persistSenderTaggingIndexRangesForTx( recordedRanges: TaggingIndexRange[], publicInputs: PrivateKernelTailCircuitPublicInputs, getTxHash: () => Promise, - jobId: string, + changeSetId: ChangeSetId, log: Logger, ): Promise { if (recordedRanges.length === 0) { @@ -52,6 +54,6 @@ export async function persistSenderTaggingIndexRangesForTx( } const txHash = await getTxHash(); - await store.storePendingIndexes(reconciledRanges, txHash, jobId); + await store.storePendingIndexes(reconciledRanges, txHash, changeSetId); log.debug(`Stored used tagging index ranges as sender for the tx`, { recordedRanges, reconciledRanges }); } diff --git a/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.bench.test.ts b/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.bench.test.ts index c05df1d749ab..b1dfc34b61a4 100644 --- a/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.bench.test.ts +++ b/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.bench.test.ts @@ -57,7 +57,7 @@ const ANCHOR_BLOCK_NUMBER = BlockNumber(100); const CURRENT_TIMESTAMP = BigInt(Math.floor(Date.now() / 1000)); const ANCHOR_BLOCK_HEADER = BlockHeader.random({ blockNumber: ANCHOR_BLOCK_NUMBER, timestamp: CURRENT_TIMESTAMP }); const AGED_TIMESTAMP = CURRENT_TIMESTAMP - BigInt(MAX_TX_LIFETIME) - 1000n; -const JOB_ID = 'bench-job'; +const CHANGE_SET_ID = 'bench-change-set'; // Every scenario starts warm: index 0 is already persisted, so the scan resumes at index 1 rather than cold-starting. const PRIOR_FINALIZED_INDEX = 0; @@ -170,9 +170,9 @@ describeBench('syncTaggedPrivateLogs constrained-sync bench', () => { // per-secret writes are independent, so run them concurrently. await Promise.all( secrets.map(async secret => { - await taggingStore.updateHighestFinalizedIndex(secret, PRIOR_FINALIZED_INDEX, JOB_ID); + await taggingStore.updateHighestFinalizedIndex(secret, PRIOR_FINALIZED_INDEX, CHANGE_SET_ID); if (kind === AppTaggingSecretKind.UNCONSTRAINED) { - await taggingStore.updateHighestAgedIndex(secret, PRIOR_FINALIZED_INDEX, JOB_ID); + await taggingStore.updateHighestAgedIndex(secret, PRIOR_FINALIZED_INDEX, CHANGE_SET_ID); } }), ); @@ -202,7 +202,7 @@ describeBench('syncTaggedPrivateLogs constrained-sync bench', () => { taggingStore, ANCHOR_BLOCK_HEADER, FINALIZED_BLOCK_NUMBER, - JOB_ID, + CHANGE_SET_ID, ); const calls = aztecNode.getPrivateLogsByTags.mock.calls; diff --git a/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.test.ts b/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.test.ts index 8844effbf322..4baa9ae86221 100644 --- a/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.test.ts +++ b/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.test.ts @@ -27,7 +27,7 @@ import { computeSiloedTagForIndex, extractTags } from '../testing/tag_query_test const FAR_FUTURE_BLOCK_NUMBER = BlockNumber(100); const CURRENT_TIMESTAMP = BigInt(Math.floor(Date.now() / 1000)); const ANCHOR_BLOCK_HEADER = BlockHeader.random({ blockNumber: FAR_FUTURE_BLOCK_NUMBER, timestamp: CURRENT_TIMESTAMP }); -const JOB_ID = 'test-job'; +const CHANGE_SET_ID = 'test-change-set'; const FINALIZED_BLOCK_NUMBER = BlockNumber(10); // Old enough that the log is past MAX_TX_LIFETIME and may advance the aged index. const AGED_TIMESTAMP = CURRENT_TIMESTAMP - BigInt(MAX_TX_LIFETIME) - 1000n; @@ -83,7 +83,7 @@ describe('syncTaggedPrivateLogs', () => { finalizedBlockNumber = FINALIZED_BLOCK_NUMBER, header = ANCHOR_BLOCK_HEADER, ) { - return syncTaggedPrivateLogs(secrets, aztecNode, taggingStore, header, finalizedBlockNumber, JOB_ID); + return syncTaggedPrivateLogs(secrets, aztecNode, taggingStore, header, finalizedBlockNumber, CHANGE_SET_ID); } /** The tags queried by the `callIndex`-th RPC call. */ @@ -159,13 +159,13 @@ describe('syncTaggedPrivateLogs', () => { const logs = await sync(secrets); expect(logs).toHaveLength(2); - expect(await taggingStore.getHighestAgedIndex(secrets[0], JOB_ID)).toBe(log1Index); - expect(await taggingStore.getHighestFinalizedIndex(secrets[0], JOB_ID)).toBe(log1Index); - expect(await taggingStore.getHighestAgedIndex(secrets[1], JOB_ID)).toBe(log2Index); - expect(await taggingStore.getHighestFinalizedIndex(secrets[1], JOB_ID)).toBe(log2Index); + expect(await taggingStore.getHighestAgedIndex(secrets[0], CHANGE_SET_ID)).toBe(log1Index); + expect(await taggingStore.getHighestFinalizedIndex(secrets[0], CHANGE_SET_ID)).toBe(log1Index); + expect(await taggingStore.getHighestAgedIndex(secrets[1], CHANGE_SET_ID)).toBe(log2Index); + expect(await taggingStore.getHighestFinalizedIndex(secrets[1], CHANGE_SET_ID)).toBe(log2Index); // secrets[2] found nothing, so its store must be untouched - expect(await taggingStore.getHighestAgedIndex(secrets[2], JOB_ID)).toBeUndefined(); - expect(await taggingStore.getHighestFinalizedIndex(secrets[2], JOB_ID)).toBeUndefined(); + expect(await taggingStore.getHighestAgedIndex(secrets[2], CHANGE_SET_ID)).toBeUndefined(); + expect(await taggingStore.getHighestFinalizedIndex(secrets[2], CHANGE_SET_ID)).toBeUndefined(); }); it('does not advance aged index for recent logs', async () => { @@ -182,8 +182,8 @@ describe('syncTaggedPrivateLogs', () => { // The recent log is still returned to the caller: recency only gates the aged index, not delivery. expect(logs).toHaveLength(1); - expect(await taggingStore.getHighestFinalizedIndex(secret, JOB_ID)).toBe(logIndex); - expect(await taggingStore.getHighestAgedIndex(secret, JOB_ID)).toBeUndefined(); + expect(await taggingStore.getHighestFinalizedIndex(secret, CHANGE_SET_ID)).toBe(logIndex); + expect(await taggingStore.getHighestAgedIndex(secret, CHANGE_SET_ID)).toBeUndefined(); }); it('updates store correctly when multiple iterations are needed', async () => { @@ -199,8 +199,8 @@ describe('syncTaggedPrivateLogs', () => { const logs = await sync([secret]); expect(logs).toHaveLength(2); - expect(await taggingStore.getHighestAgedIndex(secret, JOB_ID)).toBe(newWindowIndex); - expect(await taggingStore.getHighestFinalizedIndex(secret, JOB_ID)).toBe(newWindowIndex); + expect(await taggingStore.getHighestAgedIndex(secret, CHANGE_SET_ID)).toBe(newWindowIndex); + expect(await taggingStore.getHighestFinalizedIndex(secret, CHANGE_SET_ID)).toBe(newWindowIndex); }); it('respects pre-existing store indexes', async () => { @@ -208,8 +208,8 @@ describe('syncTaggedPrivateLogs', () => { const existingAgedIndex = 5; const existingFinalizedIndex = 8; - await taggingStore.updateHighestAgedIndex(secret, existingAgedIndex, JOB_ID); - await taggingStore.updateHighestFinalizedIndex(secret, existingFinalizedIndex, JOB_ID); + await taggingStore.updateHighestAgedIndex(secret, existingAgedIndex, CHANGE_SET_ID); + await taggingStore.updateHighestFinalizedIndex(secret, existingFinalizedIndex, CHANGE_SET_ID); mockNodeWithLogs([]); await sync([secret]); @@ -240,8 +240,8 @@ describe('syncTaggedPrivateLogs', () => { const logs = await sync([secret]); expect(logs).toHaveLength(3); - expect(await taggingStore.getHighestFinalizedIndex(secret, JOB_ID)).toBe(2); - expect(await taggingStore.getHighestAgedIndex(secret, JOB_ID)).toBeUndefined(); + expect(await taggingStore.getHighestFinalizedIndex(secret, CHANGE_SET_ID)).toBe(2); + expect(await taggingStore.getHighestAgedIndex(secret, CHANGE_SET_ID)).toBeUndefined(); }); it('advances the finalized index only through the finalized prefix', async () => { @@ -259,7 +259,7 @@ describe('syncTaggedPrivateLogs', () => { // The unfinalized logs (4, 5) are returned to the caller, but the finalized index only advances to the finalized // prefix (3): probe advancement is decoupled from the finalized index. expect(logs).toHaveLength(6); - expect(await taggingStore.getHighestFinalizedIndex(secret, JOB_ID)).toBe(3); + expect(await taggingStore.getHighestFinalizedIndex(secret, CHANGE_SET_ID)).toBe(3); }); it('advances the probe past an unfinalized-only first probe', async () => { @@ -275,7 +275,7 @@ describe('syncTaggedPrivateLogs', () => { expect(logs).toHaveLength(2); // Nothing finalized, so the finalized index must not advance. - expect(await taggingStore.getHighestFinalizedIndex(secret, JOB_ID)).toBeUndefined(); + expect(await taggingStore.getHighestFinalizedIndex(secret, CHANGE_SET_ID)).toBeUndefined(); // Round 1 probes [0, 1] and advances on the unfinalized hits; round 2 probes [2..5] and stops at the gap (2). expect(callSizes()).toEqual([2, 4]); @@ -287,7 +287,7 @@ describe('syncTaggedPrivateLogs', () => { expect(secondSyncLogs).toHaveLength(2); expect(calledTags()).toEqual(await computeSiloedTags(secret, [0, 1])); // The repeat sync saw the same unfinalized-only hits, so the finalized index must still not advance. - expect(await taggingStore.getHighestFinalizedIndex(secret, JOB_ID)).toBeUndefined(); + expect(await taggingStore.getHighestFinalizedIndex(secret, CHANGE_SET_ID)).toBeUndefined(); }); // Pins the probe schedule: the probe doubles each round (2, 4, 8, ...) until the first miss, so K-deep @@ -297,13 +297,13 @@ describe('syncTaggedPrivateLogs', () => { const secret = await randomAppTaggingSecret(AppTaggingSecretKind.CONSTRAINED); // Recipient already synced index 0; three new finalized logs sit at indexes 1..3. - await taggingStore.updateHighestFinalizedIndex(secret, 0, JOB_ID); + await taggingStore.updateHighestFinalizedIndex(secret, 0, CHANGE_SET_ID); mockNodeWithLogs(await computeSiloedTagRange(secret, 3, 1)); const logs = await sync([secret]); expect(logs).toHaveLength(3); - expect(await taggingStore.getHighestFinalizedIndex(secret, JOB_ID)).toBe(3); + expect(await taggingStore.getHighestFinalizedIndex(secret, CHANGE_SET_ID)).toBe(3); // Probe windows double each round: [1,2], then [3,4,5,6] where index 4 is the terminating miss. expect(aztecNode.getPrivateLogsByTags).toHaveBeenCalledTimes(2); @@ -318,14 +318,14 @@ describe('syncTaggedPrivateLogs', () => { // Recipient already synced index 0; a deep run of finalized logs sits past multiple capped probe windows. const newLogs = UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN * 3; - await taggingStore.updateHighestFinalizedIndex(secret, 0, JOB_ID); + await taggingStore.updateHighestFinalizedIndex(secret, 0, CHANGE_SET_ID); mockNodeWithLogs(await computeSiloedTagRange(secret, newLogs, 1)); const logs = await sync([secret]); expect(logs).toHaveLength(newLogs); // The capped catch-up still drains the run fully. - expect(await taggingStore.getHighestFinalizedIndex(secret, JOB_ID)).toBe(newLogs); + expect(await taggingStore.getHighestFinalizedIndex(secret, CHANGE_SET_ID)).toBe(newLogs); // Fixed golden probe sizes for the WINDOW_LEN*3 (=252) run: the probe doubles (2, 4, 8, 16, 32, 64) until the // next step would exceed the window, then saturates at the cap (WINDOW_LEN = 84) for the last two rounds. The 84s @@ -377,7 +377,7 @@ describe('syncTaggedPrivateLogs', () => { // First sync catches up the whole run; the finalized index lands on the last index and the probe saturated the // cap along the way. await sync([secret]); - expect(await taggingStore.getHighestFinalizedIndex(secret, JOB_ID)).toBe(totalLogs - 1); + expect(await taggingStore.getHighestFinalizedIndex(secret, CHANGE_SET_ID)).toBe(totalLogs - 1); expect(Math.max(...callSizes())).toBe(UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN); // Drop the catch-up's recorded calls (mockClear keeps the implementation; mockReset would not) so the next @@ -397,7 +397,7 @@ describe('syncTaggedPrivateLogs', () => { it('steady state probes only the initial probe length in a single round', async () => { const secret = await randomAppTaggingSecret(AppTaggingSecretKind.CONSTRAINED); const finalizedIndex = 8; - await taggingStore.updateHighestFinalizedIndex(secret, finalizedIndex, JOB_ID); + await taggingStore.updateHighestFinalizedIndex(secret, finalizedIndex, CHANGE_SET_ID); mockNodeWithLogs([]); await sync([secret]); @@ -430,13 +430,13 @@ describe('syncTaggedPrivateLogs', () => { const secret = await randomAppTaggingSecret(AppTaggingSecretKind.CONSTRAINED); // Recipient already synced index 0; K new contiguous finalized logs sit at indexes 1..K. - await taggingStore.updateHighestFinalizedIndex(secret, 0, JOB_ID); + await taggingStore.updateHighestFinalizedIndex(secret, 0, CHANGE_SET_ID); mockNodeWithLogs(await computeSiloedTagRange(secret, newLogs, 1)); const logs = await sync([secret]); expect(logs).toHaveLength(newLogs); - expect(await taggingStore.getHighestFinalizedIndex(secret, JOB_ID)).toBe(newLogs); + expect(await taggingStore.getHighestFinalizedIndex(secret, CHANGE_SET_ID)).toBe(newLogs); expect(aztecNode.getPrivateLogsByTags.mock.calls).toHaveLength(expectedRoundTrips); }); @@ -450,9 +450,9 @@ describe('syncTaggedPrivateLogs', () => { // The straggler is at index 0 with 3 new contiguous logs at indexes 1..3. const idleFinalizedIndex = 5; for (const secret of idleSecrets) { - await taggingStore.updateHighestFinalizedIndex(secret, idleFinalizedIndex, JOB_ID); + await taggingStore.updateHighestFinalizedIndex(secret, idleFinalizedIndex, CHANGE_SET_ID); } - await taggingStore.updateHighestFinalizedIndex(straggler, 0, JOB_ID); + await taggingStore.updateHighestFinalizedIndex(straggler, 0, CHANGE_SET_ID); mockNodeWithLogs(await computeSiloedTags(straggler, [1, 2, 3])); const logs = await sync([...idleSecrets, straggler]); @@ -460,12 +460,12 @@ describe('syncTaggedPrivateLogs', () => { // Round 1: 4 idle probes + straggler[1,2]. Round 2 is straggler-only: [3..6] (terminating miss at 4). expect(callSizes()).toEqual([10, 4]); - expect(await taggingStore.getHighestFinalizedIndex(straggler, JOB_ID)).toBe(3); + expect(await taggingStore.getHighestFinalizedIndex(straggler, CHANGE_SET_ID)).toBe(3); // Dropping out also means no writes: the caught-up secrets' finalized indexes are untouched by the // straggler-driven rounds. for (const secret of idleSecrets) { - expect(await taggingStore.getHighestFinalizedIndex(secret, JOB_ID)).toBe(idleFinalizedIndex); + expect(await taggingStore.getHighestFinalizedIndex(secret, CHANGE_SET_ID)).toBe(idleFinalizedIndex); } }); }); @@ -485,7 +485,7 @@ describe('syncTaggedPrivateLogs', () => { // The whole run is returned and the finalized index lands on the last index, even though the run is longer than // a single window. expect(logs).toHaveLength(totalLogs); - expect(await taggingStore.getHighestFinalizedIndex(secret, JOB_ID)).toBe(totalLogs - 1); + expect(await taggingStore.getHighestFinalizedIndex(secret, CHANGE_SET_ID)).toBe(totalLogs - 1); // Fixed golden probe sizes for the cold-start WINDOW_LEN+2 run: pure doubling with no cap saturation, since the // run drains inside the 64-tag round. Depends on INITIAL_CONSTRAINED_PROBE_LEN and, via the run length, on @@ -509,8 +509,8 @@ describe('syncTaggedPrivateLogs', () => { // Every log is returned and both stored indexes land on the last index. The aged index advances since the logs // are old enough, unlike a constrained secret, which never tracks an aged index. expect(logs).toHaveLength(totalLogs); - expect(await taggingStore.getHighestFinalizedIndex(secret, JOB_ID)).toBe(totalLogs - 1); - expect(await taggingStore.getHighestAgedIndex(secret, JOB_ID)).toBe(totalLogs - 1); + expect(await taggingStore.getHighestFinalizedIndex(secret, CHANGE_SET_ID)).toBe(totalLogs - 1); + expect(await taggingStore.getHighestAgedIndex(secret, CHANGE_SET_ID)).toBe(totalLogs - 1); // The first round spans the full cold-start window (WINDOW_LEN, the same bound the sender store permits fresh // pending indexes under). Because every index hit, the next round re-anchors to another full WINDOW_LEN window @@ -535,9 +535,9 @@ describe('syncTaggedPrivateLogs', () => { const logs = await sync([constrainedSecret, unconstrainedSecret]); expect(logs).toHaveLength(4); - expect(await taggingStore.getHighestFinalizedIndex(constrainedSecret, JOB_ID)).toBe(1); - expect(await taggingStore.getHighestAgedIndex(constrainedSecret, JOB_ID)).toBeUndefined(); - expect(await taggingStore.getHighestFinalizedIndex(unconstrainedSecret, JOB_ID)).toBe(5); + expect(await taggingStore.getHighestFinalizedIndex(constrainedSecret, CHANGE_SET_ID)).toBe(1); + expect(await taggingStore.getHighestAgedIndex(constrainedSecret, CHANGE_SET_ID)).toBeUndefined(); + expect(await taggingStore.getHighestFinalizedIndex(unconstrainedSecret, CHANGE_SET_ID)).toBe(5); // Both kinds share one batched query rather than one query per kind. const firstCallTags = calledTags(); diff --git a/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.ts b/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.ts index d00a56a389ea..3fccc4e9c0f4 100644 --- a/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.ts +++ b/yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.ts @@ -6,6 +6,7 @@ import type { AppTaggingSecret, LogResult } from '@aztec/stdlib/logs'; import { AppTaggingSecretKind, SiloedTag } from '@aztec/stdlib/logs'; import type { BlockHeader } from '@aztec/stdlib/tx'; +import type { ChangeSetId } from '../../storage/staged_write_coordinator.js'; import type { RecipientTaggingStore } from '../../storage/tagging_store/recipient_tagging_store.js'; import { INITIAL_CONSTRAINED_PROBE_LEN, @@ -88,7 +89,7 @@ export async function syncTaggedPrivateLogs( taggingStore: RecipientTaggingStore, anchorBlockHeader: BlockHeader, finalizedBlockNumber: BlockNumber, - jobId: string, + changeSetId: ChangeSetId, ): Promise { if (secrets.length === 0) { return []; @@ -98,7 +99,7 @@ export async function syncTaggedPrivateLogs( const currentTimestamp = anchorBlockHeader.globalVariables.timestamp; // Read stored indexes from the db and compute the initial [start, end) range for each secret - let pending = await getIndexRangesForSecrets(secrets, taggingStore, jobId); + let pending = await getIndexRangesForSecrets(secrets, taggingStore, changeSetId); const allLogs: LogResult[] = []; while (pending.length > 0) { @@ -124,7 +125,7 @@ export async function syncTaggedPrivateLogs( taggingStore, currentTimestamp, finalizedBlockNumber, - jobId, + changeSetId, ) : await processUnconstrainedResults( pendingSecret, @@ -132,7 +133,7 @@ export async function syncTaggedPrivateLogs( taggingStore, currentTimestamp, finalizedBlockNumber, - jobId, + changeSetId, ); }), ); @@ -147,11 +148,11 @@ export async function syncTaggedPrivateLogs( function getIndexRangesForSecrets( secrets: AppTaggingSecret[], taggingStore: RecipientTaggingStore, - jobId: string, + changeSetId: ChangeSetId, ): Promise { return allToCompletion( secrets.map(async (secret): Promise => { - const currentHighestFinalizedIndex = await taggingStore.getHighestFinalizedIndex(secret, jobId); + const currentHighestFinalizedIndex = await taggingStore.getHighestFinalizedIndex(secret, changeSetId); const boundEnd = unfinalizedTaggingIndexesWindowEnd(currentHighestFinalizedIndex); if (secret.kind === AppTaggingSecretKind.CONSTRAINED) { @@ -170,7 +171,7 @@ function getIndexRangesForSecrets( } // Unconstrained secrets can have gaps, so they scan the whole window starting past the highest aged index. - const highestAgedIndex = await taggingStore.getHighestAgedIndex(secret, jobId); + const highestAgedIndex = await taggingStore.getHighestAgedIndex(secret, changeSetId); const start = highestAgedIndex === undefined ? 0 : highestAgedIndex + 1; return { kind: AppTaggingSecretKind.UNCONSTRAINED, secret, start, end: boundEnd }; }), @@ -231,7 +232,7 @@ async function processConstrainedResults( taggingStore: RecipientTaggingStore, currentTimestamp: bigint, finalizedBlockNumber: BlockNumber, - jobId: string, + changeSetId: ChangeSetId, ): Promise { // Find where the contiguous run of indexes ends; all logs in the batch fall within this prefix. const indexesWithLogs = new Set(logsWithIndexes.map(l => l.taggingIndex)); @@ -245,7 +246,7 @@ async function processConstrainedResults( // This lets the next sync round skip already-finalized indexes. const { highestFinalizedIndex } = findHighestIndexes(logsWithIndexes, currentTimestamp, finalizedBlockNumber); if (highestFinalizedIndex !== undefined) { - await taggingStore.updateHighestFinalizedIndex(pending.secret, highestFinalizedIndex, jobId); + await taggingStore.updateHighestFinalizedIndex(pending.secret, highestFinalizedIndex, changeSetId); } // Advancing the probe is decoupled from persisting the finalized index: keep scanning as long as the probe was @@ -288,7 +289,7 @@ async function processUnconstrainedResults( taggingStore: RecipientTaggingStore, currentTimestamp: bigint, finalizedBlockNumber: BlockNumber, - jobId: string, + changeSetId: ChangeSetId, ): Promise { const { highestAgedIndex, highestFinalizedIndex } = findHighestIndexes( logsWithIndexes, @@ -298,7 +299,7 @@ async function processUnconstrainedResults( // Store updates in data provider and update local variables if (highestAgedIndex !== undefined) { - await taggingStore.updateHighestAgedIndex(pending.secret, highestAgedIndex, jobId); + await taggingStore.updateHighestAgedIndex(pending.secret, highestAgedIndex, changeSetId); } if (highestFinalizedIndex === undefined) { @@ -313,7 +314,7 @@ async function processUnconstrainedResults( ); } - await taggingStore.updateHighestFinalizedIndex(pending.secret, highestFinalizedIndex, jobId); + await taggingStore.updateHighestFinalizedIndex(pending.secret, highestFinalizedIndex, changeSetId); // For the next iteration we want to look only at indexes for which we have not yet fetched logs while // ensuring that we do not look further than WINDOW_LEN ahead of the highest finalized index. diff --git a/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.ts b/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.ts index 3e4536ac7495..fe068fe04092 100644 --- a/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.ts +++ b/yarn-project/pxe/src/tagging/sender_sync/sync_sender_tagging_indexes.ts @@ -2,6 +2,7 @@ import type { BlockNumber } from '@aztec/foundation/branded-types'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import type { AppTaggingSecret } from '@aztec/stdlib/logs'; +import type { ChangeSetId } from '../../storage/staged_write_coordinator.js'; import type { SenderTaggingStore } from '../../storage/tagging_store/sender_tagging_store.js'; import { unfinalizedTaggingIndexesWindowEnd } from '../constants.js'; import type { LogQueryAnchor } from '../get_all_logs_by_tags.js'; @@ -26,7 +27,7 @@ export async function syncSenderTaggingIndexes( taggingStore: SenderTaggingStore, finalizedBlockNumber: BlockNumber, anchor: LogQueryAnchor, - jobId: string, + changeSetId: ChangeSetId, ): Promise { // # Explanation of how syncing works // @@ -47,7 +48,7 @@ export async function syncSenderTaggingIndexes( // derived from the log block numbers and the locally-synced finalized tip, without a per-tx node call. See // `resolvePendingTxs` for the txs that the logs cannot settle and what they cost. - const finalizedIndex = await taggingStore.getLastFinalizedIndex(secret, jobId); + const finalizedIndex = await taggingStore.getLastFinalizedIndex(secret, changeSetId); let start = finalizedIndex === undefined ? 0 : finalizedIndex + 1; // The loop only extends the window when the finalized index moves, @@ -58,10 +59,18 @@ export async function syncSenderTaggingIndexes( let newFinalizedIndex = undefined; while (true) { - const txsInLogs = await loadAndStoreNewTaggingIndexes(secret, start, end, aztecNode, taggingStore, anchor, jobId); + const txsInLogs = await loadAndStoreNewTaggingIndexes( + secret, + start, + end, + aztecNode, + taggingStore, + anchor, + changeSetId, + ); // Pending txs for this window: prior syncs, txs this PXE itself sent, and what the logs just stored. - const pendingTxs = await taggingStore.getPendingTxs(secret, start, end, jobId); + const pendingTxs = await taggingStore.getPendingTxs(secret, start, end, changeSetId); if (pendingTxs.length === 0) { break; } @@ -69,17 +78,17 @@ export async function syncSenderTaggingIndexes( const { txHashesFinalizedFromLogs, txHashesFinalizedFromReceipts, txHashesDropped, receiptsWithExecutionReverted } = await resolvePendingTxs(pendingTxs, txsInLogs, finalizedBlockNumber, aztecNode); - await taggingStore.dropPendingIndexes(txHashesDropped, jobId); + await taggingStore.dropPendingIndexes(txHashesDropped, changeSetId); // The logs are queried per secret, so they only evidence this one's indexes. A receipt covers the whole tx. - await taggingStore.finalizePendingIndexesOfSecret(secret, txHashesFinalizedFromLogs, jobId); - await taggingStore.finalizePendingIndexes(txHashesFinalizedFromReceipts, jobId); + await taggingStore.finalizePendingIndexesOfSecret(secret, txHashesFinalizedFromLogs, changeSetId); + await taggingStore.finalizePendingIndexes(txHashesFinalizedFromReceipts, changeSetId); for (const receipt of receiptsWithExecutionReverted) { - await taggingStore.finalizePendingIndexesOfAPartiallyRevertedTx(receipt.txEffect, jobId); + await taggingStore.finalizePendingIndexesOfAPartiallyRevertedTx(receipt.txEffect, changeSetId); } // We check if the finalized index has been updated. - newFinalizedIndex = await taggingStore.getLastFinalizedIndex(secret, jobId); + newFinalizedIndex = await taggingStore.getLastFinalizedIndex(secret, changeSetId); if (previousFinalizedIndex !== newFinalizedIndex) { // A new finalized index was found, so we'll run the loop again. For example: // - Previous finalized index: 10 diff --git a/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.ts b/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.ts index ac4da2b53f4d..8e10a8cb4a61 100644 --- a/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.ts +++ b/yarn-project/pxe/src/tagging/sender_sync/utils/load_and_store_new_tagging_indexes.ts @@ -4,6 +4,7 @@ import type { AztecNode } from '@aztec/stdlib/interfaces/server'; import { type AppTaggingSecret, type LogResult, SiloedTag } from '@aztec/stdlib/logs'; import { TxHash } from '@aztec/stdlib/tx'; +import type { ChangeSetId } from '../../../storage/staged_write_coordinator.js'; import type { SenderTaggingStore } from '../../../storage/tagging_store/sender_tagging_store.js'; import { type LogQueryAnchor, getAllPrivateLogsByTags } from '../../get_all_logs_by_tags.js'; @@ -19,8 +20,8 @@ import { type LogQueryAnchor, getAllPrivateLogsByTags } from '../../get_all_logs * @param aztecNode - The Aztec node instance to query for logs. * @param taggingStore - The data provider to store pending indexes. * @param anchor - Block the log query is anchored to. - * @param jobId - Job identifier, used to keep writes in-memory until they can be persisted in a data integrity - * preserving way. + * @param changeSetId - Change set identifier, used to keep writes in-memory until they can be persisted in a data + * integrity preserving way. */ export async function loadAndStoreNewTaggingIndexes( extendedSecret: AppTaggingSecret, @@ -29,7 +30,7 @@ export async function loadAndStoreNewTaggingIndexes( aztecNode: AztecNode, taggingStore: SenderTaggingStore, anchor: LogQueryAnchor, - jobId: string, + changeSetId: ChangeSetId, ): Promise> { // We compute the tags for the current window of indexes const siloedTagsForWindow = await allToCompletion( @@ -55,7 +56,7 @@ export async function loadAndStoreNewTaggingIndexes( const ranges = [ { extendedSecret, lowestIndex: Math.min(...taggingIndexes), highestIndex: Math.max(...taggingIndexes) }, ]; - await taggingStore.mergePendingIndexes(ranges, txHash, jobId); + await taggingStore.mergePendingIndexes(ranges, txHash, changeSetId); } return txsInLogs; diff --git a/yarn-project/pxe/src/test_utils.ts b/yarn-project/pxe/src/test_utils.ts new file mode 100644 index 000000000000..8929c5e19c42 --- /dev/null +++ b/yarn-project/pxe/src/test_utils.ts @@ -0,0 +1,2 @@ +/** Yields to the macrotask queue, draining all pending microtasks in between. */ +export const tick = () => new Promise(resolve => setImmediate(resolve)); diff --git a/yarn-project/txe/src/oracle/interfaces.ts b/yarn-project/txe/src/oracle/interfaces.ts index 90e2881308aa..b133045986bd 100644 --- a/yarn-project/txe/src/oracle/interfaces.ts +++ b/yarn-project/txe/src/oracle/interfaces.ts @@ -3,7 +3,7 @@ import { TxHash } from '@aztec/aztec.js/tx'; import { BlockNumber } from '@aztec/foundation/branded-types'; import type { Fr } from '@aztec/foundation/curves/bn254'; import type { EthAddress } from '@aztec/foundation/eth-address'; -import type { TaggingSecretStrategy } from '@aztec/pxe/server'; +import type { ChangeSetId, TaggingSecretStrategy } from '@aztec/pxe/server'; import type { Option } from '@aztec/pxe/simulator'; import type { EventSelector, FunctionSelector } from '@aztec/stdlib/abi'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; @@ -101,7 +101,7 @@ export interface ITxeExecutionOracle { argsHash: Fr, isStaticCall: boolean, additionalScopes: AztecAddress[], - jobId: string, + changeSetId: ChangeSetId, authorizedUtilityCallTargets: AztecAddress[], gasSettings: GasSettings, ): Promise<{ returnValues: Fr[]; offchainEffects: Fr[][] }>; @@ -110,7 +110,7 @@ export interface ITxeExecutionOracle { targetContractAddress: AztecAddress, functionSelector: FunctionSelector, args: Fr[], - jobId: string, + changeSetId: ChangeSetId, authorizedUtilityCallTargets: AztecAddress[], ): Promise; publicCallNewFlow( @@ -122,5 +122,9 @@ export interface ITxeExecutionOracle { ): Promise; // TODO(F-335): Drop this from here as it's not a real oracle handler - it's only called from // RPCTranslator::txeGetPrivateEvents and never from Noir. - syncContractNonOracleMethod(contractAddress: AztecAddress, scope: AztecAddress, jobId: string): Promise; + syncContractNonOracleMethod( + contractAddress: AztecAddress, + scope: AztecAddress, + changeSetId: ChangeSetId, + ): Promise; } diff --git a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts index e9bb50513281..9807fc7f9f14 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts @@ -15,6 +15,7 @@ import { AnchoredContractData, CapsuleService, CapsuleStore, + type ChangeSetId, type ContractStore, type ExecutionHooks, FactService, @@ -212,7 +213,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl }; } - async syncContractNonOracleMethod(contractAddress: AztecAddress, scope: AztecAddress, jobId: string) { + async syncContractNonOracleMethod(contractAddress: AztecAddress, scope: AztecAddress, changeSetId: ChangeSetId) { if (contractAddress.equals(DEFAULT_ADDRESS)) { this.logger.debug(`Skipping sync in getPrivateEvents because the events correspond to the default address.`); return; @@ -223,10 +224,10 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl contract: contractAddress, functionToInvokeAfterSync: null, utilityExecutor: async (call, execScopes) => { - await this.executeUtilityCall(call, { scopes: execScopes, jobId }); + await this.executeUtilityCall(call, { scopes: execScopes, changeSetId }); }, anchorBlockHeader, - jobId, + changeSetId, scopes: [scope], triggeredBy: undefined, }); @@ -431,7 +432,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl argsHash: Fr = Fr.zero(), isStaticCall: boolean = false, additionalScopes: AztecAddress[] = [], - jobId: string, + changeSetId: ChangeSetId, authorizedUtilityCallTargets: AztecAddress[], gasSettings: GasSettings, ) { @@ -458,7 +459,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl // Sync notes before executing private function to discover notes from previous transactions const utilityExecutor = async (call: FunctionCall, execScopes: AztecAddress[]) => { - await this.executeUtilityCall(call, { scopes: execScopes, jobId }); + await this.executeUtilityCall(call, { scopes: execScopes, changeSetId }); }; await this.stateMachine.contractSyncService.ensureContractSynced({ @@ -466,7 +467,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl functionToInvokeAfterSync: functionSelector, utilityExecutor, anchorBlockHeader: blockHeader, - jobId, + changeSetId, scopes, triggeredBy: undefined, }); @@ -515,7 +516,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl factService: new FactService(this.factStore, scopes), privateEventStore: this.privateEventStore, contractSyncService: this.stateMachine.contractSyncService, - jobId, + changeSetId, totalPublicCalldataCount: 0, sideEffectCounter: minRevertibleSideEffectCounter, scopes, @@ -846,7 +847,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl targetContractAddress: AztecAddress, functionSelector: FunctionSelector, args: Fr[], - jobId: string, + changeSetId: ChangeSetId, authorizedUtilityCallTargets: AztecAddress[], ) { const blockHeader = await this.stateMachine.anchorBlockStore.getBlockHeader(); @@ -866,10 +867,10 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl contract: targetContractAddress, functionToInvokeAfterSync: functionSelector, utilityExecutor: async (call, execScopes) => { - await this.executeUtilityCall(call, { scopes: execScopes, jobId }); + await this.executeUtilityCall(call, { scopes: execScopes, changeSetId }); }, anchorBlockHeader: blockHeader, - jobId, + changeSetId, scopes: await this.keyStore.getAccounts(), triggeredBy: undefined, }); @@ -888,7 +889,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl return this.executeUtilityCall(call, { from, scopes: await this.keyStore.getAccounts(), - jobId, + changeSetId, authorizedUtilityCallTargets, }); } @@ -898,9 +899,14 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl { from = AztecAddress.NULL_MSG_SENDER, scopes, - jobId, + changeSetId, authorizedUtilityCallTargets = [], - }: { from?: AztecAddress; scopes: AztecAddress[]; jobId: string; authorizedUtilityCallTargets?: AztecAddress[] }, + }: { + from?: AztecAddress; + scopes: AztecAddress[]; + changeSetId: ChangeSetId; + authorizedUtilityCallTargets?: AztecAddress[]; + }, ): Promise { const anchorBlockHeader = await this.stateMachine.anchorBlockStore.getBlockHeader(); const anchoredContractData = new AnchoredContractData( @@ -925,7 +931,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl try { const simulator = new WASMSimulator(); const utilityExecutor = async (syncCall: FunctionCall, execScopes: AztecAddress[]) => { - await this.executeUtilityCall(syncCall, { scopes: execScopes, jobId, authorizedUtilityCallTargets }); + await this.executeUtilityCall(syncCall, { scopes: execScopes, changeSetId, authorizedUtilityCallTargets }); }; const oracle = new UtilityExecutionOracle({ callContext: CallContext.from({ @@ -950,7 +956,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl txResolver: this.stateMachine.txResolver, contractSyncService: this.stateMachine.contractSyncService, l2TipsStore: this.stateMachine.l2TipsProvider, - jobId, + changeSetId, scopes, simulator, utilityExecutor, diff --git a/yarn-project/txe/src/txe_session.test.ts b/yarn-project/txe/src/txe_session.test.ts index 318ba2135903..3f492c00ee8c 100644 --- a/yarn-project/txe/src/txe_session.test.ts +++ b/yarn-project/txe/src/txe_session.test.ts @@ -23,8 +23,9 @@ describe('TXESession.processFunction', () => { {} as any, // capsuleStore {} as any, // factStore {} as any, // privateEventStore - {} as any, // jobCoordinator - {} as any, // initialJobId + {} as any, // stagedWriteCoordinator + [], // operationContributors + {} as any, // initialChangeSetId new Fr(1), // chainId new Fr(1), // version 0n, // nextBlockTimestamp diff --git a/yarn-project/txe/src/txe_session.ts b/yarn-project/txe/src/txe_session.ts index 88c552b8bd7f..f308023b89db 100644 --- a/yarn-project/txe/src/txe_session.ts +++ b/yarn-project/txe/src/txe_session.ts @@ -13,15 +13,17 @@ import { ContractStore, FactService, FactStore, - JobCoordinator, NoteService, NoteStore, PrivateEventStore, RecipientTaggingStore, SenderTaggingStore, + StagedWriteCoordinator, TaggingSecretSourcesStore, composeHooks, + runOperation, } from '@aztec/pxe/server'; +import type { ChangeSetId, OperationContributor } from '@aztec/pxe/server'; import { ExecutionNoteCache, ExecutionTaggingIndexCache, @@ -140,7 +142,7 @@ export interface TXESessionStateHandler { /** * Executes a top-level private call: runs the private function, drains its offchain effects into the session buffer, - * commits the job, and (for non-static calls) tags the result with the mined tx hash. + * commits the change set, and (for non-static calls) tags the result with the mined tx hash. */ executePrivateCall( from: Option, @@ -154,7 +156,7 @@ export interface TXESessionStateHandler { gasSettings: GasSettings, ): Promise; - /** Executes a top-level utility function and commits the job. */ + /** Executes a top-level utility function and commits the change set. */ executeUtilityFunction( from: Option, targetContractAddress: AztecAddress, @@ -164,8 +166,8 @@ export interface TXESessionStateHandler { ): Promise; /** - * Executes a top-level public call, commits the job, and (for non-static calls) tags the result with the mined tx - * hash. + * Executes a top-level public call, commits the change set, and (for non-static calls) tags the result with the mined + * tx hash. */ executePublicCall( from: Option, @@ -268,8 +270,9 @@ export class TXESession implements TXESessionStateHandler { private capsuleStore: CapsuleStore, private factStore: FactStore, private privateEventStore: PrivateEventStore, - private jobCoordinator: JobCoordinator, - private currentJobId: string, + private readonly stagedWriteCoordinator: StagedWriteCoordinator, + private readonly operationContributors: OperationContributor[], + private currentChangeSetId: ChangeSetId, private chainId: Fr, private version: Fr, private nextBlockTimestamp: bigint, @@ -326,25 +329,19 @@ export class TXESession implements TXESessionStateHandler { const anchorBlockStore = new AnchorBlockStore(store); const stateMachine = await TXEStateMachine.create(archiver, anchorBlockStore, contractStore, noteStore); - const jobCoordinator = new JobCoordinator(store); - jobCoordinator.registerStores([ - capsuleStore, - factStore, - senderTaggingStore, - recipientTaggingStore, - privateEventStore, - noteStore, - stateMachine.contractSyncService, - ]); + const stagedWriteCoordinator = new StagedWriteCoordinator({ + kvStore: store, + stagedStores: [capsuleStore, factStore, senderTaggingStore, recipientTaggingStore, privateEventStore, noteStore], + }); const nextBlockTimestamp = BigInt(Math.floor(new Date().getTime() / 1000)); const version = new Fr(await stateMachine.node.getVersion()); const chainId = new Fr(await stateMachine.node.getChainId()); - const initialJobId = jobCoordinator.beginJob(); - const logger = createLogger('txe:session'); + const initialChangeSetId = stagedWriteCoordinator.begin(); + const topLevelOracleHandler = new TXEOracleTopLevelContext( stateMachine, contractStore, @@ -387,8 +384,9 @@ export class TXESession implements TXESessionStateHandler { capsuleStore, factStore, privateEventStore, - jobCoordinator, - initialJobId, + stagedWriteCoordinator, + [stateMachine.contractSyncService], // operationContributors + initialChangeSetId, version, chainId, nextBlockTimestamp, @@ -462,11 +460,21 @@ export class TXESession implements TXESessionStateHandler { } } - /** Commits the current job and begins a new one. Returns the new job ID. */ - private async cycleJob(): Promise { - await this.jobCoordinator.commitJob(this.currentJobId); - this.currentJobId = this.jobCoordinator.beginJob(); - return this.currentJobId; + /** Ends the current operation (committing its change set, or discarding it on failure) and begins a new one. */ + private async cycleOperation(): Promise { + const operationArgs = { + stagedWriteCoordinator: this.stagedWriteCoordinator, + contributors: this.operationContributors, + changeSetId: this.currentChangeSetId, + log: this.logger, + }; + try { + // The operation's work already happened through the session's oracles, so there is nothing left to run. + await runOperation(operationArgs, () => Promise.resolve()); + } finally { + this.currentChangeSetId = this.stagedWriteCoordinator.begin(); + } + return this.currentChangeSetId; } private resetLastCall(): void { @@ -544,7 +552,7 @@ export class TXESession implements TXESessionStateHandler { argsHash, isStaticCall, additionalScopes, - this.currentJobId, + this.currentChangeSetId, authorizedUtilityCallTargets, gasSettings, ); @@ -557,7 +565,7 @@ export class TXESession implements TXESessionStateHandler { this.recordOffchainEffect(data); } - await this.cycleJob(); + await this.cycleOperation(); if (isStaticCall) { // Static calls revert their checkpoint and mine no block, so there is no tx hash to tag offchain effects @@ -583,11 +591,11 @@ export class TXESession implements TXESessionStateHandler { targetContractAddress, functionSelector, args, - this.currentJobId, + this.currentChangeSetId, authorizedUtilityCallTargets, ); - await this.cycleJob(); + await this.cycleOperation(); return { result: returnValues }; }); @@ -610,7 +618,7 @@ export class TXESession implements TXESessionStateHandler { gasSettings, ); - await this.cycleJob(); + await this.cycleOperation(); if (isStaticCall) { // See the equivalent branch in `executePrivateCall`. @@ -623,9 +631,9 @@ export class TXESession implements TXESessionStateHandler { async getPrivateEvents(selector: EventSelector, contractAddress: AztecAddress, scope: AztecAddress): Promise { const handler = this.handlerAsTxe(); - await handler.syncContractNonOracleMethod(contractAddress, scope, this.currentJobId); - // Cycle the job to commit the stores after the contract sync. - await this.cycleJob(); + await handler.syncContractNonOracleMethod(contractAddress, scope, this.currentChangeSetId); + // Cycle the change set to commit the stores after the contract sync. + await this.cycleOperation(); return handler.getPrivateEvents(selector, contractAddress, scope); } @@ -673,8 +681,8 @@ export class TXESession implements TXESessionStateHandler { } } - // Commit all staged stores from the job that was just completed, then begin a new job - await this.cycleJob(); + // Commit all staged stores from the change set that was just completed, then begin a new change set + await this.cycleOperation(); this.oracleHandler = new TXEOracleTopLevelContext( this.stateMachine, @@ -719,10 +727,12 @@ export class TXESession implements TXESessionStateHandler { // a single transaction with the effects of what was done in the test. const anchorBlock = await this.stateMachine.node.getBlock(anchorBlockNumber ?? 'latest').then(b => b?.header); - await new NoteService(this.noteStore, this.stateMachine.node, anchorBlock!, this.currentJobId).syncNoteNullifiers( - contractAddress, - await this.keyStore.getAccounts(), - ); + await new NoteService( + this.noteStore, + this.stateMachine.node, + anchorBlock!, + this.currentChangeSetId, + ).syncNoteNullifiers(contractAddress, await this.keyStore.getAccounts()); const latestBlock = await this.stateMachine.node.getBlock('latest').then(b => b?.header); const nextBlockGlobalVariables = makeGlobalVariables(undefined, { @@ -769,7 +779,7 @@ export class TXESession implements TXESessionStateHandler { privateEventStore: this.privateEventStore, contractSyncService: this.stateMachine.contractSyncService, l2TipsStore: this.stateMachine.l2TipsProvider, - jobId: this.currentJobId, + changeSetId: this.currentChangeSetId, scopes: await this.keyStore.getAccounts(), txResolver: this.stateMachine.txResolver, simulator: new WASMSimulator(), @@ -841,7 +851,7 @@ export class TXESession implements TXESessionStateHandler { this.noteStore, this.stateMachine.node, anchorBlockHeader, - this.currentJobId, + this.currentChangeSetId, ).syncNoteNullifiers(contractAddress, await this.keyStore.getAccounts()); this.oracleHandler = new UtilityExecutionOracle({ @@ -872,7 +882,7 @@ export class TXESession implements TXESessionStateHandler { txResolver: this.stateMachine.txResolver, contractSyncService: this.stateMachine.contractSyncService, l2TipsStore: this.stateMachine.l2TipsProvider, - jobId: this.currentJobId, + changeSetId: this.currentChangeSetId, scopes: await this.keyStore.getAccounts(), simulator: new WASMSimulator(), utilityExecutor: this.utilityExecutorForContractSync(anchorBlockHeader), @@ -1002,7 +1012,7 @@ export class TXESession implements TXESessionStateHandler { txResolver: this.stateMachine.txResolver, contractSyncService: this.stateMachine.contractSyncService, l2TipsStore: this.stateMachine.l2TipsProvider, - jobId: this.currentJobId, + changeSetId: this.currentChangeSetId, scopes, simulator, utilityExecutor: this.utilityExecutorForContractSync(anchorBlock), From 7e13074a5b036b85d8ed89f7b632fc5281282863 Mon Sep 17 00:00:00 2001 From: Nicolas Chamo Date: Fri, 21 Aug 2026 10:51:44 -0300 Subject: [PATCH 2/2] refactor(pxe): extract a Rollbackable interface for reorg rollbacks (#40) * refactor(pxe): extract a Rollbackable interface for reorg rollbacks * test(pxe): assert the prune contract against a mock Rollbackable * test(pxe): replay a failed prune on the next sync (cherry picked from commit da18494f46c209d3c624c32f0eebfd32c0c1b677) --- .../block_synchronizer.test.ts | 438 +++++------------- .../block_synchronizer/block_synchronizer.ts | 14 +- yarn-project/pxe/src/pxe.ts | 4 +- .../src/storage/fact_store/fact_store.test.ts | 16 +- .../pxe/src/storage/fact_store/fact_store.ts | 5 +- .../src/storage/note_store/note_store.test.ts | 16 +- .../pxe/src/storage/note_store/note_store.ts | 5 +- .../private_event_store.test.ts | 16 +- .../private_event_store.ts | 5 +- yarn-project/pxe/src/storage/rollbackable.ts | 14 + 10 files changed, 167 insertions(+), 366 deletions(-) create mode 100644 yarn-project/pxe/src/storage/rollbackable.ts diff --git a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts index 08c63c5e74e3..9690b1f378ff 100644 --- a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts +++ b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts @@ -3,7 +3,6 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import type { AztecAsyncKVStore } from '@aztec/kv-store'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { L2TipsKVStore } from '@aztec/kv-store/stores'; -import { EventSelector } from '@aztec/stdlib/abi'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { type BlockData, @@ -18,19 +17,17 @@ import { makeL2CheckpointId, } from '@aztec/stdlib/block'; import type { AztecNode, BlockResponse } from '@aztec/stdlib/interfaces/client'; -import { NoteDao, NoteStatus } from '@aztec/stdlib/note'; -import { TxHash } from '@aztec/stdlib/tx'; +import { NoteDao } from '@aztec/stdlib/note'; +import { jest } from '@jest/globals'; import { type MockProxy, mock } from 'jest-mock-extended'; import type { BlockSynchronizerConfig } from '../config/index.js'; import type { ContractSyncService } from '../contract/contract_sync_service.js'; import { type CachingAztecNode, withCache } from '../node/caching_aztec_node.js'; import { AnchorBlockStore } from '../storage/anchor_block_store/anchor_block_store.js'; -import { FactStore } from '../storage/fact_store/fact_store.js'; -import { FactCollectionKey, FactCollectionTypeKey } from '../storage/fact_store/fact_store_keys.js'; import { NoteStore } from '../storage/note_store/note_store.js'; -import { PrivateEventStore } from '../storage/private_event_store/private_event_store.js'; +import type { Rollbackable } from '../storage/rollbackable.js'; import { BlockSynchronizer } from './block_synchronizer.js'; // `AztecNode.getBlock` is generic over its include-options; `Parameters`/`ReturnType` collapse that @@ -45,8 +42,7 @@ describe('BlockSynchronizer', () => { let tipsStore: L2TipsKVStore; let anchorBlockStore: AnchorBlockStore; let noteStore: NoteStore; - let privateEventStore: PrivateEventStore; - let factStore: FactStore; + let rollbackables: MockProxy[]; let aztecNode: MockProxy; let getBlock: NodeGetBlockMock; let blockStream: MockProxy; @@ -59,14 +55,15 @@ describe('BlockSynchronizer', () => { } }; - const createSynchronizer = (config: Partial = {}) => { + const createSynchronizer = ( + config: Partial = {}, + storesToRollBack: Rollbackable[] = rollbackables, + ) => { return new TestSynchronizer( cachedNode, store, anchorBlockStore, - noteStore, - privateEventStore, - factStore, + storesToRollBack, tipsStore, contractSyncService, config, @@ -99,24 +96,14 @@ describe('BlockSynchronizer', () => { const noteAt = (contract: AztecAddress, block: L2BlockId): Promise => NoteDao.random({ contractAddress: contract, l2BlockNumber: block.number, l2BlockHash: block.hash }); - // Stores one private event anchored to the given block id under the 'event-change-set' (caller commits). - const storeEvent = (contract: AztecAddress, scope: AztecAddress, eventId: Fr, block: L2BlockId) => - privateEventStore.storePrivateEventLog( - EventSelector.random(), - Fr.random(), - [Fr.random()], - eventId, - { - contractAddress: contract, - scope, - txHash: TxHash.random(), - l2BlockNumber: block.number, - l2BlockHash: BlockHash.fromString(block.hash), - txIndexInBlock: 0, - eventIndexInTx: 0, - }, - 'event-change-set', - ); + // A chain-pruned event forking back to `block`, with the checkpointed and proven cursors left at genesis. + const prunedEvent = (block: L2BlockId): L2BlockStreamEvent => { + const genesisTip = { + block: makeL2BlockId(BlockNumber.ZERO, GENESIS_BLOCK_HEADER_HASH.toString()), + checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()), + }; + return { type: 'chain-pruned', block, checkpointed: genesisTip, proven: genesisTip }; + }; beforeEach(async () => { store = await openTmpStore('test'); @@ -126,8 +113,7 @@ describe('BlockSynchronizer', () => { tipsStore = new L2TipsKVStore(store, 'pxe', GENESIS_BLOCK_HEADER_HASH); anchorBlockStore = new AnchorBlockStore(store); noteStore = new NoteStore(store); - privateEventStore = new PrivateEventStore(store); - factStore = new FactStore(store); + rollbackables = [mock(), mock()]; contractSyncService = mock(); cachedNode = withCache(aztecNode); synchronizer = createSynchronizer(); @@ -174,18 +160,7 @@ describe('BlockSynchronizer', () => { const anchorBlock = await L2Block.random(BlockNumber(4)); await anchorBlockStore.setHeader(anchorBlock.header); - await synchronizer.handleBlockStreamEvent({ - type: 'chain-pruned', - block: await blockId(reorgBlock), - checkpointed: { - block: makeL2BlockId(BlockNumber.ZERO, GENESIS_BLOCK_HEADER_HASH.toString()), - checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()), - }, - proven: { - block: makeL2BlockId(BlockNumber.ZERO, GENESIS_BLOCK_HEADER_HASH.toString()), - checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()), - }, - }); + await synchronizer.handleBlockStreamEvent(prunedEvent(await blockId(reorgBlock))); // The anchor block should be updated to the reorg block header. const obtainedHeader = await anchorBlockStore.getBlockHeader(); @@ -237,304 +212,129 @@ describe('BlockSynchronizer', () => { }); }); - describe('delete-on-prune', () => { - it('chain-pruned deletes rows anchored above the fork and keeps rows at or below it', async () => { - const contract = await AztecAddress.random(); - const scope = await AztecAddress.random(); - - // Block 3 is the fork point (a real block the node still serves); 4 and 5 are on the abandoned fork. - const forkBlock = await L2Block.random(BlockNumber(3)); - const block4 = makeL2BlockId(BlockNumber(4), Fr.random().toString()); - const block5 = makeL2BlockId(BlockNumber(5), Fr.random().toString()); - - // Seed a note at each block, anchored to that block's id. - const noteAt3 = await noteAt(contract, await blockId(forkBlock)); - const noteAt4 = await noteAt(contract, block4); - const noteAt5 = await noteAt(contract, block5); - await noteStore.addNotes([noteAt3, noteAt4, noteAt5], scope, 'note-change-set'); - await noteStore.commitStaged('note-change-set'); - - // Seed an event at each block. - const eventIdAt3 = Fr.random(); - const eventIdAt4 = Fr.random(); - const eventIdAt5 = Fr.random(); - await storeEvent(contract, scope, eventIdAt3, await blockId(forkBlock)); - await storeEvent(contract, scope, eventIdAt4, block4); - await storeEvent(contract, scope, eventIdAt5, block5); - await privateEventStore.commitStaged('event-change-set'); - - // Set the anchor to block 5 so the prune guard passes. - const anchorBlock5 = await L2Block.random(BlockNumber(5)); - await anchorBlockStore.setHeader(anchorBlock5.header); - - // The node serves the fork-point block; it becomes the new anchor after the prune. + describe('rollback on prune', () => { + // The anchor must sit above the fork for the prune guard to let the rollback through, and the node must still + // serve the fork point for the prune to find a header for the new anchor. + const stagePruneTo = async (forkBlock: L2Block, anchorBlockNumber: BlockNumber) => { + const anchorBlock = await L2Block.random(anchorBlockNumber); + await anchorBlockStore.setHeader(anchorBlock.header); await serveBlock(forkBlock); + }; - // Prune back to block 3 (orphaning blocks 4 and 5). - await synchronizer.handleBlockStreamEvent({ - type: 'chain-pruned', - block: await blockId(forkBlock), - checkpointed: { - block: makeL2BlockId(BlockNumber.ZERO, GENESIS_BLOCK_HEADER_HASH.toString()), - checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()), - }, - proven: { - block: makeL2BlockId(BlockNumber.ZERO, GENESIS_BLOCK_HEADER_HASH.toString()), - checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()), - }, + it('rolls every registered store back to the fork point, within the anchor update transaction', async () => { + // A depth counter rather than a boolean: reading the anchor below opens its own nested transaction, which would + // clear a boolean flag on the way out and make the enclosing prune transaction invisible. + const realTransactionAsync = store.transactionAsync.bind(store); + let transactionDepth = 0; + jest.spyOn(store, 'transactionAsync').mockImplementation(async callback => { + transactionDepth++; + try { + return await realTransactionAsync(callback); + } finally { + transactionDepth--; + } }); - // Rows at blocks 4 and 5 must be gone. - expect(await noteStore.nullifiersOfNotesAtBlock(4)).toHaveLength(0); - expect(await noteStore.nullifiersOfNotesAtBlock(5)).toHaveLength(0); - expect(await privateEventStore.eventIdsAtBlock(4)).toHaveLength(0); - expect(await privateEventStore.eventIdsAtBlock(5)).toHaveLength(0); + // Each rollback records the block it was handed, whether it ran inside the kv transaction, and the anchor as it + // stood at that moment — still the pre-prune one, since the rollbacks must precede the anchor update. + const rollbacks: { toBlock: number; inTransaction: boolean; anchorBlockNumber: number }[] = []; + for (const rollbackable of rollbackables) { + rollbackable.rollbackToBlock.mockImplementation(async toBlock => { + const anchor = await anchorBlockStore.getBlockHeader(); + rollbacks.push({ toBlock, inTransaction: transactionDepth > 0, anchorBlockNumber: anchor.getBlockNumber() }); + }); + } - // Rows at block 3 (the fork point, not an orphan) must survive. - expect(await noteStore.nullifiersOfNotesAtBlock(3)).toEqual([noteAt3.siloedNullifier.toString()]); - expect(await privateEventStore.eventIdsAtBlock(3)).toEqual([eventIdAt3.toString()]); - }); + const forkBlock = await L2Block.random(BlockNumber(3)); + await stagePruneTo(forkBlock, BlockNumber(5)); - it('chain-finalized does not delete any rows', async () => { - const contract = await AztecAddress.random(); - const scope = await AztecAddress.random(); + await synchronizer.handleBlockStreamEvent(prunedEvent(await blockId(forkBlock))); - // Canonical rows at two heights: one below the finalized block, one at it. - const block8 = makeL2BlockId(BlockNumber(8), Fr.random().toString()); - const block9 = makeL2BlockId(BlockNumber(9), Fr.random().toString()); - const note8 = await noteAt(contract, block8); - const note9 = await noteAt(contract, block9); - await noteStore.addNotes([note8, note9], scope, 'note-change-set'); - await noteStore.commitStaged('note-change-set'); + expect(rollbacks).toEqual([ + { toBlock: 3, inTransaction: true, anchorBlockNumber: 5 }, + { toBlock: 3, inTransaction: true, anchorBlockNumber: 5 }, + ]); + // Once the rollbacks were through, the anchor dropped to the fork point and the tips cursor followed it. + expect((await anchorBlockStore.getBlockHeader()).getBlockNumber()).toBe(3); + expect((await tipsStore.getL2Tips()).proposed.number).toBe(3); + }); - const eventId8 = Fr.random(); - const eventId9 = Fr.random(); - await storeEvent(contract, scope, eventId8, block8); - await storeEvent(contract, scope, eventId9, block9); - await privateEventStore.commitStaged('event-change-set'); + it('does not roll back on chain-finalized', async () => { + // Configured to anchor on the finalized tip, so the event reaches the anchor update rather than being skipped + // by the syncChainTip check before any handling runs. + synchronizer = createSynchronizer({ syncChainTip: 'finalized' }); + const finalizedBlock = await L2Block.random(BlockNumber(9)); + getBlock.mockResolvedValue(await blockResponse(finalizedBlock)); await synchronizer.handleBlockStreamEvent({ type: 'chain-finalized', - block: block9, + block: makeL2BlockId(BlockNumber(9), (await finalizedBlock.hash()).toString()), checkpoint: makeL2CheckpointId(CheckpointNumber(1), Fr.random().toString()), }); - // Finalization is a no-op for storage under delete-on-prune, every row at and below the tip survives. - expect(await noteStore.nullifiersOfNotesAtBlock(8)).toEqual([note8.siloedNullifier.toString()]); - expect(await noteStore.nullifiersOfNotesAtBlock(9)).toEqual([note9.siloedNullifier.toString()]); - expect(await privateEventStore.eventIdsAtBlock(8)).toEqual([eventId8.toString()]); - expect(await privateEventStore.eventIdsAtBlock(9)).toEqual([eventId9.toString()]); - }); - - it('chain-pruned retracts facts at pruned block heights or above, dropping collections left empty', async () => { - const changeSetId = 'fact-change-set'; - - // Block 5 will be the fork point: the prune keeps it and abandons only blocks strictly above it. - const lastSurvivingBlock = await L2Block.random(BlockNumber(5)); - - const contractAddress = await AztecAddress.random(); - const scope = await AztecAddress.random(); - const factCollectionTypeId = Fr.random(); - const typeKey = FactCollectionTypeKey.from({ contractAddress, scope, factCollectionTypeId }); - - // A collection whose only fact is retractable and anchored to the fork point (block 5): the fork point is kept, - // so the fact and its collection must survive. - const survivingCollectionId = Fr.random(); - const survivingCollectionKey = FactCollectionKey.from({ - contractAddress, - scope, - factCollectionTypeId, - factCollectionId: survivingCollectionId, - }); - await factStore.recordFact( - survivingCollectionKey, - Fr.random(), - [Fr.random()], - { blockNumber: lastSurvivingBlock.number, blockHash: (await lastSurvivingBlock.hash()).toFr() }, - changeSetId, - ); - - // A collection whose only fact is retractable and originates just above the fork (block 6): the prune deletes the - // fact, and the now-empty collection disappears entirely. - const retractedCollectionId = Fr.random(); - const retractedCollectionKey = FactCollectionKey.from({ - contractAddress, - scope, - factCollectionTypeId, - factCollectionId: retractedCollectionId, - }); - await factStore.recordFact( - retractedCollectionKey, - Fr.random(), - [Fr.random()], - { blockNumber: lastSurvivingBlock.number + 1, blockHash: Fr.random() }, - changeSetId, - ); - - await store.transactionAsync(() => factStore.commitStaged(changeSetId)); - - // Both collections must be present before the prune. - expect(await factStore.getFactCollectionsByType(typeKey, changeSetId)).toHaveLength(2); - // Release the read change set so the prune's rollback is not blocked by an in-flight change set. - await factStore.discardStaged(changeSetId); - - // Some blocks later... - const anchorBlock10 = await L2Block.random(BlockNumber(10)); - await anchorBlockStore.setHeader(anchorBlock10.header); - - // The node serves the fork-point block (number 5), so it becomes the new anchor after the prune. - await serveBlock(lastSurvivingBlock); - - // Prune back to block 5, dropping block 6 where the retracted fact originates. - await synchronizer.handleBlockStreamEvent({ - type: 'chain-pruned', - block: await blockId(lastSurvivingBlock), - checkpointed: { - block: makeL2BlockId(BlockNumber.ZERO, GENESIS_BLOCK_HEADER_HASH.toString()), - checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()), - }, - proven: { - block: makeL2BlockId(BlockNumber.ZERO, GENESIS_BLOCK_HEADER_HASH.toString()), - checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()), - }, - }); + expect((await anchorBlockStore.getBlockHeader()).getBlockNumber()).toBe(9); - // Only the fork-point collection survives. The one whose sole fact originated above the fork is gone. - const collections = await factStore.getFactCollectionsByType(typeKey, changeSetId); - expect(collections).toHaveLength(1); - expect(collections[0].key.factCollectionId.equals(survivingCollectionId)).toBe(true); - expect(await factStore.getFactCollection(retractedCollectionKey, changeSetId)).toBeUndefined(); - expect((await factStore.getFactCollection(survivingCollectionKey, changeSetId))!.facts).toHaveLength(1); + for (const rollbackable of rollbackables) { + expect(rollbackable.rollbackToBlock).not.toHaveBeenCalled(); + } }); - it('chain-pruned keeps a collection and its facts up to the fork point, deleting only those above it', async () => { - const changeSetId = 'fact-change-set'; + it('undoes a failed prune, leaving the event to be re-emitted and applied on the next sync', async () => { + // The note store rolls back first and succeeds; the store behind it then throws, so the note it deleted is only + // restored if the whole prune shares one transaction. + const failingStore = mock(); + failingStore.rollbackToBlock.mockRejectedValue(new Error('store rollback failed')); + synchronizer = createSynchronizer({}, [noteStore, failingStore]); - // Block 5 is the fork point: the prune keeps it and abandons only blocks strictly above it. - const lastSurvivingBlock = await L2Block.random(BlockNumber(5)); - - const contractAddress = await AztecAddress.random(); + const contract = await AztecAddress.random(); const scope = await AztecAddress.random(); + const forkBlock = await L2Block.random(BlockNumber(3)); + const orphanedNote = await noteAt(contract, makeL2BlockId(BlockNumber(4), Fr.random().toString())); + await noteStore.addNotes([orphanedNote], scope, 'note-change-set'); + await noteStore.commitStaged('note-change-set'); - const factCollectionTypeId = Fr.random(); - const factCollectionId = Fr.random(); - const retractedFactType = Fr.random(); - const forkPointFactType = Fr.random(); - const nonRetractableFactType = Fr.random(); - - const typeKey = FactCollectionTypeKey.from({ contractAddress, scope, factCollectionTypeId }); - const collectionKey = FactCollectionKey.from({ contractAddress, scope, factCollectionTypeId, factCollectionId }); - - // A collection carrying three facts: a non-retractable one, a retractable one anchored to the fork point (block - // 5), and a retractable one originating just above it (block 6). The prune must keep the collection, its - // non-retractable fact, and the fork-point fact, deleting only the orphaned fact. - await factStore.recordFact(collectionKey, nonRetractableFactType, [Fr.random()], undefined, changeSetId); - await factStore.recordFact( - collectionKey, - forkPointFactType, - [], - { blockNumber: lastSurvivingBlock.number, blockHash: (await lastSurvivingBlock.hash()).toFr() }, - changeSetId, - ); - await factStore.recordFact( - collectionKey, - retractedFactType, - [], - { blockNumber: lastSurvivingBlock.number + 1, blockHash: Fr.random() }, - changeSetId, + await stagePruneTo(forkBlock, BlockNumber(5)); + + await expect(synchronizer.handleBlockStreamEvent(prunedEvent(await blockId(forkBlock)))).rejects.toThrow( + 'store rollback failed', ); - await store.transactionAsync(() => factStore.commitStaged(changeSetId)); - // The collection and all three facts must be present before the prune. - expect(await factStore.getFactCollectionsByType(typeKey, changeSetId)).toHaveLength(1); - expect((await factStore.getFactCollection(collectionKey, changeSetId))!.facts).toHaveLength(3); - // Release the read change set so the prune's rollback is not blocked by an in-flight change set. - await factStore.discardStaged(changeSetId); + // Nothing from the failed attempt stuck: the orphaned note is back, the anchor still sits above the fork, and + // the tips cursor never advanced onto the prune target. + expect(await noteStore.nullifiersOfNotesAtBlock(4)).toEqual([orphanedNote.siloedNullifier.toString()]); + expect((await anchorBlockStore.getBlockHeader()).getBlockNumber()).toBe(5); + expect((await tipsStore.getL2Tips()).proposed.number).toBe(0); - // Some blocks later... - const anchorBlock10 = await L2Block.random(BlockNumber(10)); - await anchorBlockStore.setHeader(anchorBlock10.header); + // Because the cursor stayed put, the next sync re-emits the very same prune event. This time the failing store + // recovers (say the node was restarted), so the reorg is processed to completion instead of being lost. + failingStore.rollbackToBlock.mockResolvedValue(undefined); - // The node serves the fork-point block, so it becomes the new anchor after the prune. - await serveBlock(lastSurvivingBlock); + await synchronizer.handleBlockStreamEvent(prunedEvent(await blockId(forkBlock))); - // Prune back to block 5, orphaning block 6 where the retractable fact originates. - await synchronizer.handleBlockStreamEvent({ - type: 'chain-pruned', - block: await blockId(lastSurvivingBlock), - checkpointed: { - block: makeL2BlockId(BlockNumber.ZERO, GENESIS_BLOCK_HEADER_HASH.toString()), - checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()), - }, - proven: { - block: makeL2BlockId(BlockNumber.ZERO, GENESIS_BLOCK_HEADER_HASH.toString()), - checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()), - }, - }); - - // The collection survives, keeping its non-retractable fact and the fork-point fact. Only the fact originating - // above the fork is gone. - const collections = await factStore.getFactCollectionsByType(typeKey, changeSetId); - expect(collections).toHaveLength(1); - expect(collections[0].key.factCollectionId.equals(factCollectionId)).toBe(true); - - const remainingFactTypes = (await factStore.getFactCollection(collectionKey, changeSetId))!.facts.map( - fact => fact.factTypeId, - ); - expect(remainingFactTypes).toHaveLength(2); - expect(remainingFactTypes.some(factType => factType.equals(nonRetractableFactType))).toBe(true); - expect(remainingFactTypes.some(factType => factType.equals(forkPointFactType))).toBe(true); - expect(remainingFactTypes.some(factType => factType.equals(retractedFactType))).toBe(false); + expect(await noteStore.nullifiersOfNotesAtBlock(4)).toHaveLength(0); + expect((await anchorBlockStore.getBlockHeader()).getBlockNumber()).toBe(3); + expect((await tipsStore.getL2Tips()).proposed.number).toBe(3); }); - it('notes below the fork survive and remain queryable after a prune', async () => { + it('deletes rows above the fork when wired to a real store', async () => { + synchronizer = createSynchronizer({}, [noteStore]); + const contract = await AztecAddress.random(); const scope = await AztecAddress.random(); - // Block 1 is the fork point (a real block the node still serves); 2 and 3 are on the abandoned fork. - const forkBlock = await L2Block.random(BlockNumber(1)); - const block2 = makeL2BlockId(BlockNumber(2), Fr.random().toString()); - const block3 = makeL2BlockId(BlockNumber(3), Fr.random().toString()); - - const noteAt1 = await noteAt(contract, await blockId(forkBlock)); - const noteAt2 = await noteAt(contract, block2); - const noteAt3 = await noteAt(contract, block3); - await noteStore.addNotes([noteAt1, noteAt2, noteAt3], scope, 'note-change-set'); + // Block 3 is the fork point (a real block the node still serves); block 4 is on the abandoned fork. + const forkBlock = await L2Block.random(BlockNumber(3)); + const noteAtFork = await noteAt(contract, await blockId(forkBlock)); + const orphanedNote = await noteAt(contract, makeL2BlockId(BlockNumber(4), Fr.random().toString())); + await noteStore.addNotes([noteAtFork, orphanedNote], scope, 'note-change-set'); await noteStore.commitStaged('note-change-set'); - // Anchor at block 3. - const anchorBlock3 = await L2Block.random(BlockNumber(3)); - await anchorBlockStore.setHeader(anchorBlock3.header); - - // The node serves the fork-point block; it becomes the new anchor after the prune. - await serveBlock(forkBlock); - - // Prune back to block 1 (orphaning blocks 2 and 3). - await synchronizer.handleBlockStreamEvent({ - type: 'chain-pruned', - block: await blockId(forkBlock), - checkpointed: { - block: makeL2BlockId(BlockNumber.ZERO, GENESIS_BLOCK_HEADER_HASH.toString()), - checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()), - }, - proven: { - block: makeL2BlockId(BlockNumber.ZERO, GENESIS_BLOCK_HEADER_HASH.toString()), - checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()), - }, - }); + await stagePruneTo(forkBlock, BlockNumber(5)); - // Blocks 2 and 3 deleted. - expect(await noteStore.nullifiersOfNotesAtBlock(2)).toHaveLength(0); - expect(await noteStore.nullifiersOfNotesAtBlock(3)).toHaveLength(0); + await synchronizer.handleBlockStreamEvent(prunedEvent(await blockId(forkBlock))); - // Block 1 note still present and visible via getNotes. - expect(await noteStore.nullifiersOfNotesAtBlock(1)).toEqual([noteAt1.siloedNullifier.toString()]); - const found = await noteStore.getNotes( - { contractAddress: contract, scopes: [scope], status: NoteStatus.ACTIVE }, - 'read-change-set', - ); - expect(found).toHaveLength(1); - expect(found[0].siloedNullifier.equals(noteAt1.siloedNullifier)).toBe(true); + expect(await noteStore.nullifiersOfNotesAtBlock(4)).toHaveLength(0); + expect(await noteStore.nullifiersOfNotesAtBlock(3)).toEqual([noteAtFork.siloedNullifier.toString()]); }); }); @@ -685,22 +485,14 @@ describe('BlockSynchronizer', () => { await anchorBlockStore.setHeader(anchorBlock.header); // Prune to block 3 (above anchor) - should be ignored - await synchronizer.handleBlockStreamEvent({ - type: 'chain-pruned', - block: { number: BlockNumber(3), hash: '0x3' }, - checkpointed: { - block: makeL2BlockId(BlockNumber.ZERO, GENESIS_BLOCK_HEADER_HASH.toString()), - checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()), - }, - proven: { - block: makeL2BlockId(BlockNumber.ZERO, GENESIS_BLOCK_HEADER_HASH.toString()), - checkpoint: makeL2CheckpointId(CheckpointNumber.ZERO, GENESIS_CHECKPOINT_HEADER_HASH.toString()), - }, - }); + await synchronizer.handleBlockStreamEvent(prunedEvent({ number: BlockNumber(3), hash: '0x3' })); - // Anchor should be unchanged + // Anchor should be unchanged, and no store was rolled back const obtainedHeader = await anchorBlockStore.getBlockHeader(); expect(obtainedHeader.equals(anchorBlock.header)).toBe(true); + for (const rollbackable of rollbackables) { + expect(rollbackable.rollbackToBlock).not.toHaveBeenCalled(); + } }); }); @@ -764,9 +556,7 @@ describe('BlockSynchronizer', () => { withCache(aztecNode), store, anchorBlockStore, - noteStore, - privateEventStore, - factStore, + rollbackables, tipsStore, contractSyncService, { syncChainTip: 'proposed' }, diff --git a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts index 96a998f09844..e648cf29c9cb 100644 --- a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts +++ b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts @@ -10,9 +10,7 @@ import type { BlockSynchronizerConfig } from '../config/index.js'; import type { ContractSyncService } from '../contract/contract_sync_service.js'; import type { CachingAztecNode } from '../node/caching_aztec_node.js'; import type { AnchorBlockStore } from '../storage/anchor_block_store/index.js'; -import type { FactStore } from '../storage/fact_store/fact_store.js'; -import type { NoteStore } from '../storage/note_store/index.js'; -import type { PrivateEventStore } from '../storage/private_event_store/private_event_store.js'; +import type { Rollbackable } from '../storage/rollbackable.js'; import { blockStreamSourceFromAztecNode } from './block_stream_source.js'; /** @@ -30,9 +28,7 @@ export class BlockSynchronizer implements L2BlockStreamEventHandler { private readonly node: CachingAztecNode, private readonly store: AztecAsyncKVStore, private readonly anchorBlockStore: AnchorBlockStore, - private readonly noteStore: NoteStore, - private readonly privateEventStore: PrivateEventStore, - private readonly factStore: FactStore, + private readonly rollbackables: Rollbackable[], private readonly l2TipsStore: L2TipsKVStore, private readonly contractSyncService: ContractSyncService, private readonly config: Partial = {}, @@ -138,9 +134,9 @@ export class BlockSynchronizer implements L2BlockStreamEventHandler { // Operations are wrapped in a single transaction to ensure atomicity. await this.store.transactionAsync(async () => { - await this.noteStore.rollback(event.block.number); - await this.privateEventStore.rollback(event.block.number); - await this.factStore.rollback(event.block.number); + for (const rollbackable of this.rollbackables) { + await rollbackable.rollbackToBlock(event.block.number); + } await this.updateAnchorBlockHeader(newAnchorBlockHeader); }); break; diff --git a/yarn-project/pxe/src/pxe.ts b/yarn-project/pxe/src/pxe.ts index 65bce0b92dc1..7dd122acac94 100644 --- a/yarn-project/pxe/src/pxe.ts +++ b/yarn-project/pxe/src/pxe.ts @@ -333,9 +333,7 @@ export class PXE { readCachedNode, store, anchorBlockStore, - noteStore, - privateEventStore, - factStore, + [noteStore, privateEventStore, factStore], l2TipsStore, contractSyncService, config, diff --git a/yarn-project/pxe/src/storage/fact_store/fact_store.test.ts b/yarn-project/pxe/src/storage/fact_store/fact_store.test.ts index ea1c2f306090..dd978234c519 100644 --- a/yarn-project/pxe/src/storage/fact_store/fact_store.test.ts +++ b/yarn-project/pxe/src/storage/fact_store/fact_store.test.ts @@ -302,7 +302,7 @@ describe('FactStore', () => { ); await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - await kv.transactionAsync(() => store.rollback(5)); + await kv.transactionAsync(() => store.rollbackToBlock(5)); const { facts } = (await store.getFactCollection(collectionKey1, CHANGE_SET))!; expect(hexSet(facts.map(f => f.payload[0]))).toEqual(hexSet([nonRetractable])); @@ -318,7 +318,7 @@ describe('FactStore', () => { ); await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - await kv.transactionAsync(() => store.rollback(5)); + await kv.transactionAsync(() => store.rollbackToBlock(5)); expect(await store.getFactCollection(collectionKey1, CHANGE_SET)).toBeUndefined(); expect(await store.getFactCollectionsByType(typeKey, CHANGE_SET)).toHaveLength(0); @@ -342,32 +342,32 @@ describe('FactStore', () => { ); await kv.transactionAsync(() => store.commitStaged(CHANGE_SET)); - await kv.transactionAsync(() => store.rollback(7)); + await kv.transactionAsync(() => store.rollbackToBlock(7)); expect( (await store.getFactCollection(collectionKey1, CHANGE_SET))!.facts.map(f => f.originBlock?.blockNumber), ).toEqual([5]); await store.discardStaged(CHANGE_SET); - await kv.transactionAsync(() => store.rollback(4)); + await kv.transactionAsync(() => store.rollbackToBlock(4)); expect(await store.getFactCollection(collectionKey1, CHANGE_SET)).toBeUndefined(); }); it('rollback throws while a change set has staged writes', async () => { await store.recordFact(collectionKey1, factTypeA, [Fr.random()], undefined, 'uncommitted-change-set'); - await expect(kv.transactionAsync(() => store.rollback(0))).rejects.toThrow( + await expect(kv.transactionAsync(() => store.rollbackToBlock(0))).rejects.toThrow( 'PXE fact store rollback is not allowed while staged writes are pending', ); await store.discardStaged('uncommitted-change-set'); - await expect(kv.transactionAsync(() => store.rollback(0))).resolves.not.toThrow(); + await expect(kv.transactionAsync(() => store.rollbackToBlock(0))).resolves.not.toThrow(); }); it('a change set that has only read still blocks rollback until it is discarded', async () => { await store.getFactCollection(collectionKey1, 'reader-change-set'); - await expect(kv.transactionAsync(() => store.rollback(0))).rejects.toThrow( + await expect(kv.transactionAsync(() => store.rollbackToBlock(0))).rejects.toThrow( 'PXE fact store rollback is not allowed while staged writes are pending', ); await store.discardStaged('reader-change-set'); - await expect(kv.transactionAsync(() => store.rollback(0))).resolves.not.toThrow(); + await expect(kv.transactionAsync(() => store.rollbackToBlock(0))).resolves.not.toThrow(); }); }); diff --git a/yarn-project/pxe/src/storage/fact_store/fact_store.ts b/yarn-project/pxe/src/storage/fact_store/fact_store.ts index 8c441d9e7f9d..5c6665f5c4d3 100644 --- a/yarn-project/pxe/src/storage/fact_store/fact_store.ts +++ b/yarn-project/pxe/src/storage/fact_store/fact_store.ts @@ -4,6 +4,7 @@ import { allToCompletion } from '@aztec/foundation/promise'; import { Semaphore } from '@aztec/foundation/queue'; import type { AztecAsyncKVStore, AztecAsyncMap, AztecAsyncMultiMap } from '@aztec/kv-store'; +import type { Rollbackable } from '../rollbackable.js'; import type { ChangeSetId, StagedStore } from '../staged_write_coordinator.js'; import { FactCollectionKey, type FactCollectionTypeKey, type OriginBlock } from './fact_store_keys.js'; import { type Fact, StoredFact, factKeyStrOf } from './stored_fact.js'; @@ -50,7 +51,7 @@ type StagedOp = { kind: 'recordFact'; fact: StoredFact } | { kind: 'deleteFactCo * * As with most other PXE stores, writes are staged per change set ID and flushed atomically on commit. */ -export class FactStore implements StagedStore { +export class FactStore implements StagedStore, Rollbackable { readonly storeName: string = 'fact'; #store: AztecAsyncKVStore; @@ -197,7 +198,7 @@ export class FactStore implements StagedStore { * back mid-change-set could re-introduce records originating from deleted blocks or change state underneath a change * set's view. */ - async rollback(toBlock: BlockNum): Promise { + async rollbackToBlock(toBlock: BlockNum): Promise { if (this.#opsForChangeSet.size > 0) { throw new Error('PXE fact store rollback is not allowed while staged writes are pending'); } diff --git a/yarn-project/pxe/src/storage/note_store/note_store.test.ts b/yarn-project/pxe/src/storage/note_store/note_store.test.ts index be8709d25df7..5fc4caa51a47 100644 --- a/yarn-project/pxe/src/storage/note_store/note_store.test.ts +++ b/yarn-project/pxe/src/storage/note_store/note_store.test.ts @@ -798,7 +798,7 @@ describe('NoteStore', () => { }); }); -describe('NoteStore.rollback', () => { +describe('NoteStore.rollbackToBlock', () => { const CHANGE_SET = 'note-store-test-change-set'; const scope = AztecAddress.fromBigIntUnsafe(1n); const contract = AztecAddress.fromBigIntUnsafe(100n); @@ -836,7 +836,7 @@ describe('NoteStore.rollback', () => { ); await store.commitStaged(CHANGE_SET); - await kv.transactionAsync(() => store.rollback(9)); + await kv.transactionAsync(() => store.rollbackToBlock(9)); // Only note A survives. expect(await store.nullifiersOfNotesAtBlock(9)).toEqual([noteA.siloedNullifier.toString()]); @@ -864,7 +864,7 @@ describe('NoteStore.rollback', () => { await store.addNotes([noteLow, noteHigh], scope, CHANGE_SET); await store.commitStaged(CHANGE_SET); - await kv.transactionAsync(() => store.rollback(9)); + await kv.transactionAsync(() => store.rollbackToBlock(9)); expect(await store.nullifiersOfNotesAtBlock(10)).toHaveLength(0); expect(await store.nullifiersOfNotesAtBlock(50)).toHaveLength(0); @@ -887,7 +887,7 @@ describe('NoteStore.rollback', () => { ); await store.commitStaged(CHANGE_SET); - await kv.transactionAsync(() => store.rollback(16)); + await kv.transactionAsync(() => store.rollbackToBlock(16)); // The creation row at block 10 is untouched. expect(await store.nullifiersOfNotesAtBlock(10)).toEqual([noteB.siloedNullifier.toString()]); @@ -907,11 +907,11 @@ describe('NoteStore.rollback', () => { await store.addNotes([noteB], scope, CHANGE_SET); await store.commitStaged(CHANGE_SET); - await kv.transactionAsync(() => store.rollback(9)); + await kv.transactionAsync(() => store.rollbackToBlock(9)); expect(await store.nullifiersOfNotesAtBlock(10)).toHaveLength(0); // Second run hits the missing-row guard: no throw, state unchanged. - await kv.transactionAsync(() => store.rollback(9)); + await kv.transactionAsync(() => store.rollbackToBlock(9)); expect(await store.nullifiersOfNotesAtBlock(10)).toHaveLength(0); }); @@ -925,13 +925,13 @@ describe('NoteStore.rollback', () => { }); await store.addNotes([staged], scope, 'uncommitted-change-set'); - await expect(kv.transactionAsync(() => store.rollback(0))).rejects.toThrow( + await expect(kv.transactionAsync(() => store.rollbackToBlock(0))).rejects.toThrow( 'PXE note store rollback is not allowed while staged writes are pending', ); await store.discardStaged('uncommitted-change-set'); - await expect(kv.transactionAsync(() => store.rollback(0))).resolves.not.toThrow(); + await expect(kv.transactionAsync(() => store.rollbackToBlock(0))).resolves.not.toThrow(); }); afterEach(async () => { diff --git a/yarn-project/pxe/src/storage/note_store/note_store.ts b/yarn-project/pxe/src/storage/note_store/note_store.ts index 2d7663b004fb..ca08e85a419f 100644 --- a/yarn-project/pxe/src/storage/note_store/note_store.ts +++ b/yarn-project/pxe/src/storage/note_store/note_store.ts @@ -8,6 +8,7 @@ import type { DataInBlock } from '@aztec/stdlib/block'; import { NoteDao, NoteStatus } from '@aztec/stdlib/note'; import type { NotesFilter } from '../../notes_filter.js'; +import type { Rollbackable } from '../rollbackable.js'; import type { ChangeSetId, StagedStore } from '../staged_write_coordinator.js'; import { StoredNote } from './stored_note.js'; @@ -27,7 +28,7 @@ type StoredNoteBuffer = Buffer; * Reorgs are handled by delete-on-prune: the `chain-pruned` event triggers deletion of every note and nullifier * originating on a reorg'd block. */ -export class NoteStore implements StagedStore { +export class NoteStore implements StagedStore, Rollbackable { readonly storeName: string = 'note'; logger = createLogger('note_store'); @@ -399,7 +400,7 @@ export class NoteStore implements StagedStore { * Throws if any change set has uncommitted staged writes, since rolling back mid-change-set could later re-introduce * notes or nullifier emissions anchored to deleted blocks. */ - public async rollback(toBlock: number): Promise { + public async rollbackToBlock(toBlock: number): Promise { if (this.#notesForChangeSet.size > 0 || this.#nullifierEmissionsForChangeSet.size > 0) { throw new Error('PXE note store rollback is not allowed while staged writes are pending'); } diff --git a/yarn-project/pxe/src/storage/private_event_store/private_event_store.test.ts b/yarn-project/pxe/src/storage/private_event_store/private_event_store.test.ts index e4ca6fc01087..edefb2da5f7f 100644 --- a/yarn-project/pxe/src/storage/private_event_store/private_event_store.test.ts +++ b/yarn-project/pxe/src/storage/private_event_store/private_event_store.test.ts @@ -595,7 +595,7 @@ describe('PrivateEventStore', () => { await storeEventAt(eventAt10, 10, BLOCK_HASH_10); await privateEventStore.commitStaged('test'); - await kvStore.transactionAsync(() => privateEventStore.rollback(9)); + await kvStore.transactionAsync(() => privateEventStore.rollbackToBlock(9)); // Block 9 event survives; block 10 event is gone. expect(await privateEventStore.eventIdsAtBlock(9)).toEqual([eventAt9.toString()]); @@ -622,7 +622,7 @@ describe('PrivateEventStore', () => { await storeEventAt(eventAt12, 12, BLOCK_HASH_12); await privateEventStore.commitStaged('test'); - await kvStore.transactionAsync(() => privateEventStore.rollback(9)); + await kvStore.transactionAsync(() => privateEventStore.rollbackToBlock(9)); // Block 9 survives; both 10 and the non-contiguous 12 are swept. expect(await privateEventStore.eventIdsAtBlock(9)).toEqual([eventAt9.toString()]); @@ -638,9 +638,9 @@ describe('PrivateEventStore', () => { await storeEventAt(eventAt10, 10, BLOCK_HASH_10); await privateEventStore.commitStaged('test'); - await kvStore.transactionAsync(() => privateEventStore.rollback(9)); + await kvStore.transactionAsync(() => privateEventStore.rollbackToBlock(9)); // Re-running over the already-truncated tail must not throw and must not change anything. - await kvStore.transactionAsync(() => privateEventStore.rollback(9)); + await kvStore.transactionAsync(() => privateEventStore.rollbackToBlock(9)); expect(await privateEventStore.eventIdsAtBlock(9)).toEqual([eventAt9.toString()]); expect(await privateEventStore.eventIdsAtBlock(10)).toHaveLength(0); @@ -662,7 +662,7 @@ describe('PrivateEventStore', () => { await storeEventAt(commitment, 10, BLOCK_HASH_10); await privateEventStore.commitStaged('test'); - await kvStore.transactionAsync(() => privateEventStore.rollback(9)); + await kvStore.transactionAsync(() => privateEventStore.rollbackToBlock(9)); expect(await readBack()).toHaveLength(0); // Re-add the same commitment, as happens when the tx is re-included after the reorg. @@ -677,7 +677,7 @@ describe('PrivateEventStore', () => { await privateEventStore.commitStaged('test'); // Rolling back to a block above every stored event removes nothing. - await kvStore.transactionAsync(() => privateEventStore.rollback(20)); + await kvStore.transactionAsync(() => privateEventStore.rollbackToBlock(20)); expect(await privateEventStore.eventIdsAtBlock(10)).toEqual([eventAt10.toString()]); }); @@ -701,13 +701,13 @@ describe('PrivateEventStore', () => { 'uncommitted-change-set', ); - await expect(kvStore.transactionAsync(() => privateEventStore.rollback(0))).rejects.toThrow( + await expect(kvStore.transactionAsync(() => privateEventStore.rollbackToBlock(0))).rejects.toThrow( 'PXE private event store rollback is not allowed while staged writes are pending', ); await privateEventStore.discardStaged('uncommitted-change-set'); - await expect(kvStore.transactionAsync(() => privateEventStore.rollback(0))).resolves.not.toThrow(); + await expect(kvStore.transactionAsync(() => privateEventStore.rollbackToBlock(0))).resolves.not.toThrow(); }); }); diff --git a/yarn-project/pxe/src/storage/private_event_store/private_event_store.ts b/yarn-project/pxe/src/storage/private_event_store/private_event_store.ts index f7d551d58f94..3eb2f815bc6d 100644 --- a/yarn-project/pxe/src/storage/private_event_store/private_event_store.ts +++ b/yarn-project/pxe/src/storage/private_event_store/private_event_store.ts @@ -9,6 +9,7 @@ import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { InTx, TxHash } from '@aztec/stdlib/tx'; import type { PackedPrivateEvent } from '../../pxe.js'; +import type { Rollbackable } from '../rollbackable.js'; import type { ChangeSetId, StagedStore } from '../staged_write_coordinator.js'; import { StoredPrivateEvent } from './stored_private_event.js'; @@ -41,7 +42,7 @@ type StoredEventBuffer = Buffer; * Append-only: events are never deleted during normal operation. Reorgs are handled by delete-on-prune, which removes * every event originating on a reorg'd block. */ -export class PrivateEventStore implements StagedStore { +export class PrivateEventStore implements StagedStore, Rollbackable { readonly storeName: string = 'private_event'; #store: AztecAsyncKVStore; @@ -245,7 +246,7 @@ export class PrivateEventStore implements StagedStore { * uncommitted staged writes, since rolling back mid-change-set could later re-introduce events anchored to deleted * blocks. */ - public async rollback(toBlock: number): Promise { + public async rollbackToBlock(toBlock: number): Promise { if (this.#eventsForChangeSet.size > 0) { throw new Error('PXE private event store rollback is not allowed while staged writes are pending'); } diff --git a/yarn-project/pxe/src/storage/rollbackable.ts b/yarn-project/pxe/src/storage/rollbackable.ts new file mode 100644 index 000000000000..4e96a2892dc7 --- /dev/null +++ b/yarn-project/pxe/src/storage/rollbackable.ts @@ -0,0 +1,14 @@ +/** + * A store holding block-indexed state that must be truncated when a chain prune (reorg) is detected. + */ +export interface Rollbackable { + /** + * Rolls the store back to `toBlock`: deletes all state originating from blocks strictly above it, as if nothing + * past that block height ever happened. + * + * Called inside an already open store transaction shared with every other rollbackable and with the anchor block + * update, so implementations must not open one of their own. Throwing aborts that transaction, undoing the whole + * prune and leaving the sync cursor untouched so the prune event is re-emitted on the next sync. + */ + rollbackToBlock(toBlock: number): Promise; +}