Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 8 additions & 4 deletions src/constants/stellar_transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,20 @@ pub fn get_stellar_sponsored_transaction_validity_duration() -> Duration {
pub const STELLAR_MAX_STUCK_TRANSACTION_LIFETIME_MINUTES: i64 = 15;

/// Base interval (seconds) for resubmitting a Submitted transaction.
/// Stellar Core retries internally for ~3 ledgers (~15s). We start resubmitting at 15s
/// Stellar Core retries internally for ~2 ledgers (~10s). We start resubmitting at 10s
/// to ensure the transaction is back in the mempool before Core's window closes.
pub const STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS: i64 = 15;
pub const STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS: i64 = 10;

/// Maximum number of times a Stellar submission may be retried after an insufficient-fee error.
pub const STELLAR_INSUFFICIENT_FEE_MAX_RETRIES: u32 = 2;

/// Maximum resubmit interval (seconds) to cap exponential backoff.
/// Maximum resubmit interval (seconds) to cap the resubmission backoff.
/// Prevents excessively long gaps between resubmissions.
pub const STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS: i64 = 180;
pub const STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS: i64 = 120;

/// Growth factor for resubmit backoff. Each tier multiplies the interval by this factor.
/// With base=10 and factor=1.5: 10s → 15s → 22s → 33s → 50s → 75s → 113s → 120s (capped).
pub const STELLAR_RESUBMIT_GROWTH_FACTOR: f64 = 1.5;

/// Get base resubmit interval for Submitted transactions
pub fn get_stellar_resubmit_base_interval() -> Duration {
Expand Down
26 changes: 14 additions & 12 deletions src/domain/transaction/stellar/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use tracing::{debug, info, warn};
use super::{is_final_state, StellarRelayerTransaction};
use crate::constants::{
get_stellar_max_stuck_transaction_lifetime, STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS,
STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS,
STELLAR_RESUBMIT_GROWTH_FACTOR, STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS,
};
use crate::domain::transaction::stellar::prepare::common::send_submit_transaction_job;
use crate::domain::transaction::stellar::utils::{
Expand Down Expand Up @@ -451,14 +451,15 @@ where
return result;
}

// Resubmit with exponential backoff based on total transaction age.
// Resubmit with backoff based on total transaction age.
// Uses the same backoff logic as the Submitted state handler:
// 15s → 30s60s120s180s (capped).
// 10s → 15s → 22s33s50s75s → 113s → 120s (capped).
let total_age = get_age_since_created(&tx)?;
if let Some(backoff_interval) = compute_resubmit_backoff_interval(
total_age,
STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS,
STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS,
STELLAR_RESUBMIT_GROWTH_FACTOR,
) {
let age_since_last_submit = get_age_since_sent_or_created(&tx)?;
if age_since_last_submit > backoff_interval {
Comment thread
zeljkoX marked this conversation as resolved.
Expand Down Expand Up @@ -599,13 +600,14 @@ where
return result;
}

// Resubmit with exponential backoff based on total transaction age.
// The backoff interval grows: 15s → 30s60s120s180s (capped).
// Resubmit with backoff based on total transaction age.
// The backoff interval grows: 10s → 15s → 22s33s50s75s → 113s → 120s (capped).
let total_age = get_age_since_created(&tx)?;
if let Some(backoff_interval) = compute_resubmit_backoff_interval(
total_age,
STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS,
STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS,
STELLAR_RESUBMIT_GROWTH_FACTOR,
) {
let age_since_last_submit = get_age_since_sent_or_created(&tx)?;
if age_since_last_submit > backoff_interval {
Expand Down Expand Up @@ -2296,17 +2298,17 @@ mod tests {

#[tokio::test]
async fn test_handle_submitted_state_backoff_resubmits_when_interval_exceeded() {
// Transaction created 25s ago, sent_at 21s ago.
// At total_age=25s, backoff interval = 15s (base*2^0, since 25/15=1, log2(1)=0).
// age_since_last_submit=21s > 15s → should resubmit.
// Transaction created 25s ago, sent_at 25s ago.
// At total_age=25s with base=10, factor=1.5: interval = 22s (third tier).
// age_since_last_submit=25s > 22s → should resubmit.
let relayer = create_test_relayer();
let mut mocks = default_test_mocks();

let mut tx = create_test_transaction(&relayer.id);
tx.id = "tx-submitted-backoff-resubmit".to_string();
tx.status = TransactionStatus::Submitted;
tx.created_at = (Utc::now() - Duration::seconds(25)).to_rfc3339();
tx.sent_at = Some((Utc::now() - Duration::seconds(21)).to_rfc3339());
tx.sent_at = Some((Utc::now() - Duration::seconds(25)).to_rfc3339());
let tx_hash_bytes = [12u8; 32];
if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data {
stellar_data.hash = Some(hex::encode(tx_hash_bytes));
Expand All @@ -2323,7 +2325,7 @@ mod tests {
Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) })
});

// Should resubmit (21s > 15s backoff interval)
// Should resubmit (25s > 22s backoff interval)
mocks
.job_producer
.expect_produce_submit_transaction_job()
Expand All @@ -2341,8 +2343,8 @@ mod tests {
#[tokio::test]
async fn test_handle_submitted_state_recent_sent_at_prevents_resubmit() {
// Transaction created 60s ago (old), but sent_at only 5s ago (recent resubmission).
// At total_age=60s, backoff interval = 60s (base*2^2, since 60/15=4, log2(4)=2).
// age_since_last_submit=5s < 60s → should NOT resubmit.
// At total_age=60s with base=10, factor=1.5: interval = 50s (fifth tier).
// age_since_last_submit=5s < 50s → should NOT resubmit.
// This verifies that sent_at being updated on resubmission correctly resets the clock.
let relayer = create_test_relayer();
let mut mocks = default_test_mocks();
Expand Down
187 changes: 139 additions & 48 deletions src/domain/transaction/stellar/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1301,33 +1301,49 @@ pub fn asset_to_asset_id(asset: &Asset) -> Result<String, StellarTransactionUtil
}
}

/// Computes the resubmit interval with exponential backoff based on total transaction age.
/// Computes the resubmit interval with backoff based on total transaction age.
///
/// The interval doubles each time the total age doubles:
/// - age < base → `None` (too early to resubmit)
/// - age 1-2x base → interval = base (10s)
/// - age 2-4x base → interval = 2*base (20s)
/// - age 4-8x base → interval = 4*base (40s)
/// The interval grows by `growth_factor` each time the age crosses the next tier boundary:
/// - age < base → `None` (too early to resubmit)
/// - age in [1x, 1.5x) → interval = base (e.g. 10s)
/// - age in [1.5x, 2.25x) → interval = base × factor (e.g. 15s)
/// - age in [2.25x, 3.375x) → interval = base × factor² (e.g. 22s)
/// - ...capped at `max_interval`
///
/// With base=10, factor=1.5, max=120:
/// 10s → 15s → 22s → 33s → 50s → 75s → 113s → 120s (capped)
///
/// Returns the backoff interval to compare against time since last submission (`sent_at`).
pub fn compute_resubmit_backoff_interval(
total_age: chrono::Duration,
base_interval_secs: i64,
max_interval_secs: i64,
growth_factor: f64,
) -> Option<chrono::Duration> {
let age_secs = total_age.num_seconds();

if age_secs < base_interval_secs {
if age_secs < base_interval_secs || base_interval_secs <= 0 || max_interval_secs <= 0 {
return None;
}

// n = floor(log2(age / base)), so interval = base * 2^n
let ratio = age_secs / base_interval_secs; // >= 1
let n = (ratio as u64).ilog2(); // floor(log2(ratio))
let interval = base_interval_secs.saturating_mul(1_i64.wrapping_shl(n));
let capped = interval.min(max_interval_secs);
// Guard: factor must be > 1.0 to produce growth; fall back to min(base, max).
if growth_factor <= 1.0 {
return Some(chrono::Duration::seconds(
base_interval_secs.min(max_interval_secs),
));
}

// Each tier boundary = previous boundary × growth_factor.
// The interval at each tier = previous interval × growth_factor, capped at max.
let mut interval = base_interval_secs as f64;
let mut tier_end = base_interval_secs as f64 * growth_factor;

while tier_end <= age_secs as f64 {
interval = (interval * growth_factor).min(max_interval_secs as f64);
tier_end *= growth_factor;
}
Comment thread
zeljkoX marked this conversation as resolved.

let capped = (interval as i64).min(max_interval_secs);
Some(chrono::Duration::seconds(capped))
}

Expand Down Expand Up @@ -3768,76 +3784,116 @@ mod compute_resubmit_backoff_interval_tests {

const BASE: i64 = 10;
const MAX: i64 = 120;
const FACTOR: f64 = 1.5;

#[test]
fn returns_none_below_base() {
assert!(compute_resubmit_backoff_interval(Duration::seconds(0), BASE, MAX).is_none());
assert!(compute_resubmit_backoff_interval(Duration::seconds(5), BASE, MAX).is_none());
assert!(compute_resubmit_backoff_interval(Duration::seconds(9), BASE, MAX).is_none());
assert!(
compute_resubmit_backoff_interval(Duration::seconds(0), BASE, MAX, FACTOR).is_none()
);
assert!(
compute_resubmit_backoff_interval(Duration::seconds(5), BASE, MAX, FACTOR).is_none()
);
assert!(
compute_resubmit_backoff_interval(Duration::seconds(9), BASE, MAX, FACTOR).is_none()
);
}

#[test]
fn base_interval_at_1x() {
// age 10-19s: ratio=1, log2(1)=0, interval = 10 * 2^0 = 10s
fn base_interval_at_first_tier() {
// age 10-14s: interval = 10s (tier boundary at 10 * 1.5 = 15)
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(10), BASE, MAX),
compute_resubmit_backoff_interval(Duration::seconds(10), BASE, MAX, FACTOR),
Some(Duration::seconds(10))
);
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(19), BASE, MAX),
compute_resubmit_backoff_interval(Duration::seconds(14), BASE, MAX, FACTOR),
Some(Duration::seconds(10))
);
}

#[test]
fn doubles_at_2x() {
// age 20-39s: ratio=2-3, log2(2)=1, interval = 10 * 2^1 = 20s
fn grows_by_factor_at_second_tier() {
// age 15-21s: interval = 10 * 1.5 = 15s (tier boundary at 15 * 1.5 = 22.5)
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(20), BASE, MAX),
Some(Duration::seconds(20))
compute_resubmit_backoff_interval(Duration::seconds(15), BASE, MAX, FACTOR),
Some(Duration::seconds(15))
);
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(39), BASE, MAX),
Some(Duration::seconds(20))
compute_resubmit_backoff_interval(Duration::seconds(22), BASE, MAX, FACTOR),
Some(Duration::seconds(15))
);
}

#[test]
fn quadruples_at_4x() {
// age 40-79s: ratio=4-7, log2(4)=2, interval = 10 * 2^2 = 40s
fn grows_by_factor_squared_at_third_tier() {
// age 23-33s: interval = 10 * 1.5^2 = 22.5 ≈ 22s (tier boundary at 22.5 * 1.5 = 33.75)
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(40), BASE, MAX),
Some(Duration::seconds(40))
compute_resubmit_backoff_interval(Duration::seconds(23), BASE, MAX, FACTOR),
Some(Duration::seconds(22))
);
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(79), BASE, MAX),
Some(Duration::seconds(40))
compute_resubmit_backoff_interval(Duration::seconds(33), BASE, MAX, FACTOR),
Some(Duration::seconds(22))
);
}

#[test]
fn interval_at_8x() {
// age 80-119s: ratio=8-11, log2(8)=3, interval = 10 * 2^3 = 80s
fn fourth_tier() {
// age 34-50s: interval = 10 * 1.5^3 = 33.75 → 33s (truncated)
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(80), BASE, MAX),
Some(Duration::seconds(80))
compute_resubmit_backoff_interval(Duration::seconds(34), BASE, MAX, FACTOR),
Some(Duration::seconds(33))
);
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(119), BASE, MAX),
Some(Duration::seconds(80))
compute_resubmit_backoff_interval(Duration::seconds(50), BASE, MAX, FACTOR),
Some(Duration::seconds(33))
);
}

#[test]
fn capped_at_max() {
// age 160s: ratio=16, log2(16)=4, interval = 10*16 = 160 → capped at 120s
// At high ages the interval should be capped at MAX (120s)
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(300), BASE, MAX, FACTOR),
Some(Duration::seconds(MAX))
);
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(160), BASE, MAX),
compute_resubmit_backoff_interval(Duration::seconds(1000), BASE, MAX, FACTOR),
Some(Duration::seconds(MAX))
);
// age 1280s: ratio=128, log2(128)=7, interval = 10*128 = 1280 → capped at 120s
}

#[test]
fn works_with_factor_2_doubling() {
// With factor=2.0, behavior matches classic doubling: 10 → 20 → 40 → 80 → 120
let factor = 2.0;
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(10), BASE, MAX, factor),
Some(Duration::seconds(10))
);
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(19), BASE, MAX, factor),
Some(Duration::seconds(10))
);
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(20), BASE, MAX, factor),
Some(Duration::seconds(20))
);
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(39), BASE, MAX, factor),
Some(Duration::seconds(20))
);
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(40), BASE, MAX, factor),
Some(Duration::seconds(40))
);
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(80), BASE, MAX, factor),
Some(Duration::seconds(80))
);
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(1280), BASE, MAX),
compute_resubmit_backoff_interval(Duration::seconds(160), BASE, MAX, factor),
Some(Duration::seconds(MAX))
);
}
Expand All @@ -3847,20 +3903,55 @@ mod compute_resubmit_backoff_interval_tests {
// Verify the function is generic, not hardcoded to Stellar constants
let base = 5;
let max = 30;
// age 5-9s: interval = 5s
// age 5-7s: interval = 5s
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(5), base, max),
compute_resubmit_backoff_interval(Duration::seconds(5), base, max, FACTOR),
Some(Duration::seconds(5))
);
// age 10-19s: interval = 10s
// age 8-11s: interval = 5 * 1.5 = 7.5 → 7s (truncated)
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(10), base, max),
Some(Duration::seconds(10))
compute_resubmit_backoff_interval(Duration::seconds(8), base, max, FACTOR),
Some(Duration::seconds(7))
);
// age 40s: interval = 40 → capped at 30s
// age 100s: capped at 30s
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(40), base, max),
compute_resubmit_backoff_interval(Duration::seconds(100), base, max, FACTOR),
Some(Duration::seconds(30))
);
}

#[test]
fn factor_at_or_below_one_returns_min_base_max() {
// growth_factor <= 1.0 would cause an infinite loop; guard returns min(base, max) instead
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(100), BASE, MAX, 1.0),
Some(Duration::seconds(std::cmp::min(BASE, MAX)))
);
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(100), BASE, MAX, 0.5),
Some(Duration::seconds(std::cmp::min(BASE, MAX)))
);
// When base > max, returns max
assert_eq!(
compute_resubmit_backoff_interval(Duration::seconds(200), 200, MAX, 1.0),
Some(Duration::seconds(MAX))
);
// Still returns None below base
assert!(compute_resubmit_backoff_interval(Duration::seconds(5), BASE, MAX, 1.0).is_none());
}

#[test]
fn non_positive_base_or_max_returns_none() {
// base_interval_secs <= 0 would cause infinite loop; guard returns None
assert!(
compute_resubmit_backoff_interval(Duration::seconds(100), 0, MAX, FACTOR).is_none()
);
assert!(
compute_resubmit_backoff_interval(Duration::seconds(100), -5, MAX, FACTOR).is_none()
);
// max_interval_secs <= 0 also returns None
assert!(
compute_resubmit_backoff_interval(Duration::seconds(100), BASE, 0, FACTOR).is_none()
);
}
}
Loading