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
10 changes: 9 additions & 1 deletion packages/devkit/src/cli/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ impl Window {
pub struct Export;

impl Export {
/// Serialize fee points to CSV.
/// Filter points by window relative to the latest timestamp.
pub fn filter_window(points: &[FeePoint], window: Window) -> &[FeePoint] {
match window.cutoff_seconds() {
Expand Down Expand Up @@ -124,6 +123,15 @@ mod tests {
]
}

fn sample() -> Vec<FeePoint> {
vec![FeePoint {
timestamp: 1000,
fee: 100,
ledger: 1,
is_spike: false,
}]
}

#[test]
fn window_1h_filters() {
let p = pts();
Expand Down
36 changes: 33 additions & 3 deletions packages/devkit/src/harness/horizon_mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ pub struct HorizonMock {
/// Optional canned JSON response for `GET /fee_stats`. When set, takes
/// precedence over `scenario_path` and the convention-based file path.
pub fee_stats_response: Option<String>,
/// Unix timestamp when this mock was created (for uptime calculation).
pub start_time: u64,
}

impl HorizonMock {
Expand All @@ -22,6 +24,7 @@ impl HorizonMock {
scenario_path: None,
error_rate: 0.0,
fee_stats_response: None,
start_time: current_unix_secs(),
}
}

Expand Down Expand Up @@ -80,7 +83,11 @@ impl HorizonMock {

/// Returns the JSON body for `GET /health`.
pub fn health_payload(&self) -> String {
format!(r#"{{"status":"ok","scenario":"{}"}}"#, self.scenario)
let uptime = current_unix_secs().saturating_sub(self.start_time);
format!(
r#"{{"status":"ok","scenario":"{}","uptime_secs":{}}}"#,
self.scenario, uptime
)
}

/// Loads and returns the scenario JSON to be served at `GET /fee_stats`.
Expand Down Expand Up @@ -125,6 +132,10 @@ pub struct HorizonMockConfig {
pub delay_ms: u64,
/// Probability [0.0, 1.0] of injecting a 500 error response.
pub error_rate: f64,
/// Interval in seconds between automatic scenario rotations (0 = disabled).
pub rotate_secs: u64,
/// Ordered list of scenario names to rotate through (used when rotate_secs > 0).
pub rotation_scenarios: Vec<String>,
}

impl Default for HorizonMockConfig {
Expand All @@ -134,6 +145,8 @@ impl Default for HorizonMockConfig {
scenario_path: std::path::PathBuf::from("src/harness/scenarios/normal.json"),
delay_ms: 0,
error_rate: 0.0,
rotate_secs: 0,
rotation_scenarios: Vec::new(),
}
}
}
Expand All @@ -156,15 +169,16 @@ impl HorizonMock {
scenario_path: Some(config.scenario_path),
error_rate: config.error_rate,
fee_stats_response: None,
start_time: current_unix_secs(),
}
}
}

/// Starts an axum HTTP server serving mock Horizon responses.
///
/// Routes:
/// - `GET /fee_stats` — returns scenario fee stats JSON
/// - `GET /health` — returns `{"status":"ok","scenario":"<name>"}`
/// - `GET /fee_stats` — returns scenario fee stats JSON, with optional delay and error injection
/// - `GET /health` — returns `{"status":"ok","scenario":"<name>","uptime_secs":N}`
///
/// Binds to `0.0.0.0:port`. Returns when the server shuts down.
pub async fn serve(mock: std::sync::Arc<HorizonMock>, port: u16) -> std::io::Result<()> {
Expand All @@ -180,6 +194,14 @@ pub async fn serve(mock: std::sync::Arc<HorizonMock>, port: u16) -> std::io::Res
get(move || {
let m = m1.clone();
async move {
m.apply_delay();
if m.should_inject_error() {
return (
axum::http::StatusCode::SERVICE_UNAVAILABLE,
[(axum::http::header::CONTENT_TYPE, "application/json")],
r#"{"error":"service unavailable"}"#.to_string(),
);
}
match m.fee_stats_payload() {
Ok(json) => (
axum::http::StatusCode::OK,
Expand Down Expand Up @@ -212,6 +234,14 @@ pub async fn serve(mock: std::sync::Arc<HorizonMock>, port: u16) -> std::io::Res
.map_err(std::io::Error::other)
}

/// Returns the current Unix timestamp in seconds.
fn current_unix_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}

/// Minimal pseudo-random float in [0.0, 1.0) using system time as entropy.
fn rand_f64() -> f64 {
let nanos = std::time::SystemTime::now()
Expand Down
37 changes: 37 additions & 0 deletions packages/devkit/src/harness/scenarios/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ pub fn load_from_file(path: &Path) -> std::io::Result<String> {
pub struct ScenarioRotator {
scenarios: Vec<String>,
index: usize,
/// How often (in seconds) to advance to the next scenario. 0 = manual only.
pub interval_secs: u64,
/// Unix timestamp of the last rotation.
last_rotated: u64,
}

impl ScenarioRotator {
Expand All @@ -78,6 +82,18 @@ impl ScenarioRotator {
Self {
scenarios,
index: 0,
interval_secs: 0,
last_rotated: current_unix_secs(),
}
}

/// Creates a rotator that automatically advances every `interval_secs` seconds.
pub fn with_interval(scenarios: Vec<String>, interval_secs: u64) -> Self {
Self {
scenarios,
index: 0,
interval_secs,
last_rotated: current_unix_secs(),
}
}

Expand All @@ -88,6 +104,27 @@ impl ScenarioRotator {
}
let current = self.scenarios[self.index].as_str();
self.index = (self.index + 1) % self.scenarios.len();
self.last_rotated = current_unix_secs();
Some(current)
}

/// Advances if the rotation interval has elapsed. Returns the new scenario name if rotated.
pub fn advance_if_due(&mut self) -> Option<&str> {
if self.interval_secs == 0 {
return None;
}
let elapsed = current_unix_secs().saturating_sub(self.last_rotated);
if elapsed >= self.interval_secs {
self.advance()
} else {
None
}
}
}

fn current_unix_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
Loading