use crate::api::AppState;
use crate::bus::EventBus;
use crate::event::TelemetryEvent;
use crate::storage::QueryFilter;
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use parking_lot::Mutex;
use serde::Deserialize;
use serde_json::{Map, Value};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, oneshot};
#[derive(Debug, Clone, Default, Deserialize)]
pub struct WaitFilter {
pub topic: Option<String>,
pub class: Option<String>,
pub id: Option<String>,
pub payload_match: Option<Map<String, Value>>,
}
impl WaitFilter {
pub fn matches(&self, ev: &TelemetryEvent) -> bool {
if let Some(t) = &self.topic {
if &ev.topic != t {
return false;
}
}
if let Some(c) = &self.class {
if &ev.class != c {
return false;
}
}
if let Some(i) = &self.id {
if &ev.id != i {
return false;
}
}
if let Some(pm) = &self.payload_match {
let Some(obj) = ev.payload.as_object() else {
return false;
};
for (k, v) in pm {
if obj.get(k) != Some(v) {
return false;
}
}
}
true
}
}
#[derive(Debug, Deserialize)]
pub struct WaitRequest {
pub since_ts: u64,
pub timeout_ms: u64,
#[serde(default)]
pub filter: WaitFilter,
}
struct Waiter {
since_ts: u64,
filter: WaitFilter,
tx: oneshot::Sender<TelemetryEvent>,
}
#[derive(Clone)]
pub struct WaitDispatcher {
waiters: Arc<Mutex<Vec<Waiter>>>,
}
impl WaitDispatcher {
pub fn spawn(bus: &EventBus) -> Self {
let waiters = Arc::new(Mutex::new(Vec::<Waiter>::new()));
let shared = Arc::clone(&waiters);
let mut rx = bus.subscribe();
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(ev) => Self::dispatch_event(&shared, ev),
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(skipped = n, "wait dispatcher lagged")
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
});
Self { waiters }
}
fn dispatch_event(waiters: &Arc<Mutex<Vec<Waiter>>>, ev: TelemetryEvent) {
let mut guard = waiters.lock();
let mut i = 0;
while i < guard.len() {
if guard[i].tx.is_closed() {
guard.remove(i);
continue;
}
if ev.ingress_ts >= guard[i].since_ts && guard[i].filter.matches(&ev) {
let w = guard.remove(i);
let _ = w.tx.send(ev.clone());
} else {
i += 1;
}
}
}
pub fn register(&self, since_ts: u64, filter: WaitFilter) -> oneshot::Receiver<TelemetryEvent> {
let (tx, rx) = oneshot::channel();
self.waiters.lock().push(Waiter {
since_ts,
filter,
tx,
});
rx
}
}
pub async fn post_wait(
State(st): State<Arc<AppState>>,
Json(req): Json<WaitRequest>,
) -> Result<Json<TelemetryEvent>, StatusCode> {
let rx = st.dispatch.register(req.since_ts, req.filter.clone());
let filter = QueryFilter {
topic: req.filter.topic.clone(),
class: req.filter.class.clone(),
id: req.filter.id.clone(),
since: Some(req.since_ts),
until: None,
limit: None,
};
if let Ok(hits) = st.storage.query(&filter).await {
if let Some(ev) = hits.into_iter().find(|e| req.filter.matches(e)) {
return Ok(Json(ev));
}
}
match tokio::time::timeout(Duration::from_millis(req.timeout_ms), rx).await {
Ok(Ok(ev)) => Ok(Json(ev)),
Ok(Err(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR),
Err(_) => Err(StatusCode::REQUEST_TIMEOUT),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::tests::{request, test_app};
use crate::storage::tests::ev;
use serde_json::json;
fn filter_with_payload(k: &str, v: Value) -> WaitFilter {
let mut m = Map::new();
m.insert(k.to_string(), v);
WaitFilter {
payload_match: Some(m),
..Default::default()
}
}
#[test]
fn filter_matches_all_set_fields() {
let f = WaitFilter {
topic: Some("t".into()),
..Default::default()
};
assert!(f.matches(&ev("t", 1)));
assert!(!f.matches(&ev("other", 1)));
}
#[test]
fn payload_match_requires_containment() {
let f = filter_with_payload("k", json!("v"));
let mut e = ev("t", 1);
e.payload = json!({"k": "v", "extra": 1});
assert!(f.matches(&e));
e.payload = json!({"k": "other"});
assert!(!f.matches(&e));
e.payload = json!("not an object");
assert!(!f.matches(&e));
}
#[tokio::test]
async fn wait_resolves_on_matching_arrival() {
let (bus, app) = test_app(vec![]).await;
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(30)).await;
let _ = bus.publish(ev("a", 100));
});
let (status, v) = request(
app,
"POST",
"/api/v1/wait",
Some(json!({
"since_ts": 0, "timeout_ms": 2000,
"filter": {"topic": "a"}
})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(v["topic"], "a");
assert_eq!(v["ingress_ts"], 100);
}
#[tokio::test]
async fn wait_returns_stored_event_immediately() {
let (_bus, app) = test_app(vec![ev("a", 10)]).await;
let (status, v) = request(
app,
"POST",
"/api/v1/wait",
Some(json!({
"since_ts": 0, "timeout_ms": 1000,
"filter": {"topic": "a"}
})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(v["ingress_ts"], 10);
}
#[tokio::test]
async fn wait_ignores_events_older_than_since_ts() {
let (_bus, app) = test_app(vec![ev("a", 5)]).await;
let (status, _v) = request(
app,
"POST",
"/api/v1/wait",
Some(json!({"since_ts": 50, "timeout_ms": 80, "filter": {}})),
)
.await;
assert_eq!(status, StatusCode::REQUEST_TIMEOUT);
}
#[tokio::test]
async fn wait_times_out_with_408() {
let (_bus, app) = test_app(vec![]).await;
let (status, _v) = request(
app,
"POST",
"/api/v1/wait",
Some(json!({"since_ts": 0, "timeout_ms": 50, "filter": {}})),
)
.await;
assert_eq!(status, StatusCode::REQUEST_TIMEOUT);
}
#[tokio::test]
async fn non_object_payload_match_is_client_error() {
let (_bus, app) = test_app(vec![]).await;
let (status, _v) = request(
app,
"POST",
"/api/v1/wait",
Some(json!({
"since_ts": 0, "timeout_ms": 10,
"filter": {"payload_match": "not-an-object"}
})),
)
.await;
assert!(status.is_client_error());
}
#[tokio::test]
async fn non_matching_event_does_not_resolve() {
let (bus, app) = test_app(vec![]).await;
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let _ = bus.publish(ev("unrelated", 100));
});
let (status, _v) = request(
app,
"POST",
"/api/v1/wait",
Some(json!({
"since_ts": 0, "timeout_ms": 200,
"filter": {"topic": "wanted"}
})),
)
.await;
assert_eq!(status, StatusCode::REQUEST_TIMEOUT);
}
}