use core::time::Duration;
use thiserror::Error;
use routers_network::Entry;
use crate::bus::Wire;
use crate::bus::adapter::{AckHandle, PublishError, PublishOutcome, Publisher};
use crate::bus::outbound;
use crate::protocol::ids::headers::stamp_schema;
use crate::protocol::result::SolveResult;
use crate::topology::results;
const BACKOFF_CAP: Duration = Duration::from_secs(2);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublishConfig {
pub attempts: u32,
pub backoff: Duration,
pub nak_delay: Duration,
}
impl Default for PublishConfig {
fn default() -> Self {
Self {
attempts: 5,
backoff: Duration::from_millis(100),
nak_delay: Duration::from_secs(5),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Published {
pub attempts: u32,
pub duplicate: bool,
pub bytes: usize,
}
#[derive(Debug, Error)]
pub enum PublishFailure {
#[error("result publish exhausted after {attempts} ambiguous attempt(s)")]
Exhausted {
attempts: u32,
},
#[error("result publish failed")]
Failed(#[source] anyhow::Error),
#[error("result published but the job acknowledgement failed")]
AckFailed(#[source] anyhow::Error),
}
#[derive(Clone, Debug)]
pub struct ResultPublisher<P> {
publisher: P,
cfg: PublishConfig,
}
impl<P> ResultPublisher<P> {
#[must_use]
pub fn new(publisher: P, cfg: PublishConfig) -> Self {
Self { publisher, cfg }
}
pub async fn publish_then_ack<E, H>(
&self,
result: &SolveResult<E>,
job: H,
) -> Result<Published, PublishFailure>
where
E: Entry + serde::de::DeserializeOwned,
H: AckHandle,
P: Publisher<SolveResult<E>>,
{
let subject = results::result_subject(u64::from(result.partition()));
let msg_id = result.msg_id();
let mut headers = outbound();
stamp_schema(&mut headers);
let bytes = match result.encode() {
Ok(bytes) => bytes,
Err(error) => {
let _ = job.nak(Some(self.cfg.nak_delay)).await;
return Err(PublishFailure::Failed(error));
}
};
let mut backoff = self.cfg.backoff;
let mut attempt: u32 = 0;
loop {
attempt += 1;
match self
.publisher
.publish_bytes(&subject, &msg_id, headers.clone(), &bytes)
.await
{
Ok(PublishOutcome::Acked { duplicate, .. }) => {
return match job.ack().await {
Ok(()) => Ok(Published {
attempts: attempt,
duplicate,
bytes: bytes.len(),
}),
Err(error) => Err(PublishFailure::AckFailed(error)),
};
}
Err(PublishError::Ambiguous(_)) if attempt < self.cfg.attempts => {
tokio::time::sleep(backoff).await;
backoff = backoff.saturating_mul(2).min(BACKOFF_CAP);
}
Err(PublishError::Ambiguous(_)) => {
let _ = job.nak(Some(self.cfg.nak_delay)).await;
return Err(PublishFailure::Exhausted { attempts: attempt });
}
Err(PublishError::Failed(error)) => {
let _ = job.nak(Some(self.cfg.nak_delay)).await;
return Err(PublishFailure::Failed(error));
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::{PublishConfig, PublishFailure, ResultPublisher};
use alloc::sync::Arc;
use core::time::Duration;
use std::sync::Mutex;
use async_nats::HeaderMap;
use routers_network::mock::MockEntryId;
use routers_transition::matcher::Continuation;
use crate::bus::adapter::{AckHandle, PublishError, Publisher, Source};
use crate::bus::memory::{MemoryAck, MemoryBus};
use crate::event::VehicleId;
use crate::protocol::ids::{GraphVersion, Lane, ObservationId, RegionId, SCHEMA_VERSION};
use crate::protocol::job::{JobIdentity, SolveJob};
use crate::protocol::result::{SolveOutcome, SolveResult};
use crate::topology::results;
const JOB_SUBJECT: &str = "solve.jobs.test";
const RESULTS: &str = "solve-result.v1.p.>";
fn sample_result(vehicle: u64) -> SolveResult<MockEntryId> {
let identity = JobIdentity {
schema: SCHEMA_VERSION,
vehicle_id: VehicleId(vehicle),
observation: ObservationId {
partition: 3,
sequence: 99,
},
base: None,
graph: GraphVersion::new("europe-2026-09").unwrap(),
region: RegionId::new("paris").unwrap(),
};
let job = SolveJob::new(
identity,
Lane::DEFAULT,
1_726_000_000_000_000,
Continuation::Restart { fresh: Vec::new() },
);
SolveResult::new(&job, SolveOutcome::Unanchored, 1_726_000_000_500_000)
}
fn fast_config() -> PublishConfig {
PublishConfig {
attempts: 5,
backoff: Duration::from_millis(1),
nak_delay: Duration::from_millis(50),
}
}
async fn job_handle(bus: &MemoryBus) -> MemoryAck {
let publisher = bus.publisher::<SolveResult<MockEntryId>>();
let mut source = bus.source::<SolveResult<MockEntryId>>(JOB_SUBJECT);
publisher
.publish(JOB_SUBJECT, "job-1", HeaderMap::new(), &sample_result(1))
.await
.expect("publish stand-in job");
source
.next()
.await
.expect("a delivery")
.expect("it decodes")
.handle
}
struct FailingAck {
naks: Arc<Mutex<Vec<Option<Duration>>>>,
}
impl AckHandle for FailingAck {
async fn ack(self) -> anyhow::Result<()> {
Err(anyhow::anyhow!("ack rejected"))
}
async fn nak(self, delay: Option<Duration>) -> anyhow::Result<()> {
self.naks.lock().unwrap().push(delay);
Ok(())
}
fn sequence(&self) -> u64 {
0
}
fn deliveries(&self) -> u32 {
1
}
}
#[tokio::test]
async fn success_publishes_result_and_acks_job_once() {
let bus = MemoryBus::new();
let handle = job_handle(&bus).await;
let result = sample_result(0x1234_5678);
let publisher =
ResultPublisher::new(bus.publisher::<SolveResult<MockEntryId>>(), fast_config());
let published = publisher
.publish_then_ack(&result, handle)
.await
.expect("first publish lands");
assert_eq!(published.attempts, 1);
assert!(!published.duplicate);
assert!(published.bytes > 0);
let stored = bus.published(RESULTS);
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].1.as_deref(), Some(result.msg_id().as_str()));
assert_eq!(
stored[0].0,
results::result_subject(u64::from(result.partition()))
);
assert_eq!(bus.acked_count(JOB_SUBJECT), 1);
assert!(bus.nak_delays().is_empty());
}
#[tokio::test]
async fn ambiguous_then_success_dedups_and_acks() {
let bus = MemoryBus::new();
let handle = job_handle(&bus).await;
let result = sample_result(0xdead_beef);
let publisher =
ResultPublisher::new(bus.publisher::<SolveResult<MockEntryId>>(), fast_config());
bus.fail_next_publish(PublishError::Ambiguous(anyhow::anyhow!("ack lost")));
let published = publisher
.publish_then_ack(&result, handle)
.await
.expect("retry lands");
assert_eq!(published.attempts, 2);
assert!(
published.duplicate,
"the retry deduplicated against the stored copy"
);
assert_eq!(bus.published(RESULTS).len(), 1);
assert_eq!(bus.acked_count(JOB_SUBJECT), 1);
assert!(bus.nak_delays().is_empty());
}
#[tokio::test]
async fn failed_publish_naks_job_without_acking() {
let bus = MemoryBus::new();
let handle = job_handle(&bus).await;
let result = sample_result(7);
let cfg = fast_config();
let nak_delay = cfg.nak_delay;
let publisher = ResultPublisher::new(bus.publisher::<SolveResult<MockEntryId>>(), cfg);
bus.fail_next_publish(PublishError::Failed(anyhow::anyhow!("refused")));
let failure = publisher
.publish_then_ack(&result, handle)
.await
.expect_err("a failed publish surfaces");
assert!(
matches!(failure, PublishFailure::Failed(_)),
"got {failure:?}"
);
assert!(bus.published(RESULTS).is_empty());
assert_eq!(bus.acked_count(JOB_SUBJECT), 0);
assert_eq!(bus.nak_delays(), vec![Some(nak_delay)]);
}
#[tokio::test]
async fn exhausted_after_one_ambiguous_attempt_naks() {
let bus = MemoryBus::new();
let handle = job_handle(&bus).await;
let result = sample_result(99);
let cfg = PublishConfig {
attempts: 1,
backoff: Duration::from_millis(1),
nak_delay: Duration::from_millis(50),
};
let nak_delay = cfg.nak_delay;
let publisher = ResultPublisher::new(bus.publisher::<SolveResult<MockEntryId>>(), cfg);
bus.fail_next_publish(PublishError::Ambiguous(anyhow::anyhow!("timeout")));
let failure = publisher
.publish_then_ack(&result, handle)
.await
.expect_err("the single attempt exhausts");
match failure {
PublishFailure::Exhausted { attempts } => assert_eq!(attempts, 1),
other => panic!("expected Exhausted, got {other:?}"),
}
assert_eq!(bus.acked_count(JOB_SUBJECT), 0);
assert_eq!(bus.nak_delays(), vec![Some(nak_delay)]);
}
#[tokio::test]
async fn ack_failure_after_publish_reports_ack_failed_without_naking() {
let bus = MemoryBus::new();
let naks = Arc::new(Mutex::new(Vec::new()));
let handle = FailingAck { naks: naks.clone() };
let result = sample_result(3);
let publisher =
ResultPublisher::new(bus.publisher::<SolveResult<MockEntryId>>(), fast_config());
let failure = publisher
.publish_then_ack(&result, handle)
.await
.expect_err("the job ack fails");
assert!(
matches!(failure, PublishFailure::AckFailed(_)),
"got {failure:?}"
);
assert_eq!(bus.published(RESULTS).len(), 1);
assert!(naks.lock().unwrap().is_empty());
}
}