Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
451 changes: 28 additions & 423 deletions bin/ntx-builder/src/actor/mod.rs

Large diffs are not rendered by default.

158 changes: 158 additions & 0 deletions bin/ntx-builder/src/db/eligibility.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
//! When a network note becomes eligible for a transaction attempt.
//!
//! Two things delay a note: its execution hint, which sets the first block at which the note may be
//! consumed, and the exponential backoff applied after a failed attempt. This module computes both,
//! and every write path that touches a note stores the result in `notes.next_eligible_block` so the
//! scheduler can ask for the ready accounts with a single indexed query.

use miden_protocol::block::BlockNumber;
use miden_standards::note::NoteExecutionHint;

/// Block number stored for a note that can never become eligible again.
pub const NEVER_ELIGIBLE: BlockNumber = BlockNumber::MAX;

/// Returns the block at which a note becomes eligible again after `attempts` failed attempts, the
/// latest of which was recorded at `last_attempt`.
pub fn eligible_block_after_failure(
hint: NoteExecutionHint,
attempts: usize,
last_attempt: BlockNumber,
) -> BlockNumber {
hint_floor(hint).max(backoff_ready_block(Some(last_attempt), attempts))
}

/// Checks if the backoff block period has passed.
#[expect(clippy::cast_precision_loss, clippy::cast_sign_loss)]
pub fn has_backoff_passed(
chain_tip: BlockNumber,
last_attempt: Option<BlockNumber>,
attempts: usize,
) -> bool {
if attempts == 0 {
return true;
}
let blocks_passed = last_attempt
.and_then(|last| chain_tip.checked_sub(last.as_u32()))
.unwrap_or_default();

let backoff_threshold = (0.25 * attempts as f64).exp().round() as usize;

blocks_passed.as_usize() > backoff_threshold
}

/// Returns the first block at which a note's backoff period elapses.
#[expect(
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::cast_possible_truncation
)]
pub fn backoff_ready_block(last_attempt: Option<BlockNumber>, attempts: usize) -> BlockNumber {
if attempts == 0 {
return last_attempt.unwrap_or(BlockNumber::GENESIS);
}
let last = last_attempt.unwrap_or(BlockNumber::GENESIS);
let threshold = (0.25 * attempts as f64).exp().round() as u32;
last + threshold + 1
}

/// Returns the earliest block worth re-checking a currently-ineligible note at.
pub fn note_recheck_block(
hint: NoteExecutionHint,
chain_tip: BlockNumber,
last_attempt: Option<BlockNumber>,
attempts: usize,
backoff_ok: bool,
hint_ok: bool,
) -> BlockNumber {
let mut recheck = chain_tip.child();
if !backoff_ok {
recheck = recheck.max(backoff_ready_block(last_attempt, attempts));
}
if !hint_ok {
recheck = recheck.max(hint_floor(hint));
}
recheck
}

/// Returns the first block at which the execution hint permits consumption.
pub fn hint_floor(hint: NoteExecutionHint) -> BlockNumber {
match hint {
NoteExecutionHint::None | NoteExecutionHint::Always | NoteExecutionHint::Unknown(_) => {
BlockNumber::GENESIS
},
NoteExecutionHint::AfterBlock { block_num } => block_num,
NoteExecutionHint::OnBlockSlot { .. } => NEVER_ELIGIBLE,
}
}

#[cfg(test)]
mod tests {
use super::*;

#[rstest::rstest]
#[test]
#[case::all_zero(Some(BlockNumber::GENESIS), BlockNumber::GENESIS, 0, true)]
#[case::no_attempts(None, BlockNumber::GENESIS, 0, true)]
#[case::one_attempt(Some(BlockNumber::GENESIS), BlockNumber::from(2), 1, true)]
#[case::three_attempts(Some(BlockNumber::GENESIS), BlockNumber::from(3), 3, true)]
#[case::ten_attempts(Some(BlockNumber::GENESIS), BlockNumber::from(13), 10, true)]
#[case::twenty_attempts(Some(BlockNumber::GENESIS), BlockNumber::from(149), 20, true)]
#[case::one_attempt_false(Some(BlockNumber::GENESIS), BlockNumber::from(1), 1, false)]
#[case::three_attempts_false(Some(BlockNumber::GENESIS), BlockNumber::from(2), 3, false)]
#[case::ten_attempts_false(Some(BlockNumber::GENESIS), BlockNumber::from(12), 10, false)]
#[case::twenty_attempts_false(Some(BlockNumber::GENESIS), BlockNumber::from(148), 20, false)]
fn backoff_has_passed(
#[case] last_attempt_block_num: Option<BlockNumber>,
#[case] current_block_num: BlockNumber,
#[case] attempt_count: usize,
#[case] backoff_should_have_passed: bool,
) {
assert_eq!(
backoff_should_have_passed,
has_backoff_passed(current_block_num, last_attempt_block_num, attempt_count)
);
}

/// The block stored after a failure is exactly the first block at which the read-time backoff
/// check passes. This is what lets the stored column stand in for the check.
#[rstest::rstest]
#[test]
#[case(1)]
#[case(3)]
#[case(10)]
#[case(20)]
fn stored_block_after_failure_matches_the_backoff_check(#[case] attempts: usize) {
let last_attempt = BlockNumber::from(100);
let stored =
eligible_block_after_failure(NoteExecutionHint::Always, attempts, last_attempt);

assert!(
has_backoff_passed(stored, Some(last_attempt), attempts),
"the stored block must satisfy the backoff check",
);
assert!(
!has_backoff_passed(
stored.parent().expect("the stored block is past genesis"),
Some(last_attempt),
attempts
),
"no earlier block may satisfy it, or the stored value would hide the note",
);
}

/// A hint window that opens later than the backoff wins, and vice versa: the note is eligible
/// only once both allow it.
#[test]
fn stored_block_takes_the_later_of_backoff_and_hint() {
let last_attempt = BlockNumber::from(10);

let hint = NoteExecutionHint::after_block(BlockNumber::from(500));
assert_eq!(eligible_block_after_failure(hint, 1, last_attempt), BlockNumber::from(500),);

let hint = NoteExecutionHint::after_block(BlockNumber::from(1));
assert_eq!(
eligible_block_after_failure(hint, 1, last_attempt),
backoff_ready_block(Some(last_attempt), 1),
);
}
}
3 changes: 2 additions & 1 deletion bin/ntx-builder/src/db/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,11 @@ mod tests {

use super::*;

const EXPECTED_SCHEMA_HASHES: [SchemaHash; 3] = [
const EXPECTED_SCHEMA_HASHES: [SchemaHash; 4] = [
SchemaHash::from_hex("c631b773787903a3dd5ea4df5e7374119b3f02b35bacf14d11eacd8d8500e3d9"),
SchemaHash::from_hex("26b17298444f674b06327ae7289516fe75b59926741b1221ebf36735822d116a"),
SchemaHash::from_hex("6f27c48c71d173366c90752c330bf888332923e68a290ac3acdb5861539120e8"),
SchemaHash::from_hex("638b3991fe1b025ab8820e5cfc902d82d212a6bd3eb26d29af3959de1487ea5c"),
];

#[test]
Expand Down
10 changes: 10 additions & 0 deletions bin/ntx-builder/src/db/migrations/004_note_eligibility.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Materializes note eligibility so the scheduler can ask for the ready accounts.
ALTER TABLE notes ADD COLUMN next_eligible_block BIGINT NOT NULL DEFAULT 0
CHECK (next_eligible_block BETWEEN 0 AND 0xFFFFFFFF);

-- Replaces the account-only partial index with one that also covers the eligibility filter and the
-- attempt budget, so the ready-accounts query is answered from the index.
DROP INDEX idx_notes_account_pending;
CREATE INDEX idx_notes_account_pending
ON notes(account_id, next_eligible_block, attempt_count)
WHERE committed_at IS NULL;
32 changes: 31 additions & 1 deletion bin/ntx-builder/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ use std::path::{Path, PathBuf};

use anyhow::Context;
use miden_node_db::DatabaseError;
#[cfg(test)]
use miden_node_db::SqlTypeConvert;
use miden_node_db::sqlite::{DbReader, DbWriter};
use miden_node_tracing::{info, miden_instrument};
use miden_protocol::Word;
Expand All @@ -25,6 +27,7 @@ use crate::db::queries::NoteStatusRow;
use crate::sponsorship::SponsorshipNote;
use crate::{COMPONENT, NoteError, db};

pub(crate) mod eligibility;
pub(crate) mod queries;

mod migrations;
Expand Down Expand Up @@ -434,6 +437,21 @@ impl NtxDbReader {
.unwrap()
}

/// Reads the stored eligibility block of a note, so tests can assert that the write paths
/// materialize exactly what [`eligibility`] computes.
pub(crate) async fn note_eligibility(&self, note_id: NoteId) -> Option<BlockNumber> {
self.reader
.read("note_eligibility", move |tx| {
let sql = "SELECT next_eligible_block FROM notes WHERE note_id = ?1";
Ok::<Option<i64>, DatabaseError>(
tx.query(sql, &[&note_id], |row| row.get::<i64>(0))?.into_iter().next(),
)
})
.await
.unwrap()
.map(|block| BlockNumber::from_raw_sql(block).unwrap())
}

pub(crate) async fn count_notes(&self) -> i64 {
self.count("SELECT COUNT(*) FROM notes").await
}
Expand Down Expand Up @@ -473,12 +491,24 @@ impl NtxDbWriter {
.await
}

/// Inserts notes as if they were created in the genesis block, so their stored eligibility is
/// driven by their execution hint alone.
pub(crate) async fn insert_network_notes(
&self,
notes: Vec<AccountTargetNetworkNote>,
) -> Result<(), DatabaseError> {
self.insert_network_notes_at(notes, BlockNumber::GENESIS).await
}

pub(crate) async fn insert_network_notes_at(
&self,
notes: Vec<AccountTargetNetworkNote>,
created_at: BlockNumber,
) -> Result<(), DatabaseError> {
self.writer
.write("insert_network_notes", move |tx| queries::insert_network_notes(tx, &notes))
.write("insert_network_notes", move |tx| {
queries::insert_network_notes(tx, &notes, created_at)
})
.await
}

Expand Down
Loading