From b0705d0221f727f00584629b5cd858d88dfbb802 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Sat, 7 Dec 2024 11:51:10 -0600 Subject: [PATCH 01/19] fix: new query --- stackslib/src/core/mempool.rs | 99 +++++++++++++++-------------------- 1 file changed, 43 insertions(+), 56 deletions(-) diff --git a/stackslib/src/core/mempool.rs b/stackslib/src/core/mempool.rs index 46ff54924b..0ae18cd626 100644 --- a/stackslib/src/core/mempool.rs +++ b/stackslib/src/core/mempool.rs @@ -1645,8 +1645,6 @@ impl MemPoolDB { debug!("Mempool walk for {}ms", settings.max_walk_time_ms,); - let tx_consideration_sampler = Uniform::new(0, 100); - let mut rng = rand::thread_rng(); let mut candidate_cache = CandidateCache::new(settings.candidate_retry_cache_size); let mut nonce_cache = NonceCache::new(settings.nonce_cache_size); @@ -1654,30 +1652,43 @@ impl MemPoolDB { // single transaction. This cannot grow to more than `settings.nonce_cache_size` entries. let mut retry_store = HashMap::new(); + // Iterate pending mempool transactions using a heuristic that maximizes miner fee profitability and minimizes CPU time + // wasted on already-mined or not-yet-mineable transactions. This heuristic takes the following steps: + // + // 1. Tries to filter out transactions that have nonces smaller than the origin address' next expected nonce as stated in + // the `nonces` table, if available + // 2. Groups remaining transactions by origin address and ranks them prioritizing those with smaller nonces and higher + // fees + // 3. Sorts all ranked transactions by fee and returns them for evaluation + // + // This logic prevents miners from repeatedly visiting (and then skipping) high fee transactions that would get evaluated + // first based on their `fee_rate` but are otherwise non-mineable because they have very high or invalid nonces. A large + // volume of these transactions would cause considerable slowness when selecting valid transactions to mine. + // + // This query also makes sure transactions that have NULL `fee_rate`s are visited, because they will also get ranked + // according to their nonce and then sub-sorted by their total `tx_fee` to determine which of them gets evaluated first. let sql = " - SELECT txid, origin_nonce, origin_address, sponsor_nonce, sponsor_address, fee_rate - FROM mempool - WHERE fee_rate IS NULL - "; - let mut query_stmt_null = self - .db - .prepare(&sql) - .map_err(|err| Error::SqliteError(err))?; - let mut null_iterator = query_stmt_null - .query(NO_PARAMS) - .map_err(|err| Error::SqliteError(err))?; - - let sql = " + WITH nonce_filtered AS ( + SELECT txid, origin_nonce, origin_address, sponsor_nonce, sponsor_address, fee_rate, tx_fee + FROM mempool + LEFT JOIN nonces ON nonces.address = mempool.origin_address AND origin_nonce >= nonces.nonce + ), + address_nonce_ranked AS ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY origin_address + ORDER BY origin_nonce ASC, fee_rate DESC, tx_fee DESC + ) AS rank + FROM nonce_filtered + ) SELECT txid, origin_nonce, origin_address, sponsor_nonce, sponsor_address, fee_rate - FROM mempool - WHERE fee_rate IS NOT NULL - ORDER BY fee_rate DESC + FROM address_nonce_ranked + ORDER BY rank ASC, fee_rate DESC, tx_fee DESC "; - let mut query_stmt_fee = self + let mut query_stmt = self .db .prepare(&sql) .map_err(|err| Error::SqliteError(err))?; - let mut fee_iterator = query_stmt_fee + let mut tx_iterator = query_stmt .query(NO_PARAMS) .map_err(|err| Error::SqliteError(err))?; @@ -1688,9 +1699,6 @@ impl MemPoolDB { break MempoolIterationStopReason::DeadlineReached; } - let start_with_no_estimate = - tx_consideration_sampler.sample(&mut rng) < settings.consider_no_estimate_tx_prob; - // First, try to read from the retry list let (candidate, update_estimate) = match candidate_cache.next() { Some(tx) => { @@ -1698,36 +1706,16 @@ impl MemPoolDB { (tx, update_estimate) } None => { - // When the retry list is empty, read from the mempool db, - // randomly selecting from either the null fee-rate transactions - // or those with fee-rate estimates. - let opt_tx = if start_with_no_estimate { - null_iterator - .next() - .map_err(|err| Error::SqliteError(err))? - } else { - fee_iterator.next().map_err(|err| Error::SqliteError(err))? - }; - match opt_tx { - Some(row) => (MemPoolTxInfoPartial::from_row(row)?, start_with_no_estimate), + // When the retry list is empty, read from the mempool db + match tx_iterator.next().map_err(|err| Error::SqliteError(err))? { + Some(row) => { + let tx = MemPoolTxInfoPartial::from_row(row)?; + let update_estimate = tx.fee_rate.is_none(); + (tx, update_estimate) + }, None => { - // If the selected iterator is empty, check the other - match if start_with_no_estimate { - fee_iterator.next().map_err(|err| Error::SqliteError(err))? - } else { - null_iterator - .next() - .map_err(|err| Error::SqliteError(err))? - } { - Some(row) => ( - MemPoolTxInfoPartial::from_row(row)?, - !start_with_no_estimate, - ), - None => { - debug!("No more transactions to consider in mempool"); - break MempoolIterationStopReason::NoMoreCandidates; - } - } + debug!("No more transactions to consider in mempool"); + break MempoolIterationStopReason::NoMoreCandidates; } } } @@ -1774,6 +1762,7 @@ impl MemPoolDB { "expected_origin_nonce" => expected_origin_nonce, "expected_sponsor_nonce" => expected_sponsor_nonce, ); + // FIXME: record this fact so we can take it into acct in the next pass // This transaction cannot execute in this pass, just drop it continue; } @@ -1928,10 +1917,8 @@ impl MemPoolDB { // drop these rusqlite statements and queries, since their existence as immutable borrows on the // connection prevents us from beginning a transaction below (which requires a mutable // borrow). - drop(null_iterator); - drop(fee_iterator); - drop(query_stmt_null); - drop(query_stmt_fee); + drop(tx_iterator); + drop(query_stmt); if retry_store.len() > 0 { let tx = self.tx_begin()?; From a0600b63a4bc4b91d74bc73a27c130c95b0b0859 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Sat, 7 Dec 2024 11:52:20 -0600 Subject: [PATCH 02/19] chore: remove dev comment --- stackslib/src/core/mempool.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/stackslib/src/core/mempool.rs b/stackslib/src/core/mempool.rs index 0ae18cd626..5b42ccacc0 100644 --- a/stackslib/src/core/mempool.rs +++ b/stackslib/src/core/mempool.rs @@ -1762,7 +1762,6 @@ impl MemPoolDB { "expected_origin_nonce" => expected_origin_nonce, "expected_sponsor_nonce" => expected_sponsor_nonce, ); - // FIXME: record this fact so we can take it into acct in the next pass // This transaction cannot execute in this pass, just drop it continue; } From 3719188ff2de9f737a6d50e7b809a4a3b6b4b5c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20C=C3=A1rdenas?= Date: Wed, 11 Dec 2024 10:10:48 -0500 Subject: [PATCH 03/19] style: lint fixes --- stackslib/src/core/mempool.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stackslib/src/core/mempool.rs b/stackslib/src/core/mempool.rs index 5b42ccacc0..958e050978 100644 --- a/stackslib/src/core/mempool.rs +++ b/stackslib/src/core/mempool.rs @@ -1712,7 +1712,7 @@ impl MemPoolDB { let tx = MemPoolTxInfoPartial::from_row(row)?; let update_estimate = tx.fee_rate.is_none(); (tx, update_estimate) - }, + } None => { debug!("No more transactions to consider in mempool"); break MempoolIterationStopReason::NoMoreCandidates; From 2cd116f2183e24e28e9928141a3683dd238edcff Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Fri, 13 Dec 2024 15:39:13 -0600 Subject: [PATCH 04/19] fix: add simulated fee rates for null --- stackslib/src/core/mempool.rs | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/stackslib/src/core/mempool.rs b/stackslib/src/core/mempool.rs index 5b42ccacc0..337b112208 100644 --- a/stackslib/src/core/mempool.rs +++ b/stackslib/src/core/mempool.rs @@ -1655,34 +1655,44 @@ impl MemPoolDB { // Iterate pending mempool transactions using a heuristic that maximizes miner fee profitability and minimizes CPU time // wasted on already-mined or not-yet-mineable transactions. This heuristic takes the following steps: // - // 1. Tries to filter out transactions that have nonces smaller than the origin address' next expected nonce as stated in - // the `nonces` table, if available - // 2. Groups remaining transactions by origin address and ranks them prioritizing those with smaller nonces and higher - // fees - // 3. Sorts all ranked transactions by fee and returns them for evaluation + // 1. Filters out transactions that have nonces smaller than the origin address' next expected nonce as stated in the + // `nonces` table, when possible + // 2. Adds a "simulated" fee rate to transactions that don't have it by multiplying the mempool's maximum current fee rate + // by a random number. This helps us mix these transactions with others to guarantee they get processed in a reasonable + // order + // 3. Ranks transactions by prioritizing those with next nonces and higher fees (per origin address) + // 4. Sorts all ranked transactions by fee and returns them for evaluation // // This logic prevents miners from repeatedly visiting (and then skipping) high fee transactions that would get evaluated // first based on their `fee_rate` but are otherwise non-mineable because they have very high or invalid nonces. A large // volume of these transactions would cause considerable slowness when selecting valid transactions to mine. // // This query also makes sure transactions that have NULL `fee_rate`s are visited, because they will also get ranked - // according to their nonce and then sub-sorted by their total `tx_fee` to determine which of them gets evaluated first. + // according to their origin address nonce. let sql = " WITH nonce_filtered AS ( SELECT txid, origin_nonce, origin_address, sponsor_nonce, sponsor_address, fee_rate, tx_fee FROM mempool LEFT JOIN nonces ON nonces.address = mempool.origin_address AND origin_nonce >= nonces.nonce ), + null_compensated AS ( + SELECT *, + CASE + WHEN fee_rate IS NULL THEN (ABS(RANDOM()) % 10000 / 10000.0) * (SELECT MAX(fee_rate) AS max FROM nonce_filtered) + ELSE fee_rate + END AS sort_fee_rate + FROM nonce_filtered + ), address_nonce_ranked AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY origin_address - ORDER BY origin_nonce ASC, fee_rate DESC, tx_fee DESC + ORDER BY origin_nonce ASC, sort_fee_rate DESC ) AS rank - FROM nonce_filtered + FROM null_compensated ) SELECT txid, origin_nonce, origin_address, sponsor_nonce, sponsor_address, fee_rate FROM address_nonce_ranked - ORDER BY rank ASC, fee_rate DESC, tx_fee DESC + ORDER BY rank ASC, sort_fee_rate DESC "; let mut query_stmt = self .db @@ -1712,7 +1722,7 @@ impl MemPoolDB { let tx = MemPoolTxInfoPartial::from_row(row)?; let update_estimate = tx.fee_rate.is_none(); (tx, update_estimate) - }, + } None => { debug!("No more transactions to consider in mempool"); break MempoolIterationStopReason::NoMoreCandidates; From 685924ce42b67bff579931102c1063ba5bd758a3 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Fri, 13 Dec 2024 16:03:05 -0600 Subject: [PATCH 05/19] fix: indexes --- stackslib/src/core/mempool.rs | 42 ++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/stackslib/src/core/mempool.rs b/stackslib/src/core/mempool.rs index 337b112208..69be7335dc 100644 --- a/stackslib/src/core/mempool.rs +++ b/stackslib/src/core/mempool.rs @@ -820,6 +820,20 @@ const MEMPOOL_SCHEMA_7_TIME_ESTIMATES: &'static [&'static str] = &[ "#, ]; +const MEMPOOL_SCHEMA_8_NONCE_SORTING: &'static [&'static str] = &[ + r#" + -- Drop redundant mempool indexes, covered by unique constraints + DROP INDEX IF EXISTS "by_txid"; + DROP INDEX IF EXISTS "by_sponsor"; + DROP INDEX IF EXISTS "by_origin"; + -- Add index to help comparing address nonces against mempool content + CREATE INDEX IF NOT EXISTS by_address_nonce ON nonces(address, nonce); + "#, + r#" + INSERT INTO schema_version (version) VALUES (8) + "#, +]; + const MEMPOOL_INDEXES: &'static [&'static str] = &[ "CREATE INDEX IF NOT EXISTS by_txid ON mempool(txid);", "CREATE INDEX IF NOT EXISTS by_height ON mempool(height);", @@ -1393,6 +1407,16 @@ impl MemPoolDB { Ok(()) } + /// Optimize indexes for mempool visits + #[cfg_attr(test, mutants::skip)] + fn instantiate_schema_8(tx: &DBTx) -> Result<(), db_error> { + for sql_exec in MEMPOOL_SCHEMA_8_NONCE_SORTING { + tx.execute_batch(sql_exec)?; + } + + Ok(()) + } + #[cfg_attr(test, mutants::skip)] pub fn db_path(chainstate_root_path: &str) -> Result { let mut path = PathBuf::from(chainstate_root_path); @@ -1671,24 +1695,20 @@ impl MemPoolDB { // according to their origin address nonce. let sql = " WITH nonce_filtered AS ( - SELECT txid, origin_nonce, origin_address, sponsor_nonce, sponsor_address, fee_rate, tx_fee + SELECT txid, origin_nonce, origin_address, sponsor_nonce, sponsor_address, fee_rate, tx_fee, + CASE + WHEN fee_rate IS NULL THEN (ABS(RANDOM()) % 10000 / 10000.0) * (SELECT MAX(fee_rate) FROM mempool) + ELSE fee_rate + END AS sort_fee_rate FROM mempool - LEFT JOIN nonces ON nonces.address = mempool.origin_address AND origin_nonce >= nonces.nonce - ), - null_compensated AS ( - SELECT *, - CASE - WHEN fee_rate IS NULL THEN (ABS(RANDOM()) % 10000 / 10000.0) * (SELECT MAX(fee_rate) AS max FROM nonce_filtered) - ELSE fee_rate - END AS sort_fee_rate - FROM nonce_filtered + LEFT JOIN nonces ON mempool.origin_address = nonces.address AND mempool.origin_nonce >= nonces.nonce ), address_nonce_ranked AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY origin_address ORDER BY origin_nonce ASC, sort_fee_rate DESC ) AS rank - FROM null_compensated + FROM nonce_filtered ) SELECT txid, origin_nonce, origin_address, sponsor_nonce, sponsor_address, fee_rate FROM address_nonce_ranked From e1dac9d7f5f93ba9f332d098bdf36dbe77802770 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Fri, 13 Dec 2024 16:04:25 -0600 Subject: [PATCH 06/19] fix: remove tx_fee column --- stackslib/src/core/mempool.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stackslib/src/core/mempool.rs b/stackslib/src/core/mempool.rs index 69be7335dc..cdb24f9c77 100644 --- a/stackslib/src/core/mempool.rs +++ b/stackslib/src/core/mempool.rs @@ -1695,7 +1695,7 @@ impl MemPoolDB { // according to their origin address nonce. let sql = " WITH nonce_filtered AS ( - SELECT txid, origin_nonce, origin_address, sponsor_nonce, sponsor_address, fee_rate, tx_fee, + SELECT txid, origin_nonce, origin_address, sponsor_nonce, sponsor_address, fee_rate, CASE WHEN fee_rate IS NULL THEN (ABS(RANDOM()) % 10000 / 10000.0) * (SELECT MAX(fee_rate) FROM mempool) ELSE fee_rate From dd9729c9a98ac598d2477c68953fb7fa269908cf Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Sat, 14 Dec 2024 18:04:47 -0600 Subject: [PATCH 07/19] test: correct tx order --- .../stacks/tests/block_construction.rs | 185 ++++++++++++++++++ stackslib/src/core/mempool.rs | 19 +- 2 files changed, 194 insertions(+), 10 deletions(-) diff --git a/stackslib/src/chainstate/stacks/tests/block_construction.rs b/stackslib/src/chainstate/stacks/tests/block_construction.rs index 7b7720b996..ebc9c25212 100644 --- a/stackslib/src/chainstate/stacks/tests/block_construction.rs +++ b/stackslib/src/chainstate/stacks/tests/block_construction.rs @@ -5087,3 +5087,188 @@ fn paramaterized_mempool_walk_test( }, ); } + +#[test] +/// Test that the mempool walk query ignores old nonces and prefers next possible nonces before higher global fees. +fn mempool_walk_test_nonce_filtered_and_ranked() { + let key_address_pairs: Vec<(Secp256k1PrivateKey, StacksAddress)> = (0..3) + .map(|_user_index| { + let privk = StacksPrivateKey::new(); + let addr = StacksAddress::from_public_keys( + C32_ADDRESS_VERSION_TESTNET_SINGLESIG, + &AddressHashMode::SerializeP2PKH, + 1, + &vec![StacksPublicKey::from_private(&privk)], + ) + .unwrap(); + (privk, addr) + }) + .collect(); + let origin_addresses: Vec = key_address_pairs + .iter() + .map(|(_, b)| b.to_string()) + .collect(); + let address_0 = origin_addresses[0].to_string(); + let address_1 = origin_addresses[1].to_string(); + let address_2 = origin_addresses[2].to_string(); + + let test_name = "mempool_walk_test_nonce_filtered_and_ranked"; + let mut peer_config = TestPeerConfig::new(test_name, 2002, 2003); + + peer_config.initial_balances = vec![]; + for (privk, addr) in &key_address_pairs { + peer_config + .initial_balances + .push((addr.to_account_principal(), 1000000000)); + } + + let recipient = + StacksAddress::from_string("ST1RFD5Q2QPK3E0F08HG9XDX7SSC7CNRS0QR0SGEV").unwrap(); + + let mut chainstate = + instantiate_chainstate_with_balances(false, 0x80000000, &test_name, vec![]); + let chainstate_path = chainstate_path(&test_name); + let mut mempool = MemPoolDB::open_test(false, 0x80000000, &chainstate_path).unwrap(); + let b_1 = make_block( + &mut chainstate, + ConsensusHash([0x1; 20]), + &( + FIRST_BURNCHAIN_CONSENSUS_HASH.clone(), + FIRST_STACKS_BLOCK_HASH.clone(), + ), + 1, + 1, + ); + let b_2 = make_block(&mut chainstate, ConsensusHash([0x2; 20]), &b_1, 2, 2); + + let mut tx_events = Vec::new(); + + // Submit nonces 0 through 9 for each of the 3 senders. + for nonce in 0..10 { + for user_index in 0..3 { + let mut tx = make_user_stacks_transfer( + &key_address_pairs[user_index].0, + nonce as u64, + 200, + &recipient.to_account_principal(), + 1, + ); + + let mut mempool_tx = mempool.tx_begin().unwrap(); + + let origin_address = tx.origin_address(); + let sponsor_address = tx.sponsor_address().unwrap_or(origin_address); + + tx.set_tx_fee(100); + let txid = tx.txid(); + let tx_bytes = tx.serialize_to_vec(); + let tx_fee = tx.get_tx_fee(); + let height = 100; + + MemPoolDB::try_add_tx( + &mut mempool_tx, + &mut chainstate, + &b_1.0, + &b_1.1, + true, + txid, + tx_bytes, + tx_fee, + height, + &origin_address, + nonce.try_into().unwrap(), + &sponsor_address, + nonce.try_into().unwrap(), + None, + ) + .unwrap(); + + // Increase the `fee_rate` as nonce goes up, so we can test that lower nonces get confirmed before higher fee txs. + // Also slightly increase the fee for some addresses so we can check those txs get selected first. + mempool_tx + .execute( + "UPDATE mempool SET fee_rate = ? WHERE txid = ?", + params![Some(123.0 * (nonce + 1 + user_index) as f64), &txid], + ) + .unwrap(); + mempool_tx.commit().unwrap(); + } + } + + // Simulate next possible nonces for the 3 addresses: + // Address 0 => 2 + // Address 1 => 7 + // Address 2 => 9 + let mempool_tx = mempool.tx_begin().unwrap(); + mempool_tx + .execute( + "INSERT INTO nonces (address, nonce) VALUES (?, ?), (?, ?), (?, ?)", + params![address_0, 2, address_1, 7, address_2, 9], + ) + .unwrap(); + mempool_tx.commit().unwrap(); + + // Visit transactions. Keep a record of the order of visited txs so we can compare at the end. + let mut considered_txs = vec![]; + let deadline = get_epoch_time_ms() + 30000; + chainstate.with_read_only_clarity_tx( + &TEST_BURN_STATE_DB, + &StacksBlockHeader::make_index_block_hash(&b_2.0, &b_2.1), + |clarity_conn| { + // When the candidate cache fills, one pass cannot process all transactions + loop { + if mempool + .iterate_candidates::<_, ChainstateError, _>( + clarity_conn, + &mut tx_events, + MemPoolWalkSettings::default(), + |_, available_tx, _| { + considered_txs.push(( + available_tx.tx.metadata.origin_address.to_string(), + available_tx.tx.metadata.origin_nonce, + )); + Ok(Some( + // Generate any success result + TransactionResult::success( + &available_tx.tx.tx, + available_tx.tx.metadata.tx_fee, + StacksTransactionReceipt::from_stx_transfer( + available_tx.tx.tx.clone(), + vec![], + Value::okay(Value::Bool(true)).unwrap(), + ExecutionCost::zero(), + ), + ) + .convert_to_event(), + )) + }, + ) + .unwrap() + .0 + == 0 + { + break; + } + assert!(get_epoch_time_ms() < deadline, "test timed out"); + } + assert_eq!( + considered_txs, + vec![ + (address_0.clone(), 2), + (address_0.clone(), 3), + (address_0.clone(), 4), + (address_0.clone(), 5), + (address_0.clone(), 6), + (address_1.clone(), 7), // Higher fee for address 1 + (address_0.clone(), 7), + (address_1.clone(), 8), + (address_0.clone(), 8), + (address_2.clone(), 9), // Higher fee for address 2 + (address_1.clone(), 9), + (address_0.clone(), 9), + ], + "Mempool should visit transactions in the correct order while ignoring past nonces", + ); + }, + ); +} diff --git a/stackslib/src/core/mempool.rs b/stackslib/src/core/mempool.rs index cdb24f9c77..38322e2245 100644 --- a/stackslib/src/core/mempool.rs +++ b/stackslib/src/core/mempool.rs @@ -525,8 +525,7 @@ pub struct MemPoolWalkSettings { /// milliseconds. This is a soft deadline. pub max_walk_time_ms: u64, /// Probability percentage to consider a transaction which has not received a cost estimate. - /// That is, with x%, when picking the next transaction to include a block, select one that - /// either failed to get a cost estimate or has not been estimated yet. + /// This property is no longer used and will be ignored. pub consider_no_estimate_tx_prob: u8, /// Size of the nonce cache. This avoids MARF look-ups. pub nonce_cache_size: u64, @@ -1689,10 +1688,9 @@ impl MemPoolDB { // // This logic prevents miners from repeatedly visiting (and then skipping) high fee transactions that would get evaluated // first based on their `fee_rate` but are otherwise non-mineable because they have very high or invalid nonces. A large - // volume of these transactions would cause considerable slowness when selecting valid transactions to mine. - // - // This query also makes sure transactions that have NULL `fee_rate`s are visited, because they will also get ranked - // according to their origin address nonce. + // volume of these transactions would cause considerable slowness when selecting valid transactions to mine. This query + // also makes sure transactions that have NULL `fee_rate`s are visited, because they will also get ranked according to + // their origin address nonce. let sql = " WITH nonce_filtered AS ( SELECT txid, origin_nonce, origin_address, sponsor_nonce, sponsor_address, fee_rate, @@ -1704,10 +1702,11 @@ impl MemPoolDB { LEFT JOIN nonces ON mempool.origin_address = nonces.address AND mempool.origin_nonce >= nonces.nonce ), address_nonce_ranked AS ( - SELECT *, ROW_NUMBER() OVER ( - PARTITION BY origin_address - ORDER BY origin_nonce ASC, sort_fee_rate DESC - ) AS rank + SELECT *, + ROW_NUMBER() OVER ( + PARTITION BY origin_address + ORDER BY origin_nonce ASC, sort_fee_rate DESC + ) AS rank FROM nonce_filtered ) SELECT txid, origin_nonce, origin_address, sponsor_nonce, sponsor_address, fee_rate From 7702f67f9cc6097f680bf5c4542a97f5dddfedb6 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Mon, 16 Dec 2024 12:39:08 -0600 Subject: [PATCH 08/19] fix: nonce ordering --- .../chainstate/stacks/tests/block_construction.rs | 14 +++++++------- stackslib/src/core/mempool.rs | 3 ++- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/stackslib/src/chainstate/stacks/tests/block_construction.rs b/stackslib/src/chainstate/stacks/tests/block_construction.rs index ebc9c25212..39000825e4 100644 --- a/stackslib/src/chainstate/stacks/tests/block_construction.rs +++ b/stackslib/src/chainstate/stacks/tests/block_construction.rs @@ -5183,12 +5183,12 @@ fn mempool_walk_test_nonce_filtered_and_ranked() { ) .unwrap(); - // Increase the `fee_rate` as nonce goes up, so we can test that lower nonces get confirmed before higher fee txs. + // Increase the `fee_rate` as nonce goes up, so we can test that next nonces get confirmed before higher fee txs. // Also slightly increase the fee for some addresses so we can check those txs get selected first. mempool_tx .execute( "UPDATE mempool SET fee_rate = ? WHERE txid = ?", - params![Some(123.0 * (nonce + 1 + user_index) as f64), &txid], + params![Some(100.0 * (nonce + 1 + user_index) as f64), &txid], ) .unwrap(); mempool_tx.commit().unwrap(); @@ -5254,18 +5254,18 @@ fn mempool_walk_test_nonce_filtered_and_ranked() { assert_eq!( considered_txs, vec![ + (address_2.clone(), 9), // Highest fee for address 2, and 9 is the next nonce + (address_1.clone(), 7), (address_0.clone(), 2), + (address_1.clone(), 8), (address_0.clone(), 3), + (address_1.clone(), 9), // Highest fee for address 1, but have to confirm nonces 7 and 8 first (address_0.clone(), 4), (address_0.clone(), 5), (address_0.clone(), 6), - (address_1.clone(), 7), // Higher fee for address 1 (address_0.clone(), 7), - (address_1.clone(), 8), (address_0.clone(), 8), - (address_2.clone(), 9), // Higher fee for address 2 - (address_1.clone(), 9), - (address_0.clone(), 9), + (address_0.clone(), 9), // Highest fee for address 0, but have to confirm all other nonces first ], "Mempool should visit transactions in the correct order while ignoring past nonces", ); diff --git a/stackslib/src/core/mempool.rs b/stackslib/src/core/mempool.rs index 38322e2245..0ff1d8c28f 100644 --- a/stackslib/src/core/mempool.rs +++ b/stackslib/src/core/mempool.rs @@ -1699,7 +1699,8 @@ impl MemPoolDB { ELSE fee_rate END AS sort_fee_rate FROM mempool - LEFT JOIN nonces ON mempool.origin_address = nonces.address AND mempool.origin_nonce >= nonces.nonce + LEFT JOIN nonces ON mempool.origin_address = nonces.address + WHERE nonces.address IS NULL OR mempool.origin_nonce >= nonces.nonce ), address_nonce_ranked AS ( SELECT *, From d0d1f8d183ed8d38269c2a41c1a1d7db2a79d092 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Mon, 16 Dec 2024 12:43:02 -0600 Subject: [PATCH 09/19] fix: remove now unneccessary index --- stackslib/src/core/mempool.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/stackslib/src/core/mempool.rs b/stackslib/src/core/mempool.rs index 0ff1d8c28f..8871e3b9d3 100644 --- a/stackslib/src/core/mempool.rs +++ b/stackslib/src/core/mempool.rs @@ -825,8 +825,6 @@ const MEMPOOL_SCHEMA_8_NONCE_SORTING: &'static [&'static str] = &[ DROP INDEX IF EXISTS "by_txid"; DROP INDEX IF EXISTS "by_sponsor"; DROP INDEX IF EXISTS "by_origin"; - -- Add index to help comparing address nonces against mempool content - CREATE INDEX IF NOT EXISTS by_address_nonce ON nonces(address, nonce); "#, r#" INSERT INTO schema_version (version) VALUES (8) From cf6c37b2b0b6bdb6c1f33855612a7fbd3be0c7f3 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Mon, 16 Dec 2024 23:23:15 -0600 Subject: [PATCH 10/19] chore: config strategy --- .../stacks/tests/block_construction.rs | 5 ++++- testnet/stacks-node/src/config.rs | 20 ++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/stackslib/src/chainstate/stacks/tests/block_construction.rs b/stackslib/src/chainstate/stacks/tests/block_construction.rs index 2c16094135..4402dd13e6 100644 --- a/stackslib/src/chainstate/stacks/tests/block_construction.rs +++ b/stackslib/src/chainstate/stacks/tests/block_construction.rs @@ -30,6 +30,7 @@ use clarity::vm::costs::LimitedCostTracker; use clarity::vm::database::ClarityDatabase; use clarity::vm::test_util::TEST_BURN_STATE_DB; use clarity::vm::types::*; +use mempool::MemPoolWalkStrategy; use rand::seq::SliceRandom; use rand::{thread_rng, Rng}; use rusqlite::params; @@ -5209,6 +5210,8 @@ fn mempool_walk_test_nonce_filtered_and_ranked() { mempool_tx.commit().unwrap(); // Visit transactions. Keep a record of the order of visited txs so we can compare at the end. + let mut settings = MemPoolWalkSettings::default(); + settings.strategy = MemPoolWalkStrategy::NextNonceWithHighestFeeRate; let mut considered_txs = vec![]; let deadline = get_epoch_time_ms() + 30000; chainstate.with_read_only_clarity_tx( @@ -5221,7 +5224,7 @@ fn mempool_walk_test_nonce_filtered_and_ranked() { .iterate_candidates::<_, ChainstateError, _>( clarity_conn, &mut tx_events, - MemPoolWalkSettings::default(), + settings, |_, available_tx, _| { considered_txs.push(( available_tx.tx.metadata.origin_address.to_string(), diff --git a/testnet/stacks-node/src/config.rs b/testnet/stacks-node/src/config.rs index 4ad793a4c3..5d6bffdeb9 100644 --- a/testnet/stacks-node/src/config.rs +++ b/testnet/stacks-node/src/config.rs @@ -36,7 +36,7 @@ use stacks::chainstate::stacks::index::marf::MARFOpenOpts; use stacks::chainstate::stacks::index::storage::TrieHashCalculationMode; use stacks::chainstate::stacks::miner::{BlockBuilderSettings, MinerStatus}; use stacks::chainstate::stacks::MAX_BLOCK_LEN; -use stacks::core::mempool::{MemPoolWalkSettings, MemPoolWalkTxTypes}; +use stacks::core::mempool::{MemPoolWalkSettings, MemPoolWalkStrategy, MemPoolWalkTxTypes}; use stacks::core::{ MemPoolDB, StacksEpoch, StacksEpochExtension, StacksEpochId, BITCOIN_TESTNET_FIRST_BLOCK_HEIGHT, BITCOIN_TESTNET_STACKS_25_BURN_HEIGHT, @@ -1060,6 +1060,7 @@ impl Config { BlockBuilderSettings { max_miner_time_ms: miner_config.nakamoto_attempt_time_ms, mempool_settings: MemPoolWalkSettings { + strategy: miner_config.mempool_walk_strategy, max_walk_time_ms: miner_config.nakamoto_attempt_time_ms, consider_no_estimate_tx_prob: miner_config.probability_pick_no_estimate_tx, nonce_cache_size: miner_config.nonce_cache_size, @@ -1103,6 +1104,7 @@ impl Config { // second or later attempt to mine a block -- give it some time miner_config.subsequent_attempt_time_ms }, + strategy: miner_config.mempool_walk_strategy, consider_no_estimate_tx_prob: miner_config.probability_pick_no_estimate_tx, nonce_cache_size: miner_config.nonce_cache_size, candidate_retry_cache_size: miner_config.candidate_retry_cache_size, @@ -2092,6 +2094,8 @@ pub struct MinerConfig { pub microblock_attempt_time_ms: u64, /// Max time to assemble Nakamoto block pub nakamoto_attempt_time_ms: u64, + /// Strategy to follow when picking next mempool transactions to consider. + pub mempool_walk_strategy: MemPoolWalkStrategy, pub probability_pick_no_estimate_tx: u8, pub block_reward_recipient: Option, /// If possible, mine with a p2wpkh address @@ -2170,6 +2174,7 @@ impl Default for MinerConfig { activated_vrf_key_path: None, fast_rampup: false, underperform_stop_threshold: None, + mempool_walk_strategy: MemPoolWalkStrategy::GlobalFeeRate, txs_to_consider: MemPoolWalkTxTypes::all(), filter_origins: HashSet::new(), max_reorg_depth: 3, @@ -2542,6 +2547,7 @@ pub struct MinerConfigFile { pub subsequent_attempt_time_ms: Option, pub microblock_attempt_time_ms: Option, pub nakamoto_attempt_time_ms: Option, + pub mempool_walk_strategy: Option, pub probability_pick_no_estimate_tx: Option, pub block_reward_recipient: Option, pub segwit: Option, @@ -2658,6 +2664,18 @@ impl MinerConfigFile { activated_vrf_key_path: self.activated_vrf_key_path.clone(), fast_rampup: self.fast_rampup.unwrap_or(miner_default_config.fast_rampup), underperform_stop_threshold: self.underperform_stop_threshold, + mempool_walk_strategy: { + if let Some(mempool_walk_strategy) = &self.mempool_walk_strategy { + match str::parse(&mempool_walk_strategy) { + Ok(strategy) => strategy, + Err(e) => { + panic!("could not parse '{mempool_walk_strategy}': {e}"); + }, + } + } else { + MemPoolWalkStrategy::GlobalFeeRate + } + }, txs_to_consider: { if let Some(txs_to_consider) = &self.txs_to_consider { txs_to_consider From b8b9b89d0b2a694aa7b175ce2ec8a26b4b590fed Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Mon, 16 Dec 2024 23:45:16 -0600 Subject: [PATCH 11/19] chore: strategy selection draft --- .../stacks/tests/block_construction.rs | 8 +- stackslib/src/core/mempool.rs | 177 +++++++++++++++--- 2 files changed, 152 insertions(+), 33 deletions(-) diff --git a/stackslib/src/chainstate/stacks/tests/block_construction.rs b/stackslib/src/chainstate/stacks/tests/block_construction.rs index 4402dd13e6..f9f4600230 100644 --- a/stackslib/src/chainstate/stacks/tests/block_construction.rs +++ b/stackslib/src/chainstate/stacks/tests/block_construction.rs @@ -5210,8 +5210,8 @@ fn mempool_walk_test_nonce_filtered_and_ranked() { mempool_tx.commit().unwrap(); // Visit transactions. Keep a record of the order of visited txs so we can compare at the end. - let mut settings = MemPoolWalkSettings::default(); - settings.strategy = MemPoolWalkStrategy::NextNonceWithHighestFeeRate; + let mut mempool_settings = MemPoolWalkSettings::default(); + mempool_settings.strategy = MemPoolWalkStrategy::NextNonceWithHighestFeeRate; let mut considered_txs = vec![]; let deadline = get_epoch_time_ms() + 30000; chainstate.with_read_only_clarity_tx( @@ -5224,7 +5224,7 @@ fn mempool_walk_test_nonce_filtered_and_ranked() { .iterate_candidates::<_, ChainstateError, _>( clarity_conn, &mut tx_events, - settings, + mempool_settings.clone(), |_, available_tx, _| { considered_txs.push(( available_tx.tx.metadata.origin_address.to_string(), @@ -5239,7 +5239,7 @@ fn mempool_walk_test_nonce_filtered_and_ranked() { available_tx.tx.tx.clone(), vec![], Value::okay(Value::Bool(true)).unwrap(), - ExecutionCost::zero(), + ExecutionCost::ZERO, ), ) .convert_to_event(), diff --git a/stackslib/src/core/mempool.rs b/stackslib/src/core/mempool.rs index 8871e3b9d3..9d94a7d10b 100644 --- a/stackslib/src/core/mempool.rs +++ b/stackslib/src/core/mempool.rs @@ -29,7 +29,8 @@ use rand::distributions::Uniform; use rand::prelude::Distribution; use rusqlite::types::ToSql; use rusqlite::{ - params, Connection, Error as SqliteError, OpenFlags, OptionalExtension, Row, Rows, Transaction, + params, Connection, Error as SqliteError, OpenFlags, OptionalExtension, Row, Rows, Statement, + Transaction, }; use siphasher::sip::SipHasher; // this is SipHash-2-4 use stacks_common::codec::{ @@ -519,13 +520,40 @@ impl MemPoolWalkTxTypes { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MemPoolWalkStrategy { + /// Select transactions with the highest global fee rate. + GlobalFeeRate, + /// Select transactions with the next expected nonce for origin and sponsor addresses, + NextNonceWithHighestFeeRate, +} + +impl FromStr for MemPoolWalkStrategy { + type Err = &'static str; + fn from_str(s: &str) -> Result { + match s { + "GlobalFeeRate" => { + return Ok(Self::GlobalFeeRate); + } + "NextNonceWithHighestFeeRate" => { + return Ok(Self::NextNonceWithHighestFeeRate); + } + _ => { + return Err("Unknown mempool walk strategy"); + } + } + } +} + #[derive(Debug, Clone)] pub struct MemPoolWalkSettings { + /// Strategy to use when selecting the next transactions to consider in the `mempool` table. + pub strategy: MemPoolWalkStrategy, /// Maximum amount of time a miner will spend walking through mempool transactions, in /// milliseconds. This is a soft deadline. pub max_walk_time_ms: u64, /// Probability percentage to consider a transaction which has not received a cost estimate. - /// This property is no longer used and will be ignored. + /// Only used when walk strategy is `GlobalFeeRate`. pub consider_no_estimate_tx_prob: u8, /// Size of the nonce cache. This avoids MARF look-ups. pub nonce_cache_size: u64, @@ -544,6 +572,7 @@ pub struct MemPoolWalkSettings { impl Default for MemPoolWalkSettings { fn default() -> Self { MemPoolWalkSettings { + strategy: MemPoolWalkStrategy::GlobalFeeRate, max_walk_time_ms: u64::MAX, consider_no_estimate_tx_prob: 5, nonce_cache_size: 1024 * 1024, @@ -563,6 +592,7 @@ impl Default for MemPoolWalkSettings { impl MemPoolWalkSettings { pub fn zero() -> MemPoolWalkSettings { MemPoolWalkSettings { + strategy: MemPoolWalkStrategy::GlobalFeeRate, max_walk_time_ms: u64::MAX, consider_no_estimate_tx_prob: 5, nonce_cache_size: 1024 * 1024, @@ -1318,6 +1348,9 @@ impl MemPoolDB { MemPoolDB::instantiate_schema_7(tx)?; } 7 => { + MemPoolDB::instantiate_schema_8(tx)?; + } + 8 => { break; } _ => { @@ -1673,16 +1706,50 @@ impl MemPoolDB { // single transaction. This cannot grow to more than `settings.nonce_cache_size` entries. let mut retry_store = HashMap::new(); - // Iterate pending mempool transactions using a heuristic that maximizes miner fee profitability and minimizes CPU time - // wasted on already-mined or not-yet-mineable transactions. This heuristic takes the following steps: + // == Queries for `GlobalFeeRate` mempool walk strategy + // + // Selects mempool transactions only based on their fee rate. Transactions with NULL fee rates get randomly selected for + // consideration. + let tx_consideration_sampler = Uniform::new(0, 100); + let mut rng = rand::thread_rng(); + let sql = " + SELECT txid, origin_nonce, origin_address, sponsor_nonce, sponsor_address, fee_rate + FROM mempool + WHERE fee_rate IS NULL + "; + let mut query_stmt_null = self + .db + .prepare(&sql) + .map_err(|err| Error::SqliteError(err))?; + let mut null_iterator = query_stmt_null + .query(NO_PARAMS) + .map_err(|err| Error::SqliteError(err))?; + let sql = " + SELECT txid, origin_nonce, origin_address, sponsor_nonce, sponsor_address, fee_rate + FROM mempool + WHERE fee_rate IS NOT NULL + ORDER BY fee_rate DESC + "; + let mut query_stmt_fee = self + .db + .prepare(&sql) + .map_err(|err| Error::SqliteError(err))?; + let mut fee_iterator = query_stmt_fee + .query(NO_PARAMS) + .map_err(|err| Error::SqliteError(err))?; + + // == Query for `NextNonceWithHighestFeeRate` mempool walk strategy + // + // Selects the next mempool transaction to consider using a heuristic that maximizes miner fee profitability and minimizes + // CPU time wasted on already-mined or not-yet-mineable transactions. This heuristic takes the following steps: // - // 1. Filters out transactions that have nonces smaller than the origin address' next expected nonce as stated in the - // `nonces` table, when possible + // 1. Filters out transactions that have nonces smaller than the origin and sponsor address' next expected nonce as stated + // in the `nonces` table, when possible // 2. Adds a "simulated" fee rate to transactions that don't have it by multiplying the mempool's maximum current fee rate // by a random number. This helps us mix these transactions with others to guarantee they get processed in a reasonable // order - // 3. Ranks transactions by prioritizing those with next nonces and higher fees (per origin address) - // 4. Sorts all ranked transactions by fee and returns them for evaluation + // 3. Ranks transactions by prioritizing those with next nonces and higher fees (per origin and sponsor address) + // 4. Takes the top ranked transaction and returns it for evaluation // // This logic prevents miners from repeatedly visiting (and then skipping) high fee transactions that would get evaluated // first based on their `fee_rate` but are otherwise non-mineable because they have very high or invalid nonces. A large @@ -1696,29 +1763,33 @@ impl MemPoolDB { WHEN fee_rate IS NULL THEN (ABS(RANDOM()) % 10000 / 10000.0) * (SELECT MAX(fee_rate) FROM mempool) ELSE fee_rate END AS sort_fee_rate - FROM mempool - LEFT JOIN nonces ON mempool.origin_address = nonces.address - WHERE nonces.address IS NULL OR mempool.origin_nonce >= nonces.nonce + FROM mempool AS m + LEFT JOIN nonces AS no ON m.origin_address = no.address + LEFT JOIN nonces AS ns ON m.sponsor_address = ns.address + WHERE (no.address IS NULL OR m.origin_nonce >= no.nonce) + AND (ns.address IS NULL OR m.sponsor_nonce >= ns.nonce) ), address_nonce_ranked AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY origin_address ORDER BY origin_nonce ASC, sort_fee_rate DESC - ) AS rank + ) AS origin_rank, + ROW_NUMBER() OVER ( + PARTITION BY sponsor_address + ORDER BY sponsor_nonce ASC, sort_fee_rate DESC + ) AS sponsor_rank FROM nonce_filtered ) SELECT txid, origin_nonce, origin_address, sponsor_nonce, sponsor_address, fee_rate FROM address_nonce_ranked - ORDER BY rank ASC, sort_fee_rate DESC + ORDER BY origin_rank ASC, sponsor_rank ASC, sort_fee_rate DESC + LIMIT 1 "; - let mut query_stmt = self + let mut query_stmt_nonce_rank = self .db .prepare(&sql) .map_err(|err| Error::SqliteError(err))?; - let mut tx_iterator = query_stmt - .query(NO_PARAMS) - .map_err(|err| Error::SqliteError(err))?; let stop_reason = loop { if start_time.elapsed().as_millis() > settings.max_walk_time_ms as u128 { @@ -1734,16 +1805,61 @@ impl MemPoolDB { (tx, update_estimate) } None => { - // When the retry list is empty, read from the mempool db - match tx_iterator.next().map_err(|err| Error::SqliteError(err))? { - Some(row) => { - let tx = MemPoolTxInfoPartial::from_row(row)?; - let update_estimate = tx.fee_rate.is_none(); - (tx, update_estimate) - } - None => { - debug!("No more transactions to consider in mempool"); - break MempoolIterationStopReason::NoMoreCandidates; + // When the retry list is empty, read from the mempool db depending on the configured miner strategy + match settings.strategy { + MemPoolWalkStrategy::GlobalFeeRate => { + let start_with_no_estimate = + tx_consideration_sampler.sample(&mut rng) < settings.consider_no_estimate_tx_prob; + // randomly select from either the null fee-rate transactions or those with fee-rate estimates. + let opt_tx = if start_with_no_estimate { + null_iterator + .next() + .map_err(|err| Error::SqliteError(err))? + } else { + fee_iterator.next().map_err(|err| Error::SqliteError(err))? + }; + match opt_tx { + Some(row) => (MemPoolTxInfoPartial::from_row(row)?, start_with_no_estimate), + None => { + // If the selected iterator is empty, check the other + match if start_with_no_estimate { + fee_iterator.next().map_err(|err| Error::SqliteError(err))? + } else { + null_iterator + .next() + .map_err(|err| Error::SqliteError(err))? + } { + Some(row) => ( + MemPoolTxInfoPartial::from_row(row)?, + !start_with_no_estimate, + ), + None => { + debug!("No more transactions to consider in mempool"); + break MempoolIterationStopReason::NoMoreCandidates; + } + } + } + } + }, + MemPoolWalkStrategy::NextNonceWithHighestFeeRate => { + // Execute the query to get a single row. We do not use an iterator because we want the top rank to be + // recalculated every time we visit a transaction. + match query_stmt_nonce_rank + .query(NO_PARAMS) + .map_err(|err| Error::SqliteError(err))? + .next() + .map_err(|err| Error::SqliteError(err))? + { + Some(row) => { + let tx = MemPoolTxInfoPartial::from_row(row)?; + let update_estimate = tx.fee_rate.is_none(); + (tx, update_estimate) + }, + None => { + debug!("No more transactions to consider in mempool"); + break MempoolIterationStopReason::NoMoreCandidates; + } + } } } } @@ -1944,8 +2060,11 @@ impl MemPoolDB { // drop these rusqlite statements and queries, since their existence as immutable borrows on the // connection prevents us from beginning a transaction below (which requires a mutable // borrow). - drop(tx_iterator); - drop(query_stmt); + drop(null_iterator); + drop(query_stmt_null); + drop(fee_iterator); + drop(query_stmt_fee); + drop(query_stmt_nonce_rank); if retry_store.len() > 0 { let tx = self.tx_begin()?; From ea49875acb4d20b0775ba291a29470455f77b591 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Tue, 17 Dec 2024 14:35:08 -0600 Subject: [PATCH 12/19] fix: correct tx confirmation order --- .../src/chainstate/stacks/tests/block_construction.rs | 8 ++++---- stackslib/src/core/mempool.rs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/stackslib/src/chainstate/stacks/tests/block_construction.rs b/stackslib/src/chainstate/stacks/tests/block_construction.rs index f9f4600230..c29276613e 100644 --- a/stackslib/src/chainstate/stacks/tests/block_construction.rs +++ b/stackslib/src/chainstate/stacks/tests/block_construction.rs @@ -5113,8 +5113,8 @@ fn mempool_walk_test_nonce_filtered_and_ranked() { let address_1 = origin_addresses[1].to_string(); let address_2 = origin_addresses[2].to_string(); - let test_name = "mempool_walk_test_nonce_filtered_and_ranked"; - let mut peer_config = TestPeerConfig::new(test_name, 2002, 2003); + let test_name = function_name!(); + let mut peer_config = TestPeerConfig::new(&test_name, 0, 0); peer_config.initial_balances = vec![]; for (privk, addr) in &key_address_pairs { @@ -5259,10 +5259,10 @@ fn mempool_walk_test_nonce_filtered_and_ranked() { vec![ (address_2.clone(), 9), // Highest fee for address 2, and 9 is the next nonce (address_1.clone(), 7), - (address_0.clone(), 2), (address_1.clone(), 8), - (address_0.clone(), 3), (address_1.clone(), 9), // Highest fee for address 1, but have to confirm nonces 7 and 8 first + (address_0.clone(), 2), + (address_0.clone(), 3), (address_0.clone(), 4), (address_0.clone(), 5), (address_0.clone(), 6), diff --git a/stackslib/src/core/mempool.rs b/stackslib/src/core/mempool.rs index 9d94a7d10b..d9a462099e 100644 --- a/stackslib/src/core/mempool.rs +++ b/stackslib/src/core/mempool.rs @@ -1840,7 +1840,7 @@ impl MemPoolDB { } } } - }, + } MemPoolWalkStrategy::NextNonceWithHighestFeeRate => { // Execute the query to get a single row. We do not use an iterator because we want the top rank to be // recalculated every time we visit a transaction. From a56baef832146df342a997e6a19f8126931fc22c Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Tue, 17 Dec 2024 22:28:15 -0600 Subject: [PATCH 13/19] test: success --- .../stacks/tests/block_construction.rs | 212 +++++++++++------- stackslib/src/chainstate/stacks/tests/mod.rs | 31 +++ stackslib/src/core/mempool.rs | 4 +- 3 files changed, 166 insertions(+), 81 deletions(-) diff --git a/stackslib/src/chainstate/stacks/tests/block_construction.rs b/stackslib/src/chainstate/stacks/tests/block_construction.rs index c29276613e..05583f85ba 100644 --- a/stackslib/src/chainstate/stacks/tests/block_construction.rs +++ b/stackslib/src/chainstate/stacks/tests/block_construction.rs @@ -5091,8 +5091,8 @@ fn paramaterized_mempool_walk_test( #[test] /// Test that the mempool walk query ignores old nonces and prefers next possible nonces before higher global fees. -fn mempool_walk_test_nonce_filtered_and_ranked() { - let key_address_pairs: Vec<(Secp256k1PrivateKey, StacksAddress)> = (0..3) +fn mempool_walk_test_next_nonce_with_highest_fee_rate_strategy() { + let key_address_pairs: Vec<(Secp256k1PrivateKey, StacksAddress)> = (0..6) .map(|_user_index| { let privk = StacksPrivateKey::new(); let addr = StacksAddress::from_public_keys( @@ -5105,17 +5105,19 @@ fn mempool_walk_test_nonce_filtered_and_ranked() { (privk, addr) }) .collect(); - let origin_addresses: Vec = key_address_pairs + let accounts: Vec = key_address_pairs .iter() .map(|(_, b)| b.to_string()) .collect(); - let address_0 = origin_addresses[0].to_string(); - let address_1 = origin_addresses[1].to_string(); - let address_2 = origin_addresses[2].to_string(); + let address_0 = accounts[0].to_string(); + let address_1 = accounts[1].to_string(); + let address_2 = accounts[2].to_string(); + let address_3 = accounts[3].to_string(); + let address_4 = accounts[4].to_string(); + let address_5 = accounts[5].to_string(); let test_name = function_name!(); let mut peer_config = TestPeerConfig::new(&test_name, 0, 0); - peer_config.initial_balances = vec![]; for (privk, addr) in &key_address_pairs { peer_config @@ -5144,71 +5146,112 @@ fn mempool_walk_test_nonce_filtered_and_ranked() { let mut tx_events = Vec::new(); - // Submit nonces 0 through 9 for each of the 3 senders. - for nonce in 0..10 { - for user_index in 0..3 { - let mut tx = make_user_stacks_transfer( - &key_address_pairs[user_index].0, - nonce as u64, + // Simulate next possible nonces for all addresses + let mempool_tx = mempool.tx_begin().unwrap(); + mempool_tx + .execute( + "INSERT INTO nonces (address, nonce) VALUES (?, ?), (?, ?), (?, ?), (?, ?), (?, ?), (?, ?)", + params![address_0, 2, address_1, 1, address_2, 6, address_3, 0, address_4, 1, address_5, 0], + ) + .unwrap(); + mempool_tx.commit().unwrap(); + + // Test vectors with a wide variety of origin/sponsor configurations and fee rate values. Some transactions do not have a + // sponsor, some others do, some others are sponsored by other sponsors. All in flight at the same time. + // + // tuple shape -> (origin_address_index, origin_nonce, sponsor_address_index, sponsor_nonce, fee_rate) + let test_vectors = vec![ + (0, 0, 0, 0, 100.0), // Old origin nonce - ignored + (0, 1, 0, 1, 200.0), // Old origin nonce - ignored + (0, 2, 0, 2, 300.0), + (0, 3, 0, 3, 400.0), + (0, 4, 3, 0, 500.0), + (1, 0, 1, 0, 400.0), // Old origin nonce - ignored + (1, 1, 3, 1, 600.0), + (1, 2, 3, 2, 700.0), + (1, 3, 3, 3, 800.0), + (1, 4, 1, 4, 1200.0), + (2, 3, 2, 3, 9000.0), // Old origin nonce - ignored + (2, 4, 2, 4, 9000.0), // Old origin nonce - ignored + (2, 5, 2, 5, 9000.0), // Old origin nonce - ignored + (2, 6, 4, 0, 900.0), // Old sponsor nonce - ignored + (2, 6, 4, 1, 1000.0), + (2, 7, 4, 2, 800.0), + (2, 8, 2, 8, 1000.0), + (2, 9, 3, 5, 1000.0), + (2, 10, 3, 6, 1500.0), + (3, 4, 3, 4, 100.0), + (4, 3, 5, 2, 500.0), + (5, 0, 5, 0, 500.0), + (5, 1, 5, 1, 500.0), + (5, 3, 4, 4, 2000.0), + (5, 4, 4, 5, 2000.0), + ]; + for (origin_index, origin_nonce, sponsor_index, sponsor_nonce, fee_rate) in + test_vectors.into_iter() + { + let mut tx = if origin_index != sponsor_index { + let payload = TransactionPayload::TokenTransfer( + recipient.to_account_principal(), + 1, + TokenTransferMemo([0; 34]), + ); + sign_sponsored_singlesig_tx( + payload.into(), + &key_address_pairs[origin_index].0, + &key_address_pairs[sponsor_index].0, + origin_nonce, + sponsor_nonce, + 200, + ) + } else { + make_user_stacks_transfer( + &key_address_pairs[origin_index].0, + origin_nonce, 200, &recipient.to_account_principal(), 1, - ); - - let mut mempool_tx = mempool.tx_begin().unwrap(); - - let origin_address = tx.origin_address(); - let sponsor_address = tx.sponsor_address().unwrap_or(origin_address); - - tx.set_tx_fee(100); - let txid = tx.txid(); - let tx_bytes = tx.serialize_to_vec(); - let tx_fee = tx.get_tx_fee(); - let height = 100; + ) + }; + + let mut mempool_tx = mempool.tx_begin().unwrap(); + + let origin_address = tx.origin_address(); + let sponsor_address = tx.sponsor_address().unwrap_or(origin_address); + + tx.set_tx_fee(fee_rate as u64); + let txid = tx.txid(); + let tx_bytes = tx.serialize_to_vec(); + let tx_fee = tx.get_tx_fee(); + let height = 100; + + MemPoolDB::try_add_tx( + &mut mempool_tx, + &mut chainstate, + &b_1.0, + &b_1.1, + true, + txid, + tx_bytes, + tx_fee, + height, + &origin_address, + origin_nonce, + &sponsor_address, + sponsor_nonce, + None, + ) + .unwrap(); - MemPoolDB::try_add_tx( - &mut mempool_tx, - &mut chainstate, - &b_1.0, - &b_1.1, - true, - txid, - tx_bytes, - tx_fee, - height, - &origin_address, - nonce.try_into().unwrap(), - &sponsor_address, - nonce.try_into().unwrap(), - None, + mempool_tx + .execute( + "UPDATE mempool SET fee_rate = ? WHERE txid = ?", + params![Some(fee_rate), &txid], ) .unwrap(); - - // Increase the `fee_rate` as nonce goes up, so we can test that next nonces get confirmed before higher fee txs. - // Also slightly increase the fee for some addresses so we can check those txs get selected first. - mempool_tx - .execute( - "UPDATE mempool SET fee_rate = ? WHERE txid = ?", - params![Some(100.0 * (nonce + 1 + user_index) as f64), &txid], - ) - .unwrap(); - mempool_tx.commit().unwrap(); - } + mempool_tx.commit().unwrap(); } - // Simulate next possible nonces for the 3 addresses: - // Address 0 => 2 - // Address 1 => 7 - // Address 2 => 9 - let mempool_tx = mempool.tx_begin().unwrap(); - mempool_tx - .execute( - "INSERT INTO nonces (address, nonce) VALUES (?, ?), (?, ?), (?, ?)", - params![address_0, 2, address_1, 7, address_2, 9], - ) - .unwrap(); - mempool_tx.commit().unwrap(); - // Visit transactions. Keep a record of the order of visited txs so we can compare at the end. let mut mempool_settings = MemPoolWalkSettings::default(); mempool_settings.strategy = MemPoolWalkStrategy::NextNonceWithHighestFeeRate; @@ -5229,6 +5272,9 @@ fn mempool_walk_test_nonce_filtered_and_ranked() { considered_txs.push(( available_tx.tx.metadata.origin_address.to_string(), available_tx.tx.metadata.origin_nonce, + available_tx.tx.metadata.sponsor_address.to_string(), + available_tx.tx.metadata.sponsor_nonce, + available_tx.tx.metadata.tx_fee, )); Ok(Some( // Generate any success result @@ -5254,22 +5300,30 @@ fn mempool_walk_test_nonce_filtered_and_ranked() { } assert!(get_epoch_time_ms() < deadline, "test timed out"); } + + // Expected transaction consideration order, sorted by mineable first (next origin+sponsor nonces, highest fee). + let expected_tx_order = vec![ + (address_2.clone(), 6, address_4.clone(), 1, 1000), + (address_2.clone(), 7, address_4.clone(), 2, 800), + (address_2.clone(), 8, address_2.clone(), 8, 1000), + (address_5.clone(), 0, address_5.clone(), 0, 500), + (address_5.clone(), 1, address_5.clone(), 1, 500), + (address_4.clone(), 3, address_5.clone(), 2, 500), + (address_5.clone(), 3, address_4.clone(), 4, 2000), + (address_5.clone(), 4, address_4.clone(), 5, 2000), + (address_0.clone(), 2, address_0.clone(), 2, 300), + (address_0.clone(), 3, address_0.clone(), 3, 400), + (address_0.clone(), 4, address_3.clone(), 0, 500), + (address_1.clone(), 1, address_3.clone(), 1, 600), + (address_1.clone(), 2, address_3.clone(), 2, 700), + (address_1.clone(), 3, address_3.clone(), 3, 800), + (address_1.clone(), 4, address_1.clone(), 4, 1200), + (address_3.clone(), 4, address_3.clone(), 4, 100), + (address_2.clone(), 9, address_3.clone(), 5, 1000), + (address_2.clone(), 10, address_3.clone(), 6, 1500), + ]; assert_eq!( - considered_txs, - vec![ - (address_2.clone(), 9), // Highest fee for address 2, and 9 is the next nonce - (address_1.clone(), 7), - (address_1.clone(), 8), - (address_1.clone(), 9), // Highest fee for address 1, but have to confirm nonces 7 and 8 first - (address_0.clone(), 2), - (address_0.clone(), 3), - (address_0.clone(), 4), - (address_0.clone(), 5), - (address_0.clone(), 6), - (address_0.clone(), 7), - (address_0.clone(), 8), - (address_0.clone(), 9), // Highest fee for address 0, but have to confirm all other nonces first - ], + considered_txs, expected_tx_order, "Mempool should visit transactions in the correct order while ignoring past nonces", ); }, diff --git a/stackslib/src/chainstate/stacks/tests/mod.rs b/stackslib/src/chainstate/stacks/tests/mod.rs index 9a6a84507e..6e2ba7b448 100644 --- a/stackslib/src/chainstate/stacks/tests/mod.rs +++ b/stackslib/src/chainstate/stacks/tests/mod.rs @@ -1396,6 +1396,37 @@ pub fn sign_standard_singlesig_tx( tx_signer.get_tx().unwrap() } +pub fn sign_sponsored_singlesig_tx( + payload: TransactionPayload, + origin: &StacksPrivateKey, + sponsor: &StacksPrivateKey, + origin_nonce: u64, + sponsor_nonce: u64, + tx_fee: u64, +) -> StacksTransaction { + let mut origin_spending_condition = + TransactionSpendingCondition::new_singlesig_p2pkh(StacksPublicKey::from_private(origin)) + .expect("Failed to create p2pkh spending condition from public key."); + origin_spending_condition.set_nonce(origin_nonce); + origin_spending_condition.set_tx_fee(tx_fee); + let mut sponsored_spending_condition = + TransactionSpendingCondition::new_singlesig_p2pkh(StacksPublicKey::from_private(sponsor)) + .expect("Failed to create p2pkh spending condition from public key."); + sponsored_spending_condition.set_nonce(sponsor_nonce); + sponsored_spending_condition.set_tx_fee(tx_fee); + let auth = TransactionAuth::Sponsored(origin_spending_condition, sponsored_spending_condition); + let mut unsigned_tx = StacksTransaction::new(TransactionVersion::Testnet, auth, payload); + + unsigned_tx.chain_id = 0x80000000; + unsigned_tx.post_condition_mode = TransactionPostConditionMode::Allow; + + let mut tx_signer = StacksTransactionSigner::new(&unsigned_tx); + tx_signer.sign_origin(origin).unwrap(); + tx_signer.sign_sponsor(sponsor).unwrap(); + + tx_signer.get_tx().unwrap() +} + pub fn get_stacks_account(peer: &mut TestPeer, addr: &PrincipalData) -> StacksAccount { let account = peer .with_db_state(|ref mut sortdb, ref mut chainstate, _, _| { diff --git a/stackslib/src/core/mempool.rs b/stackslib/src/core/mempool.rs index d9a462099e..eded6a6416 100644 --- a/stackslib/src/core/mempool.rs +++ b/stackslib/src/core/mempool.rs @@ -1766,8 +1766,8 @@ impl MemPoolDB { FROM mempool AS m LEFT JOIN nonces AS no ON m.origin_address = no.address LEFT JOIN nonces AS ns ON m.sponsor_address = ns.address - WHERE (no.address IS NULL OR m.origin_nonce >= no.nonce) - AND (ns.address IS NULL OR m.sponsor_nonce >= ns.nonce) + WHERE (no.address IS NULL OR m.origin_nonce = no.nonce) + AND (ns.address IS NULL OR m.sponsor_nonce = ns.nonce) ), address_nonce_ranked AS ( SELECT *, From a854f3b5ce2dd2b31508c5dcfd96cf34c0757a26 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Tue, 17 Dec 2024 23:02:48 -0600 Subject: [PATCH 14/19] test: missing nonces from table --- .../stacks/tests/block_construction.rs | 29 ++++++++++--------- stackslib/src/core/mempool.rs | 6 ++-- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/stackslib/src/chainstate/stacks/tests/block_construction.rs b/stackslib/src/chainstate/stacks/tests/block_construction.rs index 05583f85ba..cb99b8c7f2 100644 --- a/stackslib/src/chainstate/stacks/tests/block_construction.rs +++ b/stackslib/src/chainstate/stacks/tests/block_construction.rs @@ -5092,7 +5092,7 @@ fn paramaterized_mempool_walk_test( #[test] /// Test that the mempool walk query ignores old nonces and prefers next possible nonces before higher global fees. fn mempool_walk_test_next_nonce_with_highest_fee_rate_strategy() { - let key_address_pairs: Vec<(Secp256k1PrivateKey, StacksAddress)> = (0..6) + let key_address_pairs: Vec<(Secp256k1PrivateKey, StacksAddress)> = (0..7) .map(|_user_index| { let privk = StacksPrivateKey::new(); let addr = StacksAddress::from_public_keys( @@ -5115,6 +5115,7 @@ fn mempool_walk_test_next_nonce_with_highest_fee_rate_strategy() { let address_3 = accounts[3].to_string(); let address_4 = accounts[4].to_string(); let address_5 = accounts[5].to_string(); + let address_6 = accounts[6].to_string(); let test_name = function_name!(); let mut peer_config = TestPeerConfig::new(&test_name, 0, 0); @@ -5146,26 +5147,27 @@ fn mempool_walk_test_next_nonce_with_highest_fee_rate_strategy() { let mut tx_events = Vec::new(); - // Simulate next possible nonces for all addresses + // Simulate next possible nonces for **some** addresses. Leave some blank so we can test the case where the nonce cannot be + // found on the db table and has to be pulled from the MARF. let mempool_tx = mempool.tx_begin().unwrap(); mempool_tx .execute( - "INSERT INTO nonces (address, nonce) VALUES (?, ?), (?, ?), (?, ?), (?, ?), (?, ?), (?, ?)", - params![address_0, 2, address_1, 1, address_2, 6, address_3, 0, address_4, 1, address_5, 0], + "INSERT INTO nonces (address, nonce) VALUES (?, ?), (?, ?), (?, ?), (?, ?), (?, ?)", + params![address_0, 2, address_1, 1, address_2, 6, address_4, 1, address_5, 0], ) .unwrap(); mempool_tx.commit().unwrap(); - // Test vectors with a wide variety of origin/sponsor configurations and fee rate values. Some transactions do not have a - // sponsor, some others do, some others are sponsored by other sponsors. All in flight at the same time. + // Test transactions with a wide variety of origin/sponsor configurations and fee rate values. Some transactions do not have a + // sponsor, some others do, and some others are sponsored by other sponsors. All will be in flight at the same time. // - // tuple shape -> (origin_address_index, origin_nonce, sponsor_address_index, sponsor_nonce, fee_rate) + // tuple shape: (origin_address_index, origin_nonce, sponsor_address_index, sponsor_nonce, fee_rate) let test_vectors = vec![ (0, 0, 0, 0, 100.0), // Old origin nonce - ignored (0, 1, 0, 1, 200.0), // Old origin nonce - ignored (0, 2, 0, 2, 300.0), (0, 3, 0, 3, 400.0), - (0, 4, 3, 0, 500.0), + (0, 4, 3, 0, 500.0), // Nonce 0 for address 3 is not in the table but will be valid on MARF (1, 0, 1, 0, 400.0), // Old origin nonce - ignored (1, 1, 3, 1, 600.0), (1, 2, 3, 2, 700.0), @@ -5186,10 +5188,12 @@ fn mempool_walk_test_next_nonce_with_highest_fee_rate_strategy() { (5, 1, 5, 1, 500.0), (5, 3, 4, 4, 2000.0), (5, 4, 4, 5, 2000.0), + (6, 2, 6, 2, 1000.0), // Address has nonce 0 in MARF - ignored ]; for (origin_index, origin_nonce, sponsor_index, sponsor_nonce, fee_rate) in test_vectors.into_iter() { + // Create tx, either standard or sponsored let mut tx = if origin_index != sponsor_index { let payload = TransactionPayload::TokenTransfer( recipient.to_account_principal(), @@ -5218,13 +5222,11 @@ fn mempool_walk_test_next_nonce_with_highest_fee_rate_strategy() { let origin_address = tx.origin_address(); let sponsor_address = tx.sponsor_address().unwrap_or(origin_address); - tx.set_tx_fee(fee_rate as u64); let txid = tx.txid(); let tx_bytes = tx.serialize_to_vec(); let tx_fee = tx.get_tx_fee(); let height = 100; - MemPoolDB::try_add_tx( &mut mempool_tx, &mut chainstate, @@ -5242,17 +5244,18 @@ fn mempool_walk_test_next_nonce_with_highest_fee_rate_strategy() { None, ) .unwrap(); - mempool_tx .execute( "UPDATE mempool SET fee_rate = ? WHERE txid = ?", params![Some(fee_rate), &txid], ) .unwrap(); + mempool_tx.commit().unwrap(); } - // Visit transactions. Keep a record of the order of visited txs so we can compare at the end. + // Visit transactions using the `NextNonceWithHighestFeeRate` strategy. Keep a record of the order of visits so we can compare + // at the end. let mut mempool_settings = MemPoolWalkSettings::default(); mempool_settings.strategy = MemPoolWalkStrategy::NextNonceWithHighestFeeRate; let mut considered_txs = vec![]; @@ -5261,7 +5264,6 @@ fn mempool_walk_test_next_nonce_with_highest_fee_rate_strategy() { &TEST_BURN_STATE_DB, &StacksBlockHeader::make_index_block_hash(&b_2.0, &b_2.1), |clarity_conn| { - // When the candidate cache fills, one pass cannot process all transactions loop { if mempool .iterate_candidates::<_, ChainstateError, _>( @@ -5302,6 +5304,7 @@ fn mempool_walk_test_next_nonce_with_highest_fee_rate_strategy() { } // Expected transaction consideration order, sorted by mineable first (next origin+sponsor nonces, highest fee). + // Ignores old and very future nonces. let expected_tx_order = vec![ (address_2.clone(), 6, address_4.clone(), 1, 1000), (address_2.clone(), 7, address_4.clone(), 2, 800), diff --git a/stackslib/src/core/mempool.rs b/stackslib/src/core/mempool.rs index eded6a6416..2b3c0bfb59 100644 --- a/stackslib/src/core/mempool.rs +++ b/stackslib/src/core/mempool.rs @@ -1743,8 +1743,8 @@ impl MemPoolDB { // Selects the next mempool transaction to consider using a heuristic that maximizes miner fee profitability and minimizes // CPU time wasted on already-mined or not-yet-mineable transactions. This heuristic takes the following steps: // - // 1. Filters out transactions that have nonces smaller than the origin and sponsor address' next expected nonce as stated - // in the `nonces` table, when possible + // 1. Filters out transactions to consider only those that have the next expected nonce for both the origin and sponsor, + // when possible // 2. Adds a "simulated" fee rate to transactions that don't have it by multiplying the mempool's maximum current fee rate // by a random number. This helps us mix these transactions with others to guarantee they get processed in a reasonable // order @@ -1842,8 +1842,6 @@ impl MemPoolDB { } } MemPoolWalkStrategy::NextNonceWithHighestFeeRate => { - // Execute the query to get a single row. We do not use an iterator because we want the top rank to be - // recalculated every time we visit a transaction. match query_stmt_nonce_rank .query(NO_PARAMS) .map_err(|err| Error::SqliteError(err))? From 07cf97f2889ef11d0534870116f64db4edb291ea Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Wed, 18 Dec 2024 10:41:30 -0600 Subject: [PATCH 15/19] style: fixes --- .../stacks/tests/block_construction.rs | 10 +++++----- stackslib/src/core/mempool.rs | 16 ++++++++++------ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/stackslib/src/chainstate/stacks/tests/block_construction.rs b/stackslib/src/chainstate/stacks/tests/block_construction.rs index cb99b8c7f2..3be2894669 100644 --- a/stackslib/src/chainstate/stacks/tests/block_construction.rs +++ b/stackslib/src/chainstate/stacks/tests/block_construction.rs @@ -5163,12 +5163,12 @@ fn mempool_walk_test_next_nonce_with_highest_fee_rate_strategy() { // // tuple shape: (origin_address_index, origin_nonce, sponsor_address_index, sponsor_nonce, fee_rate) let test_vectors = vec![ - (0, 0, 0, 0, 100.0), // Old origin nonce - ignored - (0, 1, 0, 1, 200.0), // Old origin nonce - ignored + (0, 0, 0, 0, 100.0), // Old origin nonce - ignored + (0, 1, 0, 1, 200.0), // Old origin nonce - ignored (0, 2, 0, 2, 300.0), (0, 3, 0, 3, 400.0), - (0, 4, 3, 0, 500.0), // Nonce 0 for address 3 is not in the table but will be valid on MARF - (1, 0, 1, 0, 400.0), // Old origin nonce - ignored + (0, 4, 3, 0, 500.0), // Nonce 0 for address 3 is not in the table but will be valid on MARF + (1, 0, 1, 0, 400.0), // Old origin nonce - ignored (1, 1, 3, 1, 600.0), (1, 2, 3, 2, 700.0), (1, 3, 3, 3, 800.0), @@ -5176,7 +5176,7 @@ fn mempool_walk_test_next_nonce_with_highest_fee_rate_strategy() { (2, 3, 2, 3, 9000.0), // Old origin nonce - ignored (2, 4, 2, 4, 9000.0), // Old origin nonce - ignored (2, 5, 2, 5, 9000.0), // Old origin nonce - ignored - (2, 6, 4, 0, 900.0), // Old sponsor nonce - ignored + (2, 6, 4, 0, 900.0), // Old sponsor nonce - ignored (2, 6, 4, 1, 1000.0), (2, 7, 4, 2, 800.0), (2, 8, 2, 8, 1000.0), diff --git a/stackslib/src/core/mempool.rs b/stackslib/src/core/mempool.rs index 2b3c0bfb59..2e5fc95bfc 100644 --- a/stackslib/src/core/mempool.rs +++ b/stackslib/src/core/mempool.rs @@ -1767,7 +1767,7 @@ impl MemPoolDB { LEFT JOIN nonces AS no ON m.origin_address = no.address LEFT JOIN nonces AS ns ON m.sponsor_address = ns.address WHERE (no.address IS NULL OR m.origin_nonce = no.nonce) - AND (ns.address IS NULL OR m.sponsor_nonce = ns.nonce) + AND (ns.address IS NULL OR m.sponsor_nonce = ns.nonce) ), address_nonce_ranked AS ( SELECT *, @@ -1808,8 +1808,8 @@ impl MemPoolDB { // When the retry list is empty, read from the mempool db depending on the configured miner strategy match settings.strategy { MemPoolWalkStrategy::GlobalFeeRate => { - let start_with_no_estimate = - tx_consideration_sampler.sample(&mut rng) < settings.consider_no_estimate_tx_prob; + let start_with_no_estimate = tx_consideration_sampler.sample(&mut rng) + < settings.consider_no_estimate_tx_prob; // randomly select from either the null fee-rate transactions or those with fee-rate estimates. let opt_tx = if start_with_no_estimate { null_iterator @@ -1819,11 +1819,15 @@ impl MemPoolDB { fee_iterator.next().map_err(|err| Error::SqliteError(err))? }; match opt_tx { - Some(row) => (MemPoolTxInfoPartial::from_row(row)?, start_with_no_estimate), + Some(row) => { + (MemPoolTxInfoPartial::from_row(row)?, start_with_no_estimate) + } None => { // If the selected iterator is empty, check the other match if start_with_no_estimate { - fee_iterator.next().map_err(|err| Error::SqliteError(err))? + fee_iterator + .next() + .map_err(|err| Error::SqliteError(err))? } else { null_iterator .next() @@ -1852,7 +1856,7 @@ impl MemPoolDB { let tx = MemPoolTxInfoPartial::from_row(row)?; let update_estimate = tx.fee_rate.is_none(); (tx, update_estimate) - }, + } None => { debug!("No more transactions to consider in mempool"); break MempoolIterationStopReason::NoMoreCandidates; From 0b0b821936b93c64d900c6c3e00f4af1aa49e679 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Wed, 18 Dec 2024 10:46:47 -0600 Subject: [PATCH 16/19] style: error transforms --- .../stacks/tests/block_construction.rs | 8 +++---- stackslib/src/core/mempool.rs | 22 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/stackslib/src/chainstate/stacks/tests/block_construction.rs b/stackslib/src/chainstate/stacks/tests/block_construction.rs index 3be2894669..79491afa9e 100644 --- a/stackslib/src/chainstate/stacks/tests/block_construction.rs +++ b/stackslib/src/chainstate/stacks/tests/block_construction.rs @@ -5163,12 +5163,12 @@ fn mempool_walk_test_next_nonce_with_highest_fee_rate_strategy() { // // tuple shape: (origin_address_index, origin_nonce, sponsor_address_index, sponsor_nonce, fee_rate) let test_vectors = vec![ - (0, 0, 0, 0, 100.0), // Old origin nonce - ignored - (0, 1, 0, 1, 200.0), // Old origin nonce - ignored + (0, 0, 0, 0, 100.0), // Old origin nonce - ignored + (0, 1, 0, 1, 200.0), // Old origin nonce - ignored (0, 2, 0, 2, 300.0), (0, 3, 0, 3, 400.0), - (0, 4, 3, 0, 500.0), // Nonce 0 for address 3 is not in the table but will be valid on MARF - (1, 0, 1, 0, 400.0), // Old origin nonce - ignored + (0, 4, 3, 0, 500.0), // Nonce 0 for address 3 is not in the table but will be valid on MARF + (1, 0, 1, 0, 400.0), // Old origin nonce - ignored (1, 1, 3, 1, 600.0), (1, 2, 3, 2, 700.0), (1, 3, 3, 3, 800.0), diff --git a/stackslib/src/core/mempool.rs b/stackslib/src/core/mempool.rs index 2e5fc95bfc..2f56d10969 100644 --- a/stackslib/src/core/mempool.rs +++ b/stackslib/src/core/mempool.rs @@ -1720,10 +1720,10 @@ impl MemPoolDB { let mut query_stmt_null = self .db .prepare(&sql) - .map_err(|err| Error::SqliteError(err))?; + .map_err(Error::SqliteError)?; let mut null_iterator = query_stmt_null .query(NO_PARAMS) - .map_err(|err| Error::SqliteError(err))?; + .map_err(Error::SqliteError)?; let sql = " SELECT txid, origin_nonce, origin_address, sponsor_nonce, sponsor_address, fee_rate FROM mempool @@ -1733,10 +1733,10 @@ impl MemPoolDB { let mut query_stmt_fee = self .db .prepare(&sql) - .map_err(|err| Error::SqliteError(err))?; + .map_err(Error::SqliteError)?; let mut fee_iterator = query_stmt_fee .query(NO_PARAMS) - .map_err(|err| Error::SqliteError(err))?; + .map_err(Error::SqliteError)?; // == Query for `NextNonceWithHighestFeeRate` mempool walk strategy // @@ -1789,7 +1789,7 @@ impl MemPoolDB { let mut query_stmt_nonce_rank = self .db .prepare(&sql) - .map_err(|err| Error::SqliteError(err))?; + .map_err(Error::SqliteError)?; let stop_reason = loop { if start_time.elapsed().as_millis() > settings.max_walk_time_ms as u128 { @@ -1814,9 +1814,9 @@ impl MemPoolDB { let opt_tx = if start_with_no_estimate { null_iterator .next() - .map_err(|err| Error::SqliteError(err))? + .map_err(Error::SqliteError)? } else { - fee_iterator.next().map_err(|err| Error::SqliteError(err))? + fee_iterator.next().map_err(Error::SqliteError)? }; match opt_tx { Some(row) => { @@ -1827,11 +1827,11 @@ impl MemPoolDB { match if start_with_no_estimate { fee_iterator .next() - .map_err(|err| Error::SqliteError(err))? + .map_err(Error::SqliteError)? } else { null_iterator .next() - .map_err(|err| Error::SqliteError(err))? + .map_err(Error::SqliteError)? } { Some(row) => ( MemPoolTxInfoPartial::from_row(row)?, @@ -1848,9 +1848,9 @@ impl MemPoolDB { MemPoolWalkStrategy::NextNonceWithHighestFeeRate => { match query_stmt_nonce_rank .query(NO_PARAMS) - .map_err(|err| Error::SqliteError(err))? + .map_err(Error::SqliteError)? .next() - .map_err(|err| Error::SqliteError(err))? + .map_err(Error::SqliteError)? { Some(row) => { let tx = MemPoolTxInfoPartial::from_row(row)?; From 635cfe3a6f38282f7728946a2d97db84521481da Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Wed, 18 Dec 2024 19:00:13 -0600 Subject: [PATCH 17/19] fix: style --- stackslib/src/config/mod.rs | 15 +++------------ stackslib/src/core/mempool.rs | 27 ++++++--------------------- 2 files changed, 9 insertions(+), 33 deletions(-) diff --git a/stackslib/src/config/mod.rs b/stackslib/src/config/mod.rs index c8a3348503..010ecc16fd 100644 --- a/stackslib/src/config/mod.rs +++ b/stackslib/src/config/mod.rs @@ -2676,18 +2676,9 @@ impl MinerConfigFile { activated_vrf_key_path: self.activated_vrf_key_path.clone(), fast_rampup: self.fast_rampup.unwrap_or(miner_default_config.fast_rampup), underperform_stop_threshold: self.underperform_stop_threshold, - mempool_walk_strategy: { - if let Some(mempool_walk_strategy) = &self.mempool_walk_strategy { - match str::parse(&mempool_walk_strategy) { - Ok(strategy) => strategy, - Err(e) => { - panic!("could not parse '{mempool_walk_strategy}': {e}"); - }, - } - } else { - MemPoolWalkStrategy::GlobalFeeRate - } - }, + mempool_walk_strategy: self.mempool_walk_strategy + .map(|s| str::parse(&s).unwrap_or_else(|e| panic!("Could not parse '{s}': {e}"))) + .unwrap_or(MemPoolWalkStrategy::GlobalFeeRate), txs_to_consider: { if let Some(txs_to_consider) = &self.txs_to_consider { txs_to_consider diff --git a/stackslib/src/core/mempool.rs b/stackslib/src/core/mempool.rs index 2f56d10969..066c8ba2ac 100644 --- a/stackslib/src/core/mempool.rs +++ b/stackslib/src/core/mempool.rs @@ -1717,10 +1717,7 @@ impl MemPoolDB { FROM mempool WHERE fee_rate IS NULL "; - let mut query_stmt_null = self - .db - .prepare(&sql) - .map_err(Error::SqliteError)?; + let mut query_stmt_null = self.db.prepare(&sql).map_err(Error::SqliteError)?; let mut null_iterator = query_stmt_null .query(NO_PARAMS) .map_err(Error::SqliteError)?; @@ -1730,10 +1727,7 @@ impl MemPoolDB { WHERE fee_rate IS NOT NULL ORDER BY fee_rate DESC "; - let mut query_stmt_fee = self - .db - .prepare(&sql) - .map_err(Error::SqliteError)?; + let mut query_stmt_fee = self.db.prepare(&sql).map_err(Error::SqliteError)?; let mut fee_iterator = query_stmt_fee .query(NO_PARAMS) .map_err(Error::SqliteError)?; @@ -1786,10 +1780,7 @@ impl MemPoolDB { ORDER BY origin_rank ASC, sponsor_rank ASC, sort_fee_rate DESC LIMIT 1 "; - let mut query_stmt_nonce_rank = self - .db - .prepare(&sql) - .map_err(Error::SqliteError)?; + let mut query_stmt_nonce_rank = self.db.prepare(&sql).map_err(Error::SqliteError)?; let stop_reason = loop { if start_time.elapsed().as_millis() > settings.max_walk_time_ms as u128 { @@ -1812,9 +1803,7 @@ impl MemPoolDB { < settings.consider_no_estimate_tx_prob; // randomly select from either the null fee-rate transactions or those with fee-rate estimates. let opt_tx = if start_with_no_estimate { - null_iterator - .next() - .map_err(Error::SqliteError)? + null_iterator.next().map_err(Error::SqliteError)? } else { fee_iterator.next().map_err(Error::SqliteError)? }; @@ -1825,13 +1814,9 @@ impl MemPoolDB { None => { // If the selected iterator is empty, check the other match if start_with_no_estimate { - fee_iterator - .next() - .map_err(Error::SqliteError)? + fee_iterator.next().map_err(Error::SqliteError)? } else { - null_iterator - .next() - .map_err(Error::SqliteError)? + null_iterator.next().map_err(Error::SqliteError)? } { Some(row) => ( MemPoolTxInfoPartial::from_row(row)?, From 4405afad8bb0b26c45c63bd601105e426453261f Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Thu, 19 Dec 2024 15:52:54 -0500 Subject: [PATCH 18/19] feat: redesign nonce cache This redesign uses a proper LRU cache and is more careful about flushing the cache more efficiently. --- stacks-common/src/types/sqlite.rs | 9 +- stacks-common/src/util/lru_cache.rs | 256 ++++++++++++++++++ stacks-common/src/util/mod.rs | 1 + .../stacks/tests/block_construction.rs | 2 +- stackslib/src/config/mod.rs | 8 +- stackslib/src/core/mempool.rs | 202 ++------------ stackslib/src/core/mod.rs | 1 + stackslib/src/core/nonce_cache.rs | 253 +++++++++++++++++ .../stacks-node/src/nakamoto_node/miner.rs | 7 + 9 files changed, 548 insertions(+), 191 deletions(-) create mode 100644 stacks-common/src/util/lru_cache.rs create mode 100644 stackslib/src/core/nonce_cache.rs diff --git a/stacks-common/src/types/sqlite.rs b/stacks-common/src/types/sqlite.rs index 183ec61fbc..57010ea118 100644 --- a/stacks-common/src/types/sqlite.rs +++ b/stacks-common/src/types/sqlite.rs @@ -16,7 +16,7 @@ use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef}; -use super::chainstate::VRFSeed; +use super::chainstate::{StacksAddress, VRFSeed}; use crate::deps_common::bitcoin::util::hash::Sha256dHash; use crate::types::chainstate::{ BlockHeaderHash, BurnchainHeaderHash, ConsensusHash, SortitionId, StacksBlockId, TrieHash, @@ -42,6 +42,13 @@ impl ToSql for Sha256dHash { } } +impl rusqlite::types::ToSql for StacksAddress { + fn to_sql(&self) -> rusqlite::Result { + let addr_str = self.to_string(); + Ok(addr_str.into()) + } +} + // Implement rusqlite traits for a bunch of structs that used to be defined // in the chainstate code impl_byte_array_rusqlite_only!(ConsensusHash); diff --git a/stacks-common/src/util/lru_cache.rs b/stacks-common/src/util/lru_cache.rs new file mode 100644 index 0000000000..97b55e69bc --- /dev/null +++ b/stacks-common/src/util/lru_cache.rs @@ -0,0 +1,256 @@ +// Copyright (C) 2024 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::collections::HashMap; + +/// Node in the doubly linked list +struct Node { + key: K, + value: V, + dirty: bool, + next: usize, + prev: usize, +} + +/// LRU cache for account nonces +pub struct LruCache { + capacity: usize, + /// Map from address to an offset in the linked list + cache: HashMap, + /// Doubly linked list of values in order of most recently used + order: Vec>, + /// Index of the head of the linked list -- the most recently used element + head: usize, + /// Index of the tail of the linked list -- the least recently used element + tail: usize, +} + +impl LruCache { + /// Create a new LRU cache with the given capacity + pub fn new(capacity: usize) -> Self { + LruCache { + capacity, + cache: HashMap::new(), + order: Vec::with_capacity(capacity), + head: capacity, + tail: capacity, + } + } + + /// Get the value for the given key + pub fn get(&mut self, key: &K) -> Option { + if let Some(node) = self.cache.get(key) { + // Move the node to the head of the LRU list + let node = *node; + + if node != self.head { + let prev = self.order[node].prev; + let next = self.order[node].next; + + if node == self.tail { + // If this is the tail, update the tail + self.tail = prev; + } else { + // Else, update the next node's prev pointer + self.order[next].prev = prev; + } + + self.order[prev].next = next; + self.order[node].prev = self.capacity; + self.order[node].next = self.head; + self.order[self.head].prev = node; + self.head = node; + } + + Some(self.order[node].value) + } else { + None + } + } + + /// Insert a key-value pair into the cache, marking it as dirty. + /// Returns `Some((K, V))` if a dirty value was evicted. + pub fn insert(&mut self, key: K, value: V) -> Option<(K, V)> { + self.insert_with_dirty(key, value, true) + } + + /// Insert a key-value pair into the cache, marking it as clean. + /// Returns `Some((K, V))` if a dirty value was evicted. + pub fn insert_clean(&mut self, key: K, value: V) -> Option<(K, V)> { + self.insert_with_dirty(key, value, false) + } + + /// Insert a key-value pair into the cache + /// Returns `Some((K, V))` if a dirty value was evicted. + pub fn insert_with_dirty(&mut self, key: K, value: V, dirty: bool) -> Option<(K, V)> { + let mut evicted = None; + if let Some(node) = self.cache.get(&key) { + // Update the value for the key + let node = *node; + self.order[node].value = value; + self.order[node].dirty = dirty; + + // Just call get to handle updating the LRU list + self.get(&key); + } else { + let index = if self.cache.len() == self.capacity { + // Take the place of the least recently used element. + // First, remove it from the tail of the LRU list + let index = self.tail; + let prev = self.order[index].prev; + self.order[prev].next = self.capacity; + self.tail = prev; + + // Remove it from the cache + self.cache.remove(&self.order[index].key); + + // If it is dirty, save the key-value pair to return + if self.order[index].dirty { + evicted = Some(( + std::mem::replace(&mut self.order[index].key, key.clone()), + self.order[index].value, + )); + } + + // Insert this new value into the cache + self.cache.insert(key, index); + + // Update the node with the new key-value pair, inserting it at + // the head of the LRU list + self.order[index].value = value; + self.order[index].dirty = dirty; + self.order[index].next = self.head; + self.order[index].prev = self.capacity; + + index + } else { + // Insert a new key-value pair + let node = Node { + key: key.clone(), + value, + dirty: dirty, + next: self.head, + prev: self.capacity, + }; + + let index = self.order.len(); + self.order.push(node); + self.cache.insert(key, index); + + index + }; + + // Put it at the head of the LRU list + if self.head != self.capacity { + self.order[self.head].prev = index; + } else { + self.tail = index; + } + + self.head = index; + } + evicted + } + + pub fn flush(&mut self, mut f: impl FnMut(&K, V) -> Result<(), E>) -> Result<(), E> { + let mut index = self.head; + while index != self.capacity { + println!("checking {index}, dirty? {}", self.order[index].dirty); + let next = self.order[index].next; + if self.order[index].dirty { + let value = self.order[index].value; + f(&self.order[index].key, value)?; + self.order[index].dirty = false; + } + index = next; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lru_cache() { + let mut cache = LruCache::new(2); + + cache.insert(1, 1); + cache.insert(2, 2); + assert_eq!(cache.get(&1), Some(1)); + cache.insert(3, 3); + assert_eq!(cache.get(&2), None); + cache.insert(4, 4); + assert_eq!(cache.get(&1), None); + assert_eq!(cache.get(&3), Some(3)); + assert_eq!(cache.get(&4), Some(4)); + } + + #[test] + fn test_lru_cache_update() { + let mut cache = LruCache::new(2); + + cache.insert(1, 1); + cache.insert(2, 2); + cache.insert(1, 10); + assert_eq!(cache.get(&1), Some(10)); + cache.insert(3, 3); + assert_eq!(cache.get(&2), None); + cache.insert(2, 4); + assert_eq!(cache.get(&2), Some(4)); + assert_eq!(cache.get(&3), Some(3)); + } + + #[test] + fn test_lru_cache_evicted() { + let mut cache = LruCache::new(2); + + assert!(cache.insert(1, 1).is_none()); + assert!(cache.insert(2, 2).is_none()); + let evicted = cache.insert(3, 3).expect("expected an eviction"); + assert_eq!(evicted, (1, 1)); + } + + #[test] + fn test_lru_cache_flush() { + let mut cache = LruCache::new(2); + + cache.insert(1, 1); + + let mut flushed = Vec::new(); + cache + .flush(|k, v| { + flushed.push((*k, v)); + Ok::<(), ()>(()) + }) + .unwrap(); + + assert_eq!(flushed, vec![(1, 1)]); + + cache.insert(1, 3); + cache.insert(2, 2); + + let mut flushed = Vec::new(); + cache + .flush(|k, v| { + flushed.push((*k, v)); + Ok::<(), ()>(()) + }) + .unwrap(); + + assert_eq!(flushed, vec![(2, 2), (1, 3)]); + } +} diff --git a/stacks-common/src/util/mod.rs b/stacks-common/src/util/mod.rs index 5f733eddad..f3fc7bf2bc 100644 --- a/stacks-common/src/util/mod.rs +++ b/stacks-common/src/util/mod.rs @@ -21,6 +21,7 @@ pub mod macros; pub mod chunked_encoding; pub mod db; pub mod hash; +pub mod lru_cache; pub mod pair; pub mod pipe; pub mod retry; diff --git a/stackslib/src/chainstate/stacks/tests/block_construction.rs b/stackslib/src/chainstate/stacks/tests/block_construction.rs index 79491afa9e..6985f80b9b 100644 --- a/stackslib/src/chainstate/stacks/tests/block_construction.rs +++ b/stackslib/src/chainstate/stacks/tests/block_construction.rs @@ -4913,7 +4913,7 @@ fn mempool_walk_test_users_10_rounds_3_cache_size_2000_null_prob_100() { fn paramaterized_mempool_walk_test( num_users: usize, num_rounds: usize, - nonce_and_candidate_cache_size: u64, + nonce_and_candidate_cache_size: usize, consider_no_estimate_tx_prob: u8, timeout_ms: u128, ) { diff --git a/stackslib/src/config/mod.rs b/stackslib/src/config/mod.rs index 48b5ddd457..198a59f4c9 100644 --- a/stackslib/src/config/mod.rs +++ b/stackslib/src/config/mod.rs @@ -2113,8 +2113,8 @@ pub struct MinerConfig { /// Wait for a downloader pass before mining. /// This can only be disabled in testing; it can't be changed in the config file. pub wait_for_block_download: bool, - pub nonce_cache_size: u64, - pub candidate_retry_cache_size: u64, + pub nonce_cache_size: usize, + pub candidate_retry_cache_size: usize, pub unprocessed_block_deadline_secs: u64, pub mining_key: Option, /// Amount of time while mining in nakamoto to wait in between mining interim blocks @@ -2569,8 +2569,8 @@ pub struct MinerConfigFile { pub probability_pick_no_estimate_tx: Option, pub block_reward_recipient: Option, pub segwit: Option, - pub nonce_cache_size: Option, - pub candidate_retry_cache_size: Option, + pub nonce_cache_size: Option, + pub candidate_retry_cache_size: Option, pub unprocessed_block_deadline_secs: Option, pub mining_key: Option, pub wait_on_interim_blocks_ms: Option, diff --git a/stackslib/src/core/mempool.rs b/stackslib/src/core/mempool.rs index 066c8ba2ac..612ac3ff7d 100644 --- a/stackslib/src/core/mempool.rs +++ b/stackslib/src/core/mempool.rs @@ -15,7 +15,7 @@ // along with this program. If not, see . use std::cmp::{self, Ordering}; -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{HashMap, HashSet, LinkedList, VecDeque}; use std::hash::Hasher; use std::io::{Read, Write}; use std::ops::{Deref, DerefMut}; @@ -56,6 +56,7 @@ use crate::chainstate::stacks::{ Error as ChainstateError, StacksBlock, StacksMicroblock, StacksTransaction, TransactionPayload, }; use crate::clarity_vm::clarity::ClarityConnection; +use crate::core::nonce_cache::NonceCache; use crate::core::{ ExecutionCost, StacksEpochId, FIRST_BURNCHAIN_CONSENSUS_HASH, FIRST_STACKS_BLOCK_HASH, }; @@ -556,10 +557,10 @@ pub struct MemPoolWalkSettings { /// Only used when walk strategy is `GlobalFeeRate`. pub consider_no_estimate_tx_prob: u8, /// Size of the nonce cache. This avoids MARF look-ups. - pub nonce_cache_size: u64, + pub nonce_cache_size: usize, /// Size of the candidate cache. These are the candidates that will be retried after each /// transaction is mined. - pub candidate_retry_cache_size: u64, + pub candidate_retry_cache_size: usize, /// Types of transactions we'll consider pub txs_to_consider: HashSet, /// Origins for transactions that we'll consider @@ -1055,128 +1056,6 @@ impl<'a> MemPoolTx<'a> { } } -/// Used to locally cache nonces to avoid repeatedly looking them up in the nonce. -struct NonceCache { - cache: HashMap, - /// The maximum size that this cache can be. - max_cache_size: usize, -} - -impl NonceCache { - fn new(nonce_cache_size: u64) -> Self { - let max_size: usize = nonce_cache_size - .try_into() - .expect("Could not cast `nonce_cache_size` as `usize`."); - Self { - cache: HashMap::new(), - max_cache_size: max_size, - } - } - - /// Get a nonce from the cache. - /// First, the RAM cache will be checked for this address. - /// If absent, then the `nonces` table will be queried for this address. - /// If absent, then the MARF will be queried for this address. - /// - /// If not in RAM, the nonce will be opportunistically stored to the `nonces` table. If that - /// fails due to lock contention, then the method will return `true` for its second tuple argument. - /// - /// Returns (nonce, should-try-store-again?) - fn get( - &mut self, - address: &StacksAddress, - clarity_tx: &mut C, - mempool_db: &DBConn, - ) -> (u64, bool) - where - C: ClarityConnection, - { - #[cfg(test)] - assert!(self.cache.len() <= self.max_cache_size); - - // Check in-memory cache - match self.cache.get(address) { - Some(nonce) => (*nonce, false), - None => { - // Check sqlite cache - let opt_nonce = match db_get_nonce(mempool_db, address) { - Ok(opt_nonce) => opt_nonce, - Err(e) => { - warn!("error retrieving nonce from mempool db: {}", e); - None - } - }; - match opt_nonce { - Some(nonce) => { - // Copy this into the in-memory cache if there is space - if self.cache.len() < self.max_cache_size { - self.cache.insert(address.clone(), nonce); - } - (nonce, false) - } - None => { - let nonce = - StacksChainState::get_nonce(clarity_tx, &address.clone().into()); - - let should_store_again = match db_set_nonce(mempool_db, address, nonce) { - Ok(_) => false, - Err(e) => { - debug!("error caching nonce to sqlite: {}", e); - true - } - }; - - if self.cache.len() < self.max_cache_size { - self.cache.insert(address.clone(), nonce); - } - (nonce, should_store_again) - } - } - } - } - } - - /// Store the (address, nonce) pair to the `nonces` table. - /// If storage fails, return false. - /// Otherwise return true. - fn update(&mut self, address: StacksAddress, value: u64, mempool_db: &DBConn) -> bool { - // Sqlite cache - let success = match db_set_nonce(mempool_db, &address, value) { - Ok(_) => true, - Err(e) => { - warn!("error caching nonce to sqlite: {}", e); - false - } - }; - - // In-memory cache - match self.cache.get_mut(&address) { - Some(nonce) => { - *nonce = value; - } - None => (), - } - - success - } -} - -fn db_set_nonce(conn: &DBConn, address: &StacksAddress, nonce: u64) -> Result<(), db_error> { - let addr_str = address.to_string(); - let nonce_i64 = u64_to_sql(nonce)?; - - let sql = "INSERT OR REPLACE INTO nonces (address, nonce) VALUES (?1, ?2)"; - conn.execute(sql, params![addr_str, nonce_i64])?; - Ok(()) -} - -fn db_get_nonce(conn: &DBConn, address: &StacksAddress) -> Result, db_error> { - let addr_str = address.to_string(); - - let sql = "SELECT nonce FROM nonces WHERE address = ?"; - query_row(conn, sql, params![addr_str]) -} - #[cfg(test)] pub fn db_get_all_nonces(conn: &DBConn) -> Result, db_error> { let sql = "SELECT * FROM nonces"; @@ -1206,14 +1085,11 @@ struct CandidateCache { } impl CandidateCache { - fn new(candidate_retry_cache_size: u64) -> Self { - let max_size: usize = candidate_retry_cache_size - .try_into() - .expect("Could not cast `candidate_retry_cache_size` as usize."); + fn new(candidate_retry_cache_size: usize) -> Self { Self { cache: VecDeque::new(), next: VecDeque::new(), - max_cache_size: max_size, + max_cache_size: candidate_retry_cache_size, } } @@ -1702,10 +1578,6 @@ impl MemPoolDB { let mut candidate_cache = CandidateCache::new(settings.candidate_retry_cache_size); let mut nonce_cache = NonceCache::new(settings.nonce_cache_size); - // set of (address, nonce) to store after the inner loop completes. This will be done in a - // single transaction. This cannot grow to more than `settings.nonce_cache_size` entries. - let mut retry_store = HashMap::new(); - // == Queries for `GlobalFeeRate` mempool walk strategy // // Selects mempool transactions only based on their fee rate. Transactions with NULL fee rates get randomly selected for @@ -1853,29 +1725,11 @@ impl MemPoolDB { }; // Check the nonces. - let (expected_origin_nonce, retry_store_origin_nonce) = - nonce_cache.get(&candidate.origin_address, clarity_tx, self.conn()); - let (expected_sponsor_nonce, retry_store_sponsor_nonce) = - nonce_cache.get(&candidate.sponsor_address, clarity_tx, self.conn()); - - // Try storing these nonces later if we failed to do so here, e.g. due to some other - // thread holding the write-lock on the mempool DB. - if retry_store_origin_nonce { - Self::save_nonce_for_retry( - &mut retry_store, - settings.nonce_cache_size, - candidate.origin_address.clone(), - expected_origin_nonce, - ); - } - if retry_store_sponsor_nonce { - Self::save_nonce_for_retry( - &mut retry_store, - settings.nonce_cache_size, - candidate.sponsor_address.clone(), - expected_sponsor_nonce, - ); - } + let mut nonce_conn = self.reopen(false)?; + let expected_origin_nonce = + nonce_cache.get(&candidate.origin_address, clarity_tx, &mut nonce_conn); + let expected_sponsor_nonce = + nonce_cache.get(&candidate.sponsor_address, clarity_tx, &mut nonce_conn); match order_nonces( candidate.origin_nonce, @@ -1991,34 +1845,17 @@ impl MemPoolDB { match tx_event { TransactionEvent::Success(_) => { // Bump nonces in the cache for the executed transaction - let stored = nonce_cache.update( + nonce_cache.set( consider.tx.metadata.origin_address, expected_origin_nonce + 1, - self.conn(), + &mut nonce_conn, ); - if !stored { - Self::save_nonce_for_retry( - &mut retry_store, - settings.nonce_cache_size, - consider.tx.metadata.origin_address, - expected_origin_nonce + 1, - ); - } - if consider.tx.tx.auth.is_sponsored() { - let stored = nonce_cache.update( + nonce_cache.set( consider.tx.metadata.sponsor_address, expected_sponsor_nonce + 1, - self.conn(), + &mut nonce_conn, ); - if !stored { - Self::save_nonce_for_retry( - &mut retry_store, - settings.nonce_cache_size, - consider.tx.metadata.sponsor_address, - expected_sponsor_nonce + 1, - ); - } } output_events.push(tx_event); } @@ -2053,13 +1890,8 @@ impl MemPoolDB { drop(query_stmt_fee); drop(query_stmt_nonce_rank); - if retry_store.len() > 0 { - let tx = self.tx_begin()?; - for (address, nonce) in retry_store.into_iter() { - nonce_cache.update(address, nonce, &tx); - } - tx.commit()?; - } + // Write through the nonce cache to the database + nonce_cache.flush(&mut self.db); debug!( "Mempool iteration finished"; diff --git a/stackslib/src/core/mod.rs b/stackslib/src/core/mod.rs index ba4dbf14d2..7b7fd4ee15 100644 --- a/stackslib/src/core/mod.rs +++ b/stackslib/src/core/mod.rs @@ -30,6 +30,7 @@ use crate::burnchains::bitcoin::BitcoinNetworkType; use crate::burnchains::{Burnchain, Error as burnchain_error}; use crate::chainstate::burn::ConsensusHash; pub mod mempool; +pub mod nonce_cache; #[cfg(test)] pub mod tests; diff --git a/stackslib/src/core/nonce_cache.rs b/stackslib/src/core/nonce_cache.rs new file mode 100644 index 0000000000..54c9acb563 --- /dev/null +++ b/stackslib/src/core/nonce_cache.rs @@ -0,0 +1,253 @@ +// Copyright (C) 2024 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::collections::HashMap; +use std::thread; +use std::time::Duration; + +use clarity::types::chainstate::StacksAddress; +use clarity::util::lru_cache::LruCache; +use clarity::vm::clarity::ClarityConnection; +use rand::Rng; +use rusqlite::params; + +use super::mempool::MemPoolTx; +use super::MemPoolDB; +use crate::chainstate::stacks::db::StacksChainState; +use crate::util_lib::db::{query_row, u64_to_sql, DBConn, Error as db_error}; + +/// Used to cache nonces in memory and in the mempool database. +/// 1. MARF - source of truth for nonces +/// 2. Nonce DB - table in mempool sqlite database +/// 3. HashMap - in-memory cache for nonces +/// The in-memory cache is restricted to a maximum size to avoid memory +/// exhaustion. When the cache is full, it should be flushed to the database +/// and cleared. It is recommended to do this in between batches of candidate +/// transactions from the mempool. +pub struct NonceCache { + /// In-memory LRU cache of nonces. + cache: LruCache, +} + +impl NonceCache { + pub fn new(max_size: usize) -> Self { + Self { + cache: LruCache::new(max_size), + } + } + + /// Get a nonce. + /// First, the RAM cache will be checked for this address. + /// If absent, then the `nonces` table will be queried for this address. + /// If absent, then the MARF will be queried for this address. + /// + /// If not in RAM, the nonce will be opportunistically stored to the `nonces` table. If that + /// fails due to lock contention, then the method will return `true` for its second tuple argument. + /// + /// Returns (nonce, should-try-store-again?) + pub fn get( + &mut self, + address: &StacksAddress, + clarity_tx: &mut C, + mempool_db: &mut DBConn, + ) -> u64 + where + C: ClarityConnection, + { + // Check in-memory cache + match self.cache.get(address) { + Some(nonce) => nonce, + None => { + // Check sqlite cache + let opt_nonce = match db_get_nonce(mempool_db, address) { + Ok(opt_nonce) => opt_nonce, + Err(e) => { + warn!("error retrieving nonce from mempool db: {}", e); + None + } + }; + match opt_nonce { + Some(nonce) => { + // Insert into in-memory cache, but it is not dirty, + // since we just got it from the database. + let evicted = self.cache.insert_clean(address.clone(), nonce); + if evicted.is_some() { + // If we evicted something, we need to flush the cache. + self.flush_with_evicted(mempool_db, evicted); + } + nonce + } + None => { + let nonce = + StacksChainState::get_nonce(clarity_tx, &address.clone().into()); + + self.set(address.clone(), nonce, mempool_db); + nonce + } + } + } + } + } + + /// Store the (address, nonce) pair to the `nonces` table. + /// If storage fails, return false. + /// Otherwise return true. + pub fn set(&mut self, address: StacksAddress, value: u64, conn: &mut DBConn) { + let evicted = self.cache.insert(address.clone(), value); + if evicted.is_some() { + // If we evicted something, we need to flush the cache. + self.flush_with_evicted(conn, evicted); + } + } + + pub fn flush_with_evicted(&mut self, conn: &mut DBConn, evicted: Option<(StacksAddress, u64)>) { + const MAX_BACKOFF: Duration = Duration::from_secs(30); + let mut backoff = Duration::from_millis(rand::thread_rng().gen_range(50..200)); + + loop { + let result = self.try_flush_with_evicted(conn, evicted); + + match result { + Ok(_) => return, // Success: exit the loop + Err(e) => { + // Calculate a backoff duration + warn!("Nonce cache flush failed: {e}. Retrying in {backoff:?}"); + + // Sleep for the backoff duration + thread::sleep(backoff); + + if backoff < MAX_BACKOFF { + // Exponential backoff + backoff = backoff * 2 + + Duration::from_millis(rand::thread_rng().gen_range(50..200)); + } + } + } + } + } + + pub fn try_flush_with_evicted( + &mut self, + conn: &mut DBConn, + evicted: Option<(StacksAddress, u64)>, + ) -> Result<(), db_error> { + // Flush the cache to the database + let sql = "INSERT OR REPLACE INTO nonces (address, nonce) VALUES (?1, ?2)"; + + let tx = conn.transaction()?; + + if let Some((addr, nonce)) = evicted { + tx.execute(sql, params![addr, nonce])?; + } + + self.cache.flush(|addr, nonce| { + tx.execute(sql, params![addr, nonce])?; + Ok::<(), db_error>(()) + })?; + + tx.commit()?; + + Ok(()) + } + + pub fn flush(&mut self, conn: &mut DBConn) { + self.flush_with_evicted(conn, None) + } +} + +fn db_set_nonce(conn: &DBConn, address: &StacksAddress, nonce: u64) -> Result<(), db_error> { + let addr_str = address.to_string(); + let nonce_i64 = u64_to_sql(nonce)?; + + let sql = "INSERT OR REPLACE INTO nonces (address, nonce) VALUES (?1, ?2)"; + conn.execute(sql, params![addr_str, nonce_i64])?; + Ok(()) +} + +fn db_get_nonce(conn: &DBConn, address: &StacksAddress) -> Result, db_error> { + let addr_str = address.to_string(); + + let sql = "SELECT nonce FROM nonces WHERE address = ?"; + query_row(conn, sql, params![addr_str]) +} + +#[cfg(test)] +mod tests { + use clarity::consts::CHAIN_ID_TESTNET; + use clarity::types::chainstate::StacksBlockId; + use clarity::types::Address; + use clarity::vm::tests::{TEST_BURN_STATE_DB, TEST_HEADER_DB}; + + use super::*; + use crate::chainstate::stacks::db::test::{chainstate_path, instantiate_chainstate}; + use crate::chainstate::stacks::index::ClarityMarfTrieId; + use crate::clarity_vm::clarity::ClarityInstance; + use crate::clarity_vm::database::marf::MarfedKV; + + #[test] + fn test_nonce_cache() { + let _chainstate = instantiate_chainstate(false, 0x80000000, function_name!()); + let chainstate_path = chainstate_path(function_name!()); + let mut mempool = MemPoolDB::open_test(false, CHAIN_ID_TESTNET, &chainstate_path).unwrap(); + let mut cache = NonceCache::new(2); + + let addr1 = + StacksAddress::from_string("ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM").unwrap(); + let addr2 = + StacksAddress::from_string("ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5").unwrap(); + let addr3 = + StacksAddress::from_string("ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG").unwrap(); + + let conn = &mut mempool.db; + cache.set(addr1.clone(), 1, conn); + cache.set(addr2.clone(), 2, conn); + + let marf = MarfedKV::temporary(); + let mut clarity_instance = ClarityInstance::new(false, CHAIN_ID_TESTNET, marf); + clarity_instance + .begin_test_genesis_block( + &StacksBlockId::sentinel(), + &StacksBlockId([0 as u8; 32]), + &TEST_HEADER_DB, + &TEST_BURN_STATE_DB, + ) + .commit_block(); + let mut clarity_conn = clarity_instance.begin_block( + &StacksBlockId([0 as u8; 32]), + &StacksBlockId([1 as u8; 32]), + &TEST_HEADER_DB, + &TEST_BURN_STATE_DB, + ); + + clarity_conn.as_transaction(|clarity_tx| { + assert_eq!(cache.get(&addr1, clarity_tx, conn), 1); + assert_eq!(cache.get(&addr2, clarity_tx, conn), 2); + // addr3 is not in the cache, so it should be fetched from the + // clarity instance (and get 0) + assert_eq!(cache.get(&addr3, clarity_tx, conn), 0); + }); + } + + #[test] + fn test_db_set_nonce() { + let _chainstate = instantiate_chainstate(false, 0x80000000, function_name!()); + let chainstate_path = chainstate_path(function_name!()); + let mut mempool = MemPoolDB::open_test(false, CHAIN_ID_TESTNET, &chainstate_path).unwrap(); + let conn = &mut mempool.db; + let addr = StacksAddress::from_string("ST2JHG361ZXG51QTKY2NQCVBPPRRE2KZB1HR05NNC").unwrap(); + db_set_nonce(&conn, &addr, 123).unwrap(); + assert_eq!(db_get_nonce(&conn, &addr).unwrap().unwrap(), 123); + } +} diff --git a/testnet/stacks-node/src/nakamoto_node/miner.rs b/testnet/stacks-node/src/nakamoto_node/miner.rs index d9edf97e90..150fd82f56 100644 --- a/testnet/stacks-node/src/nakamoto_node/miner.rs +++ b/testnet/stacks-node/src/nakamoto_node/miner.rs @@ -329,6 +329,13 @@ impl BlockMinerThread { )) })?; + // Reset the nonce cache, since it is only updated while mining + let mut mem_pool = self + .config + .connect_mempool_db() + .expect("Database failure opening mempool"); + mem_pool.reset_nonce_cache()?; + // now, actually run this tenure loop { if let Err(e) = self.miner_main_loop( From 0bd508f2f4ad0b5ff942643b0b4db2d2cc3dabbe Mon Sep 17 00:00:00 2001 From: Brice Dobry Date: Thu, 19 Dec 2024 20:19:08 -0500 Subject: [PATCH 19/19] chore: remove debug print --- stacks-common/src/util/lru_cache.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/stacks-common/src/util/lru_cache.rs b/stacks-common/src/util/lru_cache.rs index 97b55e69bc..41f55613e2 100644 --- a/stacks-common/src/util/lru_cache.rs +++ b/stacks-common/src/util/lru_cache.rs @@ -167,7 +167,6 @@ impl LruCache { pub fn flush(&mut self, mut f: impl FnMut(&K, V) -> Result<(), E>) -> Result<(), E> { let mut index = self.head; while index != self.capacity { - println!("checking {index}, dirty? {}", self.order[index].dirty); let next = self.order[index].next; if self.order[index].dirty { let value = self.order[index].value;