use core::future::{Future, IntoFuture};
use core::time::Duration;
use anyhow::anyhow;
use async_nats::HeaderMap;
use async_nats::jetstream::{
self,
stream::{Config, DiscardPolicy, RetentionPolicy, StorageType},
};
use thiserror::Error;
use web_time::SystemTime;
use crate::bus::{self, Wire};
use crate::event::Payload;
use crate::partition::{self, PARTITIONS};
use crate::protocol::ids::{IdError, headers, token_safe};
use crate::topology::{RawConfig, duplicate_window, ensure_raw_stream, raw_subject};
const LATITUDE_LIMIT: f64 = 90.0;
const LONGITUDE_LIMIT: f64 = 180.0;
const DEFAULT_PUBLISH_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_ATTEMPTS: u32 = 5;
const INITIAL_BACKOFF: Duration = Duration::from_millis(50);
const MAX_BACKOFF: Duration = Duration::from_secs(5);
#[derive(Debug, Error)]
pub enum IngressError {
#[error("vehicle id is zero")]
ZeroVehicle,
#[error("coordinate is not finite")]
NonFinite,
#[error("latitude {0} is outside [-90, 90]")]
LatitudeOutOfRange(f64),
#[error("longitude {0} is outside [-180, 180]")]
LongitudeOutOfRange(f64),
#[error("publish was not acknowledged after retries")]
Publish(#[source] anyhow::Error),
}
impl IngressError {
pub fn kind(&self) -> &'static str {
match self {
IngressError::ZeroVehicle => "zero_vehicle",
IngressError::NonFinite => "non_finite",
IngressError::LatitudeOutOfRange(_) => "latitude_out_of_range",
IngressError::LongitudeOutOfRange(_) => "longitude_out_of_range",
IngressError::Publish(_) => "publish",
}
}
pub fn is_data_fault(&self) -> bool {
!matches!(self, IngressError::Publish(_))
}
}
pub fn validate(payload: &Payload) -> Result<(), IngressError> {
if payload.vehicle_id.0 == 0 {
return Err(IngressError::ZeroVehicle);
}
let longitude = payload.point.x();
let latitude = payload.point.y();
if !longitude.is_finite() || !latitude.is_finite() {
return Err(IngressError::NonFinite);
}
if !(-LATITUDE_LIMIT..=LATITUDE_LIMIT).contains(&latitude) {
return Err(IngressError::LatitudeOutOfRange(latitude));
}
if !(-LONGITUDE_LIMIT..=LONGITUDE_LIMIT).contains(&longitude) {
return Err(IngressError::LongitudeOutOfRange(longitude));
}
Ok(())
}
pub fn msg_id(payload: &Payload) -> String {
format!(
"{}:{}",
payload.vehicle_id,
payload.timestamp.timestamp_micros()
)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PublishAck {
pub sequence: u64,
pub duplicate: bool,
}
#[derive(Clone)]
pub struct Ingress {
js: jetstream::Context,
run: Option<String>,
publish_timeout: Duration,
attempts: u32,
}
impl Ingress {
pub fn live(js: jetstream::Context) -> Self {
Self {
js,
run: None,
publish_timeout: DEFAULT_PUBLISH_TIMEOUT,
attempts: DEFAULT_ATTEMPTS,
}
}
pub fn isolated(js: jetstream::Context, run: &str) -> Result<Self, IdError> {
if run.is_empty() {
return Err(IdError::Empty);
}
if !token_safe(run) {
return Err(IdError::UnsafeToken(run.to_owned()));
}
Ok(Self {
js,
run: Some(run.to_owned()),
publish_timeout: DEFAULT_PUBLISH_TIMEOUT,
attempts: DEFAULT_ATTEMPTS,
})
}
pub fn with_publish_timeout(mut self, timeout: Duration) -> Self {
self.publish_timeout = timeout;
self
}
pub fn with_attempts(mut self, attempts: u32) -> Self {
self.attempts = attempts.max(1);
self
}
pub fn subject(&self, partition: u64) -> String {
prefixed_raw_subject(self.run.as_deref(), partition)
}
pub async fn ensure_streams(&self, streams: u64, cfg: &RawConfig) -> anyhow::Result<()> {
match &self.run {
None => {
for index in 0..streams {
ensure_raw_stream(&self.js, index, streams, cfg).await?;
}
}
Some(run) => {
for index in 0..streams {
self.ensure_isolated_stream(run, index, streams, cfg)
.await?;
}
}
}
Ok(())
}
async fn ensure_isolated_stream(
&self,
run: &str,
index: u64,
streams: u64,
cfg: &RawConfig,
) -> anyhow::Result<jetstream::stream::Stream> {
let chunk = PARTITIONS.div_ceil(streams);
let partitions = (index * chunk)..(((index + 1) * chunk).min(PARTITIONS));
let subjects = partitions
.map(|partition| self.subject(partition))
.collect();
self.js
.get_or_create_stream(Config {
name: format!("REPLAY-{run}-RAW-{index}"),
subjects,
retention: RetentionPolicy::Limits,
storage: StorageType::File,
max_age: cfg.max_age,
discard: DiscardPolicy::Old,
duplicate_window: duplicate_window(cfg.max_age),
..Default::default()
})
.await
.map_err(|error| anyhow!("could not create replay stream {run}/{index}: {error}"))
}
pub async fn publish(
&self,
payload: &Payload,
received_at: SystemTime,
) -> Result<PublishAck, IngressError> {
validate(payload)?;
let subject = self.subject(partition::partition_of(payload.vehicle_id));
let id = msg_id(payload);
let bytes = payload.encode().map_err(IngressError::Publish)?;
let mut headers = bus::outbound();
headers::stamp_schema(&mut headers);
headers::stamp_received_at(&mut headers, received_at);
headers::stamp_msg_id(&mut headers, &id);
let mut backoff = INITIAL_BACKOFF;
let mut last: Option<anyhow::Error> = None;
for attempt in 0..self.attempts.max(1) {
if attempt > 0 {
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(MAX_BACKOFF);
}
match self.send(&subject, headers.clone(), bytes.clone()).await {
Ok(ack) => return Ok(ack),
Err(error) => last = Some(error),
}
}
Err(IngressError::Publish(
last.unwrap_or_else(|| anyhow!("no publish attempt was made")),
))
}
async fn send(
&self,
subject: &str,
headers: HeaderMap,
bytes: Vec<u8>,
) -> anyhow::Result<PublishAck> {
let timeout = self.publish_timeout;
let ack = within_publish_timeout(timeout, async {
let pending = self
.js
.publish_with_headers(subject.to_owned(), headers, bytes.into())
.await
.map_err(|error| anyhow!("publish send failed: {error}"))?;
pending
.into_future()
.await
.map_err(|error| anyhow!("publish acknowledgement failed: {error}"))
})
.await?;
Ok(PublishAck {
sequence: ack.sequence,
duplicate: ack.duplicate,
})
}
}
async fn within_publish_timeout<T>(
timeout: Duration,
operation: impl Future<Output = anyhow::Result<T>>,
) -> anyhow::Result<T> {
tokio::time::timeout(timeout, operation)
.await
.map_err(|_| anyhow!("publish send or acknowledgement timed out after {timeout:?}"))?
}
fn prefixed_raw_subject(run: Option<&str>, partition: u64) -> String {
match run {
Some(run) => format!("replay.{run}.{}", raw_subject(partition)),
None => raw_subject(partition),
}
}
#[cfg(test)]
mod tests {
use chrono::{DateTime, TimeDelta, TimeZone, Utc};
use geo::Point;
use super::*;
use crate::event::VehicleId;
fn now() -> DateTime<Utc> {
Utc.timestamp_micros(1_775_000_000_000_000).unwrap()
}
fn payload(vehicle: u64, lon: f64, lat: f64, ts: DateTime<Utc>) -> Payload {
Payload {
vehicle_id: VehicleId(vehicle),
timestamp: ts,
point: Point::new(lon, lat),
}
}
fn valid() -> Payload {
payload(42, 151.2093, -33.8688, now())
}
#[test]
fn validation_table() {
let cases: [(&str, Payload, Option<&str>); 6] = [
("valid", valid(), None),
(
"zero vehicle",
payload(0, 151.2093, -33.8688, now()),
Some("zero_vehicle"),
),
(
"nan latitude",
payload(42, 151.2093, f64::NAN, now()),
Some("non_finite"),
),
(
"infinite longitude",
payload(42, f64::INFINITY, -33.8688, now()),
Some("non_finite"),
),
(
"latitude too high",
payload(42, 151.2093, 90.5, now()),
Some("latitude_out_of_range"),
),
(
"longitude too low",
payload(42, -180.5, -33.8688, now()),
Some("longitude_out_of_range"),
),
];
for (label, event, expected) in cases {
let result = validate(&event);
match expected {
None => assert!(result.is_ok(), "{label}: expected accept, got {result:?}"),
Some(kind) => {
let error = result.expect_err(&format!("{label}: expected rejection"));
assert_eq!(error.kind(), kind, "{label}");
}
}
}
}
#[test]
fn validation_carries_the_offending_value() {
match validate(&payload(42, 151.2093, 90.5, now())) {
Err(IngressError::LatitudeOutOfRange(lat)) => assert_eq!(lat, 90.5),
other => panic!("expected LatitudeOutOfRange, got {other:?}"),
}
match validate(&payload(42, -181.0, -33.0, now())) {
Err(IngressError::LongitudeOutOfRange(lon)) => assert_eq!(lon, -181.0),
other => panic!("expected LongitudeOutOfRange, got {other:?}"),
}
}
#[test]
fn validation_accepts_arbitrary_wall_clock_age_and_skew() {
assert!(validate(&payload(42, 151.0, -33.0, now() - TimeDelta::days(10_000),)).is_ok());
assert!(validate(&payload(42, 151.0, -33.0, now() + TimeDelta::days(10_000),)).is_ok());
}
#[test]
fn is_data_fault_separates_bad_rows_from_broker_failures() {
assert!(IngressError::ZeroVehicle.is_data_fault());
assert!(!IngressError::Publish(anyhow!("broker down")).is_data_fault());
}
#[test]
fn msg_id_is_vehicle_and_micros() {
let event = payload(
42,
151.0,
-33.0,
Utc.timestamp_micros(1_775_000_000_123_456).unwrap(),
);
assert_eq!(msg_id(&event), "42:1775000000123456");
}
#[test]
fn msg_id_distinguishes_observations_but_not_retries() {
let event = valid();
assert_eq!(msg_id(&event), msg_id(&valid()));
let later = payload(42, 151.2093, -33.8688, now() + TimeDelta::seconds(1));
assert_ne!(msg_id(&event), msg_id(&later));
}
#[tokio::test(start_paused = true)]
async fn publish_timeout_covers_a_stalled_send() {
let task = tokio::spawn(within_publish_timeout(
Duration::from_secs(1),
core::future::pending::<anyhow::Result<()>>(),
));
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(1)).await;
assert!(task.await.expect("timeout task did not panic").is_err());
}
#[test]
fn subjects_are_bare_when_live() {
for partition in [0u64, 1, 511, PARTITIONS - 1] {
assert_eq!(
prefixed_raw_subject(None, partition),
raw_subject(partition),
"live subject must not be prefixed",
);
}
}
#[test]
fn subjects_are_prefixed_when_isolated() {
assert_eq!(
prefixed_raw_subject(Some("run-7"), 485),
"replay.run-7.events.raw.p.485",
);
assert_eq!(
crate::topology::partition_of_subject(&prefixed_raw_subject(Some("run-7"), 485)),
Some(485),
);
}
#[test]
fn isolated_rejects_unsafe_run_tokens() {
assert!(token_safe("run-7"));
assert!(token_safe("backfill_2026"));
for bad in ["", "run 7", "run.7", "run*", "run>", "réplay"] {
assert!(!token_safe(bad), "{bad:?} should be rejected");
}
}
}