Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0471638
feat(node): gate verify-foreign-tx on available chains via the watch …
anodar Jul 15, 2026
cb9468d
cleanup
anodar Jul 22, 2026
7ec268f
cleanup
anodar Jul 22, 2026
267cc47
Address comments
anodar Jul 23, 2026
a87bc1f
get rid of optional in channel
anodar Jul 24, 2026
37dd1c0
add todo
anodar Jul 24, 2026
a80d3e8
address claude comments
anodar Jul 24, 2026
cc3a725
Merge branch 'main' into anodar/3569-5-node-available-chains-switch
anodar Aug 3, 2026
dd3bd7a
cleanup
anodar Aug 3, 2026
2434973
Address comments
anodar Aug 4, 2026
6ce5767
Merge branch 'main' into anodar/3569-5-node-available-chains-switch
anodar Aug 4, 2026
fae828e
Merge branch 'main' into anodar/3569-5-node-available-chains-switch
anodar Aug 4, 2026
bec2c49
feat(node): chain-compatible presignature selection for verifying for…
anodar Aug 4, 2026
721ecfc
Address claude comments
anodar Aug 5, 2026
6a57986
Update documentation, address claude
anodar Aug 5, 2026
f671291
Don't scan full queue, reword comment
anodar Aug 6, 2026
739fe13
Factor out await_with_slow_hook
anodar Aug 7, 2026
40ee1b0
Merge branch 'main' into anodar/3569-5-node-available-chains-switch
anodar Aug 17, 2026
af24ba6
Drop commented out code, reoder tests
anodar Aug 17, 2026
7d693b8
Merge branch 'anodar/3569-5-node-available-chains-switch' into anodar…
anodar Aug 17, 2026
66b3305
Merge branch 'main' into anodar/3569-6-chain-aware-presigs
anodar Aug 17, 2026
95dc3e5
Merge branch 'main' into anodar/3569-6-chain-aware-presigs
anodar Aug 19, 2026
fe7b7c1
address comments
anodar Aug 19, 2026
98c7c0d
Merge branch 'anodar/3569-6-chain-aware-presigs' of github.com:near/m…
anodar Aug 19, 2026
92d15f5
Add notify when cold queue changes
anodar Aug 20, 2026
8eb8eb6
fix bug
anodar Aug 20, 2026
e3c1f33
Address comments
anodar Aug 23, 2026
26cde2e
s/recv_async/try_recv
anodar Aug 23, 2026
22d7d38
Fix clippy
anodar Aug 23, 2026
316d2c5
Merge branch 'main' into anodar/3569-6-chain-aware-presigs
anodar Aug 24, 2026
1b8b66b
Merge branch 'main' into anodar/3569-6-chain-aware-presigs
anodar Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
322 changes: 320 additions & 2 deletions crates/node/src/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ use std::sync::{Arc, Mutex};
/// If the element *doesn't satisfy* the condition it is inserted at the back.
/// 4. When the condition changes the barriers are reset, marking
/// the entire queue as unknown.
/// 5. When taking an asset matching a caller-supplied condition value we may
/// remove from any position before the cold_available barrier. Barriers
/// past the removed position shift down by one.
///
/// NB: Assets may be reordered by these operations. No guarantees are made on the order in which
/// assets are taken or discarded from the queue.
Expand Down Expand Up @@ -165,6 +168,40 @@ impl<T, CondVal: Default + Eq> ColdQueue<T, CondVal> {
self.cold_queue.push_back((id, value));
ColdQueueAddIfNotSatisfiedResult::Enqueued
}

/// Adds an element to the cold queue unconditionally, never returned.
pub(self) fn ingest(&mut self, id: UniqueId, value: T) {
Comment thread
anodar marked this conversation as resolved.
Outdated
self.update_condition_value_if_due();
if (self.condition)(&self.last_condition_value, &value) {
self.cold_queue.push_front((id, value));
self.cold_ready += 1;
self.cold_available += 1;
} else {
self.cold_queue.push_back((id, value));
}
}

/// Removes and returns the first element satisfying both the standing
/// condition and the caller-supplied `cond_val`, shifting the barriers
/// ([`ColdQueue::cold_ready`] and [`ColdQueue::cold_available`]) that lie
/// past the removed position.
pub(self) fn take_first_matching(&mut self, cond_val: &CondVal) -> Option<(UniqueId, T)> {
self.update_condition_value_if_due();
let pos = self
.cold_queue
.iter()
.take(self.cold_available)
.position(|(_, val)| {
(self.condition)(&self.last_condition_value, val) && (self.condition)(cond_val, val)
})?;
if pos < self.cold_ready {
self.cold_ready -= 1;
}
if pos < self.cold_available {
self.cold_available -= 1;
}
self.cold_queue.remove(pos)
}
}

enum ColdQueueTakeResult<T> {
Expand Down Expand Up @@ -197,6 +234,7 @@ where
hot_receiver: flume::Receiver<(UniqueId, T)>,
cold_queue: Arc<Mutex<ColdQueue<T, CondVal>>>,
clock: Clock,
cold_queue_changed: tokio::sync::Notify,
Comment thread
anodar marked this conversation as resolved.
Outdated
}

impl<T, CondVal: Default + Eq> DoubleQueue<T, CondVal>
Expand All @@ -218,6 +256,7 @@ where
condition_value_fetcher,
))),
clock,
cold_queue_changed: tokio::sync::Notify::new(),
}
}

Expand All @@ -233,6 +272,7 @@ where
// away and we quickly exhaust the available assets.
self.cold_queue.lock().unwrap().update_condition_value();
loop {
let cold_queue_changed = self.cold_queue_changed.notified();
let taken = self.cold_queue.lock().unwrap().take();
match taken {
ColdQueueTakeResult::Taken(result) => {
Expand All @@ -252,9 +292,11 @@ where
// making a cold queue element eligible.
continue;
}
_ = cold_queue_changed => {
continue;
}
received = self.hot_receiver.recv_async() => {
// can't fail, because self keeps a sender.
let (id, value) = received.unwrap();
let (id, value) = received.expect("should never fail because self keeps a sender");
match self.cold_queue.lock().unwrap().add_if_condition_not_satisfied(id, value) {
ColdQueueAddIfNotSatisfiedResult::ConditionSatisfied(value) => {
return (id, value);
Expand All @@ -270,6 +312,42 @@ where
}
}

pub async fn take_owned_matching(&self, cond_val: CondVal) -> (UniqueId, T) {
loop {
let cold_queue_changed = self.cold_queue_changed.notified();
let (taken, ingested) = {
let mut cold = self.cold_queue.lock().unwrap();
let mut ingested = false;
while let Some(Ok((id, value))) = self.hot_receiver.recv_async().now_or_never() {
Comment thread
anodar marked this conversation as resolved.
Outdated
cold.ingest(id, value);
Comment thread
anodar marked this conversation as resolved.
ingested = true;
}
(cold.take_first_matching(&cond_val), ingested)
};

if ingested {
self.cold_queue_changed.notify_waiters();
}
if let Some(taken) = taken {
return taken;
}

// If the cold queue is exhausted, wait for a new element.
tokio::select! {
_ = self.clock.sleep(near_time::Duration::seconds(1)) => {
continue;
}
_ = cold_queue_changed => {
Comment thread
anodar marked this conversation as resolved.
Outdated
continue;
}
received = self.hot_receiver.recv_async() => {
let (id, value) = received.expect("should never fail because self keeps a sender");
self.cold_queue.lock().unwrap().ingest(id, value);
}
}
}
Comment thread
anodar marked this conversation as resolved.
}

/// Process `num_elements_to_process`, removing any that doesn't satisfy condition.
/// Return ids, that were removed from cold storage.
pub async fn maybe_discard_owned(&self, mut num_elements_to_process: usize) -> Vec<UniqueId> {
Expand Down Expand Up @@ -569,6 +647,22 @@ where
result
}

/// Takes an owned asset satisfying both the standing alive-condition and
/// the supplied `eligible` set. Blocks indefinitely if none becomes
/// available.
/// Callers are expected to enforce their own timeout.
pub async fn take_owned_matching(&self, eligible: Vec<ParticipantId>) -> (UniqueId, T) {
let (id, val) = self.owned_queue.take_owned_matching(eligible).await;
let mut update = self.db.update();
update.delete(self.col, &self.make_key(id));
update
.commit()
// TODO(#4090): propagate err instead in here and rest of the functions
// in this file.
.expect("Unrecoverable error writing to database");
Comment thread
netrome marked this conversation as resolved.
(id, val)
}

fn take_unowned_inner(&self, id: UniqueId) -> anyhow::Result<T> {
let key = self.make_key(id);
let value_ser = self.db.get(self.col, &key)?.ok_or_else(|| {
Expand Down Expand Up @@ -1398,4 +1492,228 @@ mod tests {
}
}
}

// The standing condition holds for 2 and 3, the supplied one for 3 and 4:
// only 3 satisfies both and may be taken; the rest stay in the queue.
#[test]
#[expect(non_snake_case)]
fn take_owned_matching__should_only_take_asset_satisfying_both_conditions() {
// Given
let clock = FakeClock::default();
let queue = DoubleQueue::new(
clock.clock(),
|cond: &Vec<i32>, val| cond.contains(val),
Arc::new(|| vec![2, 3]),
);
let id1 = UniqueId::new(ParticipantId::from_raw(42), 123, 456);
let id2 = id1.add_to_counter(1).unwrap();
let id3 = id1.add_to_counter(2).unwrap();
queue.add_owned(id1, 2);
queue.add_owned(id2, 3);
queue.add_owned(id3, 4);

// When
let taken = queue.take_owned_matching(vec![3, 4]).now_or_never();

// Then
assert_eq!(taken, Some((id2, 3)));
assert_eq!(queue.available(), 1);
assert_eq!(queue.offline(), 1);
}

// A take with a supplied value nothing matches yet parks; it completes once
// a matching asset is added, without consuming the non-matching one.
#[test]
#[expect(non_snake_case)]
fn take_owned_matching__should_wait_until_matching_asset_is_added() {
// Given
let clock = FakeClock::default();
let queue = DoubleQueue::new(
clock.clock(),
|cond: &Vec<i32>, val| cond.contains(val),
Arc::new(|| vec![2, 3]),
);
let id1 = UniqueId::new(ParticipantId::from_raw(42), 123, 456);
let id2 = id1.add_to_counter(1).unwrap();
queue.add_owned(id1, 2);

// When
let fut = queue.take_owned_matching(vec![3]);
let MaybeReady::Future(fut) = run_future_once(fut) else {
panic!("should not take a value when no element matches");
};

// Then
queue.add_owned(id2, 3);
assert_eq!(fut.now_or_never().unwrap(), (id2, 3));
assert_eq!(queue.available(), 1);
}

// Takes from the middle and the front of the ready section, then attempts an
// element failing the standing condition (never returned even when it
// satisfies the supplied value), checking barrier consistency at every step.
#[test]
#[expect(non_snake_case)]
fn take_first_matching__should_maintain_barrier_invariants() {
// Given
let clock = FakeClock::default();
let mut queue = ColdQueue::new(
clock.clock(),
|cond: &Vec<i32>, val| cond.contains(val),
Arc::new(|| vec![2, 4]),
);
let id1 = UniqueId::new(ParticipantId::from_raw(42), 1, 0);
let id2 = id1.add_to_counter(1).unwrap();
let id3 = id1.add_to_counter(2).unwrap();
queue.ingest(id1, 2);
queue.ingest(id2, 4);
queue.ingest(id3, 3);
verify_cold_queue_internal_consistency(&queue, 3);

// When
let taken = queue.take_first_matching(&vec![2, 3]);

// Then
assert_eq!(taken, Some((id1, 2)));
verify_cold_queue_internal_consistency(&queue, 2);

assert_eq!(queue.take_first_matching(&vec![4]), Some((id2, 4)));
verify_cold_queue_internal_consistency(&queue, 1);

assert_eq!(queue.take_first_matching(&vec![3]), None);
verify_cold_queue_internal_consistency(&queue, 1);
}

// Flips the standing condition between operations (advancing the fake
// clock past the refresh interval): the barrier reset must keep the
// sections consistent, and takes must honor the new standing value.
#[test]
#[expect(non_snake_case)]
fn take_first_matching__should_stay_consistent_when_condition_value_changes() {
// Given
let clock = FakeClock::default();
let standing = Arc::new(Mutex::new(vec![2, 4]));
let mut queue = ColdQueue::new(clock.clock(), |cond: &Vec<i32>, val| cond.contains(val), {
let standing = standing.clone();
Arc::new(move || standing.lock().unwrap().clone())
});
let id1 = UniqueId::new(ParticipantId::from_raw(42), 1, 0);
let id2 = id1.add_to_counter(1).unwrap();
queue.ingest(id1, 2);
queue.ingest(id2, 3);
verify_cold_queue_internal_consistency(&queue, 2);

// When: the standing condition changes and the refresh comes due.
*standing.lock().unwrap() = vec![3];
clock.advance(near_time::Duration::seconds(1));

// Then: 2 no longer satisfies the standing condition and cannot be
// taken, while 3 (previously non-satisfying) now can.
assert_eq!(queue.take_first_matching(&vec![2]), None);
verify_cold_queue_internal_consistency(&queue, 2);
assert_eq!(queue.take_first_matching(&vec![3]), Some((id2, 3)));
verify_cold_queue_internal_consistency(&queue, 1);
}

// Takes the asset matching the supplied participant set, then reopens the
// store from the same DB: only the taken asset is deleted from disk.
#[tokio::test]
#[expect(non_snake_case)]
async fn distributed_store_take_owned_matching__should_delete_taken_asset_from_disk() {
// Given
let dir = tempfile::tempdir().unwrap();
let db = crate::db::SecretDB::new(dir.path(), [1; 16]).unwrap();
let condition: fn(&Vec<ParticipantId>, &u32) -> bool =
|eligible, val| eligible.contains(&ParticipantId::from_raw(*val));
let alive = || vec![ParticipantId::from_raw(123), ParticipantId::from_raw(456)];
let new_store = |db: Arc<crate::db::SecretDB>| {
DistributedAssetStorage::<u32>::new(
FakeClock::default().clock(),
db,
crate::db::DBCol::TripleV2,
Vec::new(),
ParticipantId::from_raw(42),
condition,
Arc::new(alive),
)
.unwrap()
};
let store = new_store(db.clone());
let id1 = store.generate_and_reserve_id();
let id2 = store.generate_and_reserve_id();
store.add_owned(id1, 123);
store.add_owned(id2, 456);

// When
let taken = store
.take_owned_matching(vec![ParticipantId::from_raw(456)])
.await;
drop(store);
let reopened = new_store(db);

// Then
assert_eq!(taken, (id2, 456));
assert_eq!(reopened.num_owned(), 1);
assert_eq!(
reopened
.take_owned_matching(vec![ParticipantId::from_raw(123)])
.await,
(id1, 123)
);
}

/// A `take_owned_matching` taker drains a buffered asset it can't use
/// (even fails its odd condition) into the cold queue on its first scan; a
/// parked `take_owned` taker must be woken for it immediately — the clock
/// never advances, so a missing wakeup leaves it pending on its 1s tick.
#[test]
#[expect(non_snake_case)]
fn take_owned__should_wake_when_matching_take_drains_incompatible_asset() {
// Given
let clock = FakeClock::default();
let queue = DoubleQueue::new(clock.clock(), |cond, val| val % 2 == *cond, Arc::new(|| 0));
let MaybeReady::Future(take_owned) = run_future_once(queue.take_owned()) else {
panic!("take_owned should park on an empty queue");
};
let id = UniqueId::new(ParticipantId::from_raw(42), 123, 456);
queue.add_owned(id, 2);

// When
let MaybeReady::Future(_take_matching) = run_future_once(queue.take_owned_matching(1))
else {
panic!("the asset must not satisfy the matching taker's condition");
};

// Then
assert_eq!(take_owned.now_or_never().unwrap(), (id, 2));
}

/// Same as above, but the asset arrives while the matching taker is
/// already parked, so it is ingested by the taker's hot-receiver select
/// arm rather than by the drain on the first scan; the parked `take_owned`
/// taker must be woken all the same.
#[test]
#[expect(non_snake_case)]
fn take_owned__should_wake_when_parked_matching_take_receives_incompatible_asset() {
// Given
let clock = FakeClock::default();
let queue = DoubleQueue::new(clock.clock(), |cond, val| val % 2 == *cond, Arc::new(|| 0));
let MaybeReady::Future(take_owned) = run_future_once(queue.take_owned()) else {
panic!("take_owned should park on an empty queue");
};
let MaybeReady::Future(take_matching) = run_future_once(queue.take_owned_matching(1))
else {
panic!("take_owned_matching should park on an empty queue");
};

// When
let id = UniqueId::new(ParticipantId::from_raw(42), 123, 456);
queue.add_owned(id, 2);
let MaybeReady::Future(_take_matching) = run_future_once(take_matching) else {
panic!("the asset must not satisfy the matching taker's condition");
};

// Then
assert_eq!(take_owned.now_or_never().unwrap(), (id, 2));
}
}
Loading
Loading