#![cfg(feature = "prometheus")]
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::serve;
use chrono::{TimeDelta, Utc};
use ironflow_api::routes::{RouterConfig, create_router};
use ironflow_api::state::AppState;
use ironflow_auth::jwt::JwtConfig;
use ironflow_core::metric_names::{WORKER_QUEUE_BLOCKED_RUNS, WORKER_QUEUE_DEPTH};
use ironflow_core::providers::claude::ClaudeCodeProvider;
use ironflow_engine::engine::{Engine, ExecutionMode};
use ironflow_engine::notify::Event;
use ironflow_store::memory::InMemoryStore;
use ironflow_store::models::{ConcurrencyLimit, NewRun, RunFilter, RunStatus, TriggerKind};
use ironflow_store::store::RunStore;
use ironflow_worker::WorkerBuilder;
use serde_json::json;
use tokio::net::TcpListener;
use tokio::spawn;
use tokio::sync::{Mutex, broadcast};
use tokio::task::JoinHandle;
use tokio::time::{sleep, timeout};
const TEST_TIMEOUT: Duration = Duration::from_secs(30);
const GAUGE_DEADLINE: Duration = Duration::from_secs(8);
static SERIAL: Mutex<()> = Mutex::const_new(());
fn future_run(workflow: &str) -> NewRun {
NewRun {
created_by: None,
workflow_name: workflow.to_string(),
trigger: TriggerKind::Manual,
payload: json!({}),
max_retries: 0,
handler_version: None,
labels: HashMap::new(),
scheduled_at: Some(Utc::now() + TimeDelta::hours(1)),
idempotency_key: None,
concurrency_key: None,
concurrency_limits: Vec::new(),
max_cost_usd: None,
}
}
fn grouped_run() -> NewRun {
NewRun {
created_by: None,
workflow_name: "deploy".to_string(),
trigger: TriggerKind::Manual,
payload: json!({}),
max_retries: 0,
handler_version: None,
labels: HashMap::new(),
scheduled_at: None,
idempotency_key: None,
concurrency_key: None,
concurrency_limits: vec![ConcurrencyLimit::new("repo:acme", 1)],
max_cost_usd: None,
}
}
async fn serve_api(store: Arc<InMemoryStore>) -> (String, impl Fn() -> String) {
let engine = Engine::new(store.clone(), Arc::new(ClaudeCodeProvider::new()))
.with_execution_mode(ExecutionMode::Workers);
let jwt_config = Arc::new(JwtConfig {
secret: "test-secret-for-queue-depth".to_string(),
access_token_ttl_secs: 900,
refresh_token_ttl_secs: 604800,
cookie_domain: None,
cookie_secure: false,
});
let (event_sender, _) = broadcast::channel::<Event>(16);
let state = AppState::new(
store,
Arc::new(engine),
jwt_config,
"test-worker-token".to_string(),
event_sender,
);
let prometheus = state.prometheus_handle.clone();
let config = RouterConfig {
rate_limit_auth: None,
rate_limit_general: None,
..RouterConfig::default()
};
let router = create_router(state, config);
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local addr");
spawn(async move {
serve(listener, router).await.expect("serve");
});
(format!("http://{addr}"), move || prometheus.render())
}
fn spawn_worker(api_url: &str, worker_id: &str) -> JoinHandle<()> {
let worker = WorkerBuilder::new(api_url, "test-worker-token")
.provider(Arc::new(ClaudeCodeProvider::new()))
.worker_id(worker_id)
.concurrency(1)
.poll_interval(Duration::from_millis(20))
.build()
.expect("build worker");
spawn(async move {
if let Err(e) = worker.run().await {
eprintln!("worker exited with error: {e:?}");
}
})
}
async fn wait_for_value(render: &impl Fn() -> String, series: &str, expected: f64) -> Option<f64> {
let deadline = Instant::now() + GAUGE_DEADLINE;
let mut value = None;
while Instant::now() < deadline {
value = gauge_value(&render(), series);
if value == Some(expected) {
break;
}
sleep(Duration::from_millis(20)).await;
}
value
}
fn gauge_value(exposition: &str, series: &str) -> Option<f64> {
exposition
.lines()
.filter(|line| !line.starts_with('#'))
.find_map(|line| {
let (name, value) = line.rsplit_once(' ')?;
(name == series)
.then(|| value.trim().parse().ok())
.flatten()
})
}
#[tokio::test]
async fn api_mode_worker_publishes_pending_run_count_as_queue_depth() {
let _serial = SERIAL.lock().await;
timeout(TEST_TIMEOUT, async {
let store = Arc::new(InMemoryStore::new());
for _ in 0..3 {
store
.create_run(future_run("queued"))
.await
.expect("create pending run");
}
let running = store
.create_run(future_run("queued"))
.await
.expect("create run")
.into_run();
store
.update_run_status(running.id, RunStatus::Running)
.await
.expect("move run to running");
let (api_url, render) = serve_api(store).await;
let handle = spawn_worker(&api_url, "worker-queue-depth");
let depth = wait_for_value(&render, WORKER_QUEUE_DEPTH, 3.0).await;
handle.abort();
assert_eq!(
depth,
Some(3.0),
"expected {WORKER_QUEUE_DEPTH} 3, exposition was:\n{}",
render()
);
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn api_mode_worker_publishes_runs_blocked_by_group_and_resets_vanished_groups() {
let _serial = SERIAL.lock().await;
timeout(TEST_TIMEOUT, async {
let store = Arc::new(InMemoryStore::new());
let holder = store
.create_run(grouped_run())
.await
.expect("create holder run")
.into_run();
store
.update_run_status(holder.id, RunStatus::Running)
.await
.expect("move holder to running");
let mut blocked_ids = Vec::new();
for _ in 0..2 {
let run = store
.create_run(grouped_run())
.await
.expect("create blocked run")
.into_run();
blocked_ids.push(run.id);
}
let (api_url, render) = serve_api(store.clone()).await;
let handle = spawn_worker(&api_url, "worker-blocked-runs");
let series = format!("{WORKER_QUEUE_BLOCKED_RUNS}{{group=\"repo:acme\"}}");
let blocked = wait_for_value(&render, &series, 2.0).await;
assert_eq!(
blocked,
Some(2.0),
"expected {series} 2, exposition was:\n{}",
render()
);
let held = store
.list_runs(RunFilter::default(), 1, 10)
.await
.expect("list runs");
let running = held
.items
.iter()
.filter(|r| r.status.state == RunStatus::Running)
.count();
assert_eq!(running, 1, "only the holder may run");
for id in blocked_ids {
store
.update_run_status(id, RunStatus::Cancelled)
.await
.expect("cancel blocked run");
}
let reset = wait_for_value(&render, &series, 0.0).await;
handle.abort();
assert_eq!(
reset,
Some(0.0),
"expected {series} 0 once the group drained, exposition was:\n{}",
render()
);
})
.await
.expect("test timed out");
}