1pub mod adapter;
16pub mod ops_diagnostics;
17pub mod ops_profiler;
18pub mod s3_hooks;
19pub mod sidecar;
20pub mod sidecar_protocol;
21pub mod tls;
22
23use crate::Target;
24use crate::arn::TargetID;
25use crate::plugin::PluginEvent;
26use crate::store::{Key, Store, ensure_store_entry_raw_readable};
27use crate::target::QueuedPayload;
28use crate::target::TargetDeliverySnapshot;
29use crate::target::{TargetHealth, TargetHealthReason, TargetHealthState};
30use crate::{StoreError, TargetError};
31use futures_util::stream::{FuturesUnordered, StreamExt};
32use std::sync::Arc;
33use std::{collections::HashMap, fmt::Debug};
34use std::{future::Future, pin::Pin, time::Duration};
35use tokio::sync::{Semaphore, mpsc};
36use tokio::task::JoinHandle;
37use tokio_util::sync::CancellationToken;
38
39fn join_failure_reason(error: &tokio::task::JoinError) -> &'static str {
40 if error.is_cancelled() {
41 "join_cancelled"
42 } else {
43 "join_panicked"
44 }
45}
46
47pub(crate) const REPLAY_MAX_RETRIES: usize = 5;
55
56pub(crate) const REPLAY_BASE_RETRY_DELAY: Duration = Duration::from_secs(2);
59
60pub(crate) fn replay_backoff_term(shift: u32) -> Duration {
63 REPLAY_BASE_RETRY_DELAY.saturating_mul(1u32 << shift)
64}
65
66pub(crate) fn inter_attempt_backoff_sum(attempts: usize) -> Duration {
71 let mut total = Duration::ZERO;
72 for shift in 1..attempts as u32 {
73 total = total.saturating_add(replay_backoff_term(shift));
74 }
75 total
76}
77
78pub type SharedTarget<E> = Arc<dyn Target<E> + Send + Sync>;
80type ReplayHook<E> = Arc<dyn Fn(ReplayEvent<E>) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
81
82const HEALTH_COLLECTION_TIMEOUT: Duration = Duration::from_secs(5);
83const HEALTH_PROBE_CONCURRENCY: usize = 10;
84
85pub(crate) enum PrepareTargetResult<E>
86where
87 E: PluginEvent,
88{
89 Ready(SharedTarget<E>),
90 Degraded {
91 error: TargetError,
92 target: SharedTarget<E>,
93 },
94 Failed {
95 error: TargetError,
96 target: Box<dyn Target<E> + Send + Sync>,
97 },
98 Cancelled(Box<dyn Target<E> + Send + Sync>),
99}
100
101struct ReplayWorkerHandle {
104 cancel_tx: mpsc::Sender<()>,
105 join: Option<JoinHandle<()>>,
106}
107
108#[derive(Default)]
109pub struct ReplayWorkerManager {
110 cancellers: HashMap<String, ReplayWorkerHandle>,
111}
112
113impl Debug for ReplayWorkerManager {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 f.debug_struct("ReplayWorkerManager")
116 .field("worker_count", &self.cancellers.len())
117 .finish()
118 }
119}
120
121impl ReplayWorkerManager {
122 pub fn new() -> Self {
123 Self {
124 cancellers: HashMap::new(),
125 }
126 }
127
128 pub fn insert(&mut self, target_id: String, cancel_tx: mpsc::Sender<()>) {
135 self.cancellers
136 .insert(target_id, ReplayWorkerHandle { cancel_tx, join: None });
137 }
138
139 pub fn insert_with_handle(&mut self, target_id: String, cancel_tx: mpsc::Sender<()>, join: JoinHandle<()>) {
142 self.cancellers.insert(
143 target_id,
144 ReplayWorkerHandle {
145 cancel_tx,
146 join: Some(join),
147 },
148 );
149 }
150
151 pub fn len(&self) -> usize {
152 self.cancellers.len()
153 }
154
155 pub fn is_empty(&self) -> bool {
156 self.cancellers.is_empty()
157 }
158
159 pub fn snapshot(&self, target_count: usize) -> RuntimeStatusSnapshot {
160 RuntimeStatusSnapshot {
161 replay_worker_count: self.len(),
162 target_count,
163 }
164 }
165
166 pub async fn stop_all(&mut self, log_prefix: &str) {
174 let handles: Vec<(String, ReplayWorkerHandle)> = self.cancellers.drain().collect();
175 let mut joins = std::collections::VecDeque::new();
176
177 for (target_id, handle) in handles {
179 tracing::info!(target_id = %target_id, "{log_prefix}");
180 let _ = handle.cancel_tx.try_send(());
181 if let Some(join) = handle.join {
182 joins.push_back((target_id, join));
183 } else {
184 tracing::warn!(
185 target_id = %target_id,
186 "Replay worker has no join handle; cancellation was signalled but exit cannot be verified"
187 );
188 }
189 }
190
191 while let Some((target_id, join)) = joins.pop_front() {
195 if let Err(err) = join.await {
196 tracing::warn!(target_id = %target_id, reason = join_failure_reason(&err), "Replay worker terminated abnormally");
197 }
198 }
199 }
200}
201
202pub struct RuntimeActivation<E>
203where
204 E: PluginEvent,
205{
206 pub replay_workers: ReplayWorkerManager,
207 pub targets: Vec<SharedTarget<E>>,
208}
209
210pub struct OpenedActivation<E>
213where
214 E: PluginEvent,
215{
216 pub(crate) targets: Vec<SharedTarget<E>>,
217}
218
219struct TargetActivationFailure {
220 detail: String,
221}
222
223pub struct PreparedActivation<E>
227where
228 E: PluginEvent,
229{
230 failures: Vec<TargetActivationFailure>,
231 rejected_targets: Vec<SharedTarget<E>>,
232 pub(crate) targets: Vec<SharedTarget<E>>,
233}
234
235impl<E> PreparedActivation<E>
236where
237 E: PluginEvent,
238{
239 pub fn failure_summary(&self) -> Option<String> {
240 if self.failures.is_empty() {
241 return None;
242 }
243
244 Some(
245 self.failures
246 .iter()
247 .map(|failure| failure.detail.clone())
248 .collect::<Vec<_>>()
249 .join("; "),
250 )
251 }
252
253 pub fn extend_creation_failures(&mut self, failures: impl IntoIterator<Item = String>) {
254 self.failures
255 .extend(failures.into_iter().map(|detail| TargetActivationFailure { detail }));
256 }
257}
258
259#[derive(Debug, Clone, Default, PartialEq, Eq)]
260pub struct RuntimeStatusSnapshot {
261 pub replay_worker_count: usize,
262 pub target_count: usize,
263}
264
265#[derive(Debug, Clone, Default, PartialEq, Eq)]
267pub struct RuntimeTargetSnapshot {
268 pub failed_messages: u64,
269 pub failed_store_length: u64,
270 pub queue_length: u64,
271 pub target_id: String,
272 pub target_type: String,
273 pub total_messages: u64,
274}
275
276pub type RuntimeTargetHealthState = TargetHealthState;
277pub type RuntimeTargetHealthReason = TargetHealthReason;
278
279#[derive(Debug, Clone, PartialEq, Eq)]
280pub struct RuntimeTargetHealthSnapshot {
281 pub account_id: String,
282 pub enabled: bool,
283 pub error_message: Option<String>,
284 pub state: RuntimeTargetHealthState,
285 pub reason: RuntimeTargetHealthReason,
286 pub target_id: String,
287 pub target_type: String,
288}
289
290pub enum ReplayEvent<E>
291where
292 E: PluginEvent,
293{
294 Delivered {
295 key: Key,
296 target: SharedTarget<E>,
297 },
298 RetryableError {
299 error: TargetError,
300 key: Key,
301 retry_count: usize,
302 target: SharedTarget<E>,
303 },
304 Dropped {
305 key: Key,
306 reason: String,
307 target: SharedTarget<E>,
308 },
309 PermanentFailure {
310 error: TargetError,
311 key: Key,
312 target: SharedTarget<E>,
313 },
314 RetryExhausted {
315 detail: String,
316 key: Key,
317 target: SharedTarget<E>,
318 },
319 UnreadableEntry {
320 error: StoreError,
321 key: Key,
322 target: SharedTarget<E>,
323 },
324}
325
326pub struct TargetRuntimeManager<E>
332where
333 E: PluginEvent,
334{
335 targets: HashMap<String, SharedTarget<E>>,
336}
337
338impl<E> Default for TargetRuntimeManager<E>
339where
340 E: PluginEvent,
341{
342 fn default() -> Self {
343 Self::new()
344 }
345}
346
347impl<E> Debug for TargetRuntimeManager<E>
348where
349 E: PluginEvent,
350{
351 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352 f.debug_struct("TargetRuntimeManager")
353 .field("target_count", &self.targets.len())
354 .finish()
355 }
356}
357
358impl<E> TargetRuntimeManager<E>
359where
360 E: PluginEvent,
361{
362 pub fn new() -> Self {
363 Self { targets: HashMap::new() }
364 }
365
366 pub fn add_arc(&mut self, target: SharedTarget<E>) -> Option<SharedTarget<E>> {
367 let key = target.id().to_string();
368 self.targets.insert(key, target)
369 }
370
371 pub fn add_boxed(&mut self, target: Box<dyn Target<E> + Send + Sync>) -> Option<SharedTarget<E>> {
372 self.add_arc(Arc::from(target))
373 }
374
375 pub fn get(&self, key: &str) -> Option<SharedTarget<E>> {
376 self.targets.get(key).cloned()
377 }
378
379 pub fn get_by_target_id(&self, target_id: &TargetID) -> Option<SharedTarget<E>> {
380 self.get(&target_id.to_string())
381 }
382
383 pub fn remove(&mut self, key: &str) -> Option<SharedTarget<E>> {
384 self.targets.remove(key)
385 }
386
387 pub fn remove_by_target_id(&mut self, target_id: &TargetID) -> Option<SharedTarget<E>> {
388 self.remove(&target_id.to_string())
389 }
390
391 pub fn clear(&mut self) {
392 self.targets.clear();
393 }
394
395 pub async fn remove_and_close(&mut self, key: &str) -> Option<SharedTarget<E>> {
396 let target = self.targets.remove(key)?;
397 if let Err(err) = target.close().await {
398 tracing::error!(target_id = %key, error = %err, "Failed to close target during removal");
399 }
400 Some(target)
401 }
402
403 pub async fn remove_by_target_id_and_close(&mut self, target_id: &TargetID) -> Option<SharedTarget<E>> {
404 self.remove_and_close(&target_id.to_string()).await
405 }
406
407 pub async fn clear_and_close(&mut self) -> Vec<(String, TargetError)> {
411 let targets = std::mem::take(&mut self.targets);
412 let mut closes = FuturesUnordered::new();
413 for (target_id, target) in targets {
414 closes.push(async move { (target_id, target.close().await) });
415 }
416
417 let mut errors = Vec::new();
418 while let Some((target_id, result)) = closes.next().await {
419 if let Err(err) = result {
420 tracing::error!(target_id = %target_id, error = %err, "Failed to close target during shutdown");
421 errors.push((target_id, err));
422 }
423 }
424 errors
425 }
426
427 pub fn target_ids(&self) -> Vec<TargetID> {
428 self.targets.values().map(|target| target.id()).collect()
429 }
430
431 pub fn keys(&self) -> Vec<String> {
432 self.targets.keys().cloned().collect()
433 }
434
435 pub fn values(&self) -> Vec<SharedTarget<E>> {
436 self.targets.values().cloned().collect()
437 }
438
439 pub fn len(&self) -> usize {
440 self.targets.len()
441 }
442
443 pub fn is_empty(&self) -> bool {
444 self.targets.is_empty()
445 }
446
447 pub fn snapshots(&self) -> Vec<RuntimeTargetSnapshot> {
448 let mut snapshots = Vec::with_capacity(self.targets.len());
449 for target in self.targets.values() {
450 let delivery = target.delivery_snapshot();
451 let target_id = target.id();
452 snapshots.push(snapshot_from_delivery(target_id, delivery));
453 }
454 snapshots.sort_by(|a, b| a.target_id.cmp(&b.target_id));
455 snapshots
456 }
457
458 pub fn status_snapshot(&self, replay_workers: &ReplayWorkerManager) -> RuntimeStatusSnapshot {
459 replay_workers.snapshot(self.len())
460 }
461
462 pub async fn health_snapshots(&self) -> Vec<RuntimeTargetHealthSnapshot> {
463 health_snapshots_for_targets(self.values()).await
464 }
465}
466
467pub async fn health_snapshots_for_targets<E>(targets: Vec<SharedTarget<E>>) -> Vec<RuntimeTargetHealthSnapshot>
468where
469 E: PluginEvent,
470{
471 let deadline = tokio::time::Instant::now() + HEALTH_COLLECTION_TIMEOUT;
472 let permits = Arc::new(Semaphore::new(HEALTH_PROBE_CONCURRENCY));
473 let mut probes = FuturesUnordered::new();
474 for target in targets {
475 let permits = Arc::clone(&permits);
476 let enabled = target.is_enabled();
477 let target_id = target.id();
478 probes.push(async move {
479 let health = if !enabled {
480 TargetHealth::disabled()
481 } else if let Ok(Ok(_permit)) = tokio::time::timeout_at(deadline, permits.acquire_owned()).await {
482 if tokio::time::Instant::now() >= deadline {
483 TargetHealth::error(TargetHealthReason::HealthCheckFailed)
484 } else {
485 match tokio::time::timeout_at(deadline, target.health()).await {
486 Ok(health) => health,
487 Err(_) => TargetHealth::error(TargetHealthReason::TimedOut),
488 }
489 }
490 } else {
491 TargetHealth::error(TargetHealthReason::HealthCheckFailed)
492 };
493 (enabled, target_id, health)
494 });
495 }
496
497 let mut snapshots = Vec::with_capacity(probes.len());
498 while let Some((enabled, target_id, health)) = probes.next().await {
499 snapshots.push(RuntimeTargetHealthSnapshot {
500 account_id: target_id.id.clone(),
501 enabled,
502 error_message: (health.state == TargetHealthState::Error).then(|| health.reason.as_str().to_string()),
503 state: health.state,
504 reason: health.reason,
505 target_id: target_id.to_string(),
506 target_type: target_id.name,
507 });
508 }
509
510 snapshots.sort_by(|a, b| a.target_id.cmp(&b.target_id));
511 snapshots
512}
513
514fn snapshot_from_delivery(target_id: TargetID, delivery: TargetDeliverySnapshot) -> RuntimeTargetSnapshot {
515 RuntimeTargetSnapshot {
516 failed_messages: delivery.failed_messages,
517 failed_store_length: delivery.failed_store_length,
518 queue_length: delivery.queue_length,
519 target_id: target_id.to_string(),
520 target_type: target_id.name,
521 total_messages: delivery.total_messages,
522 }
523}
524
525#[hotpath::measure]
526pub async fn init_target_and_optionally_start_replay<E, F, G>(
527 target: Box<dyn Target<E> + Send + Sync>,
528 on_replay_start: F,
529 start_replay: G,
530) -> Option<(SharedTarget<E>, Option<(mpsc::Sender<()>, JoinHandle<()>)>)>
531where
532 E: PluginEvent,
533 F: FnOnce(&str, bool),
534 G: FnOnce(
535 Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send>,
536 SharedTarget<E>,
537 ) -> (mpsc::Sender<()>, JoinHandle<()>),
538{
539 let shared = match prepare_target(target, None).await {
540 PrepareTargetResult::Ready(target) => target,
541 PrepareTargetResult::Degraded { target, .. } => target,
542 PrepareTargetResult::Failed { target, .. } => {
543 let _ = target.close().await;
544 return None;
545 }
546 PrepareTargetResult::Cancelled(_) => unreachable!("preparation without a cancellation token cannot be cancelled"),
547 };
548 let target_id = shared.id().to_string();
549 if !shared.is_enabled() {
550 on_replay_start(&target_id, false);
551 return Some((shared, None));
552 }
553
554 let cancel = shared
555 .store()
556 .map(|store| start_replay(store.boxed_clone(), Arc::clone(&shared)));
557 on_replay_start(&target_id, cancel.is_some());
558 Some((shared, cancel))
559}
560
561#[hotpath::measure]
562pub(crate) async fn prepare_target<E>(
563 target: Box<dyn Target<E> + Send + Sync>,
564 cancellation: Option<&CancellationToken>,
565) -> PrepareTargetResult<E>
566where
567 E: PluginEvent,
568{
569 let target_id = target.id().to_string();
570 let has_store = target.store().is_some();
571
572 let init_result = match cancellation {
573 Some(cancellation) => {
574 tokio::select! {
575 biased;
576 _ = cancellation.cancelled() => return PrepareTargetResult::Cancelled(target),
577 result = target.init() => result,
578 }
579 }
580 None => target.init().await,
581 };
582
583 if let Err(err) = init_result {
584 tracing::error!(target_id = %target_id, reason = "initialization_failed", "Failed to initialize target");
585 if !has_store {
586 return PrepareTargetResult::Failed { error: err, target };
587 }
588 tracing::warn!(
589 target_id = %target_id,
590 "Proceeding with store-backed target despite init failure"
591 );
592 return PrepareTargetResult::Degraded {
593 error: err,
594 target: Arc::from(target),
595 };
596 }
597
598 PrepareTargetResult::Ready(Arc::from(target))
599}
600
601type ActivatedTarget<E> = (SharedTarget<E>, Option<(mpsc::Sender<()>, JoinHandle<()>)>);
602
603#[hotpath::measure]
604pub async fn activate_targets_with_replay<E, F, Fut>(
605 targets: Vec<Box<dyn Target<E> + Send + Sync>>,
606 mut activate_one: F,
607) -> RuntimeActivation<E>
608where
609 E: PluginEvent,
610 F: FnMut(Box<dyn Target<E> + Send + Sync>) -> Fut,
611 Fut: Future<Output = Option<ActivatedTarget<E>>>,
612{
613 let mut replay_workers = ReplayWorkerManager::new();
614 let mut shared_targets = Vec::new();
615
616 for target in targets {
617 if let Some((shared_target, replay)) = activate_one(target).await {
618 let target_id = shared_target.id().to_string();
619 if let Some((cancel_tx, join)) = replay {
620 replay_workers.insert_with_handle(target_id, cancel_tx, join);
621 }
622 shared_targets.push(shared_target);
623 }
624 }
625
626 RuntimeActivation {
627 replay_workers,
628 targets: shared_targets,
629 }
630}
631
632pub fn start_replay_worker<E>(
633 mut store: Box<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send>,
634 target: SharedTarget<E>,
635 hook: ReplayHook<E>,
636 semaphore: Option<Arc<Semaphore>>,
637 batch_timeout: Duration,
638 idle_sleep: Duration,
639) -> (mpsc::Sender<()>, JoinHandle<()>)
640where
641 E: PluginEvent,
642{
643 let (cancel_tx, cancel_rx) = mpsc::channel(1);
644
645 let join = tokio::spawn(async move {
646 stream_replay_worker(&mut *store, target, cancel_rx, hook, semaphore, batch_timeout, idle_sleep).await;
647 });
648
649 (cancel_tx, join)
650}
651
652const REPLAY_BATCH_SIZE: usize = 16;
655
656async fn sleep_or_cancelled(dur: Duration, cancel_rx: &mut mpsc::Receiver<()>) -> bool {
661 tokio::select! {
662 biased;
663 _ = cancel_rx.recv() => true,
664 _ = tokio::time::sleep(dur) => false,
665 }
666}
667
668fn seed_interval_start(now: tokio::time::Instant, interval: Duration) -> tokio::time::Instant {
673 now.checked_sub(interval).unwrap_or(now)
674}
675
676#[hotpath::measure]
677async fn stream_replay_worker<E>(
678 store: &mut (dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send),
679 target: SharedTarget<E>,
680 mut cancel_rx: mpsc::Receiver<()>,
681 hook: ReplayHook<E>,
682 semaphore: Option<Arc<Semaphore>>,
683 batch_timeout: Duration,
684 idle_sleep: Duration,
685) where
686 E: PluginEvent,
687{
688 const FAILED_STORE_PRUNE_INTERVAL: Duration = Duration::from_secs(60);
691
692 let mut batch_keys = Vec::with_capacity(REPLAY_BATCH_SIZE);
693 let mut last_flush = tokio::time::Instant::now();
694 let mut last_prune = seed_interval_start(tokio::time::Instant::now(), FAILED_STORE_PRUNE_INTERVAL);
697
698 loop {
699 if cancel_rx.try_recv().is_ok() {
700 return;
701 }
702
703 if let Some(failed_store) = target.failed_store()
707 && last_prune.elapsed() >= FAILED_STORE_PRUNE_INTERVAL
708 {
709 let maintenance_failed = failed_store.boxed_clone_failed();
713 let outcome = tokio::task::spawn_blocking(move || maintenance_failed.prune_failed_store()).await;
714 match outcome {
715 Ok(Err(err)) => {
716 tracing::warn!(target_id = %target.id(), error = %err, "Failed to prune the failed-events store");
717 }
718 Ok(Ok(_)) => {}
719 Err(join_err) => {
720 tracing::warn!(
721 target_id = %target.id(),
722 reason = join_failure_reason(&join_err),
723 "The failed-events maintenance task failed to join"
724 );
725 }
726 }
727 last_prune = tokio::time::Instant::now();
728 }
729
730 let keys = store.list();
731 if keys.is_empty() {
732 if !batch_keys.is_empty() && last_flush.elapsed() >= batch_timeout {
733 if process_replay_batch(&*store, &mut batch_keys, target.clone(), &hook, semaphore.clone(), &mut cancel_rx).await
734 {
735 return;
736 }
737 last_flush = tokio::time::Instant::now();
738 }
739 if sleep_or_cancelled(idle_sleep, &mut cancel_rx).await {
740 return;
741 }
742 continue;
743 }
744
745 for key in keys {
746 if cancel_rx.try_recv().is_ok() {
747 if !batch_keys.is_empty() {
748 process_replay_batch(&*store, &mut batch_keys, target.clone(), &hook, semaphore.clone(), &mut cancel_rx)
749 .await;
750 }
751 return;
752 }
753
754 match ensure_store_entry_raw_readable(&*store, &key) {
755 Ok(true) => {}
756 Ok(false) => continue,
757 Err(err) => {
758 hook(ReplayEvent::UnreadableEntry {
759 error: err,
760 key,
761 target: target.clone(),
762 })
763 .await;
764 continue;
765 }
766 }
767
768 if batch_keys
773 .iter()
774 .any(|pending: &Key| pending.to_key_string() == key.to_key_string())
775 {
776 continue;
777 }
778
779 batch_keys.push(key);
780 if batch_keys.len() >= REPLAY_BATCH_SIZE || last_flush.elapsed() >= batch_timeout {
783 if process_replay_batch(&*store, &mut batch_keys, target.clone(), &hook, semaphore.clone(), &mut cancel_rx).await
784 {
785 return;
786 }
787 last_flush = tokio::time::Instant::now();
788 }
789 }
790
791 if !batch_keys.is_empty() && last_flush.elapsed() >= batch_timeout {
794 if process_replay_batch(&*store, &mut batch_keys, target.clone(), &hook, semaphore.clone(), &mut cancel_rx).await {
795 return;
796 }
797 last_flush = tokio::time::Instant::now();
798 }
799
800 if sleep_or_cancelled(Duration::from_millis(100), &mut cancel_rx).await {
801 return;
802 }
803 }
804}
805
806#[hotpath::measure]
813async fn process_replay_batch<E>(
814 store: &(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send),
815 batch_keys: &mut Vec<Key>,
816 target: SharedTarget<E>,
817 hook: &ReplayHook<E>,
818 semaphore: Option<Arc<Semaphore>>,
819 cancel_rx: &mut mpsc::Receiver<()>,
820) -> bool
821where
822 E: PluginEvent,
823{
824 const MAX_RETRIES: usize = REPLAY_MAX_RETRIES;
825
826 if batch_keys.is_empty() {
827 return false;
828 }
829
830 let mut cancelled = false;
831 'keys: for key in batch_keys.iter() {
832 let mut retry_count = 0usize;
833 let mut success = false;
834 let mut last_retryable_detail = String::new();
835
836 while retry_count < MAX_RETRIES && !success {
837 let permit = match &semaphore {
843 None => None,
844 Some(sem) => match sem.clone().acquire_owned().await {
845 Ok(permit) => Some(permit),
846 Err(err) => {
847 tracing::error!(error = %err, "Failed to acquire replay semaphore permit");
848 batch_keys.clear();
850 return cancelled;
851 }
852 },
853 };
854
855 let result = target.send_from_store(key.clone()).await;
856 drop(permit);
857
858 match result {
859 Ok(_) => {
860 hook(ReplayEvent::Delivered {
861 key: key.clone(),
862 target: target.clone(),
863 })
864 .await;
865 success = true;
866 }
867 Err(err) => match err {
868 TargetError::NotConnected
869 | TargetError::Timeout(_)
870 | TargetError::JetStreamPublish { retryable: true, .. } => {
871 retry_count += 1;
872 last_retryable_detail = err.to_string();
873 hook(ReplayEvent::RetryableError {
874 error: err,
875 key: key.clone(),
876 retry_count,
877 target: target.clone(),
878 })
879 .await;
880 if retry_count < MAX_RETRIES && replay_backoff_sleep(key, retry_count, cancel_rx).await {
885 cancelled = true;
886 break 'keys;
887 }
888 }
889 TargetError::JetStreamPublish { retryable: false, .. } => {
890 if move_failed_entry(store, &target, key, &err, retry_count as u32).await {
894 hook(ReplayEvent::PermanentFailure {
895 error: err,
896 key: key.clone(),
897 target: target.clone(),
898 })
899 .await;
900 }
901 break;
902 }
903 TargetError::Dropped(reason) => {
904 hook(ReplayEvent::Dropped {
905 key: key.clone(),
906 reason,
907 target: target.clone(),
908 })
909 .await;
910 break;
911 }
912 other => {
913 hook(ReplayEvent::PermanentFailure {
914 error: other,
915 key: key.clone(),
916 target: target.clone(),
917 })
918 .await;
919 break;
920 }
921 },
922 }
923 }
924
925 if retry_count >= MAX_RETRIES && !success {
926 hook(ReplayEvent::RetryExhausted {
929 detail: last_retryable_detail,
930 key: key.clone(),
931 target: target.clone(),
932 })
933 .await;
934 }
935 }
936
937 batch_keys.clear();
938 cancelled
939}
940
941async fn replay_backoff_sleep(key: &Key, retry_count: usize, cancel_rx: &mut mpsc::Receiver<()>) -> bool {
944 let jitter = Duration::from_millis(key.to_string().len() as u64 % 500);
945 sleep_or_cancelled(replay_backoff_term(retry_count as u32) + jitter, cancel_rx).await
946}
947
948async fn move_failed_entry<E>(
953 store: &(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send),
954 target: &SharedTarget<E>,
955 key: &Key,
956 error: &TargetError,
957 retry_count: u32,
958) -> bool
959where
960 E: PluginEvent,
961{
962 target.handle_terminal_failure(store, key, error, retry_count).await
963}
964
965#[cfg(test)]
966mod tests {
967 use super::{HEALTH_PROBE_CONCURRENCY, TargetRuntimeManager, health_snapshots_for_targets};
968 use crate::SharedTarget;
969 use crate::store::Key;
970 use crate::testkit::MockTarget;
971 use std::sync::Arc;
972 use std::sync::atomic::Ordering;
973 use std::time::Duration;
974
975 #[tokio::test(start_paused = true)]
976 async fn seed_interval_start_backdates_by_one_interval() {
977 let origin = tokio::time::Instant::now();
980 tokio::time::advance(std::time::Duration::from_secs(60)).await;
981 let now = tokio::time::Instant::now();
982 assert_eq!(super::seed_interval_start(now, std::time::Duration::from_secs(60)), origin);
983 }
984
985 #[test]
986 fn seed_interval_start_falls_back_to_now_when_the_clock_is_too_young() {
987 let now = tokio::time::Instant::now();
990 assert_eq!(super::seed_interval_start(now, std::time::Duration::MAX), now);
991 }
992
993 #[test]
994 fn inter_attempt_backoff_sum_matches_the_realized_sleep_schedule() {
995 let mut realized = std::time::Duration::ZERO;
999 for shift in 1..super::REPLAY_MAX_RETRIES as u32 {
1000 realized += super::replay_backoff_term(shift);
1001 }
1002 assert_eq!(super::inter_attempt_backoff_sum(super::REPLAY_MAX_RETRIES), realized);
1003 assert_eq!(realized, std::time::Duration::from_secs(60));
1005 }
1006
1007 #[tokio::test(start_paused = true)]
1008 async fn replay_backoff_sleep_sleeps_the_term_for_the_retry_plus_key_jitter() {
1009 let key = Key {
1014 name: "0198c0de-0000-7000-8000-000000000000".to_string(),
1015 extension: ".event".to_string(),
1016 item_count: 1,
1017 compress: false,
1018 };
1019 let jitter = std::time::Duration::from_millis(key.to_string().len() as u64 % 500);
1020 let retry_count = 3usize;
1021
1022 let (_cancel_tx, mut cancel_rx) = tokio::sync::mpsc::channel::<()>(1);
1024 let start = tokio::time::Instant::now();
1025 let cancelled = super::replay_backoff_sleep(&key, retry_count, &mut cancel_rx).await;
1026
1027 assert!(!cancelled, "no cancel signal was sent");
1028 assert_eq!(
1029 start.elapsed(),
1030 super::replay_backoff_term(retry_count as u32) + jitter,
1031 "the sleep is the term for this retry count plus the key-derived jitter"
1032 );
1033 }
1034
1035 #[tokio::test]
1036 async fn runtime_manager_removes_and_closes_target() {
1037 let mut manager = TargetRuntimeManager::<String>::new();
1038 let target = MockTarget::new("primary", "webhook");
1039 let observer = target.clone();
1040
1041 manager.add_boxed(Box::new(target));
1042 assert_eq!(manager.len(), 1);
1043
1044 let removed = manager.remove_and_close("primary:webhook").await;
1045 assert!(removed.is_some());
1046 assert_eq!(manager.len(), 0);
1047 assert_eq!(observer.close_call_count(), 1);
1048 }
1049
1050 #[tokio::test(start_paused = true)]
1051 async fn runtime_manager_starts_all_target_closes_before_waiting_for_completion() {
1052 let mut manager = TargetRuntimeManager::<String>::new();
1053 let first = MockTarget::new("first", "webhook");
1054 let second = MockTarget::new("second", "webhook");
1055 let first_observer = first.clone();
1056 let second_observer = second.clone();
1057 manager.add_boxed(Box::new(first));
1058 manager.add_boxed(Box::new(second));
1059
1060 let first_close_key = manager
1061 .keys()
1062 .into_iter()
1063 .next()
1064 .expect("two targets should have a first close key");
1065 let (blocked, unblocked) = if first_close_key == first_observer.target_id().to_string() {
1066 (first_observer, second_observer)
1067 } else {
1068 (second_observer, first_observer)
1069 };
1070 blocked.set_block_on_close(true);
1071
1072 let close_task = tokio::spawn(async move { manager.clear_and_close().await });
1073
1074 tokio::time::timeout(std::time::Duration::from_secs(1), blocked.close_started().notified())
1075 .await
1076 .expect("the first target close should start");
1077 tokio::time::timeout(std::time::Duration::from_secs(1), unblocked.close_started().notified())
1078 .await
1079 .expect("a blocked first close must not prevent the second close from starting");
1080 assert!(!close_task.is_finished(), "clear_and_close must still await the blocked target");
1081
1082 blocked.close_gate().add_permits(1);
1083 let errors = close_task.await.expect("clear_and_close task should join");
1084 assert!(errors.is_empty());
1085 assert_eq!(blocked.close_call_count(), 1);
1086 assert_eq!(unblocked.close_call_count(), 1);
1087 }
1088
1089 #[test]
1090 fn runtime_manager_snapshots_targets() {
1091 let mut manager = TargetRuntimeManager::<String>::new();
1092 manager.add_boxed(Box::new(MockTarget::new("primary", "webhook")));
1093
1094 let snapshots = manager.snapshots();
1095 assert_eq!(snapshots.len(), 1);
1096 assert_eq!(snapshots[0].target_id, "primary:webhook");
1097 assert_eq!(snapshots[0].target_type, "webhook");
1098 }
1099
1100 #[tokio::test(start_paused = true)]
1101 async fn health_snapshot_allows_a_four_second_probe() {
1102 let mut manager = TargetRuntimeManager::<String>::new();
1103 manager.add_boxed(Box::new(MockTarget::new("slow", "webhook").with_health_delay(Duration::from_secs(4))));
1104
1105 let snapshots = manager.health_snapshots().await;
1106
1107 assert_eq!(snapshots.len(), 1);
1108 assert_eq!(snapshots[0].state, crate::TargetHealthState::Online);
1109 assert_eq!(snapshots[0].reason, crate::TargetHealthReason::Reachable);
1110 }
1111
1112 #[tokio::test(start_paused = true)]
1113 async fn health_snapshot_times_out_after_five_seconds() {
1114 let mut manager = TargetRuntimeManager::<String>::new();
1115 manager.add_boxed(Box::new(MockTarget::new("stalled", "webhook").with_health_delay(Duration::from_secs(6))));
1116
1117 let snapshots = manager.health_snapshots().await;
1118
1119 assert_eq!(snapshots.len(), 1);
1120 assert_eq!(snapshots[0].state, crate::TargetHealthState::Error);
1121 assert_eq!(snapshots[0].reason, crate::TargetHealthReason::TimedOut);
1122 }
1123
1124 #[tokio::test(start_paused = true)]
1125 async fn health_collection_deadline_does_not_scale_with_target_count() {
1126 let mut manager = TargetRuntimeManager::<String>::new();
1127 for index in 0..24 {
1128 manager.add_boxed(Box::new(
1129 MockTarget::new(&format!("stalled-{index}"), "webhook").with_health_delay(Duration::from_secs(30)),
1130 ));
1131 }
1132 let started = tokio::time::Instant::now();
1133
1134 let snapshots = manager.health_snapshots().await;
1135
1136 assert_eq!(started.elapsed(), Duration::from_secs(5));
1137 assert_eq!(snapshots.len(), 24);
1138 assert!(
1139 snapshots
1140 .iter()
1141 .all(|snapshot| snapshot.state == crate::TargetHealthState::Error)
1142 );
1143 assert_eq!(
1144 snapshots
1145 .iter()
1146 .filter(|snapshot| snapshot.reason == crate::TargetHealthReason::TimedOut)
1147 .count(),
1148 HEALTH_PROBE_CONCURRENCY
1149 );
1150 assert_eq!(
1151 snapshots
1152 .iter()
1153 .filter(|snapshot| snapshot.reason == crate::TargetHealthReason::HealthCheckFailed)
1154 .count(),
1155 24 - HEALTH_PROBE_CONCURRENCY
1156 );
1157 }
1158
1159 #[tokio::test(start_paused = true)]
1160 async fn disabled_target_does_not_wait_for_probe_capacity() {
1161 let mut targets: Vec<SharedTarget<String>> = (0..HEALTH_PROBE_CONCURRENCY)
1162 .map(|index| {
1163 Arc::new(MockTarget::new(&format!("stalled-{index}"), "webhook").with_health_delay(Duration::from_secs(30)))
1164 as SharedTarget<String>
1165 })
1166 .collect();
1167 targets.push(Arc::new(MockTarget::new("disabled", "webhook").disabled()));
1168
1169 let snapshots = health_snapshots_for_targets(targets).await;
1170 let disabled = snapshots
1171 .iter()
1172 .find(|snapshot| snapshot.target_id == "disabled:webhook")
1173 .expect("disabled target snapshot");
1174
1175 assert_eq!(disabled.state, crate::TargetHealthState::Disabled);
1176 assert_eq!(disabled.reason, crate::TargetHealthReason::Disabled);
1177 }
1178
1179 #[tokio::test]
1180 async fn cancelling_health_collection_drops_in_flight_probe() {
1181 let target = MockTarget::new("slow", "webhook").with_health_delay(Duration::from_secs(30));
1182 let observer = target.clone();
1183 let targets: Vec<SharedTarget<String>> = vec![Arc::new(target)];
1184 let collector = tokio::spawn(health_snapshots_for_targets(targets));
1185
1186 tokio::time::timeout(Duration::from_secs(1), observer.health_started().notified())
1187 .await
1188 .expect("health probe should start");
1189 collector.abort();
1190 let join_error = collector.await.expect_err("health collector should be cancelled");
1191
1192 assert!(join_error.is_cancelled());
1193 assert_eq!(observer.health_drop_count(), 1);
1194 }
1195
1196 #[tokio::test]
1197 async fn sleep_or_cancelled_returns_immediately_on_cancel() {
1198 let (cancel_tx, mut cancel_rx) = tokio::sync::mpsc::channel::<()>(1);
1199 cancel_tx.send(()).await.unwrap();
1200
1201 let start = std::time::Instant::now();
1203 let cancelled = super::sleep_or_cancelled(std::time::Duration::from_secs(30), &mut cancel_rx).await;
1204 assert!(cancelled);
1205 assert!(
1206 start.elapsed() < std::time::Duration::from_secs(5),
1207 "cancel should not wait for the full sleep"
1208 );
1209 }
1210
1211 #[tokio::test]
1212 async fn sleep_or_cancelled_returns_false_when_not_cancelled() {
1213 let (_cancel_tx, mut cancel_rx) = tokio::sync::mpsc::channel::<()>(1);
1214 let cancelled = super::sleep_or_cancelled(std::time::Duration::from_millis(10), &mut cancel_rx).await;
1215 assert!(!cancelled);
1216 }
1217
1218 #[tokio::test]
1219 async fn stop_all_joins_and_awaits_worker_exit() {
1220 use super::ReplayWorkerManager;
1221 use std::sync::atomic::AtomicBool;
1222
1223 let mut manager = ReplayWorkerManager::new();
1224 let exited = Arc::new(AtomicBool::new(false));
1225
1226 let (cancel_tx, mut cancel_rx) = tokio::sync::mpsc::channel::<()>(1);
1227 let exited_task = Arc::clone(&exited);
1228 let join = tokio::spawn(async move {
1229 loop {
1231 if super::sleep_or_cancelled(std::time::Duration::from_millis(50), &mut cancel_rx).await {
1232 break;
1233 }
1234 }
1235 exited_task.store(true, Ordering::SeqCst);
1236 });
1237
1238 manager.insert_with_handle("primary:webhook".to_string(), cancel_tx, join);
1239 assert_eq!(manager.len(), 1);
1240
1241 manager.stop_all("stopping test worker").await;
1244
1245 assert!(manager.is_empty());
1246 assert!(exited.load(Ordering::SeqCst), "stop_all must await the worker to completion");
1247 }
1248
1249 #[tokio::test(start_paused = true)]
1250 async fn stop_all_does_not_abort_delivery_awaiting_acknowledgement() {
1251 use super::ReplayWorkerManager;
1252 use std::sync::atomic::{AtomicBool, Ordering};
1253 use tokio::sync::Notify;
1254
1255 let mut manager = ReplayWorkerManager::new();
1256 let acknowledgement = Arc::new(Notify::new());
1257 let worker_started = Arc::new(Notify::new());
1258 let exited = Arc::new(AtomicBool::new(false));
1259 let (cancel_tx, mut cancel_rx) = tokio::sync::mpsc::channel::<()>(1);
1260 let worker_acknowledgement = acknowledgement.clone();
1261 let worker_started_signal = worker_started.clone();
1262 let worker_exited = exited.clone();
1263 let join = tokio::spawn(async move {
1264 worker_started_signal.notify_one();
1265 let _ = cancel_rx.recv().await;
1266 worker_acknowledgement.notified().await;
1270 worker_exited.store(true, Ordering::SeqCst);
1271 });
1272 manager.insert_with_handle("primary:webhook".to_string(), cancel_tx, join);
1273 worker_started.notified().await;
1274
1275 let mut stop = Box::pin(manager.stop_all("stopping ack-pending test worker"));
1276 tokio::select! {
1277 biased;
1278 _ = &mut stop => panic!("stop_all returned before the pending acknowledgement"),
1279 _ = std::future::ready(()) => {}
1280 }
1281 tokio::time::advance(std::time::Duration::from_secs(60)).await;
1282 tokio::select! {
1283 biased;
1284 _ = &mut stop => panic!("stop_all aborted an acknowledgement-pending delivery"),
1285 _ = std::future::ready(()) => {}
1286 }
1287
1288 acknowledgement.notify_one();
1289 stop.await;
1290 assert!(exited.load(Ordering::SeqCst));
1291 assert!(manager.is_empty());
1292 }
1293
1294 mod classifier {
1295 use super::super::{ReplayEvent, stream_replay_worker};
1296 use crate::arn::TargetID;
1297 use crate::plugin::PluginEvent;
1298 use crate::store::{FailedEventStore, Key, QueueStore, Store};
1299 use crate::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
1300 use crate::{StoreError, Target, TargetError};
1301 use async_trait::async_trait;
1302 use rustfs_s3_types::EventName;
1303 use std::sync::Arc;
1304 use std::sync::atomic::{AtomicUsize, Ordering};
1305 use std::time::Duration;
1306 use tokio::sync::{Semaphore, mpsc};
1307
1308 #[derive(Default)]
1311 struct ReplayTally {
1312 delivered: AtomicUsize,
1313 retryable: AtomicUsize,
1314 dropped: AtomicUsize,
1315 permanent_failure: AtomicUsize,
1316 retry_exhausted: AtomicUsize,
1317 unreadable: AtomicUsize,
1318 }
1319
1320 #[derive(Clone)]
1323 struct ProgrammedTarget {
1324 id: TargetID,
1325 error: Option<Arc<TargetError>>,
1326 send_calls: Arc<AtomicUsize>,
1327 in_flight: Arc<AtomicUsize>,
1328 max_in_flight: Arc<AtomicUsize>,
1329 send_gate: Arc<Semaphore>,
1330 store: QueueStore<QueuedPayload>,
1333 failed_handle: Arc<dyn FailedEventStore>,
1336 }
1337
1338 impl ProgrammedTarget {
1339 fn new(error: Option<TargetError>, store: QueueStore<QueuedPayload>) -> Self {
1340 let failed_handle: Arc<dyn FailedEventStore> = Arc::new(store.clone());
1341 Self::new_with_failed_store(error, store, failed_handle)
1342 }
1343
1344 fn new_with_failed_store(
1345 error: Option<TargetError>,
1346 store: QueueStore<QueuedPayload>,
1347 failed_handle: Arc<dyn FailedEventStore>,
1348 ) -> Self {
1349 let send_gate = Arc::new(Semaphore::new(Semaphore::MAX_PERMITS));
1350 Self {
1351 id: TargetID::new("target-a".to_string(), "nats".to_string()),
1352 error: error.map(Arc::new),
1353 send_calls: Arc::new(AtomicUsize::new(0)),
1354 in_flight: Arc::new(AtomicUsize::new(0)),
1355 max_in_flight: Arc::new(AtomicUsize::new(0)),
1356 send_gate,
1357 store,
1358 failed_handle,
1359 }
1360 }
1361 }
1362
1363 fn move_terminal_entry_for_test(
1368 store: &(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send),
1369 failed_store: &dyn FailedEventStore,
1370 key: &Key,
1371 error: &TargetError,
1372 retry_count: u32,
1373 ) -> bool {
1374 let raw = match store.get_raw(key) {
1375 Ok(raw) => raw,
1376 Err(StoreError::NotFound) => return true,
1377 Err(_) => return false,
1378 };
1379 let Ok(queued) = QueuedPayload::decode(&raw) else {
1380 return false;
1381 };
1382 let resolved = crate::target::nats::resolve_dedup_id(&queued.meta.dedup_id, key);
1383 let Ok(encoded) = crate::target::encode_failed_entry(
1384 queued,
1385 crate::target::FailedErrorClass::Terminal,
1386 error,
1387 retry_count,
1388 &resolved,
1389 ) else {
1390 return false;
1391 };
1392 if failed_store.put_failed_raw(&key.name, &encoded).is_err() {
1393 return false;
1394 }
1395 matches!(store.del(key), Ok(()) | Err(StoreError::NotFound))
1398 }
1399
1400 #[async_trait]
1401 impl<E> Target<E> for ProgrammedTarget
1402 where
1403 E: PluginEvent,
1404 {
1405 fn id(&self) -> TargetID {
1406 self.id.clone()
1407 }
1408 async fn is_active(&self) -> Result<bool, TargetError> {
1409 Ok(true)
1410 }
1411 async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
1412 Ok(())
1413 }
1414 async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
1415 Ok(())
1416 }
1417 async fn send_from_store(&self, key: Key) -> Result<(), TargetError> {
1418 self.send_calls.fetch_add(1, Ordering::SeqCst);
1419 let live = self.in_flight.fetch_add(1, Ordering::SeqCst) + 1;
1420 self.max_in_flight.fetch_max(live, Ordering::SeqCst);
1421 let permit = self.send_gate.clone().acquire_owned().await;
1424 drop(permit);
1425 self.in_flight.fetch_sub(1, Ordering::SeqCst);
1426 match self.error.as_deref() {
1427 Some(TargetError::Dropped(reason)) => {
1432 let _ = self.store.del(&key);
1433 Err(TargetError::Dropped(reason.clone()))
1434 }
1435 Some(error) => Err(clone_error(error)),
1436 None => {
1437 let _ = self.store.del(&key);
1439 Ok(())
1440 }
1441 }
1442 }
1443 async fn close(&self) -> Result<(), TargetError> {
1444 Ok(())
1445 }
1446 fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
1447 None
1448 }
1449 async fn handle_terminal_failure(
1450 &self,
1451 store: &(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send),
1452 key: &Key,
1453 error: &TargetError,
1454 retry_count: u32,
1455 ) -> bool {
1456 move_terminal_entry_for_test(store, self.failed_handle.as_ref(), key, error, retry_count)
1457 }
1458 fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
1459 Box::new(self.clone())
1460 }
1461 fn is_enabled(&self) -> bool {
1462 true
1463 }
1464 }
1465
1466 #[derive(Clone)]
1470 struct FailingFailedStore {
1471 inner: QueueStore<QueuedPayload>,
1472 fail_put_failed_raw: Arc<std::sync::atomic::AtomicBool>,
1473 fail_del: Arc<std::sync::atomic::AtomicBool>,
1474 }
1475
1476 impl Store<QueuedPayload> for FailingFailedStore {
1477 type Error = StoreError;
1478 type Key = Key;
1479
1480 fn open(&self) -> Result<(), Self::Error> {
1481 self.inner.open()
1482 }
1483 fn put(&self, item: Arc<QueuedPayload>) -> Result<Self::Key, Self::Error> {
1484 self.inner.put(item)
1485 }
1486 fn put_multiple(&self, items: Vec<QueuedPayload>) -> Result<Self::Key, Self::Error> {
1487 self.inner.put_multiple(items)
1488 }
1489 fn put_raw(&self, data: &[u8]) -> Result<Self::Key, Self::Error> {
1490 self.inner.put_raw(data)
1491 }
1492 fn get(&self, key: &Self::Key) -> Result<QueuedPayload, Self::Error> {
1493 self.inner.get(key)
1494 }
1495 fn get_multiple(&self, key: &Self::Key) -> Result<Vec<QueuedPayload>, Self::Error> {
1496 self.inner.get_multiple(key)
1497 }
1498 fn get_raw(&self, key: &Self::Key) -> Result<Vec<u8>, Self::Error> {
1499 self.inner.get_raw(key)
1500 }
1501 fn del(&self, key: &Self::Key) -> Result<(), Self::Error> {
1502 if self.fail_del.load(Ordering::SeqCst) {
1503 return Err(StoreError::Internal("injected del failure".to_string()));
1504 }
1505 self.inner.del(key)
1506 }
1507 fn delete(&self) -> Result<(), Self::Error> {
1508 self.inner.delete()
1509 }
1510 fn list(&self) -> Vec<Self::Key> {
1511 self.inner.list()
1512 }
1513 fn len(&self) -> usize {
1514 self.inner.len()
1515 }
1516 fn is_empty(&self) -> bool {
1517 self.inner.is_empty()
1518 }
1519 fn boxed_clone(&self) -> Box<dyn Store<QueuedPayload, Error = Self::Error, Key = Self::Key> + Send + Sync> {
1520 Box::new(self.clone())
1521 }
1522 }
1523
1524 impl FailedEventStore for FailingFailedStore {
1525 fn put_failed_raw(&self, entry_name: &str, data: &[u8]) -> Result<String, StoreError> {
1526 if self.fail_put_failed_raw.load(Ordering::SeqCst) {
1527 return Err(StoreError::Internal("injected put_failed_raw failure".to_string()));
1528 }
1529 self.inner.put_failed_raw(entry_name, data)
1530 }
1531 fn prune_failed_store(&self) -> Result<usize, StoreError> {
1532 self.inner.prune_failed_store()
1533 }
1534 fn failed_len(&self) -> usize {
1535 self.inner.failed_len()
1536 }
1537 fn boxed_clone_failed(&self) -> Box<dyn FailedEventStore> {
1538 Box::new(self.clone())
1539 }
1540 }
1541
1542 fn clone_error(error: &TargetError) -> TargetError {
1543 match error {
1544 TargetError::NotConnected => TargetError::NotConnected,
1545 TargetError::Timeout(value) => TargetError::Timeout(value.clone()),
1546 TargetError::Dropped(value) => TargetError::Dropped(value.clone()),
1547 TargetError::JetStreamPublish { retryable, detail } => TargetError::JetStreamPublish {
1548 retryable: *retryable,
1549 detail: detail.clone(),
1550 },
1551 TargetError::Network(value) => TargetError::Network(value.clone()),
1552 other => TargetError::Unknown(other.to_string()),
1553 }
1554 }
1555
1556 fn temp_dir(name: &str) -> std::path::PathBuf {
1557 std::env::temp_dir().join(format!("rustfs-runtime-{name}-{}", uuid::Uuid::new_v4()))
1558 }
1559
1560 fn seed_entry(store: &QueueStore<QueuedPayload>, dedup_id: &str) -> Key {
1561 let mut meta = QueuedPayloadMeta::new(
1562 EventName::ObjectCreatedPut,
1563 "bucket-a".to_string(),
1564 "obj.txt".to_string(),
1565 "application/json",
1566 7,
1567 );
1568 meta.dedup_id = dedup_id.to_string();
1569 let payload = QueuedPayload::new(meta, br#"{"x":1}"#.to_vec());
1570 store.put_raw(&payload.encode().unwrap()).unwrap()
1571 }
1572
1573 async fn run_worker(store: &mut QueueStore<QueuedPayload>, target: Arc<ProgrammedTarget>, tally: Arc<ReplayTally>) {
1579 run_worker_until(store, target, tally, |_| false).await;
1580 }
1581
1582 async fn run_worker_until(
1586 store: &mut QueueStore<QueuedPayload>,
1587 target: Arc<ProgrammedTarget>,
1588 tally: Arc<ReplayTally>,
1589 stop: impl Fn(&ReplayTally) -> bool,
1590 ) {
1591 let shared: Arc<dyn Target<String> + Send + Sync> = target.clone();
1592 let (cancel_tx, cancel_rx) = mpsc::channel(1);
1593 let hook = {
1594 let tally = tally.clone();
1595 Arc::new(move |event: ReplayEvent<String>| {
1596 let tally = tally.clone();
1597 Box::pin(async move {
1598 match event {
1599 ReplayEvent::Delivered { .. } => tally.delivered.fetch_add(1, Ordering::SeqCst),
1600 ReplayEvent::RetryableError { .. } => tally.retryable.fetch_add(1, Ordering::SeqCst),
1601 ReplayEvent::Dropped { .. } => tally.dropped.fetch_add(1, Ordering::SeqCst),
1602 ReplayEvent::PermanentFailure { .. } => tally.permanent_failure.fetch_add(1, Ordering::SeqCst),
1603 ReplayEvent::RetryExhausted { .. } => tally.retry_exhausted.fetch_add(1, Ordering::SeqCst),
1604 ReplayEvent::UnreadableEntry { .. } => tally.unreadable.fetch_add(1, Ordering::SeqCst),
1605 };
1606 }) as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
1607 }) as super::super::ReplayHook<String>
1608 };
1609
1610 let semaphore = Some(Arc::new(Semaphore::new(1)));
1611 let mut worker_store = store.clone();
1612 let worker = tokio::spawn(async move {
1613 stream_replay_worker(
1614 &mut worker_store,
1615 shared,
1616 cancel_rx,
1617 hook,
1618 semaphore,
1619 Duration::from_millis(10),
1620 Duration::from_millis(10),
1621 )
1622 .await;
1623 });
1624
1625 for _ in 0..600 {
1630 if stop(&tally) {
1631 break;
1632 }
1633 tokio::time::sleep(Duration::from_secs(1)).await;
1634 }
1635 let _ = cancel_tx.send(()).await;
1636 let _ = worker.await;
1637 }
1638
1639 #[tokio::test(start_paused = true)]
1644 async fn retryable_publish_error_retries_and_keeps_the_entry_live() {
1645 for detail in ["timed out", "broken pipe", "max ack pending", "stream not found"] {
1646 let dir = temp_dir(&format!("retryable-{}", detail.replace(' ', "-")));
1647 let mut store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
1648 store.open().unwrap();
1649 seed_entry(&store, "minted-id");
1650
1651 let target = Arc::new(ProgrammedTarget::new(
1652 Some(TargetError::JetStreamPublish {
1653 retryable: true,
1654 detail: detail.to_string(),
1655 }),
1656 store.clone(),
1657 ));
1658 let tally = Arc::new(ReplayTally::default());
1659 run_worker_until(&mut store, target.clone(), tally.clone(), |t| {
1662 t.retry_exhausted.load(Ordering::SeqCst) >= 1
1663 })
1664 .await;
1665
1666 assert!(tally.retryable.load(Ordering::SeqCst) >= 1, "{detail} retries at least once");
1667 assert_eq!(tally.retry_exhausted.load(Ordering::SeqCst), 1, "{detail} exhausts the bound once");
1668 assert_eq!(store.len(), 1, "{detail} keeps the entry on the live queue");
1669 assert_eq!(store.failed_len(), 0, "{detail} writes no failed entry");
1670 assert!(!dir.join("failed").exists(), "{detail} creates no failed directory");
1671
1672 let _ = store.delete();
1673 }
1674 }
1675
1676 #[tokio::test(start_paused = true)]
1680 async fn terminal_publish_error_writes_terminal_entry_without_retry() {
1681 for detail in [
1682 "max payload exceeded",
1683 "wrong last message id",
1684 "wrong last sequence",
1685 "stream sealed",
1686 ] {
1687 let dir = temp_dir(&format!("terminal-{}", detail.replace(' ', "-")));
1688 let mut store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
1689 store.open().unwrap();
1690 seed_entry(&store, "minted-id");
1691
1692 let target = Arc::new(ProgrammedTarget::new(
1693 Some(TargetError::JetStreamPublish {
1694 retryable: false,
1695 detail: detail.to_string(),
1696 }),
1697 store.clone(),
1698 ));
1699 let tally = Arc::new(ReplayTally::default());
1700 run_worker(&mut store, target.clone(), tally.clone()).await;
1701
1702 assert_eq!(tally.permanent_failure.load(Ordering::SeqCst), 1, "{detail} fails permanently once");
1703 assert_eq!(tally.retryable.load(Ordering::SeqCst), 0, "{detail} does not retry");
1704 assert_eq!(tally.retry_exhausted.load(Ordering::SeqCst), 0, "{detail} does not reach exhaustion");
1705 assert_eq!(store.len(), 0, "{detail} clears the live entry");
1706 assert_eq!(store.failed_len(), 1, "{detail} writes one terminal failed entry");
1707
1708 let failed_dir = dir.join("failed");
1710 let failed_file = std::fs::read_dir(&failed_dir).unwrap().next().unwrap().unwrap().path();
1711 let decoded = QueuedPayload::decode(&std::fs::read(&failed_file).unwrap()).unwrap();
1712 let failure = decoded.meta.failure.unwrap();
1713 assert_eq!(failure.error_class, crate::target::FailedErrorClass::Terminal);
1714 assert_eq!(failure.nats_msg_id, "minted-id");
1715 assert_eq!(decoded.meta.bucket_name, "bucket-a");
1716
1717 let _ = store.delete();
1718 }
1719 }
1720
1721 #[tokio::test(start_paused = true)]
1724 async fn no_replay_path_silently_drops_an_event() {
1725 let dir = temp_dir("nodrop-dropped");
1728 let mut store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
1729 store.open().unwrap();
1730 seed_entry(&store, "id");
1731 let target = Arc::new(ProgrammedTarget::new(
1732 Some(TargetError::Dropped("invalid entry".to_string())),
1733 store.clone(),
1734 ));
1735 let tally = Arc::new(ReplayTally::default());
1736 run_worker(&mut store, target.clone(), tally.clone()).await;
1737 assert_eq!(tally.dropped.load(Ordering::SeqCst), 1, "a dropped entry is reported, not silent");
1738 assert_eq!(store.failed_len(), 0, "a dropped invalid entry writes no failed entry");
1739
1740 let _ = store.delete();
1741 }
1742
1743 #[tokio::test(start_paused = true)]
1747 async fn audit_event_type_runs_the_identical_classifier_and_failed_store_logic() {
1748 let dir = temp_dir("audit-parity");
1749 let mut store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
1750 store.open().unwrap();
1751 seed_entry(&store, "audit-id");
1752
1753 let shared: Arc<dyn Target<u64> + Send + Sync> = Arc::new(ProgrammedTarget::new(
1754 Some(TargetError::JetStreamPublish {
1755 retryable: false,
1756 detail: "max payload exceeded".to_string(),
1757 }),
1758 store.clone(),
1759 ));
1760 let (cancel_tx, cancel_rx) = mpsc::channel(1);
1761 let hook = Arc::new(move |_event: ReplayEvent<u64>| {
1762 Box::pin(async move {}) as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
1763 }) as super::super::ReplayHook<u64>;
1764 let semaphore = Some(Arc::new(Semaphore::new(1)));
1765 let worker = stream_replay_worker(
1766 &mut store,
1767 shared,
1768 cancel_rx,
1769 hook,
1770 semaphore,
1771 Duration::from_millis(10),
1772 Duration::from_millis(10),
1773 );
1774 let driver = async move {
1775 tokio::time::sleep(Duration::from_secs(5)).await;
1776 let _ = cancel_tx.send(()).await;
1777 };
1778 tokio::join!(worker, driver);
1779
1780 assert_eq!(store.len(), 0, "the audit live entry is cleared after the move");
1781 assert_eq!(store.failed_len(), 1, "the audit path writes a failed entry through the shared worker");
1782
1783 let _ = store.delete();
1784 }
1785
1786 #[tokio::test]
1793 async fn admission_permit_is_held_across_the_send_bounding_concurrent_sends() {
1794 let shared_gate = Arc::new(Semaphore::new(Semaphore::MAX_PERMITS));
1797 shared_gate.forget_permits(Semaphore::MAX_PERMITS);
1798 let in_flight = Arc::new(AtomicUsize::new(0));
1799 let max_in_flight = Arc::new(AtomicUsize::new(0));
1800 let semaphore = Some(Arc::new(Semaphore::new(1)));
1801
1802 let mut stores = Vec::new();
1803 let mut workers = Vec::new();
1804 let mut cancels = Vec::new();
1805 for index in 0..2 {
1806 let dir = temp_dir(&format!("permit-{index}"));
1807 let store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
1808 store.open().unwrap();
1809 seed_entry(&store, &format!("id-{index}"));
1810
1811 let mut target = ProgrammedTarget::new(
1812 Some(TargetError::JetStreamPublish {
1813 retryable: true,
1814 detail: "timed out".to_string(),
1815 }),
1816 store.clone(),
1817 );
1818 target.send_gate = shared_gate.clone();
1819 target.in_flight = in_flight.clone();
1820 target.max_in_flight = max_in_flight.clone();
1821 let shared: Arc<dyn Target<String> + Send + Sync> = Arc::new(target);
1822
1823 let (cancel_tx, cancel_rx) = mpsc::channel(1);
1824 let hook = Arc::new(move |_event: ReplayEvent<String>| {
1825 Box::pin(async move {}) as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
1826 }) as super::super::ReplayHook<String>;
1827 let semaphore = semaphore.clone();
1828 let mut worker_store = store.clone();
1829 let worker = tokio::spawn(async move {
1830 stream_replay_worker(
1831 &mut worker_store,
1832 shared,
1833 cancel_rx,
1834 hook,
1835 semaphore,
1836 Duration::from_millis(10),
1837 Duration::from_millis(10),
1838 )
1839 .await;
1840 });
1841 stores.push(store);
1842 workers.push(worker);
1843 cancels.push(cancel_tx);
1844 }
1845
1846 for _ in 0..200 {
1848 if in_flight.load(Ordering::SeqCst) >= 1 {
1849 break;
1850 }
1851 tokio::time::sleep(Duration::from_millis(5)).await;
1852 }
1853 tokio::time::sleep(Duration::from_millis(100)).await;
1856
1857 assert_eq!(
1858 max_in_flight.load(Ordering::SeqCst),
1859 1,
1860 "the single permit is held across the send, so only one send is in flight at a time"
1861 );
1862
1863 drop(cancels);
1866 for worker in workers {
1867 worker.abort();
1868 let _ = worker.await;
1869 }
1870 for store in stores {
1871 let _ = store.delete();
1872 }
1873 }
1874
1875 #[tokio::test(start_paused = true)]
1879 async fn flag_off_path_creates_no_failed_directory() {
1880 let dir = temp_dir("flag-off");
1881 let mut store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
1882 store.open().unwrap();
1883 seed_entry(&store, "");
1884
1885 let target = Arc::new(ProgrammedTarget::new(Some(TargetError::NotConnected), store.clone()));
1888 let tally = Arc::new(ReplayTally::default());
1889 run_worker(&mut store, target.clone(), tally.clone()).await;
1890
1891 assert!(
1892 tally.retry_exhausted.load(Ordering::SeqCst) >= 1,
1893 "the connectivity error exhausts its retries"
1894 );
1895 assert!(!dir.join("failed").exists(), "no failed directory is created on the flag-off path");
1896 assert_eq!(store.failed_len(), 0);
1897
1898 let _ = store.delete();
1899 }
1900
1901 #[tokio::test(start_paused = true)]
1906 async fn jetstream_connect_failure_retries_and_keeps_the_entry_live() {
1907 let dir = temp_dir("connect-failure");
1908 let mut store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
1909 store.open().unwrap();
1910 seed_entry(&store, "minted-id");
1911
1912 let target = Arc::new(ProgrammedTarget::new(Some(TargetError::NotConnected), store.clone()));
1913 let tally = Arc::new(ReplayTally::default());
1914 run_worker_until(&mut store, target.clone(), tally.clone(), |t| {
1917 t.retry_exhausted.load(Ordering::SeqCst) >= 1
1918 })
1919 .await;
1920
1921 assert!(
1922 tally.retryable.load(Ordering::SeqCst) >= 1,
1923 "the connect failure retries within the bound"
1924 );
1925 assert_eq!(tally.retry_exhausted.load(Ordering::SeqCst), 1, "the exhaustion hook fires exactly once");
1926 assert_eq!(store.len(), 1, "the entry stays live through the outage");
1927 assert_eq!(store.failed_len(), 0, "an outage writes no failed entry");
1928 assert!(!dir.join("failed").exists(), "no failed-store write occurs for a connect failure");
1929
1930 let _ = store.delete();
1931 }
1932
1933 fn spawn_failing_store_worker(
1937 store: &FailingFailedStore,
1938 target: Arc<ProgrammedTarget>,
1939 tally: Arc<ReplayTally>,
1940 ) -> (mpsc::Sender<()>, tokio::task::JoinHandle<()>) {
1941 let shared: Arc<dyn Target<String> + Send + Sync> = target;
1942 let (cancel_tx, cancel_rx) = mpsc::channel(1);
1943 let hook = Arc::new(move |event: ReplayEvent<String>| {
1944 let tally = tally.clone();
1945 Box::pin(async move {
1946 match event {
1947 ReplayEvent::Delivered { .. } => tally.delivered.fetch_add(1, Ordering::SeqCst),
1948 ReplayEvent::RetryableError { .. } => tally.retryable.fetch_add(1, Ordering::SeqCst),
1949 ReplayEvent::Dropped { .. } => tally.dropped.fetch_add(1, Ordering::SeqCst),
1950 ReplayEvent::PermanentFailure { .. } => tally.permanent_failure.fetch_add(1, Ordering::SeqCst),
1951 ReplayEvent::RetryExhausted { .. } => tally.retry_exhausted.fetch_add(1, Ordering::SeqCst),
1952 ReplayEvent::UnreadableEntry { .. } => tally.unreadable.fetch_add(1, Ordering::SeqCst),
1953 };
1954 }) as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
1955 }) as super::super::ReplayHook<String>;
1956 let semaphore = Some(Arc::new(Semaphore::new(1)));
1957 let mut worker_store = store.clone();
1958 let worker = tokio::spawn(async move {
1959 stream_replay_worker(
1960 &mut worker_store,
1961 shared,
1962 cancel_rx,
1963 hook,
1964 semaphore,
1965 Duration::from_millis(10),
1966 Duration::from_millis(10),
1967 )
1968 .await;
1969 });
1970 (cancel_tx, worker)
1971 }
1972
1973 #[tokio::test(start_paused = true)]
1978 async fn a_failed_terminal_move_emits_no_hook_and_heals_once_repaired() {
1979 let dir = temp_dir("move-fail-terminal");
1980 let inner = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
1981 inner.open().unwrap();
1982 seed_entry(&inner, "minted-id");
1983
1984 let fail_flag = Arc::new(std::sync::atomic::AtomicBool::new(true));
1985 let store = FailingFailedStore {
1986 inner: inner.clone(),
1987 fail_put_failed_raw: fail_flag.clone(),
1988 fail_del: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1989 };
1990 let target = Arc::new(ProgrammedTarget::new_with_failed_store(
1993 Some(TargetError::JetStreamPublish {
1994 retryable: false,
1995 detail: "max payload exceeded".to_string(),
1996 }),
1997 inner.clone(),
1998 Arc::new(store.clone()),
1999 ));
2000 let tally = Arc::new(ReplayTally::default());
2001 let (cancel_tx, worker) = spawn_failing_store_worker(&store, target, tally.clone());
2002
2003 tokio::time::sleep(Duration::from_secs(300)).await;
2005 assert_eq!(
2006 tally.permanent_failure.load(Ordering::SeqCst),
2007 0,
2008 "no final-failure hook fires while the move keeps failing"
2009 );
2010 assert_eq!(inner.len(), 1, "the live entry stays for the next scan");
2011 assert_eq!(inner.failed_len(), 0, "no failed entry landed");
2012
2013 fail_flag.store(false, Ordering::SeqCst);
2015 tokio::time::sleep(Duration::from_secs(300)).await;
2016 assert_eq!(
2017 tally.permanent_failure.load(Ordering::SeqCst),
2018 1,
2019 "the hook fires exactly once for the completed move"
2020 );
2021 assert_eq!(inner.len(), 0, "the live entry is cleared by the completed move");
2022 assert_eq!(inner.failed_len(), 1, "the failed entry landed once");
2023
2024 let _ = cancel_tx.send(()).await;
2025 let _ = worker.await;
2026 let _ = inner.delete();
2027 }
2028
2029 #[tokio::test(start_paused = true)]
2033 async fn a_move_with_a_failing_delete_reports_unhandled_and_keeps_the_entry() {
2034 let dir = temp_dir("move-del-fail");
2035 let inner = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
2036 inner.open().unwrap();
2037 seed_entry(&inner, "minted-id");
2038
2039 let store = FailingFailedStore {
2042 inner: inner.clone(),
2043 fail_put_failed_raw: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2044 fail_del: Arc::new(std::sync::atomic::AtomicBool::new(true)),
2045 };
2046 let target = Arc::new(ProgrammedTarget::new(
2047 Some(TargetError::JetStreamPublish {
2048 retryable: false,
2049 detail: "max payload exceeded".to_string(),
2050 }),
2051 inner.clone(),
2052 ));
2053 let tally = Arc::new(ReplayTally::default());
2054 let (cancel_tx, worker) = spawn_failing_store_worker(&store, target, tally.clone());
2055
2056 tokio::time::sleep(Duration::from_secs(300)).await;
2058 assert_eq!(
2059 tally.permanent_failure.load(Ordering::SeqCst),
2060 0,
2061 "no final-failure hook fires while the delete fails"
2062 );
2063 assert_eq!(inner.len(), 1, "the live entry stays for the next scan");
2064 assert_eq!(inner.failed_len(), 1, "the durable failed copy landed exactly once");
2065
2066 let _ = cancel_tx.send(()).await;
2067 let _ = worker.await;
2068 let _ = inner.delete();
2069 }
2070
2071 #[tokio::test(start_paused = true)]
2076 async fn exhaustion_reports_without_a_trailing_backoff() {
2077 let dir = temp_dir("no-trailing-backoff");
2078 let store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
2079 store.open().unwrap();
2080 seed_entry(&store, "minted-id");
2081
2082 let wrapper = FailingFailedStore {
2084 inner: store.clone(),
2085 fail_put_failed_raw: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2086 fail_del: Arc::new(std::sync::atomic::AtomicBool::new(false)),
2087 };
2088 let target = Arc::new(ProgrammedTarget::new(
2089 Some(TargetError::JetStreamPublish {
2090 retryable: true,
2091 detail: "timed out".to_string(),
2092 }),
2093 store.clone(),
2094 ));
2095 let tally = Arc::new(ReplayTally::default());
2096
2097 let start = tokio::time::Instant::now();
2098 let (cancel_tx, worker) = spawn_failing_store_worker(&wrapper, target, tally.clone());
2099
2100 while tally.retry_exhausted.load(Ordering::SeqCst) == 0 {
2102 assert!(start.elapsed() < Duration::from_secs(600), "the exhaustion never reported");
2103 tokio::time::sleep(Duration::from_millis(100)).await;
2104 }
2105 let elapsed = start.elapsed();
2106 assert!(
2107 elapsed < Duration::from_secs(100),
2108 "the exhaustion reports after the inter-attempt backoffs only, got {elapsed:?}"
2109 );
2110 assert_eq!(store.len(), 1, "the entry stays on the live queue");
2111 assert_eq!(store.failed_len(), 0, "a retryable exhaustion writes no failed entry");
2112
2113 let _ = cancel_tx.send(()).await;
2114 let _ = worker.await;
2115 let _ = store.delete();
2116 }
2117
2118 #[tokio::test(start_paused = true)]
2122 async fn core_network_error_is_not_diverted_to_the_failed_store() {
2123 let dir = temp_dir("core-network");
2124 let mut store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
2125 store.open().unwrap();
2126 seed_entry(&store, "");
2127
2128 let target = Arc::new(ProgrammedTarget::new(
2129 Some(TargetError::Network("connection refused".to_string())),
2130 store.clone(),
2131 ));
2132 let tally = Arc::new(ReplayTally::default());
2133 run_worker(&mut store, target.clone(), tally.clone()).await;
2134
2135 assert!(
2136 tally.permanent_failure.load(Ordering::SeqCst) >= 1,
2137 "a core Network error is reported permanent, not silently dropped"
2138 );
2139 assert_eq!(tally.retryable.load(Ordering::SeqCst), 0, "a core Network error is not retried");
2140 assert_eq!(
2141 tally.retry_exhausted.load(Ordering::SeqCst),
2142 0,
2143 "a core Network error does not reach exhaustion"
2144 );
2145 assert_eq!(store.failed_len(), 0, "a core Network error writes no failed entry");
2146 assert_eq!(store.len(), 1, "a core Network error is left queued, its existing behaviour");
2147 assert!(!dir.join("failed").exists(), "no failed directory is created for a core Network error");
2148
2149 let _ = store.delete();
2150 }
2151
2152 #[tokio::test(start_paused = true)]
2156 async fn only_a_non_retryable_error_produces_a_failed_store_entry() {
2157 #[derive(Clone)]
2160 struct ClassifyingTarget {
2161 id: TargetID,
2162 store: QueueStore<QueuedPayload>,
2163 }
2164 #[async_trait]
2165 impl Target<String> for ClassifyingTarget {
2166 fn id(&self) -> TargetID {
2167 self.id.clone()
2168 }
2169 async fn is_active(&self) -> Result<bool, TargetError> {
2170 Ok(true)
2171 }
2172 async fn save(&self, _event: Arc<EntityTarget<String>>) -> Result<(), TargetError> {
2173 Ok(())
2174 }
2175 async fn send_raw_from_store(
2176 &self,
2177 _key: Key,
2178 _body: Vec<u8>,
2179 _meta: QueuedPayloadMeta,
2180 ) -> Result<(), TargetError> {
2181 Ok(())
2182 }
2183 async fn send_from_store(&self, key: Key) -> Result<(), TargetError> {
2184 let raw = self
2185 .store
2186 .get_raw(&key)
2187 .map_err(|err| TargetError::Unknown(err.to_string()))?;
2188 let decoded = QueuedPayload::decode(&raw).map_err(|err| TargetError::Unknown(err.to_string()))?;
2189 if decoded.meta.dedup_id == "terminal-id" {
2190 Err(TargetError::JetStreamPublish {
2191 retryable: false,
2192 detail: "max payload exceeded".to_string(),
2193 })
2194 } else {
2195 Err(TargetError::JetStreamPublish {
2196 retryable: true,
2197 detail: "timed out".to_string(),
2198 })
2199 }
2200 }
2201 async fn close(&self) -> Result<(), TargetError> {
2202 Ok(())
2203 }
2204 fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
2205 None
2206 }
2207 async fn handle_terminal_failure(
2208 &self,
2209 store: &(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send),
2210 key: &Key,
2211 error: &TargetError,
2212 retry_count: u32,
2213 ) -> bool {
2214 move_terminal_entry_for_test(store, &self.store, key, error, retry_count)
2215 }
2216 fn clone_dyn(&self) -> Box<dyn Target<String> + Send + Sync> {
2217 Box::new(self.clone())
2218 }
2219 fn is_enabled(&self) -> bool {
2220 true
2221 }
2222 }
2223
2224 let dir = temp_dir("terminals-only-invariant");
2225 let store = QueueStore::<QueuedPayload>::new_with_compression(&dir, 64, ".test", false);
2226 store.open().unwrap();
2227 seed_entry(&store, "retryable-id");
2228 seed_entry(&store, "terminal-id");
2229
2230 let shared: Arc<dyn Target<String> + Send + Sync> = Arc::new(ClassifyingTarget {
2231 id: TargetID::new("target-a".to_string(), "nats".to_string()),
2232 store: store.clone(),
2233 });
2234 let tally = Arc::new(ReplayTally::default());
2235 let (cancel_tx, cancel_rx) = mpsc::channel(1);
2236 let hook = {
2237 let tally = tally.clone();
2238 Arc::new(move |event: ReplayEvent<String>| {
2239 let tally = tally.clone();
2240 Box::pin(async move {
2241 match event {
2242 ReplayEvent::Delivered { .. } => tally.delivered.fetch_add(1, Ordering::SeqCst),
2243 ReplayEvent::RetryableError { .. } => tally.retryable.fetch_add(1, Ordering::SeqCst),
2244 ReplayEvent::Dropped { .. } => tally.dropped.fetch_add(1, Ordering::SeqCst),
2245 ReplayEvent::PermanentFailure { .. } => tally.permanent_failure.fetch_add(1, Ordering::SeqCst),
2246 ReplayEvent::RetryExhausted { .. } => tally.retry_exhausted.fetch_add(1, Ordering::SeqCst),
2247 ReplayEvent::UnreadableEntry { .. } => tally.unreadable.fetch_add(1, Ordering::SeqCst),
2248 };
2249 }) as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
2250 }) as super::super::ReplayHook<String>
2251 };
2252 let semaphore = Some(Arc::new(Semaphore::new(1)));
2253 let mut worker_store = store.clone();
2254 let worker = tokio::spawn(async move {
2255 stream_replay_worker(
2256 &mut worker_store,
2257 shared,
2258 cancel_rx,
2259 hook,
2260 semaphore,
2261 Duration::from_millis(10),
2262 Duration::from_millis(10),
2263 )
2264 .await;
2265 });
2266
2267 for _ in 0..600 {
2270 if store.failed_len() >= 1 && tally.retry_exhausted.load(Ordering::SeqCst) >= 1 {
2271 break;
2272 }
2273 tokio::time::sleep(Duration::from_secs(1)).await;
2274 }
2275 let _ = cancel_tx.send(()).await;
2276 let _ = worker.await;
2277
2278 assert_eq!(store.failed_len(), 1, "the failed store holds exactly one entry");
2279 assert_eq!(store.len(), 1, "the retryable entry stays on the live queue");
2280
2281 let failed_dir = dir.join("failed");
2283 let failed_file = std::fs::read_dir(&failed_dir).unwrap().next().unwrap().unwrap().path();
2284 let decoded = QueuedPayload::decode(&std::fs::read(&failed_file).unwrap()).unwrap();
2285 let failure = decoded.meta.failure.unwrap();
2286 assert_eq!(failure.error_class, crate::target::FailedErrorClass::Terminal);
2287 assert_eq!(failure.nats_msg_id, "terminal-id");
2288
2289 let _ = store.delete();
2290 }
2291 }
2292}