Skip to main content

durable_execution_sdk/
config.rs

1//! Configuration types for durable execution operations.
2//!
3//! This module provides type-safe configuration structs for all
4//! durable operations including steps, callbacks, invocations,
5//! map, and parallel operations.
6//!
7//! ## Performance Configuration
8//!
9//! The SDK supports different checkpointing modes that trade off between
10//! durability and performance. See [`CheckpointingMode`] for details.
11//!
12//! ## Sealed Traits
13//!
14//! The `RetryStrategy` trait is sealed and cannot be implemented outside of this crate.
15//! This allows the SDK maintainers to evolve the retry interface without breaking
16//! external code.
17
18use std::marker::PhantomData;
19use std::sync::Arc;
20
21use blake2::{Blake2b512, Digest};
22use serde::{Deserialize, Serialize};
23
24use crate::duration::Duration;
25use crate::error::DurableError;
26use crate::sealed::Sealed;
27
28/// Jitter strategy for retry delays.
29///
30/// Jitter adds randomness to retry delays to prevent thundering herd problems
31/// when many executions retry simultaneously.
32///
33/// # Variants
34///
35/// - `None` — Use the exact calculated delay (no jitter).
36/// - `Full` — Random delay in `[0, calculated_delay]`.
37/// - `Half` — Random delay in `[calculated_delay/2, calculated_delay]`.
38///
39/// # Example
40///
41/// ```
42/// use durable_execution_sdk::config::JitterStrategy;
43///
44/// let none = JitterStrategy::None;
45/// assert_eq!(none.apply(10.0, 1), 10.0);
46///
47/// let full = JitterStrategy::Full;
48/// let jittered = full.apply(10.0, 1);
49/// assert!(jittered >= 0.0 && jittered <= 10.0);
50///
51/// let half = JitterStrategy::Half;
52/// let jittered = half.apply(10.0, 1);
53/// assert!(jittered >= 5.0 && jittered <= 10.0);
54/// ```
55///
56/// # Requirements
57///
58/// - 1.1: JitterStrategy enum with None, Full, Half variants
59/// - 1.2: None returns delay exactly
60/// - 1.3: Full returns delay in [0, d]
61/// - 1.4: Half returns delay in [d/2, d]
62/// - 1.5: Default returns None
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum JitterStrategy {
65    /// No jitter — use exact calculated delay.
66    #[default]
67    None,
68    /// Full jitter — random delay in [0, calculated_delay].
69    Full,
70    /// Half jitter — random delay in [calculated_delay/2, calculated_delay].
71    Half,
72}
73
74impl JitterStrategy {
75    /// Applies jitter to a delay value in seconds.
76    ///
77    /// Uses a deterministic seed derived from the attempt number via blake2b
78    /// hashing. This makes jitter replay-safe since the same attempt always
79    /// produces the same jittered value.
80    ///
81    /// # Arguments
82    ///
83    /// * `delay_secs` - The base delay in seconds
84    /// * `attempt` - The retry attempt number (used as seed for deterministic randomness)
85    ///
86    /// # Returns
87    ///
88    /// The jittered delay in seconds:
89    /// - `None`: returns `delay_secs` exactly
90    /// - `Full`: returns a value in `[0, delay_secs]`
91    /// - `Half`: returns a value in `[delay_secs/2, delay_secs]`
92    pub fn apply(&self, delay_secs: f64, attempt: u32) -> f64 {
93        match self {
94            JitterStrategy::None => delay_secs,
95            JitterStrategy::Full => {
96                let factor = deterministic_random_factor(attempt);
97                factor * delay_secs
98            }
99            JitterStrategy::Half => {
100                let factor = deterministic_random_factor(attempt);
101                delay_secs / 2.0 + factor * (delay_secs / 2.0)
102            }
103        }
104    }
105}
106
107/// Generates a deterministic random factor in [0.0, 1.0) from an attempt number.
108///
109/// Uses blake2b hashing to produce a deterministic pseudo-random value
110/// seeded by the attempt number. This ensures replay safety.
111fn deterministic_random_factor(attempt: u32) -> f64 {
112    let mut hasher = Blake2b512::new();
113    hasher.update(b"jitter");
114    hasher.update(attempt.to_le_bytes());
115    let result = hasher.finalize();
116
117    // Take the first 8 bytes and convert to a u64, then normalize to [0.0, 1.0)
118    let mut bytes = [0u8; 8];
119    bytes.copy_from_slice(&result[..8]);
120    let value = u64::from_le_bytes(bytes);
121    (value as f64) / (u64::MAX as f64)
122}
123
124/// Decision returned by a wait strategy.
125///
126/// A wait strategy function returns this enum to indicate whether polling
127/// should continue (with a specified delay) or stop (condition is met).
128///
129/// # Variants
130///
131/// - `Continue { delay }` — Continue polling after the specified delay.
132/// - `Done` — Stop polling; the condition has been met.
133///
134/// # Example
135///
136/// ```
137/// use durable_execution_sdk::config::WaitDecision;
138/// use durable_execution_sdk::Duration;
139///
140/// let cont = WaitDecision::Continue { delay: Duration::from_seconds(5) };
141/// let done = WaitDecision::Done;
142/// ```
143///
144/// # Requirements
145///
146/// - 4.1: WaitDecision enum with Continue { delay: Duration } and Done variants
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum WaitDecision {
149    /// Continue polling after the specified delay.
150    Continue { delay: Duration },
151    /// Stop polling — condition is met.
152    Done,
153}
154
155/// Configuration for creating a wait strategy.
156///
157/// This struct holds all the parameters needed to build a wait strategy function
158/// via [`create_wait_strategy`]. The resulting function can be used with
159/// [`WaitForConditionConfig`](crate::context::WaitForConditionConfig) to control
160/// polling behavior with backoff, jitter, and a custom predicate.
161///
162/// # Type Parameters
163///
164/// - `T`: The state type returned by the condition check function.
165///
166/// # Example
167///
168/// ```
169/// use durable_execution_sdk::config::{WaitStrategyConfig, JitterStrategy, create_wait_strategy, WaitDecision};
170/// use durable_execution_sdk::Duration;
171///
172/// let config = WaitStrategyConfig {
173///     max_attempts: Some(10),
174///     initial_delay: Duration::from_seconds(5),
175///     max_delay: Duration::from_seconds(300),
176///     backoff_rate: 1.5,
177///     jitter: JitterStrategy::Full,
178///     should_continue_polling: Box::new(|state: &String| state != "COMPLETED"),
179/// };
180///
181/// let strategy = create_wait_strategy(config);
182/// // strategy(&"COMPLETED".to_string(), 1) => WaitDecision::Done
183/// ```
184///
185/// # Requirements
186///
187/// - 4.2: should_continue_polling returning false → Done
188/// - 4.3: should_continue_polling returning true + attempts < max → Continue with delay >= 1s
189/// - 4.4: Panic when max_attempts exceeded
190/// - 4.5: Delay = min(initial_delay * backoff_rate^(N-1), max_delay)
191/// - 4.6: Jitter applied to base delay
192pub struct WaitStrategyConfig<T> {
193    /// Maximum number of polling attempts. `None` defaults to 60.
194    pub max_attempts: Option<usize>,
195    /// Initial delay between polls.
196    pub initial_delay: Duration,
197    /// Maximum delay cap.
198    pub max_delay: Duration,
199    /// Backoff multiplier applied per attempt.
200    pub backoff_rate: f64,
201    /// Jitter strategy applied to the computed delay.
202    pub jitter: JitterStrategy,
203    /// Predicate that returns `true` if polling should continue, `false` if the condition is met.
204    pub should_continue_polling: Box<dyn Fn(&T) -> bool + Send + Sync>,
205}
206
207/// Creates a wait strategy function from the given configuration.
208///
209/// The returned closure takes a reference to the current state and the number of
210/// attempts made so far (1-indexed), and returns a [`WaitDecision`].
211///
212/// # Behavior
213///
214/// 1. If `should_continue_polling` returns `false`, returns `WaitDecision::Done`.
215/// 2. If `attempts_made >= max_attempts` and `should_continue_polling` is `true`,
216///    panics with a message indicating max attempts exceeded.
217/// 3. Otherwise, computes delay as `min(initial_delay * backoff_rate^(attempts_made - 1), max_delay)`,
218///    applies jitter, floors at 1 second, and returns `WaitDecision::Continue { delay }`.
219///
220/// # Requirements
221///
222/// - 4.2: should_continue_polling returning false → Done
223/// - 4.3: should_continue_polling returning true + attempts < max → Continue with delay >= 1s
224/// - 4.4: Panic when max_attempts exceeded
225/// - 4.5: Delay = min(initial_delay * backoff_rate^(N-1), max_delay)
226/// - 4.6: Jitter applied to base delay
227#[allow(clippy::type_complexity)]
228pub fn create_wait_strategy<T: Send + Sync + 'static>(
229    config: WaitStrategyConfig<T>,
230) -> Box<dyn Fn(&T, usize) -> WaitDecision + Send + Sync> {
231    let max_attempts = config.max_attempts.unwrap_or(60);
232    let initial_delay_secs = config.initial_delay.to_seconds() as f64;
233    let max_delay_secs = config.max_delay.to_seconds() as f64;
234    let backoff_rate = config.backoff_rate;
235    let jitter = config.jitter;
236    let should_continue = config.should_continue_polling;
237
238    Box::new(move |result: &T, attempts_made: usize| -> WaitDecision {
239        // Check if condition is met
240        if !should_continue(result) {
241            return WaitDecision::Done;
242        }
243
244        // Check max attempts
245        if attempts_made >= max_attempts {
246            panic!(
247                "waitForCondition exceeded maximum attempts ({})",
248                max_attempts
249            );
250        }
251
252        // Calculate delay with exponential backoff
253        let exponent = if attempts_made > 0 {
254            (attempts_made as i32) - 1
255        } else {
256            0
257        };
258        let base_delay = (initial_delay_secs * backoff_rate.powi(exponent)).min(max_delay_secs);
259
260        // Apply jitter
261        let jittered = jitter.apply(base_delay, attempts_made as u32);
262        let final_delay = jittered.max(1.0).round() as u64;
263
264        WaitDecision::Continue {
265            delay: Duration::from_seconds(final_delay),
266        }
267    })
268}
269
270/// Checkpointing mode that controls the trade-off between durability and performance.
271///
272/// The checkpointing mode determines when and how often the SDK persists operation
273/// state to the durable execution service. Different modes offer different trade-offs:
274///
275/// ## Modes
276///
277/// ### Eager Mode
278/// - Checkpoints after every operation completes
279/// - Maximum durability: minimal work is lost on failure
280/// - More API calls: higher latency and cost
281/// - Best for: Critical workflows where every operation must be durable
282///
283/// ### Batched Mode (Default)
284/// - Groups multiple operations into batches before checkpointing
285/// - Balanced durability: some operations may be replayed on failure
286/// - Fewer API calls: better performance and lower cost
287/// - Best for: Most workflows with reasonable durability requirements
288///
289/// ### Optimistic Mode
290/// - Executes multiple operations before checkpointing
291/// - Minimal durability: more work may be replayed on failure
292/// - Best performance: fewest API calls
293/// - Best for: Workflows where replay is cheap and performance is critical
294///
295/// ## Example
296///
297/// ```rust
298/// use durable_execution_sdk::CheckpointingMode;
299///
300/// // Use eager mode for maximum durability
301/// let eager = CheckpointingMode::Eager;
302///
303/// // Use batched mode for balanced performance (default)
304/// let batched = CheckpointingMode::default();
305///
306/// // Use optimistic mode for best performance
307/// let optimistic = CheckpointingMode::Optimistic;
308/// ```
309///
310/// ## Requirements
311///
312/// - 24.1: THE Performance_Configuration SHALL support eager checkpointing mode
313/// - 24.2: THE Performance_Configuration SHALL support batched checkpointing mode
314/// - 24.3: THE Performance_Configuration SHALL support optimistic execution mode
315/// - 24.4: THE Performance_Configuration SHALL document the default behavior and trade-offs
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
317pub enum CheckpointingMode {
318    /// Checkpoint after every operation for maximum durability.
319    ///
320    /// This mode provides the strongest durability guarantees but has the
321    /// highest overhead due to frequent API calls.
322    ///
323    /// ## Characteristics
324    /// - Every operation is immediately checkpointed
325    /// - Minimal work lost on failure (at most one operation)
326    /// - Higher latency due to synchronous checkpointing
327    /// - More API calls and higher cost
328    ///
329    /// ## Use Cases
330    /// - Financial transactions
331    /// - Critical business workflows
332    /// - Operations with expensive side effects
333    ///
334    /// ## Requirements
335    /// - 24.1: THE Performance_Configuration SHALL support eager checkpointing mode
336    Eager,
337
338    /// Batch multiple operations before checkpointing for balanced performance.
339    ///
340    /// This is the default mode that provides a good balance between durability
341    /// and performance. Operations are grouped into batches based on size, count,
342    /// or time limits before being checkpointed together.
343    ///
344    /// ## Characteristics
345    /// - Operations are batched before checkpointing
346    /// - Some operations may be replayed on failure
347    /// - Better performance than eager mode
348    /// - Configurable batch size and timing
349    ///
350    /// ## Use Cases
351    /// - Most general-purpose workflows
352    /// - Workflows with moderate durability requirements
353    /// - Cost-sensitive applications
354    ///
355    /// ## Requirements
356    /// - 24.2: THE Performance_Configuration SHALL support batched checkpointing mode
357    Batched,
358
359    /// Execute multiple operations before checkpointing for best performance.
360    ///
361    /// This mode prioritizes performance over durability by executing multiple
362    /// operations before creating a checkpoint. On failure, more work may need
363    /// to be replayed.
364    ///
365    /// ## Characteristics
366    /// - Multiple operations execute before checkpointing
367    /// - More work may be replayed on failure
368    /// - Best performance and lowest cost
369    /// - Suitable for idempotent operations
370    ///
371    /// ## Use Cases
372    /// - High-throughput batch processing
373    /// - Workflows with cheap, idempotent operations
374    /// - Performance-critical applications
375    ///
376    /// ## Requirements
377    /// - 24.3: THE Performance_Configuration SHALL support optimistic execution mode
378    Optimistic,
379}
380
381impl Default for CheckpointingMode {
382    /// Returns the default checkpointing mode (Batched).
383    ///
384    /// Batched mode is the default because it provides a good balance between
385    /// durability and performance for most use cases.
386    fn default() -> Self {
387        Self::Batched
388    }
389}
390
391impl CheckpointingMode {
392    /// Returns true if this mode checkpoints after every operation.
393    pub fn is_eager(&self) -> bool {
394        matches!(self, Self::Eager)
395    }
396
397    /// Returns true if this mode batches operations before checkpointing.
398    pub fn is_batched(&self) -> bool {
399        matches!(self, Self::Batched)
400    }
401
402    /// Returns true if this mode executes multiple operations before checkpointing.
403    pub fn is_optimistic(&self) -> bool {
404        matches!(self, Self::Optimistic)
405    }
406
407    /// Returns a human-readable description of this mode.
408    pub fn description(&self) -> &'static str {
409        match self {
410            Self::Eager => "Checkpoint after every operation (maximum durability)",
411            Self::Batched => "Batch operations before checkpointing (balanced)",
412            Self::Optimistic => {
413                "Execute multiple operations before checkpointing (best performance)"
414            }
415        }
416    }
417}
418
419/// Retry strategy trait for configuring step retry behavior.
420///
421/// # Sealed Trait
422///
423/// This trait is sealed and cannot be implemented outside of this crate.
424/// This allows the SDK maintainers to evolve the retry interface without
425/// breaking external code. If you need custom retry behavior, use the
426/// provided factory functions.
427///
428/// # Requirements
429///
430/// - 3.3: THE SDK SHALL implement the sealed trait pattern for the `RetryStrategy` trait
431/// - 3.5: THE SDK SHALL document that these traits are sealed and cannot be implemented externally
432#[allow(private_bounds)]
433pub trait RetryStrategy: Sealed + Send + Sync {
434    /// Returns the delay before the next retry attempt, or None if no more retries.
435    fn next_delay(&self, attempt: u32, error: &str) -> Option<Duration>;
436
437    /// Clone the retry strategy into a boxed trait object.
438    fn clone_box(&self) -> Box<dyn RetryStrategy>;
439}
440
441impl Clone for Box<dyn RetryStrategy> {
442    fn clone(&self) -> Self {
443        self.clone_box()
444    }
445}
446
447// =============================================================================
448// Built-in Retry Strategies
449// =============================================================================
450
451/// Exponential backoff retry strategy.
452///
453/// Delays increase exponentially with each attempt: `base_delay * 2^(attempt-1)`,
454/// capped at `max_delay`. Includes optional jitter to prevent thundering herd.
455///
456/// # Example
457///
458/// ```
459/// use durable_execution_sdk::config::ExponentialBackoff;
460/// use durable_execution_sdk::Duration;
461///
462/// // Retry up to 5 times with exponential backoff starting at 1 second
463/// let strategy = ExponentialBackoff::new(5, Duration::from_seconds(1));
464///
465/// // With custom max delay
466/// let strategy = ExponentialBackoff::builder()
467///     .max_attempts(5)
468///     .base_delay(Duration::from_seconds(1))
469///     .max_delay(Duration::from_minutes(5))
470///     .build();
471/// ```
472#[derive(Debug, Clone)]
473pub struct ExponentialBackoff {
474    /// Maximum number of retry attempts (not including the initial attempt).
475    pub max_attempts: u32,
476    /// Initial delay before the first retry.
477    pub base_delay: Duration,
478    /// Maximum delay between retries.
479    pub max_delay: Duration,
480    /// Multiplier for exponential growth (default: 2.0).
481    pub multiplier: f64,
482    /// Jitter strategy applied to computed delays.
483    pub jitter: JitterStrategy,
484}
485
486impl ExponentialBackoff {
487    /// Creates a new exponential backoff strategy with default settings.
488    ///
489    /// # Arguments
490    ///
491    /// * `max_attempts` - Maximum number of retry attempts
492    /// * `base_delay` - Initial delay before the first retry
493    pub fn new(max_attempts: u32, base_delay: Duration) -> Self {
494        Self {
495            max_attempts,
496            base_delay,
497            max_delay: Duration::from_hours(1),
498            multiplier: 2.0,
499            jitter: JitterStrategy::None,
500        }
501    }
502
503    /// Creates a builder for more detailed configuration.
504    pub fn builder() -> ExponentialBackoffBuilder {
505        ExponentialBackoffBuilder::default()
506    }
507}
508
509impl Sealed for ExponentialBackoff {}
510
511impl RetryStrategy for ExponentialBackoff {
512    fn next_delay(&self, attempt: u32, _error: &str) -> Option<Duration> {
513        if attempt >= self.max_attempts {
514            return None;
515        }
516
517        let base_seconds = self.base_delay.to_seconds() as f64;
518        let delay_seconds = base_seconds * self.multiplier.powi(attempt as i32);
519        let max_seconds = self.max_delay.to_seconds() as f64;
520        let capped_seconds = delay_seconds.min(max_seconds);
521
522        let jittered = self.jitter.apply(capped_seconds, attempt);
523        let final_seconds = jittered.max(1.0);
524
525        Some(Duration::from_seconds(final_seconds as u64))
526    }
527
528    fn clone_box(&self) -> Box<dyn RetryStrategy> {
529        Box::new(self.clone())
530    }
531}
532
533/// Builder for [`ExponentialBackoff`].
534#[derive(Debug, Clone)]
535pub struct ExponentialBackoffBuilder {
536    max_attempts: u32,
537    base_delay: Duration,
538    max_delay: Duration,
539    multiplier: f64,
540    jitter: JitterStrategy,
541}
542
543impl Default for ExponentialBackoffBuilder {
544    fn default() -> Self {
545        Self {
546            max_attempts: 3,
547            base_delay: Duration::from_seconds(1),
548            max_delay: Duration::from_hours(1),
549            multiplier: 2.0,
550            jitter: JitterStrategy::None,
551        }
552    }
553}
554
555impl ExponentialBackoffBuilder {
556    /// Sets the maximum number of retry attempts.
557    pub fn max_attempts(mut self, max_attempts: u32) -> Self {
558        self.max_attempts = max_attempts;
559        self
560    }
561
562    /// Sets the initial delay before the first retry.
563    pub fn base_delay(mut self, base_delay: Duration) -> Self {
564        self.base_delay = base_delay;
565        self
566    }
567
568    /// Sets the maximum delay between retries.
569    pub fn max_delay(mut self, max_delay: Duration) -> Self {
570        self.max_delay = max_delay;
571        self
572    }
573
574    /// Sets the multiplier for exponential growth (default: 2.0).
575    pub fn multiplier(mut self, multiplier: f64) -> Self {
576        self.multiplier = multiplier;
577        self
578    }
579
580    /// Sets the jitter strategy for retry delays.
581    pub fn jitter(mut self, jitter: JitterStrategy) -> Self {
582        self.jitter = jitter;
583        self
584    }
585
586    /// Builds the exponential backoff strategy.
587    pub fn build(self) -> ExponentialBackoff {
588        ExponentialBackoff {
589            max_attempts: self.max_attempts,
590            base_delay: self.base_delay,
591            max_delay: self.max_delay,
592            multiplier: self.multiplier,
593            jitter: self.jitter,
594        }
595    }
596}
597
598/// Fixed delay retry strategy.
599///
600/// Retries with a constant delay between attempts.
601///
602/// # Example
603///
604/// ```
605/// use durable_execution_sdk::config::FixedDelay;
606/// use durable_execution_sdk::Duration;
607///
608/// // Retry up to 3 times with 5 second delay between attempts
609/// let strategy = FixedDelay::new(3, Duration::from_seconds(5));
610/// ```
611#[derive(Debug, Clone)]
612pub struct FixedDelay {
613    /// Maximum number of retry attempts.
614    pub max_attempts: u32,
615    /// Delay between retry attempts.
616    pub delay: Duration,
617    /// Jitter strategy applied to the fixed delay.
618    pub jitter: JitterStrategy,
619}
620
621impl FixedDelay {
622    /// Creates a new fixed delay retry strategy.
623    ///
624    /// # Arguments
625    ///
626    /// * `max_attempts` - Maximum number of retry attempts
627    /// * `delay` - Delay between retry attempts
628    pub fn new(max_attempts: u32, delay: Duration) -> Self {
629        Self {
630            max_attempts,
631            delay,
632            jitter: JitterStrategy::None,
633        }
634    }
635
636    /// Sets the jitter strategy for retry delays.
637    pub fn with_jitter(mut self, jitter: JitterStrategy) -> Self {
638        self.jitter = jitter;
639        self
640    }
641}
642
643impl Sealed for FixedDelay {}
644
645impl RetryStrategy for FixedDelay {
646    fn next_delay(&self, attempt: u32, _error: &str) -> Option<Duration> {
647        if attempt >= self.max_attempts {
648            return None;
649        }
650
651        let delay_secs = self.delay.to_seconds() as f64;
652        let jittered = self.jitter.apply(delay_secs, attempt);
653        let final_seconds = jittered.max(1.0);
654
655        Some(Duration::from_seconds(final_seconds as u64))
656    }
657
658    fn clone_box(&self) -> Box<dyn RetryStrategy> {
659        Box::new(self.clone())
660    }
661}
662
663/// Linear backoff retry strategy.
664///
665/// Delays increase linearly with each attempt: `base_delay * attempt`.
666///
667/// # Example
668///
669/// ```
670/// use durable_execution_sdk::config::LinearBackoff;
671/// use durable_execution_sdk::Duration;
672///
673/// // Retry up to 5 times: 2s, 4s, 6s, 8s, 10s
674/// let strategy = LinearBackoff::new(5, Duration::from_seconds(2));
675/// ```
676#[derive(Debug, Clone)]
677pub struct LinearBackoff {
678    /// Maximum number of retry attempts.
679    pub max_attempts: u32,
680    /// Base delay that is multiplied by the attempt number.
681    pub base_delay: Duration,
682    /// Maximum delay between retries.
683    pub max_delay: Duration,
684    /// Jitter strategy applied to computed delays.
685    pub jitter: JitterStrategy,
686}
687
688impl LinearBackoff {
689    /// Creates a new linear backoff retry strategy.
690    ///
691    /// # Arguments
692    ///
693    /// * `max_attempts` - Maximum number of retry attempts
694    /// * `base_delay` - Base delay multiplied by attempt number
695    pub fn new(max_attempts: u32, base_delay: Duration) -> Self {
696        Self {
697            max_attempts,
698            base_delay,
699            max_delay: Duration::from_hours(1),
700            jitter: JitterStrategy::None,
701        }
702    }
703
704    /// Sets the maximum delay between retries.
705    pub fn with_max_delay(mut self, max_delay: Duration) -> Self {
706        self.max_delay = max_delay;
707        self
708    }
709
710    /// Sets the jitter strategy for retry delays.
711    pub fn with_jitter(mut self, jitter: JitterStrategy) -> Self {
712        self.jitter = jitter;
713        self
714    }
715}
716
717impl Sealed for LinearBackoff {}
718
719impl RetryStrategy for LinearBackoff {
720    fn next_delay(&self, attempt: u32, _error: &str) -> Option<Duration> {
721        if attempt >= self.max_attempts {
722            return None;
723        }
724
725        let base_seconds = self.base_delay.to_seconds();
726        let delay_seconds = base_seconds.saturating_mul((attempt + 1) as u64);
727        let max_seconds = self.max_delay.to_seconds();
728        let capped_seconds = delay_seconds.min(max_seconds) as f64;
729
730        let jittered = self.jitter.apply(capped_seconds, attempt);
731        let final_seconds = jittered.max(1.0);
732
733        Some(Duration::from_seconds(final_seconds as u64))
734    }
735
736    fn clone_box(&self) -> Box<dyn RetryStrategy> {
737        Box::new(self.clone())
738    }
739}
740
741/// No retry strategy - fails immediately on first error.
742///
743/// # Example
744///
745/// ```
746/// use durable_execution_sdk::config::NoRetry;
747///
748/// let strategy = NoRetry;
749/// ```
750#[derive(Debug, Clone, Copy, Default)]
751pub struct NoRetry;
752
753impl Sealed for NoRetry {}
754
755impl RetryStrategy for NoRetry {
756    fn next_delay(&self, _attempt: u32, _error: &str) -> Option<Duration> {
757        None
758    }
759
760    fn clone_box(&self) -> Box<dyn RetryStrategy> {
761        Box::new(*self)
762    }
763}
764
765/// Pattern for matching retryable errors.
766///
767/// Used with [`RetryableErrorFilter`] to declaratively specify which errors
768/// should be retried.
769///
770/// # Example
771///
772/// ```
773/// use durable_execution_sdk::config::ErrorPattern;
774///
775/// let contains = ErrorPattern::Contains("timeout".to_string());
776/// let regex = ErrorPattern::Regex(regex::Regex::new(r"(?i)connection.*refused").unwrap());
777/// ```
778///
779/// # Requirements
780///
781/// - 2.1: Contains matches substring
782/// - 2.2: Contains doesn't match when substring absent
783/// - 2.3: Regex matches regex pattern
784#[derive(Clone)]
785pub enum ErrorPattern {
786    /// Match if error message contains this substring.
787    Contains(String),
788    /// Match if error message matches this regex.
789    Regex(regex::Regex),
790}
791
792impl std::fmt::Debug for ErrorPattern {
793    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
794        match self {
795            ErrorPattern::Contains(s) => f.debug_tuple("Contains").field(s).finish(),
796            ErrorPattern::Regex(r) => f.debug_tuple("Regex").field(&r.as_str()).finish(),
797        }
798    }
799}
800
801/// Declarative filter for retryable errors.
802///
803/// When configured on a [`StepConfig`], only errors matching the filter will be retried.
804/// If no patterns and no error types are configured, all errors are retried (backward-compatible).
805///
806/// Patterns and error types are combined with OR logic: an error is retryable if it matches
807/// ANY pattern OR ANY error type.
808///
809/// # Example
810///
811/// ```
812/// use durable_execution_sdk::config::{RetryableErrorFilter, ErrorPattern};
813///
814/// let filter = RetryableErrorFilter {
815///     patterns: vec![
816///         ErrorPattern::Contains("timeout".to_string()),
817///         ErrorPattern::Regex(regex::Regex::new(r"(?i)connection.*refused").unwrap()),
818///     ],
819///     error_types: vec!["TransientError".to_string()],
820/// };
821///
822/// assert!(filter.is_retryable("request timeout occurred"));
823/// assert!(!filter.is_retryable("invalid input"));
824/// assert!(filter.is_retryable_with_type("invalid input", "TransientError"));
825/// ```
826///
827/// # Requirements
828///
829/// - 2.4: Empty filter retries all (backward-compatible)
830/// - 2.5: OR logic between patterns and error_types
831#[derive(Clone, Debug, Default)]
832pub struct RetryableErrorFilter {
833    /// Error message patterns (string contains or regex).
834    pub patterns: Vec<ErrorPattern>,
835    /// Error type names to match against.
836    pub error_types: Vec<String>,
837}
838
839impl RetryableErrorFilter {
840    /// Returns `true` if the error message is retryable according to this filter.
841    ///
842    /// If no filters are configured (empty patterns and empty error_types),
843    /// returns `true` for all errors (backward-compatible default).
844    ///
845    /// Otherwise, returns `true` if the error message matches any configured pattern.
846    pub fn is_retryable(&self, error_msg: &str) -> bool {
847        if self.patterns.is_empty() && self.error_types.is_empty() {
848            return true;
849        }
850
851        self.patterns.iter().any(|p| match p {
852            ErrorPattern::Contains(s) => error_msg.contains(s.as_str()),
853            ErrorPattern::Regex(r) => r.is_match(error_msg),
854        })
855    }
856
857    /// Returns `true` if the error is retryable by message or type.
858    ///
859    /// Uses OR logic: returns `true` if the error matches any pattern
860    /// OR if the error type matches any configured error type.
861    ///
862    /// If no filters are configured, returns `true` for all errors.
863    pub fn is_retryable_with_type(&self, error_msg: &str, error_type: &str) -> bool {
864        if self.patterns.is_empty() && self.error_types.is_empty() {
865            return true;
866        }
867
868        let matches_type = self.error_types.iter().any(|t| t == error_type);
869        matches_type || self.is_retryable(error_msg)
870    }
871}
872
873/// Custom retry strategy using a user-provided closure.
874///
875/// This allows users to define custom retry logic without implementing
876/// the sealed `RetryStrategy` trait directly.
877///
878/// # Example
879///
880/// ```
881/// use durable_execution_sdk::config::custom_retry;
882/// use durable_execution_sdk::Duration;
883///
884/// // Custom strategy: retry up to 3 times, but only for specific errors
885/// let strategy = custom_retry(|attempt, error| {
886///     if attempt >= 3 {
887///         return None;
888///     }
889///     if error.contains("transient") || error.contains("timeout") {
890///         Some(Duration::from_seconds(5))
891///     } else {
892///         None // Don't retry other errors
893///     }
894/// });
895/// ```
896pub fn custom_retry<F>(f: F) -> CustomRetry<F>
897where
898    F: Fn(u32, &str) -> Option<Duration> + Send + Sync + Clone + 'static,
899{
900    CustomRetry { f }
901}
902
903/// Custom retry strategy wrapper.
904///
905/// Created via the [`custom_retry`] function.
906#[derive(Clone)]
907pub struct CustomRetry<F>
908where
909    F: Fn(u32, &str) -> Option<Duration> + Send + Sync + Clone + 'static,
910{
911    f: F,
912}
913
914impl<F> std::fmt::Debug for CustomRetry<F>
915where
916    F: Fn(u32, &str) -> Option<Duration> + Send + Sync + Clone + 'static,
917{
918    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
919        f.debug_struct("CustomRetry").finish()
920    }
921}
922
923impl<F> Sealed for CustomRetry<F> where
924    F: Fn(u32, &str) -> Option<Duration> + Send + Sync + Clone + 'static
925{
926}
927
928impl<F> RetryStrategy for CustomRetry<F>
929where
930    F: Fn(u32, &str) -> Option<Duration> + Send + Sync + Clone + 'static,
931{
932    fn next_delay(&self, attempt: u32, error: &str) -> Option<Duration> {
933        (self.f)(attempt, error)
934    }
935
936    fn clone_box(&self) -> Box<dyn RetryStrategy> {
937        Box::new(self.clone())
938    }
939}
940
941// =============================================================================
942// Step Semantics and Configuration
943// =============================================================================
944
945/// Execution semantics for step operations.
946#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
947pub enum StepSemantics {
948    /// Checkpoint before execution - guarantees at most once execution per retry.
949    AtMostOncePerRetry,
950    /// Checkpoint after execution - guarantees at least once execution per retry.
951    #[default]
952    AtLeastOncePerRetry,
953}
954
955/// Configuration for step operations.
956///
957/// # Examples
958///
959/// Using default configuration:
960///
961/// ```
962/// use durable_execution_sdk::StepConfig;
963///
964/// let config = StepConfig::default();
965/// // Default uses AtLeastOncePerRetry semantics
966/// ```
967///
968/// Configuring step semantics:
969///
970/// ```
971/// use durable_execution_sdk::{StepConfig, StepSemantics};
972///
973/// // For non-idempotent operations, use AtMostOncePerRetry
974/// let config = StepConfig {
975///     step_semantics: StepSemantics::AtMostOncePerRetry,
976///     ..Default::default()
977/// };
978/// ```
979#[derive(Clone, Default)]
980pub struct StepConfig {
981    /// Optional retry strategy for failed steps.
982    pub retry_strategy: Option<Box<dyn RetryStrategy>>,
983    /// Execution semantics (at-most-once or at-least-once).
984    pub step_semantics: StepSemantics,
985    /// Optional custom serializer/deserializer.
986    pub serdes: Option<Arc<dyn SerDesAny>>,
987    /// Optional filter for retryable errors. When set, only errors matching
988    /// the filter will be retried. When `None`, all errors are retried
989    /// (current behavior preserved).
990    pub retryable_error_filter: Option<RetryableErrorFilter>,
991}
992
993impl std::fmt::Debug for StepConfig {
994    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
995        f.debug_struct("StepConfig")
996            .field("retry_strategy", &self.retry_strategy.is_some())
997            .field("step_semantics", &self.step_semantics)
998            .field("serdes", &self.serdes.is_some())
999            .field(
1000                "retryable_error_filter",
1001                &self.retryable_error_filter.is_some(),
1002            )
1003            .finish()
1004    }
1005}
1006
1007/// Configuration for callback operations.
1008#[derive(Debug, Clone, Default)]
1009pub struct CallbackConfig {
1010    /// Timeout duration for the callback.
1011    pub timeout: Duration,
1012    /// Heartbeat timeout duration.
1013    pub heartbeat_timeout: Duration,
1014    /// Optional custom serializer/deserializer.
1015    pub serdes: Option<Arc<dyn SerDesAny>>,
1016}
1017
1018/// Configuration for invoke operations.
1019#[derive(Clone)]
1020pub struct InvokeConfig<P, R> {
1021    /// Timeout duration for the invocation.
1022    pub timeout: Duration,
1023    /// Optional custom serializer for the payload.
1024    pub serdes_payload: Option<Arc<dyn SerDesAny>>,
1025    /// Optional custom deserializer for the result.
1026    pub serdes_result: Option<Arc<dyn SerDesAny>>,
1027    /// Optional tenant ID for multi-tenant scenarios.
1028    pub tenant_id: Option<String>,
1029    /// Phantom data for type parameters.
1030    _marker: PhantomData<(P, R)>,
1031}
1032
1033impl<P, R> Default for InvokeConfig<P, R> {
1034    fn default() -> Self {
1035        Self {
1036            timeout: Duration::default(),
1037            serdes_payload: None,
1038            serdes_result: None,
1039            tenant_id: None,
1040            _marker: PhantomData,
1041        }
1042    }
1043}
1044
1045impl<P, R> std::fmt::Debug for InvokeConfig<P, R> {
1046    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1047        f.debug_struct("InvokeConfig")
1048            .field("timeout", &self.timeout)
1049            .field("serdes_payload", &self.serdes_payload.is_some())
1050            .field("serdes_result", &self.serdes_result.is_some())
1051            .field("tenant_id", &self.tenant_id)
1052            .finish()
1053    }
1054}
1055
1056/// Configuration for map operations.
1057///
1058/// # Examples
1059///
1060/// Basic map configuration with concurrency limit:
1061///
1062/// ```
1063/// use durable_execution_sdk::MapConfig;
1064///
1065/// let config = MapConfig {
1066///     max_concurrency: Some(5),
1067///     ..Default::default()
1068/// };
1069/// ```
1070///
1071/// Map with failure tolerance:
1072///
1073/// ```
1074/// use durable_execution_sdk::{MapConfig, CompletionConfig};
1075///
1076/// let config = MapConfig {
1077///     max_concurrency: Some(10),
1078///     completion_config: CompletionConfig::with_failure_tolerance(2),
1079///     ..Default::default()
1080/// };
1081/// ```
1082#[derive(Debug, Clone, Default)]
1083pub struct MapConfig {
1084    /// Maximum number of concurrent executions.
1085    pub max_concurrency: Option<usize>,
1086    /// Optional item batcher for grouping items.
1087    pub item_batcher: Option<ItemBatcher>,
1088    /// Completion configuration defining success/failure criteria.
1089    pub completion_config: CompletionConfig,
1090    /// Optional custom serializer/deserializer.
1091    pub serdes: Option<Arc<dyn SerDesAny>>,
1092}
1093
1094/// Configuration for parallel operations.
1095#[derive(Debug, Clone, Default)]
1096pub struct ParallelConfig {
1097    /// Maximum number of concurrent executions.
1098    pub max_concurrency: Option<usize>,
1099    /// Completion configuration defining success/failure criteria.
1100    pub completion_config: CompletionConfig,
1101    /// Optional custom serializer/deserializer.
1102    pub serdes: Option<Arc<dyn SerDesAny>>,
1103}
1104
1105/// Configuration for child context operations.
1106///
1107/// This configuration controls how child contexts behave, including
1108/// whether to replay children when loading state for large parallel operations.
1109///
1110/// # Requirements
1111///
1112/// - 10.5: THE Child_Context_Operation SHALL support ReplayChildren option for large parallel operations
1113/// - 10.6: WHEN ReplayChildren is true, THE Child_Context_Operation SHALL include child operations in state loads for replay
1114/// - 12.8: THE Configuration_System SHALL provide ContextConfig with replay_children option
1115#[derive(Clone, Default)]
1116#[allow(clippy::type_complexity)]
1117pub struct ChildConfig {
1118    /// Optional custom serializer/deserializer.
1119    pub serdes: Option<Arc<dyn SerDesAny>>,
1120    /// Whether to replay children when loading state.
1121    ///
1122    /// When set to `true`, the child context will request child operations
1123    /// to be included in state loads during replay. This is useful for large
1124    /// parallel operations where the combined output needs to be reconstructed
1125    /// by replaying each branch.
1126    ///
1127    /// Default is `false` for better performance in most cases.
1128    ///
1129    /// # Requirements
1130    ///
1131    /// - 10.5: THE Child_Context_Operation SHALL support ReplayChildren option for large parallel operations
1132    /// - 10.6: WHEN ReplayChildren is true, THE Child_Context_Operation SHALL include child operations in state loads for replay
1133    pub replay_children: bool,
1134    /// Optional function to map child context errors before propagation.
1135    ///
1136    /// When set, this function is applied to errors from child context execution
1137    /// before they are checkpointed and propagated. Suspend errors are never mapped.
1138    ///
1139    /// Default is `None`, which preserves current behavior (errors propagate unchanged).
1140    ///
1141    /// # Requirements
1142    ///
1143    /// - 6.1: error_mapper applied to errors before checkpointing and propagation
1144    /// - 6.2: None preserves current behavior
1145    /// - 6.3: Suspend errors skip the mapper
1146    pub error_mapper: Option<Arc<dyn Fn(DurableError) -> DurableError + Send + Sync>>,
1147    /// Optional function to generate a summary when the serialized child result exceeds 256KB.
1148    ///
1149    /// When set, this function is invoked with the serialized result string if its size
1150    /// exceeds 256KB (262144 bytes). The returned summary string is stored instead of the
1151    /// full result, enabling replay-based reconstruction for large payloads.
1152    ///
1153    /// When the serialized result is 256KB or less, the full result is stored even if
1154    /// a summary generator is configured.
1155    ///
1156    /// Default is `None`, which preserves current behavior (full result stored regardless of size).
1157    ///
1158    /// # Requirements
1159    ///
1160    /// - 7.1: summary_generator invoked when result > 256KB
1161    /// - 7.2: summary_generator NOT invoked when result <= 256KB
1162    /// - 7.3: None preserves current behavior
1163    pub summary_generator: Option<Arc<dyn Fn(&str) -> String + Send + Sync>>,
1164}
1165
1166impl std::fmt::Debug for ChildConfig {
1167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1168        f.debug_struct("ChildConfig")
1169            .field("serdes", &self.serdes)
1170            .field("replay_children", &self.replay_children)
1171            .field("error_mapper", &self.error_mapper.as_ref().map(|_| "..."))
1172            .field(
1173                "summary_generator",
1174                &self.summary_generator.as_ref().map(|_| "..."),
1175            )
1176            .finish()
1177    }
1178}
1179
1180impl ChildConfig {
1181    /// Creates a new ChildConfig with default values.
1182    pub fn new() -> Self {
1183        Self::default()
1184    }
1185
1186    /// Creates a ChildConfig with replay_children enabled.
1187    ///
1188    /// Use this when you need to reconstruct the combined output of a large
1189    /// parallel operation by replaying each branch.
1190    ///
1191    /// # Example
1192    ///
1193    /// ```
1194    /// use durable_execution_sdk::ChildConfig;
1195    ///
1196    /// let config = ChildConfig::with_replay_children();
1197    /// assert!(config.replay_children);
1198    /// ```
1199    pub fn with_replay_children() -> Self {
1200        Self {
1201            replay_children: true,
1202            ..Default::default()
1203        }
1204    }
1205
1206    /// Sets the replay_children option.
1207    ///
1208    /// # Arguments
1209    ///
1210    /// * `replay_children` - Whether to replay children when loading state
1211    pub fn set_replay_children(mut self, replay_children: bool) -> Self {
1212        self.replay_children = replay_children;
1213        self
1214    }
1215
1216    /// Sets the custom serializer/deserializer.
1217    pub fn set_serdes(mut self, serdes: Arc<dyn SerDesAny>) -> Self {
1218        self.serdes = Some(serdes);
1219        self
1220    }
1221
1222    /// Sets the error mapper function.
1223    ///
1224    /// The error mapper is applied to child context errors before they are
1225    /// checkpointed and propagated. Suspend errors are never mapped.
1226    ///
1227    /// # Requirements
1228    ///
1229    /// - 6.1: error_mapper applied to errors before checkpointing and propagation
1230    pub fn set_error_mapper(
1231        mut self,
1232        mapper: Arc<dyn Fn(DurableError) -> DurableError + Send + Sync>,
1233    ) -> Self {
1234        self.error_mapper = Some(mapper);
1235        self
1236    }
1237
1238    /// Sets the summary generator function.
1239    ///
1240    /// The summary generator is invoked when the serialized child result exceeds
1241    /// 256KB (262144 bytes). It receives the serialized result string and should
1242    /// return a compact summary string to store instead.
1243    ///
1244    /// # Requirements
1245    ///
1246    /// - 7.1: summary_generator invoked when result > 256KB
1247    pub fn set_summary_generator(
1248        mut self,
1249        generator: Arc<dyn Fn(&str) -> String + Send + Sync>,
1250    ) -> Self {
1251        self.summary_generator = Some(generator);
1252        self
1253    }
1254}
1255
1256/// Type alias for ChildConfig for consistency with the design document.
1257///
1258/// The design document refers to this as `ContextConfig`, but internally
1259/// we use `ChildConfig` to be more descriptive of its purpose.
1260///
1261/// # Requirements
1262///
1263/// - 12.8: THE Configuration_System SHALL provide ContextConfig with replay_children option
1264pub type ContextConfig = ChildConfig;
1265
1266/// Configuration defining success/failure criteria for concurrent operations.
1267#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1268pub struct CompletionConfig {
1269    /// Minimum number of successful completions required.
1270    pub min_successful: Option<usize>,
1271    /// Maximum number of tolerated failures (absolute count).
1272    pub tolerated_failure_count: Option<usize>,
1273    /// Maximum percentage of tolerated failures (0.0 to 1.0).
1274    pub tolerated_failure_percentage: Option<f64>,
1275}
1276
1277impl CompletionConfig {
1278    /// Creates a completion config that succeeds when the first task succeeds.
1279    ///
1280    /// # Example
1281    ///
1282    /// ```
1283    /// use durable_execution_sdk::CompletionConfig;
1284    ///
1285    /// let config = CompletionConfig::first_successful();
1286    /// assert_eq!(config.min_successful, Some(1));
1287    /// ```
1288    pub fn first_successful() -> Self {
1289        Self {
1290            min_successful: Some(1),
1291            ..Default::default()
1292        }
1293    }
1294
1295    /// Creates a completion config that waits for all tasks to complete.
1296    ///
1297    /// # Example
1298    ///
1299    /// ```
1300    /// use durable_execution_sdk::CompletionConfig;
1301    ///
1302    /// let config = CompletionConfig::all_completed();
1303    /// assert!(config.min_successful.is_none());
1304    /// ```
1305    pub fn all_completed() -> Self {
1306        Self::default()
1307    }
1308
1309    /// Creates a completion config that requires all tasks to succeed.
1310    ///
1311    /// # Example
1312    ///
1313    /// ```
1314    /// use durable_execution_sdk::CompletionConfig;
1315    ///
1316    /// let config = CompletionConfig::all_successful();
1317    /// assert_eq!(config.tolerated_failure_count, Some(0));
1318    /// assert_eq!(config.tolerated_failure_percentage, Some(0.0));
1319    /// ```
1320    pub fn all_successful() -> Self {
1321        Self {
1322            tolerated_failure_count: Some(0),
1323            tolerated_failure_percentage: Some(0.0),
1324            ..Default::default()
1325        }
1326    }
1327
1328    /// Creates a completion config with a specific minimum successful count.
1329    pub fn with_min_successful(count: usize) -> Self {
1330        Self {
1331            min_successful: Some(count),
1332            ..Default::default()
1333        }
1334    }
1335
1336    /// Creates a completion config with a specific failure tolerance.
1337    pub fn with_failure_tolerance(count: usize) -> Self {
1338        Self {
1339            tolerated_failure_count: Some(count),
1340            ..Default::default()
1341        }
1342    }
1343}
1344
1345/// Configuration for batching items in map operations.
1346#[derive(Debug, Clone)]
1347pub struct ItemBatcher {
1348    /// Maximum number of items per batch.
1349    pub max_items_per_batch: usize,
1350    /// Maximum total bytes per batch.
1351    pub max_bytes_per_batch: usize,
1352}
1353
1354impl Default for ItemBatcher {
1355    fn default() -> Self {
1356        Self {
1357            max_items_per_batch: 100,
1358            max_bytes_per_batch: 256 * 1024, // 256KB
1359        }
1360    }
1361}
1362
1363impl ItemBatcher {
1364    /// Creates a new ItemBatcher with the specified limits.
1365    pub fn new(max_items_per_batch: usize, max_bytes_per_batch: usize) -> Self {
1366        Self {
1367            max_items_per_batch,
1368            max_bytes_per_batch,
1369        }
1370    }
1371
1372    /// Batches items according to configuration, respecting both item count and byte limits.
1373    ///
1374    /// This method groups items into batches where each batch:
1375    /// - Contains at most `max_items_per_batch` items
1376    /// - Has an estimated total size of at most `max_bytes_per_batch` bytes
1377    ///
1378    /// Item size is estimated using JSON serialization via `serde_json`.
1379    ///
1380    /// # Arguments
1381    ///
1382    /// * `items` - The slice of items to batch
1383    ///
1384    /// # Returns
1385    ///
1386    /// A vector of `(start_index, batch)` tuples where:
1387    /// - `start_index` is the index of the first item in the batch from the original slice
1388    /// - `batch` is a vector of cloned items in that batch
1389    ///
1390    /// # Requirements
1391    ///
1392    /// - 2.1: THE ItemBatcher SHALL support configuring maximum items per batch
1393    /// - 2.2: THE ItemBatcher SHALL support configuring maximum bytes per batch
1394    /// - 2.3: WHEN ItemBatcher is configured, THE map operation SHALL group items into batches before processing
1395    /// - 2.6: WHEN batch size limits are exceeded, THE ItemBatcher SHALL split items into multiple batches
1396    ///
1397    /// # Example
1398    ///
1399    /// ```
1400    /// use durable_execution_sdk::ItemBatcher;
1401    ///
1402    /// let batcher = ItemBatcher::new(2, 1024);
1403    /// let items = vec!["a", "b", "c", "d", "e"];
1404    /// let batches = batcher.batch(&items);
1405    ///
1406    /// // Items are grouped into batches of at most 2 items each
1407    /// assert_eq!(batches.len(), 3);
1408    /// assert_eq!(batches[0], (0, vec!["a", "b"]));
1409    /// assert_eq!(batches[1], (2, vec!["c", "d"]));
1410    /// assert_eq!(batches[2], (4, vec!["e"]));
1411    /// ```
1412    pub fn batch<T: Serialize + Clone>(&self, items: &[T]) -> Vec<(usize, Vec<T>)> {
1413        if items.is_empty() {
1414            return Vec::new();
1415        }
1416
1417        let mut batches = Vec::new();
1418        let mut current_batch = Vec::new();
1419        let mut current_bytes = 0usize;
1420        let mut batch_start_index = 0;
1421
1422        for (i, item) in items.iter().enumerate() {
1423            // Estimate item size using JSON serialization
1424            let item_bytes = serde_json::to_string(item).map(|s| s.len()).unwrap_or(0);
1425
1426            // Check if adding this item would exceed limits
1427            let would_exceed_items = current_batch.len() >= self.max_items_per_batch;
1428            let would_exceed_bytes =
1429                current_bytes + item_bytes > self.max_bytes_per_batch && !current_batch.is_empty();
1430
1431            if would_exceed_items || would_exceed_bytes {
1432                // Finalize current batch and start a new one
1433                batches.push((batch_start_index, std::mem::take(&mut current_batch)));
1434                current_bytes = 0;
1435                batch_start_index = i;
1436            }
1437
1438            current_batch.push(item.clone());
1439            current_bytes += item_bytes;
1440        }
1441
1442        // Don't forget the last batch
1443        if !current_batch.is_empty() {
1444            batches.push((batch_start_index, current_batch));
1445        }
1446
1447        batches
1448    }
1449}
1450
1451/// Type-erased SerDes trait for storing in config structs.
1452pub trait SerDesAny: Send + Sync {
1453    /// Serialize a value to a string.
1454    fn serialize_any(
1455        &self,
1456        value: &dyn std::any::Any,
1457    ) -> Result<String, crate::error::DurableError>;
1458    /// Deserialize a string to a boxed Any value.
1459    fn deserialize_any(
1460        &self,
1461        data: &str,
1462    ) -> Result<Box<dyn std::any::Any + Send>, crate::error::DurableError>;
1463}
1464
1465impl std::fmt::Debug for dyn SerDesAny {
1466    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1467        f.write_str("SerDesAny")
1468    }
1469}
1470
1471#[cfg(test)]
1472mod tests {
1473    use super::*;
1474    use proptest::prelude::*;
1475
1476    // =========================================================================
1477    // Unit Tests
1478    // =========================================================================
1479
1480    #[test]
1481    fn test_step_semantics_default() {
1482        let semantics = StepSemantics::default();
1483        assert_eq!(semantics, StepSemantics::AtLeastOncePerRetry);
1484    }
1485
1486    #[test]
1487    fn test_step_config_default() {
1488        let config = StepConfig::default();
1489        assert!(config.retry_strategy.is_none());
1490        assert_eq!(config.step_semantics, StepSemantics::AtLeastOncePerRetry);
1491        assert!(config.serdes.is_none());
1492    }
1493
1494    #[test]
1495    fn test_completion_config_first_successful() {
1496        let config = CompletionConfig::first_successful();
1497        assert_eq!(config.min_successful, Some(1));
1498        assert!(config.tolerated_failure_count.is_none());
1499        assert!(config.tolerated_failure_percentage.is_none());
1500    }
1501
1502    #[test]
1503    fn test_completion_config_all_completed() {
1504        let config = CompletionConfig::all_completed();
1505        assert!(config.min_successful.is_none());
1506        assert!(config.tolerated_failure_count.is_none());
1507        assert!(config.tolerated_failure_percentage.is_none());
1508    }
1509
1510    #[test]
1511    fn test_completion_config_all_successful() {
1512        let config = CompletionConfig::all_successful();
1513        assert!(config.min_successful.is_none());
1514        assert_eq!(config.tolerated_failure_count, Some(0));
1515        assert_eq!(config.tolerated_failure_percentage, Some(0.0));
1516    }
1517
1518    #[test]
1519    fn test_item_batcher_default() {
1520        let batcher = ItemBatcher::default();
1521        assert_eq!(batcher.max_items_per_batch, 100);
1522        assert_eq!(batcher.max_bytes_per_batch, 256 * 1024);
1523    }
1524
1525    #[test]
1526    fn test_item_batcher_new() {
1527        let batcher = ItemBatcher::new(50, 128 * 1024);
1528        assert_eq!(batcher.max_items_per_batch, 50);
1529        assert_eq!(batcher.max_bytes_per_batch, 128 * 1024);
1530    }
1531
1532    #[test]
1533    fn test_callback_config_default() {
1534        let config = CallbackConfig::default();
1535        assert_eq!(config.timeout.to_seconds(), 0);
1536        assert_eq!(config.heartbeat_timeout.to_seconds(), 0);
1537    }
1538
1539    #[test]
1540    fn test_invoke_config_default() {
1541        let config: InvokeConfig<String, String> = InvokeConfig::default();
1542        assert_eq!(config.timeout.to_seconds(), 0);
1543        assert!(config.tenant_id.is_none());
1544    }
1545
1546    #[test]
1547    fn test_map_config_default() {
1548        let config = MapConfig::default();
1549        assert!(config.max_concurrency.is_none());
1550        assert!(config.item_batcher.is_none());
1551    }
1552
1553    #[test]
1554    fn test_parallel_config_default() {
1555        let config = ParallelConfig::default();
1556        assert!(config.max_concurrency.is_none());
1557    }
1558
1559    #[test]
1560    fn test_child_config_default() {
1561        let config = ChildConfig::default();
1562        assert!(!config.replay_children);
1563        assert!(config.serdes.is_none());
1564        assert!(config.error_mapper.is_none());
1565        assert!(config.summary_generator.is_none());
1566    }
1567
1568    #[test]
1569    fn test_child_config_with_replay_children() {
1570        let config = ChildConfig::with_replay_children();
1571        assert!(config.replay_children);
1572    }
1573
1574    #[test]
1575    fn test_child_config_set_replay_children() {
1576        let config = ChildConfig::new().set_replay_children(true);
1577        assert!(config.replay_children);
1578    }
1579
1580    #[test]
1581    fn test_context_config_type_alias() {
1582        // ContextConfig is a type alias for ChildConfig
1583        let config: ContextConfig = ContextConfig::with_replay_children();
1584        assert!(config.replay_children);
1585    }
1586
1587    #[test]
1588    fn test_checkpointing_mode_default() {
1589        let mode = CheckpointingMode::default();
1590        assert_eq!(mode, CheckpointingMode::Batched);
1591        assert!(mode.is_batched());
1592    }
1593
1594    #[test]
1595    fn test_checkpointing_mode_eager() {
1596        let mode = CheckpointingMode::Eager;
1597        assert!(mode.is_eager());
1598        assert!(!mode.is_batched());
1599        assert!(!mode.is_optimistic());
1600    }
1601
1602    #[test]
1603    fn test_checkpointing_mode_batched() {
1604        let mode = CheckpointingMode::Batched;
1605        assert!(!mode.is_eager());
1606        assert!(mode.is_batched());
1607        assert!(!mode.is_optimistic());
1608    }
1609
1610    #[test]
1611    fn test_checkpointing_mode_optimistic() {
1612        let mode = CheckpointingMode::Optimistic;
1613        assert!(!mode.is_eager());
1614        assert!(!mode.is_batched());
1615        assert!(mode.is_optimistic());
1616    }
1617
1618    #[test]
1619    fn test_checkpointing_mode_description() {
1620        assert!(CheckpointingMode::Eager
1621            .description()
1622            .contains("maximum durability"));
1623        assert!(CheckpointingMode::Batched
1624            .description()
1625            .contains("balanced"));
1626        assert!(CheckpointingMode::Optimistic
1627            .description()
1628            .contains("best performance"));
1629    }
1630
1631    #[test]
1632    fn test_checkpointing_mode_serialization() {
1633        // Test that CheckpointingMode can be serialized and deserialized
1634        let mode = CheckpointingMode::Eager;
1635        let serialized = serde_json::to_string(&mode).unwrap();
1636        let deserialized: CheckpointingMode = serde_json::from_str(&serialized).unwrap();
1637        assert_eq!(mode, deserialized);
1638
1639        let mode = CheckpointingMode::Batched;
1640        let serialized = serde_json::to_string(&mode).unwrap();
1641        let deserialized: CheckpointingMode = serde_json::from_str(&serialized).unwrap();
1642        assert_eq!(mode, deserialized);
1643
1644        let mode = CheckpointingMode::Optimistic;
1645        let serialized = serde_json::to_string(&mode).unwrap();
1646        let deserialized: CheckpointingMode = serde_json::from_str(&serialized).unwrap();
1647        assert_eq!(mode, deserialized);
1648    }
1649
1650    // =========================================================================
1651    // Retry Strategy Tests
1652    // =========================================================================
1653
1654    #[test]
1655    fn test_exponential_backoff_new() {
1656        let strategy = ExponentialBackoff::new(5, Duration::from_seconds(1));
1657        assert_eq!(strategy.max_attempts, 5);
1658        assert_eq!(strategy.base_delay.to_seconds(), 1);
1659        assert_eq!(strategy.max_delay.to_seconds(), 3600); // 1 hour default
1660        assert!((strategy.multiplier - 2.0).abs() < f64::EPSILON);
1661    }
1662
1663    #[test]
1664    fn test_exponential_backoff_builder() {
1665        let strategy = ExponentialBackoff::builder()
1666            .max_attempts(10)
1667            .base_delay(Duration::from_seconds(2))
1668            .max_delay(Duration::from_minutes(30))
1669            .multiplier(3.0)
1670            .build();
1671
1672        assert_eq!(strategy.max_attempts, 10);
1673        assert_eq!(strategy.base_delay.to_seconds(), 2);
1674        assert_eq!(strategy.max_delay.to_seconds(), 1800); // 30 minutes
1675        assert!((strategy.multiplier - 3.0).abs() < f64::EPSILON);
1676    }
1677
1678    #[test]
1679    fn test_exponential_backoff_delays() {
1680        let strategy = ExponentialBackoff::new(5, Duration::from_seconds(1));
1681
1682        // attempt 0: 1 * 2^0 = 1 second
1683        assert_eq!(
1684            strategy.next_delay(0, "error").map(|d| d.to_seconds()),
1685            Some(1)
1686        );
1687        // attempt 1: 1 * 2^1 = 2 seconds
1688        assert_eq!(
1689            strategy.next_delay(1, "error").map(|d| d.to_seconds()),
1690            Some(2)
1691        );
1692        // attempt 2: 1 * 2^2 = 4 seconds
1693        assert_eq!(
1694            strategy.next_delay(2, "error").map(|d| d.to_seconds()),
1695            Some(4)
1696        );
1697        // attempt 3: 1 * 2^3 = 8 seconds
1698        assert_eq!(
1699            strategy.next_delay(3, "error").map(|d| d.to_seconds()),
1700            Some(8)
1701        );
1702        // attempt 4: 1 * 2^4 = 16 seconds
1703        assert_eq!(
1704            strategy.next_delay(4, "error").map(|d| d.to_seconds()),
1705            Some(16)
1706        );
1707        // attempt 5: exceeds max_attempts
1708        assert_eq!(strategy.next_delay(5, "error"), None);
1709    }
1710
1711    #[test]
1712    fn test_exponential_backoff_max_delay_cap() {
1713        let strategy = ExponentialBackoff::builder()
1714            .max_attempts(10)
1715            .base_delay(Duration::from_seconds(10))
1716            .max_delay(Duration::from_seconds(30))
1717            .build();
1718
1719        // attempt 0: 10 * 2^0 = 10 seconds
1720        assert_eq!(
1721            strategy.next_delay(0, "error").map(|d| d.to_seconds()),
1722            Some(10)
1723        );
1724        // attempt 1: 10 * 2^1 = 20 seconds
1725        assert_eq!(
1726            strategy.next_delay(1, "error").map(|d| d.to_seconds()),
1727            Some(20)
1728        );
1729        // attempt 2: 10 * 2^2 = 40 seconds, capped at 30
1730        assert_eq!(
1731            strategy.next_delay(2, "error").map(|d| d.to_seconds()),
1732            Some(30)
1733        );
1734        // attempt 3: 10 * 2^3 = 80 seconds, capped at 30
1735        assert_eq!(
1736            strategy.next_delay(3, "error").map(|d| d.to_seconds()),
1737            Some(30)
1738        );
1739    }
1740
1741    #[test]
1742    fn test_fixed_delay_new() {
1743        let strategy = FixedDelay::new(3, Duration::from_seconds(5));
1744        assert_eq!(strategy.max_attempts, 3);
1745        assert_eq!(strategy.delay.to_seconds(), 5);
1746    }
1747
1748    #[test]
1749    fn test_fixed_delay_constant() {
1750        let strategy = FixedDelay::new(3, Duration::from_seconds(5));
1751
1752        // All delays should be the same
1753        assert_eq!(
1754            strategy.next_delay(0, "error").map(|d| d.to_seconds()),
1755            Some(5)
1756        );
1757        assert_eq!(
1758            strategy.next_delay(1, "error").map(|d| d.to_seconds()),
1759            Some(5)
1760        );
1761        assert_eq!(
1762            strategy.next_delay(2, "error").map(|d| d.to_seconds()),
1763            Some(5)
1764        );
1765        // Exceeds max_attempts
1766        assert_eq!(strategy.next_delay(3, "error"), None);
1767    }
1768
1769    #[test]
1770    fn test_linear_backoff_new() {
1771        let strategy = LinearBackoff::new(5, Duration::from_seconds(2));
1772        assert_eq!(strategy.max_attempts, 5);
1773        assert_eq!(strategy.base_delay.to_seconds(), 2);
1774        assert_eq!(strategy.max_delay.to_seconds(), 3600); // 1 hour default
1775    }
1776
1777    #[test]
1778    fn test_linear_backoff_with_max_delay() {
1779        let strategy = LinearBackoff::new(5, Duration::from_seconds(2))
1780            .with_max_delay(Duration::from_seconds(10));
1781        assert_eq!(strategy.max_delay.to_seconds(), 10);
1782    }
1783
1784    #[test]
1785    fn test_linear_backoff_delays() {
1786        let strategy = LinearBackoff::new(5, Duration::from_seconds(2));
1787
1788        // attempt 0: 2 * (0+1) = 2 seconds
1789        assert_eq!(
1790            strategy.next_delay(0, "error").map(|d| d.to_seconds()),
1791            Some(2)
1792        );
1793        // attempt 1: 2 * (1+1) = 4 seconds
1794        assert_eq!(
1795            strategy.next_delay(1, "error").map(|d| d.to_seconds()),
1796            Some(4)
1797        );
1798        // attempt 2: 2 * (2+1) = 6 seconds
1799        assert_eq!(
1800            strategy.next_delay(2, "error").map(|d| d.to_seconds()),
1801            Some(6)
1802        );
1803        // attempt 3: 2 * (3+1) = 8 seconds
1804        assert_eq!(
1805            strategy.next_delay(3, "error").map(|d| d.to_seconds()),
1806            Some(8)
1807        );
1808        // attempt 4: 2 * (4+1) = 10 seconds
1809        assert_eq!(
1810            strategy.next_delay(4, "error").map(|d| d.to_seconds()),
1811            Some(10)
1812        );
1813        // attempt 5: exceeds max_attempts
1814        assert_eq!(strategy.next_delay(5, "error"), None);
1815    }
1816
1817    #[test]
1818    fn test_linear_backoff_max_delay_cap() {
1819        let strategy = LinearBackoff::new(10, Duration::from_seconds(5))
1820            .with_max_delay(Duration::from_seconds(15));
1821
1822        // attempt 0: 5 * 1 = 5 seconds
1823        assert_eq!(
1824            strategy.next_delay(0, "error").map(|d| d.to_seconds()),
1825            Some(5)
1826        );
1827        // attempt 1: 5 * 2 = 10 seconds
1828        assert_eq!(
1829            strategy.next_delay(1, "error").map(|d| d.to_seconds()),
1830            Some(10)
1831        );
1832        // attempt 2: 5 * 3 = 15 seconds
1833        assert_eq!(
1834            strategy.next_delay(2, "error").map(|d| d.to_seconds()),
1835            Some(15)
1836        );
1837        // attempt 3: 5 * 4 = 20 seconds, capped at 15
1838        assert_eq!(
1839            strategy.next_delay(3, "error").map(|d| d.to_seconds()),
1840            Some(15)
1841        );
1842    }
1843
1844    #[test]
1845    fn test_no_retry() {
1846        let strategy = NoRetry;
1847
1848        // Should always return None
1849        assert_eq!(strategy.next_delay(0, "error"), None);
1850        assert_eq!(strategy.next_delay(1, "error"), None);
1851        assert_eq!(strategy.next_delay(100, "error"), None);
1852    }
1853
1854    #[test]
1855    fn test_no_retry_default() {
1856        let strategy = NoRetry::default();
1857        assert_eq!(strategy.next_delay(0, "error"), None);
1858    }
1859
1860    #[test]
1861    fn test_custom_retry_basic() {
1862        let strategy = custom_retry(|attempt, _error| {
1863            if attempt >= 3 {
1864                None
1865            } else {
1866                Some(Duration::from_seconds(10))
1867            }
1868        });
1869
1870        assert_eq!(
1871            strategy.next_delay(0, "error").map(|d| d.to_seconds()),
1872            Some(10)
1873        );
1874        assert_eq!(
1875            strategy.next_delay(1, "error").map(|d| d.to_seconds()),
1876            Some(10)
1877        );
1878        assert_eq!(
1879            strategy.next_delay(2, "error").map(|d| d.to_seconds()),
1880            Some(10)
1881        );
1882        assert_eq!(strategy.next_delay(3, "error"), None);
1883    }
1884
1885    #[test]
1886    fn test_custom_retry_error_based() {
1887        let strategy = custom_retry(|attempt, error| {
1888            if attempt >= 5 {
1889                return None;
1890            }
1891            if error.contains("transient") {
1892                Some(Duration::from_seconds(1))
1893            } else if error.contains("rate_limit") {
1894                Some(Duration::from_seconds(30))
1895            } else {
1896                None // Don't retry other errors
1897            }
1898        });
1899
1900        // Transient errors get short delay
1901        assert_eq!(
1902            strategy
1903                .next_delay(0, "transient error")
1904                .map(|d| d.to_seconds()),
1905            Some(1)
1906        );
1907        // Rate limit errors get longer delay
1908        assert_eq!(
1909            strategy
1910                .next_delay(0, "rate_limit exceeded")
1911                .map(|d| d.to_seconds()),
1912            Some(30)
1913        );
1914        // Other errors don't retry
1915        assert_eq!(strategy.next_delay(0, "permanent failure"), None);
1916    }
1917
1918    #[test]
1919    fn test_retry_strategy_clone_box() {
1920        // Test that clone_box works for all strategies
1921        let exp: Box<dyn RetryStrategy> =
1922            Box::new(ExponentialBackoff::new(3, Duration::from_seconds(1)));
1923        let exp_clone = exp.clone_box();
1924        assert_eq!(
1925            exp.next_delay(0, "e").map(|d| d.to_seconds()),
1926            exp_clone.next_delay(0, "e").map(|d| d.to_seconds())
1927        );
1928
1929        let fixed: Box<dyn RetryStrategy> = Box::new(FixedDelay::new(3, Duration::from_seconds(5)));
1930        let fixed_clone = fixed.clone_box();
1931        assert_eq!(
1932            fixed.next_delay(0, "e").map(|d| d.to_seconds()),
1933            fixed_clone.next_delay(0, "e").map(|d| d.to_seconds())
1934        );
1935
1936        let linear: Box<dyn RetryStrategy> =
1937            Box::new(LinearBackoff::new(3, Duration::from_seconds(2)));
1938        let linear_clone = linear.clone_box();
1939        assert_eq!(
1940            linear.next_delay(0, "e").map(|d| d.to_seconds()),
1941            linear_clone.next_delay(0, "e").map(|d| d.to_seconds())
1942        );
1943
1944        let no_retry: Box<dyn RetryStrategy> = Box::new(NoRetry);
1945        let no_retry_clone = no_retry.clone_box();
1946        assert_eq!(
1947            no_retry.next_delay(0, "e"),
1948            no_retry_clone.next_delay(0, "e")
1949        );
1950    }
1951
1952    #[test]
1953    fn test_boxed_retry_strategy_clone() {
1954        // Test the Clone impl for Box<dyn RetryStrategy>
1955        let strategy: Box<dyn RetryStrategy> =
1956            Box::new(ExponentialBackoff::new(3, Duration::from_seconds(1)));
1957        let cloned = strategy.clone();
1958
1959        assert_eq!(
1960            strategy.next_delay(0, "error").map(|d| d.to_seconds()),
1961            cloned.next_delay(0, "error").map(|d| d.to_seconds())
1962        );
1963    }
1964
1965    #[test]
1966    fn test_step_config_with_retry_strategy() {
1967        let config = StepConfig {
1968            retry_strategy: Some(Box::new(ExponentialBackoff::new(
1969                3,
1970                Duration::from_seconds(1),
1971            ))),
1972            step_semantics: StepSemantics::AtLeastOncePerRetry,
1973            serdes: None,
1974            retryable_error_filter: None,
1975        };
1976
1977        assert!(config.retry_strategy.is_some());
1978        let strategy = config.retry_strategy.as_ref().unwrap();
1979        assert_eq!(
1980            strategy.next_delay(0, "error").map(|d| d.to_seconds()),
1981            Some(1)
1982        );
1983    }
1984
1985    #[test]
1986    fn test_retry_strategy_debug() {
1987        // Test Debug implementations
1988        let exp = ExponentialBackoff::new(3, Duration::from_seconds(1));
1989        let debug_str = format!("{:?}", exp);
1990        assert!(debug_str.contains("ExponentialBackoff"));
1991
1992        let fixed = FixedDelay::new(3, Duration::from_seconds(5));
1993        let debug_str = format!("{:?}", fixed);
1994        assert!(debug_str.contains("FixedDelay"));
1995
1996        let linear = LinearBackoff::new(3, Duration::from_seconds(2));
1997        let debug_str = format!("{:?}", linear);
1998        assert!(debug_str.contains("LinearBackoff"));
1999
2000        let no_retry = NoRetry;
2001        let debug_str = format!("{:?}", no_retry);
2002        assert!(debug_str.contains("NoRetry"));
2003
2004        let custom = custom_retry(|_, _| None);
2005        let debug_str = format!("{:?}", custom);
2006        assert!(debug_str.contains("CustomRetry"));
2007    }
2008
2009    // =========================================================================
2010    // Property-Based Tests
2011    // =========================================================================
2012
2013    /// Strategy for generating valid StepSemantics values
2014    fn step_semantics_strategy() -> impl Strategy<Value = StepSemantics> {
2015        prop_oneof![
2016            Just(StepSemantics::AtMostOncePerRetry),
2017            Just(StepSemantics::AtLeastOncePerRetry),
2018        ]
2019    }
2020
2021    /// Strategy for generating valid CheckpointingMode values
2022    fn checkpointing_mode_strategy() -> impl Strategy<Value = CheckpointingMode> {
2023        prop_oneof![
2024            Just(CheckpointingMode::Eager),
2025            Just(CheckpointingMode::Batched),
2026            Just(CheckpointingMode::Optimistic),
2027        ]
2028    }
2029
2030    proptest! {
2031        // **Feature: rust-sdk-test-suite, Property: StepConfig validity**
2032        // **Validates: Requirements 5.1**
2033        /// Property: For any valid StepConfig instance, the configuration SHALL be usable without panics.
2034        /// StepConfig with any StepSemantics value should be valid and usable.
2035        #[test]
2036        fn prop_step_config_validity(semantics in step_semantics_strategy()) {
2037            let config = StepConfig {
2038                retry_strategy: None,
2039                step_semantics: semantics,
2040                serdes: None,
2041                retryable_error_filter: None,
2042            };
2043
2044            // Verify the config is usable - accessing fields should not panic
2045            let _ = config.retry_strategy.is_none();
2046            let _ = config.step_semantics;
2047            let _ = config.serdes.is_none();
2048
2049            // Verify Debug trait works
2050            let debug_str = format!("{:?}", config);
2051            prop_assert!(!debug_str.is_empty());
2052        }
2053
2054        // **Feature: rust-sdk-test-suite, Property: CallbackConfig with positive timeout values**
2055        // **Validates: Requirements 5.2**
2056        /// Property: For any valid CallbackConfig with positive timeout values, the configuration SHALL be valid.
2057        #[test]
2058        fn prop_callback_config_positive_timeout(
2059            timeout_secs in 1u64..=86400u64,
2060            heartbeat_secs in 1u64..=86400u64
2061        ) {
2062            let config = CallbackConfig {
2063                timeout: Duration::from_seconds(timeout_secs),
2064                heartbeat_timeout: Duration::from_seconds(heartbeat_secs),
2065                serdes: None,
2066            };
2067
2068            // Verify the config has the expected timeout values
2069            prop_assert_eq!(config.timeout.to_seconds(), timeout_secs);
2070            prop_assert_eq!(config.heartbeat_timeout.to_seconds(), heartbeat_secs);
2071
2072            // Verify Debug trait works
2073            let debug_str = format!("{:?}", config);
2074            prop_assert!(!debug_str.is_empty());
2075        }
2076
2077        // **Feature: rust-sdk-test-suite, Property 12: Duration conversion round-trip**
2078        // **Validates: Requirements 5.3**
2079        /// Property: For any Duration value, converting to seconds and back SHALL preserve the value.
2080        #[test]
2081        fn prop_duration_conversion_roundtrip(seconds in 0u64..=u64::MAX / 2) {
2082            let original = Duration::from_seconds(seconds);
2083            let extracted = original.to_seconds();
2084            let reconstructed = Duration::from_seconds(extracted);
2085
2086            prop_assert_eq!(original, reconstructed);
2087            prop_assert_eq!(original.to_seconds(), reconstructed.to_seconds());
2088        }
2089
2090        // **Feature: rust-sdk-test-suite, Property: RetryStrategy consistency**
2091        // **Validates: Requirements 5.4**
2092        /// Property: For any CompletionConfig, the configuration SHALL produce consistent behavior.
2093        /// Since RetryStrategy is a sealed trait, we test CompletionConfig which is the main
2094        /// configurable retry-related type.
2095        #[test]
2096        fn prop_completion_config_consistency(
2097            min_successful in proptest::option::of(0usize..100),
2098            tolerated_count in proptest::option::of(0usize..100),
2099            tolerated_pct in proptest::option::of(0.0f64..=1.0f64)
2100        ) {
2101            let config = CompletionConfig {
2102                min_successful,
2103                tolerated_failure_count: tolerated_count,
2104                tolerated_failure_percentage: tolerated_pct,
2105            };
2106
2107            // Verify the config has the expected values
2108            prop_assert_eq!(config.min_successful, min_successful);
2109            prop_assert_eq!(config.tolerated_failure_count, tolerated_count);
2110            prop_assert_eq!(config.tolerated_failure_percentage, tolerated_pct);
2111
2112            // Verify serialization round-trip
2113            let serialized = serde_json::to_string(&config).unwrap();
2114            let deserialized: CompletionConfig = serde_json::from_str(&serialized).unwrap();
2115
2116            prop_assert_eq!(config.min_successful, deserialized.min_successful);
2117            prop_assert_eq!(config.tolerated_failure_count, deserialized.tolerated_failure_count);
2118            // For f64, we need to handle NaN specially
2119            match (config.tolerated_failure_percentage, deserialized.tolerated_failure_percentage) {
2120                (Some(a), Some(b)) => prop_assert!((a - b).abs() < f64::EPSILON),
2121                (None, None) => {},
2122                _ => prop_assert!(false, "tolerated_failure_percentage mismatch"),
2123            }
2124        }
2125
2126        // **Feature: rust-sdk-test-suite, Property: CheckpointingMode serialization round-trip**
2127        // **Validates: Requirements 5.1**
2128        /// Property: For any CheckpointingMode value, serializing then deserializing SHALL produce the same value.
2129        #[test]
2130        fn prop_checkpointing_mode_roundtrip(mode in checkpointing_mode_strategy()) {
2131            let serialized = serde_json::to_string(&mode).unwrap();
2132            let deserialized: CheckpointingMode = serde_json::from_str(&serialized).unwrap();
2133            prop_assert_eq!(mode, deserialized);
2134        }
2135
2136        // **Feature: rust-sdk-test-suite, Property: CheckpointingMode classification consistency**
2137        // **Validates: Requirements 5.1**
2138        /// Property: For any CheckpointingMode, exactly one of is_eager/is_batched/is_optimistic SHALL be true.
2139        #[test]
2140        fn prop_checkpointing_mode_classification(mode in checkpointing_mode_strategy()) {
2141            let eager = mode.is_eager();
2142            let batched = mode.is_batched();
2143            let optimistic = mode.is_optimistic();
2144
2145            // Exactly one should be true
2146            let count = [eager, batched, optimistic].iter().filter(|&&x| x).count();
2147            prop_assert_eq!(count, 1, "Exactly one classification should be true");
2148
2149            // Verify consistency with the enum variant
2150            match mode {
2151                CheckpointingMode::Eager => prop_assert!(eager),
2152                CheckpointingMode::Batched => prop_assert!(batched),
2153                CheckpointingMode::Optimistic => prop_assert!(optimistic),
2154            }
2155        }
2156
2157        // **Feature: rust-sdk-test-suite, Property: StepSemantics serialization round-trip**
2158        // **Validates: Requirements 5.1**
2159        /// Property: For any StepSemantics value, serializing then deserializing SHALL produce the same value.
2160        #[test]
2161        fn prop_step_semantics_roundtrip(semantics in step_semantics_strategy()) {
2162            let serialized = serde_json::to_string(&semantics).unwrap();
2163            let deserialized: StepSemantics = serde_json::from_str(&serialized).unwrap();
2164            prop_assert_eq!(semantics, deserialized);
2165        }
2166
2167        // **Feature: rust-sdk-test-suite, Property: ItemBatcher validity**
2168        // **Validates: Requirements 5.1**
2169        /// Property: For any ItemBatcher with positive values, the configuration SHALL be valid.
2170        #[test]
2171        fn prop_item_batcher_validity(
2172            max_items in 1usize..=10000,
2173            max_bytes in 1usize..=10_000_000
2174        ) {
2175            let batcher = ItemBatcher::new(max_items, max_bytes);
2176
2177            prop_assert_eq!(batcher.max_items_per_batch, max_items);
2178            prop_assert_eq!(batcher.max_bytes_per_batch, max_bytes);
2179
2180            // Verify Debug trait works
2181            let debug_str = format!("{:?}", batcher);
2182            prop_assert!(!debug_str.is_empty());
2183        }
2184
2185        // **Feature: rust-sdk-test-suite, Property: ChildConfig builder pattern consistency**
2186        // **Validates: Requirements 5.1**
2187        /// Property: For any ChildConfig, the builder pattern SHALL produce consistent results.
2188        #[test]
2189        fn prop_child_config_builder_consistency(replay_children in proptest::bool::ANY) {
2190            let config = ChildConfig::new().set_replay_children(replay_children);
2191
2192            prop_assert_eq!(config.replay_children, replay_children);
2193
2194            // Verify Debug trait works
2195            let debug_str = format!("{:?}", config);
2196            prop_assert!(!debug_str.is_empty());
2197        }
2198
2199        // **Feature: rust-sdk-test-suite, Property: MapConfig validity**
2200        // **Validates: Requirements 5.1**
2201        /// Property: For any MapConfig with valid values, the configuration SHALL be usable.
2202        #[test]
2203        fn prop_map_config_validity(
2204            max_concurrency in proptest::option::of(1usize..=1000)
2205        ) {
2206            let config = MapConfig {
2207                max_concurrency,
2208                item_batcher: None,
2209                completion_config: CompletionConfig::default(),
2210                serdes: None,
2211            };
2212
2213            prop_assert_eq!(config.max_concurrency, max_concurrency);
2214
2215            // Verify Debug trait works
2216            let debug_str = format!("{:?}", config);
2217            prop_assert!(!debug_str.is_empty());
2218        }
2219
2220        // **Feature: rust-sdk-test-suite, Property: ParallelConfig validity**
2221        // **Validates: Requirements 5.1**
2222        /// Property: For any ParallelConfig with valid values, the configuration SHALL be usable.
2223        #[test]
2224        fn prop_parallel_config_validity(
2225            max_concurrency in proptest::option::of(1usize..=1000)
2226        ) {
2227            let config = ParallelConfig {
2228                max_concurrency,
2229                completion_config: CompletionConfig::default(),
2230                serdes: None,
2231            };
2232
2233            prop_assert_eq!(config.max_concurrency, max_concurrency);
2234
2235            // Verify Debug trait works
2236            let debug_str = format!("{:?}", config);
2237            prop_assert!(!debug_str.is_empty());
2238        }
2239
2240        // **Feature: sdk-ergonomics-improvements, Property 5: ItemBatcher Configuration Respected**
2241        // **Validates: Requirements 2.1, 2.2**
2242        /// Property: For any ItemBatcher configuration with max_items_per_batch and max_bytes_per_batch,
2243        /// the batch method SHALL produce batches where each batch has at most max_items_per_batch items
2244        /// AND at most max_bytes_per_batch bytes (estimated).
2245        #[test]
2246        fn prop_item_batcher_configuration_respected(
2247            max_items in 1usize..=50,
2248            max_bytes in 100usize..=10000,
2249            item_count in 0usize..=200
2250        ) {
2251            let batcher = ItemBatcher::new(max_items, max_bytes);
2252
2253            // Generate items of varying sizes (strings of different lengths)
2254            let items: Vec<String> = (0..item_count)
2255                .map(|i| format!("item_{:04}", i))
2256                .collect();
2257
2258            let batches = batcher.batch(&items);
2259
2260            // Verify each batch respects the item count limit
2261            for (_, batch) in &batches {
2262                prop_assert!(
2263                    batch.len() <= max_items,
2264                    "Batch has {} items but max is {}",
2265                    batch.len(),
2266                    max_items
2267                );
2268            }
2269
2270            // Verify each batch respects the byte limit (with tolerance for single large items)
2271            for (_, batch) in &batches {
2272                let batch_bytes: usize = batch.iter()
2273                    .map(|item| serde_json::to_string(item).map(|s| s.len()).unwrap_or(0))
2274                    .sum();
2275
2276                // A batch may exceed max_bytes only if it contains a single item
2277                // (we can't split a single item)
2278                if batch.len() > 1 {
2279                    prop_assert!(
2280                        batch_bytes <= max_bytes,
2281                        "Batch has {} bytes but max is {} (batch has {} items)",
2282                        batch_bytes,
2283                        max_bytes,
2284                        batch.len()
2285                    );
2286                }
2287            }
2288        }
2289
2290        // **Feature: sdk-ergonomics-improvements, Property 6: ItemBatcher Ordering Preservation**
2291        // **Validates: Requirements 2.3, 2.4, 2.6, 2.7**
2292        /// Property: For any list of items, after batching with ItemBatcher, concatenating all batches
2293        /// in order SHALL produce a list equal to the original input list.
2294        #[test]
2295        fn prop_item_batcher_ordering_preservation(
2296            max_items in 1usize..=50,
2297            max_bytes in 100usize..=10000,
2298            item_count in 0usize..=200
2299        ) {
2300            let batcher = ItemBatcher::new(max_items, max_bytes);
2301
2302            // Generate items with unique identifiers to verify ordering
2303            let items: Vec<String> = (0..item_count)
2304                .map(|i| format!("item_{:04}", i))
2305                .collect();
2306
2307            let batches = batcher.batch(&items);
2308
2309            // Concatenate all batches in order
2310            let reconstructed: Vec<String> = batches
2311                .into_iter()
2312                .flat_map(|(_, batch)| batch)
2313                .collect();
2314
2315            // Verify the reconstructed list equals the original
2316            prop_assert_eq!(
2317                items.len(),
2318                reconstructed.len(),
2319                "Reconstructed list has different length: expected {}, got {}",
2320                items.len(),
2321                reconstructed.len()
2322            );
2323
2324            for (i, (original, reconstructed_item)) in items.iter().zip(reconstructed.iter()).enumerate() {
2325                prop_assert_eq!(
2326                    original,
2327                    reconstructed_item,
2328                    "Item at index {} differs: expected '{}', got '{}'",
2329                    i,
2330                    original,
2331                    reconstructed_item
2332                );
2333            }
2334        }
2335    }
2336
2337    // =========================================================================
2338    // JitterStrategy Unit Tests
2339    // =========================================================================
2340
2341    #[test]
2342    fn test_jitter_strategy_none_returns_exact_delay() {
2343        let jitter = JitterStrategy::None;
2344        assert_eq!(jitter.apply(10.0, 0), 10.0);
2345        assert_eq!(jitter.apply(5.5, 3), 5.5);
2346        assert_eq!(jitter.apply(0.0, 0), 0.0);
2347        assert_eq!(jitter.apply(100.0, 99), 100.0);
2348    }
2349
2350    #[test]
2351    fn test_jitter_strategy_full_bounds() {
2352        let jitter = JitterStrategy::Full;
2353        for attempt in 0..20 {
2354            let result = jitter.apply(10.0, attempt);
2355            assert!(
2356                result >= 0.0 && result <= 10.0,
2357                "Full jitter for attempt {} produced {}, expected [0, 10]",
2358                attempt,
2359                result
2360            );
2361        }
2362    }
2363
2364    #[test]
2365    fn test_jitter_strategy_half_bounds() {
2366        let jitter = JitterStrategy::Half;
2367        for attempt in 0..20 {
2368            let result = jitter.apply(10.0, attempt);
2369            assert!(
2370                result >= 5.0 && result <= 10.0,
2371                "Half jitter for attempt {} produced {}, expected [5, 10]",
2372                attempt,
2373                result
2374            );
2375        }
2376    }
2377
2378    #[test]
2379    fn test_jitter_strategy_deterministic() {
2380        // Same inputs should always produce the same output
2381        let full = JitterStrategy::Full;
2382        let r1 = full.apply(10.0, 5);
2383        let r2 = full.apply(10.0, 5);
2384        assert_eq!(r1, r2);
2385
2386        let half = JitterStrategy::Half;
2387        let r1 = half.apply(10.0, 5);
2388        let r2 = half.apply(10.0, 5);
2389        assert_eq!(r1, r2);
2390    }
2391
2392    #[test]
2393    fn test_jitter_strategy_zero_delay() {
2394        // Jitter with zero delay should return 0
2395        assert_eq!(JitterStrategy::Full.apply(0.0, 0), 0.0);
2396        assert_eq!(JitterStrategy::Half.apply(0.0, 0), 0.0);
2397        assert_eq!(JitterStrategy::None.apply(0.0, 0), 0.0);
2398    }
2399
2400    #[test]
2401    fn test_jitter_strategy_default_is_none() {
2402        assert_eq!(JitterStrategy::default(), JitterStrategy::None);
2403    }
2404
2405    // =========================================================================
2406    // Retry Strategy with Jitter Integration Tests
2407    // =========================================================================
2408
2409    #[test]
2410    fn test_exponential_backoff_with_full_jitter() {
2411        let strategy = ExponentialBackoff::builder()
2412            .max_attempts(5)
2413            .base_delay(Duration::from_seconds(5))
2414            .max_delay(Duration::from_seconds(60))
2415            .jitter(JitterStrategy::Full)
2416            .build();
2417
2418        for attempt in 0..5 {
2419            let delay = strategy.next_delay(attempt, "error");
2420            assert!(delay.is_some());
2421            let secs = delay.unwrap().to_seconds();
2422            // With full jitter, delay should be >= 1 (minimum floor)
2423            assert!(secs >= 1, "Attempt {} delay {} < 1", attempt, secs);
2424        }
2425        assert!(strategy.next_delay(5, "error").is_none());
2426    }
2427
2428    #[test]
2429    fn test_exponential_backoff_with_half_jitter() {
2430        let strategy = ExponentialBackoff::builder()
2431            .max_attempts(5)
2432            .base_delay(Duration::from_seconds(10))
2433            .max_delay(Duration::from_seconds(60))
2434            .jitter(JitterStrategy::Half)
2435            .build();
2436
2437        for attempt in 0..5 {
2438            let delay = strategy.next_delay(attempt, "error");
2439            assert!(delay.is_some());
2440            let secs = delay.unwrap().to_seconds();
2441            assert!(secs >= 1, "Attempt {} delay {} < 1", attempt, secs);
2442        }
2443    }
2444
2445    #[test]
2446    fn test_exponential_backoff_no_jitter_unchanged() {
2447        // Verify backward compatibility: no jitter produces same results as before
2448        let strategy = ExponentialBackoff::new(5, Duration::from_seconds(1));
2449        assert_eq!(strategy.jitter, JitterStrategy::None);
2450        assert_eq!(strategy.next_delay(0, "e").map(|d| d.to_seconds()), Some(1));
2451        assert_eq!(strategy.next_delay(1, "e").map(|d| d.to_seconds()), Some(2));
2452        assert_eq!(strategy.next_delay(2, "e").map(|d| d.to_seconds()), Some(4));
2453    }
2454
2455    #[test]
2456    fn test_fixed_delay_with_jitter() {
2457        let strategy =
2458            FixedDelay::new(3, Duration::from_seconds(10)).with_jitter(JitterStrategy::Full);
2459
2460        for attempt in 0..3 {
2461            let delay = strategy.next_delay(attempt, "error");
2462            assert!(delay.is_some());
2463            let secs = delay.unwrap().to_seconds();
2464            assert!(secs >= 1, "Attempt {} delay {} < 1", attempt, secs);
2465        }
2466        assert!(strategy.next_delay(3, "error").is_none());
2467    }
2468
2469    #[test]
2470    fn test_fixed_delay_no_jitter_unchanged() {
2471        let strategy = FixedDelay::new(3, Duration::from_seconds(5));
2472        assert_eq!(strategy.jitter, JitterStrategy::None);
2473        assert_eq!(strategy.next_delay(0, "e").map(|d| d.to_seconds()), Some(5));
2474        assert_eq!(strategy.next_delay(1, "e").map(|d| d.to_seconds()), Some(5));
2475    }
2476
2477    #[test]
2478    fn test_linear_backoff_with_jitter() {
2479        let strategy =
2480            LinearBackoff::new(5, Duration::from_seconds(5)).with_jitter(JitterStrategy::Half);
2481
2482        for attempt in 0..5 {
2483            let delay = strategy.next_delay(attempt, "error");
2484            assert!(delay.is_some());
2485            let secs = delay.unwrap().to_seconds();
2486            assert!(secs >= 1, "Attempt {} delay {} < 1", attempt, secs);
2487        }
2488        assert!(strategy.next_delay(5, "error").is_none());
2489    }
2490
2491    #[test]
2492    fn test_linear_backoff_no_jitter_unchanged() {
2493        let strategy = LinearBackoff::new(5, Duration::from_seconds(2));
2494        assert_eq!(strategy.jitter, JitterStrategy::None);
2495        assert_eq!(strategy.next_delay(0, "e").map(|d| d.to_seconds()), Some(2));
2496        assert_eq!(strategy.next_delay(1, "e").map(|d| d.to_seconds()), Some(4));
2497    }
2498
2499    #[test]
2500    fn test_jitter_minimum_floor_all_strategies() {
2501        // Even with full jitter on small delays, minimum should be 1 second
2502        let exp = ExponentialBackoff::builder()
2503            .max_attempts(3)
2504            .base_delay(Duration::from_seconds(1))
2505            .jitter(JitterStrategy::Full)
2506            .build();
2507        for attempt in 0..3 {
2508            let secs = exp.next_delay(attempt, "e").unwrap().to_seconds();
2509            assert!(
2510                secs >= 1,
2511                "ExponentialBackoff attempt {} delay {} < 1",
2512                attempt,
2513                secs
2514            );
2515        }
2516
2517        let fixed = FixedDelay::new(3, Duration::from_seconds(1)).with_jitter(JitterStrategy::Full);
2518        for attempt in 0..3 {
2519            let secs = fixed.next_delay(attempt, "e").unwrap().to_seconds();
2520            assert!(
2521                secs >= 1,
2522                "FixedDelay attempt {} delay {} < 1",
2523                attempt,
2524                secs
2525            );
2526        }
2527
2528        let linear =
2529            LinearBackoff::new(3, Duration::from_seconds(1)).with_jitter(JitterStrategy::Full);
2530        for attempt in 0..3 {
2531            let secs = linear.next_delay(attempt, "e").unwrap().to_seconds();
2532            assert!(
2533                secs >= 1,
2534                "LinearBackoff attempt {} delay {} < 1",
2535                attempt,
2536                secs
2537            );
2538        }
2539    }
2540
2541    // =========================================================================
2542    // JitterStrategy Property-Based Tests
2543    // =========================================================================
2544
2545    /// Strategy for generating valid JitterStrategy values
2546    fn jitter_strategy_strategy() -> impl Strategy<Value = JitterStrategy> {
2547        prop_oneof![
2548            Just(JitterStrategy::None),
2549            Just(JitterStrategy::Full),
2550            Just(JitterStrategy::Half),
2551        ]
2552    }
2553
2554    proptest! {
2555        // **Feature: rust-sdk-parity-gaps, Property: JitterStrategy::None identity**
2556        // **Validates: Requirements 1.2**
2557        /// Property: JitterStrategy::None SHALL return the exact delay for any delay and attempt.
2558        #[test]
2559        fn prop_jitter_none_identity(delay in 0.0f64..1000.0, attempt in 0u32..100) {
2560            let result = JitterStrategy::None.apply(delay, attempt);
2561            prop_assert!((result - delay).abs() < f64::EPSILON,
2562                "None jitter changed delay from {} to {}", delay, result);
2563        }
2564
2565        // **Feature: rust-sdk-parity-gaps, Property: JitterStrategy::Full bounds**
2566        // **Validates: Requirements 1.3**
2567        /// Property: JitterStrategy::Full SHALL return a delay in [0, d] for any non-negative delay.
2568        #[test]
2569        fn prop_jitter_full_bounds(delay in 0.0f64..1000.0, attempt in 0u32..100) {
2570            let result = JitterStrategy::Full.apply(delay, attempt);
2571            prop_assert!(result >= 0.0, "Full jitter result {} < 0", result);
2572            prop_assert!(result <= delay + f64::EPSILON,
2573                "Full jitter result {} > delay {}", result, delay);
2574        }
2575
2576        // **Feature: rust-sdk-parity-gaps, Property: JitterStrategy::Half bounds**
2577        // **Validates: Requirements 1.4**
2578        /// Property: JitterStrategy::Half SHALL return a delay in [d/2, d] for any non-negative delay.
2579        #[test]
2580        fn prop_jitter_half_bounds(delay in 0.0f64..1000.0, attempt in 0u32..100) {
2581            let result = JitterStrategy::Half.apply(delay, attempt);
2582            prop_assert!(result >= delay / 2.0 - f64::EPSILON,
2583                "Half jitter result {} < delay/2 {}", result, delay / 2.0);
2584            prop_assert!(result <= delay + f64::EPSILON,
2585                "Half jitter result {} > delay {}", result, delay);
2586        }
2587
2588        // **Feature: rust-sdk-parity-gaps, Property: JitterStrategy determinism**
2589        // **Validates: Requirements 1.2, 1.3, 1.4**
2590        /// Property: JitterStrategy::apply SHALL be deterministic for the same inputs.
2591        #[test]
2592        fn prop_jitter_deterministic(
2593            jitter in jitter_strategy_strategy(),
2594            delay in 0.0f64..1000.0,
2595            attempt in 0u32..100
2596        ) {
2597            let r1 = jitter.apply(delay, attempt);
2598            let r2 = jitter.apply(delay, attempt);
2599            prop_assert!((r1 - r2).abs() < f64::EPSILON,
2600                "Jitter not deterministic: {} vs {}", r1, r2);
2601        }
2602
2603        // **Feature: rust-sdk-parity-gaps, Property: Jittered delay minimum floor**
2604        // **Validates: Requirements 1.10**
2605        /// Property: All retry strategies with jitter SHALL produce delays >= 1 second.
2606        #[test]
2607        fn prop_jitter_minimum_floor(
2608            jitter in jitter_strategy_strategy(),
2609            attempt in 0u32..10,
2610            base_delay_secs in 1u64..100
2611        ) {
2612            // ExponentialBackoff
2613            let exp = ExponentialBackoff::builder()
2614                .max_attempts(10)
2615                .base_delay(Duration::from_seconds(base_delay_secs))
2616                .jitter(jitter)
2617                .build();
2618            if let Some(d) = exp.next_delay(attempt, "e") {
2619                prop_assert!(d.to_seconds() >= 1,
2620                    "ExponentialBackoff delay {} < 1 for attempt {}", d.to_seconds(), attempt);
2621            }
2622
2623            // FixedDelay
2624            let fixed = FixedDelay::new(10, Duration::from_seconds(base_delay_secs))
2625                .with_jitter(jitter);
2626            if let Some(d) = fixed.next_delay(attempt, "e") {
2627                prop_assert!(d.to_seconds() >= 1,
2628                    "FixedDelay delay {} < 1 for attempt {}", d.to_seconds(), attempt);
2629            }
2630
2631            // LinearBackoff
2632            let linear = LinearBackoff::new(10, Duration::from_seconds(base_delay_secs))
2633                .with_jitter(jitter);
2634            if let Some(d) = linear.next_delay(attempt, "e") {
2635                prop_assert!(d.to_seconds() >= 1,
2636                    "LinearBackoff delay {} < 1 for attempt {}", d.to_seconds(), attempt);
2637            }
2638        }
2639    }
2640}
2641
2642#[cfg(test)]
2643mod retryable_error_filter_tests {
2644    use super::*;
2645
2646    #[test]
2647    fn test_empty_filter_retries_all() {
2648        let filter = RetryableErrorFilter::default();
2649        assert!(filter.is_retryable("any error message"));
2650        assert!(filter.is_retryable(""));
2651        assert!(filter.is_retryable("timeout"));
2652        assert!(filter.is_retryable_with_type("any error", "AnyType"));
2653    }
2654
2655    #[test]
2656    fn test_contains_pattern_matches_substring() {
2657        let filter = RetryableErrorFilter {
2658            patterns: vec![ErrorPattern::Contains("timeout".to_string())],
2659            error_types: vec![],
2660        };
2661        assert!(filter.is_retryable("request timeout occurred"));
2662        assert!(filter.is_retryable("timeout"));
2663        assert!(filter.is_retryable("a timeout happened"));
2664    }
2665
2666    #[test]
2667    fn test_contains_pattern_no_match() {
2668        let filter = RetryableErrorFilter {
2669            patterns: vec![ErrorPattern::Contains("timeout".to_string())],
2670            error_types: vec![],
2671        };
2672        assert!(!filter.is_retryable("connection refused"));
2673        assert!(!filter.is_retryable("invalid input"));
2674        assert!(!filter.is_retryable(""));
2675    }
2676
2677    #[test]
2678    fn test_regex_pattern_matches() {
2679        let filter = RetryableErrorFilter {
2680            patterns: vec![ErrorPattern::Regex(
2681                regex::Regex::new(r"(?i)connection.*refused").unwrap(),
2682            )],
2683            error_types: vec![],
2684        };
2685        assert!(filter.is_retryable("Connection was refused"));
2686        assert!(filter.is_retryable("connection refused"));
2687        assert!(filter.is_retryable("CONNECTION actively REFUSED"));
2688    }
2689
2690    #[test]
2691    fn test_regex_pattern_no_match() {
2692        let filter = RetryableErrorFilter {
2693            patterns: vec![ErrorPattern::Regex(
2694                regex::Regex::new(r"(?i)connection.*refused").unwrap(),
2695            )],
2696            error_types: vec![],
2697        };
2698        assert!(!filter.is_retryable("timeout error"));
2699        assert!(!filter.is_retryable("refused connection")); // wrong order
2700    }
2701
2702    #[test]
2703    fn test_or_logic_multiple_patterns() {
2704        let filter = RetryableErrorFilter {
2705            patterns: vec![
2706                ErrorPattern::Contains("timeout".to_string()),
2707                ErrorPattern::Regex(regex::Regex::new(r"(?i)connection.*refused").unwrap()),
2708            ],
2709            error_types: vec![],
2710        };
2711        // Matches first pattern
2712        assert!(filter.is_retryable("request timeout"));
2713        // Matches second pattern
2714        assert!(filter.is_retryable("Connection refused"));
2715        // Matches neither
2716        assert!(!filter.is_retryable("invalid input"));
2717    }
2718
2719    #[test]
2720    fn test_error_type_matching() {
2721        let filter = RetryableErrorFilter {
2722            patterns: vec![],
2723            error_types: vec!["TransientError".to_string()],
2724        };
2725        // is_retryable only checks patterns, not types
2726        assert!(!filter.is_retryable("some error"));
2727        // is_retryable_with_type checks both
2728        assert!(filter.is_retryable_with_type("some error", "TransientError"));
2729        assert!(!filter.is_retryable_with_type("some error", "PermanentError"));
2730    }
2731
2732    #[test]
2733    fn test_or_logic_patterns_and_types() {
2734        let filter = RetryableErrorFilter {
2735            patterns: vec![ErrorPattern::Contains("timeout".to_string())],
2736            error_types: vec!["TransientError".to_string()],
2737        };
2738        // Matches pattern only
2739        assert!(filter.is_retryable_with_type("request timeout", "PermanentError"));
2740        // Matches type only
2741        assert!(filter.is_retryable_with_type("invalid input", "TransientError"));
2742        // Matches both
2743        assert!(filter.is_retryable_with_type("request timeout", "TransientError"));
2744        // Matches neither
2745        assert!(!filter.is_retryable_with_type("invalid input", "PermanentError"));
2746    }
2747
2748    #[test]
2749    fn test_error_pattern_debug() {
2750        let contains = ErrorPattern::Contains("test".to_string());
2751        let debug_str = format!("{:?}", contains);
2752        assert!(debug_str.contains("Contains"));
2753        assert!(debug_str.contains("test"));
2754
2755        let regex = ErrorPattern::Regex(regex::Regex::new(r"\d+").unwrap());
2756        let debug_str = format!("{:?}", regex);
2757        assert!(debug_str.contains("Regex"));
2758    }
2759
2760    #[test]
2761    fn test_retryable_error_filter_clone() {
2762        let filter = RetryableErrorFilter {
2763            patterns: vec![
2764                ErrorPattern::Contains("timeout".to_string()),
2765                ErrorPattern::Regex(regex::Regex::new(r"err\d+").unwrap()),
2766            ],
2767            error_types: vec!["TransientError".to_string()],
2768        };
2769        let cloned = filter.clone();
2770        assert!(cloned.is_retryable("timeout error"));
2771        assert!(cloned.is_retryable("err42"));
2772        assert!(cloned.is_retryable_with_type("x", "TransientError"));
2773    }
2774
2775    // ==========================================================================
2776    // Tests for WaitDecision, WaitStrategyConfig, and create_wait_strategy
2777    // Requirements: 4.1–4.6
2778    // ==========================================================================
2779
2780    #[test]
2781    fn test_wait_decision_done_when_predicate_false() {
2782        // **Validates: Requirements 4.1, 4.2**
2783        let strategy = create_wait_strategy(WaitStrategyConfig {
2784            max_attempts: Some(10),
2785            initial_delay: Duration::from_seconds(5),
2786            max_delay: Duration::from_seconds(300),
2787            backoff_rate: 1.5,
2788            jitter: JitterStrategy::None,
2789            should_continue_polling: Box::new(|state: &String| state != "COMPLETED"),
2790        });
2791
2792        // When predicate returns false (state == "COMPLETED"), should return Done
2793        let decision = strategy(&"COMPLETED".to_string(), 1);
2794        assert_eq!(decision, WaitDecision::Done);
2795    }
2796
2797    #[test]
2798    fn test_wait_decision_continue_with_backoff() {
2799        // **Validates: Requirements 4.3, 4.5**
2800        let strategy = create_wait_strategy(WaitStrategyConfig {
2801            max_attempts: Some(10),
2802            initial_delay: Duration::from_seconds(5),
2803            max_delay: Duration::from_seconds(300),
2804            backoff_rate: 2.0,
2805            jitter: JitterStrategy::None,
2806            should_continue_polling: Box::new(|state: &String| state != "DONE"),
2807        });
2808
2809        // Attempt 1: delay = min(5 * 2^0, 300) = 5s
2810        let decision = strategy(&"PENDING".to_string(), 1);
2811        assert_eq!(
2812            decision,
2813            WaitDecision::Continue {
2814                delay: Duration::from_seconds(5)
2815            }
2816        );
2817
2818        // Attempt 2: delay = min(5 * 2^1, 300) = 10s
2819        let decision = strategy(&"PENDING".to_string(), 2);
2820        assert_eq!(
2821            decision,
2822            WaitDecision::Continue {
2823                delay: Duration::from_seconds(10)
2824            }
2825        );
2826
2827        // Attempt 3: delay = min(5 * 2^2, 300) = 20s
2828        let decision = strategy(&"PENDING".to_string(), 3);
2829        assert_eq!(
2830            decision,
2831            WaitDecision::Continue {
2832                delay: Duration::from_seconds(20)
2833            }
2834        );
2835    }
2836
2837    #[test]
2838    fn test_wait_strategy_delay_capped_at_max() {
2839        // **Validates: Requirement 4.5**
2840        let strategy = create_wait_strategy(WaitStrategyConfig {
2841            max_attempts: Some(20),
2842            initial_delay: Duration::from_seconds(10),
2843            max_delay: Duration::from_seconds(30),
2844            backoff_rate: 2.0,
2845            jitter: JitterStrategy::None,
2846            should_continue_polling: Box::new(|_: &i32| true),
2847        });
2848
2849        // Attempt 3: delay = min(10 * 2^2, 30) = min(40, 30) = 30s
2850        let decision = strategy(&0, 3);
2851        assert_eq!(
2852            decision,
2853            WaitDecision::Continue {
2854                delay: Duration::from_seconds(30)
2855            }
2856        );
2857
2858        // Attempt 5: delay = min(10 * 2^4, 30) = min(160, 30) = 30s
2859        let decision = strategy(&0, 5);
2860        assert_eq!(
2861            decision,
2862            WaitDecision::Continue {
2863                delay: Duration::from_seconds(30)
2864            }
2865        );
2866    }
2867
2868    #[test]
2869    #[should_panic(expected = "waitForCondition exceeded maximum attempts")]
2870    fn test_wait_strategy_max_attempts_panic() {
2871        // **Validates: Requirement 4.4**
2872        let strategy = create_wait_strategy(WaitStrategyConfig {
2873            max_attempts: Some(3),
2874            initial_delay: Duration::from_seconds(5),
2875            max_delay: Duration::from_seconds(300),
2876            backoff_rate: 1.5,
2877            jitter: JitterStrategy::None,
2878            should_continue_polling: Box::new(|_: &i32| true),
2879        });
2880
2881        // Attempt 3 should panic (attempts_made >= max_attempts)
2882        let _ = strategy(&0, 3);
2883    }
2884
2885    #[test]
2886    fn test_wait_strategy_jitter_application() {
2887        // **Validates: Requirement 4.6**
2888        let strategy = create_wait_strategy(WaitStrategyConfig {
2889            max_attempts: Some(10),
2890            initial_delay: Duration::from_seconds(10),
2891            max_delay: Duration::from_seconds(300),
2892            backoff_rate: 1.0,
2893            jitter: JitterStrategy::Full,
2894            should_continue_polling: Box::new(|_: &i32| true),
2895        });
2896
2897        // With Full jitter on a 10s base delay, the result should be in [1, 10]
2898        // (floored at 1s minimum)
2899        let decision = strategy(&0, 1);
2900        match decision {
2901            WaitDecision::Continue { delay } => {
2902                assert!(
2903                    delay.to_seconds() >= 1 && delay.to_seconds() <= 10,
2904                    "Jittered delay {} should be in [1, 10]",
2905                    delay.to_seconds()
2906                );
2907            }
2908            WaitDecision::Done => panic!("Expected Continue, got Done"),
2909        }
2910    }
2911
2912    #[test]
2913    fn test_wait_strategy_delay_minimum_floor() {
2914        // **Validates: Requirement 4.3**
2915        let strategy = create_wait_strategy(WaitStrategyConfig {
2916            max_attempts: Some(10),
2917            initial_delay: Duration::from_seconds(1),
2918            max_delay: Duration::from_seconds(300),
2919            backoff_rate: 1.0,
2920            jitter: JitterStrategy::Full,
2921            should_continue_polling: Box::new(|_: &i32| true),
2922        });
2923
2924        // Even with Full jitter that could produce 0, the floor should be 1s
2925        let decision = strategy(&0, 1);
2926        match decision {
2927            WaitDecision::Continue { delay } => {
2928                assert!(
2929                    delay.to_seconds() >= 1,
2930                    "Delay {} should be at least 1 second",
2931                    delay.to_seconds()
2932                );
2933            }
2934            WaitDecision::Done => panic!("Expected Continue, got Done"),
2935        }
2936    }
2937
2938    #[test]
2939    fn test_wait_strategy_default_max_attempts() {
2940        // **Validates: Requirement 4.4** — default max_attempts is 60
2941        let strategy = create_wait_strategy(WaitStrategyConfig {
2942            max_attempts: None, // defaults to 60
2943            initial_delay: Duration::from_seconds(1),
2944            max_delay: Duration::from_seconds(10),
2945            backoff_rate: 1.0,
2946            jitter: JitterStrategy::None,
2947            should_continue_polling: Box::new(|_: &i32| true),
2948        });
2949
2950        // Attempt 59 should succeed (< 60)
2951        let decision = strategy(&0, 59);
2952        assert!(matches!(decision, WaitDecision::Continue { .. }));
2953    }
2954
2955    #[test]
2956    #[should_panic(expected = "waitForCondition exceeded maximum attempts")]
2957    fn test_wait_strategy_default_max_attempts_panic() {
2958        // **Validates: Requirement 4.4** — default max_attempts is 60
2959        let strategy = create_wait_strategy(WaitStrategyConfig {
2960            max_attempts: None, // defaults to 60
2961            initial_delay: Duration::from_seconds(1),
2962            max_delay: Duration::from_seconds(10),
2963            backoff_rate: 1.0,
2964            jitter: JitterStrategy::None,
2965            should_continue_polling: Box::new(|_: &i32| true),
2966        });
2967
2968        // Attempt 60 should panic (>= 60)
2969        let _ = strategy(&0, 60);
2970    }
2971
2972    #[test]
2973    fn test_wait_decision_enum_variants() {
2974        // **Validates: Requirement 4.1**
2975        let cont = WaitDecision::Continue {
2976            delay: Duration::from_seconds(5),
2977        };
2978        let done = WaitDecision::Done;
2979
2980        // Verify Debug
2981        assert!(format!("{:?}", cont).contains("Continue"));
2982        assert!(format!("{:?}", done).contains("Done"));
2983
2984        // Verify PartialEq
2985        assert_eq!(
2986            WaitDecision::Continue {
2987                delay: Duration::from_seconds(5)
2988            },
2989            WaitDecision::Continue {
2990                delay: Duration::from_seconds(5)
2991            }
2992        );
2993        assert_ne!(
2994            WaitDecision::Continue {
2995                delay: Duration::from_seconds(5)
2996            },
2997            WaitDecision::Done
2998        );
2999    }
3000}