pub mod adapter;
pub mod ops_diagnostics;
pub mod ops_profiler;
pub mod s3_hooks;
pub mod sidecar;
pub mod sidecar_protocol;
pub mod tls;
use crate::Target;
use crate::arn::TargetID;
use crate::plugin::PluginEvent;
use crate::store::{Key, Store, ensure_store_entry_raw_readable};
use crate::target::QueuedPayload;
use crate::target::TargetDeliverySnapshot;
use crate::target::{TargetHealth, TargetHealthReason, TargetHealthState};
use crate::{StoreError, TargetError};
use futures_util::stream::{FuturesUnordered, StreamExt};
use std::sync::Arc;
use std::{collections::HashMap, fmt::Debug};
use std::{future::Future, pin::Pin, time::Duration};
use tokio::sync::{Semaphore, mpsc};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
fn join_failure_reason(error: &tokio::task::JoinError) -> &'static str {
if error.is_cancelled() {
"join_cancelled"
} else {
"join_panicked"
}
}
pub(crate) const REPLAY_MAX_RETRIES: usize = 5;
pub(crate) const REPLAY_BASE_RETRY_DELAY: Duration = Duration::from_secs(2);
pub(crate) fn replay_backoff_term(shift: u32) -> Duration {
REPLAY_BASE_RETRY_DELAY.saturating_mul(1u32 << shift)
}
pub(crate) fn inter_attempt_backoff_sum(attempts: usize) -> Duration {
let mut total = Duration::ZERO;
for shift in 1..attempts as u32 {
total = total.saturating_add(replay_backoff_term(shift));
}
total
}
pub type SharedTarget<E> = Arc<dyn Target<E> + Send + Sync>;
type ReplayHook<E> = Arc<dyn Fn(ReplayEvent<E>) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
const HEALTH_COLLECTION_TIMEOUT: Duration = Duration::from_secs(5);
const HEALTH_PROBE_CONCURRENCY: usize = 10;
pub(crate) enum PrepareTargetResult<E>
where
E: PluginEvent,
{
Ready(SharedTarget<E>),
Degraded {
error: TargetError,
target: SharedTarget<E>,
},
Failed {
error: TargetError,
target: Box<dyn Target<E> + Send + Sync>,
},
Cancelled(Box<dyn Target<E> + Send + Sync>),
}
struct ReplayWorkerHandle {
cancel_tx: mpsc::Sender<()>,
join: Option<JoinHandle<()>>,
}
#[derive(Default)]
pub struct ReplayWorkerManager {
cancellers: HashMap<String, ReplayWorkerHandle>,
}
impl Debug for ReplayWorkerManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ReplayWorkerManager")
.field("worker_count", &self.cancellers.len())
.finish()
}
}
impl ReplayWorkerManager {
pub fn new() -> Self {
Self {
cancellers: HashMap::new(),
}
}
pub fn insert(&mut self, target_id: String, cancel_tx: mpsc::Sender<()>) {
self.cancellers
.insert(target_id, ReplayWorkerHandle { cancel_tx, join: None });
}
pub fn insert_with_handle(&mut self, target_id: String, cancel_tx: mpsc::Sender<()>, join: JoinHandle<()>) {
self.cancellers.insert(
target_id,
ReplayWorkerHandle {
cancel_tx,
join: Some(join),
},
);
}
pub fn len(&self) -> usize {
self.cancellers.len()
}
pub fn is_empty(&self) -> bool {
self.cancellers.is_empty()
}
pub fn snapshot(&self, target_count: usize) -> RuntimeStatusSnapshot {
RuntimeStatusSnapshot {
replay_worker_count: self.len(),
target_count,
}
}
pub async fn stop_all(&mut self, log_prefix: &str) {
let handles: Vec<(String, ReplayWorkerHandle)> = self.cancellers.drain().collect();
let mut joins = std::collections::VecDeque::new();
for (target_id, handle) in handles {
tracing::info!(target_id = %target_id, "{log_prefix}");
let _ = handle.cancel_tx.try_send(());
if let Some(join) = handle.join {
joins.push_back((target_id, join));
} else {
tracing::warn!(
target_id = %target_id,
"Replay worker has no join handle; cancellation was signalled but exit cannot be verified"
);
}
}
while let Some((target_id, join)) = joins.pop_front() {
if let Err(err) = join.await {
tracing::warn!(target_id = %target_id, reason = join_failure_reason(&err), "Replay worker terminated abnormally");
}
}
}
}
pub struct RuntimeActivation<E>
where
E: PluginEvent,
{
pub replay_workers: ReplayWorkerManager,
pub targets: Vec<SharedTarget<E>>,
}
pub struct OpenedActivation<E>
where
E: PluginEvent,
{
pub(crate) targets: Vec<SharedTarget<E>>,
}
struct TargetActivationFailure {
detail: String,
}
pub struct PreparedActivation<E>
where
E: PluginEvent,
{
failures: Vec<TargetActivationFailure>,
rejected_targets: Vec<SharedTarget<E>>,
pub(crate) targets: Vec<SharedTarget<E>>,
}
impl<E> PreparedActivation<E>
where
E: PluginEvent,
{
pub fn failure_summary(&self) -> Option<String> {
if self.failures.is_empty() {
return None;
}
Some(
self.failures
.iter()
.map(|failure| failure.detail.clone())
.collect::<Vec<_>>()
.join("; "),
)
}
pub fn extend_creation_failures(&mut self, failures: impl IntoIterator<Item = String>) {
self.failures
.extend(failures.into_iter().map(|detail| TargetActivationFailure { detail }));
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RuntimeStatusSnapshot {
pub replay_worker_count: usize,
pub target_count: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RuntimeTargetSnapshot {
pub failed_messages: u64,
pub failed_store_length: u64,
pub queue_length: u64,
pub target_id: String,
pub target_type: String,
pub total_messages: u64,
}
pub type RuntimeTargetHealthState = TargetHealthState;
pub type RuntimeTargetHealthReason = TargetHealthReason;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeTargetHealthSnapshot {
pub account_id: String,
pub enabled: bool,
pub error_message: Option<String>,
pub state: RuntimeTargetHealthState,
pub reason: RuntimeTargetHealthReason,
pub target_id: String,
pub target_type: String,
}
pub enum ReplayEvent<E>
where
E: PluginEvent,
{
Delivered {
key: Key,
target: SharedTarget<E>,
},
RetryableError {
error: TargetError,
key: Key,
retry_count: usize,
target: SharedTarget<E>,
},
Dropped {
key: Key,
reason: String,
target: SharedTarget<E>,
},
PermanentFailure {
error: TargetError,
key: Key,
target: SharedTarget<E>,
},
RetryExhausted {
detail: String,
key: Key,
target: SharedTarget<E>,
},
UnreadableEntry {
error: StoreError,
key: Key,
target: SharedTarget<E>,
},
}
pub struct TargetRuntimeManager<E>
where
E: PluginEvent,
{
targets: HashMap<String, SharedTarget<E>>,
}
impl<E> Default for TargetRuntimeManager<E>
where
E: PluginEvent,
{
fn default() -> Self {
Self::new()
}
}
impl<E> Debug for TargetRuntimeManager<E>
where
E: PluginEvent,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TargetRuntimeManager")
.field("target_count", &self.targets.len())
.finish()
}
}
impl<E> TargetRuntimeManager<E>
where
E: PluginEvent,
{
pub fn new() -> Self {
Self { targets: HashMap::new() }
}
pub fn add_arc(&mut self, target: SharedTarget<E>) -> Option<SharedTarget<E>> {
let key = target.id().to_string();
self.targets.insert(key, target)
}
pub fn add_boxed(&mut self, target: Box<dyn Target<E> + Send + Sync>) -> Option<SharedTarget<E>> {
self.add_arc(Arc::from(target))
}
pub fn get(&self, key: &str) -> Option<SharedTarget<E>> {
self.targets.get(key).cloned()
}
pub fn get_by_target_id(&self, target_id: &TargetID) -> Option<SharedTarget<E>> {
self.get(&target_id.to_string())
}
pub fn remove(&mut self, key: &str) -> Option<SharedTarget<E>> {
self.targets.remove(key)
}
pub fn remove_by_target_id(&mut self, target_id: &TargetID) -> Option<SharedTarget<E>> {
self.remove(&target_id.to_string())
}
pub fn clear(&mut self) {
self.targets.clear();
}
pub async fn remove_and_close(&mut self, key: &str) -> Option<SharedTarget<E>> {
let target = self.targets.remove(key)?;
if let Err(err) = target.close().await {
tracing::error!(target_id = %key, error = %err, "Failed to close target during removal");
}
Some(target)
}
pub async fn remove_by_target_id_and_close(&mut self, target_id: &TargetID) -> Option<SharedTarget<E>> {
self.remove_and_close(&target_id.to_string()).await
}
pub async fn clear_and_close(&mut self) -> Vec<(String, TargetError)> {
let targets = std::mem::take(&mut self.targets);
let mut closes = FuturesUnordered::new();
for (target_id, target) in targets {
closes.push(async move { (target_id, target.close().await) });
}
let mut errors = Vec::new();
while let Some((target_id, result)) = closes.next().await {
if let Err(err) = result {
tracing::error!(target_id = %target_id, error = %err, "Failed to close target during shutdown");
errors.push((target_id, err));
}
}
errors
}
pub fn target_ids(&self) -> Vec<TargetID> {
self.targets.values().map(|target| target.id()).collect()
}
pub fn keys(&self) -> Vec<String> {
self.targets.keys().cloned().collect()
}
pub fn values(&self) -> Vec<SharedTarget<E>> {
self.targets.values().cloned().collect()
}
pub fn len(&self) -> usize {
self.targets.len()
}
pub fn is_empty(&self) -> bool {
self.targets.is_empty()
}
pub fn snapshots(&self) -> Vec<RuntimeTargetSnapshot> {
let mut snapshots = Vec::with_capacity(self.targets.len());
for target in self.targets.values() {
let delivery = target.delivery_snapshot();
let target_id = target.id();
snapshots.push(snapshot_from_delivery(target_id, delivery));
}
snapshots.sort_by(|a, b| a.target_id.cmp(&b.target_id));
snapshots
}
pub fn status_snapshot(&self, replay_workers: &ReplayWorkerManager) -> RuntimeStatusSnapshot {
replay_workers.snapshot(self.len())
}
pub async fn health_snapshots(&self) -> Vec<RuntimeTargetHealthSnapshot> {
health_snapshots_for_targets(self.values()).await
}
}
pub async fn health_snapshots_for_targets<E>(targets: Vec<SharedTarget<E>>) -> Vec<RuntimeTargetHealthSnapshot>
where
E: PluginEvent,
{
let deadline = tokio::time::Instant::now() + HEALTH_COLLECTION_TIMEOUT;
let permits = Arc::new(Semaphore::new(HEALTH_PROBE_CONCURRENCY));
let mut probes = FuturesUnordered::new();
for target in targets {
let permits = Arc::clone(&permits);
let enabled = target.is_enabled();
let target_id = target.id();
probes.push(async move {
let health = if !enabled {
TargetHealth::disabled()
} else if let Ok(Ok(_permit)) = tokio::time::timeout_at(deadline, permits.acquire_owned()).await {
if tokio::time::Instant::now() >= deadline {
TargetHealth::error(TargetHealthReason::HealthCheckFailed)
} else {
match tokio::time::timeout_at(deadline, target.health()).await {
Ok(health) => health,
Err(_) => TargetHealth::error(TargetHealthReason::TimedOut),
}
}
} else {
TargetHealth::error(TargetHealthReason::HealthCheckFailed)
};
(enabled, target_id, health)
});
}
let mut snapshots = Vec::with_capacity(probes.len());
while let Some((enabled, target_id, health)) = probes.next().await {
snapshots.push(RuntimeTargetHealthSnapshot {
account_id: target_id.id.clone(),
enabled,
error_message: (health.state == TargetHealthState::Error).then(|| health.reason.as_str().to_string()),
state: health.state,
reason: health.reason,
target_id: target_id.to_string(),
target_type: target_id.name,
});
}
snapshots.sort_by(|a, b| a.target_id.cmp(&b.target_id));
snapshots
}
fn snapshot_from_delivery(target_id: TargetID, delivery: TargetDeliverySnapshot) -> RuntimeTargetSnapshot {
RuntimeTargetSnapshot {
failed_messages: delivery.failed_messages,
failed_store_length: delivery.failed_store_length,
queue_length: delivery.queue_length,
target_id: target_id.to_string(),
target_type: target_id.name,
total_messages: delivery.total_messages,
}
}
#[hotpath::measure]
pub async fn init_target_and_optionally_start_replay<E, F, G>(
target: Box<dyn Target<E> + Send + Sync>,
on_replay_start: F,
start_replay: G,
) -> Option<(SharedTarget<E>, Option<(mpsc::Sender<()>, JoinHandle<()>)>)>
where
E: PluginEvent,
F: FnOnce(&str, bool),
G: FnOnce(
Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send>,
SharedTarget<E>,
) -> (mpsc::Sender<()>, JoinHandle<()>),
{
let shared = match prepare_target(target, None).await {
PrepareTargetResult::Ready(target) => target,
PrepareTargetResult::Degraded { target, .. } => target,
PrepareTargetResult::Failed { target, .. } => {
let _ = target.close().await;
return None;
}
PrepareTargetResult::Cancelled(_) => unreachable!("preparation without a cancellation token cannot be cancelled"),
};
let target_id = shared.id().to_string();
if !shared.is_enabled() {
on_replay_start(&target_id, false);
return Some((shared, None));
}
let cancel = shared
.store()
.map(|store| start_replay(store.boxed_clone(), Arc::clone(&shared)));
on_replay_start(&target_id, cancel.is_some());
Some((shared, cancel))
}
#[hotpath::measure]
pub(crate) async fn prepare_target<E>(
target: Box<dyn Target<E> + Send + Sync>,
cancellation: Option<&CancellationToken>,
) -> PrepareTargetResult<E>
where
E: PluginEvent,
{
let target_id = target.id().to_string();
let has_store = target.store().is_some();
let init_result = match cancellation {
Some(cancellation) => {
tokio::select! {
biased;
_ = cancellation.cancelled() => return PrepareTargetResult::Cancelled(target),
result = target.init() => result,
}
}
None => target.init().await,
};
if let Err(err) = init_result {
tracing::error!(target_id = %target_id, reason = "initialization_failed", "Failed to initialize target");
if !has_store {
return PrepareTargetResult::Failed { error: err, target };
}
tracing::warn!(
target_id = %target_id,
"Proceeding with store-backed target despite init failure"
);
return PrepareTargetResult::Degraded {
error: err,
target: Arc::from(target),
};
}
PrepareTargetResult::Ready(Arc::from(target))
}
type ActivatedTarget<E> = (SharedTarget<E>, Option<(mpsc::Sender<()>, JoinHandle<()>)>);
#[hotpath::measure]
pub async fn activate_targets_with_replay<E, F, Fut>(
targets: Vec<Box<dyn Target<E> + Send + Sync>>,
mut activate_one: F,
) -> RuntimeActivation<E>
where
E: PluginEvent,
F: FnMut(Box<dyn Target<E> + Send + Sync>) -> Fut,
Fut: Future<Output = Option<ActivatedTarget<E>>>,
{
let mut replay_workers = ReplayWorkerManager::new();
let mut shared_targets = Vec::new();
for target in targets {
if let Some((shared_target, replay)) = activate_one(target).await {
let target_id = shared_target.id().to_string();
if let Some((cancel_tx, join)) = replay {
replay_workers.insert_with_handle(target_id, cancel_tx, join);
}
shared_targets.push(shared_target);
}
}
RuntimeActivation {
replay_workers,
targets: shared_targets,
}
}
pub fn start_replay_worker<E>(
mut store: Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send>,
target: SharedTarget<E>,
hook: ReplayHook<E>,
semaphore: Option<Arc<Semaphore>>,
batch_timeout: Duration,
idle_sleep: Duration,
) -> (mpsc::Sender<()>, JoinHandle<()>)
where
E: PluginEvent,
{
let (cancel_tx, cancel_rx) = mpsc::channel(1);
let join = tokio::spawn(async move {
stream_replay_worker(&mut *store, target, cancel_rx, hook, semaphore, batch_timeout, idle_sleep).await;
});
(cancel_tx, join)
}
const REPLAY_BATCH_SIZE: usize = 16;
async fn sleep_or_cancelled(dur: Duration, cancel_rx: &mut mpsc::Receiver<()>) -> bool {
tokio::select! {
biased;
_ = cancel_rx.recv() => true,
_ = tokio::time::sleep(dur) => false,
}
}
fn seed_interval_start(now: tokio::time::Instant, interval: Duration) -> tokio::time::Instant {
now.checked_sub(interval).unwrap_or(now)
}
#[hotpath::measure]
async fn stream_replay_worker<E>(
store: &mut (dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send),
target: SharedTarget<E>,
mut cancel_rx: mpsc::Receiver<()>,
hook: ReplayHook<E>,
semaphore: Option<Arc<Semaphore>>,
batch_timeout: Duration,
idle_sleep: Duration,
) where
E: PluginEvent,
{
const FAILED_STORE_PRUNE_INTERVAL: Duration = Duration::from_secs(60);
let mut batch_keys = Vec::with_capacity(REPLAY_BATCH_SIZE);
let mut last_flush = tokio::time::Instant::now();
let mut last_prune = seed_interval_start(tokio::time::Instant::now(), FAILED_STORE_PRUNE_INTERVAL);
loop {
if cancel_rx.try_recv().is_ok() {
return;
}
if let Some(failed_store) = target.failed_store()
&& last_prune.elapsed() >= FAILED_STORE_PRUNE_INTERVAL
{
let maintenance_failed = failed_store.boxed_clone_failed();
let outcome = tokio::task::spawn_blocking(move || maintenance_failed.prune_failed_store()).await;
match outcome {
Ok(Err(err)) => {
tracing::warn!(target_id = %target.id(), error = %err, "Failed to prune the failed-events store");
}
Ok(Ok(_)) => {}
Err(join_err) => {
tracing::warn!(
target_id = %target.id(),
reason = join_failure_reason(&join_err),
"The failed-events maintenance task failed to join"
);
}
}
last_prune = tokio::time::Instant::now();
}
let keys = store.list();
if keys.is_empty() {
if !batch_keys.is_empty() && last_flush.elapsed() >= batch_timeout {
if process_replay_batch(&*store, &mut batch_keys, target.clone(), &hook, semaphore.clone(), &mut cancel_rx).await
{
return;
}
last_flush = tokio::time::Instant::now();
}
if sleep_or_cancelled(idle_sleep, &mut cancel_rx).await {
return;
}
continue;
}
for key in keys {
if cancel_rx.try_recv().is_ok() {
if !batch_keys.is_empty() {
process_replay_batch(&*store, &mut batch_keys, target.clone(), &hook, semaphore.clone(), &mut cancel_rx)
.await;
}
return;
}
match ensure_store_entry_raw_readable(&*store, &key) {
Ok(true) => {}
Ok(false) => continue,
Err(err) => {
hook(ReplayEvent::UnreadableEntry {
error: err,
key,
target: target.clone(),
})
.await;
continue;
}
}
if batch_keys
.iter()
.any(|pending: &Key| pending.to_key_string() == key.to_key_string())
{
continue;
}
batch_keys.push(key);
if batch_keys.len() >= REPLAY_BATCH_SIZE || last_flush.elapsed() >= batch_timeout {
if process_replay_batch(&*store, &mut batch_keys, target.clone(), &hook, semaphore.clone(), &mut cancel_rx).await
{
return;
}
last_flush = tokio::time::Instant::now();
}
}
if !batch_keys.is_empty() && last_flush.elapsed() >= batch_timeout {
if process_replay_batch(&*store, &mut batch_keys, target.clone(), &hook, semaphore.clone(), &mut cancel_rx).await {
return;
}
last_flush = tokio::time::Instant::now();
}
if sleep_or_cancelled(Duration::from_millis(100), &mut cancel_rx).await {
return;
}
}
}
#[hotpath::measure]
async fn process_replay_batch<E>(
store: &(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send),
batch_keys: &mut Vec<Key>,
target: SharedTarget<E>,
hook: &ReplayHook<E>,
semaphore: Option<Arc<Semaphore>>,
cancel_rx: &mut mpsc::Receiver<()>,
) -> bool
where
E: PluginEvent,
{
const MAX_RETRIES: usize = REPLAY_MAX_RETRIES;
if batch_keys.is_empty() {
return false;
}
let mut cancelled = false;
'keys: for key in batch_keys.iter() {
let mut retry_count = 0usize;
let mut success = false;
let mut last_retryable_detail = String::new();
while retry_count < MAX_RETRIES && !success {
let permit = match &semaphore {
None => None,
Some(sem) => match sem.clone().acquire_owned().await {
Ok(permit) => Some(permit),
Err(err) => {
tracing::error!(error = %err, "Failed to acquire replay semaphore permit");
batch_keys.clear();
return cancelled;
}
},
};
let result = target.send_from_store(key.clone()).await;
drop(permit);
match result {
Ok(_) => {
hook(ReplayEvent::Delivered {
key: key.clone(),
target: target.clone(),
})
.await;
success = true;
}
Err(err) => match err {
TargetError::NotConnected
| TargetError::Timeout(_)
| TargetError::JetStreamPublish { retryable: true, .. } => {
retry_count += 1;
last_retryable_detail = err.to_string();
hook(ReplayEvent::RetryableError {
error: err,
key: key.clone(),
retry_count,
target: target.clone(),
})
.await;
if retry_count < MAX_RETRIES && replay_backoff_sleep(key, retry_count, cancel_rx).await {
cancelled = true;
break 'keys;
}
}
TargetError::JetStreamPublish { retryable: false, .. } => {
if move_failed_entry(store, &target, key, &err, retry_count as u32).await {
hook(ReplayEvent::PermanentFailure {
error: err,
key: key.clone(),
target: target.clone(),
})
.await;
}
break;
}
TargetError::Dropped(reason) => {
hook(ReplayEvent::Dropped {
key: key.clone(),
reason,
target: target.clone(),
})
.await;
break;
}
other => {
hook(ReplayEvent::PermanentFailure {
error: other,
key: key.clone(),
target: target.clone(),
})
.await;
break;
}
},
}
}
if retry_count >= MAX_RETRIES && !success {
hook(ReplayEvent::RetryExhausted {
detail: last_retryable_detail,
key: key.clone(),
target: target.clone(),
})
.await;
}
}
batch_keys.clear();
cancelled
}
async fn replay_backoff_sleep(key: &Key, retry_count: usize, cancel_rx: &mut mpsc::Receiver<()>) -> bool {
let jitter = Duration::from_millis(key.to_string().len() as u64 % 500);
sleep_or_cancelled(replay_backoff_term(retry_count as u32) + jitter, cancel_rx).await
}
async fn move_failed_entry<E>(
store: &(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send),
target: &SharedTarget<E>,
key: &Key,
error: &TargetError,
retry_count: u32,
) -> bool
where
E: PluginEvent,
{
target.handle_terminal_failure(store, key, error, retry_count).await
}
#[cfg(test)]
mod tests {
use super::{HEALTH_PROBE_CONCURRENCY, TargetRuntimeManager, health_snapshots_for_targets};
use crate::SharedTarget;
use crate::store::Key;
use crate::testkit::MockTarget;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;
#[tokio::test(start_paused = true)]
async fn seed_interval_start_backdates_by_one_interval() {
let origin = tokio::time::Instant::now();
tokio::time::advance(std::time::Duration::from_secs(60)).await;
let now = tokio::time::Instant::now();
assert_eq!(super::seed_interval_start(now, std::time::Duration::from_secs(60)), origin);
}
#[test]
fn seed_interval_start_falls_back_to_now_when_the_clock_is_too_young() {
let now = tokio::time::Instant::now();
assert_eq!(super::seed_interval_start(now, std::time::Duration::MAX), now);
}
#[test]
fn inter_attempt_backoff_sum_matches_the_realized_sleep_schedule() {
let mut realized = std::time::Duration::ZERO;
for shift in 1..super::REPLAY_MAX_RETRIES as u32 {
realized += super::replay_backoff_term(shift);
}
assert_eq!(super::inter_attempt_backoff_sum(super::REPLAY_MAX_RETRIES), realized);
assert_eq!(realized, std::time::Duration::from_secs(60));
}
#[tokio::test(start_paused = true)]
async fn replay_backoff_sleep_sleeps_the_term_for_the_retry_plus_key_jitter() {
let key = Key {
name: "0198c0de-0000-7000-8000-000000000000".to_string(),
extension: ".event".to_string(),
item_count: 1,
compress: false,
};
let jitter = std::time::Duration::from_millis(key.to_string().len() as u64 % 500);
let retry_count = 3usize;
let (_cancel_tx, mut cancel_rx) = tokio::sync::mpsc::channel::<()>(1);
let start = tokio::time::Instant::now();
let cancelled = super::replay_backoff_sleep(&key, retry_count, &mut cancel_rx).await;
assert!(!cancelled, "no cancel signal was sent");
assert_eq!(
start.elapsed(),
super::replay_backoff_term(retry_count as u32) + jitter,
"the sleep is the term for this retry count plus the key-derived jitter"
);
}
#[tokio::test]
async fn runtime_manager_removes_and_closes_target() {
let mut manager = TargetRuntimeManager::<String>::new();
let target = MockTarget::new("primary", "webhook");
let observer = target.clone();
manager.add_boxed(Box::new(target));
assert_eq!(manager.len(), 1);
let removed = manager.remove_and_close("primary:webhook").await;
assert!(removed.is_some());
assert_eq!(manager.len(), 0);
assert_eq!(observer.close_call_count(), 1);
}
#[tokio::test(start_paused = true)]
async fn runtime_manager_starts_all_target_closes_before_waiting_for_completion() {
let mut manager = TargetRuntimeManager::<String>::new();
let first = MockTarget::new("first", "webhook");
let second = MockTarget::new("second", "webhook");
let first_observer = first.clone();
let second_observer = second.clone();
manager.add_boxed(Box::new(first));
manager.add_boxed(Box::new(second));
let first_close_key = manager
.keys()
.into_iter()
.next()
.expect("two targets should have a first close key");
let (blocked, unblocked) = if first_close_key == first_observer.target_id().to_string() {
(first_observer, second_observer)
} else {
(second_observer, first_observer)
};
blocked.set_block_on_close(true);
let close_task = tokio::spawn(async move { manager.clear_and_close().await });
tokio::time::timeout(std::time::Duration::from_secs(1), blocked.close_started().notified())
.await
.expect("the first target close should start");
tokio::time::timeout(std::time::Duration::from_secs(1), unblocked.close_started().notified())
.await
.expect("a blocked first close must not prevent the second close from starting");
assert!(!close_task.is_finished(), "clear_and_close must still await the blocked target");
blocked.close_gate().add_permits(1);
let errors = close_task.await.expect("clear_and_close task should join");
assert!(errors.is_empty());
assert_eq!(blocked.close_call_count(), 1);
assert_eq!(unblocked.close_call_count(), 1);
}
#[test]
fn runtime_manager_snapshots_targets() {
let mut manager = TargetRuntimeManager::<String>::new();
manager.add_boxed(Box::new(MockTarget::new("primary", "webhook")));
let snapshots = manager.snapshots();
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].target_id, "primary:webhook");
assert_eq!(snapshots[0].target_type, "webhook");
}
#[tokio::test(start_paused = true)]
async fn health_snapshot_allows_a_four_second_probe() {
let mut manager = TargetRuntimeManager::<String>::new();
manager.add_boxed(Box::new(MockTarget::new("slow", "webhook").with_health_delay(Duration::from_secs(4))));
let snapshots = manager.health_snapshots().await;
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].state, crate::TargetHealthState::Online);
assert_eq!(snapshots[0].reason, crate::TargetHealthReason::Reachable);
}
#[tokio::test(start_paused = true)]
async fn health_snapshot_times_out_after_five_seconds() {
let mut manager = TargetRuntimeManager::<String>::new();
manager.add_boxed(Box::new(MockTarget::new("stalled", "webhook").with_health_delay(Duration::from_secs(6))));
let snapshots = manager.health_snapshots().await;
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].state, crate::TargetHealthState::Error);
assert_eq!(snapshots[0].reason, crate::TargetHealthReason::TimedOut);
}
#[tokio::test(start_paused = true)]
async fn health_collection_deadline_does_not_scale_with_target_count() {
let mut manager = TargetRuntimeManager::<String>::new();
for index in 0..24 {
manager.add_boxed(Box::new(
MockTarget::new(&format!("stalled-{index}"), "webhook").with_health_delay(Duration::from_secs(30)),
));
}
let started = tokio::time::Instant::now();
let snapshots = manager.health_snapshots().await;
assert_eq!(started.elapsed(), Duration::from_secs(5));
assert_eq!(snapshots.len(), 24);
assert!(
snapshots
.iter()
.all(|snapshot| snapshot.state == crate::TargetHealthState::Error)
);
assert_eq!(
snapshots
.iter()
.filter(|snapshot| snapshot.reason == crate::TargetHealthReason::TimedOut)
.count(),
HEALTH_PROBE_CONCURRENCY
);
assert_eq!(
snapshots
.iter()
.filter(|snapshot| snapshot.reason == crate::TargetHealthReason::HealthCheckFailed)
.count(),
24 - HEALTH_PROBE_CONCURRENCY
);
}
#[tokio::test(start_paused = true)]
async fn disabled_target_does_not_wait_for_probe_capacity() {
let mut targets: Vec<SharedTarget<String>> = (0..HEALTH_PROBE_CONCURRENCY)
.map(|index| {
Arc::new(MockTarget::new(&format!("stalled-{index}"), "webhook").with_health_delay(Duration::from_secs(30)))
as SharedTarget<String>
})
.collect();
targets.push(Arc::new(MockTarget::new("disabled", "webhook").disabled()));
let snapshots = health_snapshots_for_targets(targets).await;
let disabled = snapshots
.iter()
.find(|snapshot| snapshot.target_id == "disabled:webhook")
.expect("disabled target snapshot");
assert_eq!(disabled.state, crate::TargetHealthState::Disabled);
assert_eq!(disabled.reason, crate::TargetHealthReason::Disabled);
}
#[tokio::test]
async fn cancelling_health_collection_drops_in_flight_probe() {
let target = MockTarget::new("slow", "webhook").with_health_delay(Duration::from_secs(30));
let observer = target.clone();
let targets: Vec<SharedTarget<String>> = vec![Arc::new(target)];
let collector = tokio::spawn(health_snapshots_for_targets(targets));
tokio::time::timeout(Duration::from_secs(1), observer.health_started().notified())
.await
.expect("health probe should start");
collector.abort();
let join_error = collector.await.expect_err("health collector should be cancelled");
assert!(join_error.is_cancelled());
assert_eq!(observer.health_drop_count(), 1);
}
#[tokio::test]
async fn sleep_or_cancelled_returns_immediately_on_cancel() {
let (cancel_tx, mut cancel_rx) = tokio::sync::mpsc::channel::<()>(1);
cancel_tx.send(()).await.unwrap();
let start = std::time::Instant::now();
let cancelled = super::sleep_or_cancelled(std::time::Duration::from_secs(30), &mut cancel_rx).await;
assert!(cancelled);
assert!(
start.elapsed() < std::time::Duration::from_secs(5),
"cancel should not wait for the full sleep"
);
}
#[tokio::test]
async fn sleep_or_cancelled_returns_false_when_not_cancelled() {
let (_cancel_tx, mut cancel_rx) = tokio::sync::mpsc::channel::<()>(1);
let cancelled = super::sleep_or_cancelled(std::time::Duration::from_millis(10), &mut cancel_rx).await;
assert!(!cancelled);
}
#[tokio::test]
async fn stop_all_joins_and_awaits_worker_exit() {
use super::ReplayWorkerManager;
use std::sync::atomic::AtomicBool;
let mut manager = ReplayWorkerManager::new();
let exited = Arc::new(AtomicBool::new(false));
let (cancel_tx, mut cancel_rx) = tokio::sync::mpsc::channel::<()>(1);
let exited_task = Arc::clone(&exited);
let join = tokio::spawn(async move {
loop {
if super::sleep_or_cancelled(std::time::Duration::from_millis(50), &mut cancel_rx).await {
break;
}
}
exited_task.store(true, Ordering::SeqCst);
});
manager.insert_with_handle("primary:webhook".to_string(), cancel_tx, join);
assert_eq!(manager.len(), 1);
manager.stop_all("stopping test worker").await;
assert!(manager.is_empty());
assert!(exited.load(Ordering::SeqCst), "stop_all must await the worker to completion");
}
#[tokio::test(start_paused = true)]
async fn stop_all_does_not_abort_delivery_awaiting_acknowledgement() {
use super::ReplayWorkerManager;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::Notify;
let mut manager = ReplayWorkerManager::new();
let acknowledgement = Arc::new(Notify::new());
let worker_started = Arc::new(Notify::new());
let exited = Arc::new(AtomicBool::new(false));
let (cancel_tx, mut cancel_rx) = tokio::sync::mpsc::channel::<()>(1);
let worker_acknowledgement = acknowledgement.clone();
let worker_started_signal = worker_started.clone();
let worker_exited = exited.clone();
let join = tokio::spawn(async move {
worker_started_signal.notify_one();
let _ = cancel_rx.recv().await;
worker_acknowledgement.notified().await;
worker_exited.store(true, Ordering::SeqCst);
});
manager.insert_with_handle("primary:webhook".to_string(), cancel_tx, join);
worker_started.notified().await;
let mut stop = Box::pin(manager.stop_all("stopping ack-pending test worker"));
tokio::select! {
biased;
_ = &mut stop => panic!("stop_all returned before the pending acknowledgement"),
_ = std::future::ready(()) => {}
}
tokio::time::advance(std::time::Duration::from_secs(60)).await;
tokio::select! {
biased;
_ = &mut stop => panic!("stop_all aborted an acknowledgement-pending delivery"),
_ = std::future::ready(()) => {}
}
acknowledgement.notify_one();
stop.await;
assert!(exited.load(Ordering::SeqCst));
assert!(manager.is_empty());
}
mod classifier {
use super::super::{ReplayEvent, stream_replay_worker};
use crate::arn::TargetID;
use crate::plugin::PluginEvent;
use crate::store::{FailedEventStore, Key, QueueStore, Store};
use crate::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
use crate::{StoreError, Target, TargetError};
use async_trait::async_trait;
use rustfs_s3_types::EventName;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::{Semaphore, mpsc};
#[derive(Default)]
struct ReplayTally {
delivered: AtomicUsize,
retryable: AtomicUsize,
dropped: AtomicUsize,
permanent_failure: AtomicUsize,
retry_exhausted: AtomicUsize,
unreadable: AtomicUsize,
}
#[derive(Clone)]
struct ProgrammedTarget {
id: TargetID,
error: Option<Arc<TargetError>>,
send_calls: Arc<AtomicUsize>,
in_flight: Arc<AtomicUsize>,
max_in_flight: Arc<AtomicUsize>,
send_gate: Arc<Semaphore>,
store: QueueStore<QueuedPayload>,
failed_handle: Arc<dyn FailedEventStore>,
}
impl ProgrammedTarget {
fn new(error: Option<TargetError>, store: QueueStore<QueuedPayload>) -> Self {
let failed_handle: Arc<dyn FailedEventStore> = Arc::new(store.clone());
Self::new_with_failed_store(error, store, failed_handle)
}
fn new_with_failed_store(
error: Option<TargetError>,
store: QueueStore<QueuedPayload>,
failed_handle: Arc<dyn FailedEventStore>,
) -> Self {
let send_gate = Arc::new(Semaphore::new(Semaphore::MAX_PERMITS));
Self {
id: TargetID::new("target-a".to_string(), "nats".to_string()),
error: error.map(Arc::new),
send_calls: Arc::new(AtomicUsize::new(0)),
in_flight: Arc::new(AtomicUsize::new(0)),
max_in_flight: Arc::new(AtomicUsize::new(0)),
send_gate,
store,
failed_handle,
}
}
}
fn move_terminal_entry_for_test(
store: &(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send),
failed_store: &dyn FailedEventStore,
key: &Key,
error: &TargetError,
retry_count: u32,
) -> bool {
let raw = match store.get_raw(key) {
Ok(raw) => raw,
Err(StoreError::NotFound) => return true,
Err(_) => return false,
};
let Ok(queued) = QueuedPayload::decode(&raw) else {
return false;
};
let resolved = crate::target::nats::resolve_dedup_id(&queued.meta.dedup_id, key);
let Ok(encoded) = crate::target::encode_failed_entry(
queued,
crate::target::FailedErrorClass::Terminal,
error,
retry_count,
&resolved,
) else {
return false;
};
if failed_store.put_failed_raw(&key.name, &encoded).is_err() {
return false;
}
matches!(store.del(key), Ok(()) | Err(StoreError::NotFound))
}
#[async_trait]
impl<E> Target<E> for ProgrammedTarget
where
E: PluginEvent,
{
fn id(&self) -> TargetID {
self.id.clone()
}
async fn is_active(&self) -> Result<bool, TargetError> {
Ok(true)
}
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
Ok(())
}
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
Ok(())
}
async fn send_from_store(&self, key: Key) -> Result<(), TargetError> {
self.send_calls.fetch_add(1, Ordering::SeqCst);
let live = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
self.max_in_flight.fetch_max(live, Ordering::SeqCst);
let permit = self.send_gate.clone().acquire_owned().await;
drop(permit);
self.in_flight.fetch_sub(1, Ordering::SeqCst);
match self.error.as_deref() {
Some(TargetError::Dropped(reason)) => {
let _ = self.store.del(&key);
Err(TargetError::Dropped(reason.clone()))
}
Some(error) => Err(clone_error(error)),
None => {
let _ = self.store.del(&key);
Ok(())
}
}
}
async fn close(&self) -> Result<(), TargetError> {
Ok(())
}
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
None
}
async fn handle_terminal_failure(
&self,
store: &(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send),
key: &Key,
error: &TargetError,
retry_count: u32,
) -> bool {
move_terminal_entry_for_test(store, self.failed_handle.as_ref(), key, error, retry_count)
}
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
Box::new(self.clone())
}
fn is_enabled(&self) -> bool {
true
}
}
#[derive(Clone)]
struct FailingFailedStore {
inner: QueueStore<QueuedPayload>,
fail_put_failed_raw: Arc<std::sync::atomic::AtomicBool>,
fail_del: Arc<std::sync::atomic::AtomicBool>,
}
impl Store<QueuedPayload> for FailingFailedStore {
type Error = StoreError;
type Key = Key;
fn open(&self) -> Result<(), Self::Error> {
self.inner.open()
}
fn put(&self, item: Arc<QueuedPayload>) -> Result<Self::Key, Self::Error> {
self.inner.put(item)
}
fn put_multiple(&self, items: Vec<QueuedPayload>) -> Result<Self::Key, Self::Error> {
self.inner.put_multiple(items)
}
fn put_raw(&self, data: &[u8]) -> Result<Self::Key, Self::Error> {
self.inner.put_raw(data)
}
fn get(&self, key: &Self::Key) -> Result<QueuedPayload, Self::Error> {
self.inner.get(key)
}
fn get_multiple(&self, key: &Self::Key) -> Result<Vec<QueuedPayload>, Self::Error> {
self.inner.get_multiple(key)
}
fn get_raw(&self, key: &Self::Key) -> Result<Vec<u8>, Self::Error> {
self.inner.get_raw(key)
}
fn del(&self, key: &Self::Key) -> Result<(), Self::Error> {
if self.fail_del.load(Ordering::SeqCst) {
return Err(StoreError::Internal("injected del failure".to_string()));
}
self.inner.del(key)
}
fn delete(&self) -> Result<(), Self::Error> {
self.inner.delete()
}
fn list(&self) -> Vec<Self::Key> {
self.inner.list()
}
fn len(&self) -> usize {
self.inner.len()
}
fn is_empty(&self) -> bool {
self.inner.is_empty()
}
fn boxed_clone(&self) -> Box<dyn Store<QueuedPayload, Error = Self::Error, Key = Self::Key> + Send + Sync> {
Box::new(self.clone())
}
}
impl FailedEventStore for FailingFailedStore {
fn put_failed_raw(&self, entry_name: &str, data: &[u8]) -> Result<String, StoreError> {
if self.fail_put_failed_raw.load(Ordering::SeqCst) {
return Err(StoreError::Internal("injected put_failed_raw failure".to_string()));
}
self.inner.put_failed_raw(entry_name, data)
}
fn prune_failed_store(&self) -> Result<usize, StoreError> {
self.inner.prune_failed_store()
}
fn failed_len(&self) -> usize {
self.inner.failed_len()
}
fn boxed_clone_failed(&self) -> Box<dyn FailedEventStore> {
Box::new(self.clone())
}
}
fn clone_error(error: &TargetError) -> TargetError {
match error {
TargetError::NotConnected => TargetError::NotConnected,
TargetError::Timeout(value) => TargetError::Timeout(value.clone()),
TargetError::Dropped(value) => TargetError::Dropped(value.clone()),
TargetError::JetStreamPublish { retryable, detail } => TargetError::JetStreamPublish {
retryable: *retryable,
detail: detail.clone(),
},
TargetError::Network(value) => TargetError::Network(value.clone()),
other => TargetError::Unknown(other.to_string()),
}
}
fn temp_dir(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("rustfs-runtime-{name}-{}", uuid::Uuid::new_v4()))
}
fn seed_entry(store: &QueueStore<QueuedPayload>, dedup_id: &str) -> Key {
let mut meta = QueuedPayloadMeta::new(
EventName::ObjectCreatedPut,
"bucket-a".to_string(),
"obj.txt".to_string(),
"application/json",
7,
);
meta.dedup_id = dedup_id.to_string();
let payload = QueuedPayload::new(meta, br#"{"x":1}"#.to_vec());
store.put_raw(&payload.encode().unwrap()).unwrap()
}
async fn run_worker(store: &mut QueueStore<QueuedPayload>, target: Arc<ProgrammedTarget>, tally: Arc<ReplayTally>) {
run_worker_until(store, target, tally, |_| false).await;
}
async fn run_worker_until(
store: &mut QueueStore<QueuedPayload>,
target: Arc<ProgrammedTarget>,
tally: Arc<ReplayTally>,
stop: impl Fn(&ReplayTally) -> bool,
) {
let shared: Arc<dyn Target<String> + Send + Sync> = target.clone();
let (cancel_tx, cancel_rx) = mpsc::channel(1);
let hook = {
let tally = tally.clone();
Arc::new(move |event: ReplayEvent<String>| {
let tally = tally.clone();
Box::pin(async move {
match event {
ReplayEvent::Delivered { .. } => tally.delivered.fetch_add(1, Ordering::SeqCst),
ReplayEvent::RetryableError { .. } => tally.retryable.fetch_add(1, Ordering::SeqCst),
ReplayEvent::Dropped { .. } => tally.dropped.fetch_add(1, Ordering::SeqCst),
ReplayEvent::PermanentFailure { .. } => tally.permanent_failure.fetch_add(1, Ordering::SeqCst),
ReplayEvent::RetryExhausted { .. } => tally.retry_exhausted.fetch_add(1, Ordering::SeqCst),
ReplayEvent::UnreadableEntry { .. } => tally.unreadable.fetch_add(1, Ordering::SeqCst),
};
}) as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
}) as super::super::ReplayHook<String>
};
let semaphore = Some(Arc::new(Semaphore::new(1)));
let mut worker_store = store.clone();
let worker = tokio::spawn(async move {
stream_replay_worker(
&mut worker_store,
shared,
cancel_rx,
hook,
semaphore,
Duration::from_millis(10),
Duration::from_millis(10),
)
.await;
});
for _ in 0..600 {
if stop(&tally) {
break;
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
let _ = cancel_tx.send(()).await;
let _ = worker.await;
}
#[tokio::test(start_paused = true)]
async fn retryable_publish_error_retries_and_keeps_the_entry_live() {
for detail in ["timed out", "broken pipe", "max ack pending", "stream not found"] {
let dir = temp_dir(&format!("retryable-{}", detail.replace(' ', "-")));
let mut store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
store.open().unwrap();
seed_entry(&store, "minted-id");
let target = Arc::new(ProgrammedTarget::new(
Some(TargetError::JetStreamPublish {
retryable: true,
detail: detail.to_string(),
}),
store.clone(),
));
let tally = Arc::new(ReplayTally::default());
run_worker_until(&mut store, target.clone(), tally.clone(), |t| {
t.retry_exhausted.load(Ordering::SeqCst) >= 1
})
.await;
assert!(tally.retryable.load(Ordering::SeqCst) >= 1, "{detail} retries at least once");
assert_eq!(tally.retry_exhausted.load(Ordering::SeqCst), 1, "{detail} exhausts the bound once");
assert_eq!(store.len(), 1, "{detail} keeps the entry on the live queue");
assert_eq!(store.failed_len(), 0, "{detail} writes no failed entry");
assert!(!dir.join("failed").exists(), "{detail} creates no failed directory");
let _ = store.delete();
}
}
#[tokio::test(start_paused = true)]
async fn terminal_publish_error_writes_terminal_entry_without_retry() {
for detail in [
"max payload exceeded",
"wrong last message id",
"wrong last sequence",
"stream sealed",
] {
let dir = temp_dir(&format!("terminal-{}", detail.replace(' ', "-")));
let mut store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
store.open().unwrap();
seed_entry(&store, "minted-id");
let target = Arc::new(ProgrammedTarget::new(
Some(TargetError::JetStreamPublish {
retryable: false,
detail: detail.to_string(),
}),
store.clone(),
));
let tally = Arc::new(ReplayTally::default());
run_worker(&mut store, target.clone(), tally.clone()).await;
assert_eq!(tally.permanent_failure.load(Ordering::SeqCst), 1, "{detail} fails permanently once");
assert_eq!(tally.retryable.load(Ordering::SeqCst), 0, "{detail} does not retry");
assert_eq!(tally.retry_exhausted.load(Ordering::SeqCst), 0, "{detail} does not reach exhaustion");
assert_eq!(store.len(), 0, "{detail} clears the live entry");
assert_eq!(store.failed_len(), 1, "{detail} writes one terminal failed entry");
let failed_dir = dir.join("failed");
let failed_file = std::fs::read_dir(&failed_dir).unwrap().next().unwrap().unwrap().path();
let decoded = QueuedPayload::decode(&std::fs::read(&failed_file).unwrap()).unwrap();
let failure = decoded.meta.failure.unwrap();
assert_eq!(failure.error_class, crate::target::FailedErrorClass::Terminal);
assert_eq!(failure.nats_msg_id, "minted-id");
assert_eq!(decoded.meta.bucket_name, "bucket-a");
let _ = store.delete();
}
}
#[tokio::test(start_paused = true)]
async fn no_replay_path_silently_drops_an_event() {
let dir = temp_dir("nodrop-dropped");
let mut store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
store.open().unwrap();
seed_entry(&store, "id");
let target = Arc::new(ProgrammedTarget::new(
Some(TargetError::Dropped("invalid entry".to_string())),
store.clone(),
));
let tally = Arc::new(ReplayTally::default());
run_worker(&mut store, target.clone(), tally.clone()).await;
assert_eq!(tally.dropped.load(Ordering::SeqCst), 1, "a dropped entry is reported, not silent");
assert_eq!(store.failed_len(), 0, "a dropped invalid entry writes no failed entry");
let _ = store.delete();
}
#[tokio::test(start_paused = true)]
async fn audit_event_type_runs_the_identical_classifier_and_failed_store_logic() {
let dir = temp_dir("audit-parity");
let mut store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
store.open().unwrap();
seed_entry(&store, "audit-id");
let shared: Arc<dyn Target<u64> + Send + Sync> = Arc::new(ProgrammedTarget::new(
Some(TargetError::JetStreamPublish {
retryable: false,
detail: "max payload exceeded".to_string(),
}),
store.clone(),
));
let (cancel_tx, cancel_rx) = mpsc::channel(1);
let hook = Arc::new(move |_event: ReplayEvent<u64>| {
Box::pin(async move {}) as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
}) as super::super::ReplayHook<u64>;
let semaphore = Some(Arc::new(Semaphore::new(1)));
let worker = stream_replay_worker(
&mut store,
shared,
cancel_rx,
hook,
semaphore,
Duration::from_millis(10),
Duration::from_millis(10),
);
let driver = async move {
tokio::time::sleep(Duration::from_secs(5)).await;
let _ = cancel_tx.send(()).await;
};
tokio::join!(worker, driver);
assert_eq!(store.len(), 0, "the audit live entry is cleared after the move");
assert_eq!(store.failed_len(), 1, "the audit path writes a failed entry through the shared worker");
let _ = store.delete();
}
#[tokio::test]
async fn admission_permit_is_held_across_the_send_bounding_concurrent_sends() {
let shared_gate = Arc::new(Semaphore::new(Semaphore::MAX_PERMITS));
shared_gate.forget_permits(Semaphore::MAX_PERMITS);
let in_flight = Arc::new(AtomicUsize::new(0));
let max_in_flight = Arc::new(AtomicUsize::new(0));
let semaphore = Some(Arc::new(Semaphore::new(1)));
let mut stores = Vec::new();
let mut workers = Vec::new();
let mut cancels = Vec::new();
for index in 0..2 {
let dir = temp_dir(&format!("permit-{index}"));
let store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
store.open().unwrap();
seed_entry(&store, &format!("id-{index}"));
let mut target = ProgrammedTarget::new(
Some(TargetError::JetStreamPublish {
retryable: true,
detail: "timed out".to_string(),
}),
store.clone(),
);
target.send_gate = shared_gate.clone();
target.in_flight = in_flight.clone();
target.max_in_flight = max_in_flight.clone();
let shared: Arc<dyn Target<String> + Send + Sync> = Arc::new(target);
let (cancel_tx, cancel_rx) = mpsc::channel(1);
let hook = Arc::new(move |_event: ReplayEvent<String>| {
Box::pin(async move {}) as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
}) as super::super::ReplayHook<String>;
let semaphore = semaphore.clone();
let mut worker_store = store.clone();
let worker = tokio::spawn(async move {
stream_replay_worker(
&mut worker_store,
shared,
cancel_rx,
hook,
semaphore,
Duration::from_millis(10),
Duration::from_millis(10),
)
.await;
});
stores.push(store);
workers.push(worker);
cancels.push(cancel_tx);
}
for _ in 0..200 {
if in_flight.load(Ordering::SeqCst) >= 1 {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(
max_in_flight.load(Ordering::SeqCst),
1,
"the single permit is held across the send, so only one send is in flight at a time"
);
drop(cancels);
for worker in workers {
worker.abort();
let _ = worker.await;
}
for store in stores {
let _ = store.delete();
}
}
#[tokio::test(start_paused = true)]
async fn flag_off_path_creates_no_failed_directory() {
let dir = temp_dir("flag-off");
let mut store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
store.open().unwrap();
seed_entry(&store, "");
let target = Arc::new(ProgrammedTarget::new(Some(TargetError::NotConnected), store.clone()));
let tally = Arc::new(ReplayTally::default());
run_worker(&mut store, target.clone(), tally.clone()).await;
assert!(
tally.retry_exhausted.load(Ordering::SeqCst) >= 1,
"the connectivity error exhausts its retries"
);
assert!(!dir.join("failed").exists(), "no failed directory is created on the flag-off path");
assert_eq!(store.failed_len(), 0);
let _ = store.delete();
}
#[tokio::test(start_paused = true)]
async fn jetstream_connect_failure_retries_and_keeps_the_entry_live() {
let dir = temp_dir("connect-failure");
let mut store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
store.open().unwrap();
seed_entry(&store, "minted-id");
let target = Arc::new(ProgrammedTarget::new(Some(TargetError::NotConnected), store.clone()));
let tally = Arc::new(ReplayTally::default());
run_worker_until(&mut store, target.clone(), tally.clone(), |t| {
t.retry_exhausted.load(Ordering::SeqCst) >= 1
})
.await;
assert!(
tally.retryable.load(Ordering::SeqCst) >= 1,
"the connect failure retries within the bound"
);
assert_eq!(tally.retry_exhausted.load(Ordering::SeqCst), 1, "the exhaustion hook fires exactly once");
assert_eq!(store.len(), 1, "the entry stays live through the outage");
assert_eq!(store.failed_len(), 0, "an outage writes no failed entry");
assert!(!dir.join("failed").exists(), "no failed-store write occurs for a connect failure");
let _ = store.delete();
}
fn spawn_failing_store_worker(
store: &FailingFailedStore,
target: Arc<ProgrammedTarget>,
tally: Arc<ReplayTally>,
) -> (mpsc::Sender<()>, tokio::task::JoinHandle<()>) {
let shared: Arc<dyn Target<String> + Send + Sync> = target;
let (cancel_tx, cancel_rx) = mpsc::channel(1);
let hook = Arc::new(move |event: ReplayEvent<String>| {
let tally = tally.clone();
Box::pin(async move {
match event {
ReplayEvent::Delivered { .. } => tally.delivered.fetch_add(1, Ordering::SeqCst),
ReplayEvent::RetryableError { .. } => tally.retryable.fetch_add(1, Ordering::SeqCst),
ReplayEvent::Dropped { .. } => tally.dropped.fetch_add(1, Ordering::SeqCst),
ReplayEvent::PermanentFailure { .. } => tally.permanent_failure.fetch_add(1, Ordering::SeqCst),
ReplayEvent::RetryExhausted { .. } => tally.retry_exhausted.fetch_add(1, Ordering::SeqCst),
ReplayEvent::UnreadableEntry { .. } => tally.unreadable.fetch_add(1, Ordering::SeqCst),
};
}) as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
}) as super::super::ReplayHook<String>;
let semaphore = Some(Arc::new(Semaphore::new(1)));
let mut worker_store = store.clone();
let worker = tokio::spawn(async move {
stream_replay_worker(
&mut worker_store,
shared,
cancel_rx,
hook,
semaphore,
Duration::from_millis(10),
Duration::from_millis(10),
)
.await;
});
(cancel_tx, worker)
}
#[tokio::test(start_paused = true)]
async fn a_failed_terminal_move_emits_no_hook_and_heals_once_repaired() {
let dir = temp_dir("move-fail-terminal");
let inner = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
inner.open().unwrap();
seed_entry(&inner, "minted-id");
let fail_flag = Arc::new(std::sync::atomic::AtomicBool::new(true));
let store = FailingFailedStore {
inner: inner.clone(),
fail_put_failed_raw: fail_flag.clone(),
fail_del: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
let target = Arc::new(ProgrammedTarget::new_with_failed_store(
Some(TargetError::JetStreamPublish {
retryable: false,
detail: "max payload exceeded".to_string(),
}),
inner.clone(),
Arc::new(store.clone()),
));
let tally = Arc::new(ReplayTally::default());
let (cancel_tx, worker) = spawn_failing_store_worker(&store, target, tally.clone());
tokio::time::sleep(Duration::from_secs(300)).await;
assert_eq!(
tally.permanent_failure.load(Ordering::SeqCst),
0,
"no final-failure hook fires while the move keeps failing"
);
assert_eq!(inner.len(), 1, "the live entry stays for the next scan");
assert_eq!(inner.failed_len(), 0, "no failed entry landed");
fail_flag.store(false, Ordering::SeqCst);
tokio::time::sleep(Duration::from_secs(300)).await;
assert_eq!(
tally.permanent_failure.load(Ordering::SeqCst),
1,
"the hook fires exactly once for the completed move"
);
assert_eq!(inner.len(), 0, "the live entry is cleared by the completed move");
assert_eq!(inner.failed_len(), 1, "the failed entry landed once");
let _ = cancel_tx.send(()).await;
let _ = worker.await;
let _ = inner.delete();
}
#[tokio::test(start_paused = true)]
async fn a_move_with_a_failing_delete_reports_unhandled_and_keeps_the_entry() {
let dir = temp_dir("move-del-fail");
let inner = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
inner.open().unwrap();
seed_entry(&inner, "minted-id");
let store = FailingFailedStore {
inner: inner.clone(),
fail_put_failed_raw: Arc::new(std::sync::atomic::AtomicBool::new(false)),
fail_del: Arc::new(std::sync::atomic::AtomicBool::new(true)),
};
let target = Arc::new(ProgrammedTarget::new(
Some(TargetError::JetStreamPublish {
retryable: false,
detail: "max payload exceeded".to_string(),
}),
inner.clone(),
));
let tally = Arc::new(ReplayTally::default());
let (cancel_tx, worker) = spawn_failing_store_worker(&store, target, tally.clone());
tokio::time::sleep(Duration::from_secs(300)).await;
assert_eq!(
tally.permanent_failure.load(Ordering::SeqCst),
0,
"no final-failure hook fires while the delete fails"
);
assert_eq!(inner.len(), 1, "the live entry stays for the next scan");
assert_eq!(inner.failed_len(), 1, "the durable failed copy landed exactly once");
let _ = cancel_tx.send(()).await;
let _ = worker.await;
let _ = inner.delete();
}
#[tokio::test(start_paused = true)]
async fn exhaustion_reports_without_a_trailing_backoff() {
let dir = temp_dir("no-trailing-backoff");
let store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
store.open().unwrap();
seed_entry(&store, "minted-id");
let wrapper = FailingFailedStore {
inner: store.clone(),
fail_put_failed_raw: Arc::new(std::sync::atomic::AtomicBool::new(false)),
fail_del: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
let target = Arc::new(ProgrammedTarget::new(
Some(TargetError::JetStreamPublish {
retryable: true,
detail: "timed out".to_string(),
}),
store.clone(),
));
let tally = Arc::new(ReplayTally::default());
let start = tokio::time::Instant::now();
let (cancel_tx, worker) = spawn_failing_store_worker(&wrapper, target, tally.clone());
while tally.retry_exhausted.load(Ordering::SeqCst) == 0 {
assert!(start.elapsed() < Duration::from_secs(600), "the exhaustion never reported");
tokio::time::sleep(Duration::from_millis(100)).await;
}
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(100),
"the exhaustion reports after the inter-attempt backoffs only, got {elapsed:?}"
);
assert_eq!(store.len(), 1, "the entry stays on the live queue");
assert_eq!(store.failed_len(), 0, "a retryable exhaustion writes no failed entry");
let _ = cancel_tx.send(()).await;
let _ = worker.await;
let _ = store.delete();
}
#[tokio::test(start_paused = true)]
async fn core_network_error_is_not_diverted_to_the_failed_store() {
let dir = temp_dir("core-network");
let mut store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
store.open().unwrap();
seed_entry(&store, "");
let target = Arc::new(ProgrammedTarget::new(
Some(TargetError::Network("connection refused".to_string())),
store.clone(),
));
let tally = Arc::new(ReplayTally::default());
run_worker(&mut store, target.clone(), tally.clone()).await;
assert!(
tally.permanent_failure.load(Ordering::SeqCst) >= 1,
"a core Network error is reported permanent, not silently dropped"
);
assert_eq!(tally.retryable.load(Ordering::SeqCst), 0, "a core Network error is not retried");
assert_eq!(
tally.retry_exhausted.load(Ordering::SeqCst),
0,
"a core Network error does not reach exhaustion"
);
assert_eq!(store.failed_len(), 0, "a core Network error writes no failed entry");
assert_eq!(store.len(), 1, "a core Network error is left queued, its existing behaviour");
assert!(!dir.join("failed").exists(), "no failed directory is created for a core Network error");
let _ = store.delete();
}
#[tokio::test(start_paused = true)]
async fn only_a_non_retryable_error_produces_a_failed_store_entry() {
#[derive(Clone)]
struct ClassifyingTarget {
id: TargetID,
store: QueueStore<QueuedPayload>,
}
#[async_trait]
impl Target<String> for ClassifyingTarget {
fn id(&self) -> TargetID {
self.id.clone()
}
async fn is_active(&self) -> Result<bool, TargetError> {
Ok(true)
}
async fn save(&self, _event: Arc<EntityTarget<String>>) -> Result<(), TargetError> {
Ok(())
}
async fn send_raw_from_store(
&self,
_key: Key,
_body: Vec<u8>,
_meta: QueuedPayloadMeta,
) -> Result<(), TargetError> {
Ok(())
}
async fn send_from_store(&self, key: Key) -> Result<(), TargetError> {
let raw = self
.store
.get_raw(&key)
.map_err(|err| TargetError::Unknown(err.to_string()))?;
let decoded = QueuedPayload::decode(&raw).map_err(|err| TargetError::Unknown(err.to_string()))?;
if decoded.meta.dedup_id == "terminal-id" {
Err(TargetError::JetStreamPublish {
retryable: false,
detail: "max payload exceeded".to_string(),
})
} else {
Err(TargetError::JetStreamPublish {
retryable: true,
detail: "timed out".to_string(),
})
}
}
async fn close(&self) -> Result<(), TargetError> {
Ok(())
}
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
None
}
async fn handle_terminal_failure(
&self,
store: &(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send),
key: &Key,
error: &TargetError,
retry_count: u32,
) -> bool {
move_terminal_entry_for_test(store, &self.store, key, error, retry_count)
}
fn clone_dyn(&self) -> Box<dyn Target<String> + Send + Sync> {
Box::new(self.clone())
}
fn is_enabled(&self) -> bool {
true
}
}
let dir = temp_dir("terminals-only-invariant");
let store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
store.open().unwrap();
seed_entry(&store, "retryable-id");
seed_entry(&store, "terminal-id");
let shared: Arc<dyn Target<String> + Send + Sync> = Arc::new(ClassifyingTarget {
id: TargetID::new("target-a".to_string(), "nats".to_string()),
store: store.clone(),
});
let tally = Arc::new(ReplayTally::default());
let (cancel_tx, cancel_rx) = mpsc::channel(1);
let hook = {
let tally = tally.clone();
Arc::new(move |event: ReplayEvent<String>| {
let tally = tally.clone();
Box::pin(async move {
match event {
ReplayEvent::Delivered { .. } => tally.delivered.fetch_add(1, Ordering::SeqCst),
ReplayEvent::RetryableError { .. } => tally.retryable.fetch_add(1, Ordering::SeqCst),
ReplayEvent::Dropped { .. } => tally.dropped.fetch_add(1, Ordering::SeqCst),
ReplayEvent::PermanentFailure { .. } => tally.permanent_failure.fetch_add(1, Ordering::SeqCst),
ReplayEvent::RetryExhausted { .. } => tally.retry_exhausted.fetch_add(1, Ordering::SeqCst),
ReplayEvent::UnreadableEntry { .. } => tally.unreadable.fetch_add(1, Ordering::SeqCst),
};
}) as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
}) as super::super::ReplayHook<String>
};
let semaphore = Some(Arc::new(Semaphore::new(1)));
let mut worker_store = store.clone();
let worker = tokio::spawn(async move {
stream_replay_worker(
&mut worker_store,
shared,
cancel_rx,
hook,
semaphore,
Duration::from_millis(10),
Duration::from_millis(10),
)
.await;
});
for _ in 0..600 {
if store.failed_len() >= 1 && tally.retry_exhausted.load(Ordering::SeqCst) >= 1 {
break;
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
let _ = cancel_tx.send(()).await;
let _ = worker.await;
assert_eq!(store.failed_len(), 1, "the failed store holds exactly one entry");
assert_eq!(store.len(), 1, "the retryable entry stays on the live queue");
let failed_dir = dir.join("failed");
let failed_file = std::fs::read_dir(&failed_dir).unwrap().next().unwrap().unwrap().path();
let decoded = QueuedPayload::decode(&std::fs::read(&failed_file).unwrap()).unwrap();
let failure = decoded.meta.failure.unwrap();
assert_eq!(failure.error_class, crate::target::FailedErrorClass::Terminal);
assert_eq!(failure.nats_msg_id, "terminal-id");
let _ = store.delete();
}
}
}