Skip to main content

oxide_batch/
completion.rs

1//! Bounded chunk-completion (repeat) policies -- `REPEAT-POLICY-001`.
2//!
3//! A [`CompletionPolicy`] decides, *in addition to* the [`ChunkSize`] ceiling
4//! every [`crate::ChunkStep`] already enforces, whether a chunk should stop
5//! accepting further items before that hard ceiling is reached. Installing a
6//! policy never removes the ceiling: `ChunkSize` remains the resource-safety
7//! bound for every chunk regardless of which policy (if any) is installed
8//! via [`crate::ChunkStep::with_completion_policy`], so buffering stays
9//! bounded even if a custom policy never completes on its own.
10//!
11//! Four families are provided:
12//!
13//! - [`ItemCountCompletionPolicy`] -- deterministic item-count completion,
14//!   the same bound as [`ChunkSize`] expressed as a policy.
15//! - [`TimeCompletionPolicy`] -- deterministic, clock-injected time-based
16//!   completion.
17//! - [`CompositeCompletionPolicy`] -- bounded `Any`/`All` composition of
18//!   other policies.
19//! - [`AdaptiveCompletionPolicy`] -- a bounded policy whose target chunk
20//!   size adapts toward an observed target duration. It is both a
21//!   [`CompletionPolicy`] and an [`crate::ItemStream`] over the *same*
22//!   instance state. [`CompletionPolicy::stream_registrations`] reports that
23//!   `ItemStream` registration bound to the exact instance it is called on,
24//!   so [`crate::ChunkStep::with_completion_policy`] (or the
25//!   [`crate::ChunkStep::with_adaptive_completion_policy`] alias) wires it up
26//!   automatically -- whether the policy is installed directly or nested at
27//!   any depth inside a [`CompositeCompletionPolicy`] -- through the same
28//!   commit boundary as any other component state, with no second
29//!   persistence path and no risk of the two registrations drifting onto
30//!   different instances.
31//!
32//! ```
33//! use std::sync::Arc;
34//!
35//! use oxide_batch::{ChunkCount, ChunkSize, CompletionPolicy, ItemCountCompletionPolicy};
36//!
37//! let policy = ItemCountCompletionPolicy::new(ChunkSize::new(10)?);
38//! assert!(!policy.is_complete(ChunkCount::new(9)));
39//! assert!(policy.is_complete(ChunkCount::new(10)));
40//! # Ok::<(), oxide_batch::ChunkError>(())
41//! ```
42
43use std::error::Error;
44use std::fmt;
45use std::panic::{AssertUnwindSafe, catch_unwind};
46use std::sync::{Arc, Mutex, PoisonError, Weak};
47use std::time::{Duration, SystemTime};
48
49use crate::{
50    BoxedStream, ChunkAttemptOutcome, ChunkCount, ChunkSize, Clock, CodecId, CodecVersion,
51    ComponentStateEnvelope, ComponentStreamIdentity, DefaultComponentCodec, ItemStream,
52    RestartabilityDeclaration, StateCodecError, StateLimits, StateSchemaId, StateSchemaVersion,
53    StreamCloseContext, StreamCloseError, StreamCloseOutcome, StreamOpenContext, StreamOpenError,
54    StreamOpenOutcome, StreamStateContract, StreamUpdateContext, StreamUpdateError,
55    VersionedStateCodec,
56};
57
58/// Decides whether a chunk should stop accepting further items.
59///
60/// The chunk runtime calls [`begin_chunk`](Self::begin_chunk) once per chunk
61/// attempt -- including a replayed attempt of the same logical chunk -- and
62/// then calls [`is_complete`](Self::is_complete) once before every item read
63/// in that attempt, stopping the read phase as soon as it (or the
64/// configured [`ChunkSize`] ceiling) returns `true`.
65///
66/// Implementations must be deterministic for a given call sequence and must
67/// never block on I/O: this is a synchronous, per-item decision on the
68/// single-threaded chunk read path.
69pub trait CompletionPolicy: Send + Sync {
70    /// Resets any per-attempt state at the start of a new chunk attempt.
71    ///
72    /// # Lifecycle contract (`REPEAT-POLICY-001`)
73    ///
74    /// The chunk runtime calls [`begin_chunk`](Self::begin_chunk) exactly
75    /// once for every chunk attempt whose transaction successfully began --
76    /// including a replayed attempt of the same logical chunk, and
77    /// regardless of what happens later in that same attempt (a read,
78    /// process, or write failure; an unsupported-capability rejection; a
79    /// cooperative stop) -- and calls [`end_chunk`](Self::end_chunk) exactly
80    /// once in return, once that same attempt reaches a terminal outcome.
81    /// An attempt whose transaction never began receives *neither* call:
82    /// the pairing is exactly-once per begun attempt, never a bare
83    /// `end_chunk` with no matching `begin_chunk`, and never a `begin_chunk`
84    /// left without its matching `end_chunk` -- even when the attempt's own
85    /// rollback subsequently fails. This pairing is structurally enforced by
86    /// the chunk runtime, not left to per-call-site bookkeeping.
87    ///
88    /// The default implementation does nothing, which is correct for a
89    /// stateless policy such as [`ItemCountCompletionPolicy`].
90    fn begin_chunk(&self) {}
91
92    /// Reports whether the chunk should stop accepting more items.
93    ///
94    /// `items_read` is the number of items already read into the current
95    /// chunk attempt.
96    ///
97    /// # Forward-progress invariant
98    ///
99    /// Once an attempt has begun, the chunk runtime consults this once
100    /// before every item read *after* the first: an attempt always reads
101    /// (or observes end-of-input for) at least one item before this
102    /// policy's decision can end its read phase. A policy that reports
103    /// `is_complete(0) == true` therefore can never make the step
104    /// repeatedly commit an all-empty chunk purely because it was never
105    /// given the chance to read anything -- the runtime never repeats a
106    /// zero-item commit solely on this policy's say-so. The read phase
107    /// also always stops once the configured [`ChunkSize`] ceiling is
108    /// reached, independent of what this method returns.
109    fn is_complete(&self, items_read: ChunkCount) -> bool;
110
111    /// Observes the terminal outcome of the chunk attempt this policy's most
112    /// recent [`begin_chunk`](Self::begin_chunk) started.
113    ///
114    /// The chunk runtime calls this exactly once for every attempt whose
115    /// `begin_chunk` actually ran -- after that attempt's transaction has
116    /// committed, rolled back, stopped, or reached an unknown commit result,
117    /// and always before the next attempt's `begin_chunk`. A rollback whose
118    /// own call fails is reported as [`ChunkAttemptOutcome::Unknown`] rather
119    /// than suppressing this callback: the transaction's fate is no longer
120    /// knowable at that point, but the policy still owes a matching `end`
121    /// for the `begin` it already ran. This is never called for an attempt
122    /// whose transaction failed to begin in the first place, since that
123    /// attempt's `begin_chunk` never ran -- see the lifecycle contract on
124    /// [`begin_chunk`](Self::begin_chunk). The default implementation does
125    /// nothing, which is correct for any policy whose decisions depend only
126    /// on the current attempt (every policy in this module except
127    /// [`AdaptiveCompletionPolicy`], which uses this callback to promote a
128    /// speculative candidate into authoritative state only once its chunk is
129    /// known to have committed, never before).
130    fn end_chunk(&self, _outcome: ChunkAttemptOutcome) {}
131
132    /// Returns a canonical, deterministic description of this policy's
133    /// restart-relevant *configuration*, hashed into the owning chunk
134    /// definition's fingerprint so a configuration change that alters
135    /// completion semantics is never mistaken for the same definition across
136    /// a restart.
137    ///
138    /// Must depend only on how this policy was configured, never on state it
139    /// observes at runtime (for example [`AdaptiveCompletionPolicy`]'s
140    /// currently confirmed target): two instances configured identically
141    /// must return the same string regardless of what either has observed so
142    /// far, and a configuration change that changes completion behavior must
143    /// change this string.
144    ///
145    /// # Restart-safety guarantee is only as strong as this override
146    ///
147    /// The framework cannot itself detect a semantic change inside an
148    /// arbitrary application-supplied policy: it can only hash whatever this
149    /// method returns. The default falls back to this policy's concrete type
150    /// name, which distinguishes different policy *kinds* but not different
151    /// *configurations* of the same kind -- so a custom policy that changes
152    /// a configuration value (a threshold, a bound, anything that alters
153    /// which chunks it completes) without overriding this method keeps the
154    /// same fingerprint across that change, and the framework will treat the
155    /// two configurations as the same restart-compatible definition even
156    /// though their completion behavior differs. This is a deliberately
157    /// narrow guarantee (not a compatibility break enforced by the type
158    /// system): every policy in this module overrides it with its actual
159    /// configuration, and a custom policy is responsible for doing the same
160    /// whenever a configuration change must invalidate restart metadata.
161    fn fingerprint(&self) -> String {
162        std::any::type_name::<Self>().to_owned()
163    }
164
165    /// Returns this policy's own required [`ItemStream`] registration(s),
166    /// bound to the exact same instance this policy makes completion
167    /// decisions from -- never a second, independently constructed instance
168    /// that could drift out of sync with it.
169    ///
170    /// Every policy in this module except [`AdaptiveCompletionPolicy`]
171    /// returns an empty list: it has no persisted state of its own.
172    /// [`AdaptiveCompletionPolicy`] returns exactly one entry (itself, under
173    /// its own [`identity`](AdaptiveCompletionPolicy::identity)).
174    /// [`CompositeCompletionPolicy`] returns the concatenation of its
175    /// members' registrations, recursively -- so an adaptive policy nested
176    /// anywhere inside a composite tree, not only installed directly, still
177    /// gets its persisted decision wired to the same instance
178    /// [`crate::ChunkStep::with_completion_policy`] installs, with no
179    /// separate registration call for a caller to get wrong.
180    ///
181    /// A custom policy with its own restart-relevant state should override
182    /// this the same way it overrides [`fingerprint`](Self::fingerprint):
183    /// by returning its own registration(s) here rather than requiring a
184    /// caller to register a second, possibly different, instance.
185    fn stream_registrations(
186        &self,
187    ) -> Vec<(
188        ComponentStreamIdentity,
189        Arc<BoxedStream>,
190        StreamStateContract,
191    )> {
192        Vec::new()
193    }
194
195    /// Returns this policy's own composite nesting depth: `0` for a leaf
196    /// policy, or `1 + ` the greatest depth among a composite's own members.
197    ///
198    /// Used by [`CompositeCompletionPolicy::new`] to enforce
199    /// [`MAX_COMPOSITE_DEPTH`] against direct *and* indirect nesting. The
200    /// default of `0` is correct for every leaf policy; only
201    /// [`CompositeCompletionPolicy`] overrides it.
202    #[doc(hidden)]
203    fn composite_depth(&self) -> usize {
204        0
205    }
206}
207
208// ---------------------------------------------------------------------
209// Count
210// ---------------------------------------------------------------------
211
212/// A deterministic, item-count-bounded completion policy.
213///
214/// Equivalent to the [`ChunkSize`] ceiling every chunk already enforces.
215/// Useful as an explicit [`CompositeCompletionPolicy`] member alongside a
216/// [`TimeCompletionPolicy`], where the raw ceiling alone cannot express "stop
217/// at N items *or* after duration D, whichever comes first".
218#[derive(Clone, Copy, Debug)]
219pub struct ItemCountCompletionPolicy(ChunkSize);
220
221impl ItemCountCompletionPolicy {
222    /// Constructs a policy that completes once `size` items are read.
223    #[must_use]
224    pub const fn new(size: ChunkSize) -> Self {
225        Self(size)
226    }
227
228    /// Returns the configured item limit.
229    #[must_use]
230    pub const fn size(self) -> ChunkSize {
231        self.0
232    }
233}
234
235impl CompletionPolicy for ItemCountCompletionPolicy {
236    fn is_complete(&self, items_read: ChunkCount) -> bool {
237        items_read.get() >= u64::from(self.0.get())
238    }
239
240    fn fingerprint(&self) -> String {
241        format!("count/{}", self.0.get())
242    }
243}
244
245// ---------------------------------------------------------------------
246// Time
247// ---------------------------------------------------------------------
248
249const MIN_TIME_THRESHOLD: Duration = Duration::from_millis(1);
250const MAX_TIME_THRESHOLD: Duration = Duration::from_hours(24);
251
252/// A bounded chunk-completion time threshold in `1 ms..=24 h`.
253#[derive(Clone, Copy, Debug, Eq, PartialEq)]
254pub struct ChunkTimeThreshold(Duration);
255
256impl ChunkTimeThreshold {
257    /// Validates a completion threshold.
258    ///
259    /// # Errors
260    ///
261    /// Returns [`CompletionPolicyError::InvalidTimeThreshold`] outside
262    /// `1 ms..=24 h`.
263    pub fn new(value: Duration) -> Result<Self, CompletionPolicyError> {
264        if !(MIN_TIME_THRESHOLD..=MAX_TIME_THRESHOLD).contains(&value) {
265            return Err(CompletionPolicyError::InvalidTimeThreshold);
266        }
267        Ok(Self(value))
268    }
269
270    /// Returns the validated duration.
271    #[must_use]
272    pub const fn get(self) -> Duration {
273        self.0
274    }
275}
276
277/// A deterministic, clock-injected time-bounded completion policy.
278///
279/// `clock` is read only at [`begin_chunk`](CompletionPolicy::begin_chunk)
280/// and each [`is_complete`](CompletionPolicy::is_complete) call, so a test
281/// can substitute a deterministic clock (for example
282/// `oxide_batch_test::ManualClock`) wherever this policy is constructed --
283/// no wall-clock read is spread through runtime logic. A clock reading that
284/// goes backward between those two calls is treated as zero elapsed time,
285/// never a negative duration and never a panic.
286///
287/// This policy alone does not bound the number of items buffered while
288/// waiting for the threshold to elapse; the enclosing [`ChunkSize`] ceiling
289/// still applies and remains the resource-safety bound.
290pub struct TimeCompletionPolicy {
291    clock: Arc<dyn Clock>,
292    threshold: ChunkTimeThreshold,
293    started: Mutex<Option<SystemTime>>,
294}
295
296impl TimeCompletionPolicy {
297    /// Constructs a policy that completes once `threshold` elapses since the
298    /// current chunk attempt began.
299    #[must_use]
300    pub fn new(clock: Arc<dyn Clock>, threshold: ChunkTimeThreshold) -> Self {
301        Self {
302            clock,
303            threshold,
304            started: Mutex::new(None),
305        }
306    }
307}
308
309impl CompletionPolicy for TimeCompletionPolicy {
310    fn begin_chunk(&self) {
311        let mut started = self.started.lock().unwrap_or_else(PoisonError::into_inner);
312        *started = Some(self.clock.now());
313    }
314
315    fn is_complete(&self, _items_read: ChunkCount) -> bool {
316        let started = self.started.lock().unwrap_or_else(PoisonError::into_inner);
317        let Some(started) = *started else {
318            return false;
319        };
320        let elapsed = self
321            .clock
322            .now()
323            .duration_since(started)
324            .unwrap_or(Duration::ZERO);
325        elapsed >= self.threshold.get()
326    }
327
328    fn fingerprint(&self) -> String {
329        format!("time/{}", self.threshold.get().as_nanos())
330    }
331}
332
333// ---------------------------------------------------------------------
334// Composite
335// ---------------------------------------------------------------------
336
337/// How a [`CompositeCompletionPolicy`] combines its members' decisions.
338#[derive(Clone, Copy, Debug, Eq, PartialEq)]
339#[non_exhaustive]
340pub enum CompositeMode {
341    /// Complete as soon as any member policy completes (logical OR).
342    Any,
343    /// Complete only once every member policy completes (logical AND).
344    All,
345}
346
347/// The largest number of members one [`CompositeCompletionPolicy`] may hold.
348pub const MAX_COMPOSITE_MEMBERS: usize = 32;
349
350/// The largest nesting depth one [`CompositeCompletionPolicy`] tree may
351/// reach, counting the outermost composite as depth `1`.
352///
353/// Bounds direct *and* indirect nesting: a composite whose member is itself a
354/// composite (at any remove) contributes its own depth plus one to its
355/// parent's. [`CompositeCompletionPolicy::new`] enforces this eagerly at
356/// construction, before any nested structure exists to recurse over at
357/// runtime.
358pub const MAX_COMPOSITE_DEPTH: usize = 8;
359
360/// A deterministic, bounded composition of completion policies.
361///
362/// Composition never recurses without bound: a composite's own member count
363/// is validated at construction against [`MAX_COMPOSITE_MEMBERS`], and its
364/// full nesting depth (direct and indirect) is validated against
365/// [`MAX_COMPOSITE_DEPTH`]. Both checks run at construction, so a
366/// too-deep tree is rejected before it exists rather than discovered by
367/// runtime recursion.
368pub struct CompositeCompletionPolicy {
369    members: Vec<Arc<dyn CompletionPolicy>>,
370    mode: CompositeMode,
371    depth: usize,
372}
373
374impl fmt::Debug for CompositeCompletionPolicy {
375    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
376        formatter
377            .debug_struct("CompositeCompletionPolicy")
378            .field("mode", &self.mode)
379            .field("members", &self.members.len())
380            .field("depth", &self.depth)
381            .finish()
382    }
383}
384
385impl CompositeCompletionPolicy {
386    /// Validates and constructs a composite policy.
387    ///
388    /// # Errors
389    ///
390    /// Returns [`CompletionPolicyError::EmptyComposite`] for an empty
391    /// `members`, [`CompletionPolicyError::TooManyMembers`] above
392    /// [`MAX_COMPOSITE_MEMBERS`], or [`CompletionPolicyError::CompositeTooDeep`]
393    /// when nesting a member (at any remove) would exceed
394    /// [`MAX_COMPOSITE_DEPTH`].
395    pub fn new(
396        mode: CompositeMode,
397        members: Vec<Arc<dyn CompletionPolicy>>,
398    ) -> Result<Self, CompletionPolicyError> {
399        if members.is_empty() {
400            return Err(CompletionPolicyError::EmptyComposite);
401        }
402        if members.len() > MAX_COMPOSITE_MEMBERS {
403            return Err(CompletionPolicyError::TooManyMembers {
404                max: MAX_COMPOSITE_MEMBERS,
405            });
406        }
407        let depth = 1 + members
408            .iter()
409            .map(|member| member.composite_depth())
410            .max()
411            .unwrap_or(0);
412        if depth > MAX_COMPOSITE_DEPTH {
413            return Err(CompletionPolicyError::CompositeTooDeep {
414                max: MAX_COMPOSITE_DEPTH,
415            });
416        }
417        Ok(Self {
418            members,
419            mode,
420            depth,
421        })
422    }
423}
424
425impl CompletionPolicy for CompositeCompletionPolicy {
426    /// Begins every member in registration order, containing a panic from
427    /// any one of them so the lifecycle contract on
428    /// [`CompletionPolicy::begin_chunk`] still holds *per member*, not only
429    /// for this composite as a whole.
430    ///
431    /// The chunk runtime's own panic boundary treats a panicking
432    /// `begin_chunk` as never having begun -- correct for this composite as
433    /// a unit, but not sufficient on its own: a member positioned earlier
434    /// than the one that panics has already begun and is owed a matching
435    /// `end_chunk`, which nothing outside this composite can ever supply
436    /// once this call unwinds past it. So this method supplies it itself,
437    /// with [`ChunkAttemptOutcome::Unknown`] (this attempt's real outcome is
438    /// not yet known, and never will be, since it never validly began) --
439    /// then re-panics so the caller still observes exactly what a
440    /// non-composite policy's panicking `begin_chunk` would produce: this
441    /// composite, as a whole, never began.
442    fn begin_chunk(&self) {
443        let mut panicked = None;
444        for (index, member) in self.members.iter().enumerate() {
445            match catch_unwind(AssertUnwindSafe(|| member.begin_chunk())) {
446                Ok(()) => {}
447                Err(payload) => {
448                    panicked = Some((index, payload));
449                    break;
450                }
451            }
452        }
453        if let Some((panicked_at, payload)) = panicked {
454            for member in &self.members[..panicked_at] {
455                let _ = catch_unwind(AssertUnwindSafe(|| {
456                    member.end_chunk(ChunkAttemptOutcome::Unknown);
457                }));
458            }
459            // Resumes the member's own panic rather than raising a new one:
460            // this is a rethrow, not a fresh `panic!`, so the caller's
461            // `catch_unwind` still observes exactly what a non-composite
462            // policy's panicking `begin_chunk` would produce.
463            std::panic::resume_unwind(payload);
464        }
465    }
466
467    fn is_complete(&self, items_read: ChunkCount) -> bool {
468        match self.mode {
469            CompositeMode::Any => self
470                .members
471                .iter()
472                .any(|member| member.is_complete(items_read)),
473            CompositeMode::All => self
474                .members
475                .iter()
476                .all(|member| member.is_complete(items_read)),
477        }
478    }
479
480    /// Ends every member in registration order, continuing after any one
481    /// member's `end_chunk` panics so every member that began still
482    /// receives its own matching `end_chunk` call -- the same per-member
483    /// contract [`Self::begin_chunk`] preserves on the panicking path.
484    /// Re-panics once every member has been given the chance, so the caller
485    /// still observes this composite's own `end_chunk` as having panicked
486    /// (never silently succeeding when a member failed).
487    fn end_chunk(&self, outcome: ChunkAttemptOutcome) {
488        let mut first_panic = None;
489        for member in &self.members {
490            if let Err(payload) = catch_unwind(AssertUnwindSafe(|| member.end_chunk(outcome))) {
491                first_panic.get_or_insert(payload);
492            }
493        }
494        if let Some(payload) = first_panic {
495            // Resumes the first member's own panic, once every member has
496            // had its own `end_chunk` attempted -- a rethrow, not a fresh
497            // `panic!`, so the caller's `catch_unwind` still observes this
498            // composite's `end_chunk` as having panicked.
499            std::panic::resume_unwind(payload);
500        }
501    }
502
503    fn fingerprint(&self) -> String {
504        // Length-prefixed (rather than delimiter-joined) so the encoding is
505        // injective: a delimiter appearing inside one member's own
506        // fingerprint (for example a nested composite's `[...]`) can never
507        // be misread as a boundary between members. Two composites whose
508        // members split their fingerprints differently -- `["a", "b,c"]`
509        // vs `["a,b", "c"]` -- therefore always produce different strings.
510        use std::fmt::Write as _;
511        let mut members = String::new();
512        for member in &self.members {
513            let fingerprint = member.fingerprint();
514            let _ = write!(members, "{}:{fingerprint}", fingerprint.len());
515        }
516        format!("composite/{:?}/[{members}]", self.mode)
517    }
518
519    fn stream_registrations(
520        &self,
521    ) -> Vec<(
522        ComponentStreamIdentity,
523        Arc<BoxedStream>,
524        StreamStateContract,
525    )> {
526        self.members
527            .iter()
528            .flat_map(|member| member.stream_registrations())
529            .collect()
530    }
531
532    fn composite_depth(&self) -> usize {
533        self.depth
534    }
535}
536
537// ---------------------------------------------------------------------
538// Errors
539// ---------------------------------------------------------------------
540
541/// Stable completion-policy validation failure.
542#[derive(Clone, Copy, Debug, Eq, PartialEq)]
543#[non_exhaustive]
544pub enum CompletionPolicyError {
545    /// A time threshold was outside `1 ms..=24 h`.
546    InvalidTimeThreshold,
547    /// A composite policy had no members.
548    EmptyComposite,
549    /// A composite policy exceeded [`MAX_COMPOSITE_MEMBERS`].
550    TooManyMembers {
551        /// The maximum accepted member count.
552        max: usize,
553    },
554    /// Nesting a composite policy's member (at any remove) would exceed
555    /// [`MAX_COMPOSITE_DEPTH`].
556    CompositeTooDeep {
557        /// The maximum accepted nesting depth.
558        max: usize,
559    },
560    /// An adaptive policy's minimum bound exceeded its maximum bound.
561    InvalidAdaptiveBounds,
562}
563
564impl fmt::Display for CompletionPolicyError {
565    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
566        match self {
567            Self::InvalidTimeThreshold => {
568                formatter.write_str("completion time threshold must be within 1 ms..=24 h")
569            }
570            Self::EmptyComposite => {
571                formatter.write_str("a composite completion policy requires at least one member")
572            }
573            Self::TooManyMembers { max } => write!(
574                formatter,
575                "a composite completion policy accepts at most {max} members"
576            ),
577            Self::CompositeTooDeep { max } => write!(
578                formatter,
579                "a composite completion policy nests at most {max} levels deep"
580            ),
581            Self::InvalidAdaptiveBounds => formatter
582                .write_str("adaptive completion policy minimum bound exceeds its maximum bound"),
583        }
584    }
585}
586
587impl Error for CompletionPolicyError {}
588
589// ---------------------------------------------------------------------
590// Adaptive
591// ---------------------------------------------------------------------
592
593/// Validated `min..=max` item-count bounds for an [`AdaptiveCompletionPolicy`].
594#[derive(Clone, Copy, Debug, Eq, PartialEq)]
595pub struct AdaptiveBounds {
596    min: ChunkSize,
597    max: ChunkSize,
598}
599
600impl AdaptiveBounds {
601    /// Validates `min <= max`.
602    ///
603    /// # Errors
604    ///
605    /// Returns [`CompletionPolicyError::InvalidAdaptiveBounds`] when `min`
606    /// exceeds `max`.
607    pub fn new(min: ChunkSize, max: ChunkSize) -> Result<Self, CompletionPolicyError> {
608        if min.get() > max.get() {
609            return Err(CompletionPolicyError::InvalidAdaptiveBounds);
610        }
611        Ok(Self { min, max })
612    }
613
614    /// Returns the minimum accepted target size.
615    #[must_use]
616    pub const fn min(self) -> ChunkSize {
617        self.min
618    }
619
620    /// Returns the maximum accepted target size.
621    #[must_use]
622    pub const fn max(self) -> ChunkSize {
623        self.max
624    }
625
626    fn clamp(self, target: u32) -> ChunkSize {
627        let clamped = target.clamp(self.min.get(), self.max.get());
628        ChunkSize::new(clamped).unwrap_or(self.min)
629    }
630}
631
632#[derive(Clone, Copy)]
633struct AdaptiveDecision {
634    target: ChunkSize,
635}
636
637const ADAPTIVE_SCHEMA: &str = "oxide-batch.completion.adaptive-decision";
638const ADAPTIVE_CODEC: &str = "oxide-batch.completion.adaptive-decision-codec";
639
640#[derive(Clone, Copy)]
641struct AdaptiveDecisionSchema;
642
643impl VersionedStateCodec<AdaptiveDecision> for AdaptiveDecisionSchema {
644    fn schema_id(&self) -> &StateSchemaId {
645        static SCHEMA: std::sync::OnceLock<StateSchemaId> = std::sync::OnceLock::new();
646        #[allow(
647            clippy::unwrap_used,
648            reason = "fixed literal schema identity cannot fail validation"
649        )]
650        SCHEMA.get_or_init(|| StateSchemaId::new(ADAPTIVE_SCHEMA).unwrap())
651    }
652
653    fn current_version(&self) -> StateSchemaVersion {
654        #[allow(
655            clippy::unwrap_used,
656            reason = "fixed literal schema version cannot fail validation"
657        )]
658        StateSchemaVersion::new(1).unwrap()
659    }
660
661    fn encode(&self, value: &AdaptiveDecision) -> Result<Vec<u8>, StateCodecError> {
662        serde_json::to_vec(&serde_json::json!({ "target_items": value.target.get() }))
663            .map_err(|_| StateCodecError::InvalidPayload)
664    }
665
666    fn decode(&self, payload: &[u8]) -> Result<AdaptiveDecision, StateCodecError> {
667        let value: serde_json::Value =
668            serde_json::from_slice(payload).map_err(|_| StateCodecError::InvalidPayload)?;
669        let target_items = u32::try_from(
670            value
671                .get("target_items")
672                .and_then(serde_json::Value::as_u64)
673                .ok_or(StateCodecError::InvalidPayload)?,
674        )
675        .map_err(|_| StateCodecError::InvalidPayload)?;
676        let target = ChunkSize::new(target_items).map_err(|_| StateCodecError::InvalidPayload)?;
677        Ok(AdaptiveDecision { target })
678    }
679}
680
681fn adaptive_decision_codec() -> DefaultComponentCodec<AdaptiveDecisionSchema> {
682    #[allow(
683        clippy::unwrap_used,
684        reason = "fixed literal identities cannot fail validation"
685    )]
686    DefaultComponentCodec::new(
687        AdaptiveDecisionSchema,
688        CodecId::new(ADAPTIVE_CODEC).unwrap(),
689        CodecVersion::new(1).unwrap(),
690        RestartabilityDeclaration::Restartable,
691    )
692}
693
694struct AdaptiveInterior {
695    /// The authoritative target: the last value this process has observed
696    /// *commit* (via [`CompletionPolicy::end_chunk`]) or restore (via
697    /// [`ItemStream::open`]). [`CompletionPolicy::is_complete`] and
698    /// [`AdaptiveCompletionPolicy::current_target`] read only this field --
699    /// never `pending` -- so an in-flight, not-yet-committed candidate can
700    /// never be observed as authoritative.
701    confirmed: ChunkSize,
702    /// This attempt's not-yet-committed candidate, computed by
703    /// [`ItemStream::update`] from `confirmed` and this attempt's freshly
704    /// observed duration. Promoted into `confirmed` by
705    /// [`CompletionPolicy::end_chunk`] on [`ChunkAttemptOutcome::Committed`],
706    /// and discarded on every other outcome (including by the next
707    /// [`CompletionPolicy::begin_chunk`], defensively, should `end_chunk`
708    /// never run for this attempt).
709    pending: Option<ChunkSize>,
710    chunk_started: Option<SystemTime>,
711}
712
713/// A bounded, restart-safe adaptive completion policy.
714///
715/// The policy adjusts its target chunk size toward `target_duration`,
716/// clamped to `bounds`, based on the most recently *committed* chunk's
717/// observed duration. Install it via
718/// [`crate::ChunkStep::with_completion_policy`] (directly, or nested inside
719/// a [`CompositeCompletionPolicy`] at any depth): [`stream_registrations`]
720/// reports its namespaced [`ItemStream`] registration under the same
721/// identity returned by [`identity`](Self::identity), bound to this exact
722/// instance, so the confirmed target survives restart through the same
723/// commit-boundary mechanism as any other component state -- never a second
724/// persistence path, and never two registrations that could drift onto
725/// different instances.
726///
727/// [`stream_registrations`]: CompletionPolicy::stream_registrations
728///
729/// # Restart safety
730///
731/// A process crash before a chunk commits leaves the previously committed
732/// target authoritative: [`ItemStream::open`] restores exactly that target
733/// from the durable envelope, never a value a still-open, not-yet-committed
734/// attempt only speculated.
735///
736/// # Same-process rollback safety
737///
738/// [`ItemStream::update`] never mutates the confirmed target: it only
739/// computes a candidate from the confirmed baseline and this attempt's
740/// freshly observed duration, storing it as a separate, speculative
741/// `pending` value. That candidate is promoted to `confirmed` -- becoming
742/// visible to [`CompletionPolicy::is_complete`] -- only when
743/// [`CompletionPolicy::end_chunk`] observes
744/// [`ChunkAttemptOutcome::Committed`]; every other outcome discards it. A
745/// rolled-back attempt that gets replayed therefore always recomputes its
746/// candidate from the same unmodified confirmed baseline, never a
747/// corrupted or partially-applied one, and a rollback can never leave a
748/// speculative target looking authoritative. The recomputed candidate
749/// itself is not guaranteed identical to the discarded one: it is a
750/// function of this replay's freshly observed duration, which genuinely
751/// can differ from the rolled-back attempt's (only a fully deterministic
752/// clock, as a test might inject, makes the two identical).
753pub struct AdaptiveCompletionPolicy {
754    bounds: AdaptiveBounds,
755    target_duration: ChunkTimeThreshold,
756    clock: Arc<dyn Clock>,
757    limits: StateLimits,
758    identity: ComponentStreamIdentity,
759    state: Mutex<AdaptiveInterior>,
760    /// A weak self-reference, populated at construction via
761    /// [`Arc::new_cyclic`], letting [`stream_registrations`](CompletionPolicy::stream_registrations)
762    /// hand out an `Arc` that shares this exact instance's state -- never a
763    /// second, independently constructed instance -- without requiring a
764    /// caller to already hold one.
765    self_weak: Weak<AdaptiveCompletionPolicy>,
766}
767
768impl AdaptiveCompletionPolicy {
769    /// Constructs an adaptive policy starting at `bounds`'s minimum target.
770    ///
771    /// Returns `Arc<Self>` rather than `Self`: the policy holds a weak
772    /// self-reference so [`stream_registrations`](CompletionPolicy::stream_registrations)
773    /// can bind its persisted state to this exact instance regardless of
774    /// whether it ends up installed directly or nested inside a
775    /// [`CompositeCompletionPolicy`].
776    #[must_use]
777    pub fn new(
778        identity: ComponentStreamIdentity,
779        bounds: AdaptiveBounds,
780        target_duration: ChunkTimeThreshold,
781        clock: Arc<dyn Clock>,
782    ) -> Arc<Self> {
783        Arc::new_cyclic(|self_weak| Self {
784            bounds,
785            target_duration,
786            clock,
787            limits: StateLimits::default(),
788            identity,
789            state: Mutex::new(AdaptiveInterior {
790                confirmed: bounds.min(),
791                pending: None,
792                chunk_started: None,
793            }),
794            self_weak: self_weak.clone(),
795        })
796    }
797
798    /// Returns the namespace this policy's persisted decision is registered
799    /// under.
800    #[must_use]
801    pub const fn identity(&self) -> &ComponentStreamIdentity {
802        &self.identity
803    }
804
805    /// Returns the `StreamStateContract` matching this policy's own
806    /// internal codec.
807    ///
808    /// This policy's schema and codec identity are its own implementation
809    /// detail: a caller has no way to reconstruct a matching contract
810    /// itself, so [`stream_registrations`](CompletionPolicy::stream_registrations)
811    /// calls this rather than accepting a contract parameter that could
812    /// mismatch.
813    pub(crate) fn stream_state_contract() -> StreamStateContract {
814        StreamStateContract::new(adaptive_decision_codec())
815    }
816
817    /// Returns the current confirmed target: the authoritative decision as
818    /// of the last chunk this process has observed commit (or restored at
819    /// [`ItemStream::open`]). Never a speculative, not-yet-committed
820    /// candidate.
821    #[must_use]
822    pub fn current_target(&self) -> ChunkSize {
823        self.state
824            .lock()
825            .unwrap_or_else(PoisonError::into_inner)
826            .confirmed
827    }
828}
829
830impl CompletionPolicy for AdaptiveCompletionPolicy {
831    fn begin_chunk(&self) {
832        let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
833        // Defensive: a pending candidate only survives here if this attempt's
834        // `end_chunk` never ran (for example a fatal error outside the
835        // ordinary commit/rollback/stop/unknown outcomes). Discarding it
836        // keeps `confirmed` the sole source of truth for the attempt about
837        // to begin.
838        state.pending = None;
839        state.chunk_started = Some(self.clock.now());
840    }
841
842    fn is_complete(&self, items_read: ChunkCount) -> bool {
843        let target = self.current_target();
844        items_read.get() >= u64::from(target.get())
845    }
846
847    fn end_chunk(&self, outcome: ChunkAttemptOutcome) {
848        let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
849        let pending = state.pending.take();
850        if let (ChunkAttemptOutcome::Committed, Some(pending)) = (outcome, pending) {
851            state.confirmed = pending;
852        }
853    }
854
855    fn fingerprint(&self) -> String {
856        format!(
857            "adaptive/{}/{}/{}",
858            self.bounds.min().get(),
859            self.bounds.max().get(),
860            self.target_duration.get().as_nanos()
861        )
862    }
863
864    fn stream_registrations(
865        &self,
866    ) -> Vec<(
867        ComponentStreamIdentity,
868        Arc<BoxedStream>,
869        StreamStateContract,
870    )> {
871        let Some(strong) = self.self_weak.upgrade() else {
872            return Vec::new();
873        };
874        vec![(
875            self.identity.clone(),
876            Arc::new(BoxedStream::new(AdaptiveCompletionStream(strong))),
877            Self::stream_state_contract(),
878        )]
879    }
880}
881
882/// Delegates the [`ItemStream`] contract to a shared
883/// [`AdaptiveCompletionPolicy`], so [`CompletionPolicy::stream_registrations`]
884/// can register the same underlying instance for both roles without exposing
885/// a public `ItemStream` impl over `Arc<T>` (which would let any two
886/// unrelated `Arc` clones of *different* instances be registered instead,
887/// reopening the exact mistake this type exists to prevent).
888struct AdaptiveCompletionStream(Arc<AdaptiveCompletionPolicy>);
889
890impl ItemStream for AdaptiveCompletionStream {
891    async fn open(
892        &self,
893        context: StreamOpenContext<'_>,
894    ) -> Result<StreamOpenOutcome, StreamOpenError> {
895        self.0.open(context).await
896    }
897
898    async fn update(
899        &self,
900        context: StreamUpdateContext<'_>,
901    ) -> Result<ComponentStateEnvelope, StreamUpdateError> {
902        self.0.update(context).await
903    }
904
905    async fn close(
906        &self,
907        context: StreamCloseContext<'_>,
908    ) -> Result<StreamCloseOutcome, StreamCloseError> {
909        self.0.close(context).await
910    }
911}
912
913impl ItemStream for AdaptiveCompletionPolicy {
914    async fn open(
915        &self,
916        context: StreamOpenContext<'_>,
917    ) -> Result<StreamOpenOutcome, StreamOpenError> {
918        let Some(inherited) = context.inherited_state() else {
919            return Ok(StreamOpenOutcome::Initial);
920        };
921        let codec = adaptive_decision_codec();
922        let decoded: AdaptiveDecision = inherited
923            .decode(&codec)
924            .map_err(|_| StreamOpenError::new())?;
925        let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
926        state.confirmed = decoded.target;
927        state.pending = None;
928        Ok(StreamOpenOutcome::Restored)
929    }
930
931    async fn update(
932        &self,
933        _context: StreamUpdateContext<'_>,
934    ) -> Result<ComponentStateEnvelope, StreamUpdateError> {
935        // Pure with respect to `confirmed`: reads the confirmed baseline but
936        // never writes it, so a replayed attempt (rolled back and retried
937        // without an intervening commit) recomputes this exact candidate
938        // again from the same baseline. Only `pending` -- never observed by
939        // `is_complete` -- records the result, and only `end_chunk` may ever
940        // promote it into `confirmed`.
941        let next_target = {
942            let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
943            let elapsed = state.chunk_started.map(|started| {
944                self.clock
945                    .now()
946                    .duration_since(started)
947                    .unwrap_or(Duration::ZERO)
948            });
949            adjust_target(
950                state.confirmed,
951                elapsed,
952                self.target_duration.get(),
953                self.bounds,
954            )
955        };
956        {
957            let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
958            state.pending = Some(next_target);
959        }
960        let codec = adaptive_decision_codec();
961        ComponentStateEnvelope::encode(
962            self.identity.clone(),
963            &AdaptiveDecision {
964                target: next_target,
965            },
966            &codec,
967            self.limits,
968        )
969        .map_err(|_| StreamUpdateError::new())
970    }
971
972    async fn close(
973        &self,
974        _context: StreamCloseContext<'_>,
975    ) -> Result<StreamCloseOutcome, StreamCloseError> {
976        Ok(StreamCloseOutcome::Closed)
977    }
978}
979
980/// Adjusts `confirmed` one bounded step toward `target_duration`, given the
981/// just-observed chunk duration (`None` when no chunk attempt has started
982/// yet in this step attempt).
983///
984/// Moves by roughly one eighth of the current target per attempt so the
985/// policy converges smoothly instead of oscillating between the bounds, and
986/// only when the observed duration is more than 20% away from the target so
987/// noise near the target does not perturb the decision.
988fn adjust_target(
989    confirmed: ChunkSize,
990    observed: Option<Duration>,
991    target_duration: Duration,
992    bounds: AdaptiveBounds,
993) -> ChunkSize {
994    let Some(observed) = observed else {
995        return confirmed;
996    };
997    let tolerance = target_duration / 5;
998    let step = (confirmed.get() / 8).max(1);
999    if observed < target_duration.saturating_sub(tolerance) {
1000        bounds.clamp(confirmed.get().saturating_add(step))
1001    } else if observed > target_duration.saturating_add(tolerance) {
1002        bounds.clamp(confirmed.get().saturating_sub(step))
1003    } else {
1004        confirmed
1005    }
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010    use super::*;
1011
1012    #[test]
1013    fn count_policy_boundaries() {
1014        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1015        let policy = ItemCountCompletionPolicy::new(ChunkSize::new(3).unwrap());
1016        assert!(!policy.is_complete(ChunkCount::new(0)));
1017        assert!(!policy.is_complete(ChunkCount::new(2)));
1018        assert!(policy.is_complete(ChunkCount::new(3)));
1019        assert!(policy.is_complete(ChunkCount::new(4)));
1020    }
1021
1022    #[test]
1023    fn time_threshold_rejects_out_of_bounds() {
1024        assert!(ChunkTimeThreshold::new(Duration::ZERO).is_err());
1025        assert!(ChunkTimeThreshold::new(Duration::from_hours(25)).is_err());
1026        assert!(ChunkTimeThreshold::new(Duration::from_secs(1)).is_ok());
1027    }
1028
1029    #[test]
1030    #[allow(clippy::unwrap_used, reason = "test asserts the exact Err variant")]
1031    fn composite_rejects_empty_and_oversized() {
1032        assert_eq!(
1033            CompositeCompletionPolicy::new(CompositeMode::Any, Vec::new()).unwrap_err(),
1034            CompletionPolicyError::EmptyComposite
1035        );
1036        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1037        let member: Arc<dyn CompletionPolicy> =
1038            Arc::new(ItemCountCompletionPolicy::new(ChunkSize::new(1).unwrap()));
1039        let too_many = std::iter::repeat_with(|| Arc::clone(&member))
1040            .take(MAX_COMPOSITE_MEMBERS + 1)
1041            .collect();
1042        assert!(matches!(
1043            CompositeCompletionPolicy::new(CompositeMode::Any, too_many),
1044            Err(CompletionPolicyError::TooManyMembers { max }) if max == MAX_COMPOSITE_MEMBERS
1045        ));
1046    }
1047
1048    #[test]
1049    #[allow(clippy::unwrap_used, reason = "test constructs known-valid policies")]
1050    fn composite_any_and_all_semantics() {
1051        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1052        let small: Arc<dyn CompletionPolicy> =
1053            Arc::new(ItemCountCompletionPolicy::new(ChunkSize::new(2).unwrap()));
1054        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1055        let large: Arc<dyn CompletionPolicy> =
1056            Arc::new(ItemCountCompletionPolicy::new(ChunkSize::new(5).unwrap()));
1057
1058        let any = CompositeCompletionPolicy::new(
1059            CompositeMode::Any,
1060            vec![Arc::clone(&small), Arc::clone(&large)],
1061        )
1062        .unwrap();
1063        assert!(any.is_complete(ChunkCount::new(2)));
1064        assert!(!any.is_complete(ChunkCount::new(1)));
1065
1066        let all = CompositeCompletionPolicy::new(CompositeMode::All, vec![small, large]).unwrap();
1067        assert!(!all.is_complete(ChunkCount::new(2)));
1068        assert!(all.is_complete(ChunkCount::new(5)));
1069    }
1070
1071    #[test]
1072    #[allow(clippy::unwrap_used, reason = "test asserts the exact Err variant")]
1073    fn adaptive_bounds_reject_min_above_max() {
1074        #[allow(clippy::unwrap_used, reason = "test literals are valid ChunkSizes")]
1075        let (min, max) = (ChunkSize::new(10).unwrap(), ChunkSize::new(5).unwrap());
1076        assert_eq!(
1077            AdaptiveBounds::new(min, max).unwrap_err(),
1078            CompletionPolicyError::InvalidAdaptiveBounds
1079        );
1080    }
1081
1082    #[test]
1083    fn adjust_target_moves_toward_faster_chunks_when_slow() {
1084        #[allow(clippy::unwrap_used, reason = "test literals are valid ChunkSizes")]
1085        let bounds =
1086            AdaptiveBounds::new(ChunkSize::new(1).unwrap(), ChunkSize::new(100).unwrap()).unwrap();
1087        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1088        let confirmed = ChunkSize::new(16).unwrap();
1089        let slow = adjust_target(
1090            confirmed,
1091            Some(Duration::from_secs(2)),
1092            Duration::from_secs(1),
1093            bounds,
1094        );
1095        assert!(
1096            slow.get() < confirmed.get(),
1097            "slow chunk should shrink the target"
1098        );
1099
1100        let fast = adjust_target(
1101            confirmed,
1102            Some(Duration::from_millis(100)),
1103            Duration::from_secs(1),
1104            bounds,
1105        );
1106        assert!(
1107            fast.get() > confirmed.get(),
1108            "fast chunk should grow the target"
1109        );
1110
1111        let on_target = adjust_target(
1112            confirmed,
1113            Some(Duration::from_secs(1)),
1114            Duration::from_secs(1),
1115            bounds,
1116        );
1117        assert_eq!(on_target.get(), confirmed.get());
1118    }
1119
1120    #[test]
1121    fn adjust_target_stays_within_bounds() {
1122        #[allow(clippy::unwrap_used, reason = "test literals are valid ChunkSizes")]
1123        let bounds =
1124            AdaptiveBounds::new(ChunkSize::new(4).unwrap(), ChunkSize::new(8).unwrap()).unwrap();
1125        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1126        let confirmed = ChunkSize::new(8).unwrap();
1127        let still_fast = adjust_target(
1128            confirmed,
1129            Some(Duration::from_millis(1)),
1130            Duration::from_secs(1),
1131            bounds,
1132        );
1133        assert_eq!(still_fast.get(), 8, "must not exceed the configured max");
1134
1135        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1136        let confirmed_min = ChunkSize::new(4).unwrap();
1137        let still_slow = adjust_target(
1138            confirmed_min,
1139            Some(Duration::from_secs(1000)),
1140            Duration::from_secs(1),
1141            bounds,
1142        );
1143        assert_eq!(still_slow.get(), 4, "must not go below the configured min");
1144    }
1145
1146    struct ManualClock(Mutex<SystemTime>);
1147
1148    impl ManualClock {
1149        fn new(start: SystemTime) -> Self {
1150            Self(Mutex::new(start))
1151        }
1152
1153        fn advance(&self, delta: Duration) {
1154            let mut now = self.0.lock().unwrap_or_else(PoisonError::into_inner);
1155            *now += delta;
1156        }
1157    }
1158
1159    impl Clock for ManualClock {
1160        fn now(&self) -> SystemTime {
1161            *self.0.lock().unwrap_or_else(PoisonError::into_inner)
1162        }
1163    }
1164
1165    fn decode_target(envelope: &ComponentStateEnvelope) -> ChunkSize {
1166        let codec = adaptive_decision_codec();
1167        #[allow(
1168            clippy::unwrap_used,
1169            reason = "test decodes a value this test just encoded"
1170        )]
1171        let decoded: AdaptiveDecision = envelope.decode(&codec).unwrap();
1172        decoded.target
1173    }
1174
1175    #[test]
1176    #[allow(clippy::unwrap_used, reason = "test builds a controlled nesting chain")]
1177    fn composite_depth_boundary_and_overflow() {
1178        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1179        let mut current: Arc<dyn CompletionPolicy> =
1180            Arc::new(ItemCountCompletionPolicy::new(ChunkSize::new(1).unwrap()));
1181        for depth in 1..=MAX_COMPOSITE_DEPTH {
1182            let composite =
1183                CompositeCompletionPolicy::new(CompositeMode::Any, vec![current]).unwrap();
1184            assert_eq!(composite.composite_depth(), depth);
1185            current = Arc::new(composite);
1186        }
1187        // One more level of nesting exceeds MAX_COMPOSITE_DEPTH.
1188        assert!(matches!(
1189            CompositeCompletionPolicy::new(CompositeMode::Any, vec![current]),
1190            Err(CompletionPolicyError::CompositeTooDeep { max }) if max == MAX_COMPOSITE_DEPTH
1191        ));
1192    }
1193
1194    #[test]
1195    #[allow(clippy::unwrap_used, reason = "test builds a controlled nesting chain")]
1196    fn composite_depth_bounded_via_indirect_nesting() {
1197        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1198        let leaf: Arc<dyn CompletionPolicy> =
1199            Arc::new(ItemCountCompletionPolicy::new(ChunkSize::new(1).unwrap()));
1200        let mut deep = Arc::clone(&leaf);
1201        for _ in 0..MAX_COMPOSITE_DEPTH - 1 {
1202            deep =
1203                Arc::new(CompositeCompletionPolicy::new(CompositeMode::Any, vec![deep]).unwrap());
1204        }
1205        // `deep` has composite_depth() == MAX_COMPOSITE_DEPTH - 1.
1206        let at_boundary =
1207            CompositeCompletionPolicy::new(CompositeMode::All, vec![leaf.clone(), deep.clone()])
1208                .unwrap();
1209        assert_eq!(at_boundary.composite_depth(), MAX_COMPOSITE_DEPTH);
1210
1211        let one_level_deeper =
1212            Arc::new(CompositeCompletionPolicy::new(CompositeMode::Any, vec![deep]).unwrap());
1213        assert!(matches!(
1214            CompositeCompletionPolicy::new(CompositeMode::All, vec![leaf, one_level_deeper]),
1215            Err(CompletionPolicyError::CompositeTooDeep { max }) if max == MAX_COMPOSITE_DEPTH
1216        ));
1217    }
1218
1219    #[test]
1220    #[allow(
1221        clippy::unwrap_used,
1222        reason = "test drives a controlled adaptive policy"
1223    )]
1224    fn adaptive_update_never_mutates_confirmed_and_end_chunk_gates_promotion() {
1225        #[allow(clippy::unwrap_used, reason = "test literal is a valid identity")]
1226        let identity = ComponentStreamIdentity::new("test.adaptive").unwrap();
1227        #[allow(clippy::unwrap_used, reason = "test literals are valid ChunkSizes")]
1228        let bounds =
1229            AdaptiveBounds::new(ChunkSize::new(1).unwrap(), ChunkSize::new(100).unwrap()).unwrap();
1230        #[allow(clippy::unwrap_used, reason = "test literal is a valid threshold")]
1231        let target_duration = ChunkTimeThreshold::new(Duration::from_secs(1)).unwrap();
1232        let clock = Arc::new(ManualClock::new(SystemTime::UNIX_EPOCH));
1233        let policy = AdaptiveCompletionPolicy::new(
1234            identity,
1235            bounds,
1236            target_duration,
1237            Arc::clone(&clock) as Arc<dyn Clock>,
1238        );
1239        let (_source, stop) = crate::StopSource::new();
1240        let initial = policy.current_target();
1241
1242        // Attempt 1: a fast chunk, then rolled back.
1243        policy.begin_chunk();
1244        clock.advance(Duration::from_millis(50));
1245        #[allow(clippy::unwrap_used, reason = "test drives a known-valid update")]
1246        let candidate_1 =
1247            futures_executor::block_on(policy.update(StreamUpdateContext::new(&stop))).unwrap();
1248        assert_eq!(
1249            policy.current_target(),
1250            initial,
1251            "update must never mutate the confirmed target before commit"
1252        );
1253        policy.end_chunk(ChunkAttemptOutcome::RolledBack);
1254        assert_eq!(
1255            policy.current_target(),
1256            initial,
1257            "a rolled-back attempt must leave confirmed exactly as it was"
1258        );
1259
1260        // Attempt 2 (replay of the same logical chunk): identical inputs must
1261        // recompute the identical candidate from the same unmodified baseline.
1262        policy.begin_chunk();
1263        clock.advance(Duration::from_millis(50));
1264        #[allow(clippy::unwrap_used, reason = "test drives a known-valid update")]
1265        let candidate_2 =
1266            futures_executor::block_on(policy.update(StreamUpdateContext::new(&stop))).unwrap();
1267        assert_eq!(
1268            decode_target(&candidate_1),
1269            decode_target(&candidate_2),
1270            "a replayed attempt must recompute the identical candidate"
1271        );
1272        assert_ne!(
1273            decode_target(&candidate_2).get(),
1274            initial.get(),
1275            "the fast chunk should have produced a larger candidate than the baseline"
1276        );
1277
1278        // Committing promotes the pending candidate, and only the pending one.
1279        policy.end_chunk(ChunkAttemptOutcome::Committed);
1280        assert_eq!(
1281            policy.current_target(),
1282            decode_target(&candidate_2),
1283            "a committed attempt must promote its candidate to confirmed"
1284        );
1285
1286        // Attempt 3: a further update recomputes from the new baseline, but an
1287        // `Unknown` outcome must not promote it either.
1288        policy.begin_chunk();
1289        #[allow(clippy::unwrap_used, reason = "test drives a known-valid update")]
1290        let _candidate_3 =
1291            futures_executor::block_on(policy.update(StreamUpdateContext::new(&stop))).unwrap();
1292        let confirmed_after_commit = policy.current_target();
1293        policy.end_chunk(ChunkAttemptOutcome::Unknown);
1294        assert_eq!(
1295            policy.current_target(),
1296            confirmed_after_commit,
1297            "an unknown commit outcome must not promote a speculative candidate"
1298        );
1299    }
1300
1301    #[test]
1302    #[allow(
1303        clippy::unwrap_used,
1304        reason = "test drives a controlled adaptive policy"
1305    )]
1306    fn adaptive_open_restores_confirmed_and_discards_stale_pending() {
1307        #[allow(clippy::unwrap_used, reason = "test literal is a valid identity")]
1308        let identity = ComponentStreamIdentity::new("test.adaptive").unwrap();
1309        #[allow(clippy::unwrap_used, reason = "test literals are valid ChunkSizes")]
1310        let bounds =
1311            AdaptiveBounds::new(ChunkSize::new(1).unwrap(), ChunkSize::new(100).unwrap()).unwrap();
1312        #[allow(clippy::unwrap_used, reason = "test literal is a valid threshold")]
1313        let target_duration = ChunkTimeThreshold::new(Duration::from_secs(1)).unwrap();
1314        let clock = Arc::new(ManualClock::new(SystemTime::UNIX_EPOCH));
1315        let policy = AdaptiveCompletionPolicy::new(
1316            identity.clone(),
1317            bounds,
1318            target_duration,
1319            Arc::clone(&clock) as Arc<dyn Clock>,
1320        );
1321        let (_source, stop) = crate::StopSource::new();
1322
1323        // Produce a pending candidate that never gets confirmed.
1324        policy.begin_chunk();
1325        clock.advance(Duration::from_millis(1));
1326        #[allow(clippy::unwrap_used, reason = "test drives a known-valid update")]
1327        let _pending =
1328            futures_executor::block_on(policy.update(StreamUpdateContext::new(&stop))).unwrap();
1329
1330        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1331        let restored_target = ChunkSize::new(42).unwrap();
1332        let codec = adaptive_decision_codec();
1333        #[allow(clippy::unwrap_used, reason = "test builds a known-valid envelope")]
1334        let inherited = ComponentStateEnvelope::encode(
1335            identity,
1336            &AdaptiveDecision {
1337                target: restored_target,
1338            },
1339            &codec,
1340            StateLimits::default(),
1341        )
1342        .unwrap();
1343        #[allow(clippy::unwrap_used, reason = "test drives a known-valid open")]
1344        futures_executor::block_on(policy.open(StreamOpenContext::new(Some(&inherited), &stop)))
1345            .unwrap();
1346        assert_eq!(
1347            policy.current_target(),
1348            restored_target,
1349            "open must restore the durable envelope's target"
1350        );
1351
1352        // The pending candidate from before `open` must never resurface.
1353        policy.end_chunk(ChunkAttemptOutcome::Committed);
1354        assert_eq!(
1355            policy.current_target(),
1356            restored_target,
1357            "a pending candidate predating `open` must never be promoted"
1358        );
1359    }
1360
1361    #[test]
1362    #[allow(clippy::unwrap_used, reason = "test constructs known-valid policies")]
1363    fn fingerprint_is_deterministic_for_identical_configuration() {
1364        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1365        let a = ItemCountCompletionPolicy::new(ChunkSize::new(7).unwrap());
1366        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1367        let b = ItemCountCompletionPolicy::new(ChunkSize::new(7).unwrap());
1368        assert_eq!(a.fingerprint(), b.fingerprint());
1369
1370        #[allow(clippy::unwrap_used, reason = "test literal is a valid threshold")]
1371        let threshold = ChunkTimeThreshold::new(Duration::from_secs(3)).unwrap();
1372        let clock_a: Arc<dyn Clock> = Arc::new(ManualClock::new(SystemTime::UNIX_EPOCH));
1373        let clock_b: Arc<dyn Clock> = Arc::new(ManualClock::new(SystemTime::UNIX_EPOCH));
1374        let time_a = TimeCompletionPolicy::new(clock_a, threshold);
1375        let time_b = TimeCompletionPolicy::new(clock_b, threshold);
1376        assert_eq!(
1377            time_a.fingerprint(),
1378            time_b.fingerprint(),
1379            "the injected clock is runtime state, not configuration"
1380        );
1381
1382        #[allow(clippy::unwrap_used, reason = "test literals are valid ChunkSizes")]
1383        let bounds =
1384            AdaptiveBounds::new(ChunkSize::new(2).unwrap(), ChunkSize::new(9).unwrap()).unwrap();
1385        #[allow(clippy::unwrap_used, reason = "test literal is a valid identity")]
1386        let adaptive_a = AdaptiveCompletionPolicy::new(
1387            ComponentStreamIdentity::new("a").unwrap(),
1388            bounds,
1389            threshold,
1390            Arc::new(ManualClock::new(SystemTime::UNIX_EPOCH)),
1391        );
1392        #[allow(clippy::unwrap_used, reason = "test literal is a valid identity")]
1393        let adaptive_b = AdaptiveCompletionPolicy::new(
1394            ComponentStreamIdentity::new("b").unwrap(),
1395            bounds,
1396            threshold,
1397            Arc::new(ManualClock::new(SystemTime::UNIX_EPOCH)),
1398        );
1399        assert_eq!(
1400            adaptive_a.fingerprint(),
1401            adaptive_b.fingerprint(),
1402            "identity and clock are runtime wiring, not configuration"
1403        );
1404    }
1405
1406    #[test]
1407    #[allow(clippy::unwrap_used, reason = "test constructs known-valid policies")]
1408    fn fingerprint_changes_with_configuration() {
1409        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1410        let count_5 = ItemCountCompletionPolicy::new(ChunkSize::new(5).unwrap());
1411        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1412        let count_6 = ItemCountCompletionPolicy::new(ChunkSize::new(6).unwrap());
1413        assert_ne!(count_5.fingerprint(), count_6.fingerprint());
1414
1415        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1416        let small: Arc<dyn CompletionPolicy> =
1417            Arc::new(ItemCountCompletionPolicy::new(ChunkSize::new(2).unwrap()));
1418        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1419        let large: Arc<dyn CompletionPolicy> =
1420            Arc::new(ItemCountCompletionPolicy::new(ChunkSize::new(5).unwrap()));
1421        #[allow(
1422            clippy::unwrap_used,
1423            reason = "test constructs a known-valid composite"
1424        )]
1425        let any = CompositeCompletionPolicy::new(
1426            CompositeMode::Any,
1427            vec![Arc::clone(&small), Arc::clone(&large)],
1428        )
1429        .unwrap();
1430        #[allow(
1431            clippy::unwrap_used,
1432            reason = "test constructs a known-valid composite"
1433        )]
1434        let all = CompositeCompletionPolicy::new(CompositeMode::All, vec![small, large]).unwrap();
1435        assert_ne!(
1436            any.fingerprint(),
1437            all.fingerprint(),
1438            "Any vs All must fingerprint differently even with the same members"
1439        );
1440
1441        #[allow(clippy::unwrap_used, reason = "test literals are valid ChunkSizes")]
1442        let bounds_a =
1443            AdaptiveBounds::new(ChunkSize::new(1).unwrap(), ChunkSize::new(10).unwrap()).unwrap();
1444        #[allow(clippy::unwrap_used, reason = "test literals are valid ChunkSizes")]
1445        let bounds_b =
1446            AdaptiveBounds::new(ChunkSize::new(1).unwrap(), ChunkSize::new(20).unwrap()).unwrap();
1447        #[allow(clippy::unwrap_used, reason = "test literal is a valid threshold")]
1448        let threshold = ChunkTimeThreshold::new(Duration::from_secs(1)).unwrap();
1449        #[allow(clippy::unwrap_used, reason = "test literal is a valid identity")]
1450        let adaptive_a = AdaptiveCompletionPolicy::new(
1451            ComponentStreamIdentity::new("same").unwrap(),
1452            bounds_a,
1453            threshold,
1454            Arc::new(ManualClock::new(SystemTime::UNIX_EPOCH)),
1455        );
1456        #[allow(clippy::unwrap_used, reason = "test literal is a valid identity")]
1457        let adaptive_b = AdaptiveCompletionPolicy::new(
1458            ComponentStreamIdentity::new("same").unwrap(),
1459            bounds_b,
1460            threshold,
1461            Arc::new(ManualClock::new(SystemTime::UNIX_EPOCH)),
1462        );
1463        assert_ne!(
1464            adaptive_a.fingerprint(),
1465            adaptive_b.fingerprint(),
1466            "a bounds change must change the fingerprint"
1467        );
1468
1469        // Nested composite structure must be reflected too.
1470        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1471        let nested_leaf: Arc<dyn CompletionPolicy> =
1472            Arc::new(ItemCountCompletionPolicy::new(ChunkSize::new(2).unwrap()));
1473        #[allow(
1474            clippy::unwrap_used,
1475            reason = "test constructs a known-valid composite"
1476        )]
1477        let inner_any =
1478            CompositeCompletionPolicy::new(CompositeMode::Any, vec![Arc::clone(&nested_leaf)])
1479                .unwrap();
1480        #[allow(
1481            clippy::unwrap_used,
1482            reason = "test constructs a known-valid composite"
1483        )]
1484        let inner_all =
1485            CompositeCompletionPolicy::new(CompositeMode::All, vec![nested_leaf]).unwrap();
1486        #[allow(clippy::unwrap_used, reason = "test literal is a valid ChunkSize")]
1487        let sibling: Arc<dyn CompletionPolicy> =
1488            Arc::new(ItemCountCompletionPolicy::new(ChunkSize::new(3).unwrap()));
1489        #[allow(
1490            clippy::unwrap_used,
1491            reason = "test constructs a known-valid composite"
1492        )]
1493        let outer_a = CompositeCompletionPolicy::new(
1494            CompositeMode::All,
1495            vec![Arc::new(inner_any), Arc::clone(&sibling)],
1496        )
1497        .unwrap();
1498        #[allow(
1499            clippy::unwrap_used,
1500            reason = "test constructs a known-valid composite"
1501        )]
1502        let outer_b =
1503            CompositeCompletionPolicy::new(CompositeMode::All, vec![Arc::new(inner_all), sibling])
1504                .unwrap();
1505        assert_ne!(
1506            outer_a.fingerprint(),
1507            outer_b.fingerprint(),
1508            "a mode change nested inside a composite must change the outer fingerprint"
1509        );
1510    }
1511
1512    /// A leaf policy whose `fingerprint()` is a fixed test literal, standing
1513    /// in for a custom policy's own configuration string.
1514    struct FixedFingerprint(&'static str);
1515
1516    impl CompletionPolicy for FixedFingerprint {
1517        fn is_complete(&self, _items_read: ChunkCount) -> bool {
1518            false
1519        }
1520
1521        fn fingerprint(&self) -> String {
1522            self.0.to_owned()
1523        }
1524    }
1525
1526    #[test]
1527    #[allow(clippy::unwrap_used, reason = "test constructs known-valid composites")]
1528    fn composite_fingerprint_encoding_is_injective_across_member_splits() {
1529        // A naive `join(",")` encoding cannot distinguish a composite whose
1530        // members are `["a", "b,c"]` from one whose members are `["a,b",
1531        // "c"]": both join to the same `"a,b,c"` string. The length-prefixed
1532        // encoding must keep them apart.
1533        let split_a: Vec<Arc<dyn CompletionPolicy>> = vec![
1534            Arc::new(FixedFingerprint("a")),
1535            Arc::new(FixedFingerprint("b,c")),
1536        ];
1537        let split_b: Vec<Arc<dyn CompletionPolicy>> = vec![
1538            Arc::new(FixedFingerprint("a,b")),
1539            Arc::new(FixedFingerprint("c")),
1540        ];
1541        let composite_a = CompositeCompletionPolicy::new(CompositeMode::Any, split_a).unwrap();
1542        let composite_b = CompositeCompletionPolicy::new(CompositeMode::Any, split_b).unwrap();
1543        assert_ne!(
1544            composite_a.fingerprint(),
1545            composite_b.fingerprint(),
1546            "different member splits must never collide onto the same \
1547             composite fingerprint, even when a member's own fingerprint \
1548             contains the naive join delimiter"
1549        );
1550    }
1551
1552    /// Records every `begin_chunk`/`end_chunk` call it receives.
1553    struct RecordingMember {
1554        begins: Arc<std::sync::atomic::AtomicUsize>,
1555        ends: Arc<Mutex<Vec<ChunkAttemptOutcome>>>,
1556    }
1557
1558    impl RecordingMember {
1559        fn new() -> (
1560            Self,
1561            Arc<std::sync::atomic::AtomicUsize>,
1562            Arc<Mutex<Vec<ChunkAttemptOutcome>>>,
1563        ) {
1564            let begins = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1565            let ends = Arc::new(Mutex::new(Vec::new()));
1566            (
1567                Self {
1568                    begins: Arc::clone(&begins),
1569                    ends: Arc::clone(&ends),
1570                },
1571                begins,
1572                ends,
1573            )
1574        }
1575    }
1576
1577    impl CompletionPolicy for RecordingMember {
1578        fn begin_chunk(&self) {
1579            self.begins
1580                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1581        }
1582
1583        fn is_complete(&self, _items_read: ChunkCount) -> bool {
1584            false
1585        }
1586
1587        fn end_chunk(&self, outcome: ChunkAttemptOutcome) {
1588            self.ends
1589                .lock()
1590                .unwrap_or_else(PoisonError::into_inner)
1591                .push(outcome);
1592        }
1593    }
1594
1595    /// Panics in `begin_chunk` or `end_chunk`, per the chosen point.
1596    struct PanickingMember {
1597        panic_in_begin: bool,
1598        panic_in_end: bool,
1599    }
1600
1601    impl CompletionPolicy for PanickingMember {
1602        fn begin_chunk(&self) {
1603            assert!(!self.panic_in_begin, "member begin_chunk secret");
1604        }
1605
1606        fn is_complete(&self, _items_read: ChunkCount) -> bool {
1607            false
1608        }
1609
1610        fn end_chunk(&self, _outcome: ChunkAttemptOutcome) {
1611            assert!(!self.panic_in_end, "member end_chunk secret");
1612        }
1613    }
1614
1615    #[test]
1616    #[allow(
1617        clippy::unwrap_used,
1618        reason = "test constructs a known-valid composite"
1619    )]
1620    fn composite_begin_panic_still_ends_every_member_that_already_began() {
1621        let (before, before_begins, before_ends) = RecordingMember::new();
1622        let (after, after_begins, _after_ends) = RecordingMember::new();
1623        let panicking = PanickingMember {
1624            panic_in_begin: true,
1625            panic_in_end: false,
1626        };
1627        let composite = CompositeCompletionPolicy::new(
1628            CompositeMode::All,
1629            vec![
1630                Arc::new(before) as Arc<dyn CompletionPolicy>,
1631                Arc::new(panicking),
1632                Arc::new(after),
1633            ],
1634        )
1635        .unwrap();
1636
1637        let result = catch_unwind(AssertUnwindSafe(|| composite.begin_chunk()));
1638
1639        assert!(
1640            result.is_err(),
1641            "the composite's begin_chunk must still panic, matching a \
1642             non-composite policy's panicking begin_chunk"
1643        );
1644        assert_eq!(
1645            before_begins.load(std::sync::atomic::Ordering::SeqCst),
1646            1,
1647            "the member positioned before the panicking one must have begun"
1648        );
1649        assert_eq!(
1650            *before_ends.lock().unwrap_or_else(PoisonError::into_inner),
1651            vec![ChunkAttemptOutcome::Unknown],
1652            "a member that already began before a sibling's begin_chunk \
1653             panicked must still receive its own matching end_chunk -- with \
1654             Unknown, since this attempt never validly began"
1655        );
1656        assert_eq!(
1657            after_begins.load(std::sync::atomic::Ordering::SeqCst),
1658            0,
1659            "a member positioned after the panicking one must never have \
1660             begun at all"
1661        );
1662    }
1663
1664    #[test]
1665    #[allow(
1666        clippy::unwrap_used,
1667        reason = "test constructs a known-valid composite"
1668    )]
1669    fn composite_end_panic_still_ends_every_remaining_member() {
1670        let (before, _before_begins, before_ends) = RecordingMember::new();
1671        let (after, _after_begins, after_ends) = RecordingMember::new();
1672        let panicking = PanickingMember {
1673            panic_in_begin: false,
1674            panic_in_end: true,
1675        };
1676        let composite = CompositeCompletionPolicy::new(
1677            CompositeMode::All,
1678            vec![
1679                Arc::new(before) as Arc<dyn CompletionPolicy>,
1680                Arc::new(panicking),
1681                Arc::new(after),
1682            ],
1683        )
1684        .unwrap();
1685        composite.begin_chunk();
1686
1687        let result = catch_unwind(AssertUnwindSafe(|| {
1688            composite.end_chunk(ChunkAttemptOutcome::Committed);
1689        }));
1690
1691        assert!(
1692            result.is_err(),
1693            "the composite's end_chunk must still panic, matching a \
1694             non-composite policy's panicking end_chunk"
1695        );
1696        assert_eq!(
1697            *before_ends.lock().unwrap_or_else(PoisonError::into_inner),
1698            vec![ChunkAttemptOutcome::Committed],
1699            "a member before the panicking one must receive its end_chunk"
1700        );
1701        assert_eq!(
1702            *after_ends.lock().unwrap_or_else(PoisonError::into_inner),
1703            vec![ChunkAttemptOutcome::Committed],
1704            "a member positioned *after* the panicking one must still \
1705             receive its own end_chunk call -- one member's panic must \
1706             never suppress a sibling's matching end"
1707        );
1708    }
1709}