Repository navigation
fix(mempool): reconcile conflicts before insertion #254
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
base: new-index
Are you sure you want to change the base?
Changes from 1 commit
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 |
|---|---|---|
|
|
@@ -373,6 +373,19 @@ impl Mempool { | |
| // Fails if any are missing. | ||
| txos.extend(self.lookup_txos(remain_prevouts)?); | ||
|
|
||
| // Transactions submitted through broadcast_raw()/submit_package() are inserted into the | ||
| // local view immediately, before the next periodic sync has a chance to remove transactions | ||
| // they replaced in bitcoind. Reconcile those conflicts here so the indexes never contain two | ||
| // spenders for one outpoint. Descendants of a replaced transaction are no longer valid either. | ||
| let conflicts = self.conflicts_and_descendants(&txs_map)?; | ||
|
shesek marked this conversation as resolved.
Outdated
|
||
| if !conflicts.is_empty() { | ||
| debug!( | ||
| "removing {} conflicting mempool transactions before insertion", | ||
| conflicts.len() | ||
| ); | ||
| self.remove(conflicts.iter().collect()); | ||
| } | ||
|
|
||
| // Add to txstore and indexes | ||
| for (txid, tx) in txs_map { | ||
| self.txstore.insert(txid, tx); | ||
|
|
@@ -464,6 +477,58 @@ impl Mempool { | |
| Ok(()) | ||
| } | ||
|
|
||
| fn conflicts_and_descendants( | ||
| &self, | ||
| txs_map: &HashMap<Txid, Transaction>, | ||
| ) -> Result<HashSet<Txid>> { | ||
| let mut incoming_spends = HashMap::new(); | ||
| let mut to_remove = HashSet::new(); | ||
|
|
||
| for (txid, tx) in txs_map { | ||
| for txin in &tx.input { | ||
| if let Some(other_txid) = incoming_spends.insert(txin.previous_output, *txid) { | ||
| if other_txid != *txid { | ||
| bail!( | ||
|
Collaborator
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. Can this failure case actually happen if the tx package was considered valid and accepted by bitcoind? |
||
| "incoming mempool transactions {} and {} both spend outpoint {}:{}", | ||
| other_txid, | ||
| txid, | ||
| txin.previous_output.txid, | ||
| txin.previous_output.vout | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| if let Some((indexed_txid, _)) = self.edges.get(&txin.previous_output) { | ||
| if indexed_txid != txid && !txs_map.contains_key(indexed_txid) { | ||
| to_remove.insert(*indexed_txid); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Follow spend edges from every output of each conflict to collect the full descendant | ||
| // closure without scanning the whole mempool. | ||
| let mut pending = to_remove.iter().copied().collect::<Vec<_>>(); | ||
| while let Some(txid) = pending.pop() { | ||
| let Some(tx) = self.txstore.get(&txid) else { | ||
| continue; | ||
| }; | ||
| for vout in 0..tx.output.len() { | ||
| let outpoint = OutPoint { | ||
| txid, | ||
| vout: vout as u32, | ||
| }; | ||
| if let Some((descendant_txid, _)) = self.edges.get(&outpoint) { | ||
| if to_remove.insert(*descendant_txid) { | ||
| pending.push(*descendant_txid); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Ok(to_remove) | ||
| } | ||
|
|
||
| fn lookup_txo(&self, outpoint: &OutPoint) -> Option<TxOut> { | ||
| self.txstore | ||
| .get(&outpoint.txid) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1576,13 +1576,22 @@ fn test_rest_liquid_block() -> Result<()> { | |
|
|
||
| #[cfg(not(feature = "liquid"))] | ||
|
Collaborator
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. [P2] Cover descendant eviction and Liquid’s affected indexes — tests/rest.rs:1577 The tests exercise only a direct A→B replacement and both are disabled under liquid. The new implementation promises descendant closure and, under Liquid, mutates asset_history and asset_issuance. Add a production-path case where A has descendants before B replaces it, asserting the complete closure disappears from histories, UTXOs, spends, and transaction lookup. The equivalent Elements integration path also needs coverage. |
||
| #[test] | ||
| fn test_rest_mempool_rbf_eviction() -> Result<()> { | ||
| // Regression test for the mempool eviction panic ("missing mempool edge | ||
| // for outpoint"): tx A and its RBF replacement B spend the same outpoint | ||
| // and transiently coexist in the local mempool view when B is injected | ||
| // through the broadcast endpoint (add_by_txid) while A is still indexed. | ||
| // B's add() clobbers A's `edges` entry; the next sync round evicts A and | ||
| // must tolerate the missing/foreign edge instead of panicking. | ||
| fn test_rest_mempool_rbf_reconciled_on_broadcast() -> Result<()> { | ||
| test_rest_mempool_rbf_reconciliation(false) | ||
| } | ||
|
|
||
| #[cfg(not(feature = "liquid"))] | ||
| #[test] | ||
| fn test_rest_mempool_rbf_reconciled_on_package_broadcast() -> Result<()> { | ||
| test_rest_mempool_rbf_reconciliation(true) | ||
| } | ||
|
|
||
| #[cfg(not(feature = "liquid"))] | ||
| fn test_rest_mempool_rbf_reconciliation(use_package: bool) -> Result<()> { | ||
| // Tx A and its RBF replacement B spend the same outpoint. B is inserted | ||
| // immediately through add_by_txid(s), before the periodic sync can evict A. | ||
| // The insertion must reconcile the conflict instead of exposing both | ||
| // transactions in the local indexes. | ||
| let (rest_handle, rest_addr, mut tester) = common::init_rest_tester().unwrap(); | ||
|
|
||
| // Broadcast tx A via the node wallet, explicitly BIP125-replaceable, | ||
|
|
@@ -1616,22 +1625,28 @@ fn test_rest_mempool_rbf_eviction() -> Result<()> { | |
| .node_client() | ||
| .call("finalizepsbt", &[processed["psbt"].clone()])?; | ||
| let b_hex = finalized["hex"].as_str().expect("finalized tx hex"); | ||
| let b_bytes = Vec::from_hex(b_hex).expect("valid finalized tx hex"); | ||
| let txid_b = bitcoin::consensus::deserialize::<bitcoin::Transaction>(&b_bytes) | ||
| .expect("valid finalized transaction") | ||
| .compute_txid() | ||
| .to_string(); | ||
|
|
||
| // Inject B through the electrs broadcast endpoint: the node accepts the | ||
| // replacement (evicting A node-side), and add_by_txid() indexes B locally | ||
| // while A is still present - clobbering A's edges entry for the shared | ||
| // outpoint. | ||
| let broadcast_resp = ureq::post(&format!("http://{}/tx", rest_addr)).send(b_hex)?; | ||
| assert_eq!(broadcast_resp.status(), 200); | ||
| let txid_b = broadcast_resp.into_body().read_to_string()?; | ||
|
|
||
| // The next sync evicts A from the local view. The unfixed code passes the | ||
| // eviction assert here - but only by STEALING B's edge entry (any Some() | ||
| // satisfied it), which is the actual arming step of the crash. | ||
| tester.sync()?; | ||
| // Inject B through either immediate insertion path. bitcoind accepts the | ||
| // replacement and evicts A node-side; electrs must do the same locally as | ||
| // part of this request, without waiting for tester.sync(). | ||
| if use_package { | ||
| let response = ureq::post(&format!("http://{}/txs/package", rest_addr)) | ||
| .send_json([b_hex])?; | ||
| assert_eq!(response.status(), 200); | ||
| } else { | ||
| let response = ureq::post(&format!("http://{}/tx", rest_addr)).send(b_hex)?; | ||
| assert_eq!(response.status(), 200); | ||
| assert_eq!(response.into_body().read_to_string()?.trim(), txid_b); | ||
| } | ||
|
|
||
| // B remains queryable in the mempool; A is gone. | ||
| let res = get_json(rest_addr, &format!("/tx/{}", txid_b.trim()))?; | ||
| // B is immediately queryable and A is immediately absent. Before #236, | ||
| // this assertion observed both conflicting transactions until the poll. | ||
| let res = get_json(rest_addr, &format!("/tx/{}", txid_b))?; | ||
| assert_eq!(res["status"]["confirmed"].as_bool(), Some(false)); | ||
| let gone = ureq::get(&format!("http://{}/tx/{}", rest_addr, txid_a)) | ||
| .config() | ||
|
|
@@ -1640,16 +1655,15 @@ fn test_rest_mempool_rbf_eviction() -> Result<()> { | |
| .call()?; | ||
| assert_eq!(gone.status(), 404); | ||
|
|
||
| // Now B itself leaves the mempool (confirmed here; RBF-of-B or expiry are | ||
| // equivalent). Evicting B finds its edge entry gone - stolen by A's | ||
| // eviction above - and the unfixed code panics with "missing mempool edge | ||
| // for outpoint", killing the sync loop. The fixed code only removes an | ||
| // edge its evicted tx still owns, so B's edge survived A's eviction and | ||
| // this round stays clean. | ||
| // A periodic sync should preserve the already-reconciled state. | ||
| tester.sync()?; | ||
|
|
||
| // B can subsequently leave the mempool without missing-edge warnings or | ||
| // a panic, and the server remains fully alive. | ||
| tester.mine()?; | ||
| tester.sync()?; | ||
|
|
||
| let res = get_json(rest_addr, &format!("/tx/{}", txid_b.trim()))?; | ||
| let res = get_json(rest_addr, &format!("/tx/{}", txid_b))?; | ||
| assert_eq!(res["status"]["confirmed"].as_bool(), Some(true)); | ||
|
|
||
| // And the server is still fully alive. | ||
|
|
||
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.
This can fail when inserting submitted txs, if they descent from ancestor transactions that bitcoind has in its mempool view but we don't yet.
We could proactively fetch missing ancestors for submitted txs, but I would opt to keep this simpler and just avoid adding it to the local mempool view until the next periodic sync (the current behavior).
There's one thing we could improve though:
broadcast_raw()/submit_package()could explicitly identify this failure case and return a202 Acceptedwith a message saying the tx was submitted to the network but still not available in the local view. Currently this will fail with a400, despite the network submission being successful.Uh oh!
There was an error while loading. Please reload this page.
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.
Agreed that we should not proactively fetch missing unconfirmed ancestors here. If a submitted transaction depends on an ancestor present in bitcoind but not yet in electrs’s local view, immediate insertion fails and the next periodic snapshot adds the complete dependency set.
I traced the response path again: both broadcast_raw() and submit_package() currently discard the local add_by_txid(s) result after successful daemon submission (let _ = ...). Therefore this case returns the normal successful daemon response (currently 200, not 400) while local visibility is deferred. I left that behavior unchanged. We could separately expose this outcome as 202 Accepted, but that would require changing the query/REST response contract. Maybe better in a new PR?
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.
Ah right, sorry, I misremembered
add_by_txid(s)errors as being propagated.I agree that the current behavior, returning
200in both cases, is already acceptable.But I still think it would be nicer to use
202to indicate deferred processing to clients, e.g. so that Esplora can display an appropriate message instead of immediately redirecting to the tx page and showing a "tx not found" error.It's not high priority and could be done in a separate PR, but it's also a small change that could make sense to bundle with this PR if we expand its scope to more generally improve handling of submitted txs. It does have some potential to break clients that explicitly check for
200, but I think it should be quite common to treat any2xxas successful.Otoh, the fact that we don't have official point releases, a changelog for breaking changes or versioned API endpoints does make me more hesitant to change the REST contract. Perhaps a good time to prioritize addressing that?