1use 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
58pub trait CompletionPolicy: Send + Sync {
70 fn begin_chunk(&self) {}
91
92 fn is_complete(&self, items_read: ChunkCount) -> bool;
110
111 fn end_chunk(&self, _outcome: ChunkAttemptOutcome) {}
131
132 fn fingerprint(&self) -> String {
162 std::any::type_name::<Self>().to_owned()
163 }
164
165 fn stream_registrations(
186 &self,
187 ) -> Vec<(
188 ComponentStreamIdentity,
189 Arc<BoxedStream>,
190 StreamStateContract,
191 )> {
192 Vec::new()
193 }
194
195 #[doc(hidden)]
203 fn composite_depth(&self) -> usize {
204 0
205 }
206}
207
208#[derive(Clone, Copy, Debug)]
219pub struct ItemCountCompletionPolicy(ChunkSize);
220
221impl ItemCountCompletionPolicy {
222 #[must_use]
224 pub const fn new(size: ChunkSize) -> Self {
225 Self(size)
226 }
227
228 #[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
245const MIN_TIME_THRESHOLD: Duration = Duration::from_millis(1);
250const MAX_TIME_THRESHOLD: Duration = Duration::from_hours(24);
251
252#[derive(Clone, Copy, Debug, Eq, PartialEq)]
254pub struct ChunkTimeThreshold(Duration);
255
256impl ChunkTimeThreshold {
257 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 #[must_use]
272 pub const fn get(self) -> Duration {
273 self.0
274 }
275}
276
277pub struct TimeCompletionPolicy {
291 clock: Arc<dyn Clock>,
292 threshold: ChunkTimeThreshold,
293 started: Mutex<Option<SystemTime>>,
294}
295
296impl TimeCompletionPolicy {
297 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
339#[non_exhaustive]
340pub enum CompositeMode {
341 Any,
343 All,
345}
346
347pub const MAX_COMPOSITE_MEMBERS: usize = 32;
349
350pub const MAX_COMPOSITE_DEPTH: usize = 8;
359
360pub 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 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 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 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 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 std::panic::resume_unwind(payload);
500 }
501 }
502
503 fn fingerprint(&self) -> String {
504 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
543#[non_exhaustive]
544pub enum CompletionPolicyError {
545 InvalidTimeThreshold,
547 EmptyComposite,
549 TooManyMembers {
551 max: usize,
553 },
554 CompositeTooDeep {
557 max: usize,
559 },
560 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
595pub struct AdaptiveBounds {
596 min: ChunkSize,
597 max: ChunkSize,
598}
599
600impl AdaptiveBounds {
601 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 #[must_use]
616 pub const fn min(self) -> ChunkSize {
617 self.min
618 }
619
620 #[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 confirmed: ChunkSize,
702 pending: Option<ChunkSize>,
710 chunk_started: Option<SystemTime>,
711}
712
713pub 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 self_weak: Weak<AdaptiveCompletionPolicy>,
766}
767
768impl AdaptiveCompletionPolicy {
769 #[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 #[must_use]
801 pub const fn identity(&self) -> &ComponentStreamIdentity {
802 &self.identity
803 }
804
805 pub(crate) fn stream_state_contract() -> StreamStateContract {
814 StreamStateContract::new(adaptive_decision_codec())
815 }
816
817 #[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 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
882struct 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 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
980fn 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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}