use async_trait::async_trait;
use tracing::{debug, error, info, warn};
use crate::types::{
GASPInitialReply, GASPInitialRequest, GASPInitialResponse, GASPNode, GASPNodeResponse,
GASPOutput,
};
pub const GASP_VERSION: u32 = 1;
pub const DEFAULT_GASP_SYNC_LIMIT: u64 = 10000;
pub const ENGINE_GASP_SYNC_PAGE_LIMIT: u64 = 100;
pub async fn race_or_deadline<F, D, T>(fut: F, deadline: D) -> Option<T>
where
F: std::future::Future<Output = T>,
D: std::future::Future<Output = ()>,
{
let mut fut = std::pin::pin!(fut);
let mut deadline = std::pin::pin!(deadline);
std::future::poll_fn(move |cx| {
if let std::task::Poll::Ready(v) = fut.as_mut().poll(cx) {
return std::task::Poll::Ready(Some(v));
}
if deadline.as_mut().poll(cx).is_ready() {
return std::task::Poll::Ready(None);
}
std::task::Poll::Pending
})
.await
}
pub fn latched(deadline: crate::engine::SleepFuture) -> crate::engine::SleepFuture {
let mut inner = Some(deadline);
Box::pin(std::future::poll_fn(move |cx| {
let Some(deadline) = inner.as_mut() else {
return std::task::Poll::Ready(());
};
if deadline.as_mut().poll(cx).is_ready() {
inner = None;
return std::task::Poll::Ready(());
}
std::task::Poll::Pending
}))
}
#[derive(Debug, Default)]
pub struct SubmitGate {
writing: std::cell::Cell<u32>,
due: std::cell::Cell<bool>,
}
impl SubmitGate {
pub fn write_section(&self) -> WriteSection<'_> {
self.writing.set(self.writing.get() + 1);
WriteSection(self)
}
pub fn deadline_is_due(&self) -> bool {
self.due.get()
}
pub async fn stop_if_due(&self) {
if self.due.get() {
std::future::pending::<()>().await;
}
}
}
#[must_use = "the section closes when this is dropped"]
pub struct WriteSection<'g>(&'g SubmitGate);
impl Drop for WriteSection<'_> {
fn drop(&mut self) {
self.0.writing.set(self.0.writing.get().saturating_sub(1));
}
}
pub async fn race_or_deadline_guarded<F, D, T>(fut: F, deadline: D, gate: &SubmitGate) -> Option<T>
where
F: std::future::Future<Output = T>,
D: std::future::Future<Output = ()>,
{
struct ClearDue<'g>(&'g SubmitGate);
impl Drop for ClearDue<'_> {
fn drop(&mut self) {
self.0.due.set(false);
}
}
let _clear = ClearDue(gate);
let mut fut = std::pin::pin!(fut);
let mut deadline = std::pin::pin!(deadline);
let mut due = false;
std::future::poll_fn(move |cx| {
if let std::task::Poll::Ready(v) = fut.as_mut().poll(cx) {
return std::task::Poll::Ready(Some(v));
}
if !due && deadline.as_mut().poll(cx).is_ready() {
due = true;
}
if due {
if gate.writing.get() == 0 {
return std::task::Poll::Ready(None);
}
gate.due.set(true);
}
std::task::Poll::Pending
})
.await
}
pub const PEER_QUARANTINE_THRESHOLD: u64 = 8;
pub const PEER_QUARANTINE_REPROBE_SECS: u64 = 6 * 3600;
pub const PEER_YIELDLESS_SYNCS_ALLOWED: u64 = 12;
pub const PEER_YIELDLESS_SECS_ALLOWED: u64 = 3 * 3600;
pub const PEER_YIELDLESS_DECAY_SECS: u64 = PEER_QUARANTINE_REPROBE_SECS;
pub fn yieldless_sync_failed(streak: &crate::storage::PeerYieldStreak) -> bool {
streak.yieldless_syncs > PEER_YIELDLESS_SYNCS_ALLOWED
&& streak
.secs_since_first
.is_some_and(|secs| secs >= PEER_YIELDLESS_SECS_ALLOWED)
}
pub fn peer_origin(url: &str) -> String {
peer_authority(url).1
}
fn peer_authority(url: &str) -> (String, String, Option<String>) {
let url = url.trim();
let (scheme, rest) = url.split_once("://").unwrap_or(("https", url));
let scheme = scheme.to_ascii_lowercase();
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
let (host, port) = match host_port.strip_prefix('[') {
Some(v6) => match v6.split_once(']') {
Some((addr, after)) => (format!("[{addr}]"), after.strip_prefix(':')),
None => (host_port.to_string(), None),
},
None => match host_port.split_once(':') {
Some((host, port)) => (host.to_string(), Some(port)),
None => (host_port.to_string(), None),
},
};
let default_port = match scheme.as_str() {
"https" => "443",
"http" => "80",
_ => "",
};
let port = port.filter(|p| !p.is_empty() && *p != default_port);
(
scheme,
host.trim_end_matches('.').to_ascii_lowercase(),
port.map(str::to_string),
)
}
pub fn ship_peers_by_origin<I: IntoIterator<Item = String>>(domains: I) -> Vec<String> {
type Rank = (bool, bool, String);
let mut by_origin: std::collections::BTreeMap<String, (Rank, String)> =
std::collections::BTreeMap::new();
for domain in domains {
let (scheme, origin, port) = peer_authority(&domain);
if origin.is_empty() {
continue;
}
let canonical = match &port {
Some(port) => format!("{scheme}://{origin}:{port}"),
None => format!("{scheme}://{origin}"),
};
let candidate = (scheme != "https", port.is_some(), canonical.clone());
match by_origin.get(&origin) {
Some((kept, _)) if *kept <= candidate => {}
_ => {
by_origin.insert(origin, (candidate, canonical));
}
}
}
let mut peers: Vec<String> = by_origin.into_values().map(|(_, url)| url).collect();
peers.sort();
peers
}
pub fn peer_sync_quarantined(health: &crate::storage::PeerSyncHealth) -> bool {
health.consecutive_failures >= PEER_QUARANTINE_THRESHOLD
&& health
.secs_since_last_attempt
.is_some_and(|secs| secs < PEER_QUARANTINE_REPROBE_SECS)
}
pub const DEFAULT_GRAPH_BUDGET_CALLS: u32 = 100;
pub const DEFAULT_GRAPH_BUDGET_MS: u64 = 60_000;
pub const DEFAULT_GRAPH_BUDGET_BYTES: u64 = 917_504;
pub const DEFAULT_GRAPH_BUDGET_NODES: u32 = 64;
pub const DEFERRED_GRAPH_MAX_PASSES: u32 = 60;
pub const DEFERRED_GRAPHS_PER_PEER_TOPIC: usize = 16;
pub const FAULT_RECORDS_PER_PEER_TOPIC: usize = 32;
pub const DEFERRED_GRAPH_MAX_IDLE_FAULTS: u32 = 3;
pub const DEADLINE_STALL_DEFERRAL_RUNS: u32 = 3;
pub const EXPIRED_OUTPOINT_MEMO_SECS: u64 = 7 * 24 * 3600;
pub const EXPIRED_OUTPOINT_MEMO_MAX_ROWS: usize = 20_000;
pub const UNANCHORABLE_AFTER_FAULTED_RUNS: u32 = 3;
pub const UNANCHORABLE_RETRY_SECS: u64 = 6 * 3600;
pub const IDLE_RESUMES_BEFORE_BACKOFF: u32 = 3;
pub const IDLE_RESUME_RETRY_SECS: u64 = UNANCHORABLE_RETRY_SECS;
pub const RESUMES_SHARE_OF_PEER_BUDGET_PERCENT: u64 = 33;
pub fn resume_share_ms(peer_budget_ms: u64, graph_budget_ms: u64) -> u64 {
(peer_budget_ms.saturating_mul(RESUMES_SHARE_OF_PEER_BUDGET_PERCENT) / 100).min(graph_budget_ms)
}
pub const FAULT_RETRY_REASON: &str = "retry:fault:";
pub const UNANCHORABLE_REASON: &str = "unanchorable:";
pub mod fault_class {
pub const COURIER: &str = "courier";
pub const NOT_SERVED: &str = "not_served";
pub const PEER: &str = "peer";
pub const VERIFICATION: &str = "verification";
pub const OTHER: &str = "other";
pub fn strikes(class: &str) -> bool {
matches!(class, COURIER | NOT_SERVED | VERIFICATION)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct FaultedRoot {
#[serde(default)]
pub peer: String,
pub outpoint: String,
pub class: String,
pub faults: u32,
pub unanchorable: bool,
pub error: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub refused: String,
}
pub mod strike_refused {
pub const NO_ROOM: &str = "no_room";
pub const STORE_FAULT: &str = "store_fault";
}
pub struct GraphBudget<'a> {
pub max_calls: u32,
pub max_bytes_fetched: u64,
pub max_nodes: u32,
pub deadline: Box<dyn Fn() -> crate::engine::SleepFuture + 'a>,
pub half_deadline: Option<Box<dyn Fn() -> crate::engine::SleepFuture + 'a>>,
}
impl GraphBudget<'_> {
fn pass(&self) -> PassCap {
PassCap {
calls: self.max_calls,
bytes: self.max_bytes_fetched,
nodes: self.max_nodes,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PassCap {
calls: u32,
bytes: u64,
nodes: u32,
}
impl PassCap {
const NONE: PassCap = PassCap {
calls: u32::MAX,
bytes: u64::MAX,
nodes: u32::MAX,
};
fn spent(self) -> bool {
self.calls == 0 || self.bytes == 0 || self.nodes == 0
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeferredGraphKey {
pub outpoint: String,
pub score: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeferredGraphSave {
Saved,
AtCeiling,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WalkedNode {
pub node: GASPNode,
pub spent_by: Option<String>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PendingInput {
pub outpoint: String,
pub graph_id: String,
pub metadata: bool,
pub spent_by: Option<String>,
pub parent_proven: bool,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeferredGraph {
pub peer: String,
pub topic: String,
pub outpoint: String,
pub score: u64,
pub nodes: Vec<WalkedNode>,
pub pending: Vec<PendingInput>,
pub calls: u64,
pub passes: u32,
pub reason: String,
#[serde(default)]
pub configured: bool,
#[serde(default)]
pub idle_faults: u32,
#[serde(default)]
pub cursor_passable: bool,
#[serde(default, skip_serializing_if = "is_zero_u32")]
pub faults: u32,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub retry_at: u64,
#[serde(default, skip_serializing_if = "is_zero_u32")]
pub idle_resumes: u32,
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_zero_u32(n: &u32) -> bool {
*n == 0
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_zero_u64(n: &u64) -> bool {
*n == 0
}
impl DeferredGraph {
pub fn is_struck(&self) -> bool {
self.faults > 0
&& (self.reason.starts_with(FAULT_RETRY_REASON)
|| self.reason.starts_with(UNANCHORABLE_REASON))
}
pub fn is_unanchorable(&self) -> bool {
self.is_struck() && self.reason.starts_with(UNANCHORABLE_REASON)
}
pub fn waits_idle(&self, now: u64) -> bool {
!self.is_struck() && self.cursor_passable && now < self.retry_at
}
pub fn fault_class(&self) -> Option<&str> {
if !self.is_struck() {
return None;
}
self.reason
.strip_prefix(UNANCHORABLE_REASON)
.or_else(|| self.reason.strip_prefix(FAULT_RETRY_REASON))
}
pub fn byte_size(&self) -> usize {
serde_json::to_vec(self).map_or(usize::MAX, |b| b.len())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DropReason {
MaxPasses,
TooMany,
StoreFault,
NotServed,
Held,
NotHeld,
RootProven,
Refused,
NoProgress,
IdleFaults,
}
impl DropReason {
pub const ALL: [DropReason; 10] = [
Self::MaxPasses,
Self::TooMany,
Self::StoreFault,
Self::NotServed,
Self::Held,
Self::NotHeld,
Self::RootProven,
Self::Refused,
Self::NoProgress,
Self::IdleFaults,
];
pub fn as_str(self) -> &'static str {
match self {
Self::MaxPasses => "max_passes",
Self::TooMany => "too_many",
Self::StoreFault => "store_fault",
Self::NotServed => "not_served",
Self::Held => "held",
Self::NotHeld => "not_held",
Self::RootProven => "root_proven",
Self::Refused => "refused",
Self::NoProgress => "no_progress",
Self::IdleFaults => "idle_faults",
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct DroppedGraph {
pub outpoint: String,
pub reason: DropReason,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DeferralStats {
pub deferred: u64,
pub resumed: u64,
pub converged: u64,
pub dropped: Vec<DroppedGraph>,
pub progressed: u64,
pub stalled: u64,
pub held_back: u64,
pub backed_off: u64,
pub waiting: u64,
pub idle_counted: u64,
}
impl Walk {
fn over(&self) -> Option<&'static str> {
if self.calls_this_pass >= self.cap.calls {
Some("calls")
} else if self.bytes_this_pass >= self.cap.bytes {
Some("bytes")
} else if self.nodes_this_pass >= self.cap.nodes {
Some("nodes")
} else {
None
}
}
}
struct Walk {
record: DeferredGraph,
seen: std::collections::HashSet<String>,
calls_this_pass: u32,
bytes_this_pass: u64,
nodes_this_pass: u32,
resumed: bool,
cap: PassCap,
nodes_at_start: usize,
counted: bool,
gone_on: bool,
half: Option<crate::engine::SleepFuture>,
struck: Option<Box<DeferredGraph>>,
}
enum WalkEnd {
Expired,
Done,
Deferred(&'static str),
}
enum StepOut {
Expired,
Pruned,
Seen,
Appended {
node_id: String,
walked: Box<WalkedNode>,
children: Vec<PendingInput>,
},
}
enum Ingested {
Retry,
Expired,
Completed,
Deferred,
HeldBack,
Unanchorable,
Waiting,
}
#[derive(Debug, Clone, Copy, Default)]
struct Strike {
held: Option<(u32, bool)>,
refused: Option<&'static str>,
}
impl Strike {
fn passable(self) -> bool {
self.held.is_some_and(|(_, unanchorable)| unanchorable)
}
}
enum Saved {
Yes,
Retry,
Unkept(DropReason, Box<Walk>),
Fault(GASPError, Box<Walk>),
Ceiling(Box<Walk>),
WentOn,
}
#[async_trait(?Send)]
pub trait GASPStorage {
fn sync_expiry(&self, _node: &GASPNode, _outpoint: &str) -> Result<Option<u64>, GASPError> {
Ok(None)
}
async fn find_known_utxos(
&self,
since: u64,
limit: Option<u64>,
) -> Result<Vec<GASPOutput>, GASPError>;
async fn hydrate_gasp_node(
&self,
graph_id: &str,
txid: &str,
output_index: u32,
metadata: bool,
) -> Result<GASPNode, GASPError>;
async fn find_needed_inputs(
&self,
node: &GASPNode,
) -> Result<Option<GASPNodeResponse>, GASPError>;
async fn append_to_graph(
&self,
node: &GASPNode,
spent_by: Option<&str>,
) -> Result<(), GASPError>;
async fn validate_graph_anchor(&self, graph_id: &str) -> Result<(), GASPError>;
async fn finalize_graph(&self, graph_id: &str) -> Result<(), GASPError>;
async fn discard_graph(&self, graph_id: &str) -> Result<(), GASPError>;
async fn load_deferred_graphs(&self) -> Result<Vec<DeferredGraphKey>, GASPError>;
async fn get_deferred_graph(&self, outpoint: &str) -> Result<Option<DeferredGraph>, GASPError>;
async fn save_deferred_graph(
&self,
record: &DeferredGraph,
) -> Result<DeferredGraphSave, GASPError>;
async fn delete_deferred_graph(&self, outpoint: &str) -> Result<(), GASPError>;
async fn remember_expired(&self, _outpoint: &str, _expiry: u64) -> Result<(), GASPError> {
Ok(())
}
async fn remembered_expired(&self, _outpoints: &[String]) -> Result<Vec<String>, GASPError> {
Ok(Vec::new())
}
}
#[async_trait(?Send)]
pub trait GASPRemote {
async fn get_initial_response(
&self,
request: &GASPInitialRequest,
) -> Result<GASPInitialResponse, GASPError>;
async fn get_initial_reply(
&self,
response: &GASPInitialResponse,
) -> Result<GASPInitialReply, GASPError>;
async fn request_node(
&self,
graph_id: &str,
txid: &str,
output_index: u32,
metadata: bool,
) -> Result<GASPNode, GASPError>;
async fn submit_node(&self, node: &GASPNode) -> Result<Option<GASPNodeResponse>, GASPError>;
}
pub trait GASPRemoteFactory {
fn create_remote(&self, peer_url: &str, topic: &str) -> Box<dyn GASPRemote>;
}
#[async_trait(?Send)]
pub trait AncestorFetcher {
async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError>;
async fn verified_proof_for(&self, txid: &str) -> Option<String> {
self.fetch_ancestor(txid).await.ok().and_then(|a| a.proof)
}
async fn verified_proof_for_detailed(&self, txid: &str) -> Result<Option<String>, String> {
Ok(self.verified_proof_for(txid).await)
}
async fn verified_proof_for_exhaustive(&self, txid: &str) -> Result<Option<String>, String> {
self.verified_proof_for_detailed(txid).await
}
fn budget_remaining(&self) -> Option<u32> {
None
}
async fn verify_proof(&self, txid: &str, bump_hex: &str) -> bool {
let _ = (txid, bump_hex);
false
}
async fn resolve_spender(&self, _txid: &str, _vout: u32) -> Result<Option<String>, String> {
Ok(None)
}
async fn resolve_spender_by_script(
&self,
_scripthash_le_hex: &str,
_funding_txid: &str,
) -> Result<Vec<String>, String> {
Ok(Vec::new())
}
async fn output_script_hint(&self, _txid: &str, _vout: u32) -> Result<Option<Vec<u8>>, String> {
Ok(None)
}
async fn spender_binding_raw(
&self,
_spender: &str,
_txid: &str,
_vout: u32,
) -> Result<Option<String>, String> {
Ok(None)
}
}
#[derive(Debug, Clone)]
pub struct FetchedAncestor {
pub raw_tx: String,
pub proof: Option<String>,
}
#[async_trait(?Send)]
pub trait FinalizedGraphHook {
async fn graph_completed(&self) -> Result<(), GASPError>;
}
pub struct GASPSync<'a> {
expired: std::rc::Rc<std::cell::RefCell<std::collections::HashSet<String>>>,
root_proofs: std::cell::RefCell<std::collections::HashMap<String, bool>>,
roots_proven: std::cell::Cell<u64>,
roots_unproven: std::cell::Cell<u64>,
cursor_held_by: Option<(u64, String)>,
cursor_after_in_flight: u64,
in_flight: Option<(u64, String)>,
retained: std::cell::Cell<u64>,
resumed_with_progress: std::cell::Cell<u64>,
cursor_yielded: std::cell::Cell<bool>,
expired_refused: std::cell::Cell<u64>,
refused_expiry: std::cell::Cell<Option<u64>>,
storage: Box<dyn GASPStorage + 'a>,
remote: Box<dyn GASPRemote + 'a>,
pub last_interaction: u64,
pub unidirectional: bool,
log_prefix: String,
ancestor_fetcher: Option<std::rc::Rc<dyn AncestorFetcher + 'a>>,
pruned: std::cell::RefCell<std::collections::HashSet<String>>,
pruned_inputs: std::cell::Cell<u64>,
discarded_graphs: std::cell::Cell<u64>,
anchor_walk_held: std::cell::Cell<u64>,
finalized_hook: Option<Box<dyn FinalizedGraphHook + 'a>>,
completed_cursor: u64,
graph_in_flight: bool,
graph_budget: Option<GraphBudget<'a>>,
deferred: std::cell::RefCell<std::collections::HashSet<String>>,
resume_deadline: std::cell::RefCell<Option<crate::engine::SleepFuture>>,
resume_left: std::cell::Cell<PassCap>,
resume_share: Option<Box<dyn Fn() -> crate::engine::SleepFuture + 'a>>,
calls_made: std::cell::Cell<u64>,
bytes_fetched: std::cell::Cell<u64>,
nodes_appended: std::cell::Cell<u64>,
held_records: std::cell::Cell<usize>,
fault_records: std::cell::RefCell<std::collections::HashSet<String>>,
fault_keys_unread: std::cell::Cell<bool>,
strikes_refused: std::cell::Cell<u64>,
resume_idle: std::cell::Cell<Option<(u32, usize)>>,
pass_saved: std::cell::Cell<bool>,
keys_unread: std::cell::Cell<usize>,
walk: std::cell::RefCell<Option<Walk>>,
stats: std::cell::RefCell<DeferralStats>,
graphs_attempted: std::cell::Cell<u64>,
last_fault: std::cell::Cell<Option<&'static str>>,
faulted: std::cell::RefCell<Vec<FaultedRoot>>,
unanchorable_recorded: std::cell::Cell<u64>,
unanchorable_cleared: std::cell::Cell<u64>,
}
impl<'a> GASPSync<'a> {
pub fn new(
storage: Box<dyn GASPStorage + 'a>,
remote: Box<dyn GASPRemote + 'a>,
last_interaction: u64,
log_prefix: impl Into<String>,
unidirectional: bool,
) -> Self {
Self {
storage,
remote,
expired: std::rc::Rc::default(),
expired_refused: std::cell::Cell::new(0),
refused_expiry: std::cell::Cell::new(None),
root_proofs: std::cell::RefCell::default(),
roots_proven: std::cell::Cell::new(0),
roots_unproven: std::cell::Cell::new(0),
cursor_held_by: None,
cursor_after_in_flight: last_interaction,
in_flight: None,
retained: std::cell::Cell::new(0),
resumed_with_progress: std::cell::Cell::new(0),
cursor_yielded: std::cell::Cell::new(false),
last_interaction,
unidirectional,
log_prefix: log_prefix.into(),
ancestor_fetcher: None,
pruned: std::cell::RefCell::new(std::collections::HashSet::new()),
pruned_inputs: std::cell::Cell::new(0),
discarded_graphs: std::cell::Cell::new(0),
anchor_walk_held: std::cell::Cell::new(0),
finalized_hook: None,
completed_cursor: last_interaction,
graph_in_flight: false,
graph_budget: None,
deferred: std::cell::RefCell::new(std::collections::HashSet::new()),
resume_deadline: std::cell::RefCell::new(None),
resume_left: std::cell::Cell::new(PassCap::NONE),
resume_share: None,
calls_made: std::cell::Cell::new(0),
bytes_fetched: std::cell::Cell::new(0),
nodes_appended: std::cell::Cell::new(0),
held_records: std::cell::Cell::new(0),
fault_records: std::cell::RefCell::default(),
fault_keys_unread: std::cell::Cell::new(false),
strikes_refused: std::cell::Cell::new(0),
resume_idle: std::cell::Cell::new(None),
pass_saved: std::cell::Cell::new(false),
keys_unread: std::cell::Cell::new(0),
walk: std::cell::RefCell::new(None),
stats: std::cell::RefCell::new(DeferralStats::default()),
graphs_attempted: std::cell::Cell::new(0),
last_fault: std::cell::Cell::new(None),
faulted: std::cell::RefCell::default(),
unanchorable_recorded: std::cell::Cell::new(0),
unanchorable_cleared: std::cell::Cell::new(0),
}
}
#[must_use]
pub fn with_expired_outpoints(
mut self,
expired: std::rc::Rc<std::cell::RefCell<std::collections::HashSet<String>>>,
) -> Self {
self.expired = expired;
self
}
pub fn expired_refused(&self) -> u64 {
self.expired_refused.get()
}
pub fn roots_proven(&self) -> u64 {
self.roots_proven.get()
}
pub fn roots_unproven(&self) -> u64 {
self.roots_unproven.get()
}
pub fn cursor_held_by(&self) -> Option<String> {
self.cursor_held_by
.as_ref()
.map(|(_, outpoint)| outpoint.clone())
}
pub fn cursor_yielded(&self) -> bool {
self.cursor_yielded.get()
}
pub fn graphs_retained(&self) -> u64 {
self.retained.get()
}
pub fn did_work(&self) -> bool {
self.expired_refused.get() > 0
|| self.retained.get() > 0
|| self.resumed_with_progress.get() > 0
}
pub fn graphs_resumed_with_progress(&self) -> u64 {
self.resumed_with_progress.get()
}
pub fn faulted_roots(&self) -> Vec<FaultedRoot> {
self.faulted.borrow().clone()
}
pub fn unanchorable_recorded(&self) -> u64 {
self.unanchorable_recorded.get()
}
pub fn unanchorable_cleared(&self) -> u64 {
self.unanchorable_cleared.get()
}
pub fn strikes_refused(&self) -> u64 {
self.strikes_refused.get()
}
fn know_fault_record(&self, outpoint: &str) {
if self.fault_records.borrow_mut().insert(outpoint.to_string()) {
self.held_records
.set(self.held_records.get().saturating_sub(1));
}
}
fn read_record(&self, record: Option<&DeferredGraph>) {
if let Some(record) = record.filter(|r| r.is_struck()) {
self.know_fault_record(&record.outpoint);
}
}
fn forget_record(&self, outpoint: &str) {
if !self.fault_records.borrow_mut().remove(outpoint) {
self.held_records
.set(self.held_records.get().saturating_sub(1));
}
}
fn release_hold(&mut self, outpoint: &str) {
if self
.cursor_held_by
.as_ref()
.is_some_and(|(_, held)| held == outpoint)
{
self.cursor_held_by = None;
}
}
fn hold_cursor(&mut self, score: u64, outpoint: &str) {
if self
.cursor_held_by
.as_ref()
.is_none_or(|(held, _)| score < *held)
{
self.cursor_held_by = Some((score, outpoint.to_string()));
}
}
fn root_expired(&self, node: &GASPNode, outpoint: &str) -> Result<bool, GASPError> {
let now = (crate::engine::current_timestamp_ms() / 1000.0) as u64;
let expiry = self
.storage
.sync_expiry(node, outpoint)?
.filter(|expiry| *expiry <= now);
if expiry.is_some() {
self.refused_expiry.set(expiry);
}
Ok(expiry.is_some())
}
async fn load_remembered_expired(&self, outpoints: Vec<String>) {
let outpoints: Vec<String> = {
let memo = self.expired.borrow();
outpoints
.into_iter()
.filter(|o| !memo.contains(o))
.collect()
};
if outpoints.is_empty() {
return;
}
match self.storage.remembered_expired(&outpoints).await {
Ok(dead) => self.expired.borrow_mut().extend(dead),
Err(e) => warn!(
"{} Could not read the remembered expired roots: {}",
self.log_prefix, e
),
}
}
async fn refuse_expired(&self, outpoint: &str) -> Result<Ingested, GASPError> {
let walk = self.walk.borrow_mut().take();
let expiry = self.refused_expiry.take();
if self.expired.borrow_mut().insert(outpoint.to_string()) {
self.expired_refused.set(self.expired_refused.get() + 1);
if let Some(expiry) = expiry {
if let Err(e) = self.storage.remember_expired(outpoint, expiry).await {
warn!(
"{} Could not remember expired root {}: {}",
self.log_prefix, outpoint, e
);
}
}
}
let _ = self
.storage
.discard_graph(&Self::graph_id_of(walk.as_ref(), outpoint))
.await;
if walk
.as_ref()
.is_some_and(|w| w.resumed || w.struck.is_some())
|| self.deferred.borrow_mut().remove(outpoint)
{
self.drop_record(outpoint, DropReason::Refused).await;
}
Ok(Ingested::Expired)
}
fn now_secs() -> u64 {
(crate::engine::current_timestamp_ms() / 1000.0) as u64
}
fn faulted(&self, class: &'static str, error: GASPError) -> GASPError {
if !matches!(error, GASPError::StorageError(_)) {
self.last_fault.set(Some(class));
}
error
}
fn peer_fault_class(error: &GASPError) -> &'static str {
match error {
GASPError::NodeNotFound(_) => fault_class::NOT_SERVED,
_ => fault_class::PEER,
}
}
fn note_faulted(
&self,
outpoint: &str,
error: &GASPError,
class: Option<&'static str>,
struck: Strike,
) {
let mut faulted = self.faulted.borrow_mut();
if faulted.iter().any(|f| f.outpoint == outpoint) {
return;
}
let (faults, unanchorable) = struck.held.unwrap_or((0, false));
faulted.push(FaultedRoot {
peer: String::new(),
outpoint: outpoint.to_string(),
class: class.unwrap_or(fault_class::OTHER).to_string(),
faults,
unanchorable,
error: error.to_string().chars().take(160).collect(),
refused: struck.refused.unwrap_or_default().to_string(),
});
}
async fn strike_fresh(
&self,
score: u64,
outpoint: &str,
error: &GASPError,
lowest: bool,
) -> bool {
let class = self.last_fault.take();
let struck = match class {
Some(class) if lowest && self.graph_budget.is_some() && fault_class::strikes(class) => {
self.strike(score, outpoint, class, None).await
}
_ => Strike::default(),
};
self.note_faulted(outpoint, error, class, struck);
struck.passable()
}
async fn strike_resumed(
&self,
outpoint: &str,
walk: Option<&Walk>,
error: &GASPError,
) -> Option<Strike> {
let class = self.last_fault.get().filter(|c| fault_class::strikes(c))?;
if walk.is_none_or(|w| w.record.nodes.is_empty()) {
return None;
}
let prior = self.storage.get_deferred_graph(outpoint).await.ok()??;
if prior.is_struck() {
return None;
}
self.last_fault.set(None);
let struck = self
.strike(prior.score, outpoint, class, Some(&prior))
.await;
self.note_faulted(outpoint, error, Some(class), struck);
struck.held.map(|_| struck)
}
async fn strike(
&self,
score: u64,
outpoint: &str,
class: &'static str,
prior: Option<&DeferredGraph>,
) -> Strike {
let refuse = |why: &'static str| {
self.strikes_refused.set(self.strikes_refused.get() + 1);
warn!(
"{} Root {} faulted ({}): its strike was REFUSED ({}), the cursor holds (engine 0.1.10)",
self.log_prefix, outpoint, class, why
);
Strike {
held: prior
.filter(|r| r.is_unanchorable())
.map(|r| (r.faults, true)),
refused: Some(why),
}
};
if !prior.is_some_and(DeferredGraph::is_struck)
&& (self.fault_keys_unread.get()
|| self.fault_records.borrow().len() + self.keys_unread.get()
>= FAULT_RECORDS_PER_PEER_TOPIC)
{
return refuse(strike_refused::NO_ROOM);
}
let mut record = Self::fresh_walk(score, outpoint, PassCap::NONE).record;
record.faults = prior.map_or(0, |r| r.faults) + 1;
let unanchorable = record.faults >= UNANCHORABLE_AFTER_FAULTED_RUNS;
record.cursor_passable = unanchorable || prior.is_some_and(|r| r.cursor_passable);
if unanchorable {
record.reason = format!("{UNANCHORABLE_REASON}{class}");
record.retry_at = Self::now_secs() + UNANCHORABLE_RETRY_SECS;
} else {
record.reason = format!("{FAULT_RETRY_REASON}{class}");
}
match self.storage.save_deferred_graph(&record).await {
Ok(DeferredGraphSave::Saved) => {
if prior.is_some() {
self.know_fault_record(outpoint);
} else {
self.fault_records.borrow_mut().insert(outpoint.to_string());
}
if unanchorable && !prior.is_some_and(DeferredGraph::is_unanchorable) {
self.unanchorable_recorded
.set(self.unanchorable_recorded.get() + 1);
warn!(
"{} Root {} UNANCHORABLE ({}): faulted {} runs in a row; the cursor passes it, walked again in {} s (engine 0.1.9)",
self.log_prefix, outpoint, class, record.faults, UNANCHORABLE_RETRY_SECS
);
} else {
warn!(
"{} Root {} faulted ({}): run {} of {} (engine 0.1.9)",
self.log_prefix,
outpoint,
class,
record.faults,
UNANCHORABLE_AFTER_FAULTED_RUNS
);
}
Strike {
held: Some((record.faults, unanchorable)),
refused: None,
}
}
Ok(DeferredGraphSave::AtCeiling) => refuse(strike_refused::NO_ROOM),
Err(_) => refuse(strike_refused::STORE_FAULT),
}
}
async fn rewalk_struck(
&self,
utxo: &GASPOutput,
outpoint: &str,
record: DeferredGraph,
deadline: &mut crate::engine::SleepFuture,
cap: PassCap,
) -> Result<Ingested, GASPError> {
let score = utxo.score as u64;
if record.is_unanchorable() && Self::now_secs() < record.retry_at {
self.graphs_attempted
.set(self.graphs_attempted.get().saturating_sub(1));
debug!(
"{} Unanchorable root {} waits until {} (engine 0.1.9)",
self.log_prefix, outpoint, record.retry_at
);
return Ok(Ingested::Unanchorable);
}
info!(
"{} Walking root {} again after {} faulted runs ({}) (engine 0.1.9)",
self.log_prefix, outpoint, record.faults, record.reason
);
let mut walk = Self::fresh_walk(score, outpoint, cap);
walk.struck = Some(Box::new(record.clone()));
*self.walk.borrow_mut() = Some(walk);
let ended = self.walk_graph(Some(&mut *deadline)).await;
let out = self.finish_pass(outpoint, ended).await;
let keep_unanchorable = |record: &DeferredGraph| {
let mut record = record.clone();
record.retry_at = Self::now_secs() + UNANCHORABLE_RETRY_SECS;
record
};
match out {
Ok(Ingested::Retry) if record.cursor_passable => {
let unanchorable = record.is_unanchorable();
let kept = if unanchorable {
keep_unanchorable(&record)
} else {
record.clone()
};
if let Ok(DeferredGraphSave::Saved) = self.storage.save_deferred_graph(&kept).await
{
self.know_fault_record(outpoint);
}
Ok(if unanchorable {
Ingested::Unanchorable
} else {
Ingested::Deferred
})
}
Ok(ingested) => Ok(ingested),
Err(error) => {
self.walk.borrow_mut().take();
let class = self.last_fault.take();
let Some(class) = class.filter(|c| fault_class::strikes(c)) else {
self.note_faulted(
outpoint,
&error,
class,
Strike {
held: Some((record.faults, record.is_unanchorable())),
refused: None,
},
);
if record.is_unanchorable() {
let _ = self
.storage
.save_deferred_graph(&keep_unanchorable(&record))
.await;
return Ok(Ingested::Unanchorable);
}
return Err(error);
};
let struck = self.strike(score, outpoint, class, Some(&record)).await;
self.note_faulted(outpoint, &error, Some(class), struck);
if struck.passable() {
Ok(Ingested::Unanchorable)
} else {
Err(error)
}
}
}
}
#[must_use]
pub fn with_resume_share(
mut self,
share: Box<dyn Fn() -> crate::engine::SleepFuture + 'a>,
) -> Self {
self.resume_share = Some(share);
self
}
pub fn with_graph_budget(mut self, budget: GraphBudget<'a>) -> Self {
self.graph_budget = Some(budget);
self
}
pub fn deferral_stats(&self) -> DeferralStats {
self.stats.borrow().clone()
}
pub fn graphs_attempted(&self) -> u64 {
self.graphs_attempted.get()
}
#[must_use]
pub fn with_finalized_graph_hook(mut self, hook: Box<dyn FinalizedGraphHook + 'a>) -> Self {
self.finalized_hook = Some(hook);
self
}
pub fn completed_cursor(&self) -> u64 {
self.completed_cursor
}
pub fn graph_in_flight(&self) -> bool {
self.graph_in_flight
}
fn cursor_for_completed(
completed: &std::collections::BTreeSet<u64>,
lowest_unfinished: Option<u64>,
floor: u64,
) -> u64 {
let highest = match lowest_unfinished {
Some(score) => completed.range(..score).next_back(),
None => completed.iter().next_back(),
};
highest.copied().unwrap_or(floor).max(floor)
}
pub fn discarded_graphs(&self) -> u64 {
self.discarded_graphs.get()
}
pub fn anchor_walk_held(&self) -> u64 {
self.anchor_walk_held.get()
}
pub fn pruned_inputs(&self) -> u64 {
self.pruned_inputs.get()
}
#[must_use]
pub fn with_ancestor_fetcher(
mut self,
fetcher: Option<std::rc::Rc<dyn AncestorFetcher + 'a>>,
) -> Self {
self.ancestor_fetcher = fetcher;
self
}
pub async fn sync(&mut self, limit: Option<u64>) -> Result<(), GASPError> {
info!(
"{} Starting sync. last_interaction={}",
self.log_prefix, self.last_interaction
);
self.pruned.borrow_mut().clear();
self.pruned_inputs.set(0);
self.expired_refused.set(0);
self.root_proofs.borrow_mut().clear();
self.roots_proven.set(0);
self.roots_unproven.set(0);
self.cursor_held_by = None;
self.in_flight = None;
self.retained.set(0);
self.resumed_with_progress.set(0);
self.cursor_yielded.set(false);
self.last_fault.set(None);
self.faulted.borrow_mut().clear();
self.unanchorable_recorded.set(0);
self.unanchorable_cleared.set(0);
self.strikes_refused.set(0);
self.fault_records.borrow_mut().clear();
self.fault_keys_unread.set(false);
self.discarded_graphs.set(0);
self.anchor_walk_held.set(0);
self.completed_cursor = self.last_interaction;
self.graph_in_flight = false;
self.walk.borrow_mut().take();
*self.stats.borrow_mut() = DeferralStats::default();
self.deferred.borrow_mut().clear();
self.held_records.set(0);
self.resume_deadline.borrow_mut().take();
self.resume_left.set(
self.graph_budget
.as_ref()
.map_or(PassCap::NONE, GraphBudget::pass),
);
let mut held_keys = Vec::new();
self.keys_unread.set(0);
if self.graph_budget.is_some() {
match self.storage.load_deferred_graphs().await {
Ok(keys) => {
held_keys.clone_from(&keys);
self.held_records.set(keys.len());
self.keys_unread.set(keys.len());
self.deferred
.borrow_mut()
.extend(keys.into_iter().map(|k| k.outpoint));
}
Err(e) => {
self.held_records.set(DEFERRED_GRAPHS_PER_PEER_TOPIC);
self.fault_keys_unread.set(true);
warn!(
"{} Could not read the deferred graphs (bsv-low #555): {}",
self.log_prefix, e
);
}
}
}
let local_utxos = self.storage.find_known_utxos(0, None).await?;
let mut known_outpoints: std::collections::HashSet<String> = local_utxos
.iter()
.map(|u| format!("{}.{}", u.txid, u.output_index))
.collect();
let mut shared_outpoints: std::collections::HashSet<String> =
std::collections::HashSet::new();
let initial_interaction = self.last_interaction;
let mut completed_scores = std::collections::BTreeSet::new();
let mut min_failed_score: Option<u64> = None;
let mut failed_outpoints: std::collections::HashSet<String> =
std::collections::HashSet::new();
self.load_remembered_expired(held_keys.iter().map(|k| k.outpoint.clone()).collect())
.await;
if !held_keys.is_empty() && self.graph_budget.is_some() {
if let Some(share) = &self.resume_share {
let mut deadline = latched(share());
let _ = Self::due(&mut deadline).await;
*self.resume_deadline.borrow_mut() = Some(deadline);
}
}
for key in held_keys {
self.keys_unread
.set(self.keys_unread.get().saturating_sub(1));
if known_outpoints.contains(&key.outpoint) {
self.deferred.borrow_mut().remove(&key.outpoint);
self.drop_record(&key.outpoint, DropReason::Held).await;
shared_outpoints.insert(key.outpoint);
continue;
}
let Some((txid, output_index)) = parse_outpoint(&key.outpoint) else {
continue;
};
let utxo = GASPOutput {
txid,
output_index,
score: key.score as f64,
};
self.graph_in_flight = true;
self.in_flight = Some((key.score, key.outpoint.clone()));
self.cursor_after_in_flight = initial_interaction;
self.hold_cursor(key.score, &key.outpoint);
let result = self.ingest_utxo(&utxo, &key.outpoint).await;
self.graph_in_flight = false;
self.in_flight = None;
let settled = match result {
Ok(Ingested::Completed) => {
let landed = match &self.finalized_hook {
Some(hook) => hook.graph_completed().await.is_ok(),
None => true,
};
self.settle_completed(landed).await;
if landed {
self.cursor_yielded.set(true);
}
landed
}
Ok(Ingested::Expired) => {
self.cursor_yielded.set(true);
true
}
Ok(
Ingested::Deferred
| Ingested::HeldBack
| Ingested::Unanchorable
| Ingested::Waiting,
) => true,
Ok(Ingested::Retry) => false,
Err(error) => {
warn!(
"{} Deferred graph {} retry failed: {}",
self.log_prefix, key.outpoint, error
);
self.walk.borrow_mut().take();
let class = self.last_fault.take();
self.note_faulted(&key.outpoint, &error, class, Strike::default());
false
}
};
let settled = settled
|| self
.storage
.get_deferred_graph(&key.outpoint)
.await
.ok()
.flatten()
.is_some_and(|r| r.cursor_passable);
if settled {
if self
.cursor_held_by
.as_ref()
.is_some_and(|(_, held)| held == &key.outpoint)
{
self.cursor_held_by = None;
}
shared_outpoints.insert(key.outpoint);
} else {
self.hold_cursor(key.score, &key.outpoint);
if key.score >= initial_interaction {
min_failed_score =
Some(min_failed_score.map_or(key.score, |s| s.min(key.score)));
}
failed_outpoints.insert(key.outpoint);
}
}
loop {
let cursor_before_page = self.last_interaction;
self.completed_cursor = Self::cursor_for_completed(
&completed_scores,
min_failed_score,
initial_interaction,
);
let request = GASPInitialRequest {
version: GASP_VERSION,
since: self.last_interaction,
limit,
};
let response = self.remote.get_initial_response(&request).await?;
let page_size = response.utxo_list.len();
info!(
"{} Processing page with {} UTXOs (since: {})",
self.log_prefix, page_size, response.since
);
self.load_remembered_expired(
response
.utxo_list
.iter()
.map(|u| format!("{}.{}", u.txid, u.output_index))
.filter(|o| !known_outpoints.contains(o))
.collect(),
)
.await;
let mut lowest_ahead: Vec<u64> =
response.utxo_list.iter().map(|u| u.score as u64).collect();
for i in (0..lowest_ahead.len().saturating_sub(1)).rev() {
lowest_ahead[i] = lowest_ahead[i].min(lowest_ahead[i + 1]);
}
let unfinished = match lowest_ahead.as_slice() {
[lowest, ..] => Some(min_failed_score.map_or(*lowest, |score| score.min(*lowest))),
[] => min_failed_score,
};
self.completed_cursor =
Self::cursor_for_completed(&completed_scores, unfinished, initial_interaction);
for (position, utxo) in response.utxo_list.iter().enumerate() {
if utxo.score as u64 > self.last_interaction {
self.last_interaction = utxo.score as u64;
}
let outpoint = format!("{}.{}", utxo.txid, utxo.output_index);
if known_outpoints.contains(&outpoint) {
self.cursor_yielded.set(true);
if self.deferred.borrow_mut().remove(&outpoint) {
self.drop_record(&outpoint, DropReason::Held).await;
}
shared_outpoints.insert(outpoint.clone());
known_outpoints.remove(&outpoint);
} else if !shared_outpoints.contains(&outpoint)
&& !failed_outpoints.contains(&outpoint)
{
let lowest_unfinished = min_failed_score
.map_or(lowest_ahead[position], |f| f.min(lowest_ahead[position]));
self.completed_cursor = Self::cursor_for_completed(
&completed_scores,
Some(lowest_unfinished),
initial_interaction,
);
let mut with_current = completed_scores.clone();
with_current.insert(utxo.score as u64);
let remaining = lowest_ahead
.get(position + 1)
.copied()
.into_iter()
.chain(min_failed_score)
.min();
self.cursor_after_in_flight =
Self::cursor_for_completed(&with_current, remaining, initial_interaction);
self.hold_cursor(utxo.score as u64, &outpoint);
self.graph_in_flight = true;
self.in_flight = Some((utxo.score as u64, outpoint.clone()));
let ingested = self.ingest_utxo(utxo, &outpoint).await;
self.graph_in_flight = false;
self.in_flight = None;
let ingested = match ingested {
Ok(Ingested::Expired) => {
self.cursor_yielded.set(true);
Ok(true)
}
Ok(Ingested::Completed) => {
let landed = match &self.finalized_hook {
Some(hook) => hook.graph_completed().await,
None => Ok(()),
};
self.settle_completed(landed.is_ok()).await;
if landed.is_ok() {
self.cursor_yielded.set(true);
}
landed.map(|()| true)
}
Ok(
Ingested::Deferred
| Ingested::HeldBack
| Ingested::Unanchorable
| Ingested::Waiting,
) => Ok(true),
Ok(Ingested::Retry) => Ok(false),
Err(e) => {
self.walk.borrow_mut().take();
Err(e)
}
};
match ingested {
Ok(true) => {
if self
.cursor_held_by
.as_ref()
.is_some_and(|(_, held)| held == &outpoint)
{
self.cursor_held_by = None;
}
shared_outpoints.insert(outpoint.clone());
}
Ok(false) => {
let s = utxo.score as u64;
min_failed_score = Some(min_failed_score.map_or(s, |cur| cur.min(s)));
failed_outpoints.insert(outpoint.clone());
}
Err(e) => {
warn!(
"{} Error ingesting UTXO {}: {}",
self.log_prefix, outpoint, e
);
let s = utxo.score as u64;
let lowest = min_failed_score.is_none_or(|m| s < m);
if self.strike_fresh(s, &outpoint, &e, lowest).await {
self.release_hold(&outpoint);
shared_outpoints.insert(outpoint.clone());
} else {
min_failed_score =
Some(min_failed_score.map_or(s, |cur| cur.min(s)));
failed_outpoints.insert(outpoint.clone());
}
}
}
}
if shared_outpoints.contains(&outpoint) {
completed_scores.insert(utxo.score as u64);
}
let unfinished = lowest_ahead
.get(position + 1)
.copied()
.into_iter()
.chain(min_failed_score)
.min();
self.completed_cursor =
Self::cursor_for_completed(&completed_scores, unfinished, initial_interaction);
}
completed_scores.retain(|score| *score >= self.completed_cursor);
if self.last_interaction == cursor_before_page {
break;
}
}
self.completed_cursor =
Self::cursor_for_completed(&completed_scores, min_failed_score, initial_interaction);
if let Some(failed) = min_failed_score {
if self.last_interaction > self.completed_cursor {
warn!(
"{} Capping cursor {} -> {} (transient ingest failure at score {}); next sync will re-pull",
self.log_prefix, self.last_interaction, self.completed_cursor, failed
);
}
}
self.last_interaction = self.completed_cursor;
if !self.unidirectional {
for utxo in &local_utxos {
let outpoint = format!("{}.{}", utxo.txid, utxo.output_index);
if !shared_outpoints.contains(&outpoint) {
match self.push_utxo(utxo).await {
Ok(()) => {}
Err(e) => {
warn!("{} Error pushing UTXO {}: {}", self.log_prefix, outpoint, e);
}
}
}
}
}
info!("{} Sync completed!", self.log_prefix);
Ok(())
}
async fn ingest_utxo(&self, utxo: &GASPOutput, outpoint: &str) -> Result<Ingested, GASPError> {
self.last_fault.set(None);
self.resume_idle.set(None);
self.pass_saved.set(false);
if self.expired.borrow().contains(outpoint) {
return self.refuse_expired(outpoint).await;
}
debug!("{} Requesting node for {}", self.log_prefix, outpoint);
self.graphs_attempted.set(self.graphs_attempted.get() + 1);
let Some(budget) = &self.graph_budget else {
*self.walk.borrow_mut() =
Some(Self::fresh_walk(utxo.score as u64, outpoint, PassCap::NONE));
let walked = self.walk_graph(None).await;
if matches!(walked, Ok(WalkEnd::Expired)) {
return self.refuse_expired(outpoint).await;
}
let walk = self.walk.borrow_mut().take();
walked?;
self.complete_graph(&Self::graph_id_of(walk.as_ref(), outpoint))
.await?;
return Ok(Ingested::Completed);
};
if !self.deferred.borrow_mut().remove(outpoint) {
let mut deadline = latched((budget.deadline)());
return self
.ingest_budgeted(utxo, outpoint, &mut deadline, budget.pass(), false)
.await;
}
let mut deadline = self
.resume_deadline
.borrow_mut()
.take()
.unwrap_or_else(|| latched((budget.deadline)()));
let cap = self.resume_left.get();
if cap.spent() || Self::due(&mut deadline).await {
*self.resume_deadline.borrow_mut() = Some(deadline);
let record = self.storage.get_deferred_graph(outpoint).await?;
self.read_record(record.as_ref());
if record
.as_ref()
.is_some_and(|r| r.waits_idle(Self::now_secs()))
{
return Ok(self.wait_idle(outpoint));
}
if record
.as_ref()
.is_some_and(|r| r.is_unanchorable() && Self::now_secs() < r.retry_at)
{
self.graphs_attempted
.set(self.graphs_attempted.get().saturating_sub(1));
return Ok(Ingested::Unanchorable);
}
self.stats.borrow_mut().held_back += 1;
info!(
"{} Deferred graph {} held back: the resumes of this pass spent their budget (bsv-low #555)",
self.log_prefix, outpoint
);
let Some(mut record) = record else {
return Ok(Ingested::Retry);
};
if record.reason == "retry:deadline" {
return Ok(Ingested::Retry);
}
if record.is_struck() && !record.cursor_passable {
return Ok(Ingested::Retry);
}
if !record.cursor_passable {
record.cursor_passable = true;
if self.storage.save_deferred_graph(&record).await? != DeferredGraphSave::Saved {
return Ok(Ingested::Retry);
}
}
return Ok(Ingested::HeldBack);
}
let before = (
self.calls_made.get(),
self.bytes_fetched.get(),
self.nodes_appended.get(),
);
let ingested = self
.ingest_budgeted(utxo, outpoint, &mut deadline, cap, true)
.await;
if let Some((prior, held)) = self.resume_idle.take() {
if self.calls_made.get() > before.0
&& !self.pass_saved.get()
&& !matches!(ingested, Ok(Ingested::Completed | Ingested::Expired))
{
self.idle_resume(outpoint, prior, held).await;
}
}
let narrowed = |spent: u64| u32::try_from(spent).unwrap_or(u32::MAX);
self.resume_left.set(PassCap {
calls: cap
.calls
.saturating_sub(narrowed(self.calls_made.get() - before.0)),
bytes: cap
.bytes
.saturating_sub(self.bytes_fetched.get() - before.1),
nodes: cap
.nodes
.saturating_sub(narrowed(self.nodes_appended.get() - before.2)),
});
*self.resume_deadline.borrow_mut() = Some(deadline);
ingested
}
fn wait_idle(&self, outpoint: &str) -> Ingested {
self.graphs_attempted
.set(self.graphs_attempted.get().saturating_sub(1));
self.stats.borrow_mut().waiting += 1;
debug!(
"{} Deferred graph {} waits out its idle backoff (engine 0.1.10)",
self.log_prefix, outpoint
);
Ingested::Waiting
}
async fn idle_resume(&self, outpoint: &str, prior: u32, held: usize) {
let Ok(Some(mut record)) = self.storage.get_deferred_graph(outpoint).await else {
return;
};
if record.is_struck() || !record.cursor_passable || record.nodes.len() > held {
return;
}
record.idle_resumes = prior.saturating_add(1);
let backoff = record.idle_resumes >= IDLE_RESUMES_BEFORE_BACKOFF;
if backoff {
record.retry_at = Self::now_secs() + IDLE_RESUME_RETRY_SECS;
}
let saved = self.storage.save_deferred_graph(&record).await;
if let Ok(DeferredGraphSave::Saved) = saved {
self.stats.borrow_mut().idle_counted += 1;
}
match saved {
Ok(DeferredGraphSave::Saved) if backoff => {
self.stats.borrow_mut().backed_off += 1;
info!(
"{} Deferred graph {} resumed {} passes in a row with no node appended: not resumed for {} s (engine 0.1.10)",
self.log_prefix, outpoint, record.idle_resumes, IDLE_RESUME_RETRY_SECS
);
}
Ok(_) => {}
Err(e) => warn!(
"{} Could not count an idle resume of deferred graph {}: {}",
self.log_prefix, outpoint, e
),
}
}
async fn due(deadline: &mut crate::engine::SleepFuture) -> bool {
std::future::poll_fn(|cx| std::task::Poll::Ready(deadline.as_mut().poll(cx).is_ready()))
.await
}
fn fresh_walk(score: u64, outpoint: &str, cap: PassCap) -> Walk {
Walk {
record: DeferredGraph {
peer: String::new(),
topic: String::new(),
outpoint: outpoint.to_string(),
score,
nodes: Vec::new(),
pending: vec![PendingInput {
outpoint: outpoint.to_string(),
graph_id: outpoint.to_string(),
metadata: true,
spent_by: None,
parent_proven: false,
}],
calls: 0,
passes: 0,
reason: String::new(),
configured: false,
idle_faults: 0,
cursor_passable: false,
faults: 0,
retry_at: 0,
idle_resumes: 0,
},
seen: std::collections::HashSet::new(),
calls_this_pass: 0,
bytes_this_pass: 0,
nodes_this_pass: 0,
resumed: false,
cap,
nodes_at_start: 0,
counted: false,
gone_on: false,
half: None,
struck: None,
}
}
async fn ingest_budgeted(
&self,
utxo: &GASPOutput,
outpoint: &str,
deadline: &mut crate::engine::SleepFuture,
cap: PassCap,
from_record: bool,
) -> Result<Ingested, GASPError> {
let score = utxo.score as u64;
let record = if from_record {
let record = self.storage.get_deferred_graph(outpoint).await?;
self.read_record(record.as_ref());
if record.is_none() {
self.held_records
.set(self.held_records.get().saturating_sub(1));
}
record
} else {
None
};
if let Some(record) = record.as_ref().filter(|r| r.is_struck()) {
return self
.rewalk_struck(utxo, outpoint, record.clone(), deadline, cap)
.await;
}
if let Some(record) = &record {
if let [root, ..] = record.nodes.as_slice() {
if self.root_expired(&root.node, outpoint)? {
self.deferred.borrow_mut().insert(outpoint.to_string());
return self.refuse_expired(outpoint).await;
}
}
if record.cursor_passable {
if record.waits_idle(Self::now_secs()) {
return Ok(self.wait_idle(outpoint));
}
self.resume_idle
.set(Some((record.idle_resumes, record.nodes.len())));
}
}
let walk = match record {
None => {
let mut walk = Self::fresh_walk(score, outpoint, cap);
if let Some(half) = self
.graph_budget
.as_ref()
.and_then(|b| b.half_deadline.as_ref())
{
let mut half = latched(half());
let _ = Self::due(&mut half).await;
walk.half = Some(half);
}
walk
}
Some(record) if record.passes >= DEFERRED_GRAPH_MAX_PASSES => {
let passes = record.passes;
self.drop_record(outpoint, DropReason::MaxPasses).await;
return Err(GASPError::Other(format!(
"deferred graph {outpoint} dropped after {passes} passes (bsv-low #555)"
)));
}
Some(record) => {
self.stats.borrow_mut().resumed += 1;
info!(
"{} Resuming deferred graph {} (bsv-low #555): {} nodes, {} pending, {} calls over {} passes",
self.log_prefix,
outpoint,
record.nodes.len(),
record.pending.len(),
record.calls,
record.passes
);
Walk {
seen: record
.nodes
.iter()
.filter_map(|w| Self::node_id(&w.node))
.collect(),
nodes_at_start: record.nodes.len(),
record,
calls_this_pass: 0,
bytes_this_pass: 0,
nodes_this_pass: 0,
resumed: true,
cap,
counted: false,
gone_on: false,
half: None,
struck: None,
}
}
};
let resumed = walk.resumed;
let root_unproven = walk
.record
.nodes
.first()
.is_some_and(|w| w.node.proof.is_none());
*self.walk.borrow_mut() = Some(walk);
let ended = async {
if resumed && root_unproven {
let root = PendingInput {
outpoint: outpoint.to_string(),
graph_id: outpoint.to_string(),
metadata: true,
spent_by: None,
parent_proven: false,
};
match race_or_deadline(self.fetch_root(&root), deadline.as_mut()).await {
None => return Ok(WalkEnd::Deferred("time")),
Some(Ok(None)) => return Ok(WalkEnd::Expired),
Some(Ok(Some(node))) if node.proof.is_some() => {
let (carried, carried_bytes, carried_nodes) =
self.walk.borrow().as_ref().map_or((0, 0, 0), |w| {
(w.calls_this_pass, w.bytes_this_pass, w.nodes_this_pass)
});
let reserved = self
.walk
.borrow()
.as_ref()
.is_some_and(|w| w.record.cursor_passable);
self.drop_record(outpoint, DropReason::RootProven).await;
let mut restarted = Self::fresh_walk(score, outpoint, cap);
restarted.resumed = reserved;
restarted.calls_this_pass = carried;
restarted.bytes_this_pass = carried_bytes;
restarted.nodes_this_pass = carried_nodes;
restarted.record.calls = u64::from(carried);
*self.walk.borrow_mut() = Some(restarted);
}
Some(_) => {}
}
}
self.append_walked().await?;
self.walk_graph(Some(&mut *deadline)).await
}
.await;
self.finish_pass(outpoint, ended).await
}
async fn append_walked(&self) -> Result<(), GASPError> {
let nodes: Vec<WalkedNode> = self
.walk
.borrow()
.as_ref()
.map(|w| w.record.nodes.clone())
.unwrap_or_default();
for walked in &nodes {
self.storage
.append_to_graph(&walked.node, walked.spent_by.as_deref())
.await?;
}
Ok(())
}
async fn finish_pass(
&self,
outpoint: &str,
mut ended: Result<WalkEnd, GASPError>,
) -> Result<Ingested, GASPError> {
loop {
let graph_id = Self::graph_id_of(self.walk.borrow().as_ref(), outpoint);
let resumed = self.walk.borrow().as_ref().is_some_and(|w| w.resumed);
let (reason, completed) = match ended {
Ok(WalkEnd::Expired) => return self.refuse_expired(outpoint).await,
Ok(WalkEnd::Done) => match self.complete_graph(&graph_id).await {
Ok(true) => return Ok(Ingested::Completed),
Ok(false) => {
let struck = self
.walk
.borrow_mut()
.take()
.is_some_and(|w| w.struck.is_some());
if resumed || struck {
if struck {
self.unanchorable_cleared
.set(self.unanchorable_cleared.get() + 1);
}
self.drop_record(outpoint, DropReason::Refused).await;
}
return Ok(Ingested::Completed);
}
Err(GASPError::AnchorUnavailable(_)) => ("anchor_unavailable", true),
Err(GASPError::AnchorWalkHeld(_)) => ("anchor_walk_held", true),
Err(_) => {
let _ = self.storage.discard_graph(&graph_id).await;
("fault", true)
}
},
Ok(WalkEnd::Deferred(reason)) => (reason, false),
Err(e @ GASPError::NodeNotFound(_)) => {
let _ = self.storage.discard_graph(&graph_id).await;
let walk = self.walk.borrow_mut().take();
if resumed {
if let Some(struck) = self.strike_resumed(outpoint, walk.as_ref(), &e).await
{
return if struck.passable() {
Ok(Ingested::Unanchorable)
} else {
Err(e)
};
}
self.drop_record(outpoint, DropReason::NotHeld).await;
}
return Err(e);
}
Err(e) if !resumed && !self.walk_paid().await => {
warn!(
"{} Walk of {} faulted: no record, the UTXO fails as before #555: {}",
self.log_prefix, outpoint, e
);
let _ = self.storage.discard_graph(&graph_id).await;
self.walk.borrow_mut().take();
return Err(e);
}
Err(e) => {
let idle = {
let mut guard = self.walk.borrow_mut();
guard.as_mut().map_or(0, |w| {
if w.resumed && w.record.nodes.len() <= w.nodes_at_start {
w.record.idle_faults += 1;
}
w.record.idle_faults
})
};
if idle >= DEFERRED_GRAPH_MAX_IDLE_FAULTS {
warn!(
"{} Walk of {} faulted with nothing appended {} passes in a row: dropped: {}",
self.log_prefix, outpoint, idle, e
);
let _ = self.storage.discard_graph(&graph_id).await;
let walk = self.walk.borrow_mut().take();
if let Some(struck) = self.strike_resumed(outpoint, walk.as_ref(), &e).await
{
return if struck.passable() {
Ok(Ingested::Unanchorable)
} else {
Err(e)
};
}
self.drop_record(outpoint, DropReason::IdleFaults).await;
return Err(e);
}
warn!(
"{} Walk of {} faulted, deferred with its progress: {}",
self.log_prefix, outpoint, e
);
let _ = self.storage.discard_graph(&graph_id).await;
("fault", false)
}
};
let budget_cut = matches!(reason, "calls" | "time" | "bytes" | "nodes");
let (why, mut walk) = match self.save_walk(reason, completed).await {
Saved::Retry => {
let _ = self.storage.discard_graph(&graph_id).await;
return Ok(Ingested::Retry);
}
Saved::Yes => {
let _ = self.storage.discard_graph(&graph_id).await;
return Ok(Ingested::Deferred);
}
Saved::WentOn => {
let _ = self.storage.discard_graph(&graph_id).await;
return Err(GASPError::Other(format!(
"graph {outpoint} went on and did not complete (bsv-low #555)"
)));
}
Saved::Fault(e, walk) => {
if !budget_cut {
let _ = self.storage.discard_graph(&graph_id).await;
return Err(e);
}
(DropReason::StoreFault, walk)
}
Saved::Ceiling(walk) => {
if !budget_cut {
let _ = self.storage.discard_graph(&graph_id).await;
return Err(GASPError::Other(format!(
"deferred graph {outpoint} found no room, its record kept (bsv-low #585)"
)));
}
(DropReason::TooMany, walk)
}
Saved::Unkept(why, mut walk) => {
self.unkept(why, &mut walk).await;
if !(budget_cut && why == DropReason::TooMany) {
let _ = self.storage.discard_graph(&graph_id).await;
return Err(GASPError::Other(format!(
"deferred graph {outpoint} not kept: {} (bsv-low #555)",
why.as_str()
)));
}
(why, walk)
}
};
info!(
"{} Graph {} cannot be kept ({}): its walk goes on under the per-peer budget alone (bsv-low #555)",
self.log_prefix,
outpoint,
why.as_str()
);
walk.cap = PassCap::NONE;
walk.gone_on = true;
*self.walk.borrow_mut() = Some(*walk);
ended = self.walk_graph(None).await;
}
}
async fn settle_completed(&self, landed: bool) {
if self.graph_budget.is_none() {
self.walk.borrow_mut().take();
return;
}
if landed {
let Some(walk) = self.walk.borrow_mut().take() else {
return;
};
if walk.struck.is_some() {
if let Err(e) = self
.storage
.delete_deferred_graph(&walk.record.outpoint)
.await
{
warn!(
"{} Could not delete the fault record of landed root {}: {}",
self.log_prefix, walk.record.outpoint, e
);
}
self.forget_record(&walk.record.outpoint);
self.unanchorable_cleared
.set(self.unanchorable_cleared.get() + 1);
info!(
"{} Root {} LANDED after {} faulted runs: its record cleared (engine 0.1.9)",
self.log_prefix,
walk.record.outpoint,
walk.struck.as_ref().map_or(0, |r| r.faults)
);
} else if walk.resumed {
if let Err(e) = self
.storage
.delete_deferred_graph(&walk.record.outpoint)
.await
{
warn!(
"{} Could not delete the record of converged graph {}: {}",
self.log_prefix, walk.record.outpoint, e
);
}
self.forget_record(&walk.record.outpoint);
self.stats.borrow_mut().converged += 1;
self.resumed_with_progress
.set(self.resumed_with_progress.get() + 1);
info!(
"{} Deferred graph {} CONVERGED after {} passes, {} calls, {} nodes (bsv-low #555)",
self.log_prefix,
walk.record.outpoint,
walk.record.passes + 1,
walk.record.calls,
walk.record.nodes.len()
);
}
} else if let Saved::Unkept(why, mut walk) = self.save_walk("not_landed", true).await {
self.unkept(why, &mut walk).await;
}
}
async fn save_walk(&self, reason: &'static str, completed: bool) -> Saved {
let Some(mut walk) = self.walk.borrow_mut().take() else {
return Saved::Yes;
};
if walk.gone_on {
return Saved::WentOn;
}
if !walk.counted {
walk.counted = true;
let mut stats = self.stats.borrow_mut();
if completed || walk.record.nodes.len() > walk.nodes_at_start {
stats.progressed += 1;
} else {
stats.stalled += 1;
}
}
let was_held = walk.record.cursor_passable;
let deadline_cut = matches!(reason, "time" | "peer_deadline");
let deadline_streak = was_held || walk.record.reason == "retry:deadline";
if walk.record.nodes.is_empty() && !deadline_streak {
walk.record.passes = 0;
}
walk.record.passes += 1;
let retry = walk.record.nodes.is_empty()
&& reason == "time"
&& !was_held
&& walk.record.passes < DEADLINE_STALL_DEFERRAL_RUNS;
walk.record.cursor_passable = !retry;
walk.record.reason = if retry {
"retry:deadline".into()
} else {
reason.into()
};
if walk.record.nodes.len() > walk.nodes_at_start {
walk.record.idle_faults = 0;
walk.record.idle_resumes = 0;
walk.record.retry_at = 0;
}
let idle_now = walk.resumed
&& was_held
&& walk.struck.is_none()
&& !completed
&& walk.calls_this_pass > 0
&& walk.record.nodes.len() <= walk.nodes_at_start;
let backing_off = if idle_now {
walk.record.idle_resumes = walk.record.idle_resumes.saturating_add(1);
let due = walk.record.idle_resumes >= IDLE_RESUMES_BEFORE_BACKOFF;
if due {
walk.record.retry_at = Self::now_secs() + IDLE_RESUME_RETRY_SECS;
}
due
} else {
false
};
if walk.record.nodes.is_empty() && !deadline_cut && !was_held {
return Saved::Unkept(DropReason::NoProgress, Box::new(walk));
}
if !walk.resumed && self.held_records.get() >= DEFERRED_GRAPHS_PER_PEER_TOPIC {
return Saved::Unkept(DropReason::TooMany, Box::new(walk));
}
let bytes = walk.record.byte_size();
let outpoint = walk.record.outpoint.clone();
match self.storage.save_deferred_graph(&walk.record).await {
Ok(DeferredGraphSave::Saved) => {
if walk.resumed {
self.pass_saved.set(true);
}
if idle_now {
self.stats.borrow_mut().idle_counted += 1;
}
if !walk.resumed {
self.fault_records.borrow_mut().remove(&outpoint);
self.held_records.set(self.held_records.get() + 1);
if !retry && !walk.record.nodes.is_empty() {
self.retained.set(self.retained.get() + 1);
}
} else if walk.record.nodes.len() > walk.nodes_at_start {
self.resumed_with_progress
.set(self.resumed_with_progress.get() + 1);
}
if !retry {
self.stats.borrow_mut().deferred += 1;
}
if backing_off {
self.stats.borrow_mut().backed_off += 1;
info!(
"{} Deferred graph {} resumed {} passes in a row with no node appended: not resumed for {} s (engine 0.1.10)",
self.log_prefix, outpoint, walk.record.idle_resumes, IDLE_RESUME_RETRY_SECS
);
}
info!(
"{} DEFERRED graph {} ({}): {} nodes, {} pending, {} calls ({} this pass), pass {}, {} bytes (bsv-low #555)",
self.log_prefix,
outpoint,
reason,
walk.record.nodes.len(),
walk.record.pending.len(),
walk.record.calls,
walk.calls_this_pass,
walk.record.passes,
bytes
);
if retry {
Saved::Retry
} else {
Saved::Yes
}
}
Ok(DeferredGraphSave::AtCeiling) if walk.resumed => {
self.note_dropped(&outpoint, DropReason::TooMany);
Saved::Ceiling(Box::new(walk))
}
Ok(DeferredGraphSave::AtCeiling) => Saved::Unkept(DropReason::TooMany, Box::new(walk)),
Err(e) => {
self.note_dropped(&outpoint, DropReason::StoreFault);
Saved::Fault(e, Box::new(walk))
}
}
}
async fn unkept(&self, why: DropReason, walk: &mut Walk) {
let outpoint = walk.record.outpoint.clone();
if walk.resumed {
self.drop_record(&outpoint, why).await;
walk.resumed = false;
} else if why == DropReason::NoProgress {
warn!(
"{} Walk of {} fetched nothing: not kept, the UTXO fails (bsv-low #555)",
self.log_prefix, outpoint
);
} else {
self.note_dropped(&outpoint, why);
}
}
async fn walk_paid(&self) -> bool {
let Some(budget) = &self.graph_budget else {
return false;
};
let mut half = {
let mut guard = self.walk.borrow_mut();
let Some(walk) = guard.as_mut() else {
return false;
};
if walk.record.nodes.is_empty() {
return false;
}
if u64::from(walk.calls_this_pass) * 2 >= u64::from(budget.max_calls) {
return true;
}
match walk.half.take() {
Some(half) => half,
None => return false,
}
};
let paid = Self::due(&mut half).await;
if let Some(walk) = self.walk.borrow_mut().as_mut() {
walk.half = Some(half);
}
paid
}
async fn drop_record(&self, outpoint: &str, reason: DropReason) {
if !matches!(reason, DropReason::Held | DropReason::Refused) {
let Ok(record) = self.storage.get_deferred_graph(outpoint).await else {
return;
};
if let Some(record) = record {
if record.cursor_passable {
let mut retry = Self::fresh_walk(record.score, outpoint, PassCap::NONE).record;
retry.reason = "retry".into();
retry.cursor_passable = true;
let _ = self.storage.save_deferred_graph(&retry).await;
self.note_dropped(outpoint, reason);
return;
}
}
}
if let Err(e) = self.storage.delete_deferred_graph(outpoint).await {
warn!(
"{} Could not delete the record of deferred graph {}: {}",
self.log_prefix, outpoint, e
);
}
self.forget_record(outpoint);
self.note_dropped(outpoint, reason);
}
fn note_dropped(&self, outpoint: &str, reason: DropReason) {
warn!(
"{} Deferred graph {} DROPPED ({}) (bsv-low #555)",
self.log_prefix,
outpoint,
reason.as_str()
);
self.stats.borrow_mut().dropped.push(DroppedGraph {
outpoint: outpoint.to_string(),
reason,
});
}
pub async fn defer_in_flight(&mut self) {
let in_flight = self.in_flight.take();
if self.graph_budget.is_none() {
return;
}
if let Some((_, outpoint)) = &in_flight {
if self.expired.borrow().contains(outpoint) {
let outpoint = outpoint.clone();
self.walk.borrow_mut().take();
self.completed_cursor = self.cursor_after_in_flight;
self.release_hold(&outpoint);
return;
}
}
let unanchorable = self
.walk
.borrow()
.as_ref()
.and_then(|w| w.struck.as_deref().filter(|r| r.is_unanchorable()).cloned());
if let Some(mut record) = unanchorable {
self.walk.borrow_mut().take();
record.retry_at = Self::now_secs() + UNANCHORABLE_RETRY_SECS;
let outpoint = record.outpoint.clone();
if let Err(e) = self.storage.save_deferred_graph(&record).await {
warn!(
"{} Could not keep unanchorable root {} at the deadline: {}",
self.log_prefix, outpoint, e
);
}
self.completed_cursor = self.cursor_after_in_flight;
self.release_hold(&outpoint);
return;
}
if self.walk.borrow().is_none() {
let Some((score, outpoint)) = in_flight else {
return;
};
*self.walk.borrow_mut() = Some(Self::fresh_walk(score, &outpoint, PassCap::NONE));
}
let outpoint = self
.walk
.borrow()
.as_ref()
.map(|w| w.record.outpoint.clone())
.unwrap_or_default();
match self.save_walk("peer_deadline", false).await {
Saved::Yes => {
self.completed_cursor = self.cursor_after_in_flight;
self.release_hold(&outpoint);
}
Saved::Unkept(why, mut walk) => self.unkept(why, &mut walk).await,
_ => {}
}
}
fn graph_id_of(walk: Option<&Walk>, outpoint: &str) -> String {
walk.and_then(|w| w.record.nodes.first())
.map_or_else(|| outpoint.to_string(), |w| w.node.graph_id.clone())
}
fn node_id(node: &GASPNode) -> Option<String> {
bsv_rs::transaction::Transaction::from_hex(&node.raw_tx)
.ok()
.map(|tx| format!("{}.{}", tx.id(), node.output_index))
}
fn count_fetched(&self, node: &GASPNode) {
let bytes = (node.raw_tx.len() + node.proof.as_ref().map_or(0, String::len)) as u64;
self.bytes_fetched.set(self.bytes_fetched.get() + bytes);
if let Some(w) = self.walk.borrow_mut().as_mut() {
w.bytes_this_pass += bytes;
}
}
fn count_call(&self) {
self.calls_made.set(self.calls_made.get() + 1);
if let Some(w) = self.walk.borrow_mut().as_mut() {
w.calls_this_pass += 1;
w.record.calls += 1;
}
}
async fn push_utxo(&self, utxo: &GASPOutput) -> Result<(), GASPError> {
let outpoint = format!("{}.{}", utxo.txid, utxo.output_index);
debug!("{} Hydrating node for {}", self.log_prefix, outpoint);
let node = self
.storage
.hydrate_gasp_node(&outpoint, &utxo.txid, utxo.output_index, true)
.await?;
self.process_outgoing_node(&node, &mut std::collections::HashSet::new())
.await?;
Ok(())
}
async fn walk_graph(
&self,
mut deadline: Option<&mut crate::engine::SleepFuture>,
) -> Result<WalkEnd, GASPError> {
loop {
let (item, over) = {
let walk = self.walk.borrow();
let Some(walk) = walk.as_ref() else {
return Ok(WalkEnd::Done);
};
(
walk.record.pending.last().cloned(),
self.graph_budget.as_ref().and_then(|_| walk.over()),
)
};
let Some(item) = item else {
return Ok(WalkEnd::Done);
};
if item.parent_proven && self.pruned.borrow().contains(&item.outpoint) {
debug!(
"{} Input {} of {:?} was already pruned in this sync; not re-requested",
self.log_prefix, item.outpoint, item.spent_by
);
if let Some(w) = self.walk.borrow_mut().as_mut() {
w.record.pending.pop();
}
continue;
}
if let Some(limb) = over {
return Ok(WalkEnd::Deferred(limb));
}
let step = self.step(&item);
let out = match deadline.as_mut() {
Some(deadline) => match race_or_deadline(step, deadline.as_mut()).await {
Some(out) => out,
None => return Ok(WalkEnd::Deferred("time")),
},
None => step.await,
}?;
if matches!(out, StepOut::Expired) {
return Ok(WalkEnd::Expired);
}
self.commit(out);
}
}
fn commit(&self, out: StepOut) {
let mut walk = self.walk.borrow_mut();
let Some(walk) = walk.as_mut() else {
return;
};
walk.record.pending.pop();
if let StepOut::Appended {
node_id,
walked,
children,
} = out
{
walk.seen.insert(node_id);
walk.nodes_this_pass += 1;
self.nodes_appended.set(self.nodes_appended.get() + 1);
walk.record.nodes.push(*walked);
walk.record.pending.extend(children.into_iter().rev());
}
}
#[cfg(test)]
async fn process_incoming_node(
&self,
node: &GASPNode,
_spent_by: Option<&str>,
_seen: &mut std::collections::HashSet<String>,
) -> Result<(), GASPError> {
let root = PendingInput {
outpoint: node.graph_id.clone(),
graph_id: node.graph_id.clone(),
metadata: true,
spent_by: None,
parent_proven: false,
};
*self.walk.borrow_mut() = Some(Walk {
record: DeferredGraph {
peer: String::new(),
topic: String::new(),
outpoint: node.graph_id.clone(),
score: 0,
nodes: Vec::new(),
pending: vec![root.clone()],
calls: 0,
passes: 0,
reason: String::new(),
configured: false,
idle_faults: 0,
cursor_passable: false,
faults: 0,
retry_at: 0,
idle_resumes: 0,
},
seen: std::collections::HashSet::new(),
calls_this_pass: 0,
bytes_this_pass: 0,
nodes_this_pass: 0,
resumed: false,
cap: PassCap::NONE,
nodes_at_start: 0,
counted: false,
gone_on: false,
half: None,
struck: None,
});
let node = self.hydrate_root(node.clone()).await;
let out = self.absorb(&root, node).await?;
self.commit(out);
self.walk_graph(None).await.map(|_| ())
}
async fn fetch_root(&self, item: &PendingInput) -> Result<Option<GASPNode>, GASPError> {
let (txid, oi) = parse_outpoint(&item.outpoint)
.ok_or_else(|| GASPError::Other(format!("bad outpoint {}", item.outpoint)))?;
self.count_call();
let node = self
.remote
.request_node(&item.graph_id, &txid, oi, item.metadata)
.await?;
self.count_fetched(&node);
if self.root_expired(&node, &item.outpoint)? {
return Ok(None);
}
Ok(Some(self.hydrate_root(node).await))
}
async fn hydrate_root(&self, mut node: GASPNode) -> GASPNode {
if node.proof.is_none() {
if let Some(fetcher) = &self.ancestor_fetcher {
if let Some(node_txid) =
Self::node_id(&node).and_then(|id| parse_outpoint(&id).map(|(txid, _)| txid))
{
self.count_call();
node.proof = fetcher.verified_proof_for(&node_txid).await;
}
}
}
self.root_proofs
.borrow_mut()
.insert(node.graph_id.clone(), node.proof.is_some());
node
}
async fn step(&self, item: &PendingInput) -> Result<StepOut, GASPError> {
let node = if item.spent_by.is_none() {
let Some(node) = self
.fetch_root(item)
.await
.map_err(|e| self.faulted(Self::peer_fault_class(&e), e))?
else {
return Ok(StepOut::Expired);
};
node
} else {
let Some((txid, oi)) = parse_outpoint(&item.outpoint) else {
return Ok(StepOut::Pruned);
};
self.count_call();
match &self.ancestor_fetcher {
Some(fetcher) => {
let ancestor = fetcher
.fetch_ancestor(&txid)
.await
.map_err(|e| self.faulted(fault_class::COURIER, e))?;
let node = GASPNode {
graph_id: item.graph_id.clone(),
raw_tx: ancestor.raw_tx,
output_index: oi,
proof: ancestor.proof,
tx_metadata: None,
output_metadata: None,
inputs: None,
};
self.count_fetched(&node);
node
}
None => match self
.remote
.request_node(&item.graph_id, &txid, oi, item.metadata)
.await
{
Ok(child_node) => {
self.count_fetched(&child_node);
child_node
}
Err(e @ GASPError::NodeNotFound(_)) if item.parent_proven => {
warn!(
"{} Pruned input {} named by proven node {}: the peer does not hold it: {}",
self.log_prefix,
item.outpoint,
item.spent_by.as_deref().unwrap_or_default(),
e
);
if self.pruned.borrow_mut().insert(item.outpoint.clone()) {
self.pruned_inputs.set(self.pruned_inputs.get() + 1);
}
return Ok(StepOut::Pruned);
}
Err(e) => return Err(self.faulted(Self::peer_fault_class(&e), e)),
},
}
};
self.absorb(item, node)
.await
.map_err(|e| self.faulted(fault_class::VERIFICATION, e))
}
async fn absorb(&self, item: &PendingInput, node: GASPNode) -> Result<StepOut, GASPError> {
let node_txid = match bsv_rs::transaction::Transaction::from_hex(&node.raw_tx) {
Ok(tx) => tx.id(),
Err(_) => node.raw_tx[..node.raw_tx.len().min(64)].to_string(),
};
let node_id = format!("{}.{}", node_txid, node.output_index);
if self
.walk
.borrow()
.as_ref()
.is_some_and(|w| w.seen.contains(&node_id))
{
return Ok(StepOut::Seen);
}
self.storage
.append_to_graph(&node, item.spent_by.as_deref())
.await?;
let mut children = Vec::new();
if let Some(needed) = self.storage.find_needed_inputs(&node).await? {
let parent_proven = node.proof.is_some();
for (outpoint, input_req) in &needed.requested_inputs {
if parse_outpoint(outpoint).is_some() {
children.push(PendingInput {
outpoint: outpoint.clone(),
graph_id: node.graph_id.clone(),
metadata: input_req.metadata,
spent_by: Some(node_id.clone()),
parent_proven,
});
}
}
}
Ok(StepOut::Appended {
node_id,
walked: Box::new(WalkedNode {
node,
spent_by: item.spent_by.clone(),
}),
children,
})
}
fn process_outgoing_node<'b>(
&'b self,
node: &'b GASPNode,
seen: &'b mut std::collections::HashSet<String>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), GASPError>> + 'b>> {
Box::pin(async move {
if self.unidirectional {
return Ok(());
}
let node_id = format!("{}.{}", node.graph_id, node.output_index);
if seen.contains(&node_id) {
return Ok(());
}
seen.insert(node_id);
if let Some(response) = self.remote.submit_node(node).await? {
for (outpoint, input_req) in &response.requested_inputs {
if let Some((txid, oi)) = parse_outpoint(outpoint) {
match self
.storage
.hydrate_gasp_node(&node.graph_id, &txid, oi, input_req.metadata)
.await
{
Ok(hydrated) => {
self.process_outgoing_node(&hydrated, seen).await?;
}
Err(e) => {
error!("{} Error hydrating outgoing node: {}", self.log_prefix, e);
return Ok(()); }
}
}
}
}
Ok(())
})
}
async fn complete_graph(&self, graph_id: &str) -> Result<bool, GASPError> {
info!("{} Completing graph: {}", self.log_prefix, graph_id);
match self.storage.validate_graph_anchor(graph_id).await {
Ok(()) => {
self.storage.finalize_graph(graph_id).await?;
let proven =
self.root_proofs
.borrow_mut()
.remove(graph_id)
.or_else(|| {
self.walk.borrow().as_ref().and_then(|w| {
match w.record.nodes.as_slice() {
[root, ..] => Some(root.node.proof.is_some()),
[] => None,
}
})
})
.unwrap_or(false);
let count = if proven {
&self.roots_proven
} else {
&self.roots_unproven
};
count.set(count.get() + 1);
info!("{} Graph finalized: {}", self.log_prefix, graph_id);
Ok(true)
}
Err(e) => {
warn!(
"{} Graph validation failed for {}: {}. Discarding.",
self.log_prefix, graph_id, e
);
self.storage.discard_graph(graph_id).await?;
if matches!(e, GASPError::AnchorUnavailable(_)) {
return Err(e);
}
if matches!(e, GASPError::AnchorWalkHeld(_)) {
self.anchor_walk_held.set(self.anchor_walk_held.get() + 1);
return Err(e);
}
self.discarded_graphs.set(self.discarded_graphs.get() + 1);
Ok(false)
}
}
}
}
pub fn parse_outpoint(s: &str) -> Option<(String, u32)> {
let parts: Vec<&str> = s.splitn(2, '.').collect();
if parts.len() != 2 {
return None;
}
let oi = parts[1].parse::<u32>().ok()?;
Some((parts[0].to_string(), oi))
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum GASPError {
#[error("version mismatch: local={local}, remote={remote}")]
VersionMismatch { local: u32, remote: u32 },
#[error("invalid timestamp: {0}")]
InvalidTimestamp(String),
#[error("node not found: {0}")]
NodeNotFound(String),
#[error("graph validation failed: {0}")]
ValidationFailed(String),
#[error("remote error: {0}")]
RemoteError(String),
#[error("storage error: {0}")]
StorageError(String),
#[error("anchor unavailable: {0}")]
AnchorUnavailable(String),
#[error("anchor walk held: {0}")]
AnchorWalkHeld(String),
#[error("{0}")]
Other(String),
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
#[tokio::test]
async fn race_or_deadline_future_wins() {
let out = race_or_deadline(async { 42u32 }, std::future::pending::<()>()).await;
assert_eq!(out, Some(42));
}
#[tokio::test]
async fn race_or_deadline_deadline_wins() {
let out = race_or_deadline(std::future::pending::<u32>(), async {}).await;
assert_eq!(out, None);
}
#[tokio::test]
async fn guarded_race_never_drops_inside_a_write_section() {
let gate = SubmitGate::default();
let written = std::cell::Cell::new(0u32);
let out = race_or_deadline_guarded(
async {
for _ in 0..3 {
gate.stop_if_due().await;
let _section = gate.write_section();
tokio::task::yield_now().await;
written.set(written.get() + 1);
tokio::task::yield_now().await;
written.set(written.get() + 1);
}
},
async {},
&gate,
)
.await;
assert_eq!(out, None, "the deadline still wins");
assert_eq!(written.get(), 2, "one transaction, whole, and no second");
assert!(
!gate.deadline_is_due(),
"the flag does not outlive the race"
);
let out = race_or_deadline_guarded(std::future::pending::<u8>(), async {}, &gate).await;
assert_eq!(out, None);
let out = race_or_deadline_guarded(async { 7u8 }, std::future::pending(), &gate).await;
assert_eq!(out, Some(7));
let out = race_or_deadline_guarded(
async {
let _section = gate.write_section();
tokio::task::yield_now().await;
7u8
},
async {},
&gate,
)
.await;
assert_eq!(out, Some(7));
assert!(!gate.deadline_is_due());
}
#[tokio::test]
async fn race_or_deadline_ready_future_beats_ready_deadline() {
let out = race_or_deadline(async { 7u32 }, async {}).await;
assert_eq!(out, Some(7));
}
fn health(fails: u64, age: Option<u64>) -> crate::storage::PeerSyncHealth {
crate::storage::PeerSyncHealth {
consecutive_failures: fails,
secs_since_last_attempt: age,
}
}
#[test]
fn quarantine_needs_the_full_threshold() {
assert!(!peer_sync_quarantined(&health(0, Some(0))));
assert!(!peer_sync_quarantined(&health(
PEER_QUARANTINE_THRESHOLD - 1,
Some(0)
)));
assert!(peer_sync_quarantined(&health(
PEER_QUARANTINE_THRESHOLD,
Some(0)
)));
assert!(peer_sync_quarantined(&health(
PEER_QUARANTINE_THRESHOLD + 100,
Some(0)
)));
}
#[test]
fn quarantine_reopens_one_probe_after_the_reprobe_window() {
let fails = PEER_QUARANTINE_THRESHOLD;
assert!(peer_sync_quarantined(&health(
fails,
Some(PEER_QUARANTINE_REPROBE_SECS - 1)
)));
assert!(!peer_sync_quarantined(&health(
fails,
Some(PEER_QUARANTINE_REPROBE_SECS)
)));
}
#[test]
fn never_attempted_peer_is_never_quarantined() {
assert!(!peer_sync_quarantined(&health(u64::MAX, None)));
}
struct MockGASPStorage {
utxos: Vec<GASPOutput>,
graphs: Mutex<HashMap<String, Vec<GASPNode>>>,
finalized: Mutex<Vec<String>>,
discarded: Mutex<Vec<String>>,
}
impl MockGASPStorage {
fn new(utxos: Vec<GASPOutput>) -> Self {
Self {
utxos,
graphs: Mutex::new(HashMap::new()),
finalized: Mutex::new(Vec::new()),
discarded: Mutex::new(Vec::new()),
}
}
}
#[async_trait(?Send)]
impl GASPStorage for MockGASPStorage {
async fn load_deferred_graphs(
&self,
) -> Result<Vec<crate::gasp::DeferredGraphKey>, GASPError> {
Ok(Vec::new())
}
async fn get_deferred_graph(
&self,
_: &str,
) -> Result<Option<crate::gasp::DeferredGraph>, GASPError> {
Ok(None)
}
async fn save_deferred_graph(
&self,
_: &crate::gasp::DeferredGraph,
) -> Result<crate::gasp::DeferredGraphSave, GASPError> {
Err(GASPError::StorageError("keeps no deferred graphs".into()))
}
async fn delete_deferred_graph(&self, _: &str) -> Result<(), GASPError> {
Ok(())
}
async fn find_known_utxos(
&self,
since: u64,
_limit: Option<u64>,
) -> Result<Vec<GASPOutput>, GASPError> {
Ok(self
.utxos
.iter()
.filter(|u| u.score as u64 >= since)
.cloned()
.collect())
}
async fn hydrate_gasp_node(
&self,
graph_id: &str,
txid: &str,
output_index: u32,
_metadata: bool,
) -> Result<GASPNode, GASPError> {
Ok(GASPNode {
graph_id: graph_id.to_string(),
raw_tx: format!("rawtx_{txid}"),
output_index,
proof: None,
tx_metadata: None,
output_metadata: None,
inputs: None,
})
}
async fn find_needed_inputs(
&self,
_node: &GASPNode,
) -> Result<Option<GASPNodeResponse>, GASPError> {
Ok(None) }
async fn append_to_graph(
&self,
node: &GASPNode,
_spent_by: Option<&str>,
) -> Result<(), GASPError> {
self.graphs
.lock()
.unwrap()
.entry(node.graph_id.clone())
.or_default()
.push(node.clone());
Ok(())
}
async fn validate_graph_anchor(&self, _graph_id: &str) -> Result<(), GASPError> {
Ok(())
}
async fn finalize_graph(&self, graph_id: &str) -> Result<(), GASPError> {
self.finalized.lock().unwrap().push(graph_id.to_string());
Ok(())
}
async fn discard_graph(&self, graph_id: &str) -> Result<(), GASPError> {
self.discarded.lock().unwrap().push(graph_id.to_string());
Ok(())
}
}
struct MockGASPRemote {
remote_utxos: Vec<GASPOutput>,
submitted: Mutex<Vec<GASPNode>>,
}
impl MockGASPRemote {
fn new(utxos: Vec<GASPOutput>) -> Self {
Self {
remote_utxos: utxos,
submitted: Mutex::new(Vec::new()),
}
}
}
#[async_trait(?Send)]
impl GASPRemote for MockGASPRemote {
async fn get_initial_response(
&self,
request: &GASPInitialRequest,
) -> Result<GASPInitialResponse, GASPError> {
if request.version != GASP_VERSION {
return Err(GASPError::VersionMismatch {
local: GASP_VERSION,
remote: request.version,
});
}
let utxos: Vec<GASPOutput> = self
.remote_utxos
.iter()
.filter(|u| u.score as u64 >= request.since)
.cloned()
.collect();
Ok(GASPInitialResponse {
utxo_list: utxos,
since: request.since,
})
}
async fn get_initial_reply(
&self,
_response: &GASPInitialResponse,
) -> Result<GASPInitialReply, GASPError> {
Ok(GASPInitialReply {
utxo_list: Vec::new(),
})
}
async fn request_node(
&self,
graph_id: &str,
txid: &str,
output_index: u32,
_metadata: bool,
) -> Result<GASPNode, GASPError> {
Ok(GASPNode {
graph_id: graph_id.to_string(),
raw_tx: format!("remote_rawtx_{txid}"),
output_index,
proof: Some("proof_hex".to_string()),
tx_metadata: None,
output_metadata: None,
inputs: None,
})
}
async fn submit_node(
&self,
node: &GASPNode,
) -> Result<Option<GASPNodeResponse>, GASPError> {
self.submitted.lock().unwrap().push(node.clone());
Ok(None) }
}
#[tokio::test]
async fn proofs_page_deadline_never_passes_a_failed_unsaved_graph() {
struct PageDeadline(std::rc::Rc<std::cell::Cell<bool>>);
#[async_trait(?Send)]
impl GASPRemote for PageDeadline {
async fn get_initial_response(
&self,
request: &GASPInitialRequest,
) -> Result<GASPInitialResponse, GASPError> {
if request.since > 0 {
self.0.set(true);
std::future::pending::<()>().await;
}
Ok(GASPInitialResponse {
since: request.since,
utxo_list: vec![GASPOutput {
txid: "11".repeat(32),
output_index: 0,
score: 10.0,
}],
})
}
async fn get_initial_reply(
&self,
_: &GASPInitialResponse,
) -> Result<GASPInitialReply, GASPError> {
unreachable!()
}
async fn request_node(
&self,
_: &str,
_: &str,
_: u32,
_: bool,
) -> Result<GASPNode, GASPError> {
Err(GASPError::RemoteError("offline".into()))
}
async fn submit_node(
&self,
_: &GASPNode,
) -> Result<Option<GASPNodeResponse>, GASPError> {
unreachable!()
}
}
let due = std::rc::Rc::new(std::cell::Cell::new(false));
let mut sync = GASPSync::new(
Box::new(MockGASPStorage::new(vec![])),
Box::new(PageDeadline(due.clone())),
0,
"test",
true,
)
.with_graph_budget(GraphBudget {
max_calls: 10,
max_bytes_fetched: u64::MAX,
max_nodes: u32::MAX,
deadline: Box::new(|| Box::pin(std::future::pending())),
half_deadline: None,
});
let deadline = std::future::poll_fn(move |_| {
if due.get() {
std::task::Poll::Ready(())
} else {
std::task::Poll::Pending
}
});
assert!(race_or_deadline(sync.sync(None), deadline).await.is_none());
assert!(!sync.graph_in_flight());
sync.defer_in_flight().await;
assert_eq!(
sync.completed_cursor(),
0,
"a failed graph has no durable hold"
);
assert_eq!(
sync.cursor_held_by(),
Some(format!("{}.0", "11".repeat(32)))
);
}
#[tokio::test]
async fn test_sync_pulls_remote_utxos() {
let local_storage = MockGASPStorage::new(vec![]);
let remote = MockGASPRemote::new(vec![
GASPOutput {
txid: "tx1".to_string(),
output_index: 0,
score: 100.0,
},
GASPOutput {
txid: "tx2".to_string(),
output_index: 0,
score: 200.0,
},
]);
let mut gasp = GASPSync::new(
Box::new(local_storage),
Box::new(remote),
0,
"[TEST]",
false,
);
gasp.sync(None).await.unwrap();
assert_eq!(gasp.last_interaction, 200);
}
#[tokio::test]
async fn test_sync_skips_known_utxos() {
let local_storage = MockGASPStorage::new(vec![GASPOutput {
txid: "tx1".to_string(),
output_index: 0,
score: 100.0,
}]);
let remote = MockGASPRemote::new(vec![
GASPOutput {
txid: "tx1".to_string(),
output_index: 0,
score: 100.0,
},
GASPOutput {
txid: "tx2".to_string(),
output_index: 0,
score: 200.0,
},
]);
let mut gasp = GASPSync::new(
Box::new(local_storage),
Box::new(remote),
0,
"[TEST]",
true, );
gasp.sync(None).await.unwrap();
assert_eq!(gasp.last_interaction, 200);
}
#[tokio::test]
async fn test_sync_unidirectional_skips_push() {
let local_storage = MockGASPStorage::new(vec![GASPOutput {
txid: "local_only".to_string(),
output_index: 0,
score: 50.0,
}]);
let remote = MockGASPRemote::new(vec![]);
let mut gasp = GASPSync::new(Box::new(local_storage), Box::new(remote), 0, "[TEST]", true);
gasp.sync(None).await.unwrap();
}
#[tokio::test]
async fn test_complete_graph_finalizes_on_valid() {
let storage = MockGASPStorage::new(vec![]);
let remote = MockGASPRemote::new(vec![]);
let gasp = GASPSync::new(Box::new(storage), Box::new(remote), 0, "[TEST]", false);
gasp.complete_graph("test_graph.0").await.unwrap();
}
#[tokio::test]
async fn test_complete_graph_discards_on_invalid() {
struct FailValidationStorage;
#[async_trait(?Send)]
impl GASPStorage for FailValidationStorage {
async fn load_deferred_graphs(
&self,
) -> Result<Vec<crate::gasp::DeferredGraphKey>, GASPError> {
Ok(Vec::new())
}
async fn get_deferred_graph(
&self,
_: &str,
) -> Result<Option<crate::gasp::DeferredGraph>, GASPError> {
Ok(None)
}
async fn save_deferred_graph(
&self,
_: &crate::gasp::DeferredGraph,
) -> Result<crate::gasp::DeferredGraphSave, GASPError> {
Err(GASPError::StorageError("keeps no deferred graphs".into()))
}
async fn delete_deferred_graph(&self, _: &str) -> Result<(), GASPError> {
Ok(())
}
async fn find_known_utxos(
&self,
_: u64,
_: Option<u64>,
) -> Result<Vec<GASPOutput>, GASPError> {
Ok(vec![])
}
async fn hydrate_gasp_node(
&self,
_: &str,
_: &str,
_: u32,
_: bool,
) -> Result<GASPNode, GASPError> {
unreachable!()
}
async fn find_needed_inputs(
&self,
_: &GASPNode,
) -> Result<Option<GASPNodeResponse>, GASPError> {
Ok(None)
}
async fn append_to_graph(
&self,
_: &GASPNode,
_: Option<&str>,
) -> Result<(), GASPError> {
Ok(())
}
async fn validate_graph_anchor(&self, _: &str) -> Result<(), GASPError> {
Err(GASPError::ValidationFailed("bad anchor".into()))
}
async fn finalize_graph(&self, _: &str) -> Result<(), GASPError> {
panic!("should not finalize")
}
async fn discard_graph(&self, _: &str) -> Result<(), GASPError> {
Ok(())
}
}
let gasp = GASPSync::new(
Box::new(FailValidationStorage),
Box::new(MockGASPRemote::new(vec![])),
0,
"[TEST]",
false,
);
gasp.complete_graph("bad_graph.0").await.unwrap();
}
fn make_tx_hex(source_txid: &str, source_oi: u32) -> String {
let mut tx = bsv_rs::transaction::Transaction::new();
tx.inputs.push(bsv_rs::transaction::TransactionInput::new(
source_txid.to_string(),
source_oi,
));
tx.outputs.push(bsv_rs::transaction::TransactionOutput::new(
100,
bsv_rs::script::LockingScript::from_hex(
"76a914000000000000000000000000000000000000000088ac",
)
.unwrap(),
));
tx.to_hex()
}
struct RecordingStorage {
appended: Mutex<Vec<GASPNode>>,
request_once: Mutex<bool>,
ancestor_outpoint: String,
}
#[async_trait(?Send)]
impl GASPStorage for RecordingStorage {
async fn load_deferred_graphs(
&self,
) -> Result<Vec<crate::gasp::DeferredGraphKey>, GASPError> {
Ok(Vec::new())
}
async fn get_deferred_graph(
&self,
_: &str,
) -> Result<Option<crate::gasp::DeferredGraph>, GASPError> {
Ok(None)
}
async fn save_deferred_graph(
&self,
_: &crate::gasp::DeferredGraph,
) -> Result<crate::gasp::DeferredGraphSave, GASPError> {
Err(GASPError::StorageError("keeps no deferred graphs".into()))
}
async fn delete_deferred_graph(&self, _: &str) -> Result<(), GASPError> {
Ok(())
}
async fn find_known_utxos(
&self,
_: u64,
_: Option<u64>,
) -> Result<Vec<GASPOutput>, GASPError> {
Ok(vec![])
}
async fn hydrate_gasp_node(
&self,
_: &str,
_: &str,
_: u32,
_: bool,
) -> Result<GASPNode, GASPError> {
unreachable!()
}
async fn find_needed_inputs(
&self,
_: &GASPNode,
) -> Result<Option<GASPNodeResponse>, GASPError> {
let mut once = self.request_once.lock().unwrap();
if *once {
*once = false;
let mut requested_inputs = HashMap::new();
requested_inputs.insert(
self.ancestor_outpoint.clone(),
crate::types::GASPInputRequest { metadata: false },
);
Ok(Some(GASPNodeResponse { requested_inputs }))
} else {
Ok(None)
}
}
async fn append_to_graph(&self, node: &GASPNode, _: Option<&str>) -> Result<(), GASPError> {
self.appended.lock().unwrap().push(node.clone());
Ok(())
}
async fn validate_graph_anchor(&self, _: &str) -> Result<(), GASPError> {
Ok(())
}
async fn finalize_graph(&self, _: &str) -> Result<(), GASPError> {
Ok(())
}
async fn discard_graph(&self, _: &str) -> Result<(), GASPError> {
Ok(())
}
}
struct FailingNodeRemote;
#[async_trait(?Send)]
impl GASPRemote for FailingNodeRemote {
async fn get_initial_response(
&self,
_: &GASPInitialRequest,
) -> Result<GASPInitialResponse, GASPError> {
Ok(GASPInitialResponse {
utxo_list: vec![],
since: 0,
})
}
async fn get_initial_reply(
&self,
_: &GASPInitialResponse,
) -> Result<GASPInitialReply, GASPError> {
Ok(GASPInitialReply { utxo_list: vec![] })
}
async fn request_node(
&self,
_: &str,
_: &str,
_: u32,
_: bool,
) -> Result<GASPNode, GASPError> {
Err(GASPError::RemoteError(
"Peer returned HTTP 400: Incomplete SPV data!".to_string(),
))
}
async fn submit_node(&self, _: &GASPNode) -> Result<Option<GASPNodeResponse>, GASPError> {
Ok(None)
}
}
struct MockFetcher {
ancestor_hex: String,
proof: Option<String>,
called: Mutex<u32>,
}
#[async_trait(?Send)]
impl AncestorFetcher for MockFetcher {
async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
*self.called.lock().unwrap() += 1;
Ok(FetchedAncestor {
raw_tx: self.ancestor_hex.clone(),
proof: self.proof.clone(),
})
}
}
struct CountingNodeRemote {
request_node_calls: std::rc::Rc<Mutex<u32>>,
}
#[async_trait(?Send)]
impl GASPRemote for CountingNodeRemote {
async fn get_initial_response(
&self,
_: &GASPInitialRequest,
) -> Result<GASPInitialResponse, GASPError> {
Ok(GASPInitialResponse {
utxo_list: vec![],
since: 0,
})
}
async fn get_initial_reply(
&self,
_: &GASPInitialResponse,
) -> Result<GASPInitialReply, GASPError> {
Ok(GASPInitialReply { utxo_list: vec![] })
}
async fn request_node(
&self,
_: &str,
_: &str,
_: u32,
_: bool,
) -> Result<GASPNode, GASPError> {
*self.request_node_calls.lock().unwrap() += 1;
Err(GASPError::RemoteError(
"Peer returned HTTP 400: Incomplete SPV data!".to_string(),
))
}
async fn submit_node(&self, _: &GASPNode) -> Result<Option<GASPNodeResponse>, GASPError> {
Ok(None)
}
}
#[tokio::test]
async fn ancestor_fetcher_none_preserves_peer_error() {
let ancestor_hex = make_tx_hex(
"1111111111111111111111111111111111111111111111111111111111111111",
0,
);
let ancestor_txid = bsv_rs::transaction::Transaction::from_hex(&ancestor_hex)
.unwrap()
.id();
let tip_hex = make_tx_hex(&ancestor_txid, 0);
let storage = RecordingStorage {
appended: Mutex::new(vec![]),
request_once: Mutex::new(true),
ancestor_outpoint: format!("{ancestor_txid}.0"),
};
let gasp = GASPSync::new(
Box::new(storage),
Box::new(FailingNodeRemote),
0,
"[TEST]",
true,
);
let tip_node = GASPNode {
graph_id: format!("{}.0", "deadbeef"),
raw_tx: tip_hex,
output_index: 0,
proof: None,
tx_metadata: None,
output_metadata: None,
inputs: None,
};
let mut seen = std::collections::HashSet::new();
let result = gasp.process_incoming_node(&tip_node, None, &mut seen).await;
assert!(
result.is_err(),
"with no fetcher, the peer error must propagate (default path unchanged)"
);
}
#[tokio::test]
async fn ancestor_fetcher_some_self_heals_missing_ancestor() {
let ancestor_hex = make_tx_hex(
"2222222222222222222222222222222222222222222222222222222222222222",
0,
);
let ancestor_txid = bsv_rs::transaction::Transaction::from_hex(&ancestor_hex)
.unwrap()
.id();
let tip_hex = make_tx_hex(&ancestor_txid, 0);
let storage = RecordingStorage {
appended: Mutex::new(vec![]),
request_once: Mutex::new(true),
ancestor_outpoint: format!("{ancestor_txid}.0"),
};
let fetcher = std::rc::Rc::new(MockFetcher {
ancestor_hex: ancestor_hex.clone(),
proof: None,
called: Mutex::new(0),
});
let gasp = GASPSync::new(
Box::new(storage),
Box::new(FailingNodeRemote),
0,
"[TEST]",
true,
)
.with_ancestor_fetcher(Some(fetcher.clone()));
let tip_node = GASPNode {
graph_id: "deadbeef.0".to_string(),
raw_tx: tip_hex.clone(),
output_index: 0,
proof: None,
tx_metadata: None,
output_metadata: None,
inputs: None,
};
let mut seen = std::collections::HashSet::new();
gasp.process_incoming_node(&tip_node, None, &mut seen)
.await
.expect("with fetcher, missing ancestor self-heals from chain");
assert_eq!(
*fetcher.called.lock().unwrap(),
2,
"fetcher fires twice: once to hydrate the root's OWN proof (mock returns \
proof:None here, so the root stays proofless + the walk continues), then \
once to self-heal the missing ancestor (#126 root proof-anchoring)"
);
}
#[tokio::test]
async fn ancestor_fetcher_some_skips_peer_request_node() {
let ancestor_hex = make_tx_hex(
"3333333333333333333333333333333333333333333333333333333333333333",
0,
);
let ancestor_txid = bsv_rs::transaction::Transaction::from_hex(&ancestor_hex)
.unwrap()
.id();
let tip_hex = make_tx_hex(&ancestor_txid, 0);
let storage = RecordingStorage {
appended: Mutex::new(vec![]),
request_once: Mutex::new(true),
ancestor_outpoint: format!("{ancestor_txid}.0"),
};
let counter = std::rc::Rc::new(Mutex::new(0u32));
let remote = CountingNodeRemote {
request_node_calls: counter.clone(),
};
let fetcher = std::rc::Rc::new(MockFetcher {
ancestor_hex: ancestor_hex.clone(),
proof: None,
called: Mutex::new(0),
});
let gasp = GASPSync::new(Box::new(storage), Box::new(remote), 0, "[TEST]", true)
.with_ancestor_fetcher(Some(fetcher.clone()));
let tip_node = GASPNode {
graph_id: "deadbeef.0".to_string(),
raw_tx: tip_hex,
output_index: 0,
proof: None,
tx_metadata: None,
output_metadata: None,
inputs: None,
};
let mut seen = std::collections::HashSet::new();
gasp.process_incoming_node(&tip_node, None, &mut seen)
.await
.expect("self-heal via fetcher should succeed");
assert_eq!(
*counter.lock().unwrap(),
0,
"peer request_node must be SKIPPED for ancestors when a fetcher is present"
);
assert_eq!(
*fetcher.called.lock().unwrap(),
2,
"fetcher fires twice: root's OWN proof hydration (#126) + serving the \
ancestor directly (peer request_node still skipped)"
);
}
#[test]
fn test_parse_outpoint() {
let (txid, oi) = parse_outpoint("abc123.5").unwrap();
assert_eq!(txid, "abc123");
assert_eq!(oi, 5);
assert!(parse_outpoint("no_dot").is_none());
assert!(parse_outpoint("abc.notanum").is_none());
}
#[tokio::test]
async fn test_gasp_storage_is_object_safe() {
let storage: Box<dyn GASPStorage> = Box::new(MockGASPStorage::new(vec![]));
let utxos = storage.find_known_utxos(0, None).await.unwrap();
assert!(utxos.is_empty());
}
#[tokio::test]
async fn test_gasp_remote_is_object_safe() {
let remote: Box<dyn GASPRemote> = Box::new(MockGASPRemote::new(vec![]));
let response = remote
.get_initial_response(&GASPInitialRequest {
version: 1,
since: 0,
limit: None,
})
.await
.unwrap();
assert!(response.utxo_list.is_empty());
}
#[tokio::test]
async fn test_version_mismatch_error() {
let remote = MockGASPRemote::new(vec![]);
let result = remote
.get_initial_response(&GASPInitialRequest {
version: 99,
since: 0,
limit: None,
})
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
GASPError::VersionMismatch { .. }
));
}
}