diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d775d7cff4..101d0cdcb0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -203,6 +203,8 @@ jobs: save-if: false - name: Run miden-bench CLI tests run: make test-miden-bench + - name: Run the SQL store scaling benchmark + run: make bench-store - name: Fetch test node binaries uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index c285a35d02..7e7c90405b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ * [BREAKING][param][rust] `NodeRpcClient` models encrypted submissions: `submit_proven_transaction` now takes `SealedTransactionInputs` instead of `TransactionInputs`, `submit_proven_batch` now takes `Vec` (one per transaction, each sealed against its own transaction ID), and implementations must provide the new `get_transaction_encryption_key` method ([#2341](https://github.com/0xMiden/rust-sdk/pull/2341)). * [BREAKING][type][rust] Added the `NoteFilter::ScriptRoots` variant, so exhaustive matches on `NoteFilter` in `Store` implementations must handle it ([#2335](https://github.com/0xMiden/rust-sdk/pull/2335)). * [BREAKING][behavior][store] The SQLite base schema now declares an index on `input_notes(script_root)`. This changes the schema fingerprint, so opening a database created before this change fails with `SchemaHashMismatch` and existing stores must be recreated ([#2335](https://github.com/0xMiden/rust-sdk/pull/2335)). +* [BREAKING][behavior][store] The SQLite base schema now indexes `code_commitment` on `latest_account_headers`, `historical_account_headers` and `foreign_account_code`, the `input_notes` consumption index now leads with `consumer_account_id`, and the `input_notes` state index now carries `nullifier`. This changes the schema fingerprint, so opening a database created before this change fails with `SchemaHashMismatch` and existing stores must be recreated ([#2364](https://github.com/0xMiden/rust-sdk/pull/2364)). +* [BREAKING][param][rust] `Store::get_input_note_by_offset` is replaced by `Store::get_input_note_after`, which takes an `Option` identifying the last note read instead of an ordinal offset. Build the cursor for the next call with `InputNoteCursor::from_record`. `Store` implementations must be updated; `InputNoteReader` is unaffected ([#2364](https://github.com/0xMiden/rust-sdk/pull/2364)). ### Enhancements @@ -21,9 +23,15 @@ * [FEATURE][rust] `Client::get_consumable_notes(Some(account_id))` now screens only that account instead of screening every tracked account and discarding the rest, so its cost no longer grows with the number of tracked accounts. Added `NoteScreener::get_batch_consumability_for_account` to screen notes against a single account ([#2338](https://github.com/0xMiden/rust-sdk/pull/2338)). * [FEATURE][rust] Added the `miden_client::rpc::encryption` module backing encrypted submissions: `TransactionEncryptionKey`, `AttestedTransactionEncryptionKey` (whose `verify` is the only path to a usable key), `ValidatorAttestation`, `NextTransactionEncryptionKey`, `SealedTransactionInputs` and `seal_transaction_inputs`, along with re-exports of the validator DSA key types reachable from this API ([#2341](https://github.com/0xMiden/rust-sdk/pull/2341)). * [FEATURE][rust,store] Added `NoteFilter::ScriptRoots` to query input notes by their note script root directly at the store level, without loading and screening unrelated notes. The filter doesn't apply to output notes: querying output notes with it returns an empty list ([#2335](https://github.com/0xMiden/rust-sdk/pull/2335)). +* [FEATURE][store] Added the `miden-bench store` subcommand, which measures how the `SQLite` store methods scale with the number of notes and accounts and reports the growth between the smallest and the largest size. It seeds its own throwaway databases and needs no node, and `make bench-store` runs the same sweep CI does ([#2364](https://github.com/0xMiden/rust-sdk/pull/2364)). * [rust] Added `PartialBlockchainUpdates::block_headers_to_store`, which narrows the staged headers to the ones a sync must persist: those marked as relevant, genesis, and the block at the sync height. `block_headers` still yields all staged headers ([#2297](https://github.com/0xMiden/rust-sdk/pull/2297)). * [rust] State sync now authenticates every relevant note block but only persists block headers and MMR authentication nodes for blocks containing notes that remain unspent or that a `NoteObserver` explicitly marks as relevant ([#2297](https://github.com/0xMiden/rust-sdk/pull/2297)). +### Fixes + +* [FIX][rust] `InputNoteReader::next` now fails with `ClientError::MissingNoteConsumptionPosition` when the store yields a note that carries no consumption position. The walk cannot advance past such a note, and it previously restarted from the first note on every subsequent call ([#2364](https://github.com/0xMiden/rust-sdk/pull/2364)). +* [FIX][store] Input notes listed with `NoteFilter::Consumed` now break ties on the details commitment instead of the note ID, so notes consumed by the same transaction come back in a stable order. A note only carries an ID once its metadata is known, which left the previous tie-break undefined for the rest ([#2364](https://github.com/0xMiden/rust-sdk/pull/2364)). + ## 0.16.0-alpha.1 (2026-07-17) ### Breaking Changes diff --git a/Makefile b/Makefile index d4becdf3c2..ede1278894 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,10 @@ WARNINGS=RUSTDOCFLAGS="-D warnings" TEST_MIDEN_NOTE_TRANSPORT_URL?=http://127.0.0.1:57292 +# Sizes the SQL store scaling benchmark sweeps over. Kept small enough to run on every PR, and +# overridable for a deeper local run. +STORE_BENCH_ARGS?=--notes 1000,10000 --accounts 100,1000 --iterations 5 + # --- Linting ------------------------------------------------------------------------------------- .PHONY: clippy @@ -84,6 +88,12 @@ test-miden-bench: ## Run miden-bench CLI tests test-docs: ## Run documentation tests cargo test --doc $(FEATURES_CLIENT) +# --- Benchmarking -------------------------------------------------------------------------------- + +.PHONY: bench-store +bench-store: ## Run the SQL store scaling benchmark (no node needed) + cargo run --package miden-client-bench --release --locked -- store $(STORE_BENCH_ARGS) + # --- Integration testing ------------------------------------------------------------------------- .PHONY: start-node diff --git a/bin/miden-bench/README.md b/bin/miden-bench/README.md index 8a86f6d68a..eefb1f26b4 100644 --- a/bin/miden-bench/README.md +++ b/bin/miden-bench/README.md @@ -1,6 +1,6 @@ # miden-bench -Benchmarking tool for the Miden client library. This binary measures performance of transactions to establish baselines and identify optimization opportunities. +Benchmarking tool for the Miden client library. This binary measures performance of transactions and of the `SQLite` store to establish baselines and identify optimization opportunities. ## Installation @@ -66,6 +66,39 @@ The number of storage maps is auto-detected from the account. miden-bench --network localhost transaction --account-id 0x... ``` +### `store` + +Benchmarks the `SQLite` store as the database grows. + +```bash +miden-bench store --notes 1000,10000 --accounts 100,1000 --iterations 5 +``` + +Two tables come out of a run, one per sweep. Each row is a store method, each column a size, and the last column the growth from the first size to the last. That growth is the number to read: a method served by an index stays near `1.00x` while the database grows, and one that falls back to a scan grows with it. + +The note sweep seeds three quarters of the notes as consumed by a single account, spread over blocks and transaction orders, and the rest as unspent notes carrying a nullifier. It measures: + +- `get_input_notes` for the `Unspent`, `Consumed`, `List`, `Nullifiers` and `ScriptRoots` filters +- `get_unspent_input_note_nullifiers` and `get_tracked_block_headers` +- `get_input_note_after` seeded with a cursor near the end of the account's history +- a full `InputNoteReader` walk, reported as a total and per note +- `upsert_input_notes` and `apply_state_sync` for a fixed batch of new notes + +The account sweep seeds wallets that share one account code, and measures `SqliteStore::new` (which rebuilds the SMT forest on open), `get_account_headers`, `get_account_header` and `prune_account_history`. + +Reads run before writes within a size, so every read measures exactly the seeded database. + +```bash +# A deeper sweep. Seeding is linear in the sizes, so the run time is too. +miden-bench store --notes 10000,100000 --accounts 1000,10000 +``` + +`make bench-store` runs the same sweep CI does. Override `STORE_BENCH_ARGS` to change the sizes: + +```bash +make bench-store STORE_BENCH_ARGS="--notes 5000,50000 --accounts 500,5000" +``` + ### `import` Imports an account into the local store. Two mutually exclusive modes: @@ -196,6 +229,12 @@ miden-bench deploy --maps 3 - `-r, --reads ` - Maximum storage reads per transaction. When total entries exceed this limit, reads are split across multiple transactions per benchmark iteration. Each iteration's time is the sum across all transactions. When omitted, all entries are read in a single transaction. - `-i, --iterations ` - Number of benchmark iterations (default: 5) +#### Store + +- `--notes ` - Note counts to sweep over (default: `1000,10000`) +- `--accounts ` - Account counts to sweep over (default: `100,1000`) +- `-i, --iterations ` - Number of benchmark iterations per measurement (default: 5) + #### Import Exactly one of the following must be provided: diff --git a/bin/miden-bench/src/benchmarks/mod.rs b/bin/miden-bench/src/benchmarks/mod.rs index 37f08066e0..4836a23180 100644 --- a/bin/miden-bench/src/benchmarks/mod.rs +++ b/bin/miden-bench/src/benchmarks/mod.rs @@ -1 +1,2 @@ +pub mod store; pub mod transaction; diff --git a/bin/miden-bench/src/benchmarks/store.rs b/bin/miden-bench/src/benchmarks/store.rs new file mode 100644 index 0000000000..6d0db2a910 --- /dev/null +++ b/bin/miden-bench/src/benchmarks/store.rs @@ -0,0 +1,336 @@ +//! Benchmarks the `SQLite` store methods against a growing database. +//! +//! Each size in the sweep seeds its own database file, so the numbers of one size never depend on +//! the leftovers of another. What matters in the output is the growth between the smallest and the +//! largest size: a query served by an index stays flat, one that falls back to a scan does not. +//! +//! Within a size, the read measurements run before the writing ones, so every read sees exactly +//! the seeded database. + +use std::path::Path; +use std::sync::Arc; +use std::time::Instant; + +use anyhow::Context; +use miden_client::ONE; +use miden_client::account::{Account, AccountId, Address}; +use miden_client::block::BlockNumber; +use miden_client::note::{InputNoteReader, NoteUpdateTracker}; +use miden_client::store::{ClientAccountType, InputNoteCursor, NoteFilter, Store}; +use miden_client::sync::{ + AccountUpdates, + PartialBlockchainUpdates, + StateSyncUpdate, + TransactionUpdateTracker, +}; +use miden_client_sqlite_store::SqliteStore; + +use crate::generators::store_data; +use crate::metrics::BenchmarkResult; +use crate::report::ScalingPoint; + +/// Notes written by each of the two insert measurements. It is a batch a sync could realistically +/// carry, and it stays constant across sizes so the insert cost is comparable between them. +const INSERT_BATCH_SIZE: usize = 50; + +/// Results of both sweeps, ready to be printed as one table each. +pub struct StoreBenchmarks { + /// One point per note count. + pub notes: Vec, + /// One point per account count. + pub accounts: Vec, +} + +/// Runs the note-count and account-count sweeps, seeding one database per size under `workdir`. +pub async fn run_store_benchmarks( + note_counts: &[usize], + account_counts: &[usize], + iterations: usize, + workdir: &Path, +) -> anyhow::Result { + let mut notes = Vec::new(); + for &count in note_counts { + println!("Seeding and measuring {count} notes..."); + notes.push(ScalingPoint { + label: format!("{count} notes"), + results: bench_note_methods(workdir, count, iterations).await?, + }); + } + + let mut accounts = Vec::new(); + for &count in account_counts { + println!("Seeding and measuring {count} accounts..."); + accounts.push(ScalingPoint { + label: format!("{count} accounts"), + results: bench_account_methods(workdir, count, iterations).await?, + }); + } + + Ok(StoreBenchmarks { notes, accounts }) +} + +// NOTE METHODS +// ================================================================================================ + +async fn bench_note_methods( + workdir: &Path, + count: usize, + iterations: usize, +) -> anyhow::Result> { + let store = SqliteStore::new(workdir.join(format!("notes-{count}.sqlite3"))) + .await + .context("failed to create the note benchmark store")?; + + let consumer = store_data::consumer_account_id(); + let seed = store_data::note_seed(consumer, count); + + store.upsert_input_notes(&seed.consumed).await?; + store.upsert_input_notes(&seed.unspent).await?; + for (header, has_client_notes) in &seed.block_headers { + store.insert_block_header(header, &[], *has_client_notes).await?; + } + + let mut results = note_read_measurements(&store, &seed, consumer, iterations).await?; + let inserts = note_write_measurements(&store, &seed, consumer, iterations).await?; + + // The walk runs last, over the seeded notes plus whatever the inserts added, so its per-note + // cost is divided by the notes actually returned. + let store: Arc = Arc::new(store); + let mut walked = 0u32; + let walk = measure("InputNoteReader [full walk]", iterations, async |_| { + let mut reader = InputNoteReader::new(store.clone(), consumer); + let mut count = 0; + while reader.next().await?.is_some() { + count += 1; + } + walked = count; + Ok(()) + }) + .await?; + + results.push(per_note(&walk, walked)); + results.push(walk); + results.extend(inserts); + + Ok(results) +} + +/// Measures the read paths against the seeded database. +async fn note_read_measurements( + store: &SqliteStore, + seed: &store_data::NoteSeed, + consumer: AccountId, + iterations: usize, +) -> anyhow::Result> { + // The point lookups below read notes the seed holds, so each one is a lookup that hits rather + // than one that stops at the index. + let last_consumed = seed + .consumed + .last() + .context("a note benchmark needs at least one consumed note")?; + let note_id = last_consumed.id().context("a consumed note carries an id")?; + let nullifier = last_consumed.nullifier().context("a consumed note carries a nullifier")?; + let script_root = store_data::note_scripts()[0].root(); + + // The cursor of the second-to-last note, so the seek has the whole history in front of it. + let deep_cursor = { + let mut ordered: Vec<_> = seed.consumed.iter().collect(); + ordered.sort_by_key(|note| store_data::consumption_key(note)); + let index = ordered.len().saturating_sub(2); + InputNoteCursor::from_record(ordered[index]).context("a consumed note yields a cursor")? + }; + + Ok(vec![ + measure("get_input_notes(Unspent)", iterations, async |_| { + store.get_input_notes(NoteFilter::Unspent).await?; + Ok(()) + }) + .await?, + measure("get_input_notes(Consumed)", iterations, async |_| { + store.get_input_notes(NoteFilter::Consumed).await?; + Ok(()) + }) + .await?, + measure("get_input_notes(List) [1 note]", iterations, async |_| { + store.get_input_notes(NoteFilter::List(vec![note_id])).await?; + Ok(()) + }) + .await?, + measure("get_input_notes(Nullifiers) [1 note]", iterations, async |_| { + store.get_input_notes(NoteFilter::Nullifiers(vec![nullifier])).await?; + Ok(()) + }) + .await?, + measure("get_input_notes(ScriptRoots) [1 root]", iterations, async |_| { + store.get_input_notes(NoteFilter::ScriptRoots(vec![script_root])).await?; + Ok(()) + }) + .await?, + measure("get_unspent_input_note_nullifiers", iterations, async |_| { + store.get_unspent_input_note_nullifiers().await?; + Ok(()) + }) + .await?, + measure("get_tracked_block_headers", iterations, async |_| { + store.get_tracked_block_headers().await?; + Ok(()) + }) + .await?, + measure("get_input_note_after [deep cursor]", iterations, async |_| { + store + .get_input_note_after(NoteFilter::Consumed, consumer, None, None, Some(deep_cursor)) + .await?; + Ok(()) + }) + .await?, + ]) +} + +/// Measures the write paths against the seeded database. Every iteration writes notes of its own, +/// so each one is an insert into an already-full table and never a replace. +async fn note_write_measurements( + store: &SqliteStore, + seed: &store_data::NoteSeed, + consumer: AccountId, + iterations: usize, +) -> anyhow::Result> { + let sync_block = BlockNumber::from(u32::try_from(seed.block_headers.len()).unwrap_or(u32::MAX)); + + Ok(vec![ + measure( + &format!("upsert_input_notes [{INSERT_BATCH_SIZE} notes]"), + iterations, + async |i| { + let notes = store_data::insert_batch(consumer, INSERT_BATCH_SIZE, i); + store.upsert_input_notes(¬es).await?; + Ok(()) + }, + ) + .await?, + measure( + &format!("apply_state_sync [{INSERT_BATCH_SIZE} notes]"), + iterations, + async |i| { + // Offset past the batches the measurement above wrote, so this one inserts too. + let notes = + store_data::insert_batch(consumer, INSERT_BATCH_SIZE, i + iterations + 1); + let update = StateSyncUpdate::from_parts( + sync_block, + PartialBlockchainUpdates::default(), + NoteUpdateTracker::for_transaction_updates(notes, [], []), + TransactionUpdateTracker::default(), + AccountUpdates::default(), + ); + store.apply_state_sync(update).await?; + Ok(()) + }, + ) + .await?, + ]) +} + +/// Returns the per-note cost of a walk over `walked` notes. +fn per_note(walk: &BenchmarkResult, walked: u32) -> BenchmarkResult { + let mut result = BenchmarkResult::new("InputNoteReader [per note]") + .with_metadata(format!("{walked} notes walked")); + for iteration in &walk.iterations { + result.add_iteration(iteration.checked_div(walked).unwrap_or_default()); + } + + result +} + +// ACCOUNT METHODS +// ================================================================================================ + +async fn bench_account_methods( + workdir: &Path, + count: usize, + iterations: usize, +) -> anyhow::Result> { + let store_path = workdir.join(format!("accounts-{count}.sqlite3")); + let store = SqliteStore::new(store_path.clone()) + .await + .context("failed to create the account benchmark store")?; + + let accounts = store_data::wallet_accounts(count)?; + for account in &accounts { + store + .insert_account(account, Address::new(account.id()), ClientAccountType::Native) + .await?; + } + + let last = accounts.last().context("an account benchmark needs at least one account")?; + let last_id = last.id(); + + let mut results = vec![ + measure("SqliteStore::new [open]", iterations, async |_| { + SqliteStore::new(store_path.clone()).await?; + Ok(()) + }) + .await?, + measure("get_account_headers", iterations, async |_| { + store.get_account_headers().await?; + Ok(()) + }) + .await?, + measure("get_account_header [single]", iterations, async |_| { + store.get_account_header(last_id).await?; + Ok(()) + }) + .await?, + ]; + + results.push(prune_account_history(&store, last.clone(), iterations).await?); + + Ok(results) +} + +/// Measures pruning one historical account state. The state is created outside the timed section, +/// once per iteration, because the prune is what the measurement is about. +async fn prune_account_history( + store: &SqliteStore, + mut account: Account, + iterations: usize, +) -> anyhow::Result { + let mut result = BenchmarkResult::new("prune_account_history [1 state]"); + + for iteration in 0..iterations { + account.increment_nonce(ONE)?; + store.update_account(&account).await?; + + let start = Instant::now(); + let deleted = store.prune_account_history(account.id(), account.nonce()).await?; + result.add_iteration(start.elapsed()); + + // The state archived just above has to be what the prune deletes. A prune that finds + // nothing measures nothing, and would report a flat row for the wrong reason. + anyhow::ensure!(deleted > 0, "iteration {iteration} pruned no historical state"); + } + + Ok(result) +} + +// HELPERS +// ================================================================================================ + +/// Runs `operation` `iterations` times, recording how long each run took. The iteration index is +/// passed in so that measurements which write can keep every run's data distinct. +async fn measure( + name: &str, + iterations: usize, + mut operation: F, +) -> anyhow::Result +where + F: AsyncFnMut(usize) -> anyhow::Result<()>, +{ + let mut result = BenchmarkResult::new(name); + + for iteration in 0..iterations { + let start = Instant::now(); + operation(iteration).await?; + result.add_iteration(start.elapsed()); + } + + Ok(result) +} diff --git a/bin/miden-bench/src/generators/mod.rs b/bin/miden-bench/src/generators/mod.rs index 1e1d5f9396..b3b7ba0fe6 100644 --- a/bin/miden-bench/src/generators/mod.rs +++ b/bin/miden-bench/src/generators/mod.rs @@ -1,3 +1,4 @@ mod large_account; +pub mod store_data; pub use large_account::{SlotDescriptor, generate_reader_component_code, random_word, slot_rng}; diff --git a/bin/miden-bench/src/generators/store_data.rs b/bin/miden-bench/src/generators/store_data.rs new file mode 100644 index 0000000000..8f7bad6d57 --- /dev/null +++ b/bin/miden-bench/src/generators/store_data.rs @@ -0,0 +1,234 @@ +//! Synthetic store contents for the SQL store scaling benchmark. +//! +//! Every record is built through public client APIs, so the seeded database is the same shape a +//! real client would produce, and the measurements below it stay honest about what the store has +//! to do. + +use miden_client::account::component::BasicWallet; +use miden_client::account::{Account, AccountBuilder, AccountId, AccountType}; +use miden_client::auth::{Approver, AuthSchemeId, AuthSingleSig, PublicKeyCommitment}; +use miden_client::block::BlockHeader; +use miden_client::note::{ + BlockNumber, + NoteAssets, + NoteAttachments, + NoteDetails, + NoteMetadata, + NoteRecipient, + NoteScript, + NoteStorage, + NoteTag, + NoteType, + PartialNoteMetadata, + StandardNote, +}; +use miden_client::store::InputNoteRecord; +use miden_client::store::input_note_states::{ + ConsumedUnauthenticatedLocalNoteState, + ExpectedNoteState, + NoteSubmissionData, +}; +use miden_client::testing::account_id::ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE; +use miden_client::transaction::{TransactionId, TransactionKernel}; +use miden_client::utils::Serializable; +use miden_client::{EMPTY_WORD, Felt, Word, ZERO}; + +/// Notes consumed by a single transaction of the benchmarked account. Consumed notes are spread +/// over blocks and transaction orders so that walking them exercises the whole cursor key. +const NOTES_PER_TX: usize = 2; + +/// Transactions per block, so a note count spreads over several blocks instead of piling into one. +const TXS_PER_BLOCK: usize = 4; + +/// Share of the seeded notes that are consumed. The rest stay unspent, which is what the unspent +/// filters and the nullifier listing read. +const CONSUMED_SHARE: usize = 3; +const SHARE_DIVISOR: usize = 4; + +/// Serial number offsets keeping the generated note families disjoint, so no two seeded notes +/// collapse onto the same details commitment. +const CONSUMED_SERIAL_BASE: u64 = 1_000_000; +const UNSPENT_SERIAL_BASE: u64 = 2_000_000; +const INSERT_SERIAL_BASE: u64 = 3_000_000; + +// SEED +// ================================================================================================ + +/// The store contents a note-count measurement runs against. +pub struct NoteSeed { + /// Notes consumed by the benchmarked account. + pub consumed: Vec, + /// Notes that have not been consumed. + pub unspent: Vec, + /// One header per block the consumed notes were consumed in, paired with the + /// `has_client_notes` flag it is stored under. + pub block_headers: Vec<(BlockHeader, bool)>, +} + +/// Returns the account whose consumed notes the benchmark walks. +pub fn consumer_account_id() -> AccountId { + AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE) + .expect("the testing account id is valid") +} + +/// Builds `count` input notes for `consumer`, alongside the block headers covering their +/// consumption. +pub fn note_seed(consumer: AccountId, count: usize) -> NoteSeed { + let consumed_count = count * CONSUMED_SHARE / SHARE_DIVISOR; + let consumed_notes = consumed_input_notes(consumer, consumed_count, CONSUMED_SERIAL_BASE); + let unspent_notes = unspent_input_notes(consumer, count - consumed_count, UNSPENT_SERIAL_BASE); + + // Half the headers are stored as holding client notes, so the partial index that serves the + // tracked-header query covers a part of the table rather than all of it. + let block_headers = (0..block_count(consumed_count)) + .map(|block| (mock_block_header(block), block % 2 == 0)) + .collect(); + + NoteSeed { + consumed: consumed_notes, + unspent: unspent_notes, + block_headers, + } +} + +/// Builds the batch of consumed notes an insert measurement adds on iteration `iteration`. Each +/// iteration gets its own serial numbers, so every batch is an insert and never a replace. +pub fn insert_batch(consumer: AccountId, size: usize, iteration: usize) -> Vec { + let iteration = u64::try_from(iteration).expect("iteration count fits in u64"); + let size_step = u64::try_from(size).expect("batch size fits in u64"); + let base = INSERT_SERIAL_BASE + (iteration + 1) * size_step; + + consumed_input_notes(consumer, size, base) +} + +/// Returns the key the per-account consumption order sorts by. Sorting the generated notes by it +/// mirrors the order the store returns them in. +pub fn consumption_key(note: &InputNoteRecord) -> (u32, u32, Vec) { + ( + note.state().consumed_block_height().expect("note is consumed").as_u32(), + note.state().consumed_tx_order().expect("note has a consumption order"), + note.details_commitment().to_bytes(), + ) +} + +// NOTES +// ================================================================================================ + +/// Builds `count` notes consumed by `consumer`, spread over blocks and transaction orders. +fn consumed_input_notes( + consumer: AccountId, + count: usize, + serial_base: u64, +) -> Vec { + let scripts = note_scripts(); + + (0..count) + .map(|index| { + let tx = index / NOTES_PER_TX; + let block = u32::try_from(tx / TXS_PER_BLOCK).expect("block index fits in u32"); + let tx_order = u32::try_from(tx % TXS_PER_BLOCK).expect("tx order fits in u32"); + let details = note_details(serial_base, index, &scripts); + + let state = ConsumedUnauthenticatedLocalNoteState { + metadata: note_metadata(consumer, index), + nullifier_block_height: BlockNumber::from(block), + submission_data: NoteSubmissionData { + submitted_at: Some(0), + consumer_account: consumer, + consumer_transaction: TransactionId::from_raw(Word::default()), + }, + consumed_tx_order: Some(tx_order), + }; + + InputNoteRecord::new(details, NoteAttachments::empty(), Some(0), state.into()) + }) + .collect() +} + +/// Builds `count` notes that carry metadata, and therefore a nullifier, but were never consumed. +fn unspent_input_notes(sender: AccountId, count: usize, serial_base: u64) -> Vec { + let scripts = note_scripts(); + + (0..count) + .map(|index| { + let state = ExpectedNoteState { + metadata: Some(note_metadata(sender, index)), + after_block_num: BlockNumber::from(0u32), + tag: None, + }; + + InputNoteRecord::new( + note_details(serial_base, index, &scripts), + NoteAttachments::empty(), + Some(0), + state.into(), + ) + }) + .collect() +} + +/// Returns the note scripts the generated notes are split across, so that filtering by script root +/// selects a part of the table. +pub fn note_scripts() -> Vec { + vec![StandardNote::SWAP.script(), StandardNote::P2ID.script()] +} + +/// Returns the number of blocks `count` consumed notes span. +fn block_count(count: usize) -> u32 { + let notes_per_block = NOTES_PER_TX * TXS_PER_BLOCK; + u32::try_from(count.div_ceil(notes_per_block)).expect("block count fits in u32") +} + +fn note_details(serial_base: u64, index: usize, scripts: &[NoteScript]) -> NoteDetails { + let serial = serial_base + u64::try_from(index).expect("note index fits in u64"); + let serial_number: Word = [Felt::new_unchecked(serial), ZERO, ZERO, ZERO].into(); + let script = scripts[index % scripts.len()].clone(); + let recipient = NoteRecipient::new( + serial_number, + script, + NoteStorage::new(vec![]).expect("empty note storage is valid"), + ); + + NoteDetails::new(NoteAssets::new(vec![]).expect("an empty asset list is valid"), recipient) +} + +fn note_metadata(sender: AccountId, index: usize) -> NoteMetadata { + let tag = NoteTag::from(u32::try_from(index).expect("note index fits in u32")); + let partial = PartialNoteMetadata::new(sender, NoteType::Public).with_tag(tag); + + NoteMetadata::new(partial, &NoteAttachments::empty()) +} + +// BLOCK HEADERS +// ================================================================================================ + +fn mock_block_header(block_num: u32) -> BlockHeader { + BlockHeader::mock(block_num, None, None, &[], TransactionKernel.to_commitment()) +} + +// ACCOUNTS +// ================================================================================================ + +/// Builds `count` distinct wallet accounts. They all share one account code, which is what a store +/// full of wallets looks like and what makes the code reference lookups worth indexing. +pub fn wallet_accounts(count: usize) -> anyhow::Result> { + (0..count) + .map(|index| { + let mut init_seed = [0u8; 32]; + let index = u64::try_from(index).expect("account index fits in u64"); + init_seed[0..8].copy_from_slice(&index.to_le_bytes()); + + let auth = AuthSingleSig::new(Approver::new( + PublicKeyCommitment::from(EMPTY_WORD), + AuthSchemeId::Falcon512Poseidon2, + )); + + AccountBuilder::new(init_seed) + .account_type(AccountType::Private) + .with_auth_component(auth) + .with_component(BasicWallet) + .build_existing() + .map_err(anyhow::Error::from) + }) + .collect() +} diff --git a/bin/miden-bench/src/main.rs b/bin/miden-bench/src/main.rs index a32d48724c..09efd4021e 100644 --- a/bin/miden-bench/src/main.rs +++ b/bin/miden-bench/src/main.rs @@ -44,6 +44,8 @@ struct CliArgs { enum Command { /// Benchmark transaction operations: read all storage entries from account (requires node) Transaction(TransactionArgs), + /// Benchmark how the SQL store scales with the number of notes and accounts (no node needed) + Store(StoreArgs), /// Deploy a public wallet with configurable storage to the network (requires node) Deploy(StorageArgs), /// Expand storage: fill entries in a specific map of a deployed account (requires node) @@ -65,7 +67,7 @@ impl Command { Command::Deploy(_) | Command::Expand(_) | Command::Transaction(_) => { StartupMode::Synced }, - Command::Import(_) | Command::Export(_) => StartupMode::Unsynced, + Command::Import(_) | Command::Export(_) | Command::Store(_) => StartupMode::Unsynced, } } } @@ -103,6 +105,22 @@ struct TransactionArgs { iterations: usize, } +/// SQL store scaling benchmark options +#[derive(Args, Clone)] +struct StoreArgs { + /// Note counts to measure, as a comma-separated list + #[arg(long, default_value = "1000,10000", value_delimiter = ',', value_parser = parse_size)] + notes: Vec, + + /// Account counts to measure, as a comma-separated list + #[arg(long, default_value = "100,1000", value_delimiter = ',', value_parser = parse_size)] + accounts: Vec, + + /// Number of benchmark iterations + #[arg(short, long, default_value_t = DEFAULT_ITERATION_COUNT)] + iterations: usize, +} + /// Import an account from a `.mac` file or download a public account by ID. /// /// Exactly one of `--filename` or `--account-id` must be provided. @@ -317,6 +335,9 @@ async fn dispatch_command( }, } }, + Command::Store(store_args) => { + run_store_benchmark(store_args).await; + }, Command::Import(import_args) => { let result = match (import_args.filename, import_args.account_id) { (Some(filename), None) => { @@ -349,9 +370,56 @@ async fn dispatch_command( } } +/// Runs the SQL store scaling sweeps and prints one table per sweep. +async fn run_store_benchmark(store_args: StoreArgs) { + // The seeded databases are throwaway: they live in their own temp directory, so a run can + // neither read nor overwrite the accounts and notes the other commands keep. + let workdir = std::env::temp_dir().join(format!("miden-bench-scaling-{}", std::process::id())); + std::fs::create_dir_all(&workdir).expect("Failed to create the benchmark directory"); + println!("Seeded databases: {}", workdir.display()); + + // Ascending sizes make the growth column read from the first size to the last. + let mut notes = store_args.notes; + notes.sort_unstable(); + let mut accounts = store_args.accounts; + accounts.sort_unstable(); + + let start_time = Instant::now(); + let results = Box::pin(benchmarks::store::run_store_benchmarks( + ¬es, + &accounts, + store_args.iterations, + &workdir, + )) + .await; + let total_duration = start_time.elapsed(); + + std::fs::remove_dir_all(&workdir).expect("Failed to remove the benchmark directory"); + + match results { + Ok(results) => { + report::print_scaling_results(&results.notes, "Note scaling"); + report::print_scaling_results(&results.accounts, "Account scaling"); + println!("\nTotal time: {:.2}s", total_duration.as_secs_f64()); + }, + Err(e) => { + panic!("Store benchmark failed: {e:?}"); + }, + } +} + // HELPERS // ================================================================================================ +fn parse_size(s: &str) -> Result { + let n: usize = s.trim().parse().map_err(|e| format!("{e}"))?; + if n == 0 { + return Err("size must be greater than 0".to_string()); + } + + Ok(n) +} + fn parse_maps(s: &str) -> Result { let n: usize = s.parse().map_err(|e| format!("{e}"))?; if (1..=100).contains(&n) { diff --git a/bin/miden-bench/src/report.rs b/bin/miden-bench/src/report.rs index 50f8277b40..5b40411901 100644 --- a/bin/miden-bench/src/report.rs +++ b/bin/miden-bench/src/report.rs @@ -54,6 +54,82 @@ pub fn print_results(results: &[BenchmarkResult], title: &str, total_duration: D ); } +// SCALING RESULTS +// ================================================================================================ + +/// The measurements taken at one point of a scaling sweep. +pub struct ScalingPoint { + /// Column header, naming the input size the measurements ran against. + pub label: String, + /// One result per measured operation. + pub results: Vec, +} + +/// Prints a scaling sweep as a table: one row per operation, one column per size, and the growth +/// from the first size to the last. +/// +/// The growth column is what the sweep is for. An operation served by an index stays near `1.00x` +/// no matter the size, while one that scans grows with it. +pub fn print_scaling_results(points: &[ScalingPoint], title: &str) { + if points.is_empty() { + return; + } + + println!(); + + // Rows follow the order of the first point, and an operation only measured at a later size is + // appended when it first shows up. + let mut operations: Vec<&str> = Vec::new(); + for point in points { + for result in &point.results { + if !operations.contains(&result.name.as_str()) { + operations.push(result.name.as_str()); + } + } + } + + let mut headers = vec![title]; + headers.extend(points.iter().map(|point| point.label.as_str())); + headers.push("Growth"); + + let mut table = create_dynamic_table(&headers); + + for operation in operations { + let means: Vec> = points + .iter() + .map(|point| { + point + .results + .iter() + .find(|result| result.name == operation) + .map(BenchmarkResult::mean) + }) + .collect(); + + let mut row = vec![operation.to_string()]; + row.extend(means.iter().map(|mean| mean.map_or_else(|| "-".to_string(), format_duration))); + row.push(format_growth(&means)); + + table.add_row(row); + } + + println!("{table}"); +} + +/// Formats the ratio between the last and the first measurement of a row. +fn format_growth(means: &[Option]) -> String { + let measured: Vec = means.iter().flatten().copied().collect(); + let (Some(first), Some(last)) = (measured.first(), measured.last()) else { + return "-".to_string(); + }; + + if first.is_zero() { + return "-".to_string(); + } + + format!("{:.2}x", last.as_secs_f64() / first.as_secs_f64()) +} + fn format_duration(d: Duration) -> String { let ms = d.as_secs_f64() * 1000.0; format!("{ms:.2}ms") diff --git a/crates/rust-client/src/errors.rs b/crates/rust-client/src/errors.rs index a06b0269cc..3c325bc9b7 100644 --- a/crates/rust-client/src/errors.rs +++ b/crates/rust-client/src/errors.rs @@ -118,6 +118,10 @@ pub enum ClientError { "cannot recover consumed note {0}: its nullifier has no position in the sync's transaction execution order" )] MissingConsumedNoteOrder(NoteId), + #[error( + "cannot continue iterating consumed notes: the store returned the note with details commitment {0}, which carries no consumption position" + )] + MissingNoteConsumptionPosition(Word), #[error("note with id {0} not found on chain")] NoteNotFoundOnChain(NoteId), #[error("failed to parse hex string")] diff --git a/crates/rust-client/src/note/note_reader.rs b/crates/rust-client/src/note/note_reader.rs index 85ed765f96..e5f39502aa 100644 --- a/crates/rust-client/src/note/note_reader.rs +++ b/crates/rust-client/src/note/note_reader.rs @@ -6,7 +6,7 @@ use miden_protocol::account::AccountId; use miden_protocol::block::BlockNumber; use crate::ClientError; -use crate::store::{InputNoteRecord, NoteFilter, Store}; +use crate::store::{InputNoteCursor, InputNoteRecord, NoteFilter, Store}; /// A lazy iterator over consumed input notes for a specific consumer account. /// @@ -16,12 +16,13 @@ use crate::store::{InputNoteRecord, NoteFilter, Store}; /// # Ordering /// /// Notes are returned in on-chain consumption order: first by block number, then by -/// per-account transaction order within the block. +/// per-account transaction order within the block. Notes consumed by the same transaction +/// are returned in a deterministic order that is consistent across calls. pub struct InputNoteReader { store: Arc, consumer: AccountId, block_range: Option<(BlockNumber, BlockNumber)>, - offset: u32, + cursor: Option, } impl InputNoteReader { @@ -35,7 +36,7 @@ impl InputNoteReader { store, consumer, block_range: None, - offset: 0, + cursor: None, } } @@ -48,7 +49,7 @@ impl InputNoteReader { /// Resets the iterator to the beginning. pub fn reset(&mut self) { - self.offset = 0; + self.cursor = None; } /// Returns the next consumed input note, or `None` when all matching notes have been @@ -64,18 +65,23 @@ impl InputNoteReader { // TODO: The note filter should be configurable instead of hardcoding `NoteFilter::Consumed` let note = self .store - .get_input_note_by_offset( + .get_input_note_after( NoteFilter::Consumed, self.consumer, block_start, block_end, - self.offset, + self.cursor, ) .await .map_err(ClientError::StoreError)?; - if note.is_some() { - self.offset += 1; + if let Some(note) = ¬e { + // A note with no position cannot move the cursor forward, so silently keeping or + // clearing it would either return this same note forever or restart the walk. + let cursor = InputNoteCursor::from_record(note).ok_or_else(|| { + ClientError::MissingNoteConsumptionPosition(note.details_commitment().as_word()) + })?; + self.cursor = Some(cursor); } Ok(note) } diff --git a/crates/rust-client/src/store/mod.rs b/crates/rust-client/src/store/mod.rs index d03353cdf7..7202b8811b 100644 --- a/crates/rust-client/src/store/mod.rs +++ b/crates/rust-client/src/store/mod.rs @@ -109,6 +109,46 @@ pub enum SettingMutation { Remove { key: String }, } +// INPUT NOTE CURSOR +// ================================================================================================ + +/// Identifies a position in the per-account consumption order of input notes. +/// +/// Obtained from a record returned by [`Store::get_input_note_after`] and passed back to fetch +/// the note that follows it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct InputNoteCursor { + consumed_block_height: BlockNumber, + consumed_tx_order: u32, + details_commitment: NoteDetailsCommitment, +} + +impl InputNoteCursor { + /// Returns the cursor pointing at `record`, or `None` if the note is not consumed. + pub fn from_record(record: &InputNoteRecord) -> Option { + Some(Self { + consumed_block_height: record.state().consumed_block_height()?, + consumed_tx_order: record.state().consumed_tx_order()?, + details_commitment: record.details_commitment(), + }) + } + + /// Returns the block height at which the note was consumed. + pub fn consumed_block_height(&self) -> BlockNumber { + self.consumed_block_height + } + + /// Returns the per-account position of the consuming transaction within the block. + pub fn consumed_tx_order(&self) -> u32 { + self.consumed_tx_order + } + + /// Returns the commitment to the note's details. + pub fn details_commitment(&self) -> NoteDetailsCommitment { + self.details_commitment + } +} + // STORE TRAIT // ================================================================================================ @@ -190,20 +230,26 @@ pub trait Store: Send + Sync { filter: NoteFilter, ) -> Result, StoreError>; - /// Retrieves a single input note at the given offset from the filtered set for the given - /// consumer account. Optionally restricts to a block range via `block_start` and - /// `block_end`. Returns `None` when the offset is past the end of the matching notes. + /// Retrieves the input note following `cursor` in the filtered set for the given consumer + /// account, or the first matching note when `cursor` is `None`. Optionally restricts to a + /// block range via `block_start` and `block_end`. Returns `None` when no matching note + /// follows the cursor. + /// + /// Build the cursor for the next call from the returned record with + /// [`InputNoteCursor::from_record`]. /// /// # Ordering /// - /// Notes are sorted by their per-account on-chain execution order. - async fn get_input_note_by_offset( + /// Notes are sorted by their per-account on-chain execution order: block number, then + /// per-account transaction order within the block. Notes consumed by the same transaction + /// are ordered deterministically and consistently across calls. + async fn get_input_note_after( &self, filter: NoteFilter, consumer: AccountId, block_start: Option, block_end: Option, - offset: u32, + cursor: Option, ) -> Result, StoreError>; /// Returns the nullifiers of all unspent input notes. @@ -478,7 +524,7 @@ pub trait Store: Send + Sync { /// - Updating the corresponding tracked input/output notes. Consumed notes carry consumption /// metadata — `consumed_block_height`, `consumed_tx_order`, and `consumer_account_id` — in /// their note state. Implementations must persist these fields so that ordered queries (see - /// [`Store::get_input_note_by_offset`]) work correctly. + /// [`Store::get_input_note_after`]) work correctly. /// - Removing note tags that are no longer relevant. /// - Updating transactions in the store, marking as `committed` or `discarded`. /// - In turn, validating private account's state transitions. If a private account's @@ -776,10 +822,9 @@ impl TransactionFilter { }, TransactionFilter::ExpiredBefore(block_num) => { format!( - "{QUERY} WHERE tx.block_num < {} AND tx.status_variant != {} AND tx.status_variant != {}", + "{QUERY} WHERE tx.block_num < {} AND tx.status_variant = {}", block_num.as_u32(), - TransactionStatusVariant::Discarded as u8, - TransactionStatusVariant::Committed as u8 + TransactionStatusVariant::Pending as u8, ) }, } diff --git a/crates/sqlite-store/src/chain_data.rs b/crates/sqlite-store/src/chain_data.rs index c61ac6d5f4..dbca526cfb 100644 --- a/crates/sqlite-store/src/chain_data.rs +++ b/crates/sqlite-store/src/chain_data.rs @@ -65,7 +65,9 @@ impl SqliteStore { pub(crate) fn get_tracked_block_headers( conn: &mut Connection, ) -> Result, StoreError> { - const QUERY: &str = "SELECT block_num, header, has_client_notes FROM block_headers WHERE has_client_notes=true"; + // `idx_block_headers_has_notes` is declared `WHERE has_client_notes = 1`, and SQLite + // matches a partial index only when the predicate is spelled the same way. + const QUERY: &str = "SELECT block_num, header, has_client_notes FROM block_headers WHERE has_client_notes=1"; conn.prepare(QUERY) .into_store_error()? .query_map(params![], parse_block_headers_columns) @@ -81,7 +83,7 @@ impl SqliteStore { pub(crate) fn get_tracked_block_header_numbers( conn: &mut Connection, ) -> Result, StoreError> { - const QUERY: &str = "SELECT block_num FROM block_headers WHERE has_client_notes=true"; + const QUERY: &str = "SELECT block_num FROM block_headers WHERE has_client_notes=1"; conn.prepare(QUERY) .into_store_error()? .query_map(params![], |row| row.get::<_, u32>(0)) @@ -386,7 +388,7 @@ pub(crate) fn set_block_header_has_client_notes( const QUERY: &str = "\ UPDATE block_headers SET has_client_notes=? - WHERE block_num=? AND has_client_notes=FALSE;"; + WHERE block_num=? AND has_client_notes=0;"; tx.execute(QUERY, params![has_client_notes, block_num]).into_store_error()?; Ok(()) } diff --git a/crates/sqlite-store/src/lib.rs b/crates/sqlite-store/src/lib.rs index 2d6d22ad24..099471b73f 100644 --- a/crates/sqlite-store/src/lib.rs +++ b/crates/sqlite-store/src/lib.rs @@ -41,6 +41,7 @@ use miden_client::store::{ AccountStorageFilter, BlockRelevance, ClientAccountType, + InputNoteCursor, InputNoteRecord, NoteFilter, OutputNoteRecord, @@ -235,22 +236,22 @@ impl Store for SqliteStore { .await } - async fn get_input_note_by_offset( + async fn get_input_note_after( &self, filter: NoteFilter, consumer: AccountId, block_start: Option, block_end: Option, - offset: u32, + cursor: Option, ) -> Result, StoreError> { self.interact_with_connection(move |conn| { - SqliteStore::get_input_note_by_offset( + SqliteStore::get_input_note_after( conn, &filter, consumer, block_start, block_end, - offset, + cursor, ) }) .await diff --git a/crates/sqlite-store/src/note/filters.rs b/crates/sqlite-store/src/note/filters.rs index 51d520458a..c93068d69e 100644 --- a/crates/sqlite-store/src/note/filters.rs +++ b/crates/sqlite-store/src/note/filters.rs @@ -5,11 +5,16 @@ use std::rc::Rc; use miden_client::account::AccountId; use miden_client::note::BlockNumber; -use miden_client::store::{InputNoteState, NoteFilter, OutputNoteState}; +use miden_client::store::{InputNoteCursor, InputNoteState, NoteFilter, OutputNoteState}; use miden_client::utils::Serializable; -use rusqlite::types::Value; +use rusqlite::types::{ToSqlOutput, Value}; -type NoteQueryParams = Vec>>; +type NoteQueryParams = Vec>; + +/// Wraps a value list as an `rarray` pointer parameter. +fn array_param(values: Vec) -> ToSqlOutput<'static> { + ToSqlOutput::Array(Rc::new(values)) +} /// Returns the output notes query for a specific `NoteFilter` pub(super) fn note_filter_to_query_output_notes(filter: &NoteFilter) -> (String, NoteQueryParams) { @@ -55,7 +60,7 @@ pub(super) fn note_filter_output_notes_condition(filter: &NoteFilter) -> (String }, NoteFilter::Unique(note_id) => { let note_ids_list = vec![Value::Blob(note_id.as_word().to_bytes())]; - params.push(Rc::new(note_ids_list)); + params.push(array_param(note_ids_list)); "note.note_id IN rarray(?)".to_string() }, NoteFilter::List(note_ids) => { @@ -64,7 +69,7 @@ pub(super) fn note_filter_output_notes_condition(filter: &NoteFilter) -> (String .map(|note_id| Value::Blob(note_id.as_word().to_bytes())) .collect::>(); - params.push(Rc::new(note_ids_list)); + params.push(array_param(note_ids_list)); "note.note_id IN rarray(?)".to_string() }, NoteFilter::DetailsCommitments(commitments) => { @@ -73,7 +78,7 @@ pub(super) fn note_filter_output_notes_condition(filter: &NoteFilter) -> (String .map(|commitment| Value::Blob(commitment.to_bytes())) .collect::>(); - params.push(Rc::new(commitments_list)); + params.push(array_param(commitments_list)); "note.details_commitment IN rarray(?)".to_string() }, NoteFilter::Nullifiers(nullifiers) => { @@ -82,7 +87,7 @@ pub(super) fn note_filter_output_notes_condition(filter: &NoteFilter) -> (String .map(|nullifier| Value::Blob(nullifier.to_bytes())) .collect::>(); - params.push(Rc::new(nullifiers_list)); + params.push(array_param(nullifiers_list)); "note.nullifier IN rarray(?)".to_string() }, NoteFilter::Unspent => { @@ -114,6 +119,16 @@ const INPUT_NOTES_BASE_QUERY: &str = "SELECT LEFT OUTER JOIN notes_scripts AS script ON note.script_root = script.script_root"; +/// State discriminants of the input notes that haven't been nullified yet. Invalid notes are left +/// out because they can't be consumed. +pub(super) const UNSPENT_INPUT_NOTE_STATES: [u8; 5] = [ + InputNoteState::STATE_EXPECTED, + InputNoteState::STATE_UNVERIFIED, + InputNoteState::STATE_COMMITTED, + InputNoteState::STATE_PROCESSING_AUTHENTICATED, + InputNoteState::STATE_PROCESSING_UNAUTHENTICATED, +]; + pub(super) fn note_filter_to_query_input_notes(filter: &NoteFilter) -> (String, NoteQueryParams) { let (condition, params) = note_filter_input_notes_condition(filter); let query = if matches!(filter, NoteFilter::Consumed) { @@ -121,7 +136,7 @@ pub(super) fn note_filter_to_query_input_notes(filter: &NoteFilter) -> (String, "{INPUT_NOTES_BASE_QUERY} WHERE {condition} \ ORDER BY note.consumed_block_height ASC, \ note.consumed_tx_order IS NULL, note.consumed_tx_order ASC, \ - note.note_id ASC" + note.details_commitment ASC" ) } else { format!("{INPUT_NOTES_BASE_QUERY} WHERE {condition}") @@ -130,33 +145,59 @@ pub(super) fn note_filter_to_query_input_notes(filter: &NoteFilter) -> (String, (query, params) } -/// Returns a query that fetches a single input note at the given offset from the filtered set, -/// restricted to a consumer account and optionally to a block range. -pub(super) fn note_filter_to_query_input_note_by_offset( +/// Returns a query that fetches the input note following `cursor` in the filtered set, restricted +/// to a consumer account and optionally to a block range. +pub(super) fn note_filter_to_query_input_note_after( filter: &NoteFilter, consumer: AccountId, block_start: Option, block_end: Option, - offset: u32, + cursor: Option, ) -> (String, NoteQueryParams) { - use core::fmt::Write; let (mut condition, mut params) = note_filter_input_notes_condition(filter); - params.push(Rc::new(vec![Value::Blob(consumer.to_bytes())])); - condition.push_str(" AND note.consumer_account_id IN rarray(?)"); + // `consumer_account_id` is the first column of `idx_input_notes_consumption`. The equality + // avoids a full sort for the ORDER BY. + params.push(ToSqlOutput::from(consumer.to_bytes())); + condition.push_str(" AND note.consumer_account_id = ?"); condition.push_str(" AND note.consumed_tx_order IS NOT NULL"); - if let Some(start) = block_start { - let _ = write!(condition, " AND note.consumed_block_height >= {}", start.as_u32()); + // A cursor at or after `block_start` is the tighter lower bound, and emitting both makes + // SQLite abandon the row-value seek over `idx_input_notes_consumption`. A cursor before + // `block_start` excludes nothing that `block_start` does not, so it is dropped. + let cursor = cursor + .filter(|cursor| block_start.is_none_or(|start| cursor.consumed_block_height() >= start)); + + match cursor { + Some(cursor) => { + condition.push_str( + " AND (note.consumed_block_height, note.consumed_tx_order, \ + note.details_commitment) > (?, ?, ?)", + ); + params.push(ToSqlOutput::from(cursor.consumed_block_height().as_u32())); + params.push(ToSqlOutput::from(cursor.consumed_tx_order())); + params.push(ToSqlOutput::from(cursor.details_commitment().to_bytes())); + }, + None => { + if let Some(start) = block_start { + condition.push_str(" AND note.consumed_block_height >= ?"); + params.push(ToSqlOutput::from(start.as_u32())); + } + }, } + if let Some(end) = block_end { - let _ = write!(condition, " AND note.consumed_block_height <= {}", end.as_u32()); + condition.push_str(" AND note.consumed_block_height <= ?"); + params.push(ToSqlOutput::from(end.as_u32())); } + // `details_commitment` is the primary key of the `WITHOUT ROWID` table, so it trails every + // index on it. Ordering by it makes the order total and keeps the seek index-served. let query = format!( "{INPUT_NOTES_BASE_QUERY} WHERE {condition} \ - ORDER BY note.consumed_block_height ASC, note.consumed_tx_order ASC, note.note_id ASC \ - LIMIT 1 OFFSET {offset}" + ORDER BY note.consumed_block_height ASC, note.consumed_tx_order ASC, \ + note.details_commitment ASC \ + LIMIT 1" ); (query, params) @@ -190,7 +231,7 @@ pub(super) fn note_filter_input_notes_condition(filter: &NoteFilter) -> (String, }, NoteFilter::Unique(note_id) => { let note_ids_list = vec![Value::Blob(note_id.as_word().to_bytes())]; - params.push(Rc::new(note_ids_list)); + params.push(array_param(note_ids_list)); "(note.note_id IN rarray(?))".to_string() }, NoteFilter::List(note_ids) => { @@ -199,7 +240,7 @@ pub(super) fn note_filter_input_notes_condition(filter: &NoteFilter) -> (String, .map(|note_id| Value::Blob(note_id.as_word().to_bytes())) .collect::>(); - params.push(Rc::new(note_ids_list)); + params.push(array_param(note_ids_list)); "(note.note_id IN rarray(?))".to_string() }, NoteFilter::DetailsCommitments(commitments) => { @@ -208,7 +249,7 @@ pub(super) fn note_filter_input_notes_condition(filter: &NoteFilter) -> (String, .map(|commitment| Value::Blob(commitment.to_bytes())) .collect::>(); - params.push(Rc::new(commitments_list)); + params.push(array_param(commitments_list)); "(note.details_commitment IN rarray(?))".to_string() }, NoteFilter::Nullifiers(nullifiers) => { @@ -217,7 +258,7 @@ pub(super) fn note_filter_input_notes_condition(filter: &NoteFilter) -> (String, .map(|nullifier| Value::Blob(nullifier.to_bytes())) .collect::>(); - params.push(Rc::new(nullifiers_list)); + params.push(array_param(nullifiers_list)); "(note.nullifier IN rarray(?))".to_string() }, NoteFilter::ScriptRoots(script_roots) => { @@ -226,21 +267,15 @@ pub(super) fn note_filter_input_notes_condition(filter: &NoteFilter) -> (String, .map(|script_root| Value::Blob(script_root.to_bytes())) .collect::>(); - params.push(Rc::new(script_roots_list)); + params.push(array_param(script_roots_list)); "(note.script_root IN rarray(?))".to_string() }, NoteFilter::Unverified => { format!("(state_discriminant = {})", InputNoteState::STATE_UNVERIFIED) }, NoteFilter::Unspent => { - format!( - "(state_discriminant in ({}, {}, {}, {}, {}))", - InputNoteState::STATE_EXPECTED, - InputNoteState::STATE_PROCESSING_AUTHENTICATED, - InputNoteState::STATE_PROCESSING_UNAUTHENTICATED, - InputNoteState::STATE_UNVERIFIED, - InputNoteState::STATE_COMMITTED - ) + let states = UNSPENT_INPUT_NOTE_STATES.map(|state| state.to_string()).join(", "); + format!("(state_discriminant in ({states}))") }, }; diff --git a/crates/sqlite-store/src/note/mod.rs b/crates/sqlite-store/src/note/mod.rs index c12adf5c17..5561e77812 100644 --- a/crates/sqlite-store/src/note/mod.rs +++ b/crates/sqlite-store/src/note/mod.rs @@ -1,7 +1,6 @@ #![allow(clippy::items_after_statements)] use std::collections::BTreeMap; -use std::rc::Rc; use std::string::ToString; use std::vec::Vec; @@ -20,6 +19,7 @@ use miden_client::note::{ Nullifier, }; use miden_client::store::{ + InputNoteCursor, InputNoteRecord, InputNoteState, NoteFilter, @@ -34,7 +34,11 @@ use rusqlite::{Connection, Transaction, params, params_from_iter}; use super::SqliteStore; use crate::chain_data::set_block_header_has_client_notes; -use crate::note::filters::{note_filter_to_query_input_notes, note_filter_to_query_output_notes}; +use crate::note::filters::{ + note_filter_input_notes_condition, + note_filter_to_query_input_notes, + note_filter_to_query_output_notes, +}; use crate::sql_error::SqlResultExt; use crate::{insert_sql, subst}; @@ -44,11 +48,22 @@ mod filters; // ================================================================================================ // SQLite limits statements to 999 parameters. Each batch size is chosen to stay under that -// limit: input notes: 13 columns × 50 = 650, output notes: 8 × 80 = 640, scripts: 2 × 200 = 400. +// limit: input notes: 14 columns × 50 = 700, output notes: 10 × 80 = 800, scripts: 2 × 200 = 400. const INPUT_NOTE_BATCH_SIZE: usize = 50; const OUTPUT_NOTE_BATCH_SIZE: usize = 80; const SCRIPT_BATCH_SIZE: usize = 200; +// NOTE SCRIPT UPSERT +// ================================================================================================ + +// `input_notes.script_root` references `notes_scripts.script_root`, so replacing a script row +// deletes the parent and forces a foreign key check against every referencing note. Updating the +// row in place keeps the parent alive, so no check runs at all. +const UPSERT_NOTE_SCRIPT_QUERY: &str = "INSERT INTO `notes_scripts` \ + (`script_root`, `serialized_note_script`) VALUES (?, ?) \ + ON CONFLICT(`script_root`) DO UPDATE SET \ + `serialized_note_script` = excluded.`serialized_note_script`"; + #[cfg(test)] mod tests; @@ -168,25 +183,25 @@ impl SqliteStore { Ok(notes) } - /// Retrieves a single input note at the given offset from the filtered set, restricted to a - /// consumer account and optionally to a block range. - pub(crate) fn get_input_note_by_offset( + /// Retrieves the input note following `cursor` in the filtered set, restricted to a consumer + /// account and optionally to a block range. + pub(crate) fn get_input_note_after( conn: &mut Connection, filter: &NoteFilter, consumer: AccountId, block_start: Option, block_end: Option, - offset: u32, + cursor: Option, ) -> Result, StoreError> { - let (query, params) = filters::note_filter_to_query_input_note_by_offset( + let (query, params) = filters::note_filter_to_query_input_note_after( filter, consumer, block_start, block_end, - offset, + cursor, ); let note = conn - .prepare(&query) + .prepare_cached(&query) .into_store_error()? .query_map(params_from_iter(params), parse_input_note_columns) .expect("no binding parameters used in query") @@ -222,16 +237,14 @@ impl SqliteStore { pub(crate) fn get_unspent_input_note_nullifiers( conn: &mut Connection, ) -> Result, StoreError> { - const QUERY: &str = - "SELECT nullifier FROM input_notes WHERE state_discriminant NOT IN rarray(?)"; - let unspent_filters = Rc::new(vec![ - Value::from(InputNoteState::STATE_CONSUMED_AUTHENTICATED_LOCAL), - Value::from(InputNoteState::STATE_CONSUMED_UNAUTHENTICATED_LOCAL), - Value::from(InputNoteState::STATE_CONSUMED_EXTERNAL), - ]); - conn.prepare(QUERY) + let (unspent_condition, _) = note_filter_input_notes_condition(&NoteFilter::Unspent); + let query = format!( + "SELECT nullifier FROM input_notes \ + WHERE {unspent_condition} AND nullifier IS NOT NULL" + ); + conn.prepare(&query) .into_store_error()? - .query_map([unspent_filters], |row| row.get(0)) + .query_map([], |row| row.get(0)) .expect("no binding parameters used in query") .map(|result| { result @@ -302,9 +315,7 @@ pub(super) fn upsert_input_note_tx( consumer_account_id, } = serialize_input_note(note); - const SCRIPT_QUERY: &str = - insert_sql!(notes_scripts { script_root, serialized_note_script } | REPLACE); - tx.prepare_cached(SCRIPT_QUERY) + tx.prepare_cached(UPSERT_NOTE_SCRIPT_QUERY) .into_store_error()? .execute(params![script_root, script]) .into_store_error()?; @@ -610,7 +621,7 @@ pub(crate) fn apply_note_updates_tx( Ok(()) } -/// Batch-insert note scripts using multi-row INSERT OR REPLACE. +/// Batch-upsert note scripts using a multi-row insert. /// Multi-row inserts reduce per-statement overhead and show faster insertion times than /// individual inserts. fn batch_upsert_scripts( @@ -625,8 +636,10 @@ fn batch_upsert_scripts( for chunk in entries.chunks(SCRIPT_BATCH_SIZE) { let placeholders = vec!["(?, ?)"; chunk.len()].join(", "); let query = format!( - "INSERT OR REPLACE INTO `notes_scripts` (`script_root`, `serialized_note_script`) \ - VALUES {placeholders}" + "INSERT INTO `notes_scripts` (`script_root`, `serialized_note_script`) \ + VALUES {placeholders} \ + ON CONFLICT(`script_root`) DO UPDATE SET \ + `serialized_note_script` = excluded.`serialized_note_script`" ); let mut param_values: Vec = Vec::with_capacity(chunk.len() * 2); for (root, script) in chunk { @@ -792,14 +805,12 @@ fn batch_update_output_note_states( } /// Inserts the provided note script into the database, if the script already exists, it will be -/// replaced. +/// updated. pub(super) fn upsert_note_script_tx( tx: &Transaction<'_>, note_script: &NoteScript, ) -> Result<(), StoreError> { - const QUERY: &str = - insert_sql!(notes_scripts { script_root, serialized_note_script } | REPLACE); - tx.prepare_cached(QUERY) + tx.prepare_cached(UPSERT_NOTE_SCRIPT_QUERY) .into_store_error()? .execute(params![note_script.root().to_bytes(), note_script.to_bytes()]) .into_store_error()?; diff --git a/crates/sqlite-store/src/note/tests.rs b/crates/sqlite-store/src/note/tests.rs index fe5c223ae3..95b60b079a 100644 --- a/crates/sqlite-store/src/note/tests.rs +++ b/crates/sqlite-store/src/note/tests.rs @@ -18,13 +18,22 @@ use miden_client::store::input_note_states::{ ExpectedNoteState, NoteSubmissionData, }; -use miden_client::store::{InputNoteRecord, NoteFilter, OutputNoteRecord, OutputNoteState, Store}; +use miden_client::store::{ + InputNoteCursor, + InputNoteRecord, + InputNoteState, + NoteFilter, + OutputNoteRecord, + OutputNoteState, + Store, +}; use miden_client::sync::{ AccountUpdates, PartialBlockchainUpdates, StateSyncUpdate, TransactionUpdateTracker, }; +use miden_client::utils::{Deserializable, DeserializationError, Serializable}; use miden_client::{Felt, ZERO}; use miden_protocol::Word; use miden_protocol::account::AccountId; @@ -37,16 +46,26 @@ use miden_protocol::testing::account_id::{ use miden_protocol::transaction::TransactionId; use miden_standards::note::StandardNote; +use crate::note::filters::UNSPENT_INPUT_NOTE_STATES; use crate::tests::create_test_store; // HELPERS // ================================================================================================ -/// Helper to create a consumed-external input note with an optional consumer account. +/// Helper to build the metadata of a note sent by the given account. +fn create_note_metadata(sender: AccountId, index: u32) -> NoteMetadata { + let partial_metadata = + PartialNoteMetadata::new(sender, NoteType::Public).with_tag(NoteTag::from(index)); + NoteMetadata::new(partial_metadata, &NoteAttachments::empty()) +} + +/// Helper to create a consumed-external input note with an optional consumer account. A note +/// without metadata has no nullifier, so its column is NULL. fn create_consumed_external_input_note( index: u32, block_height: u32, consumer_account: Option, + metadata: Option, ) -> InputNoteRecord { let serial_number: Word = [Felt::new_unchecked(u64::from(index) + 2000), ZERO, ZERO, ZERO].into(); @@ -62,7 +81,7 @@ fn create_consumed_external_input_note( nullifier_block_height: BlockNumber::from(block_height), consumer_account, consumed_tx_order: None, - metadata: None, + metadata, }; InputNoteRecord::new(details, NoteAttachments::empty(), Some(0), state.into()) @@ -90,6 +109,30 @@ fn create_expected_input_note_with_script(index: u32, script: NoteScript) -> Inp InputNoteRecord::new(details, NoteAttachments::empty(), Some(0), state.into()) } +/// Helper to create an expected (non-consumed) input note that carries metadata, so it has a +/// known nullifier. +fn create_expected_input_note_with_metadata(index: u32) -> InputNoteRecord { + let serial_number: Word = + [Felt::new_unchecked(u64::from(index) + 9000), ZERO, ZERO, ZERO].into(); + let assets = NoteAssets::new(vec![]).unwrap(); + let recipient = NoteRecipient::new( + serial_number, + StandardNote::SWAP.script(), + NoteStorage::new(vec![]).unwrap(), + ); + let details = NoteDetails::new(assets, recipient); + + let sender = AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE).unwrap(); + + let state = ExpectedNoteState { + metadata: Some(create_note_metadata(sender, index)), + after_block_num: BlockNumber::from(0u32), + tag: None, + }; + + InputNoteRecord::new(details, NoteAttachments::empty(), Some(0), state.into()) +} + /// Helper to create an expected output note with a specific script. fn create_expected_output_note_with_script(index: u32, script: NoteScript) -> OutputNoteRecord { let serial_number: Word = @@ -97,14 +140,10 @@ fn create_expected_output_note_with_script(index: u32, script: NoteScript) -> Ou let recipient = NoteRecipient::new(serial_number, script, NoteStorage::new(vec![]).unwrap()); let sender = AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE).unwrap(); - let partial_metadata = - PartialNoteMetadata::new(sender, NoteType::Public).with_tag(NoteTag::from(index)); - let metadata = NoteMetadata::new(partial_metadata, &NoteAttachments::empty()); - OutputNoteRecord::new( recipient.digest(), NoteAssets::new(vec![]).unwrap(), - metadata, + create_note_metadata(sender, index), OutputNoteState::ExpectedFull { recipient }, BlockNumber::from(0u32), NoteAttachments::empty(), @@ -128,12 +167,8 @@ fn create_consumed_input_note_with_consumer( ); let details = NoteDetails::new(assets, recipient); - let partial_metadata = - PartialNoteMetadata::new(consumer, NoteType::Public).with_tag(NoteTag::from(index)); - let metadata = NoteMetadata::new(partial_metadata, &NoteAttachments::empty()); - let state = ConsumedUnauthenticatedLocalNoteState { - metadata, + metadata: create_note_metadata(consumer, index), nullifier_block_height: BlockNumber::from(block_height), submission_data: NoteSubmissionData { submitted_at: Some(0), @@ -146,6 +181,25 @@ fn create_consumed_input_note_with_consumer( InputNoteRecord::new(details, NoteAttachments::empty(), Some(0), state.into()) } +/// Returns the key that the per-account consumption order sorts by: consumption block height, +/// transaction order within that block and, as the tie-break, the details commitment. +fn consumption_key(note: &InputNoteRecord) -> (u32, u32, Vec) { + ( + note.state().consumed_block_height().expect("note is consumed").as_u32(), + note.state().consumed_tx_order().expect("note has a consumption order"), + note.details_commitment().to_bytes(), + ) +} + +/// Drains `reader`, returning the consumption key of every note it yields. +async fn walk(reader: &mut InputNoteReader) -> Vec<(u32, u32, Vec)> { + let mut collected = Vec::new(); + while let Some(note) = reader.next().await.unwrap() { + collected.push(consumption_key(¬e)); + } + collected +} + // INPUT NOTE READER TESTS // ================================================================================================ @@ -335,10 +389,10 @@ async fn input_note_reader_finds_externally_consumed_notes() { let store = create_test_store().await; let consumer = AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE).unwrap(); - let mut tracked_note = create_consumed_external_input_note(0, 1, Some(consumer)); + let mut tracked_note = create_consumed_external_input_note(0, 1, Some(consumer), None); tracked_note.set_consumed_tx_order(Some(0)); - let mut untracked_note = create_consumed_external_input_note(1, 2, None); + let mut untracked_note = create_consumed_external_input_note(1, 2, None, None); untracked_note.set_consumed_tx_order(Some(0)); store @@ -368,6 +422,187 @@ async fn input_note_reader_finds_externally_consumed_notes() { assert_eq!(collected[0].consumer_account(), Some(consumer)); } +#[tokio::test] +async fn input_note_reader_separates_notes_consumed_by_the_same_transaction() { + let store = create_test_store().await; + let consumer = AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE).unwrap(); + + // Externally-consumed notes without metadata have no note id, and all three share a block + // height and tx order, so only the details commitment separates them. + let notes: Vec<_> = (0..3u32) + .map(|index| { + let mut note = create_consumed_external_input_note(index, 1, Some(consumer), None); + note.set_consumed_tx_order(Some(0)); + note + }) + .collect(); + store.upsert_input_notes(¬es).await.unwrap(); + + let store: Arc = Arc::new(store); + let mut reader = InputNoteReader::new(store, consumer); + + let mut collected = Vec::new(); + while let Some(note) = reader.next().await.unwrap() { + collected.push(note.details_commitment()); + } + + let mut expected: Vec<_> = notes.iter().map(InputNoteRecord::details_commitment).collect(); + expected.sort_by_key(Serializable::to_bytes); + + assert_eq!(collected, expected); +} + +#[tokio::test] +async fn input_note_reader_reset_restarts_the_iteration() { + let store = create_test_store().await; + let consumer = AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE).unwrap(); + + let notes: Vec<_> = (0..3u32) + .map(|index| create_consumed_input_note_with_consumer(consumer, index, index, 0)) + .collect(); + store.upsert_input_notes(¬es).await.unwrap(); + + let store: Arc = Arc::new(store); + let mut reader = InputNoteReader::new(store, consumer); + + let mut first_pass = Vec::new(); + while let Some(note) = reader.next().await.unwrap() { + first_pass.push(note.details_commitment()); + } + assert_eq!(first_pass.len(), 3); + + reader.reset(); + + let mut second_pass = Vec::new(); + while let Some(note) = reader.next().await.unwrap() { + second_pass.push(note.details_commitment()); + } + + assert_eq!(first_pass, second_pass); +} + +#[test] +fn input_note_cursor_is_none_for_a_note_that_is_not_consumed() { + assert!(InputNoteCursor::from_record(&create_expected_input_note(0)).is_none()); +} + +#[tokio::test] +async fn input_note_after_ignores_a_cursor_before_the_block_range() { + let store = create_test_store().await; + let consumer = AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE).unwrap(); + + let note_at_1 = create_consumed_input_note_with_consumer(consumer, 0, 1, 0); + // Follows the cursor but falls outside the range, so it must not be returned. + let note_at_3 = create_consumed_input_note_with_consumer(consumer, 1, 3, 0); + let note_at_5 = create_consumed_input_note_with_consumer(consumer, 2, 5, 0); + store + .upsert_input_notes(&[note_at_1.clone(), note_at_3, note_at_5.clone()]) + .await + .unwrap(); + + // A cursor before `block_start` selects nothing that the range does not already exclude, so + // the first note in the range is returned. + let cursor = InputNoteCursor::from_record(¬e_at_1).unwrap(); + let note = store + .get_input_note_after( + NoteFilter::Consumed, + consumer, + Some(BlockNumber::from(5u32)), + None, + Some(cursor), + ) + .await + .unwrap() + .expect("the range holds a note following the cursor"); + + assert_eq!(note.details_commitment(), note_at_5.details_commitment()); +} + +#[tokio::test] +async fn input_note_reader_walks_every_note_of_a_long_history() { + const BLOCKS: u32 = 8; + const TXS_PER_BLOCK: u32 = 5; + + let store = create_test_store().await; + let consumer = AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE).unwrap(); + + let mut notes = Vec::new(); + for block in 1..=BLOCKS { + for tx_order in 0..TXS_PER_BLOCK { + let index = block * TXS_PER_BLOCK + tx_order; + notes.push(create_consumed_input_note_with_consumer(consumer, index, block, tx_order)); + } + } + + // Insert from the last note backwards, so a walk that leaned on insertion order would fail. + let mut inserted = notes.clone(); + inserted.reverse(); + store.upsert_input_notes(&inserted).await.unwrap(); + + let store: Arc = Arc::new(store); + let mut reader = InputNoteReader::new(store, consumer); + + let mut expected: Vec<_> = notes.iter().map(consumption_key).collect(); + expected.sort(); + assert_eq!(expected.len(), usize::try_from(BLOCKS * TXS_PER_BLOCK).unwrap()); + + // Equality against the full expected sequence rules out both a skipped and a repeated note. + assert_eq!(walk(&mut reader).await, expected); +} + +#[tokio::test] +async fn input_note_reader_only_returns_mid_iteration_inserts_after_the_cursor() { + let store = create_test_store().await; + let consumer = AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE).unwrap(); + + let at_2 = create_consumed_input_note_with_consumer(consumer, 60, 2, 0); + store.upsert_input_notes(std::slice::from_ref(&at_2)).await.unwrap(); + + let store: Arc = Arc::new(store); + let mut reader = InputNoteReader::new(store.clone(), consumer); + + let first = reader.next().await.unwrap().expect("the store holds one consumed note"); + assert_eq!(consumption_key(&first), consumption_key(&at_2)); + + // One note lands before the cursor and one after it. + let at_1 = create_consumed_input_note_with_consumer(consumer, 61, 1, 0); + let at_3 = create_consumed_input_note_with_consumer(consumer, 62, 3, 0); + store.upsert_input_notes(&[at_1, at_3.clone()]).await.unwrap(); + + assert_eq!(walk(&mut reader).await, vec![consumption_key(&at_3)]); +} + +#[tokio::test] +async fn input_note_after_keeps_a_cursor_at_the_start_of_the_block_range() { + let store = create_test_store().await; + let consumer = AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE).unwrap(); + + let at_1 = create_consumed_input_note_with_consumer(consumer, 71, 1, 0); + let first_at_3 = create_consumed_input_note_with_consumer(consumer, 72, 3, 0); + let second_at_3 = create_consumed_input_note_with_consumer(consumer, 73, 3, 1); + store + .upsert_input_notes(&[at_1, first_at_3.clone(), second_at_3.clone()]) + .await + .unwrap(); + + // The cursor sits exactly at `block_start`, so it is the tighter bound: dropping it in favour + // of the range would return the note the cursor points at all over again. + let cursor = InputNoteCursor::from_record(&first_at_3).unwrap(); + let note = store + .get_input_note_after( + NoteFilter::Consumed, + consumer, + Some(BlockNumber::from(3u32)), + None, + Some(cursor), + ) + .await + .unwrap() + .expect("the second note of block 3 follows the cursor"); + + assert_eq!(note.details_commitment(), second_at_3.details_commitment()); +} + // ORDERING TESTS (INPUT NOTES) // ================================================================================================ @@ -376,9 +611,9 @@ async fn consumed_input_notes_ordered_by_block_height_then_tx_order() { let store = create_test_store().await; // Create consumed notes at different block heights with tx_order set. - let mut note_block3 = create_consumed_external_input_note(0, 3, None); - let mut note_block1 = create_consumed_external_input_note(1, 1, None); - let mut note_block2 = create_consumed_external_input_note(2, 2, None); + let mut note_block3 = create_consumed_external_input_note(0, 3, None, None); + let mut note_block1 = create_consumed_external_input_note(1, 1, None, None); + let mut note_block2 = create_consumed_external_input_note(2, 2, None, None); note_block3.set_consumed_tx_order(Some(0)); note_block1.set_consumed_tx_order(Some(1)); note_block2.set_consumed_tx_order(Some(0)); @@ -402,9 +637,9 @@ async fn consumed_input_notes_same_block_ordered_by_tx_order() { let store = create_test_store().await; // All notes consumed at the same block height, different tx_order. - let mut note_tx2 = create_consumed_external_input_note(10, 5, None); - let mut note_tx0 = create_consumed_external_input_note(11, 5, None); - let mut note_tx1 = create_consumed_external_input_note(12, 5, None); + let mut note_tx2 = create_consumed_external_input_note(10, 5, None, None); + let mut note_tx0 = create_consumed_external_input_note(11, 5, None, None); + let mut note_tx1 = create_consumed_external_input_note(12, 5, None, None); note_tx2.set_consumed_tx_order(Some(2)); note_tx0.set_consumed_tx_order(Some(0)); note_tx1.set_consumed_tx_order(Some(1)); @@ -426,8 +661,8 @@ async fn consumed_input_notes_null_tx_order_sort_last_within_block() { let store = create_test_store().await; // Two notes at the same block: one with tx_order, one without (external consumption). - let mut note_with_order = create_consumed_external_input_note(20, 5, None); - let note_without_order = create_consumed_external_input_note(21, 5, None); + let mut note_with_order = create_consumed_external_input_note(20, 5, None, None); + let note_without_order = create_consumed_external_input_note(21, 5, None, None); note_with_order.set_consumed_tx_order(Some(0)); store @@ -518,3 +753,124 @@ async fn output_notes_never_match_script_root_filter() { .unwrap(); assert!(notes.is_empty()); } + +// BATCH SCRIPT TESTS +// ================================================================================================ + +#[tokio::test] +async fn state_sync_stores_scripts_of_new_input_notes() { + let store = create_test_store().await; + + // Two notes share the SWAP script, so the batch holds one entry per distinct root rather than + // one per note. The multi-row upsert relies on that dedup: a root repeated inside a single + // VALUES list would make ON CONFLICT DO UPDATE fail at runtime. + let swap_a = create_expected_input_note_with_script(0, StandardNote::SWAP.script()); + let swap_b = create_expected_input_note_with_script(1, StandardNote::SWAP.script()); + let p2id = create_expected_input_note_with_script(2, StandardNote::P2ID.script()); + + let notes = [swap_a, swap_b, p2id]; + + // Applying the same update twice takes the insert branch and then the DO UPDATE branch. + for _ in 0..2 { + let state_sync_update = StateSyncUpdate::from_parts( + BlockNumber::from(0u32), + PartialBlockchainUpdates::default(), + NoteUpdateTracker::for_transaction_updates(notes.clone(), [], []), + TransactionUpdateTracker::default(), + AccountUpdates::default(), + ); + store.apply_state_sync(state_sync_update).await.unwrap(); + + let swap_notes = store + .get_input_notes(NoteFilter::ScriptRoots(vec![StandardNote::SWAP.script().root()])) + .await + .unwrap(); + assert_eq!(swap_notes.len(), 2); + + let p2id_notes = store + .get_input_notes(NoteFilter::ScriptRoots(vec![StandardNote::P2ID.script().root()])) + .await + .unwrap(); + assert_eq!(p2id_notes.len(), 1); + assert_eq!(p2id_notes[0].details().script().root(), StandardNote::P2ID.script().root()); + } +} + +// UNSPENT NULLIFIER TESTS +// ================================================================================================ + +#[tokio::test] +async fn unspent_nullifiers_skip_notes_without_metadata() { + let store = create_test_store().await; + + // An expected note without metadata has no nullifier, so its column is NULL. + let without_metadata = create_expected_input_note(0); + let with_metadata = create_expected_input_note_with_metadata(1); + assert!(without_metadata.nullifier().is_none()); + + store + .upsert_input_notes(&[without_metadata, with_metadata.clone()]) + .await + .unwrap(); + + let nullifiers = store.get_unspent_input_note_nullifiers().await.unwrap(); + assert_eq!(nullifiers, vec![with_metadata.nullifier().unwrap()]); +} + +#[tokio::test] +async fn unspent_nullifiers_exclude_consumed_notes() { + let store = create_test_store().await; + + let consumer = AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE).unwrap(); + let consumed_local = create_consumed_input_note_with_consumer(consumer, 0, 1, 0); + let consumed_external = create_consumed_external_input_note( + 1, + 1, + Some(consumer), + Some(create_note_metadata(consumer, 1)), + ); + let unspent = create_expected_input_note_with_metadata(2); + + // Both consumed notes carry a nullifier, so only the state filter can exclude them. + assert!(consumed_local.nullifier().is_some()); + assert!(consumed_external.nullifier().is_some()); + + store + .upsert_input_notes(&[consumed_local, consumed_external, unspent.clone()]) + .await + .unwrap(); + + let nullifiers = store.get_unspent_input_note_nullifiers().await.unwrap(); + assert_eq!(nullifiers, vec![unspent.nullifier().unwrap()]); +} + +#[test] +fn unspent_states_classify_every_note_state() { + // Invalid notes sit here because they can't be consumed, so they are not offered as unspent + // either. + const SPENT_OR_UNCONSUMABLE: [u8; 4] = [ + InputNoteState::STATE_INVALID, + InputNoteState::STATE_CONSUMED_AUTHENTICATED_LOCAL, + InputNoteState::STATE_CONSUMED_UNAUTHENTICATED_LOCAL, + InputNoteState::STATE_CONSUMED_EXTERNAL, + ]; + + for discriminant in 0..=u8::MAX { + // The deserializer decides which bytes are real discriminants: an unused one hits the + // catch-all arm and returns `InvalidValue`, while a real one gets past the discriminant + // match and fails later on the payload a one-byte input doesn't carry. A new state whose + // payload also reports `InvalidValue` would be skipped here instead of checked. + if matches!( + InputNoteState::read_from_bytes(&[discriminant]), + Err(DeserializationError::InvalidValue(_)) + ) { + continue; + } + + assert!( + UNSPENT_INPUT_NOTE_STATES.contains(&discriminant) + != SPENT_OR_UNCONSUMABLE.contains(&discriminant), + "note state {discriminant} is in neither list or in both" + ); + } +} diff --git a/crates/sqlite-store/src/store.sql b/crates/sqlite-store/src/store.sql index 207d766f46..175075e5a1 100644 --- a/crates/sqlite-store/src/store.sql +++ b/crates/sqlite-store/src/store.sql @@ -30,6 +30,9 @@ CREATE TABLE latest_account_headers ( PRIMARY KEY (id), FOREIGN KEY (code_commitment) REFERENCES account_code(commitment) ); +-- SQLite does not index foreign key child columns automatically. Without this, account code garbage +-- collection scans the whole table once per candidate commitment. +CREATE INDEX idx_latest_account_headers_code_commitment ON latest_account_headers(code_commitment); -- Historical account headers: stores old headers that were replaced by newer states. -- Each row represents a previous account state that was superseded at replaced_at_nonce. @@ -49,6 +52,7 @@ CREATE TABLE historical_account_headers ( CONSTRAINT check_seed_nonzero CHECK (NOT (nonce = 0 AND account_seed IS NULL)) ); CREATE INDEX idx_historical_account_headers_id_replaced_at ON historical_account_headers(id, replaced_at_nonce DESC); +CREATE INDEX idx_historical_account_headers_code_commitment ON historical_account_headers(code_commitment); -- ── Account storage (latest + historical) ──────────────────────────────── @@ -119,6 +123,7 @@ CREATE TABLE foreign_account_code( PRIMARY KEY (account_id), FOREIGN KEY (code_commitment) REFERENCES account_code(commitment) ); +CREATE INDEX idx_foreign_account_code_code_commitment ON foreign_account_code(code_commitment); -- ── Transactions ───────────────────────────────────────────────────────── @@ -132,7 +137,7 @@ CREATE TABLE transactions ( FOREIGN KEY (script_root) REFERENCES transaction_scripts(script_root), PRIMARY KEY (id) ) WITHOUT ROWID; -CREATE INDEX idx_transactions_uncommitted ON transactions(status_variant); +CREATE INDEX idx_transactions_pending_block_num ON transactions(block_num) WHERE status_variant = 0; CREATE TABLE transaction_scripts ( @@ -163,10 +168,12 @@ CREATE TABLE input_notes ( PRIMARY KEY (details_commitment), FOREIGN KEY (script_root) REFERENCES notes_scripts(script_root) ) WITHOUT ROWID; -CREATE INDEX idx_input_notes_state ON input_notes(state_discriminant); +-- `nullifier` is the second column so the unspent nullifier query reads this index alone, and +-- `state_discriminant` stays first so the other state filters still match on the prefix. +CREATE INDEX idx_input_notes_state ON input_notes(state_discriminant, nullifier); CREATE INDEX idx_input_notes_nullifier ON input_notes(nullifier); CREATE INDEX idx_input_notes_note_id ON input_notes(note_id); -CREATE INDEX idx_input_notes_consumption ON input_notes(consumed_block_height, consumed_tx_order); +CREATE INDEX idx_input_notes_consumption ON input_notes(consumer_account_id, consumed_block_height, consumed_tx_order); CREATE INDEX idx_input_notes_script_root ON input_notes(script_root); CREATE TABLE output_notes ( diff --git a/crates/sqlite-store/src/transaction.rs b/crates/sqlite-store/src/transaction.rs index 386a326f0a..abf9403566 100644 --- a/crates/sqlite-store/src/transaction.rs +++ b/crates/sqlite-store/src/transaction.rs @@ -316,3 +316,132 @@ fn parse_transaction( status: TransactionStatus::read_from_bytes(&status)?, }) } + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use miden_client::store::TransactionFilter; + use miden_client::transaction::{ + DiscardCause, + RawOutputNotes, + TransactionDetails, + TransactionId, + TransactionRecord, + TransactionStatus, + }; + use miden_client::{Felt, Word, ZERO}; + use miden_protocol::account::AccountId; + use miden_protocol::block::BlockNumber; + use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE; + use rusqlite::Connection; + + use super::{SqliteStore, upsert_transaction_record}; + use crate::db_management::utils::apply_migrations; + + /// Builds a script-less transaction record executed against `block_num`. + fn create_transaction_record( + index: u64, + block_num: u32, + status: TransactionStatus, + ) -> TransactionRecord { + let account_id = + AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE).unwrap(); + let details = TransactionDetails { + account_id, + init_account_state: Word::default(), + final_account_state: Word::default(), + input_note_nullifiers: vec![], + output_notes: RawOutputNotes::new(vec![]).unwrap(), + block_num: BlockNumber::from(block_num), + submission_height: BlockNumber::from(block_num), + expiration_block_num: BlockNumber::from(block_num + 1), + creation_timestamp: 0, + }; + + let id = TransactionId::from_raw([Felt::new_unchecked(index), ZERO, ZERO, ZERO].into()); + + TransactionRecord::new(id, details, None, status) + } + + fn create_test_connection(records: &[TransactionRecord]) -> Connection { + let mut conn = Connection::open_in_memory().unwrap(); + apply_migrations(&mut conn).unwrap(); + + let db_tx = conn.transaction().unwrap(); + for record in records { + upsert_transaction_record(&db_tx, record).unwrap(); + } + db_tx.commit().unwrap(); + + conn + } + + /// Returns the `detail` column of every step of the query plan for `query`. + fn query_plan(conn: &Connection, query: &str) -> Vec { + let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {query}")).unwrap(); + stmt.query_map([], |row| row.get::<_, String>(3)) + .unwrap() + .collect::, _>>() + .unwrap() + } + + #[test] + fn expired_before_returns_only_pending_transactions_executed_before_the_bound() { + let expired = create_transaction_record(1, 5, TransactionStatus::Pending); + // The bound is exclusive, so a transaction executed against it is not expired yet. + let at_the_bound = create_transaction_record(2, 10, TransactionStatus::Pending); + let later = create_transaction_record(3, 15, TransactionStatus::Pending); + let committed = create_transaction_record( + 4, + 5, + TransactionStatus::Committed { + block_number: BlockNumber::from(6u32), + commit_timestamp: 0, + }, + ); + let discarded = + create_transaction_record(5, 5, TransactionStatus::Discarded(DiscardCause::Expired)); + + let mut conn = + create_test_connection(&[expired.clone(), at_the_bound, later, committed, discarded]); + + let records = SqliteStore::get_transactions( + &mut conn, + &TransactionFilter::ExpiredBefore(BlockNumber::from(10u32)), + ) + .unwrap(); + + let ids: Vec<_> = records.iter().map(|record| record.id).collect(); + assert_eq!(ids, vec![expired.id]); + } + + #[test] + fn expired_before_is_served_by_the_pending_transactions_index() { + let conn = create_test_connection(&[]); + + let query = TransactionFilter::ExpiredBefore(BlockNumber::from(10u32)).to_query(); + let plan = query_plan(&conn, &query).join("\n"); + + assert!( + plan.contains("SEARCH tx USING INDEX idx_transactions_pending_block_num (block_num