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 super::{
11 CachedPocketIcBaseline,
12 bounded_pool::{BoundedSlotLease, BoundedSlotPool},
13};
14
15#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
17pub struct FixtureRecipeId(String);
18
19#[non_exhaustive]
21#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
22pub enum ResetDomainKind {
23 PocketIcTime,
25 ExtraCanisters,
27 PendingMessages,
29 SubnetState,
31 ExternalResources,
33}
34
35#[non_exhaustive]
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub enum CycleResetPolicy {
39 PreserveCurrent,
43 TopUpTo(u128),
46 RestoreExactBaseline,
48 RebuildOnMutation,
50}
51
52#[non_exhaustive]
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55pub enum TimeResetPolicy {
56 PreserveCurrent,
58 RestoreBaseline,
60 RebuildOnMutation,
62}
63
64#[non_exhaustive]
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub enum ExtraCanisterPolicy {
68 RequireBaselineSet,
70 RemoveTracked,
72 RebuildOnChange,
74}
75
76#[non_exhaustive]
78#[derive(Clone, Copy, Debug, Eq, PartialEq)]
79pub enum StateResetPolicy {
80 ResetByRecipe,
82 ValidateUnchanged,
84 IrrelevantByRecipeContract,
86 RebuildOnChange,
88}
89
90#[non_exhaustive]
117#[derive(Clone, Debug, Eq, PartialEq)]
118pub enum ResetDomainPolicy {
119 PocketIcTime(TimeResetPolicy),
121 ExtraCanisters(ExtraCanisterPolicy),
123 PendingMessages(StateResetPolicy),
125 SubnetState(StateResetPolicy),
127 ExternalResources(StateResetPolicy),
129}
130
131#[derive(Clone, Debug, Eq, PartialEq)]
133pub struct ResetRequirements {
134 cycle_policy: CycleResetPolicy,
135 domains: BTreeMap<ResetDomainKind, ResetDomainPolicy>,
136}
137
138#[derive(Clone, Debug, Default, Eq, PartialEq)]
140pub struct ResetReceipt(BTreeMap<ResetDomainKind, ResetDomainPolicy>);
141
142#[derive(Clone, Debug, Eq, PartialEq)]
144pub struct CanisterRestoreReceipt {
145 canister_ids: Vec<Principal>,
146 cycle_policy: CycleResetPolicy,
147}
148
149#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct ReadinessReceipt {
152 identity: String,
153}
154
155#[derive(Clone, Debug, Eq, PartialEq)]
157pub struct ValidationReceipt {
158 recipe_id: FixtureRecipeId,
159 invariant_identity: String,
160}
161
162#[non_exhaustive]
164#[derive(Clone, Debug, Eq, PartialEq)]
165pub enum BaselinePoolContractError {
166 EmptyRecipeIdentity,
168 EmptyReceiptIdentity { receipt: &'static str },
170 DuplicateResetDomain { domain: ResetDomainKind },
172 DuplicateCanisterId { canister_id: Principal },
174 EmptyCanisterSet,
176 CyclePolicyMismatch {
178 required: CycleResetPolicy,
179 achieved: CycleResetPolicy,
180 },
181 RestoreCanisterSetMismatch {
183 expected: Vec<Principal>,
184 actual: Vec<Principal>,
185 },
186 MissingResetDomain { domain: ResetDomainKind },
188 ResetPolicyMismatch {
190 requirement: ResetDomainPolicy,
191 achievement: ResetDomainPolicy,
192 },
193 RecipeIdentityMismatch {
195 expected: FixtureRecipeId,
196 actual: FixtureRecipeId,
197 },
198}
199
200#[non_exhaustive]
202#[derive(Clone, Debug, Eq, PartialEq)]
203pub enum PreparedBaseline {
204 Built,
206 Restored {
208 canisters: CanisterRestoreReceipt,
210 reset: ResetReceipt,
212 readiness: ReadinessReceipt,
214 },
215}
216
217#[non_exhaustive]
219#[derive(Clone, Copy, Debug, Eq, PartialEq)]
220pub enum BaselinePreparationStage {
221 Build,
223 RestoreCanisters,
225 ResetNonSnapshotState,
227 DriveToReadiness,
229 ValidateBuilt,
231 ValidateRestored,
233}
234
235#[non_exhaustive]
237#[derive(Clone, Debug, Eq, PartialEq)]
238pub enum RebuildReason {
239 DeadPocketIcTransport,
241 SnapshotRestoreFailure,
243 ResetFailure,
245 ReadinessFailure,
247 ResetCoverageMismatch,
249 InvariantValidationFailure,
251 ExplicitLeaseInvalidation,
253 UnwindWhileLeased,
255 RecipeClassified { code: String },
257}
258
259#[non_exhaustive]
261#[derive(Clone, Debug, Eq, PartialEq)]
262pub enum FailureDisposition {
263 Fatal,
265 Rebuild(RebuildReason),
267}
268
269#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
271pub struct BaselinePoolTimings {
272 wait: Duration,
273 build: Option<Duration>,
274 restore: Option<Duration>,
275 reset: Option<Duration>,
276 readiness: Option<Duration>,
277 validation: Option<Duration>,
278 stale_teardown: Option<Duration>,
279 total: Duration,
280}
281
282#[non_exhaustive]
284#[derive(Clone, Debug, Eq, PartialEq)]
285pub enum BaselinePoolOutcome {
286 Built {
288 slot: usize,
290 timings: BaselinePoolTimings,
292 },
293 Restored {
295 slot: usize,
297 timings: BaselinePoolTimings,
299 },
300 Rebuilt {
302 slot: usize,
304 reason: RebuildReason,
306 timings: BaselinePoolTimings,
308 },
309}
310
311#[non_exhaustive]
313#[derive(Debug)]
314pub enum BaselinePoolPreparationError<E> {
315 Recipe {
317 stage: BaselinePreparationStage,
319 source: E,
321 },
322 Contract(BaselinePoolContractError),
324}
325
326#[non_exhaustive]
328#[derive(Debug)]
329pub enum BaselinePoolError<E> {
330 Preparation {
332 error: BaselinePoolPreparationError<E>,
334 timings: Box<BaselinePoolTimings>,
336 },
337 RecoveryFailed {
339 original: Box<BaselinePoolPreparationError<E>>,
341 rebuild: Box<BaselinePoolPreparationError<E>>,
343 timings: Box<BaselinePoolTimings>,
345 },
346}
347
348pub trait PocketIcBaselineRecipe: Send + Sync + 'static {
350 type Metadata: Send + 'static;
352 type Error: std::error::Error + Send + Sync + 'static;
354
355 fn id(&self) -> &FixtureRecipeId;
357
358 fn reset_requirements(&self) -> &ResetRequirements;
360
361 fn build(&self) -> Result<CachedPocketIcBaseline<Self::Metadata>, Self::Error>;
363
364 fn restore_canisters(
366 &self,
367 baseline: &CachedPocketIcBaseline<Self::Metadata>,
368 ) -> Result<CanisterRestoreReceipt, Self::Error>;
369
370 fn reset_non_snapshot_state(
372 &self,
373 baseline: &CachedPocketIcBaseline<Self::Metadata>,
374 ) -> Result<ResetReceipt, Self::Error>;
375
376 fn drive_to_readiness(
378 &self,
379 baseline: &CachedPocketIcBaseline<Self::Metadata>,
380 ) -> Result<ReadinessReceipt, Self::Error>;
381
382 fn validate(
384 &self,
385 baseline: &CachedPocketIcBaseline<Self::Metadata>,
386 preparation: &PreparedBaseline,
387 ) -> Result<ValidationReceipt, Self::Error>;
388
389 fn classify_failure(
391 &self,
392 stage: BaselinePreparationStage,
393 _error: &Self::Error,
394 ) -> FailureDisposition {
395 FailureDisposition::Rebuild(stage.default_rebuild_reason())
396 }
397}
398
399pub struct CachedPocketIcBaselinePool<R>
411where
412 R: PocketIcBaselineRecipe,
413{
414 recipe: R,
415 slots: BoundedSlotPool<BaselineSlot<R::Metadata>>,
416}
417
418struct BaselineSlot<M> {
419 baseline: CachedPocketIcBaseline<M>,
420 invalidation_reason: Option<RebuildReason>,
421}
422
423pub struct CachedPocketIcBaselinePoolGuard<'a, R>
425where
426 R: PocketIcBaselineRecipe,
427{
428 slot: BoundedSlotLease<'a, BaselineSlot<R::Metadata>>,
429}
430
431impl FixtureRecipeId {
432 pub fn try_new(identity: impl Into<String>) -> Result<Self, BaselinePoolContractError> {
434 let identity = identity.into();
435 if identity.trim().is_empty() {
436 return Err(BaselinePoolContractError::EmptyRecipeIdentity);
437 }
438 Ok(Self(identity))
439 }
440
441 #[must_use]
443 pub fn as_str(&self) -> &str {
444 &self.0
445 }
446}
447
448impl ResetDomainPolicy {
449 #[must_use]
451 pub const fn domain(&self) -> ResetDomainKind {
452 match self {
453 Self::PocketIcTime(_) => ResetDomainKind::PocketIcTime,
454 Self::ExtraCanisters(_) => ResetDomainKind::ExtraCanisters,
455 Self::PendingMessages(_) => ResetDomainKind::PendingMessages,
456 Self::SubnetState(_) => ResetDomainKind::SubnetState,
457 Self::ExternalResources(_) => ResetDomainKind::ExternalResources,
458 }
459 }
460}
461
462fn collect_reset_domains(
463 policies: impl IntoIterator<Item = ResetDomainPolicy>,
464) -> Result<BTreeMap<ResetDomainKind, ResetDomainPolicy>, BaselinePoolContractError> {
465 let mut domains = BTreeMap::new();
466 for policy in policies {
467 let domain = policy.domain();
468 if domains.insert(domain, policy).is_some() {
469 return Err(BaselinePoolContractError::DuplicateResetDomain { domain });
470 }
471 }
472 Ok(domains)
473}
474
475impl ResetRequirements {
476 pub fn try_new<I>(
481 cycle_policy: CycleResetPolicy,
482 requirements: I,
483 ) -> Result<Self, BaselinePoolContractError>
484 where
485 I: IntoIterator<Item = ResetDomainPolicy>,
486 {
487 Ok(Self {
488 cycle_policy,
489 domains: collect_reset_domains(requirements)?,
490 })
491 }
492
493 #[must_use]
495 pub const fn cycle_policy(&self) -> CycleResetPolicy {
496 self.cycle_policy
497 }
498
499 #[must_use]
501 pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetDomainPolicy> {
502 self.domains.get(&domain)
503 }
504
505 pub fn iter(&self) -> impl Iterator<Item = &ResetDomainPolicy> {
507 self.domains.values()
508 }
509
510 fn verify(
511 &self,
512 restore: &CanisterRestoreReceipt,
513 receipt: &ResetReceipt,
514 ) -> Result<(), BaselinePoolContractError> {
515 if restore.cycle_policy != self.cycle_policy {
516 return Err(BaselinePoolContractError::CyclePolicyMismatch {
517 required: self.cycle_policy,
518 achieved: restore.cycle_policy,
519 });
520 }
521 for (domain, requirement) in &self.domains {
522 let Some(achievement) = receipt.0.get(domain) else {
523 return Err(BaselinePoolContractError::MissingResetDomain { domain: *domain });
524 };
525 if achievement != requirement {
526 return Err(BaselinePoolContractError::ResetPolicyMismatch {
527 requirement: requirement.clone(),
528 achievement: achievement.clone(),
529 });
530 }
531 }
532 Ok(())
533 }
534}
535
536impl ResetReceipt {
537 pub fn try_new<I>(achievements: I) -> Result<Self, BaselinePoolContractError>
542 where
543 I: IntoIterator<Item = ResetDomainPolicy>,
544 {
545 Ok(Self(collect_reset_domains(achievements)?))
546 }
547
548 #[must_use]
550 pub const fn empty() -> Self {
551 Self(BTreeMap::new())
552 }
553
554 #[must_use]
556 pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetDomainPolicy> {
557 self.0.get(&domain)
558 }
559
560 pub fn iter(&self) -> impl Iterator<Item = &ResetDomainPolicy> {
562 self.0.values()
563 }
564}
565
566impl CanisterRestoreReceipt {
567 pub fn try_new<I>(
569 canister_ids: I,
570 cycle_policy: CycleResetPolicy,
571 ) -> Result<Self, BaselinePoolContractError>
572 where
573 I: IntoIterator<Item = Principal>,
574 {
575 let mut unique = BTreeSet::new();
576 for canister_id in canister_ids {
577 if !unique.insert(canister_id) {
578 return Err(BaselinePoolContractError::DuplicateCanisterId { canister_id });
579 }
580 }
581 if unique.is_empty() {
582 return Err(BaselinePoolContractError::EmptyCanisterSet);
583 }
584 Ok(Self {
585 canister_ids: unique.into_iter().collect(),
586 cycle_policy,
587 })
588 }
589
590 pub fn try_from_baseline<M>(
598 baseline: &CachedPocketIcBaseline<M>,
599 cycle_policy: CycleResetPolicy,
600 ) -> Result<Self, BaselinePoolContractError> {
601 if baseline.snapshot_count() == 0 {
602 return Err(BaselinePoolContractError::EmptyCanisterSet);
603 }
604 Ok(Self {
607 canister_ids: baseline.snapshot_canister_ids().collect(),
608 cycle_policy,
609 })
610 }
611
612 #[must_use]
614 pub fn canister_ids(&self) -> &[Principal] {
615 &self.canister_ids
616 }
617
618 #[must_use]
620 pub const fn cycle_policy(&self) -> CycleResetPolicy {
621 self.cycle_policy
622 }
623}
624
625impl ReadinessReceipt {
626 pub fn try_new(identity: impl Into<String>) -> Result<Self, BaselinePoolContractError> {
628 Ok(Self {
629 identity: nonempty_receipt_identity("readiness", identity.into())?,
630 })
631 }
632
633 #[must_use]
635 pub fn identity(&self) -> &str {
636 &self.identity
637 }
638}
639
640impl ValidationReceipt {
641 pub fn try_new(
643 recipe_id: FixtureRecipeId,
644 invariant_identity: impl Into<String>,
645 ) -> Result<Self, BaselinePoolContractError> {
646 Ok(Self {
647 recipe_id,
648 invariant_identity: nonempty_receipt_identity("validation", invariant_identity.into())?,
649 })
650 }
651
652 #[must_use]
654 pub const fn recipe_id(&self) -> &FixtureRecipeId {
655 &self.recipe_id
656 }
657
658 #[must_use]
660 pub fn invariant_identity(&self) -> &str {
661 &self.invariant_identity
662 }
663}
664
665impl BaselinePreparationStage {
666 #[must_use]
671 pub fn default_rebuild_reason(self) -> RebuildReason {
672 match self {
673 Self::RestoreCanisters => RebuildReason::SnapshotRestoreFailure,
674 Self::ResetNonSnapshotState => RebuildReason::ResetFailure,
675 Self::DriveToReadiness => RebuildReason::ReadinessFailure,
676 Self::ValidateRestored | Self::ValidateBuilt => {
677 RebuildReason::InvariantValidationFailure
678 }
679 Self::Build => RebuildReason::RecipeClassified {
680 code: "build".to_owned(),
681 },
682 }
683 }
684}
685
686impl BaselinePoolTimings {
687 #[must_use]
689 pub const fn wait(self) -> Duration {
690 self.wait
691 }
692
693 #[must_use]
695 pub const fn build(self) -> Option<Duration> {
696 self.build
697 }
698
699 #[must_use]
701 pub const fn restore(self) -> Option<Duration> {
702 self.restore
703 }
704
705 #[must_use]
707 pub const fn reset(self) -> Option<Duration> {
708 self.reset
709 }
710
711 #[must_use]
713 pub const fn readiness(self) -> Option<Duration> {
714 self.readiness
715 }
716
717 #[must_use]
719 pub const fn validation(self) -> Option<Duration> {
720 self.validation
721 }
722
723 #[must_use]
725 pub const fn stale_teardown(self) -> Option<Duration> {
726 self.stale_teardown
727 }
728
729 #[must_use]
731 pub const fn total(self) -> Duration {
732 self.total
733 }
734}
735
736impl BaselinePoolOutcome {
737 #[must_use]
739 pub const fn slot(&self) -> usize {
740 match self {
741 Self::Built { slot, .. } | Self::Restored { slot, .. } | Self::Rebuilt { slot, .. } => {
742 *slot
743 }
744 }
745 }
746
747 #[must_use]
749 pub const fn timings(&self) -> BaselinePoolTimings {
750 match self {
751 Self::Built { timings, .. }
752 | Self::Restored { timings, .. }
753 | Self::Rebuilt { timings, .. } => *timings,
754 }
755 }
756}
757
758impl std::fmt::Display for BaselinePoolTimings {
759 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
760 write!(
761 formatter,
762 "total={:?} wait={:?} build={:?} restore={:?} reset={:?} readiness={:?} validation={:?} stale_teardown={:?}",
763 self.total,
764 self.wait,
765 self.build,
766 self.restore,
767 self.reset,
768 self.readiness,
769 self.validation,
770 self.stale_teardown,
771 )
772 }
773}
774
775impl std::fmt::Display for BaselinePoolOutcome {
776 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
777 match self {
778 Self::Built { slot, timings } => write!(formatter, "built slot={slot} {timings}"),
779 Self::Restored { slot, timings } => {
780 write!(formatter, "restored slot={slot} {timings}")
781 }
782 Self::Rebuilt {
783 slot,
784 reason,
785 timings,
786 } => write!(formatter, "rebuilt slot={slot} reason={reason:?} {timings}"),
787 }
788 }
789}
790
791impl<E> BaselinePoolError<E> {
792 #[must_use]
794 pub const fn timings(&self) -> BaselinePoolTimings {
795 match self {
796 Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
797 }
798 }
799}
800
801impl<R> CachedPocketIcBaselinePool<R>
802where
803 R: PocketIcBaselineRecipe,
804{
805 #[must_use]
810 pub const fn new(capacity: NonZeroUsize, recipe: R) -> Self {
811 Self {
812 recipe,
813 slots: BoundedSlotPool::new(capacity),
814 }
815 }
816
817 #[must_use]
819 pub fn recipe_id(&self) -> &FixtureRecipeId {
820 self.recipe.id()
821 }
822
823 #[must_use]
825 pub const fn capacity(&self) -> NonZeroUsize {
826 self.slots.capacity()
827 }
828
829 pub fn acquire(
841 &self,
842 ) -> Result<
843 (CachedPocketIcBaselinePoolGuard<'_, R>, BaselinePoolOutcome),
844 BaselinePoolError<R::Error>,
845 > {
846 let total_started = Instant::now();
847 let mut slot = self.slots.acquire();
848 let mut timings = BaselinePoolTimings {
849 wait: slot.wait(),
850 ..BaselinePoolTimings::default()
851 };
852 let slot_index = slot.slot_index();
853
854 let (rebuild_reason, original) = if slot.is_reusable() {
855 match self.prepare_reused(&slot, &mut timings) {
856 Ok(()) => {
857 timings.total = total_started.elapsed();
858 return Ok((
859 CachedPocketIcBaselinePoolGuard { slot },
860 BaselinePoolOutcome::Restored {
861 slot: slot_index,
862 timings,
863 },
864 ));
865 }
866 Err(original) => match self.failure_disposition(&original) {
867 FailureDisposition::Fatal => {
868 Self::discard_stale_slot(&mut slot, &mut timings);
871 timings.total = total_started.elapsed();
872 return Err(BaselinePoolError::Preparation {
873 error: original,
874 timings: Box::new(timings),
875 });
876 }
877 FailureDisposition::Rebuild(reason) => (Some(reason), Some(original)),
878 },
879 }
880 } else {
881 let reason = if slot.invalidated_by_unwind() {
882 Some(RebuildReason::UnwindWhileLeased)
883 } else {
884 slot.get()
885 .and_then(|slot| slot.invalidation_reason.clone())
886 .or_else(|| {
887 slot.is_populated()
888 .then_some(RebuildReason::ExplicitLeaseInvalidation)
889 })
890 };
891 (reason, None)
892 };
893
894 if slot.is_populated() {
895 Self::discard_stale_slot(&mut slot, &mut timings);
896 }
897 let build = self.build_slot(&mut slot, &mut timings);
898 timings.total = total_started.elapsed();
899 if let Err(error) = build {
900 return Err(match original {
901 Some(original) => BaselinePoolError::RecoveryFailed {
902 original: Box::new(original),
903 rebuild: Box::new(error),
904 timings: Box::new(timings),
905 },
906 None => BaselinePoolError::Preparation {
907 error,
908 timings: Box::new(timings),
909 },
910 });
911 }
912
913 let outcome = rebuild_reason.map_or_else(
914 || BaselinePoolOutcome::Built {
915 slot: slot_index,
916 timings,
917 },
918 |reason| BaselinePoolOutcome::Rebuilt {
919 slot: slot_index,
920 reason,
921 timings,
922 },
923 );
924 Ok((CachedPocketIcBaselinePoolGuard { slot }, outcome))
925 }
926
927 fn prepare_reused(
928 &self,
929 slot: &BoundedSlotLease<'_, BaselineSlot<R::Metadata>>,
930 timings: &mut BaselinePoolTimings,
931 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
932 let baseline = &slot
933 .get()
934 .expect("reusable baseline slot must be populated")
935 .baseline;
936
937 let started = Instant::now();
938 let restore = self.recipe.restore_canisters(baseline);
939 add_timing(&mut timings.restore, started.elapsed());
940 let canisters = restore.map_err(|source| BaselinePoolPreparationError::Recipe {
941 stage: BaselinePreparationStage::RestoreCanisters,
942 source,
943 })?;
944 if !baseline
945 .snapshot_canister_ids()
946 .eq(canisters.canister_ids().iter().copied())
947 {
948 return Err(BaselinePoolPreparationError::Contract(
949 BaselinePoolContractError::RestoreCanisterSetMismatch {
950 expected: baseline.snapshot_canister_ids().collect(),
951 actual: canisters.canister_ids().to_vec(),
952 },
953 ));
954 }
955
956 let started = Instant::now();
957 let reset_result = self.recipe.reset_non_snapshot_state(baseline);
958 add_timing(&mut timings.reset, started.elapsed());
959 let reset = reset_result.map_err(|source| BaselinePoolPreparationError::Recipe {
960 stage: BaselinePreparationStage::ResetNonSnapshotState,
961 source,
962 })?;
963
964 let started = Instant::now();
965 let readiness_result = self.recipe.drive_to_readiness(baseline);
966 add_timing(&mut timings.readiness, started.elapsed());
967 let readiness =
968 readiness_result.map_err(|source| BaselinePoolPreparationError::Recipe {
969 stage: BaselinePreparationStage::DriveToReadiness,
970 source,
971 })?;
972 self.recipe
973 .reset_requirements()
974 .verify(&canisters, &reset)
975 .map_err(BaselinePoolPreparationError::Contract)?;
976
977 let preparation = PreparedBaseline::Restored {
978 canisters,
979 reset,
980 readiness,
981 };
982 self.validate_baseline(
983 baseline,
984 &preparation,
985 BaselinePreparationStage::ValidateRestored,
986 timings,
987 )?;
988 Ok(())
989 }
990
991 fn build_slot(
992 &self,
993 slot: &mut BoundedSlotLease<'_, BaselineSlot<R::Metadata>>,
994 timings: &mut BaselinePoolTimings,
995 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
996 let started = Instant::now();
997 let build = self.recipe.build();
998 add_timing(&mut timings.build, started.elapsed());
999 let baseline = build.map_err(|source| BaselinePoolPreparationError::Recipe {
1000 stage: BaselinePreparationStage::Build,
1001 source,
1002 })?;
1003
1004 if let Err(error) = self.validate_baseline(
1005 &baseline,
1006 &PreparedBaseline::Built,
1007 BaselinePreparationStage::ValidateBuilt,
1008 timings,
1009 ) {
1010 drop_baseline_safely(baseline);
1011 return Err(error);
1012 }
1013 let replaced = slot.replace(BaselineSlot {
1014 baseline,
1015 invalidation_reason: None,
1016 });
1017 debug_assert!(replaced.is_none());
1018 Ok(())
1019 }
1020
1021 fn validate_baseline(
1022 &self,
1023 baseline: &CachedPocketIcBaseline<R::Metadata>,
1024 preparation: &PreparedBaseline,
1025 stage: BaselinePreparationStage,
1026 timings: &mut BaselinePoolTimings,
1027 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
1028 let started = Instant::now();
1029 let validation = self.recipe.validate(baseline, preparation);
1030 add_timing(&mut timings.validation, started.elapsed());
1031 let receipt =
1032 validation.map_err(|source| BaselinePoolPreparationError::Recipe { stage, source })?;
1033 if receipt.recipe_id() != self.recipe.id() {
1034 return Err(BaselinePoolPreparationError::Contract(
1035 BaselinePoolContractError::RecipeIdentityMismatch {
1036 expected: self.recipe.id().clone(),
1037 actual: receipt.recipe_id().clone(),
1038 },
1039 ));
1040 }
1041 Ok(())
1042 }
1043
1044 fn failure_disposition(
1045 &self,
1046 error: &BaselinePoolPreparationError<R::Error>,
1047 ) -> FailureDisposition {
1048 match error {
1049 BaselinePoolPreparationError::Recipe { stage, source } => {
1050 self.recipe.classify_failure(*stage, source)
1051 }
1052 BaselinePoolPreparationError::Contract(
1053 BaselinePoolContractError::RecipeIdentityMismatch { .. },
1054 ) => FailureDisposition::Fatal,
1055 BaselinePoolPreparationError::Contract(_) => {
1056 FailureDisposition::Rebuild(rebuild_reason_for_error(error))
1057 }
1058 }
1059 }
1060
1061 fn discard_stale_slot(
1062 slot: &mut BoundedSlotLease<'_, BaselineSlot<R::Metadata>>,
1063 timings: &mut BaselinePoolTimings,
1064 ) {
1065 let started = Instant::now();
1066 if let Some(stale) = slot.take() {
1067 drop_baseline_safely(stale.baseline);
1068 }
1069 timings.stale_teardown = Some(started.elapsed());
1070 }
1071}
1072
1073impl<R> CachedPocketIcBaselinePoolGuard<'_, R>
1074where
1075 R: PocketIcBaselineRecipe,
1076{
1077 #[must_use]
1079 pub const fn slot(&self) -> usize {
1080 self.slot.slot_index()
1081 }
1082
1083 pub fn invalidate(&mut self, reason: RebuildReason) {
1085 if let Some(slot) = self.slot.get_mut() {
1086 slot.invalidation_reason = Some(reason);
1087 }
1088 self.slot.invalidate();
1089 }
1090}
1091
1092impl<R> Deref for CachedPocketIcBaselinePoolGuard<'_, R>
1093where
1094 R: PocketIcBaselineRecipe,
1095{
1096 type Target = CachedPocketIcBaseline<R::Metadata>;
1097
1098 fn deref(&self) -> &Self::Target {
1099 &self
1100 .slot
1101 .get()
1102 .expect("leased baseline pool slot must be populated")
1103 .baseline
1104 }
1105}
1106
1107fn nonempty_receipt_identity(
1108 receipt: &'static str,
1109 identity: String,
1110) -> Result<String, BaselinePoolContractError> {
1111 if identity.trim().is_empty() {
1112 return Err(BaselinePoolContractError::EmptyReceiptIdentity { receipt });
1113 }
1114 Ok(identity)
1115}
1116
1117const fn add_timing(total: &mut Option<Duration>, elapsed: Duration) {
1118 *total = Some(match *total {
1119 None => elapsed,
1120 Some(previous) => previous.saturating_add(elapsed),
1121 });
1122}
1123
1124fn rebuild_reason_for_error<E>(error: &BaselinePoolPreparationError<E>) -> RebuildReason {
1125 match error {
1126 BaselinePoolPreparationError::Recipe { stage, .. } => stage.default_rebuild_reason(),
1127 BaselinePoolPreparationError::Contract(
1128 BaselinePoolContractError::MissingResetDomain { .. }
1129 | BaselinePoolContractError::ResetPolicyMismatch { .. }
1130 | BaselinePoolContractError::CyclePolicyMismatch { .. }
1131 | BaselinePoolContractError::DuplicateResetDomain { .. }
1132 | BaselinePoolContractError::RestoreCanisterSetMismatch { .. },
1133 ) => RebuildReason::ResetCoverageMismatch,
1134 BaselinePoolPreparationError::Contract(_) => RebuildReason::InvariantValidationFailure,
1135 }
1136}
1137
1138fn drop_baseline_safely<M>(baseline: CachedPocketIcBaseline<M>) {
1139 let _ = catch_unwind(AssertUnwindSafe(|| drop(baseline)));
1140}
1141
1142impl std::fmt::Display for FixtureRecipeId {
1143 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1144 formatter.write_str(&self.0)
1145 }
1146}
1147
1148impl std::fmt::Display for BaselinePreparationStage {
1149 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1150 formatter.write_str(match self {
1151 Self::Build => "build",
1152 Self::RestoreCanisters => "canister restore",
1153 Self::ResetNonSnapshotState => "non-snapshot reset",
1154 Self::DriveToReadiness => "readiness",
1155 Self::ValidateBuilt => "built-baseline validation",
1156 Self::ValidateRestored => "restored-baseline validation",
1157 })
1158 }
1159}
1160
1161impl std::fmt::Display for BaselinePoolContractError {
1162 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1163 match self {
1164 Self::EmptyRecipeIdentity => formatter.write_str("fixture recipe identity is empty"),
1165 Self::EmptyReceiptIdentity { receipt } => {
1166 write!(formatter, "{receipt} receipt identity is empty")
1167 }
1168 Self::DuplicateResetDomain { domain } => {
1169 write!(
1170 formatter,
1171 "reset domain {domain:?} was reported more than once"
1172 )
1173 }
1174 Self::DuplicateCanisterId { canister_id } => {
1175 write!(formatter, "restore receipt repeats canister {canister_id}")
1176 }
1177 Self::EmptyCanisterSet => formatter.write_str("restore receipt contains no canisters"),
1178 Self::CyclePolicyMismatch { required, achieved } => write!(
1179 formatter,
1180 "restore cycle policy {achieved:?} does not satisfy {required:?}",
1181 ),
1182 Self::RestoreCanisterSetMismatch { expected, actual } => write!(
1183 formatter,
1184 "restore receipt identified canisters {actual:?}, expected captured set {expected:?}",
1185 ),
1186 Self::MissingResetDomain { domain } => {
1187 write!(
1188 formatter,
1189 "required reset domain {domain:?} was not achieved"
1190 )
1191 }
1192 Self::ResetPolicyMismatch {
1193 requirement,
1194 achievement,
1195 } => write!(
1196 formatter,
1197 "reset achievement {achievement:?} does not satisfy {requirement:?}",
1198 ),
1199 Self::RecipeIdentityMismatch { expected, actual } => write!(
1200 formatter,
1201 "validation receipt used recipe `{actual}` instead of `{expected}`",
1202 ),
1203 }
1204 }
1205}
1206
1207impl std::error::Error for BaselinePoolContractError {}
1208
1209impl<E> std::fmt::Display for BaselinePoolPreparationError<E>
1210where
1211 E: std::fmt::Display,
1212{
1213 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1214 match self {
1215 Self::Recipe { stage, source } => {
1216 write!(formatter, "baseline {stage} failed: {source}")
1217 }
1218 Self::Contract(error) => write!(formatter, "baseline pool contract failed: {error}"),
1219 }
1220 }
1221}
1222
1223impl<E> std::error::Error for BaselinePoolPreparationError<E>
1224where
1225 E: std::error::Error + 'static,
1226{
1227 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1228 match self {
1229 Self::Recipe { source, .. } => Some(source),
1230 Self::Contract(error) => Some(error),
1231 }
1232 }
1233}
1234
1235impl<E> std::fmt::Display for BaselinePoolError<E>
1236where
1237 E: std::fmt::Display,
1238{
1239 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1240 match self {
1241 Self::Preparation { error, .. } => error.fmt(formatter),
1242 Self::RecoveryFailed {
1243 original, rebuild, ..
1244 } => write!(
1245 formatter,
1246 "baseline preparation failed ({original}); rebuilding the slot also failed: {rebuild}",
1247 ),
1248 }
1249 }
1250}
1251
1252impl<E> std::error::Error for BaselinePoolError<E>
1253where
1254 E: std::error::Error + 'static,
1255{
1256 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1257 match self {
1258 Self::Preparation { error, .. } => Some(error),
1259 Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
1260 }
1261 }
1262}
1263
1264#[cfg(test)]
1265mod tests {
1266 use super::{
1267 BaselinePoolContractError, BaselinePoolTimings, CanisterRestoreReceipt, CycleResetPolicy,
1268 ExtraCanisterPolicy, FixtureRecipeId, ResetDomainKind, ResetDomainPolicy, ResetReceipt,
1269 ResetRequirements, StateResetPolicy, TimeResetPolicy, add_timing,
1270 };
1271 use candid::Principal;
1272 use std::time::Duration;
1273
1274 #[test]
1275 fn phase_timing_preserves_absence_zero_and_saturation() {
1276 let mut timings = BaselinePoolTimings::default();
1277 assert_eq!(timings.restore(), None);
1278 add_timing(&mut timings.restore, Duration::ZERO);
1279 assert_eq!(timings.restore(), Some(Duration::ZERO));
1280 add_timing(&mut timings.restore, Duration::from_secs(2));
1281 assert_eq!(timings.restore(), Some(Duration::from_secs(2)));
1282 add_timing(&mut timings.restore, Duration::MAX);
1283 assert_eq!(timings.restore(), Some(Duration::MAX));
1284 assert_eq!(timings.build(), None);
1285 }
1286
1287 #[test]
1288 fn recipe_identity_must_be_nonempty() {
1289 assert!(matches!(
1290 FixtureRecipeId::try_new(" "),
1291 Err(BaselinePoolContractError::EmptyRecipeIdentity)
1292 ));
1293 }
1294
1295 #[test]
1296 fn caller_restore_receipts_validate_and_order_canister_ids() {
1297 let first = Principal::from_slice(&[1]);
1298 let second = Principal::from_slice(&[2]);
1299 let policy = CycleResetPolicy::TopUpTo(123);
1300 let receipt = CanisterRestoreReceipt::try_new([second, first], policy).unwrap();
1301 assert_eq!(receipt.canister_ids(), [first, second]);
1302 assert_eq!(receipt.cycle_policy(), policy);
1303 assert_eq!(
1304 CanisterRestoreReceipt::try_new([], policy),
1305 Err(BaselinePoolContractError::EmptyCanisterSet),
1306 );
1307 assert_eq!(
1308 CanisterRestoreReceipt::try_new([second, first, second], policy),
1309 Err(BaselinePoolContractError::DuplicateCanisterId {
1310 canister_id: second,
1311 }),
1312 );
1313 }
1314
1315 #[test]
1316 fn reset_declarations_and_receipts_reject_duplicate_domains() {
1317 let policies = [
1318 ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent),
1319 ResetDomainPolicy::PocketIcTime(TimeResetPolicy::RebuildOnMutation),
1320 ];
1321 let error = BaselinePoolContractError::DuplicateResetDomain {
1322 domain: ResetDomainKind::PocketIcTime,
1323 };
1324 assert_eq!(
1325 ResetRequirements::try_new(CycleResetPolicy::PreserveCurrent, policies.clone()),
1326 Err(error.clone()),
1327 );
1328 assert_eq!(ResetReceipt::try_new(policies), Err(error));
1329 }
1330
1331 #[test]
1332 fn required_policy_must_match_achieved_policy() {
1333 let restore = CanisterRestoreReceipt::try_new(
1334 [Principal::anonymous()],
1335 CycleResetPolicy::PreserveCurrent,
1336 )
1337 .unwrap();
1338 for (requirement, achievement) in [
1339 (
1340 ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent),
1341 ResetDomainPolicy::PocketIcTime(TimeResetPolicy::RebuildOnMutation),
1342 ),
1343 (
1344 ResetDomainPolicy::ExtraCanisters(ExtraCanisterPolicy::RequireBaselineSet),
1345 ResetDomainPolicy::ExtraCanisters(ExtraCanisterPolicy::RemoveTracked),
1346 ),
1347 (
1348 ResetDomainPolicy::PendingMessages(StateResetPolicy::ValidateUnchanged),
1349 ResetDomainPolicy::PendingMessages(StateResetPolicy::IrrelevantByRecipeContract),
1350 ),
1351 (
1352 ResetDomainPolicy::SubnetState(StateResetPolicy::ResetByRecipe),
1353 ResetDomainPolicy::SubnetState(StateResetPolicy::RebuildOnChange),
1354 ),
1355 (
1356 ResetDomainPolicy::ExternalResources(StateResetPolicy::ValidateUnchanged),
1357 ResetDomainPolicy::ExternalResources(StateResetPolicy::ResetByRecipe),
1358 ),
1359 ] {
1360 let requirements = ResetRequirements::try_new(
1361 CycleResetPolicy::PreserveCurrent,
1362 [requirement.clone()],
1363 )
1364 .unwrap();
1365 let receipt = ResetReceipt::try_new([requirement.clone()]).unwrap();
1366 assert_eq!(requirements.verify(&restore, &receipt), Ok(()));
1367 assert_eq!(
1368 requirements.verify(&restore, &ResetReceipt::empty()),
1369 Err(BaselinePoolContractError::MissingResetDomain {
1370 domain: requirement.domain(),
1371 }),
1372 );
1373 let receipt = ResetReceipt::try_new([achievement.clone()]).unwrap();
1374 assert_eq!(
1375 requirements.verify(&restore, &receipt),
1376 Err(BaselinePoolContractError::ResetPolicyMismatch {
1377 requirement,
1378 achievement,
1379 }),
1380 );
1381 }
1382 }
1383
1384 #[test]
1385 fn restore_cycle_policy_must_match_required_policy() {
1386 let requirements =
1387 ResetRequirements::try_new(CycleResetPolicy::RestoreExactBaseline, []).unwrap();
1388 let restore = CanisterRestoreReceipt::try_new(
1389 [Principal::anonymous()],
1390 CycleResetPolicy::PreserveCurrent,
1391 )
1392 .unwrap();
1393 assert!(matches!(
1394 requirements.verify(&restore, &ResetReceipt::empty()),
1395 Err(BaselinePoolContractError::CyclePolicyMismatch {
1396 required: CycleResetPolicy::RestoreExactBaseline,
1397 achieved: CycleResetPolicy::PreserveCurrent,
1398 })
1399 ));
1400 }
1401}