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 let (rebuild_reason, original) = 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) => match self.failure_disposition(&original) {
869 FailureDisposition::Fatal => {
870 Self::discard_stale_slot(&mut slot, &mut timings);
873 timings.total = total_started.elapsed();
874 return Err(BaselinePoolError::Preparation {
875 error: original,
876 timings: Box::new(timings),
877 });
878 }
879 FailureDisposition::Rebuild(reason) => (Some(reason), Some(original)),
880 },
881 }
882 } else {
883 let reason = if slot.invalidated_by_unwind() {
884 Some(RebuildReason::UnwindWhileLeased)
885 } else {
886 slot.get()
887 .and_then(|slot| slot.invalidation_reason.clone())
888 .or_else(|| {
889 slot.is_populated()
890 .then_some(RebuildReason::ExplicitLeaseInvalidation)
891 })
892 };
893 (reason, None)
894 };
895
896 if slot.is_populated() {
897 Self::discard_stale_slot(&mut slot, &mut timings);
898 }
899 let build = self.build_slot(&mut slot, &mut timings);
900 timings.total = total_started.elapsed();
901 if let Err(error) = build {
902 return Err(match original {
903 Some(original) => BaselinePoolError::RecoveryFailed {
904 original: Box::new(original),
905 rebuild: Box::new(error),
906 timings: Box::new(timings),
907 },
908 None => BaselinePoolError::Preparation {
909 error,
910 timings: Box::new(timings),
911 },
912 });
913 }
914
915 let outcome = rebuild_reason.map_or_else(
916 || BaselinePoolOutcome::Built {
917 slot: slot_index,
918 timings,
919 },
920 |reason| BaselinePoolOutcome::Rebuilt {
921 slot: slot_index,
922 reason,
923 timings,
924 },
925 );
926 Ok((CachedPocketIcBaselinePoolGuard { slot }, outcome))
927 }
928
929 fn prepare_reused(
930 &self,
931 slot: &BoundedSlotLease<'_, BaselineSlot<R::Metadata>>,
932 timings: &mut BaselinePoolTimings,
933 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
934 let baseline = &slot
935 .get()
936 .expect("reusable baseline slot must be populated")
937 .baseline;
938
939 let started = Instant::now();
940 let restore = self.recipe.restore_canisters(baseline);
941 add_timing(&mut timings.restore, started.elapsed());
942 let canisters = restore.map_err(|source| BaselinePoolPreparationError::Recipe {
943 stage: BaselinePreparationStage::RestoreCanisters,
944 source,
945 })?;
946 if !baseline
947 .snapshot_canister_ids()
948 .eq(canisters.canister_ids().iter().copied())
949 {
950 return Err(BaselinePoolPreparationError::Contract(
951 BaselinePoolContractError::RestoreCanisterSetMismatch {
952 expected: baseline.snapshot_canister_ids().collect(),
953 actual: canisters.canister_ids().to_vec(),
954 },
955 ));
956 }
957
958 let started = Instant::now();
959 let reset_result = self.recipe.reset_non_snapshot_state(baseline);
960 add_timing(&mut timings.reset, started.elapsed());
961 let reset = reset_result.map_err(|source| BaselinePoolPreparationError::Recipe {
962 stage: BaselinePreparationStage::ResetNonSnapshotState,
963 source,
964 })?;
965
966 let started = Instant::now();
967 let readiness_result = self.recipe.drive_to_readiness(baseline);
968 add_timing(&mut timings.readiness, started.elapsed());
969 let readiness =
970 readiness_result.map_err(|source| BaselinePoolPreparationError::Recipe {
971 stage: BaselinePreparationStage::DriveToReadiness,
972 source,
973 })?;
974 self.recipe
975 .reset_requirements()
976 .verify(&canisters, &reset)
977 .map_err(BaselinePoolPreparationError::Contract)?;
978
979 let preparation = PreparedBaseline::Restored {
980 canisters,
981 reset,
982 readiness,
983 };
984 self.validate_baseline(
985 baseline,
986 &preparation,
987 BaselinePreparationStage::ValidateRestored,
988 timings,
989 )?;
990 Ok(())
991 }
992
993 fn build_slot(
994 &self,
995 slot: &mut BoundedSlotLease<'_, BaselineSlot<R::Metadata>>,
996 timings: &mut BaselinePoolTimings,
997 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
998 let started = Instant::now();
999 let build = self.recipe.build();
1000 add_timing(&mut timings.build, started.elapsed());
1001 let baseline = build.map_err(|source| BaselinePoolPreparationError::Recipe {
1002 stage: BaselinePreparationStage::Build,
1003 source,
1004 })?;
1005
1006 if let Err(error) = self.validate_baseline(
1007 &baseline,
1008 &PreparedBaseline::Built,
1009 BaselinePreparationStage::ValidateBuilt,
1010 timings,
1011 ) {
1012 drop_baseline_safely(baseline);
1013 return Err(error);
1014 }
1015 let replaced = slot.replace(BaselineSlot {
1016 baseline,
1017 invalidation_reason: None,
1018 });
1019 debug_assert!(replaced.is_none());
1020 Ok(())
1021 }
1022
1023 fn validate_baseline(
1024 &self,
1025 baseline: &CachedPocketIcBaseline<R::Metadata>,
1026 preparation: &PreparedBaseline,
1027 stage: BaselinePreparationStage,
1028 timings: &mut BaselinePoolTimings,
1029 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
1030 let started = Instant::now();
1031 let validation = self.recipe.validate(baseline, preparation);
1032 add_timing(&mut timings.validation, started.elapsed());
1033 let receipt =
1034 validation.map_err(|source| BaselinePoolPreparationError::Recipe { stage, source })?;
1035 if receipt.recipe_id() != self.recipe.id() {
1036 return Err(BaselinePoolPreparationError::Contract(
1037 BaselinePoolContractError::RecipeIdentityMismatch {
1038 expected: self.recipe.id().clone(),
1039 actual: receipt.recipe_id().clone(),
1040 },
1041 ));
1042 }
1043 Ok(())
1044 }
1045
1046 fn failure_disposition(
1047 &self,
1048 error: &BaselinePoolPreparationError<R::Error>,
1049 ) -> FailureDisposition {
1050 match error {
1051 BaselinePoolPreparationError::Recipe { stage, source } => {
1052 self.recipe.classify_failure(*stage, source)
1053 }
1054 BaselinePoolPreparationError::Contract(
1055 BaselinePoolContractError::RecipeIdentityMismatch { .. },
1056 ) => FailureDisposition::Fatal,
1057 BaselinePoolPreparationError::Contract(_) => {
1058 FailureDisposition::Rebuild(rebuild_reason_for_error(error))
1059 }
1060 }
1061 }
1062
1063 fn discard_stale_slot(
1064 slot: &mut BoundedSlotLease<'_, BaselineSlot<R::Metadata>>,
1065 timings: &mut BaselinePoolTimings,
1066 ) {
1067 let started = Instant::now();
1068 if let Some(stale) = slot.take() {
1069 drop_baseline_safely(stale.baseline);
1070 }
1071 timings.stale_teardown = Some(started.elapsed());
1072 }
1073}
1074
1075impl<R> CachedPocketIcBaselinePoolGuard<'_, R>
1076where
1077 R: PocketIcBaselineRecipe,
1078{
1079 #[must_use]
1081 pub const fn slot(&self) -> usize {
1082 self.slot.slot_index()
1083 }
1084
1085 pub fn invalidate(&mut self, reason: RebuildReason) {
1087 if let Some(slot) = self.slot.get_mut() {
1088 slot.invalidation_reason = Some(reason);
1089 }
1090 self.slot.invalidate();
1091 }
1092}
1093
1094impl<R> Deref for CachedPocketIcBaselinePoolGuard<'_, R>
1095where
1096 R: PocketIcBaselineRecipe,
1097{
1098 type Target = CachedPocketIcBaseline<R::Metadata>;
1099
1100 fn deref(&self) -> &Self::Target {
1101 &self
1102 .slot
1103 .get()
1104 .expect("leased baseline pool slot must be populated")
1105 .baseline
1106 }
1107}
1108
1109fn nonempty_receipt_identity(
1110 receipt: &'static str,
1111 identity: String,
1112) -> Result<String, BaselinePoolContractError> {
1113 if identity.trim().is_empty() {
1114 return Err(BaselinePoolContractError::EmptyReceiptIdentity { receipt });
1115 }
1116 Ok(identity)
1117}
1118
1119const fn add_timing(total: &mut Option<Duration>, elapsed: Duration) {
1120 *total = saturating_add_optional_duration(*total, Some(elapsed));
1121}
1122
1123fn rebuild_reason_for_error<E>(error: &BaselinePoolPreparationError<E>) -> RebuildReason {
1124 match error {
1125 BaselinePoolPreparationError::Recipe { stage, .. } => stage.default_rebuild_reason(),
1126 BaselinePoolPreparationError::Contract(
1127 BaselinePoolContractError::MissingResetDomain { .. }
1128 | BaselinePoolContractError::ResetPolicyMismatch { .. }
1129 | BaselinePoolContractError::CyclePolicyMismatch { .. }
1130 | BaselinePoolContractError::DuplicateResetDomain { .. }
1131 | BaselinePoolContractError::RestoreCanisterSetMismatch { .. },
1132 ) => RebuildReason::ResetCoverageMismatch,
1133 BaselinePoolPreparationError::Contract(_) => RebuildReason::InvariantValidationFailure,
1134 }
1135}
1136
1137fn drop_baseline_safely<M>(baseline: CachedPocketIcBaseline<M>) {
1138 let _ = catch_unwind(AssertUnwindSafe(|| drop(baseline)));
1139}
1140
1141impl std::fmt::Display for FixtureRecipeId {
1142 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1143 formatter.write_str(&self.0)
1144 }
1145}
1146
1147impl std::fmt::Display for BaselinePreparationStage {
1148 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1149 formatter.write_str(match self {
1150 Self::Build => "build",
1151 Self::RestoreCanisters => "canister restore",
1152 Self::ResetNonSnapshotState => "non-snapshot reset",
1153 Self::DriveToReadiness => "readiness",
1154 Self::ValidateBuilt => "built-baseline validation",
1155 Self::ValidateRestored => "restored-baseline validation",
1156 })
1157 }
1158}
1159
1160impl std::fmt::Display for BaselinePoolContractError {
1161 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1162 match self {
1163 Self::EmptyRecipeIdentity => formatter.write_str("fixture recipe identity is empty"),
1164 Self::EmptyReceiptIdentity { receipt } => {
1165 write!(formatter, "{receipt} receipt identity is empty")
1166 }
1167 Self::DuplicateResetDomain { domain } => {
1168 write!(
1169 formatter,
1170 "reset domain {domain:?} was reported more than once"
1171 )
1172 }
1173 Self::DuplicateCanisterId { canister_id } => {
1174 write!(formatter, "restore receipt repeats canister {canister_id}")
1175 }
1176 Self::EmptyCanisterSet => formatter.write_str("restore receipt contains no canisters"),
1177 Self::CyclePolicyMismatch { required, achieved } => write!(
1178 formatter,
1179 "restore cycle policy {achieved:?} does not satisfy {required:?}",
1180 ),
1181 Self::RestoreCanisterSetMismatch { expected, actual } => write!(
1182 formatter,
1183 "restore receipt identified canisters {actual:?}, expected captured set {expected:?}",
1184 ),
1185 Self::MissingResetDomain { domain } => {
1186 write!(
1187 formatter,
1188 "required reset domain {domain:?} was not achieved"
1189 )
1190 }
1191 Self::ResetPolicyMismatch {
1192 requirement,
1193 achievement,
1194 } => write!(
1195 formatter,
1196 "reset achievement {achievement:?} does not satisfy {requirement:?}",
1197 ),
1198 Self::RecipeIdentityMismatch { expected, actual } => write!(
1199 formatter,
1200 "validation receipt used recipe `{actual}` instead of `{expected}`",
1201 ),
1202 }
1203 }
1204}
1205
1206impl std::error::Error for BaselinePoolContractError {}
1207
1208impl<E> std::fmt::Display for BaselinePoolPreparationError<E>
1209where
1210 E: std::fmt::Display,
1211{
1212 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1213 match self {
1214 Self::Recipe { stage, source } => {
1215 write!(formatter, "baseline {stage} failed: {source}")
1216 }
1217 Self::Contract(error) => write!(formatter, "baseline pool contract failed: {error}"),
1218 }
1219 }
1220}
1221
1222impl<E> std::error::Error for BaselinePoolPreparationError<E>
1223where
1224 E: std::error::Error + 'static,
1225{
1226 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1227 match self {
1228 Self::Recipe { source, .. } => Some(source),
1229 Self::Contract(error) => Some(error),
1230 }
1231 }
1232}
1233
1234impl<E> std::fmt::Display for BaselinePoolError<E>
1235where
1236 E: std::fmt::Display,
1237{
1238 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1239 match self {
1240 Self::Preparation { error, .. } => error.fmt(formatter),
1241 Self::RecoveryFailed {
1242 original, rebuild, ..
1243 } => write!(
1244 formatter,
1245 "baseline preparation failed ({original}); rebuilding the slot also failed: {rebuild}",
1246 ),
1247 }
1248 }
1249}
1250
1251impl<E> std::error::Error for BaselinePoolError<E>
1252where
1253 E: std::error::Error + 'static,
1254{
1255 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1256 match self {
1257 Self::Preparation { error, .. } => Some(error),
1258 Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
1259 }
1260 }
1261}
1262
1263#[cfg(test)]
1264mod tests {
1265 use super::{
1266 BaselinePoolContractError, CanisterRestoreReceipt, CycleResetPolicy, ExtraCanisterPolicy,
1267 FixtureRecipeId, ResetDomainKind, ResetDomainPolicy, ResetReceipt, ResetRequirements,
1268 StateResetPolicy, TimeResetPolicy,
1269 };
1270 use candid::Principal;
1271
1272 #[test]
1273 fn recipe_identity_must_be_nonempty() {
1274 assert!(matches!(
1275 FixtureRecipeId::try_new(" "),
1276 Err(BaselinePoolContractError::EmptyRecipeIdentity)
1277 ));
1278 }
1279
1280 #[test]
1281 fn caller_restore_receipts_validate_and_order_canister_ids() {
1282 let first = Principal::from_slice(&[1]);
1283 let second = Principal::from_slice(&[2]);
1284 let policy = CycleResetPolicy::TopUpTo(123);
1285 let receipt = CanisterRestoreReceipt::try_new([second, first], policy).unwrap();
1286 assert_eq!(receipt.canister_ids(), [first, second]);
1287 assert_eq!(receipt.cycle_policy(), policy);
1288 assert_eq!(
1289 CanisterRestoreReceipt::try_new([], policy),
1290 Err(BaselinePoolContractError::EmptyCanisterSet),
1291 );
1292 assert_eq!(
1293 CanisterRestoreReceipt::try_new([second, first, second], policy),
1294 Err(BaselinePoolContractError::DuplicateCanisterId {
1295 canister_id: second,
1296 }),
1297 );
1298 }
1299
1300 #[test]
1301 fn reset_declarations_and_receipts_reject_duplicate_domains() {
1302 let policies = [
1303 ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent),
1304 ResetDomainPolicy::PocketIcTime(TimeResetPolicy::RebuildOnMutation),
1305 ];
1306 let error = BaselinePoolContractError::DuplicateResetDomain {
1307 domain: ResetDomainKind::PocketIcTime,
1308 };
1309 assert_eq!(
1310 ResetRequirements::try_new(CycleResetPolicy::PreserveCurrent, policies.clone()),
1311 Err(error.clone()),
1312 );
1313 assert_eq!(ResetReceipt::try_new(policies), Err(error));
1314 }
1315
1316 #[test]
1317 fn required_policy_must_match_achieved_policy() {
1318 let restore = CanisterRestoreReceipt::try_new(
1319 [Principal::anonymous()],
1320 CycleResetPolicy::PreserveCurrent,
1321 )
1322 .unwrap();
1323 for (requirement, achievement) in [
1324 (
1325 ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent),
1326 ResetDomainPolicy::PocketIcTime(TimeResetPolicy::RebuildOnMutation),
1327 ),
1328 (
1329 ResetDomainPolicy::ExtraCanisters(ExtraCanisterPolicy::RequireBaselineSet),
1330 ResetDomainPolicy::ExtraCanisters(ExtraCanisterPolicy::RemoveTracked),
1331 ),
1332 (
1333 ResetDomainPolicy::PendingMessages(StateResetPolicy::ValidateUnchanged),
1334 ResetDomainPolicy::PendingMessages(StateResetPolicy::IrrelevantByRecipeContract),
1335 ),
1336 (
1337 ResetDomainPolicy::SubnetState(StateResetPolicy::ResetByRecipe),
1338 ResetDomainPolicy::SubnetState(StateResetPolicy::RebuildOnChange),
1339 ),
1340 (
1341 ResetDomainPolicy::ExternalResources(StateResetPolicy::ValidateUnchanged),
1342 ResetDomainPolicy::ExternalResources(StateResetPolicy::ResetByRecipe),
1343 ),
1344 ] {
1345 let requirements = ResetRequirements::try_new(
1346 CycleResetPolicy::PreserveCurrent,
1347 [requirement.clone()],
1348 )
1349 .unwrap();
1350 let receipt = ResetReceipt::try_new([requirement.clone()]).unwrap();
1351 assert_eq!(requirements.verify(&restore, &receipt), Ok(()));
1352 assert_eq!(
1353 requirements.verify(&restore, &ResetReceipt::empty()),
1354 Err(BaselinePoolContractError::MissingResetDomain {
1355 domain: requirement.domain(),
1356 }),
1357 );
1358 let receipt = ResetReceipt::try_new([achievement.clone()]).unwrap();
1359 assert_eq!(
1360 requirements.verify(&restore, &receipt),
1361 Err(BaselinePoolContractError::ResetPolicyMismatch {
1362 requirement,
1363 achievement,
1364 }),
1365 );
1366 }
1367 }
1368
1369 #[test]
1370 fn restore_cycle_policy_must_match_required_policy() {
1371 let requirements =
1372 ResetRequirements::try_new(CycleResetPolicy::RestoreExactBaseline, []).unwrap();
1373 let restore = CanisterRestoreReceipt::try_new(
1374 [Principal::anonymous()],
1375 CycleResetPolicy::PreserveCurrent,
1376 )
1377 .unwrap();
1378 assert!(matches!(
1379 requirements.verify(&restore, &ResetReceipt::empty()),
1380 Err(BaselinePoolContractError::CyclePolicyMismatch {
1381 required: CycleResetPolicy::RestoreExactBaseline,
1382 achieved: CycleResetPolicy::PreserveCurrent,
1383 })
1384 ));
1385 }
1386}