Skip to content

Commit 20bbb4d

Browse files
committed
feat(chain)!: Change TxGraph to understand evicted-at & last-evicted
The evicted-at and last-evicted timestamp informs the `TxGraph` when the transaction was last deemed as missing (evicted) from the mempool. The canonicalization algorithm is changed to disregard transactions with a last-evicted timestamp greater or equal to it's last-seen timestamp. The exception is when we have a canonical descendant due to rules of transitivity. Update rusqlite_impl to persist `last_evicted`. Also update docs: * Remove duplicate paragraphs about `ChangeSet`s. * Add "Canonicalization" section which expands on methods that require canonicalization and the associated data types used in the canonicalization algorithm.
1 parent ccccaa5 commit 20bbb4d

File tree

3 files changed

+177
-22
lines changed

3 files changed

+177
-22
lines changed

crates/chain/src/rusqlite_impl.rs

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -264,12 +264,20 @@ impl tx_graph::ChangeSet<ConfirmationBlockTime> {
264264
format!("{add_confirmation_time_column}; {extract_confirmation_time_from_anchor_column}; {drop_anchor_column}")
265265
}
266266

267+
/// Get v2 of sqlite [tx_graph::ChangeSet] schema
268+
pub fn schema_v2() -> String {
269+
format!(
270+
"ALTER TABLE {} ADD COLUMN last_evicted INTEGER",
271+
Self::TXS_TABLE_NAME,
272+
)
273+
}
274+
267275
/// Initialize sqlite tables.
268276
pub fn init_sqlite_tables(db_tx: &rusqlite::Transaction) -> rusqlite::Result<()> {
269277
migrate_schema(
270278
db_tx,
271279
Self::SCHEMA_NAME,
272-
&[&Self::schema_v0(), &Self::schema_v1()],
280+
&[&Self::schema_v0(), &Self::schema_v1(), &Self::schema_v2()],
273281
)
274282
}
275283

@@ -280,24 +288,28 @@ impl tx_graph::ChangeSet<ConfirmationBlockTime> {
280288
let mut changeset = Self::default();
281289

282290
let mut statement = db_tx.prepare(&format!(
283-
"SELECT txid, raw_tx, last_seen FROM {}",
291+
"SELECT txid, raw_tx, last_seen, last_evicted FROM {}",
284292
Self::TXS_TABLE_NAME,
285293
))?;
286294
let row_iter = statement.query_map([], |row| {
287295
Ok((
288296
row.get::<_, Impl<bitcoin::Txid>>("txid")?,
289297
row.get::<_, Option<Impl<bitcoin::Transaction>>>("raw_tx")?,
290298
row.get::<_, Option<u64>>("last_seen")?,
299+
row.get::<_, Option<u64>>("last_evicted")?,
291300
))
292301
})?;
293302
for row in row_iter {
294-
let (Impl(txid), tx, last_seen) = row?;
303+
let (Impl(txid), tx, last_seen, last_evicted) = row?;
295304
if let Some(Impl(tx)) = tx {
296305
changeset.txs.insert(Arc::new(tx));
297306
}
298307
if let Some(last_seen) = last_seen {
299308
changeset.last_seen.insert(txid, last_seen);
300309
}
310+
if let Some(last_evicted) = last_evicted {
311+
changeset.last_evicted.insert(txid, last_evicted);
312+
}
301313
}
302314

303315
let mut statement = db_tx.prepare(&format!(
@@ -377,6 +389,19 @@ impl tx_graph::ChangeSet<ConfirmationBlockTime> {
377389
})?;
378390
}
379391

392+
let mut statement = db_tx
393+
.prepare_cached(&format!(
394+
"INSERT INTO {}(txid, last_evicted) VALUES(:txid, :last_evicted) ON CONFLICT(txid) DO UPDATE SET last_evicted=:last_evicted",
395+
Self::TXS_TABLE_NAME,
396+
))?;
397+
for (&txid, &last_evicted) in &self.last_evicted {
398+
let checked_time = last_evicted.to_sql()?;
399+
statement.execute(named_params! {
400+
":txid": Impl(txid),
401+
":last_evicted": Some(checked_time),
402+
})?;
403+
}
404+
380405
let mut statement = db_tx.prepare_cached(&format!(
381406
"REPLACE INTO {}(txid, vout, value, script) VALUES(:txid, :vout, :value, :script)",
382407
Self::TXOUTS_TABLE_NAME,
@@ -628,7 +653,7 @@ mod test {
628653
}
629654

630655
#[test]
631-
fn v0_to_v1_schema_migration_is_backward_compatible() -> anyhow::Result<()> {
656+
fn v0_to_v2_schema_migration_is_backward_compatible() -> anyhow::Result<()> {
632657
type ChangeSet = tx_graph::ChangeSet<ConfirmationBlockTime>;
633658
let mut conn = rusqlite::Connection::open_in_memory()?;
634659

@@ -697,13 +722,17 @@ mod test {
697722
}
698723
}
699724

700-
// Apply v1 sqlite schema to tables with data
725+
// Apply v1 & v2 sqlite schema to tables with data
701726
{
702727
let db_tx = conn.transaction()?;
703728
migrate_schema(
704729
&db_tx,
705730
ChangeSet::SCHEMA_NAME,
706-
&[&ChangeSet::schema_v0(), &ChangeSet::schema_v1()],
731+
&[
732+
&ChangeSet::schema_v0(),
733+
&ChangeSet::schema_v1(),
734+
&ChangeSet::schema_v2(),
735+
],
707736
)?;
708737
db_tx.commit()?;
709738
}
@@ -718,4 +747,43 @@ mod test {
718747

719748
Ok(())
720749
}
750+
751+
#[test]
752+
fn can_persist_last_evicted() -> anyhow::Result<()> {
753+
use bitcoin::hashes::Hash;
754+
755+
type ChangeSet = tx_graph::ChangeSet<ConfirmationBlockTime>;
756+
let mut conn = rusqlite::Connection::open_in_memory()?;
757+
758+
// Init tables
759+
{
760+
let db_tx = conn.transaction()?;
761+
ChangeSet::init_sqlite_tables(&db_tx)?;
762+
db_tx.commit()?;
763+
}
764+
765+
let txid = bitcoin::Txid::all_zeros();
766+
let last_evicted = 100;
767+
768+
// Persist `last_evicted`
769+
{
770+
let changeset = ChangeSet {
771+
last_evicted: [(txid, last_evicted)].into(),
772+
..Default::default()
773+
};
774+
let db_tx = conn.transaction()?;
775+
changeset.persist_to_sqlite(&db_tx)?;
776+
db_tx.commit()?;
777+
}
778+
779+
// Load from sqlite should succeed
780+
{
781+
let db_tx = conn.transaction()?;
782+
let changeset = ChangeSet::from_sqlite(&db_tx)?;
783+
db_tx.commit()?;
784+
assert_eq!(changeset.last_evicted.get(&txid), Some(&last_evicted));
785+
}
786+
787+
Ok(())
788+
}
721789
}

crates/chain/src/tx_graph.rs

Lines changed: 99 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,52 @@
1616
//! documentation for more details), and the timestamp of the last time we saw the transaction as
1717
//! unconfirmed.
1818
//!
19-
//! Conflicting transactions are allowed to coexist within a [`TxGraph`]. This is useful for
20-
//! identifying and traversing conflicts and descendants of a given transaction. Some [`TxGraph`]
21-
//! methods only consider transactions that are "canonical" (i.e., in the best chain or in mempool).
22-
//! We decide which transactions are canonical based on the transaction's anchors and the
23-
//! `last_seen` (as unconfirmed) timestamp.
19+
//! # Canonicalization
2420
//!
25-
//! The [`ChangeSet`] reports changes made to a [`TxGraph`]; it can be used to either save to
26-
//! persistent storage, or to be applied to another [`TxGraph`].
21+
//! Conflicting transactions are allowed to coexist within a [`TxGraph`]. A process called
22+
//! canonicalization is required to get a conflict-free history of transactions.
23+
//!
24+
//! * [`list_canonical_txs`](TxGraph::list_canonical_txs) lists canonical transactions.
25+
//! * [`filter_chain_txouts`](TxGraph::filter_chain_txouts) filters out canonical outputs from a
26+
//! list of outpoints.
27+
//! * [`filter_chain_unspents`](TxGraph::filter_chain_unspents) filters out canonical unspent
28+
//! outputs from a list of outpoints.
29+
//! * [`balance`](TxGraph::balance) gets the total sum of unspent outputs filtered from a list of
30+
//! outpoints.
31+
//! * [`canonical_iter`](TxGraph::canonical_iter) returns the [`CanonicalIter`] which contains all
32+
//! of the canonicalization logic.
33+
//!
34+
//! All these methods require a `chain` and `chain_tip` argument. The `chain` must be a
35+
//! [`ChainOracle`] implementation (such as [`LocalChain`](crate::local_chain::LocalChain)) which
36+
//! identifies which blocks exist under a given `chain_tip`.
37+
//!
38+
//! The canonicalization algorithm uses the following associated data to determine which
39+
//! transactions have precedence over others:
2740
//!
28-
//! Lastly, you can use [`TxAncestors`]/[`TxDescendants`] to traverse ancestors and descendants of
29-
//! a given transaction, respectively.
41+
//! * [`Anchor`] - This bit of data represents that a transaction is anchored in a given block. If
42+
//! the transaction is anchored in chain of `chain_tip`, or is an ancestor of a transaction
43+
//! anchored in chain of `chain_tip`, then the transaction must be canonical.
44+
//! * `last_seen` - This is the timestamp of when a transaction is last-seen in the mempool. This
45+
//! value is updated by [`insert_seen_at`](TxGraph::insert_seen_at) and
46+
//! [`apply_update`](TxGraph::apply_update). Transactions that are seen later have higher
47+
//! priority than those that are seen earlier. `last_seen` values are transitive. Meaning that
48+
//! the actual `last_seen` value of a transaction is the max of all the `last_seen` values of
49+
//! it's descendants.
50+
//! * `last_evicted` - This is the timestamp of when a transaction is last-seen as evicted in the
51+
//! mempool. If this value is equal to or higher than the transaction's `last_seen` value, then
52+
//! it will not be considered canonical.
53+
//!
54+
//! # Graph traversal
55+
//!
56+
//! You can use [`TxAncestors`]/[`TxDescendants`] to traverse ancestors and descendants of a given
57+
//! transaction, respectively.
3058
//!
3159
//! # Applying changes
3260
//!
61+
//! The [`ChangeSet`] reports changes made to a [`TxGraph`]; it can be used to either save to
62+
//! persistent storage, or to be applied to another [`TxGraph`].
63+
//!
3364
//! Methods that change the state of [`TxGraph`] will return [`ChangeSet`]s.
34-
//! [`ChangeSet`]s can be applied back to a [`TxGraph`] or be used to inform persistent storage
35-
//! of the changes to [`TxGraph`].
3665
//!
3766
//! # Generics
3867
//!
@@ -122,6 +151,7 @@ impl<A: Ord> From<TxGraph<A>> for TxUpdate<A> {
122151
.flat_map(|(txid, anchors)| anchors.into_iter().map(move |a| (a, txid)))
123152
.collect();
124153
tx_update.seen_ats = graph.last_seen.into_iter().collect();
154+
tx_update.evicted_ats = graph.last_evicted.into_iter().collect();
125155
tx_update
126156
}
127157
}
@@ -145,6 +175,7 @@ pub struct TxGraph<A = ConfirmationBlockTime> {
145175
spends: BTreeMap<OutPoint, HashSet<Txid>>,
146176
anchors: HashMap<Txid, BTreeSet<A>>,
147177
last_seen: HashMap<Txid, u64>,
178+
last_evicted: HashMap<Txid, u64>,
148179

149180
txs_by_highest_conf_heights: BTreeSet<(u32, Txid)>,
150181
txs_by_last_seen: BTreeSet<(u64, Txid)>,
@@ -162,6 +193,7 @@ impl<A> Default for TxGraph<A> {
162193
spends: Default::default(),
163194
anchors: Default::default(),
164195
last_seen: Default::default(),
196+
last_evicted: Default::default(),
165197
txs_by_highest_conf_heights: Default::default(),
166198
txs_by_last_seen: Default::default(),
167199
empty_outspends: Default::default(),
@@ -715,6 +747,34 @@ impl<A: Anchor> TxGraph<A> {
715747
changeset
716748
}
717749

750+
/// Inserts the given `evicted_at` for `txid` into [`TxGraph`].
751+
///
752+
/// The `evicted_at` timestamp represents the last known time when the transaction was observed
753+
/// to be missing from the mempool. If `txid` was previously recorded with an earlier
754+
/// `evicted_at` value, it is updated only if the new value is greater.
755+
pub fn insert_evicted_at(&mut self, txid: Txid, evicted_at: u64) -> ChangeSet<A> {
756+
let is_changed = match self.last_evicted.entry(txid) {
757+
hash_map::Entry::Occupied(mut e) => {
758+
let last_evicted = e.get_mut();
759+
let change = *last_evicted < evicted_at;
760+
if change {
761+
*last_evicted = evicted_at;
762+
}
763+
change
764+
}
765+
hash_map::Entry::Vacant(e) => {
766+
e.insert(evicted_at);
767+
true
768+
}
769+
};
770+
771+
let mut changeset = ChangeSet::<A>::default();
772+
if is_changed {
773+
changeset.last_evicted.insert(txid, evicted_at);
774+
}
775+
changeset
776+
}
777+
718778
/// Extends this graph with the given `update`.
719779
///
720780
/// The returned [`ChangeSet`] is the set difference between `update` and `self` (transactions that
@@ -733,6 +793,9 @@ impl<A: Anchor> TxGraph<A> {
733793
for (txid, seen_at) in update.seen_ats {
734794
changeset.merge(self.insert_seen_at(txid, seen_at));
735795
}
796+
for (txid, evicted_at) in update.evicted_ats {
797+
changeset.merge(self.insert_evicted_at(txid, evicted_at));
798+
}
736799
changeset
737800
}
738801

@@ -750,6 +813,7 @@ impl<A: Anchor> TxGraph<A> {
750813
.flat_map(|(txid, anchors)| anchors.iter().map(|a| (a.clone(), *txid)))
751814
.collect(),
752815
last_seen: self.last_seen.iter().map(|(&k, &v)| (k, v)).collect(),
816+
last_evicted: self.last_evicted.iter().map(|(&k, &v)| (k, v)).collect(),
753817
}
754818
}
755819

@@ -767,6 +831,9 @@ impl<A: Anchor> TxGraph<A> {
767831
for (txid, seen_at) in changeset.last_seen {
768832
let _ = self.insert_seen_at(txid, seen_at);
769833
}
834+
for (txid, evicted_at) in changeset.last_evicted {
835+
let _ = self.insert_evicted_at(txid, evicted_at);
836+
}
770837
}
771838
}
772839

@@ -937,9 +1004,14 @@ impl<A: Anchor> TxGraph<A> {
9371004

9381005
/// List txids by descending last-seen order.
9391006
///
940-
/// Transactions without last-seens are excluded.
941-
pub fn txids_by_descending_last_seen(&self) -> impl ExactSizeIterator<Item = (u64, Txid)> + '_ {
942-
self.txs_by_last_seen.iter().copied().rev()
1007+
/// Transactions without last-seens are excluded. Transactions with a last-evicted timestamp
1008+
/// equal or higher than it's last-seen timestamp are excluded.
1009+
pub fn txids_by_descending_last_seen(&self) -> impl Iterator<Item = (u64, Txid)> + '_ {
1010+
self.txs_by_last_seen
1011+
.iter()
1012+
.copied()
1013+
.rev()
1014+
.filter(|(last_seen, txid)| !matches!(self.last_evicted.get(txid), Some(last_evicted) if last_evicted >= last_seen))
9431015
}
9441016

9451017
/// Returns a [`CanonicalIter`].
@@ -1107,6 +1179,8 @@ pub struct ChangeSet<A = ()> {
11071179
pub anchors: BTreeSet<(A, Txid)>,
11081180
/// Added last-seen unix timestamps of transactions.
11091181
pub last_seen: BTreeMap<Txid, u64>,
1182+
/// Added timestamps of when a transaction is last evicted from the mempool.
1183+
pub last_evicted: BTreeMap<Txid, u64>,
11101184
}
11111185

11121186
impl<A> Default for ChangeSet<A> {
@@ -1116,6 +1190,7 @@ impl<A> Default for ChangeSet<A> {
11161190
txouts: Default::default(),
11171191
anchors: Default::default(),
11181192
last_seen: Default::default(),
1193+
last_evicted: Default::default(),
11191194
}
11201195
}
11211196
}
@@ -1170,13 +1245,22 @@ impl<A: Ord> Merge for ChangeSet<A> {
11701245
.filter(|(txid, update_ls)| self.last_seen.get(txid) < Some(update_ls))
11711246
.collect::<Vec<_>>(),
11721247
);
1248+
// last_evicted timestamps should only increase
1249+
self.last_evicted.extend(
1250+
other
1251+
.last_evicted
1252+
.into_iter()
1253+
.filter(|(txid, update_lm)| self.last_evicted.get(txid) < Some(update_lm))
1254+
.collect::<Vec<_>>(),
1255+
);
11731256
}
11741257

11751258
fn is_empty(&self) -> bool {
11761259
self.txs.is_empty()
11771260
&& self.txouts.is_empty()
11781261
&& self.anchors.is_empty()
11791262
&& self.last_seen.is_empty()
1263+
&& self.last_evicted.is_empty()
11801264
}
11811265
}
11821266

@@ -1196,6 +1280,7 @@ impl<A: Ord> ChangeSet<A> {
11961280
self.anchors.into_iter().map(|(a, txid)| (f(a), txid)),
11971281
),
11981282
last_seen: self.last_seen,
1283+
last_evicted: self.last_evicted,
11991284
}
12001285
}
12011286
}

crates/chain/tests/test_tx_graph.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@ fn insert_txouts() {
115115
txs: [Arc::new(update_tx.clone())].into(),
116116
txouts: update_ops.clone().into(),
117117
anchors: [(conf_anchor, update_tx.compute_txid()),].into(),
118-
last_seen: [(hash!("tx2"), 1000000)].into()
118+
last_seen: [(hash!("tx2"), 1000000)].into(),
119+
last_evicted: [].into(),
119120
}
120121
);
121122

@@ -168,7 +169,8 @@ fn insert_txouts() {
168169
txs: [Arc::new(update_tx.clone())].into(),
169170
txouts: update_ops.into_iter().chain(original_ops).collect(),
170171
anchors: [(conf_anchor, update_tx.compute_txid()),].into(),
171-
last_seen: [(hash!("tx2"), 1000000)].into()
172+
last_seen: [(hash!("tx2"), 1000000)].into(),
173+
last_evicted: [].into(),
172174
}
173175
);
174176
}

0 commit comments

Comments
 (0)