Skip to main content

rustfs_targets/runtime/
mod.rs

1// Copyright 2024 RustFS Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15pub 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
47/// Maximum number of replay attempts before a stored entry is exhausted. Each attempt runs one full
48/// send (one ack wait at the configured timeout for a JetStream entry), then a backoff sleep before
49/// the next. The JetStream duplicate-window validation derives its worst-case retry lifetime from the
50/// attempt count and REPLAY_BASE_RETRY_DELAY through inter_attempt_backoff_sum, the same source the
51/// sleep schedule builds on. Pinning tests hold each layer of the coupling: the shared per-attempt
52/// term, the sum over the schedule, retry_lifetime against that sum, and the realized sleep in
53/// replay_backoff_sleep under a paused clock.
54pub(crate) const REPLAY_MAX_RETRIES: usize = 5;
55
56/// Base unit of the exponential replay backoff. The sleep before the retry at shift n is this
57/// multiplied by 2^n.
58pub(crate) const REPLAY_BASE_RETRY_DELAY: Duration = Duration::from_secs(2);
59
60/// Backoff sleep before the retry attempt at `shift`: REPLAY_BASE_RETRY_DELAY doubled `shift` times.
61/// The single per-attempt term the replay sleep and the duplicate-window sum both derive from.
62pub(crate) fn replay_backoff_term(shift: u32) -> Duration {
63    REPLAY_BASE_RETRY_DELAY.saturating_mul(1u32 << shift)
64}
65
66/// Sum of the backoff sleeps that run between `attempts` replay sends. The retry at shift k is
67/// preceded by replay_backoff_term(k) for k in 1..attempts, so attempts minus one terms contribute
68/// and no sleep follows the final attempt. retry_lifetime derives its worst-case span from the same
69/// sum, so the sleep schedule and the stream duplicate-window requirement move together.
70pub(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
78/// Shared target trait object used by the runtime manager.
79pub 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
101/// Tracks a running replay worker: its cancel channel and, when the worker was
102/// spawned in-process, the [`JoinHandle`] used to await its exit on shutdown.
103struct 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    /// Registers a cancel channel without a join handle.
129    ///
130    /// Used where the worker's lifetime is managed elsewhere (or in tests). Such
131    /// workers are signalled on `stop_all` but not awaited. Prefer
132    /// [`Self::insert_with_handle`] for in-process workers so shutdown can join
133    /// them and avoid orphaned tasks.
134    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    /// Registers a cancel channel together with the worker's join handle so
140    /// `stop_all` can await the worker's exit.
141    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    /// Stops every replay worker: it first signals cancellation to all of them,
167    /// then strictly awaits each worker's exit. A worker already awaiting a
168    /// delivery acknowledgement is allowed to finish; aborting it at an
169    /// arbitrary deadline could leave an acknowledged queue entry undeleted and
170    /// make the replacement worker deliver it again. Signalling before joining
171    /// lets all workers wind down concurrently. Legacy joinless registrations
172    /// can only be signalled.
173    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        // Phase 1: signal cancellation to all workers.
178        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        // Phase 2: strict join. Delivery operations own their own protocol
192        // deadlines; lifecycle must not invent a shorter deadline that turns an
193        // acknowledgement race into duplicate delivery.
194        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
210/// Targets whose persistent queue stores are open and are ready to start
211/// replay. This distinct stage prevents activation from skipping store open.
212pub struct OpenedActivation<E>
213where
214    E: PluginEvent,
215{
216    pub(crate) targets: Vec<SharedTarget<E>>,
217}
218
219struct TargetActivationFailure {
220    detail: String,
221}
222
223/// Targets that have completed initialization but have not started replay
224/// workers yet. Keeping preparation dormant lets lifecycle orchestration stop
225/// the previous workers before the replacement workers are spawned.
226pub 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/// A read-only runtime snapshot for a target instance.
266#[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
326/// Shared runtime container for managing instantiated targets.
327///
328/// This intentionally focuses on low-risk shared lifecycle primitives first:
329/// add/remove/close/list/snapshot. Replay workers and reload orchestration can
330/// be layered on top in later phases.
331pub 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    /// Closes and removes every target, returning the id and error of each target whose close failed.
408    /// Surfacing them lets a caller fail an explicit shutdown while still tearing down the rest of the
409    /// runtime.
410    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
652/// Number of readable entries accumulated before a replay batch is flushed under a single semaphore
653/// permit.
654const REPLAY_BATCH_SIZE: usize = 16;
655
656/// Sleeps for `dur` unless a cancel signal arrives first. Returns `true` if
657/// cancellation was observed. Used so idle waits, inter-scan pauses, and retry
658/// backoff all react promptly to shutdown instead of blocking for the full
659/// duration.
660async 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
668/// Seeds an interval tracker one interval in the past so the first eligible tick runs the action
669/// immediately. The monotonic clock starts at host boot, so a process started within one interval
670/// of boot cannot represent an instant that far back. The seed falls back to now in that case and
671/// the first run waits one full interval.
672fn 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    // Lower bound between two failed-store expired-entry removal scans. Far below the retention TTL,
689    // so retention is unaffected, while keeping the read_dir scan off every idle tick.
690    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    // Seeded one interval back so the first eligible tick runs the removal. Near host boot the
695    // seed is now and the first removal waits one interval.
696    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        // Remove expired failed-store entries on the replay tick rather than a separate timer, at most
704        // once per interval so the idle path does not scan the directory on every tick. Only a target
705        // that records terminal failures carries a failed store, so a target without one skips the scan.
706        if let Some(failed_store) = target.failed_store()
707            && last_prune.elapsed() >= FAILED_STORE_PRUNE_INTERVAL
708        {
709            // Run the stat-and-sort scan off the async worker thread. The owned handle shares the same
710            // directory and cached-count state through its Arc handles, so the scan reconciles the
711            // count the live handles read.
712            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            // Skip keys already pending in the current batch: an un-flushed
769            // partial batch carries across scans, and `store.list()` keeps
770            // returning not-yet-delivered keys, so without this guard the same
771            // key would be enqueued repeatedly.
772            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            // Flush once a full batch has accumulated or the batch has aged past
781            // batch_timeout — real size/time-based batching, not once-per-entry.
782            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        // Flush a partial batch that has aged past batch_timeout, so a lone or slow-arriving entry is
792        // delivered rather than stranded waiting for a full batch to accumulate.
793        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/// Delivers a batch of queued entries, each send bounded by a freshly acquired semaphore permit held
807/// across the send and released before any backoff sleep or failed-store move.
808///
809/// Returns `true` if a cancel signal was observed while processing (e.g. during
810/// retry backoff), so the caller can stop promptly instead of continuing to
811/// drain a store that a replacement worker may already own.
812#[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            // The permit bounds how many replay sends run concurrently across targets sharing the
838            // semaphore. It is held across the send await so the cap is real, then released before
839            // any backoff sleep or failed-store move, because the sleep blocks for the backoff and
840            // holding the shared permit across it would throttle the whole replay path. An absent
841            // semaphore means no bound (the audit path).
842            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                        // Drop the batch so its keys do not strand under the dedup guard.
849                        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                        // The backoff runs only when another attempt follows, so the final failure
881                        // proceeds straight to the exhaustion handling. Cancellation is observed
882                        // during the sleep so shutdown/reload is not blocked for the full
883                        // (potentially many-second) delay.
884                        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                        // The hook fires only on a completed move. On a failed move the entry stays
891                        // live, the next scan retries the move, and a repaired failed-store
892                        // directory heals without a restart, with the hook then firing exactly once.
893                        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            // A retryable error that never succeeded within the bound leaves the entry on the live
927            // queue for the next scan. The exhaustion is reported through the hook for metrics.
928            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
941/// Sleeps the exponential backoff for a retry attempt, with key-derived jitter. Returns `true` if a
942/// cancel signal arrives first, so the caller can stop promptly instead of blocking for the full delay.
943async 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
948/// Reacts to a terminal classification by delegating to the target's terminal-failure handling and
949/// reports whether the entry was handled. The target moves the entry to its failed-events store and
950/// reports true, or declines and reports false so the caller keeps the entry live and skips the
951/// final-failure hook. A target without a terminal-failure store always declines.
952async 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        // With one interval of clock history available, the seed is exactly one interval in the
978        // past, so the first eligible tick runs the action immediately.
979        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        // Duration::MAX reaches past the monotonic clock origin on any host, so the checked
988        // subtraction yields no instant and the seed falls back to now instead of panicking.
989        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        // Sums the shared per-attempt term over the schedule shifts (1..REPLAY_MAX_RETRIES, no
996        // trailing sleep after the last attempt) and asserts it equals inter_attempt_backoff_sum.
997        // The sleep's own use of the term is pinned by the paused-clock test below.
998        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        // 2s * (2 + 4 + 8 + 16) at the default base.
1004        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        // Drives the sleep itself, so a shifted argument at its replay_backoff_term call (for
1010        // example retry_count minus 1) fails here even though the term and sum tests still pass.
1011        // The jitter is deterministic, the key string length modulo 500 milliseconds, so the
1012        // virtual elapsed time is exact under the paused clock.
1013        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        // The held sender keeps the cancel channel open so the sleep runs to its deadline.
1023        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        // A pending cancel signal must short-circuit a long sleep.
1202        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            // Run until cancelled, then record clean exit.
1230            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        // stop_all must signal AND await the worker: once it returns, the worker
1242        // has actually exited (no orphaned task).
1243        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            // Model a protocol operation that has accepted the request but has
1267            // not returned its acknowledgement yet. Lifecycle must not abort
1268            // this future or the same durable entry can be sent twice.
1269            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        /// Records each ReplayEvent class the worker emitted, so a test can assert no event class was
1309        /// silently skipped.
1310        #[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        /// A target whose send_from_store returns a programmed error each call, so the replay
1321        /// classifier can be exercised per error class against a real on-disk store.
1322        #[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            // A clone of the worker's store so the mock can delete on the Dropped or success path, as
1331            // the real send_from_store does, rather than leaving the entry to be re-listed forever.
1332            store: QueueStore<QueuedPayload>,
1333            // The failed-events handle the terminal move writes to. Defaults to the store itself and is
1334            // overridden with a rejecting handle to model an unwritable failed store.
1335            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        /// Models the NATS target's terminal move for the classifier tests: reads the entry, encodes it
1364        /// as a terminal failed entry, writes it to the failed-events store, then clears the live entry.
1365        /// A failed write leaves the live entry in place for the next scan. The delete outcome mirrors
1366        /// the production move, so a delete failure returns false rather than reporting the entry handled.
1367        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            // A missing entry is not a failure, any other delete error declines the move, matching the
1396            // production delete which treats NotFound as done and propagates every other error.
1397            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                // The gate lets a test hold a send in flight to observe permit behaviour during the
1422                // await. It is open by default.
1423                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                    // A Dropped error means send_from_store already removed the invalid entry before
1428                    // returning, so the mock deletes it too. A retryable or terminal publish error
1429                    // leaves the entry in place, matching the real path where the entry is cleared only
1430                    // on Ok or by the failed-store move.
1431                    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                        // Success clears the entry, as the real default implementation does.
1438                        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        /// A store that delegates every operation to a real on-disk QueueStore but fails
1467        /// put_failed_raw or del while the matching flag is set, modeling an unwritable failed-store
1468        /// directory or a live queue whose delete fails, either of which an operator later repairs.
1469        #[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        /// Runs the replay worker against the seeded store for up to 600 virtual seconds, then cancels.
1574        ///
1575        /// The worker runs on a store clone (the QueueStore shares its state through an Arc), so the
1576        /// caller's handle observes drain while the worker holds its own mutable borrow. Tests run with
1577        /// a paused clock, so the production backoff sleeps cost no real time and the loop drains fast.
1578        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        /// Runs the replay worker like run_worker, polling the tally once per virtual second and
1583        /// cancelling as soon as the stop condition holds, so a test over an entry that stays live
1584        /// can observe exactly one retry cycle instead of every cycle the full window admits.
1585        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            // The paused clock makes the production backoff sleeps free, so the virtual window lets
1626            // the worker fully process the seeded entry (a full retry exhaustion is about two
1627            // virtual minutes) before the worker is cancelled. The once-per-virtual-second poll
1628            // cancels within one second of the stop condition, well inside a retry cycle.
1629            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        // A retryable JetStream publish error retries within the bound and, on exhaustion, leaves the
1640        // entry on the live queue rather than moving it to the failed store. Covers TimedOut,
1641        // BrokenPipe, MaxAckPending, and the bounded StreamNotFound path, all of which the classifier
1642        // carries as a retryable publish error.
1643        #[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                // The entry stays live and is rescanned after exhaustion, so the run stops at the
1660                // first exhaustion to observe one full retry cycle.
1661                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        // A terminal JetStream publish error writes a terminal failed entry on the first attempt with
1677        // no retry, and the live entry is cleared. Covers MaxPayloadExceeded, WrongLastMessageId,
1678        // WrongLastSequence, and the terminal Other code, all carried as a non-retryable publish error.
1679        #[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                // The failed entry carries the terminal class and the full meta.
1709                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        // A Dropped entry is reported through the drop hook rather than silently discarded, and it
1722        // writes no failed entry.
1723        #[tokio::test(start_paused = true)]
1724        async fn no_replay_path_silently_drops_an_event() {
1725            // A Dropped error is recorded through the drop hook and writes no failed entry, because a
1726            // dropped payload is an invalid entry the store itself removed, not a deliverable event.
1727            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        // The audit path and the notify path monomorphize the same generic replay worker, so the
1744        // classifier and the failed-store write run identically for an audit entry type. Driving the
1745        // worker over a non-String event type exercises the audit monomorphization.
1746        #[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        // The replay admission permit is held across the send await, so under a single-permit
1787        // semaphore only one worker enters its send at a time. Two workers share a single-permit
1788        // semaphore and a closed gate that holds every send in flight. The first worker admits and
1789        // enters its gated send. The second worker blocks on the permit and never enters its send, so
1790        // at most one send is in flight. The permit is still released before the backoff sleep, so a
1791        // send that fails does not hold the shared permit across the backoff.
1792        #[tokio::test]
1793        async fn admission_permit_is_held_across_the_send_bounding_concurrent_sends() {
1794            // A shared gate held closed keeps every send blocked inside its await, and a shared
1795            // in-flight counter observes how many sends are concurrently in their await.
1796            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            // Wait until the first worker has admitted and entered its gated send.
1847            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            // Give the second worker ample time to try to admit. It must block on the held permit and
1854            // never enter its send.
1855            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            // The observation is complete. Abort the workers rather than draining them, so the test
1864            // does not wait out the production backoff after the gated sends resume.
1865            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        // With the flag off the NATS target never produces a JetStream publish error, so the worker
1876        // never writes the failed store. A non-JetStream connectivity error retries and exhausts
1877        // through the existing path without creating the failed directory.
1878        #[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            // A flag-off target surfaces connectivity errors, not JetStreamPublish, so the failed-store
1886            // move is never triggered.
1887            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        // A connect-level failure on a JetStream target surfaces as NotConnected, mapped at the
1902        // publish path. The worker retries it with backoff and leaves the entry live at exhaustion,
1903        // so a broker outage writes no failed-store entry and the event delivers when the connection
1904        // recovers, matching the Core path.
1905        #[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            // The live entry is rescanned after exhaustion, so the run stops at the first
1915            // exhaustion to observe one full retry cycle.
1916            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        /// Spawns the replay worker over a FailingFailedStore and returns the cancel channel and
1934        /// join handle, so a test can advance virtual time, repair the failed store mid-run, and
1935        /// observe the hook tally across the repair.
1936        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        // A terminal failure whose failed-store move fails must not count as a final failure. No
1974        // PermanentFailure hook fires, no failed entry lands, and the live entry stays for the next
1975        // scan. Once the failed store is repaired, the move completes and the hook fires exactly
1976        // once, so one lost event counts once rather than zero or once per scan.
1977        #[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            // The target's terminal move writes through the rejecting failed handle, so the injected
1991            // failure is what the move hits.
1992            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            // Many scans run while the failed store rejects the move.
2004            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            // The operator repairs the failed store. The next scan completes the move.
2014            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        // A terminal move whose failed-record write succeeds but whose live delete fails is not
2030        // handled: no final-failure hook fires and the live entry stays for the next scan, while the
2031        // durable failed copy is already in place and stays single through the idempotent overwrite.
2032        #[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            // The worker's live store rejects deletes while the failed handle (the plain inner store)
2040            // accepts the failed-record write, so only the delete half of the move fails.
2041            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            // Many scans run while the live delete keeps failing.
2057            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        // The fifth failure is the last attempt, so no backoff follows it. The exhaustion is reported
2072        // after the four inter-attempt backoffs, 2s * (2 + 4 + 8 + 16) = 60s plus sub-second jitter,
2073        // well inside the 100s bound asserted here. A trailing backoff after the final failure would
2074        // add 64s more and push the report past the bound, failing the assertion.
2075        #[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            // The delegating wrapper with both failure flags off behaves as the plain on-disk store.
2083            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            // Poll the virtual clock until the first exhaustion is reported.
2101            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        // A raw Network error models a non-JetStream core target, whose connect failure keeps the core
2119        // handling: a permanent failure left in the live queue, never diverted into the JetStream
2120        // retryable path or the failed store.
2121        #[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        // The single failed-store invariant: only a non-retryable error ever produces a failed-store
2153        // entry. One retryable entry driven past the budget stays on the live queue while one terminal
2154        // entry moves, so after both are processed the failed store holds exactly the terminal entry.
2155        #[tokio::test(start_paused = true)]
2156        async fn only_a_non_retryable_error_produces_a_failed_store_entry() {
2157            // A target that reads each entry and returns a terminal error for the entry whose dedup id
2158            // marks it terminal, and a retryable error for every other entry.
2159            #[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            // Drive until the terminal entry has moved and the retryable entry has exhausted at least
2268            // once. Both share one batch, so one pass processes each.
2269            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            // The one failed entry is the terminal one.
2282            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}