1use candid::Principal;
2use std::{
3 collections::{BTreeMap, BTreeSet},
4 num::NonZeroUsize,
5 ops::Deref,
6 panic::{AssertUnwindSafe, catch_unwind},
7 time::{Duration, Instant},
8};
9
10use crate::timing::saturating_add_optional_duration;
11
12use super::{
13 CachedPocketIcBaseline,
14 bounded_pool::{BoundedSlotLease, BoundedSlotPool},
15};
16
17#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub struct FixtureRecipeId(String);
20
21#[non_exhaustive]
23#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
24pub enum ResetDomainKind {
25 CanisterSnapshots,
27 CanisterCycles,
29 PocketIcTime,
31 ExtraCanisters,
33 PendingMessages,
35 SubnetState,
37 ExternalResources,
39}
40
41#[non_exhaustive]
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum CycleResetPolicy {
45 PreserveCurrent,
49 TopUpTo(u128),
52 RestoreExactBaseline,
54 RebuildOnMutation,
56}
57
58#[non_exhaustive]
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub enum TimeResetPolicy {
62 PreserveCurrent,
64 RestoreBaseline,
66 RebuildOnMutation,
68}
69
70#[non_exhaustive]
72#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub enum ExtraCanisterPolicy {
74 RequireBaselineSet,
76 RemoveTracked,
78 RebuildOnChange,
80}
81
82#[non_exhaustive]
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub enum StateResetPolicy {
86 ResetByRecipe,
88 ValidateUnchanged,
90 IrrelevantByRecipeContract,
92 RebuildOnChange,
94}
95
96#[non_exhaustive]
98#[derive(Clone, Debug, Eq, PartialEq)]
99pub enum ResetRequirement {
100 CanisterSnapshots,
102 CanisterCycles(CycleResetPolicy),
104 PocketIcTime(TimeResetPolicy),
106 ExtraCanisters(ExtraCanisterPolicy),
108 PendingMessages(StateResetPolicy),
110 SubnetState(StateResetPolicy),
112 ExternalResources(StateResetPolicy),
114}
115
116#[non_exhaustive]
118#[derive(Clone, Debug, Eq, PartialEq)]
119pub enum ResetAchievement {
120 CanisterSnapshots,
122 CanisterCycles(CycleResetPolicy),
124 PocketIcTime(TimeResetPolicy),
126 ExtraCanisters(ExtraCanisterPolicy),
128 PendingMessages(StateResetPolicy),
130 SubnetState(StateResetPolicy),
132 ExternalResources(StateResetPolicy),
134}
135
136#[derive(Clone, Debug, Eq, PartialEq)]
138pub struct ResetRequirements(BTreeMap<ResetDomainKind, ResetRequirement>);
139
140#[derive(Clone, Debug, Default, Eq, PartialEq)]
142pub struct ResetReceipt(BTreeMap<ResetDomainKind, ResetAchievement>);
143
144#[derive(Clone, Debug, Eq, PartialEq)]
146pub struct CanisterRestoreReceipt {
147 canister_ids: Vec<Principal>,
148 cycle_policy: CycleResetPolicy,
149}
150
151#[derive(Clone, Debug, Eq, PartialEq)]
153pub struct ReadinessReceipt {
154 identity: String,
155}
156
157#[derive(Clone, Debug, Eq, PartialEq)]
159pub struct ValidationReceipt {
160 recipe_id: FixtureRecipeId,
161 invariant_identity: String,
162}
163
164#[non_exhaustive]
166#[derive(Clone, Debug, Eq, PartialEq)]
167pub enum BaselinePoolContractError {
168 EmptyRecipeIdentity,
170 EmptyReceiptIdentity { receipt: &'static str },
172 DuplicateResetDomain { domain: ResetDomainKind },
174 DuplicateCanisterId { canister_id: Principal },
176 EmptyCanisterSet,
178 UndeclaredRequiredResetDomain { domain: ResetDomainKind },
180 RestoreCanisterSetMismatch {
182 expected: Vec<Principal>,
183 actual: Vec<Principal>,
184 },
185 MissingResetDomain { domain: ResetDomainKind },
187 ResetPolicyMismatch {
189 requirement: ResetRequirement,
190 achievement: ResetAchievement,
191 },
192 RecipeIdentityMismatch {
194 expected: FixtureRecipeId,
195 actual: FixtureRecipeId,
196 },
197}
198
199#[non_exhaustive]
201#[derive(Clone, Debug, Eq, PartialEq)]
202pub enum PreparedBaseline {
203 Built,
205 Restored {
207 canisters: CanisterRestoreReceipt,
209 reset: ResetReceipt,
211 readiness: ReadinessReceipt,
213 },
214}
215
216#[non_exhaustive]
218#[derive(Clone, Copy, Debug, Eq, PartialEq)]
219pub enum BaselinePreparationStage {
220 Build,
222 RestoreCanisters,
224 ResetNonSnapshotState,
226 DriveToReadiness,
228 ValidateBuilt,
230 ValidateRestored,
232}
233
234#[non_exhaustive]
236#[derive(Clone, Debug, Eq, PartialEq)]
237pub enum RebuildReason {
238 DeadPocketIcTransport,
240 SnapshotRestoreFailure,
242 ResetFailure,
244 ReadinessFailure,
246 ResetCoverageMismatch,
248 InvariantValidationFailure,
250 ExplicitLeaseInvalidation,
252 UnwindWhileLeased,
254 RecipeClassified { code: String },
256}
257
258#[non_exhaustive]
260#[derive(Clone, Debug, Eq, PartialEq)]
261pub enum FailureDisposition {
262 Fatal,
264 Rebuild(RebuildReason),
266}
267
268#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
270pub struct BaselinePoolTimings {
271 wait: Duration,
272 build: Option<Duration>,
273 restore: Option<Duration>,
274 reset: Option<Duration>,
275 readiness: Option<Duration>,
276 validation: Option<Duration>,
277 stale_teardown: Option<Duration>,
278 total: Duration,
279}
280
281#[non_exhaustive]
283#[derive(Clone, Debug, Eq, PartialEq)]
284pub enum BaselinePoolOutcome {
285 Built {
287 slot: usize,
289 timings: BaselinePoolTimings,
291 },
292 Restored {
294 slot: usize,
296 timings: BaselinePoolTimings,
298 },
299 Rebuilt {
301 slot: usize,
303 reason: RebuildReason,
305 timings: BaselinePoolTimings,
307 },
308}
309
310#[non_exhaustive]
312#[derive(Debug)]
313pub enum BaselinePoolPreparationError<E> {
314 Recipe {
316 stage: BaselinePreparationStage,
318 source: E,
320 },
321 Contract(BaselinePoolContractError),
323}
324
325#[non_exhaustive]
327#[derive(Debug)]
328pub enum BaselinePoolError<E> {
329 Preparation {
331 error: BaselinePoolPreparationError<E>,
333 timings: Box<BaselinePoolTimings>,
335 },
336 RecoveryFailed {
338 original: Box<BaselinePoolPreparationError<E>>,
340 rebuild: Box<BaselinePoolPreparationError<E>>,
342 timings: Box<BaselinePoolTimings>,
344 },
345}
346
347pub trait PocketIcBaselineRecipe: Send + Sync + 'static {
349 type Metadata: Send + 'static;
351 type Error: std::error::Error + Send + Sync + 'static;
353
354 fn id(&self) -> &FixtureRecipeId;
356
357 fn reset_requirements(&self) -> &ResetRequirements;
359
360 fn build(&self) -> Result<CachedPocketIcBaseline<Self::Metadata>, Self::Error>;
362
363 fn restore_canisters(
365 &self,
366 baseline: &CachedPocketIcBaseline<Self::Metadata>,
367 ) -> Result<CanisterRestoreReceipt, Self::Error>;
368
369 fn reset_non_snapshot_state(
371 &self,
372 baseline: &CachedPocketIcBaseline<Self::Metadata>,
373 ) -> Result<ResetReceipt, Self::Error>;
374
375 fn drive_to_readiness(
377 &self,
378 baseline: &CachedPocketIcBaseline<Self::Metadata>,
379 ) -> Result<ReadinessReceipt, Self::Error>;
380
381 fn validate(
383 &self,
384 baseline: &CachedPocketIcBaseline<Self::Metadata>,
385 preparation: &PreparedBaseline,
386 ) -> Result<ValidationReceipt, Self::Error>;
387
388 fn classify_failure(
390 &self,
391 stage: BaselinePreparationStage,
392 _error: &Self::Error,
393 ) -> FailureDisposition {
394 FailureDisposition::Rebuild(stage.default_rebuild_reason())
395 }
396}
397
398pub struct CachedPocketIcBaselinePool<R>
410where
411 R: PocketIcBaselineRecipe,
412{
413 recipe: R,
414 slots: BoundedSlotPool<BaselineSlot<R::Metadata>>,
415}
416
417struct BaselineSlot<M> {
418 baseline: CachedPocketIcBaseline<M>,
419 invalidation_reason: Option<RebuildReason>,
420}
421
422pub struct CachedPocketIcBaselinePoolGuard<'a, R>
424where
425 R: PocketIcBaselineRecipe,
426{
427 slot: BoundedSlotLease<'a, BaselineSlot<R::Metadata>>,
428}
429
430impl FixtureRecipeId {
431 pub fn try_new(identity: impl Into<String>) -> Result<Self, BaselinePoolContractError> {
433 let identity = identity.into();
434 if identity.trim().is_empty() {
435 return Err(BaselinePoolContractError::EmptyRecipeIdentity);
436 }
437 Ok(Self(identity))
438 }
439
440 #[must_use]
442 pub fn as_str(&self) -> &str {
443 &self.0
444 }
445}
446
447impl ResetRequirement {
448 #[must_use]
450 pub const fn domain(&self) -> ResetDomainKind {
451 match self {
452 Self::CanisterSnapshots => ResetDomainKind::CanisterSnapshots,
453 Self::CanisterCycles(_) => ResetDomainKind::CanisterCycles,
454 Self::PocketIcTime(_) => ResetDomainKind::PocketIcTime,
455 Self::ExtraCanisters(_) => ResetDomainKind::ExtraCanisters,
456 Self::PendingMessages(_) => ResetDomainKind::PendingMessages,
457 Self::SubnetState(_) => ResetDomainKind::SubnetState,
458 Self::ExternalResources(_) => ResetDomainKind::ExternalResources,
459 }
460 }
461}
462
463impl ResetAchievement {
464 #[must_use]
466 pub const fn domain(&self) -> ResetDomainKind {
467 match self {
468 Self::CanisterSnapshots => ResetDomainKind::CanisterSnapshots,
469 Self::CanisterCycles(_) => ResetDomainKind::CanisterCycles,
470 Self::PocketIcTime(_) => ResetDomainKind::PocketIcTime,
471 Self::ExtraCanisters(_) => ResetDomainKind::ExtraCanisters,
472 Self::PendingMessages(_) => ResetDomainKind::PendingMessages,
473 Self::SubnetState(_) => ResetDomainKind::SubnetState,
474 Self::ExternalResources(_) => ResetDomainKind::ExternalResources,
475 }
476 }
477
478 fn satisfies(&self, requirement: &ResetRequirement) -> bool {
479 match (requirement, self) {
480 (ResetRequirement::CanisterSnapshots, Self::CanisterSnapshots) => true,
481 (ResetRequirement::CanisterCycles(left), Self::CanisterCycles(right)) => left == right,
482 (ResetRequirement::PocketIcTime(left), Self::PocketIcTime(right)) => left == right,
483 (ResetRequirement::ExtraCanisters(left), Self::ExtraCanisters(right)) => left == right,
484 (ResetRequirement::PendingMessages(left), Self::PendingMessages(right))
485 | (ResetRequirement::SubnetState(left), Self::SubnetState(right))
486 | (ResetRequirement::ExternalResources(left), Self::ExternalResources(right)) => {
487 left == right
488 }
489 _ => false,
490 }
491 }
492}
493
494impl ResetRequirements {
495 pub fn try_new<I>(requirements: I) -> Result<Self, BaselinePoolContractError>
500 where
501 I: IntoIterator<Item = ResetRequirement>,
502 {
503 let mut domains = BTreeMap::new();
504 for requirement in requirements {
505 let domain = requirement.domain();
506 if domains.insert(domain, requirement).is_some() {
507 return Err(BaselinePoolContractError::DuplicateResetDomain { domain });
508 }
509 }
510 for domain in [
511 ResetDomainKind::CanisterSnapshots,
512 ResetDomainKind::CanisterCycles,
513 ] {
514 if !domains.contains_key(&domain) {
515 return Err(BaselinePoolContractError::UndeclaredRequiredResetDomain { domain });
516 }
517 }
518 Ok(Self(domains))
519 }
520
521 #[must_use]
523 pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetRequirement> {
524 self.0.get(&domain)
525 }
526
527 pub fn iter(&self) -> impl Iterator<Item = &ResetRequirement> {
529 self.0.values()
530 }
531
532 fn verify(&self, receipt: &ResetReceipt) -> Result<(), BaselinePoolContractError> {
533 for (domain, requirement) in &self.0 {
534 let Some(achievement) = receipt.0.get(domain) else {
535 return Err(BaselinePoolContractError::MissingResetDomain { domain: *domain });
536 };
537 if !achievement.satisfies(requirement) {
538 return Err(BaselinePoolContractError::ResetPolicyMismatch {
539 requirement: requirement.clone(),
540 achievement: achievement.clone(),
541 });
542 }
543 }
544 Ok(())
545 }
546}
547
548impl ResetReceipt {
549 pub fn try_new<I>(achievements: I) -> Result<Self, BaselinePoolContractError>
555 where
556 I: IntoIterator<Item = ResetAchievement>,
557 {
558 let mut domains = BTreeMap::new();
559 for achievement in achievements {
560 let domain = achievement.domain();
561 if domains.insert(domain, achievement).is_some() {
562 return Err(BaselinePoolContractError::DuplicateResetDomain { domain });
563 }
564 }
565 Ok(Self(domains))
566 }
567
568 #[must_use]
570 pub const fn empty() -> Self {
571 Self(BTreeMap::new())
572 }
573
574 #[must_use]
576 pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetAchievement> {
577 self.0.get(&domain)
578 }
579
580 pub fn iter(&self) -> impl Iterator<Item = &ResetAchievement> {
582 self.0.values()
583 }
584
585 fn include_restore(
586 &mut self,
587 restore: &CanisterRestoreReceipt,
588 ) -> Result<(), BaselinePoolContractError> {
589 self.insert(ResetAchievement::CanisterSnapshots)?;
590 self.insert(ResetAchievement::CanisterCycles(restore.cycle_policy))
591 }
592
593 fn insert(&mut self, achievement: ResetAchievement) -> Result<(), BaselinePoolContractError> {
594 let domain = achievement.domain();
595 if self.0.insert(domain, achievement).is_some() {
596 return Err(BaselinePoolContractError::DuplicateResetDomain { domain });
597 }
598 Ok(())
599 }
600}
601
602impl CanisterRestoreReceipt {
603 pub fn try_new<I>(
605 canister_ids: I,
606 cycle_policy: CycleResetPolicy,
607 ) -> Result<Self, BaselinePoolContractError>
608 where
609 I: IntoIterator<Item = Principal>,
610 {
611 let mut unique = BTreeSet::new();
612 for canister_id in canister_ids {
613 if !unique.insert(canister_id) {
614 return Err(BaselinePoolContractError::DuplicateCanisterId { canister_id });
615 }
616 }
617 if unique.is_empty() {
618 return Err(BaselinePoolContractError::EmptyCanisterSet);
619 }
620 Ok(Self {
621 canister_ids: unique.into_iter().collect(),
622 cycle_policy,
623 })
624 }
625
626 pub fn try_from_baseline<M>(
634 baseline: &CachedPocketIcBaseline<M>,
635 cycle_policy: CycleResetPolicy,
636 ) -> Result<Self, BaselinePoolContractError> {
637 Self::try_new(baseline.snapshot_canister_ids(), cycle_policy)
638 }
639
640 #[must_use]
642 pub fn canister_ids(&self) -> &[Principal] {
643 &self.canister_ids
644 }
645
646 #[must_use]
648 pub const fn cycle_policy(&self) -> CycleResetPolicy {
649 self.cycle_policy
650 }
651}
652
653impl ReadinessReceipt {
654 pub fn try_new(identity: impl Into<String>) -> Result<Self, BaselinePoolContractError> {
656 Ok(Self {
657 identity: nonempty_receipt_identity("readiness", identity.into())?,
658 })
659 }
660
661 #[must_use]
663 pub fn identity(&self) -> &str {
664 &self.identity
665 }
666}
667
668impl ValidationReceipt {
669 pub fn try_new(
671 recipe_id: FixtureRecipeId,
672 invariant_identity: impl Into<String>,
673 ) -> Result<Self, BaselinePoolContractError> {
674 Ok(Self {
675 recipe_id,
676 invariant_identity: nonempty_receipt_identity("validation", invariant_identity.into())?,
677 })
678 }
679
680 #[must_use]
682 pub const fn recipe_id(&self) -> &FixtureRecipeId {
683 &self.recipe_id
684 }
685
686 #[must_use]
688 pub fn invariant_identity(&self) -> &str {
689 &self.invariant_identity
690 }
691}
692
693impl BaselinePreparationStage {
694 #[must_use]
699 pub fn default_rebuild_reason(self) -> RebuildReason {
700 match self {
701 Self::RestoreCanisters => RebuildReason::SnapshotRestoreFailure,
702 Self::ResetNonSnapshotState => RebuildReason::ResetFailure,
703 Self::DriveToReadiness => RebuildReason::ReadinessFailure,
704 Self::ValidateRestored | Self::ValidateBuilt => {
705 RebuildReason::InvariantValidationFailure
706 }
707 Self::Build => RebuildReason::RecipeClassified {
708 code: "build".to_owned(),
709 },
710 }
711 }
712}
713
714impl BaselinePoolTimings {
715 #[must_use]
717 pub const fn wait(self) -> Duration {
718 self.wait
719 }
720
721 #[must_use]
723 pub const fn build(self) -> Option<Duration> {
724 self.build
725 }
726
727 #[must_use]
729 pub const fn restore(self) -> Option<Duration> {
730 self.restore
731 }
732
733 #[must_use]
735 pub const fn reset(self) -> Option<Duration> {
736 self.reset
737 }
738
739 #[must_use]
741 pub const fn readiness(self) -> Option<Duration> {
742 self.readiness
743 }
744
745 #[must_use]
747 pub const fn validation(self) -> Option<Duration> {
748 self.validation
749 }
750
751 #[must_use]
753 pub const fn stale_teardown(self) -> Option<Duration> {
754 self.stale_teardown
755 }
756
757 #[must_use]
759 pub const fn total(self) -> Duration {
760 self.total
761 }
762}
763
764impl BaselinePoolOutcome {
765 #[must_use]
767 pub const fn slot(&self) -> usize {
768 match self {
769 Self::Built { slot, .. } | Self::Restored { slot, .. } | Self::Rebuilt { slot, .. } => {
770 *slot
771 }
772 }
773 }
774
775 #[must_use]
777 pub const fn timings(&self) -> BaselinePoolTimings {
778 match self {
779 Self::Built { timings, .. }
780 | Self::Restored { timings, .. }
781 | Self::Rebuilt { timings, .. } => *timings,
782 }
783 }
784}
785
786impl std::fmt::Display for BaselinePoolTimings {
787 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
788 write!(
789 formatter,
790 "total={:?} wait={:?} build={:?} restore={:?} reset={:?} readiness={:?} validation={:?} stale_teardown={:?}",
791 self.total,
792 self.wait,
793 self.build,
794 self.restore,
795 self.reset,
796 self.readiness,
797 self.validation,
798 self.stale_teardown,
799 )
800 }
801}
802
803impl std::fmt::Display for BaselinePoolOutcome {
804 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
805 match self {
806 Self::Built { slot, timings } => write!(formatter, "built slot={slot} {timings}"),
807 Self::Restored { slot, timings } => {
808 write!(formatter, "restored slot={slot} {timings}")
809 }
810 Self::Rebuilt {
811 slot,
812 reason,
813 timings,
814 } => write!(formatter, "rebuilt slot={slot} reason={reason:?} {timings}"),
815 }
816 }
817}
818
819impl<E> BaselinePoolError<E> {
820 #[must_use]
822 pub const fn timings(&self) -> BaselinePoolTimings {
823 match self {
824 Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
825 }
826 }
827}
828
829impl<R> CachedPocketIcBaselinePool<R>
830where
831 R: PocketIcBaselineRecipe,
832{
833 #[must_use]
835 pub fn new(capacity: NonZeroUsize, recipe: R) -> Self {
836 Self {
837 recipe,
838 slots: BoundedSlotPool::new(capacity),
839 }
840 }
841
842 #[must_use]
844 pub fn recipe_id(&self) -> &FixtureRecipeId {
845 self.recipe.id()
846 }
847
848 #[must_use]
850 pub fn capacity(&self) -> NonZeroUsize {
851 self.slots.capacity()
852 }
853
854 pub fn acquire(
866 &self,
867 ) -> Result<
868 (CachedPocketIcBaselinePoolGuard<'_, R>, BaselinePoolOutcome),
869 BaselinePoolError<R::Error>,
870 > {
871 let total_started = Instant::now();
872 let mut slot = self.slots.acquire();
873 let mut timings = BaselinePoolTimings {
874 wait: slot.wait(),
875 ..BaselinePoolTimings::default()
876 };
877 let slot_index = slot.slot_index();
878
879 if slot.is_reusable() {
880 match self.prepare_reused(&mut slot, &mut timings) {
881 Ok(()) => {
882 timings.total = total_started.elapsed();
883 return Ok((
884 CachedPocketIcBaselinePoolGuard { slot },
885 BaselinePoolOutcome::Restored {
886 slot: slot_index,
887 timings,
888 },
889 ));
890 }
891 Err(original) => {
892 let disposition = self.failure_disposition(&original);
893 match disposition {
894 FailureDisposition::Fatal => {
895 Self::discard_stale_slot(&mut slot, &mut timings);
899 timings.total = total_started.elapsed();
900 return Err(BaselinePoolError::Preparation {
901 error: original,
902 timings: Box::new(timings),
903 });
904 }
905 FailureDisposition::Rebuild(reason) => {
906 Self::discard_stale_slot(&mut slot, &mut timings);
907 if let Err(rebuild) = self.build_slot(&mut slot, &mut timings) {
908 timings.total = total_started.elapsed();
909 return Err(BaselinePoolError::RecoveryFailed {
910 original: Box::new(original),
911 rebuild: Box::new(rebuild),
912 timings: Box::new(timings),
913 });
914 }
915 timings.total = total_started.elapsed();
916 return Ok((
917 CachedPocketIcBaselinePoolGuard { slot },
918 BaselinePoolOutcome::Rebuilt {
919 slot: slot_index,
920 reason,
921 timings,
922 },
923 ));
924 }
925 }
926 }
927 }
928 }
929
930 let rebuild_reason = if slot.invalidated_by_unwind() {
931 Some(RebuildReason::UnwindWhileLeased)
932 } else {
933 slot.get()
934 .and_then(|slot| slot.invalidation_reason.clone())
935 .or_else(|| {
936 slot.is_populated()
937 .then_some(RebuildReason::ExplicitLeaseInvalidation)
938 })
939 };
940 if slot.is_populated() {
941 Self::discard_stale_slot(&mut slot, &mut timings);
942 }
943 if let Err(error) = self.build_slot(&mut slot, &mut timings) {
944 timings.total = total_started.elapsed();
945 return Err(BaselinePoolError::Preparation {
946 error,
947 timings: Box::new(timings),
948 });
949 }
950 timings.total = total_started.elapsed();
951
952 let outcome = rebuild_reason.map_or_else(
953 || BaselinePoolOutcome::Built {
954 slot: slot_index,
955 timings,
956 },
957 |reason| BaselinePoolOutcome::Rebuilt {
958 slot: slot_index,
959 reason,
960 timings,
961 },
962 );
963 Ok((CachedPocketIcBaselinePoolGuard { slot }, outcome))
964 }
965
966 fn prepare_reused(
967 &self,
968 slot: &mut BoundedSlotLease<'_, BaselineSlot<R::Metadata>>,
969 timings: &mut BaselinePoolTimings,
970 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
971 let baseline = &slot
972 .get()
973 .expect("reusable baseline slot must be populated")
974 .baseline;
975
976 let started = Instant::now();
977 let restore = self.recipe.restore_canisters(baseline);
978 add_timing(&mut timings.restore, started.elapsed());
979 let canisters = restore.map_err(|source| BaselinePoolPreparationError::Recipe {
980 stage: BaselinePreparationStage::RestoreCanisters,
981 source,
982 })?;
983 let expected_canisters = baseline.snapshot_canister_ids().collect::<Vec<_>>();
984 if canisters.canister_ids() != expected_canisters {
985 return Err(BaselinePoolPreparationError::Contract(
986 BaselinePoolContractError::RestoreCanisterSetMismatch {
987 expected: expected_canisters,
988 actual: canisters.canister_ids().to_vec(),
989 },
990 ));
991 }
992
993 let started = Instant::now();
994 let reset_result = self.recipe.reset_non_snapshot_state(baseline);
995 add_timing(&mut timings.reset, started.elapsed());
996 let mut reset = reset_result.map_err(|source| BaselinePoolPreparationError::Recipe {
997 stage: BaselinePreparationStage::ResetNonSnapshotState,
998 source,
999 })?;
1000 reset
1001 .include_restore(&canisters)
1002 .map_err(BaselinePoolPreparationError::Contract)?;
1003
1004 let started = Instant::now();
1005 let readiness_result = self.recipe.drive_to_readiness(baseline);
1006 add_timing(&mut timings.readiness, started.elapsed());
1007 let readiness =
1008 readiness_result.map_err(|source| BaselinePoolPreparationError::Recipe {
1009 stage: BaselinePreparationStage::DriveToReadiness,
1010 source,
1011 })?;
1012 self.recipe
1013 .reset_requirements()
1014 .verify(&reset)
1015 .map_err(BaselinePoolPreparationError::Contract)?;
1016
1017 let preparation = PreparedBaseline::Restored {
1018 canisters,
1019 reset,
1020 readiness,
1021 };
1022 self.validate_baseline(
1023 baseline,
1024 &preparation,
1025 BaselinePreparationStage::ValidateRestored,
1026 timings,
1027 )?;
1028 slot.get_mut()
1029 .expect("validated baseline slot must remain populated")
1030 .invalidation_reason = None;
1031 Ok(())
1032 }
1033
1034 fn build_slot(
1035 &self,
1036 slot: &mut BoundedSlotLease<'_, BaselineSlot<R::Metadata>>,
1037 timings: &mut BaselinePoolTimings,
1038 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
1039 let started = Instant::now();
1040 let build = self.recipe.build();
1041 add_timing(&mut timings.build, started.elapsed());
1042 let baseline = build.map_err(|source| BaselinePoolPreparationError::Recipe {
1043 stage: BaselinePreparationStage::Build,
1044 source,
1045 })?;
1046
1047 if let Err(error) = self.validate_baseline(
1048 &baseline,
1049 &PreparedBaseline::Built,
1050 BaselinePreparationStage::ValidateBuilt,
1051 timings,
1052 ) {
1053 drop_baseline_safely(baseline);
1054 return Err(error);
1055 }
1056 let replaced = slot.replace(BaselineSlot {
1057 baseline,
1058 invalidation_reason: None,
1059 });
1060 debug_assert!(replaced.is_none());
1061 Ok(())
1062 }
1063
1064 fn validate_baseline(
1065 &self,
1066 baseline: &CachedPocketIcBaseline<R::Metadata>,
1067 preparation: &PreparedBaseline,
1068 stage: BaselinePreparationStage,
1069 timings: &mut BaselinePoolTimings,
1070 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
1071 let started = Instant::now();
1072 let validation = self.recipe.validate(baseline, preparation);
1073 add_timing(&mut timings.validation, started.elapsed());
1074 let receipt =
1075 validation.map_err(|source| BaselinePoolPreparationError::Recipe { stage, source })?;
1076 if receipt.recipe_id() != self.recipe.id() {
1077 return Err(BaselinePoolPreparationError::Contract(
1078 BaselinePoolContractError::RecipeIdentityMismatch {
1079 expected: self.recipe.id().clone(),
1080 actual: receipt.recipe_id().clone(),
1081 },
1082 ));
1083 }
1084 Ok(())
1085 }
1086
1087 fn failure_disposition(
1088 &self,
1089 error: &BaselinePoolPreparationError<R::Error>,
1090 ) -> FailureDisposition {
1091 match error {
1092 BaselinePoolPreparationError::Recipe { stage, source } => {
1093 self.recipe.classify_failure(*stage, source)
1094 }
1095 BaselinePoolPreparationError::Contract(
1096 BaselinePoolContractError::RecipeIdentityMismatch { .. },
1097 ) => FailureDisposition::Fatal,
1098 BaselinePoolPreparationError::Contract(_) => {
1099 FailureDisposition::Rebuild(rebuild_reason_for_error(error))
1100 }
1101 }
1102 }
1103
1104 fn discard_stale_slot(
1105 slot: &mut BoundedSlotLease<'_, BaselineSlot<R::Metadata>>,
1106 timings: &mut BaselinePoolTimings,
1107 ) {
1108 let started = Instant::now();
1109 if let Some(stale) = slot.take() {
1110 drop_baseline_safely(stale.baseline);
1111 }
1112 timings.stale_teardown = Some(started.elapsed());
1113 }
1114}
1115
1116impl<R> CachedPocketIcBaselinePoolGuard<'_, R>
1117where
1118 R: PocketIcBaselineRecipe,
1119{
1120 #[must_use]
1122 pub const fn slot(&self) -> usize {
1123 self.slot.slot_index()
1124 }
1125
1126 pub fn invalidate(&mut self, reason: RebuildReason) {
1128 if let Some(slot) = self.slot.get_mut() {
1129 slot.invalidation_reason = Some(reason);
1130 }
1131 self.slot.invalidate();
1132 }
1133}
1134
1135impl<R> Deref for CachedPocketIcBaselinePoolGuard<'_, R>
1136where
1137 R: PocketIcBaselineRecipe,
1138{
1139 type Target = CachedPocketIcBaseline<R::Metadata>;
1140
1141 fn deref(&self) -> &Self::Target {
1142 &self
1143 .slot
1144 .get()
1145 .expect("leased baseline pool slot must be populated")
1146 .baseline
1147 }
1148}
1149
1150fn nonempty_receipt_identity(
1151 receipt: &'static str,
1152 identity: String,
1153) -> Result<String, BaselinePoolContractError> {
1154 if identity.trim().is_empty() {
1155 return Err(BaselinePoolContractError::EmptyReceiptIdentity { receipt });
1156 }
1157 Ok(identity)
1158}
1159
1160const fn add_timing(total: &mut Option<Duration>, elapsed: Duration) {
1161 *total = saturating_add_optional_duration(*total, Some(elapsed));
1162}
1163
1164fn rebuild_reason_for_error<E>(error: &BaselinePoolPreparationError<E>) -> RebuildReason {
1165 match error {
1166 BaselinePoolPreparationError::Recipe { stage, .. } => stage.default_rebuild_reason(),
1167 BaselinePoolPreparationError::Contract(
1168 BaselinePoolContractError::MissingResetDomain { .. }
1169 | BaselinePoolContractError::ResetPolicyMismatch { .. }
1170 | BaselinePoolContractError::DuplicateResetDomain { .. }
1171 | BaselinePoolContractError::RestoreCanisterSetMismatch { .. },
1172 ) => RebuildReason::ResetCoverageMismatch,
1173 BaselinePoolPreparationError::Contract(_) => RebuildReason::InvariantValidationFailure,
1174 }
1175}
1176
1177fn drop_baseline_safely<M>(baseline: CachedPocketIcBaseline<M>) {
1178 let _ = catch_unwind(AssertUnwindSafe(|| drop(baseline)));
1179}
1180
1181impl std::fmt::Display for FixtureRecipeId {
1182 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1183 formatter.write_str(&self.0)
1184 }
1185}
1186
1187impl std::fmt::Display for BaselinePreparationStage {
1188 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1189 formatter.write_str(match self {
1190 Self::Build => "build",
1191 Self::RestoreCanisters => "canister restore",
1192 Self::ResetNonSnapshotState => "non-snapshot reset",
1193 Self::DriveToReadiness => "readiness",
1194 Self::ValidateBuilt => "built-baseline validation",
1195 Self::ValidateRestored => "restored-baseline validation",
1196 })
1197 }
1198}
1199
1200impl std::fmt::Display for BaselinePoolContractError {
1201 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1202 match self {
1203 Self::EmptyRecipeIdentity => formatter.write_str("fixture recipe identity is empty"),
1204 Self::EmptyReceiptIdentity { receipt } => {
1205 write!(formatter, "{receipt} receipt identity is empty")
1206 }
1207 Self::DuplicateResetDomain { domain } => {
1208 write!(
1209 formatter,
1210 "reset domain {domain:?} was reported more than once"
1211 )
1212 }
1213 Self::DuplicateCanisterId { canister_id } => {
1214 write!(formatter, "restore receipt repeats canister {canister_id}")
1215 }
1216 Self::EmptyCanisterSet => formatter.write_str("restore receipt contains no canisters"),
1217 Self::UndeclaredRequiredResetDomain { domain } => write!(
1218 formatter,
1219 "baseline recipe does not declare required reset domain {domain:?}",
1220 ),
1221 Self::RestoreCanisterSetMismatch { expected, actual } => write!(
1222 formatter,
1223 "restore receipt identified canisters {actual:?}, expected captured set {expected:?}",
1224 ),
1225 Self::MissingResetDomain { domain } => {
1226 write!(
1227 formatter,
1228 "required reset domain {domain:?} was not achieved"
1229 )
1230 }
1231 Self::ResetPolicyMismatch {
1232 requirement,
1233 achievement,
1234 } => write!(
1235 formatter,
1236 "reset achievement {achievement:?} does not satisfy {requirement:?}",
1237 ),
1238 Self::RecipeIdentityMismatch { expected, actual } => write!(
1239 formatter,
1240 "validation receipt used recipe `{actual}` instead of `{expected}`",
1241 ),
1242 }
1243 }
1244}
1245
1246impl std::error::Error for BaselinePoolContractError {}
1247
1248impl<E> std::fmt::Display for BaselinePoolPreparationError<E>
1249where
1250 E: std::fmt::Display,
1251{
1252 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1253 match self {
1254 Self::Recipe { stage, source } => {
1255 write!(formatter, "baseline {stage} failed: {source}")
1256 }
1257 Self::Contract(error) => write!(formatter, "baseline pool contract failed: {error}"),
1258 }
1259 }
1260}
1261
1262impl<E> std::error::Error for BaselinePoolPreparationError<E>
1263where
1264 E: std::error::Error + 'static,
1265{
1266 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1267 match self {
1268 Self::Recipe { source, .. } => Some(source),
1269 Self::Contract(error) => Some(error),
1270 }
1271 }
1272}
1273
1274impl<E> std::fmt::Display for BaselinePoolError<E>
1275where
1276 E: std::fmt::Display,
1277{
1278 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1279 match self {
1280 Self::Preparation { error, .. } => error.fmt(formatter),
1281 Self::RecoveryFailed {
1282 original, rebuild, ..
1283 } => write!(
1284 formatter,
1285 "baseline preparation failed ({original}); rebuilding the slot also failed: {rebuild}",
1286 ),
1287 }
1288 }
1289}
1290
1291impl<E> std::error::Error for BaselinePoolError<E>
1292where
1293 E: std::error::Error + 'static,
1294{
1295 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1296 match self {
1297 Self::Preparation { error, .. } => Some(error),
1298 Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
1299 }
1300 }
1301}
1302
1303#[cfg(test)]
1304mod tests {
1305 use super::{
1306 BaselinePoolContractError, CycleResetPolicy, FixtureRecipeId, ResetAchievement,
1307 ResetDomainKind, ResetReceipt, ResetRequirement, ResetRequirements,
1308 };
1309
1310 #[test]
1311 fn recipe_identity_must_be_nonempty() {
1312 assert!(matches!(
1313 FixtureRecipeId::try_new(" "),
1314 Err(BaselinePoolContractError::EmptyRecipeIdentity)
1315 ));
1316 }
1317
1318 #[test]
1319 fn reset_requirements_reject_duplicate_domains() {
1320 let result = ResetRequirements::try_new([
1321 ResetRequirement::CanisterCycles(CycleResetPolicy::PreserveCurrent),
1322 ResetRequirement::CanisterCycles(CycleResetPolicy::RestoreExactBaseline),
1323 ]);
1324 assert!(matches!(
1325 result,
1326 Err(BaselinePoolContractError::DuplicateResetDomain {
1327 domain: ResetDomainKind::CanisterCycles,
1328 })
1329 ));
1330 }
1331
1332 #[test]
1333 fn required_policy_must_match_achieved_policy() {
1334 let requirements = ResetRequirements::try_new([
1335 ResetRequirement::CanisterSnapshots,
1336 ResetRequirement::CanisterCycles(CycleResetPolicy::RestoreExactBaseline),
1337 ])
1338 .unwrap();
1339 let receipt = ResetReceipt::try_new([
1340 ResetAchievement::CanisterSnapshots,
1341 ResetAchievement::CanisterCycles(CycleResetPolicy::PreserveCurrent),
1342 ])
1343 .unwrap();
1344
1345 assert!(matches!(
1346 requirements.verify(&receipt),
1347 Err(BaselinePoolContractError::ResetPolicyMismatch { .. })
1348 ));
1349 }
1350}