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