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, ControllerSnapshots, SnapshotRestoreFunding,
12 bounded_pool::{BoundedSlotLease, BoundedSlotPool},
13};
14
15#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
17pub struct FixtureRecipeId(String);
18
19impl From<SnapshotRestoreFunding> for CycleResetPolicy {
20 fn from(funding: SnapshotRestoreFunding) -> Self {
21 match funding {
22 SnapshotRestoreFunding::Preserve => Self::PreserveCurrent,
23 SnapshotRestoreFunding::TopUpTo { minimum_cycles } => Self::TopUpTo(minimum_cycles),
24 }
25 }
26}
27
28#[non_exhaustive]
30#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
31pub enum ResetDomainKind {
32 PocketIcTime,
34 ExtraCanisters,
36 PendingMessages,
38 SubnetState,
40 ExternalResources,
42}
43
44#[non_exhaustive]
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum CycleResetPolicy {
48 PreserveCurrent,
52 TopUpTo(u128),
55 RestoreExactBaseline,
57 RebuildOnMutation,
59}
60
61#[non_exhaustive]
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum TimeResetPolicy {
65 PreserveCurrent,
67 RestoreBaseline,
69 RebuildOnMutation,
71}
72
73#[non_exhaustive]
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub enum ExtraCanisterPolicy {
77 RequireBaselineSet,
79 RemoveTracked,
81 RebuildOnChange,
83}
84
85#[non_exhaustive]
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub enum StateResetPolicy {
89 ResetByRecipe,
91 ValidateUnchanged,
93 IrrelevantByRecipeContract,
95 RebuildOnChange,
97}
98
99#[non_exhaustive]
126#[derive(Clone, Debug, Eq, PartialEq)]
127pub enum ResetDomainPolicy {
128 PocketIcTime(TimeResetPolicy),
130 ExtraCanisters(ExtraCanisterPolicy),
132 PendingMessages(StateResetPolicy),
134 SubnetState(StateResetPolicy),
136 ExternalResources(StateResetPolicy),
138}
139
140#[derive(Clone, Debug, Eq, PartialEq)]
142pub struct ResetRequirements {
143 cycle_policy: CycleResetPolicy,
144 domains: BTreeMap<ResetDomainKind, ResetDomainPolicy>,
145}
146
147#[derive(Clone, Debug, Default, Eq, PartialEq)]
149pub struct ResetReceipt(BTreeMap<ResetDomainKind, ResetDomainPolicy>);
150
151#[derive(Clone, Debug, Eq, PartialEq)]
153pub struct CanisterRestoreReceipt {
154 canister_ids: Vec<Principal>,
155 cycle_policy: CycleResetPolicy,
156}
157
158#[derive(Clone, Debug, Eq, PartialEq)]
160pub struct ReadinessReceipt {
161 identity: String,
162}
163
164#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct ValidationReceipt {
167 recipe_id: FixtureRecipeId,
168 invariant_identity: String,
169}
170
171#[non_exhaustive]
173#[derive(Clone, Debug, Eq, PartialEq)]
174pub enum BaselinePoolContractError {
175 EmptyRecipeIdentity,
177 EmptyReceiptIdentity { receipt: &'static str },
179 DuplicateResetDomain { domain: ResetDomainKind },
181 DuplicateCanisterId { canister_id: Principal },
183 EmptyCanisterSet,
185 CyclePolicyMismatch {
187 required: CycleResetPolicy,
188 achieved: CycleResetPolicy,
189 },
190 RestoreCanisterSetMismatch {
192 expected: Vec<Principal>,
193 actual: Vec<Principal>,
194 },
195 MissingResetDomain { domain: ResetDomainKind },
197 ResetPolicyMismatch {
199 requirement: ResetDomainPolicy,
200 achievement: ResetDomainPolicy,
201 },
202 RecipeIdentityMismatch {
204 expected: FixtureRecipeId,
205 actual: FixtureRecipeId,
206 },
207}
208
209#[non_exhaustive]
211#[derive(Clone, Debug, Eq, PartialEq)]
212pub enum PreparedBaseline {
213 Built,
215 Restored {
217 canisters: CanisterRestoreReceipt,
219 reset: ResetReceipt,
221 readiness: ReadinessReceipt,
223 },
224}
225
226#[non_exhaustive]
228#[derive(Clone, Copy, Debug, Eq, PartialEq)]
229pub enum BaselinePreparationStage {
230 Build,
232 RestoreCanisters,
234 ResetNonSnapshotState,
236 DriveToReadiness,
238 ValidateBuilt,
240 ValidateRestored,
242}
243
244#[non_exhaustive]
246#[derive(Clone, Debug, Eq, PartialEq)]
247pub enum RebuildReason {
248 DeadPocketIcTransport,
250 SnapshotRestoreFailure,
252 ResetFailure,
254 ReadinessFailure,
256 ResetCoverageMismatch,
258 InvariantValidationFailure,
260 ExplicitLeaseInvalidation,
262 UnwindWhileLeased,
264 RecipeClassified { code: String },
266}
267
268#[non_exhaustive]
270#[derive(Clone, Debug, Eq, PartialEq)]
271pub enum FailureDisposition {
272 Fatal,
274 Rebuild(RebuildReason),
276}
277
278#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
280pub struct BaselinePoolTimings {
281 wait: Duration,
282 build: Option<Duration>,
283 restore: Option<Duration>,
284 reset: Option<Duration>,
285 readiness: Option<Duration>,
286 validation: Option<Duration>,
287 stale_teardown: Option<Duration>,
288 total: Duration,
289}
290
291#[derive(Clone, Debug, Eq, PartialEq)]
293pub struct BaselinePoolOutcome {
294 pub slot: usize,
296 pub timings: BaselinePoolTimings,
298 pub disposition: BaselinePoolDisposition,
300}
301
302#[non_exhaustive]
304#[derive(Clone, Debug, Eq, PartialEq)]
305pub enum BaselinePoolDisposition {
306 Built,
308 Restored,
310 Rebuilt(RebuildReason),
312}
313
314#[non_exhaustive]
316#[derive(Debug)]
317pub enum BaselinePoolPreparationError<E> {
318 Recipe {
320 stage: BaselinePreparationStage,
322 source: E,
324 },
325 Contract(BaselinePoolContractError),
327}
328
329#[non_exhaustive]
331#[derive(Debug)]
332pub enum BaselinePoolError<E> {
333 Preparation {
335 error: BaselinePoolPreparationError<E>,
337 timings: Box<BaselinePoolTimings>,
339 },
340 RecoveryFailed {
342 original: Box<BaselinePoolPreparationError<E>>,
344 rebuild: Box<BaselinePoolPreparationError<E>>,
346 timings: Box<BaselinePoolTimings>,
348 },
349}
350
351pub trait PocketIcBaselineRecipe: Send + Sync + 'static {
353 type Metadata: Send + 'static;
355 type Error: std::error::Error + Send + Sync + 'static;
357
358 fn id(&self) -> &FixtureRecipeId;
360
361 fn reset_requirements(&self) -> &ResetRequirements;
363
364 fn build(&self) -> Result<CachedPocketIcBaseline<Self::Metadata>, Self::Error>;
366
367 fn restore_canisters(
369 &self,
370 baseline: &CachedPocketIcBaseline<Self::Metadata>,
371 ) -> Result<CanisterRestoreReceipt, Self::Error>;
372
373 fn reset_non_snapshot_state(
375 &self,
376 baseline: &CachedPocketIcBaseline<Self::Metadata>,
377 ) -> Result<ResetReceipt, Self::Error>;
378
379 fn drive_to_readiness(
381 &self,
382 baseline: &CachedPocketIcBaseline<Self::Metadata>,
383 ) -> Result<ReadinessReceipt, Self::Error>;
384
385 fn validate(
387 &self,
388 baseline: &CachedPocketIcBaseline<Self::Metadata>,
389 preparation: &PreparedBaseline,
390 ) -> Result<ValidationReceipt, Self::Error>;
391
392 fn classify_failure(
394 &self,
395 stage: BaselinePreparationStage,
396 _error: &Self::Error,
397 ) -> FailureDisposition {
398 FailureDisposition::Rebuild(stage.default_rebuild_reason())
399 }
400}
401
402pub struct CachedPocketIcBaselinePool<R>
414where
415 R: PocketIcBaselineRecipe,
416{
417 recipe: R,
418 slots: BoundedSlotPool<CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
419}
420
421pub struct CachedPocketIcBaselinePoolGuard<'a, R>
423where
424 R: PocketIcBaselineRecipe,
425{
426 slot: BoundedSlotLease<'a, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
427}
428
429impl FixtureRecipeId {
430 pub fn try_new(identity: impl Into<String>) -> Result<Self, BaselinePoolContractError> {
432 let identity = identity.into();
433 if identity.trim().is_empty() {
434 return Err(BaselinePoolContractError::EmptyRecipeIdentity);
435 }
436 Ok(Self(identity))
437 }
438
439 #[must_use]
441 pub fn as_str(&self) -> &str {
442 &self.0
443 }
444}
445
446impl ResetDomainPolicy {
447 #[must_use]
449 pub const fn domain(&self) -> ResetDomainKind {
450 match self {
451 Self::PocketIcTime(_) => ResetDomainKind::PocketIcTime,
452 Self::ExtraCanisters(_) => ResetDomainKind::ExtraCanisters,
453 Self::PendingMessages(_) => ResetDomainKind::PendingMessages,
454 Self::SubnetState(_) => ResetDomainKind::SubnetState,
455 Self::ExternalResources(_) => ResetDomainKind::ExternalResources,
456 }
457 }
458}
459
460fn collect_reset_domains(
461 policies: impl IntoIterator<Item = ResetDomainPolicy>,
462) -> Result<BTreeMap<ResetDomainKind, ResetDomainPolicy>, BaselinePoolContractError> {
463 let mut domains = BTreeMap::new();
464 for policy in policies {
465 let domain = policy.domain();
466 if domains.insert(domain, policy).is_some() {
467 return Err(BaselinePoolContractError::DuplicateResetDomain { domain });
468 }
469 }
470 Ok(domains)
471}
472
473impl ResetRequirements {
474 pub fn try_new<I>(
479 cycle_policy: CycleResetPolicy,
480 requirements: I,
481 ) -> Result<Self, BaselinePoolContractError>
482 where
483 I: IntoIterator<Item = ResetDomainPolicy>,
484 {
485 Ok(Self {
486 cycle_policy,
487 domains: collect_reset_domains(requirements)?,
488 })
489 }
490
491 #[must_use]
493 pub const fn cycle_policy(&self) -> CycleResetPolicy {
494 self.cycle_policy
495 }
496
497 #[must_use]
499 pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetDomainPolicy> {
500 self.domains.get(&domain)
501 }
502
503 pub fn iter(&self) -> impl Iterator<Item = &ResetDomainPolicy> {
505 self.domains.values()
506 }
507
508 fn verify(
509 &self,
510 restore: &CanisterRestoreReceipt,
511 receipt: &ResetReceipt,
512 ) -> Result<(), BaselinePoolContractError> {
513 if restore.cycle_policy != self.cycle_policy {
514 return Err(BaselinePoolContractError::CyclePolicyMismatch {
515 required: self.cycle_policy,
516 achieved: restore.cycle_policy,
517 });
518 }
519 for (domain, requirement) in &self.domains {
520 let Some(achievement) = receipt.0.get(domain) else {
521 return Err(BaselinePoolContractError::MissingResetDomain { domain: *domain });
522 };
523 if achievement != requirement {
524 return Err(BaselinePoolContractError::ResetPolicyMismatch {
525 requirement: requirement.clone(),
526 achievement: achievement.clone(),
527 });
528 }
529 }
530 Ok(())
531 }
532}
533
534impl ResetReceipt {
535 pub fn try_new<I>(achievements: I) -> Result<Self, BaselinePoolContractError>
540 where
541 I: IntoIterator<Item = ResetDomainPolicy>,
542 {
543 Ok(Self(collect_reset_domains(achievements)?))
544 }
545
546 #[must_use]
548 pub const fn empty() -> Self {
549 Self(BTreeMap::new())
550 }
551
552 #[must_use]
554 pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetDomainPolicy> {
555 self.0.get(&domain)
556 }
557
558 pub fn iter(&self) -> impl Iterator<Item = &ResetDomainPolicy> {
560 self.0.values()
561 }
562}
563
564impl CanisterRestoreReceipt {
565 pub fn try_new<I>(
567 canister_ids: I,
568 cycle_policy: CycleResetPolicy,
569 ) -> Result<Self, BaselinePoolContractError>
570 where
571 I: IntoIterator<Item = Principal>,
572 {
573 let mut unique = BTreeSet::new();
574 for canister_id in canister_ids {
575 if !unique.insert(canister_id) {
576 return Err(BaselinePoolContractError::DuplicateCanisterId { canister_id });
577 }
578 }
579 if unique.is_empty() {
580 return Err(BaselinePoolContractError::EmptyCanisterSet);
581 }
582 Ok(Self {
583 canister_ids: unique.into_iter().collect(),
584 cycle_policy,
585 })
586 }
587
588 pub(super) fn from_restore(
591 snapshots: &ControllerSnapshots,
592 funding: SnapshotRestoreFunding,
593 ) -> Self {
594 Self {
595 canister_ids: snapshots.canister_ids().collect(),
596 cycle_policy: funding.into(),
597 }
598 }
599
600 #[must_use]
602 pub fn canister_ids(&self) -> &[Principal] {
603 &self.canister_ids
604 }
605
606 #[must_use]
608 pub const fn cycle_policy(&self) -> CycleResetPolicy {
609 self.cycle_policy
610 }
611}
612
613impl ReadinessReceipt {
614 pub fn try_new(identity: impl Into<String>) -> Result<Self, BaselinePoolContractError> {
616 Ok(Self {
617 identity: nonempty_receipt_identity("readiness", identity.into())?,
618 })
619 }
620
621 #[must_use]
623 pub fn identity(&self) -> &str {
624 &self.identity
625 }
626}
627
628impl ValidationReceipt {
629 pub fn try_new(
631 recipe_id: FixtureRecipeId,
632 invariant_identity: impl Into<String>,
633 ) -> Result<Self, BaselinePoolContractError> {
634 Ok(Self {
635 recipe_id,
636 invariant_identity: nonempty_receipt_identity("validation", invariant_identity.into())?,
637 })
638 }
639
640 #[must_use]
642 pub const fn recipe_id(&self) -> &FixtureRecipeId {
643 &self.recipe_id
644 }
645
646 #[must_use]
648 pub fn invariant_identity(&self) -> &str {
649 &self.invariant_identity
650 }
651}
652
653impl BaselinePreparationStage {
654 #[must_use]
659 pub fn default_rebuild_reason(self) -> RebuildReason {
660 match self {
661 Self::RestoreCanisters => RebuildReason::SnapshotRestoreFailure,
662 Self::ResetNonSnapshotState => RebuildReason::ResetFailure,
663 Self::DriveToReadiness => RebuildReason::ReadinessFailure,
664 Self::ValidateRestored | Self::ValidateBuilt => {
665 RebuildReason::InvariantValidationFailure
666 }
667 Self::Build => RebuildReason::RecipeClassified {
668 code: "build".to_owned(),
669 },
670 }
671 }
672}
673
674impl BaselinePoolTimings {
675 #[must_use]
677 pub const fn wait(self) -> Duration {
678 self.wait
679 }
680
681 #[must_use]
683 pub const fn build(self) -> Option<Duration> {
684 self.build
685 }
686
687 #[must_use]
689 pub const fn restore(self) -> Option<Duration> {
690 self.restore
691 }
692
693 #[must_use]
695 pub const fn reset(self) -> Option<Duration> {
696 self.reset
697 }
698
699 #[must_use]
701 pub const fn readiness(self) -> Option<Duration> {
702 self.readiness
703 }
704
705 #[must_use]
707 pub const fn validation(self) -> Option<Duration> {
708 self.validation
709 }
710
711 #[must_use]
713 pub const fn stale_teardown(self) -> Option<Duration> {
714 self.stale_teardown
715 }
716
717 #[must_use]
719 pub const fn total(self) -> Duration {
720 self.total
721 }
722}
723
724impl std::fmt::Display for BaselinePoolTimings {
725 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
726 write!(
727 formatter,
728 "total={:?} wait={:?} build={:?} restore={:?} reset={:?} readiness={:?} validation={:?} stale_teardown={:?}",
729 self.total,
730 self.wait,
731 self.build,
732 self.restore,
733 self.reset,
734 self.readiness,
735 self.validation,
736 self.stale_teardown,
737 )
738 }
739}
740
741impl std::fmt::Display for BaselinePoolOutcome {
742 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
743 match &self.disposition {
744 BaselinePoolDisposition::Built => formatter.write_str("built")?,
745 BaselinePoolDisposition::Restored => formatter.write_str("restored")?,
746 BaselinePoolDisposition::Rebuilt(reason) => {
747 write!(
748 formatter,
749 "rebuilt slot={} reason={reason:?} {}",
750 self.slot, self.timings
751 )?;
752 return Ok(());
753 }
754 }
755 write!(formatter, " slot={} {}", self.slot, self.timings)
756 }
757}
758
759impl<E> BaselinePoolError<E> {
760 #[must_use]
762 pub const fn timings(&self) -> BaselinePoolTimings {
763 match self {
764 Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
765 }
766 }
767}
768
769impl<R> CachedPocketIcBaselinePool<R>
770where
771 R: PocketIcBaselineRecipe,
772{
773 #[must_use]
778 pub const fn new(capacity: NonZeroUsize, recipe: R) -> Self {
779 Self {
780 recipe,
781 slots: BoundedSlotPool::new(capacity),
782 }
783 }
784
785 #[must_use]
787 pub fn recipe_id(&self) -> &FixtureRecipeId {
788 self.recipe.id()
789 }
790
791 #[must_use]
793 pub const fn capacity(&self) -> NonZeroUsize {
794 self.slots.capacity()
795 }
796
797 pub fn acquire(
809 &self,
810 ) -> Result<
811 (CachedPocketIcBaselinePoolGuard<'_, R>, BaselinePoolOutcome),
812 BaselinePoolError<R::Error>,
813 > {
814 let total_started = Instant::now();
815 let mut slot = self.slots.acquire();
816 let mut timings = BaselinePoolTimings {
817 wait: slot.wait(),
818 ..BaselinePoolTimings::default()
819 };
820 let slot_index = slot.slot_index();
821
822 let (rebuild_reason, original) = if slot.is_reusable() {
823 match self.prepare_reused(&slot, &mut timings) {
824 Ok(()) => {
825 timings.total = total_started.elapsed();
826 return Ok((
827 CachedPocketIcBaselinePoolGuard { slot },
828 BaselinePoolOutcome {
829 slot: slot_index,
830 timings,
831 disposition: BaselinePoolDisposition::Restored,
832 },
833 ));
834 }
835 Err(original) => match self.failure_disposition(&original) {
836 FailureDisposition::Fatal => {
837 Self::discard_stale_slot(&mut slot, &mut timings);
840 timings.total = total_started.elapsed();
841 return Err(BaselinePoolError::Preparation {
842 error: original,
843 timings: Box::new(timings),
844 });
845 }
846 FailureDisposition::Rebuild(reason) => (Some(reason), Some(original)),
847 },
848 }
849 } else {
850 let reason = if slot.invalidated_by_unwind() {
851 Some(RebuildReason::UnwindWhileLeased)
852 } else {
853 slot.invalidation_reason().cloned()
854 };
855 (reason, None)
856 };
857
858 if slot.is_populated() {
859 Self::discard_stale_slot(&mut slot, &mut timings);
860 }
861 let build = self.build_slot(&mut slot, &mut timings);
862 timings.total = total_started.elapsed();
863 if let Err(error) = build {
864 return Err(match original {
865 Some(original) => BaselinePoolError::RecoveryFailed {
866 original: Box::new(original),
867 rebuild: Box::new(error),
868 timings: Box::new(timings),
869 },
870 None => BaselinePoolError::Preparation {
871 error,
872 timings: Box::new(timings),
873 },
874 });
875 }
876
877 let outcome = BaselinePoolOutcome {
878 slot: slot_index,
879 timings,
880 disposition: rebuild_reason.map_or(
881 BaselinePoolDisposition::Built,
882 BaselinePoolDisposition::Rebuilt,
883 ),
884 };
885 Ok((CachedPocketIcBaselinePoolGuard { slot }, outcome))
886 }
887
888 fn prepare_reused(
889 &self,
890 slot: &BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
891 timings: &mut BaselinePoolTimings,
892 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
893 let baseline = slot
894 .get()
895 .expect("reusable baseline slot must be populated");
896
897 let started = Instant::now();
898 let restore = self.recipe.restore_canisters(baseline);
899 add_timing(&mut timings.restore, started.elapsed());
900 let canisters = restore.map_err(|source| BaselinePoolPreparationError::Recipe {
901 stage: BaselinePreparationStage::RestoreCanisters,
902 source,
903 })?;
904 if !baseline
905 .snapshot_canister_ids()
906 .eq(canisters.canister_ids().iter().copied())
907 {
908 return Err(BaselinePoolPreparationError::Contract(
909 BaselinePoolContractError::RestoreCanisterSetMismatch {
910 expected: baseline.snapshot_canister_ids().collect(),
911 actual: canisters.canister_ids().to_vec(),
912 },
913 ));
914 }
915
916 let started = Instant::now();
917 let reset_result = self.recipe.reset_non_snapshot_state(baseline);
918 add_timing(&mut timings.reset, started.elapsed());
919 let reset = reset_result.map_err(|source| BaselinePoolPreparationError::Recipe {
920 stage: BaselinePreparationStage::ResetNonSnapshotState,
921 source,
922 })?;
923
924 let started = Instant::now();
925 let readiness_result = self.recipe.drive_to_readiness(baseline);
926 add_timing(&mut timings.readiness, started.elapsed());
927 let readiness =
928 readiness_result.map_err(|source| BaselinePoolPreparationError::Recipe {
929 stage: BaselinePreparationStage::DriveToReadiness,
930 source,
931 })?;
932 self.recipe
933 .reset_requirements()
934 .verify(&canisters, &reset)
935 .map_err(BaselinePoolPreparationError::Contract)?;
936
937 let preparation = PreparedBaseline::Restored {
938 canisters,
939 reset,
940 readiness,
941 };
942 self.validate_baseline(
943 baseline,
944 &preparation,
945 BaselinePreparationStage::ValidateRestored,
946 timings,
947 )?;
948 Ok(())
949 }
950
951 fn build_slot(
952 &self,
953 slot: &mut BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
954 timings: &mut BaselinePoolTimings,
955 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
956 let started = Instant::now();
957 let build = self.recipe.build();
958 add_timing(&mut timings.build, started.elapsed());
959 let baseline = build.map_err(|source| BaselinePoolPreparationError::Recipe {
960 stage: BaselinePreparationStage::Build,
961 source,
962 })?;
963
964 if let Err(error) = self.validate_baseline(
965 &baseline,
966 &PreparedBaseline::Built,
967 BaselinePreparationStage::ValidateBuilt,
968 timings,
969 ) {
970 drop_baseline_safely(baseline);
971 return Err(error);
972 }
973 let replaced = slot.replace(baseline);
974 debug_assert!(replaced.is_none());
975 Ok(())
976 }
977
978 fn validate_baseline(
979 &self,
980 baseline: &CachedPocketIcBaseline<R::Metadata>,
981 preparation: &PreparedBaseline,
982 stage: BaselinePreparationStage,
983 timings: &mut BaselinePoolTimings,
984 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
985 let started = Instant::now();
986 let validation = self.recipe.validate(baseline, preparation);
987 add_timing(&mut timings.validation, started.elapsed());
988 let receipt =
989 validation.map_err(|source| BaselinePoolPreparationError::Recipe { stage, source })?;
990 if receipt.recipe_id() != self.recipe.id() {
991 return Err(BaselinePoolPreparationError::Contract(
992 BaselinePoolContractError::RecipeIdentityMismatch {
993 expected: self.recipe.id().clone(),
994 actual: receipt.recipe_id().clone(),
995 },
996 ));
997 }
998 Ok(())
999 }
1000
1001 fn failure_disposition(
1002 &self,
1003 error: &BaselinePoolPreparationError<R::Error>,
1004 ) -> FailureDisposition {
1005 match error {
1006 BaselinePoolPreparationError::Recipe { stage, source } => {
1007 self.recipe.classify_failure(*stage, source)
1008 }
1009 BaselinePoolPreparationError::Contract(
1010 BaselinePoolContractError::RecipeIdentityMismatch { .. },
1011 ) => FailureDisposition::Fatal,
1012 BaselinePoolPreparationError::Contract(_) => {
1013 FailureDisposition::Rebuild(rebuild_reason_for_error(error))
1014 }
1015 }
1016 }
1017
1018 fn discard_stale_slot(
1019 slot: &mut BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
1020 timings: &mut BaselinePoolTimings,
1021 ) {
1022 let started = Instant::now();
1023 if let Some(stale) = slot.take() {
1024 drop_baseline_safely(stale);
1025 }
1026 timings.stale_teardown = Some(started.elapsed());
1027 }
1028}
1029
1030impl<R> CachedPocketIcBaselinePoolGuard<'_, R>
1031where
1032 R: PocketIcBaselineRecipe,
1033{
1034 #[must_use]
1036 pub const fn slot(&self) -> usize {
1037 self.slot.slot_index()
1038 }
1039
1040 pub fn invalidate(&mut self, reason: RebuildReason) {
1042 self.slot.invalidate(reason);
1043 }
1044}
1045
1046impl<R> Deref for CachedPocketIcBaselinePoolGuard<'_, R>
1047where
1048 R: PocketIcBaselineRecipe,
1049{
1050 type Target = CachedPocketIcBaseline<R::Metadata>;
1051
1052 fn deref(&self) -> &Self::Target {
1053 self.slot
1054 .get()
1055 .expect("leased baseline pool slot must be populated")
1056 }
1057}
1058
1059fn nonempty_receipt_identity(
1060 receipt: &'static str,
1061 identity: String,
1062) -> Result<String, BaselinePoolContractError> {
1063 if identity.trim().is_empty() {
1064 return Err(BaselinePoolContractError::EmptyReceiptIdentity { receipt });
1065 }
1066 Ok(identity)
1067}
1068
1069const fn add_timing(total: &mut Option<Duration>, elapsed: Duration) {
1070 *total = Some(match *total {
1071 None => elapsed,
1072 Some(previous) => previous.saturating_add(elapsed),
1073 });
1074}
1075
1076fn rebuild_reason_for_error<E>(error: &BaselinePoolPreparationError<E>) -> RebuildReason {
1077 match error {
1078 BaselinePoolPreparationError::Recipe { stage, .. } => stage.default_rebuild_reason(),
1079 BaselinePoolPreparationError::Contract(
1080 BaselinePoolContractError::MissingResetDomain { .. }
1081 | BaselinePoolContractError::ResetPolicyMismatch { .. }
1082 | BaselinePoolContractError::CyclePolicyMismatch { .. }
1083 | BaselinePoolContractError::DuplicateResetDomain { .. }
1084 | BaselinePoolContractError::RestoreCanisterSetMismatch { .. },
1085 ) => RebuildReason::ResetCoverageMismatch,
1086 BaselinePoolPreparationError::Contract(_) => RebuildReason::InvariantValidationFailure,
1087 }
1088}
1089
1090fn drop_baseline_safely<M>(baseline: CachedPocketIcBaseline<M>) {
1091 let _ = catch_unwind(AssertUnwindSafe(|| drop(baseline)));
1092}
1093
1094impl std::fmt::Display for FixtureRecipeId {
1095 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1096 formatter.write_str(&self.0)
1097 }
1098}
1099
1100impl std::fmt::Display for BaselinePreparationStage {
1101 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1102 formatter.write_str(match self {
1103 Self::Build => "build",
1104 Self::RestoreCanisters => "canister restore",
1105 Self::ResetNonSnapshotState => "non-snapshot reset",
1106 Self::DriveToReadiness => "readiness",
1107 Self::ValidateBuilt => "built-baseline validation",
1108 Self::ValidateRestored => "restored-baseline validation",
1109 })
1110 }
1111}
1112
1113impl std::fmt::Display for BaselinePoolContractError {
1114 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1115 match self {
1116 Self::EmptyRecipeIdentity => formatter.write_str("fixture recipe identity is empty"),
1117 Self::EmptyReceiptIdentity { receipt } => {
1118 write!(formatter, "{receipt} receipt identity is empty")
1119 }
1120 Self::DuplicateResetDomain { domain } => {
1121 write!(
1122 formatter,
1123 "reset domain {domain:?} was reported more than once"
1124 )
1125 }
1126 Self::DuplicateCanisterId { canister_id } => {
1127 write!(formatter, "restore receipt repeats canister {canister_id}")
1128 }
1129 Self::EmptyCanisterSet => formatter.write_str("restore receipt contains no canisters"),
1130 Self::CyclePolicyMismatch { required, achieved } => write!(
1131 formatter,
1132 "restore cycle policy {achieved:?} does not satisfy {required:?}",
1133 ),
1134 Self::RestoreCanisterSetMismatch { expected, actual } => write!(
1135 formatter,
1136 "restore receipt identified canisters {actual:?}, expected captured set {expected:?}",
1137 ),
1138 Self::MissingResetDomain { domain } => {
1139 write!(
1140 formatter,
1141 "required reset domain {domain:?} was not achieved"
1142 )
1143 }
1144 Self::ResetPolicyMismatch {
1145 requirement,
1146 achievement,
1147 } => write!(
1148 formatter,
1149 "reset achievement {achievement:?} does not satisfy {requirement:?}",
1150 ),
1151 Self::RecipeIdentityMismatch { expected, actual } => write!(
1152 formatter,
1153 "validation receipt used recipe `{actual}` instead of `{expected}`",
1154 ),
1155 }
1156 }
1157}
1158
1159impl std::error::Error for BaselinePoolContractError {}
1160
1161impl<E> std::fmt::Display for BaselinePoolPreparationError<E>
1162where
1163 E: std::fmt::Display,
1164{
1165 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1166 match self {
1167 Self::Recipe { stage, source } => {
1168 write!(formatter, "baseline {stage} failed: {source}")
1169 }
1170 Self::Contract(error) => write!(formatter, "baseline pool contract failed: {error}"),
1171 }
1172 }
1173}
1174
1175impl<E> std::error::Error for BaselinePoolPreparationError<E>
1176where
1177 E: std::error::Error + 'static,
1178{
1179 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1180 match self {
1181 Self::Recipe { source, .. } => Some(source),
1182 Self::Contract(error) => Some(error),
1183 }
1184 }
1185}
1186
1187impl<E> std::fmt::Display for BaselinePoolError<E>
1188where
1189 E: std::fmt::Display,
1190{
1191 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1192 match self {
1193 Self::Preparation { error, .. } => error.fmt(formatter),
1194 Self::RecoveryFailed {
1195 original, rebuild, ..
1196 } => write!(
1197 formatter,
1198 "baseline preparation failed ({original}); rebuilding the slot also failed: {rebuild}",
1199 ),
1200 }
1201 }
1202}
1203
1204impl<E> std::error::Error for BaselinePoolError<E>
1205where
1206 E: std::error::Error + 'static,
1207{
1208 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1209 match self {
1210 Self::Preparation { error, .. } => Some(error),
1211 Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
1212 }
1213 }
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218 use super::{
1219 BaselinePoolContractError, BaselinePoolTimings, CanisterRestoreReceipt, CycleResetPolicy,
1220 ExtraCanisterPolicy, FixtureRecipeId, ResetDomainKind, ResetDomainPolicy, ResetReceipt,
1221 ResetRequirements, StateResetPolicy, TimeResetPolicy, add_timing,
1222 };
1223 use candid::Principal;
1224 use std::time::Duration;
1225
1226 #[test]
1227 fn phase_timing_preserves_absence_zero_and_saturation() {
1228 let mut timings = BaselinePoolTimings::default();
1229 assert_eq!(timings.restore(), None);
1230 add_timing(&mut timings.restore, Duration::ZERO);
1231 assert_eq!(timings.restore(), Some(Duration::ZERO));
1232 add_timing(&mut timings.restore, Duration::from_secs(2));
1233 assert_eq!(timings.restore(), Some(Duration::from_secs(2)));
1234 add_timing(&mut timings.restore, Duration::MAX);
1235 assert_eq!(timings.restore(), Some(Duration::MAX));
1236 assert_eq!(timings.build(), None);
1237 }
1238
1239 #[test]
1240 fn recipe_identity_must_be_nonempty() {
1241 assert!(matches!(
1242 FixtureRecipeId::try_new(" "),
1243 Err(BaselinePoolContractError::EmptyRecipeIdentity)
1244 ));
1245 }
1246
1247 #[test]
1248 fn caller_restore_receipts_validate_and_order_canister_ids() {
1249 let first = Principal::from_slice(&[1]);
1250 let second = Principal::from_slice(&[2]);
1251 let policy = CycleResetPolicy::TopUpTo(123);
1252 let receipt = CanisterRestoreReceipt::try_new([second, first], policy).unwrap();
1253 assert_eq!(receipt.canister_ids(), [first, second]);
1254 assert_eq!(receipt.cycle_policy(), policy);
1255 assert_eq!(
1256 CanisterRestoreReceipt::try_new([], policy),
1257 Err(BaselinePoolContractError::EmptyCanisterSet),
1258 );
1259 assert_eq!(
1260 CanisterRestoreReceipt::try_new([second, first, second], policy),
1261 Err(BaselinePoolContractError::DuplicateCanisterId {
1262 canister_id: second,
1263 }),
1264 );
1265 }
1266
1267 #[test]
1268 fn reset_declarations_and_receipts_reject_duplicate_domains() {
1269 let policies = [
1270 ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent),
1271 ResetDomainPolicy::PocketIcTime(TimeResetPolicy::RebuildOnMutation),
1272 ];
1273 let error = BaselinePoolContractError::DuplicateResetDomain {
1274 domain: ResetDomainKind::PocketIcTime,
1275 };
1276 assert_eq!(
1277 ResetRequirements::try_new(CycleResetPolicy::PreserveCurrent, policies.clone()),
1278 Err(error.clone()),
1279 );
1280 assert_eq!(ResetReceipt::try_new(policies), Err(error));
1281 }
1282
1283 #[test]
1284 fn required_policy_must_match_achieved_policy() {
1285 let restore = CanisterRestoreReceipt::try_new(
1286 [Principal::anonymous()],
1287 CycleResetPolicy::PreserveCurrent,
1288 )
1289 .unwrap();
1290 for (requirement, achievement) in [
1291 (
1292 ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent),
1293 ResetDomainPolicy::PocketIcTime(TimeResetPolicy::RebuildOnMutation),
1294 ),
1295 (
1296 ResetDomainPolicy::ExtraCanisters(ExtraCanisterPolicy::RequireBaselineSet),
1297 ResetDomainPolicy::ExtraCanisters(ExtraCanisterPolicy::RemoveTracked),
1298 ),
1299 (
1300 ResetDomainPolicy::PendingMessages(StateResetPolicy::ValidateUnchanged),
1301 ResetDomainPolicy::PendingMessages(StateResetPolicy::IrrelevantByRecipeContract),
1302 ),
1303 (
1304 ResetDomainPolicy::SubnetState(StateResetPolicy::ResetByRecipe),
1305 ResetDomainPolicy::SubnetState(StateResetPolicy::RebuildOnChange),
1306 ),
1307 (
1308 ResetDomainPolicy::ExternalResources(StateResetPolicy::ValidateUnchanged),
1309 ResetDomainPolicy::ExternalResources(StateResetPolicy::ResetByRecipe),
1310 ),
1311 ] {
1312 let requirements = ResetRequirements::try_new(
1313 CycleResetPolicy::PreserveCurrent,
1314 [requirement.clone()],
1315 )
1316 .unwrap();
1317 let receipt = ResetReceipt::try_new([requirement.clone()]).unwrap();
1318 assert_eq!(requirements.verify(&restore, &receipt), Ok(()));
1319 assert_eq!(
1320 requirements.verify(&restore, &ResetReceipt::empty()),
1321 Err(BaselinePoolContractError::MissingResetDomain {
1322 domain: requirement.domain(),
1323 }),
1324 );
1325 let receipt = ResetReceipt::try_new([achievement.clone()]).unwrap();
1326 assert_eq!(
1327 requirements.verify(&restore, &receipt),
1328 Err(BaselinePoolContractError::ResetPolicyMismatch {
1329 requirement,
1330 achievement,
1331 }),
1332 );
1333 }
1334 }
1335
1336 #[test]
1337 fn restore_cycle_policy_must_match_required_policy() {
1338 let requirements =
1339 ResetRequirements::try_new(CycleResetPolicy::RestoreExactBaseline, []).unwrap();
1340 let restore = CanisterRestoreReceipt::try_new(
1341 [Principal::anonymous()],
1342 CycleResetPolicy::PreserveCurrent,
1343 )
1344 .unwrap();
1345 assert!(matches!(
1346 requirements.verify(&restore, &ResetReceipt::empty()),
1347 Err(BaselinePoolContractError::CyclePolicyMismatch {
1348 required: CycleResetPolicy::RestoreExactBaseline,
1349 achieved: CycleResetPolicy::PreserveCurrent,
1350 })
1351 ));
1352 }
1353}