-
Notifications
You must be signed in to change notification settings - Fork 0
feat: per-wallet filter scan and runtime wallet catch-up #118
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -11,7 +11,7 @@ use crate::network::RequestSender; | |||||
| use crate::sync::download_coordinator::{DownloadConfig, DownloadCoordinator}; | ||||||
| use dashcore::blockdata::block::Block; | ||||||
| use dashcore::BlockHash; | ||||||
| use key_wallet_manager::FilterMatchKey; | ||||||
| use key_wallet_manager::{FilterMatchKey, WalletId}; | ||||||
|
|
||||||
| /// Maximum number of concurrent block downloads. | ||||||
| const MAX_CONCURRENT_BLOCK_DOWNLOADS: usize = 20; | ||||||
|
|
@@ -36,6 +36,9 @@ pub(super) struct BlocksPipeline { | |||||
| downloaded: BTreeMap<u32, Block>, | ||||||
| /// Map hash -> height for looking up height when block arrives. | ||||||
| hash_to_height: HashMap<BlockHash, u32>, | ||||||
| /// Per-block interested wallets, populated when the block is queued. | ||||||
| /// Only those wallets get the block processed. | ||||||
| hash_to_wallets: HashMap<BlockHash, BTreeSet<WalletId>>, | ||||||
| } | ||||||
|
|
||||||
| impl std::fmt::Debug for BlocksPipeline { | ||||||
|
|
@@ -66,17 +69,26 @@ impl BlocksPipeline { | |||||
| pending_heights: BTreeSet::new(), | ||||||
| downloaded: BTreeMap::new(), | ||||||
| hash_to_height: HashMap::new(), | ||||||
| hash_to_wallets: HashMap::new(), | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /// Queue blocks with their heights for download. | ||||||
| /// Queue blocks with their heights and per-block interested wallet sets. | ||||||
| /// | ||||||
| /// This is the preferred method as it enables height-ordered processing. | ||||||
| pub(super) fn queue(&mut self, blocks: impl IntoIterator<Item = FilterMatchKey>) { | ||||||
| for key in blocks { | ||||||
| self.coordinator.enqueue([*key.hash()]); | ||||||
| /// Each entry's wallet set is the union of wallets whose addresses matched | ||||||
| /// the filter for that block. If the block is already queued we merge the | ||||||
| /// new wallet ids into the existing set so a late-discovered wallet still | ||||||
| /// gets the block processed when it arrives. | ||||||
| pub(super) fn queue( | ||||||
| &mut self, | ||||||
| blocks: impl IntoIterator<Item = (FilterMatchKey, BTreeSet<WalletId>)>, | ||||||
| ) { | ||||||
| for (key, wallets) in blocks { | ||||||
| let hash = *key.hash(); | ||||||
| self.coordinator.enqueue([hash]); | ||||||
| self.pending_heights.insert(key.height()); | ||||||
| self.hash_to_height.insert(*key.hash(), key.height()); | ||||||
| self.hash_to_height.insert(hash, key.height()); | ||||||
| self.hash_to_wallets.entry(hash).or_default().extend(wallets); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -141,12 +153,13 @@ impl BlocksPipeline { | |||||
| true | ||||||
| } | ||||||
|
|
||||||
| /// Take the next block that's safe to process in height order. | ||||||
| /// Take the next block that's safe to process in height order, along with | ||||||
| /// the wallet set whose filters matched this block. | ||||||
| /// | ||||||
| /// Returns None if: | ||||||
| /// - No downloaded blocks available, or | ||||||
| /// - Waiting for a lower-height block still pending | ||||||
| pub(super) fn take_next_ordered_block(&mut self) -> Option<(Block, u32)> { | ||||||
| pub(super) fn take_next_ordered_block(&mut self) -> Option<(Block, u32, BTreeSet<WalletId>)> { | ||||||
| let lowest_downloaded = *self.downloaded.keys().next()?; | ||||||
|
|
||||||
| // Check if any pending blocks have lower heights | ||||||
|
|
@@ -156,15 +169,22 @@ impl BlocksPipeline { | |||||
| } | ||||||
| } | ||||||
|
|
||||||
| // Safe to return this block | ||||||
| let block = self.downloaded.remove(&lowest_downloaded).unwrap(); | ||||||
| Some((block, lowest_downloaded)) | ||||||
| let wallets = self.hash_to_wallets.remove(&block.block_hash()).unwrap_or_default(); | ||||||
| Some((block, lowest_downloaded, wallets)) | ||||||
| } | ||||||
|
|
||||||
| /// Add a block that was loaded from storage (skip download). | ||||||
| /// | ||||||
| /// Used when blocks are already persisted from a previous sync. | ||||||
| pub(super) fn add_from_storage(&mut self, block: Block, height: u32) { | ||||||
| pub(super) fn add_from_storage( | ||||||
| &mut self, | ||||||
| block: Block, | ||||||
| height: u32, | ||||||
| wallets: BTreeSet<WalletId>, | ||||||
| ) { | ||||||
| let hash = block.block_hash(); | ||||||
| self.hash_to_wallets.entry(hash).or_default().extend(wallets); | ||||||
| self.downloaded.insert(height, block); | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -212,7 +232,7 @@ mod tests { | |||||
| fn test_queue_block() { | ||||||
| let mut pipeline = BlocksPipeline::new(); | ||||||
| let block = make_test_block(1); | ||||||
| pipeline.queue([FilterMatchKey::new(100, block.block_hash())]); | ||||||
| pipeline.queue([(FilterMatchKey::new(100, block.block_hash()), BTreeSet::new())]); | ||||||
|
|
||||||
| assert_eq!(pipeline.coordinator.pending_count(), 1); | ||||||
| assert!(!pipeline.is_complete()); | ||||||
|
|
@@ -226,9 +246,9 @@ mod tests { | |||||
| let block2 = make_test_block(2); | ||||||
| let block3 = make_test_block(3); | ||||||
| pipeline.queue([ | ||||||
| FilterMatchKey::new(100, block1.block_hash()), | ||||||
| FilterMatchKey::new(101, block2.block_hash()), | ||||||
| FilterMatchKey::new(102, block3.block_hash()), | ||||||
| (FilterMatchKey::new(100, block1.block_hash()), BTreeSet::new()), | ||||||
| (FilterMatchKey::new(101, block2.block_hash()), BTreeSet::new()), | ||||||
| (FilterMatchKey::new(102, block3.block_hash()), BTreeSet::new()), | ||||||
| ]); | ||||||
|
|
||||||
| assert_eq!(pipeline.coordinator.pending_count(), 3); | ||||||
|
|
@@ -245,7 +265,7 @@ mod tests { | |||||
| let hash = block.block_hash(); | ||||||
|
|
||||||
| // Queue with height tracking | ||||||
| pipeline.queue([FilterMatchKey::new(100, block.block_hash())]); | ||||||
| pipeline.queue([(FilterMatchKey::new(100, block.block_hash()), BTreeSet::new())]); | ||||||
|
|
||||||
| // Simulate sending via coordinator | ||||||
| let hashes = pipeline.coordinator.take_pending(1); | ||||||
|
|
@@ -276,7 +296,7 @@ mod tests { | |||||
| // Queue more blocks than max concurrent | ||||||
| for i in 0..=MAX_CONCURRENT_BLOCK_DOWNLOADS { | ||||||
| let block = make_test_block(i as u8); | ||||||
| pipeline.queue([FilterMatchKey::new(i as u32, block.block_hash())]); | ||||||
| pipeline.queue([(FilterMatchKey::new(i as u32, block.block_hash()), BTreeSet::new())]); | ||||||
| } | ||||||
|
|
||||||
| // Take and mark as downloading up to limit | ||||||
|
|
@@ -301,6 +321,7 @@ mod tests { | |||||
| pending_heights: BTreeSet::new(), | ||||||
| downloaded: BTreeMap::new(), | ||||||
| hash_to_height: HashMap::new(), | ||||||
| hash_to_wallets: HashMap::new(), | ||||||
| }; | ||||||
|
|
||||||
| // Use coordinator directly to set up in-flight state | ||||||
|
|
@@ -328,7 +349,7 @@ mod tests { | |||||
|
|
||||||
| // Use add_from_storage to test ordering logic without network | ||||||
| // Add block 2 first (out of order) | ||||||
| pipeline.add_from_storage(block2.clone(), 101); | ||||||
| pipeline.add_from_storage(block2.clone(), 101, BTreeSet::new()); | ||||||
| // Also track height 100 as pending to simulate waiting | ||||||
| pipeline.pending_heights.insert(100); | ||||||
|
|
||||||
|
|
@@ -337,15 +358,15 @@ mod tests { | |||||
|
|
||||||
| // Add block 1 | ||||||
| pipeline.pending_heights.remove(&100); | ||||||
| pipeline.add_from_storage(block1.clone(), 100); | ||||||
| pipeline.add_from_storage(block1.clone(), 100, BTreeSet::new()); | ||||||
|
|
||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Every pipeline unit test passes Suggested fix
Suggested change
AI context{
"file": "dash-spv/src/sync/blocks/pipeline.rs",
"line": 362,
"severity": "warning",
"confidence": "high",
"flaggedBy": [
"Testing & Coverage"
],
"title": "Pipeline wallet propagation has zero test coverage",
"fix": "Add a test that queues a block with a non-empty wallet set, simulates download completion, calls `take_next_ordered_block`, and asserts the returned wallet set equals the one that was queued. Also add",
"reachability": "reachable"
} |
||||||
| // Now block 1 is ready (lowest height) | ||||||
| let (block, height) = pipeline.take_next_ordered_block().unwrap(); | ||||||
| let (block, height, _) = pipeline.take_next_ordered_block().unwrap(); | ||||||
| assert_eq!(height, 100); | ||||||
| assert_eq!(block.block_hash(), hash1); | ||||||
|
|
||||||
| // Block 2 is now ready | ||||||
| let (block, height) = pipeline.take_next_ordered_block().unwrap(); | ||||||
| let (block, height, _) = pipeline.take_next_ordered_block().unwrap(); | ||||||
| assert_eq!(height, 101); | ||||||
| assert_eq!(block.block_hash(), hash2); | ||||||
|
|
||||||
|
|
@@ -360,7 +381,7 @@ mod tests { | |||||
|
|
||||||
| // Add block at height 101, but height 100 is still pending | ||||||
| pipeline.pending_heights.insert(100); | ||||||
| pipeline.add_from_storage(block2.clone(), 101); | ||||||
| pipeline.add_from_storage(block2.clone(), 101, BTreeSet::new()); | ||||||
|
|
||||||
| // Cannot take block 2 - block at height 100 is still pending | ||||||
| assert!(pipeline.take_next_ordered_block().is_none()); | ||||||
|
|
@@ -369,7 +390,7 @@ mod tests { | |||||
| pipeline.pending_heights.remove(&100); | ||||||
|
|
||||||
| // Now block 2 is ready | ||||||
| let (_, height) = pipeline.take_next_ordered_block().unwrap(); | ||||||
| let (_, height, _) = pipeline.take_next_ordered_block().unwrap(); | ||||||
| assert_eq!(height, 101); | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -379,11 +400,11 @@ mod tests { | |||||
| let block = make_test_block(1); | ||||||
| let hash = block.block_hash(); | ||||||
|
|
||||||
| pipeline.add_from_storage(block.clone(), 100); | ||||||
| pipeline.add_from_storage(block.clone(), 100, BTreeSet::new()); | ||||||
|
|
||||||
| assert_eq!(pipeline.downloaded.len(), 1); | ||||||
|
|
||||||
| let (taken_block, height) = pipeline.take_next_ordered_block().unwrap(); | ||||||
| let (taken_block, height, _) = pipeline.take_next_ordered_block().unwrap(); | ||||||
| assert_eq!(height, 100); | ||||||
| assert_eq!(taken_block.block_hash(), hash); | ||||||
| } | ||||||
|
|
@@ -395,7 +416,7 @@ mod tests { | |||||
|
|
||||||
| // Adding to downloaded makes it incomplete | ||||||
| let block = make_test_block(1); | ||||||
| pipeline.add_from_storage(block, 100); | ||||||
| pipeline.add_from_storage(block, 100, BTreeSet::new()); | ||||||
| assert!(!pipeline.is_complete()); | ||||||
|
|
||||||
| // Take the block | ||||||
|
|
@@ -422,7 +443,7 @@ mod tests { | |||||
| let block = make_test_block(1); | ||||||
|
|
||||||
| // Queue and mark as sent via coordinator | ||||||
| pipeline.queue([FilterMatchKey::new(100, block.block_hash())]); | ||||||
| pipeline.queue([(FilterMatchKey::new(100, block.block_hash()), BTreeSet::new())]); | ||||||
| let hashes = pipeline.coordinator.take_pending(1); | ||||||
| pipeline.coordinator.mark_sent(&hashes); | ||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -10,7 +10,8 @@ use crate::types::HashedBlock; | |||||||||||||||||
| use crate::SyncError; | ||||||||||||||||||
| use async_trait::async_trait; | ||||||||||||||||||
| use dashcore::network::message::NetworkMessage; | ||||||||||||||||||
| use key_wallet_manager::WalletInterface; | ||||||||||||||||||
| use key_wallet_manager::{FilterMatchKey, WalletId, WalletInterface}; | ||||||||||||||||||
| use std::collections::BTreeSet; | ||||||||||||||||||
|
|
||||||||||||||||||
| #[async_trait] | ||||||||||||||||||
| impl<H: BlockHeaderStorage, B: BlockStorage, W: WalletInterface + 'static> SyncManager | ||||||||||||||||||
|
|
@@ -115,10 +116,10 @@ impl<H: BlockHeaderStorage, B: BlockStorage, W: WalletInterface + 'static> SyncM | |||||||||||||||||
|
|
||||||||||||||||||
| tracing::debug!("Blocks needed: {} blocks", blocks.len()); | ||||||||||||||||||
|
|
||||||||||||||||||
| let mut to_download = Vec::new(); | ||||||||||||||||||
| let mut to_download: Vec<(FilterMatchKey, BTreeSet<WalletId>)> = Vec::new(); | ||||||||||||||||||
|
|
||||||||||||||||||
| let block_storage = self.block_storage.read().await; | ||||||||||||||||||
| for key in blocks { | ||||||||||||||||||
| for (key, wallets) in blocks { | ||||||||||||||||||
| // Check if block is already stored (from previous sync) | ||||||||||||||||||
| if let Ok(Some(hashed_block)) = block_storage.load_block(key.height()).await { | ||||||||||||||||||
| if hashed_block.hash() != key.hash() { | ||||||||||||||||||
|
|
@@ -135,13 +136,17 @@ impl<H: BlockHeaderStorage, B: BlockStorage, W: WalletInterface + 'static> SyncM | |||||||||||||||||
| ))); | ||||||||||||||||||
| } | ||||||||||||||||||
| // Block loaded from storage, add to pipeline for processing | ||||||||||||||||||
| self.pipeline.add_from_storage(hashed_block.block().clone(), key.height()); | ||||||||||||||||||
| self.pipeline.add_from_storage( | ||||||||||||||||||
| hashed_block.block().clone(), | ||||||||||||||||||
| key.height(), | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 ✨ Suggestion: Unnecessary clone of wallets BTreeSet in block-loading loop In the Suggested fix
Suggested change
AI context{
"file": "dash-spv/src/sync/blocks/sync_manager.rs",
"line": 141,
"severity": "suggestion",
"confidence": "high",
"flaggedBy": [
"Performance & Efficiency"
],
"title": "Unnecessary clone of wallets BTreeSet in block-loading loop",
"fix": "// Storage path — move wallets:\nself.pipeline.add_from_storage(hashed_block.block().clone(), key.height(), wallets);\nself.progress.add_from_storage(1);\ncontinue;\n\n// Download path — move key and walle",
"reachability": "reachable"
} |
||||||||||||||||||
| wallets.clone(), | ||||||||||||||||||
| ); | ||||||||||||||||||
| self.progress.add_from_storage(1); | ||||||||||||||||||
| continue; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Block not in storage, queue for download with height | ||||||||||||||||||
| to_download.push(key.clone()); | ||||||||||||||||||
| // Block not in storage, queue for download with height + wallets | ||||||||||||||||||
| to_download.push((key.clone(), wallets.clone())); | ||||||||||||||||||
| } | ||||||||||||||||||
| drop(block_storage); | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 ✨ Suggestion: queue() wallet-merge behavior is untested
The updated
queue()merges wallet sets when a block hash is already tracked (hash_to_wallets.entry(hash).or_default().extend(wallets)). This merge is important for the late-wallet catch-up case, but no test exercises it. A future refactor that accidentally switchesor_default().extend()to an overwrite would go undetected.Suggested fix
AI context
{ "file": "dash-spv/src/sync/blocks/pipeline.rs", "line": 246, "severity": "suggestion", "confidence": "medium", "flaggedBy": [ "Testing & Coverage" ], "title": "queue() wallet-merge behavior is untested", "fix": "Add a test that calls `queue` twice for the same `FilterMatchKey` with disjoint wallet sets, then calls `take_next_ordered_block` and asserts the returned wallet set is the union of both.", "reachability": "reachable" }