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#[non_exhaustive]
293#[derive(Clone, Debug, Eq, PartialEq)]
294pub enum BaselinePoolOutcome {
295 Built {
297 slot: usize,
299 timings: BaselinePoolTimings,
301 },
302 Restored {
304 slot: usize,
306 timings: BaselinePoolTimings,
308 },
309 Rebuilt {
311 slot: usize,
313 reason: RebuildReason,
315 timings: BaselinePoolTimings,
317 },
318}
319
320#[non_exhaustive]
322#[derive(Debug)]
323pub enum BaselinePoolPreparationError<E> {
324 Recipe {
326 stage: BaselinePreparationStage,
328 source: E,
330 },
331 Contract(BaselinePoolContractError),
333}
334
335#[non_exhaustive]
337#[derive(Debug)]
338pub enum BaselinePoolError<E> {
339 Preparation {
341 error: BaselinePoolPreparationError<E>,
343 timings: Box<BaselinePoolTimings>,
345 },
346 RecoveryFailed {
348 original: Box<BaselinePoolPreparationError<E>>,
350 rebuild: Box<BaselinePoolPreparationError<E>>,
352 timings: Box<BaselinePoolTimings>,
354 },
355}
356
357pub trait PocketIcBaselineRecipe: Send + Sync + 'static {
359 type Metadata: Send + 'static;
361 type Error: std::error::Error + Send + Sync + 'static;
363
364 fn id(&self) -> &FixtureRecipeId;
366
367 fn reset_requirements(&self) -> &ResetRequirements;
369
370 fn build(&self) -> Result<CachedPocketIcBaseline<Self::Metadata>, Self::Error>;
372
373 fn restore_canisters(
375 &self,
376 baseline: &CachedPocketIcBaseline<Self::Metadata>,
377 ) -> Result<CanisterRestoreReceipt, Self::Error>;
378
379 fn reset_non_snapshot_state(
381 &self,
382 baseline: &CachedPocketIcBaseline<Self::Metadata>,
383 ) -> Result<ResetReceipt, Self::Error>;
384
385 fn drive_to_readiness(
387 &self,
388 baseline: &CachedPocketIcBaseline<Self::Metadata>,
389 ) -> Result<ReadinessReceipt, Self::Error>;
390
391 fn validate(
393 &self,
394 baseline: &CachedPocketIcBaseline<Self::Metadata>,
395 preparation: &PreparedBaseline,
396 ) -> Result<ValidationReceipt, Self::Error>;
397
398 fn classify_failure(
400 &self,
401 stage: BaselinePreparationStage,
402 _error: &Self::Error,
403 ) -> FailureDisposition {
404 FailureDisposition::Rebuild(stage.default_rebuild_reason())
405 }
406}
407
408pub struct CachedPocketIcBaselinePool<R>
420where
421 R: PocketIcBaselineRecipe,
422{
423 recipe: R,
424 slots: BoundedSlotPool<CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
425}
426
427pub struct CachedPocketIcBaselinePoolGuard<'a, R>
429where
430 R: PocketIcBaselineRecipe,
431{
432 slot: BoundedSlotLease<'a, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
433}
434
435impl FixtureRecipeId {
436 pub fn try_new(identity: impl Into<String>) -> Result<Self, BaselinePoolContractError> {
438 let identity = identity.into();
439 if identity.trim().is_empty() {
440 return Err(BaselinePoolContractError::EmptyRecipeIdentity);
441 }
442 Ok(Self(identity))
443 }
444
445 #[must_use]
447 pub fn as_str(&self) -> &str {
448 &self.0
449 }
450}
451
452impl ResetDomainPolicy {
453 #[must_use]
455 pub const fn domain(&self) -> ResetDomainKind {
456 match self {
457 Self::PocketIcTime(_) => ResetDomainKind::PocketIcTime,
458 Self::ExtraCanisters(_) => ResetDomainKind::ExtraCanisters,
459 Self::PendingMessages(_) => ResetDomainKind::PendingMessages,
460 Self::SubnetState(_) => ResetDomainKind::SubnetState,
461 Self::ExternalResources(_) => ResetDomainKind::ExternalResources,
462 }
463 }
464}
465
466fn collect_reset_domains(
467 policies: impl IntoIterator<Item = ResetDomainPolicy>,
468) -> Result<BTreeMap<ResetDomainKind, ResetDomainPolicy>, BaselinePoolContractError> {
469 let mut domains = BTreeMap::new();
470 for policy in policies {
471 let domain = policy.domain();
472 if domains.insert(domain, policy).is_some() {
473 return Err(BaselinePoolContractError::DuplicateResetDomain { domain });
474 }
475 }
476 Ok(domains)
477}
478
479impl ResetRequirements {
480 pub fn try_new<I>(
485 cycle_policy: CycleResetPolicy,
486 requirements: I,
487 ) -> Result<Self, BaselinePoolContractError>
488 where
489 I: IntoIterator<Item = ResetDomainPolicy>,
490 {
491 Ok(Self {
492 cycle_policy,
493 domains: collect_reset_domains(requirements)?,
494 })
495 }
496
497 #[must_use]
499 pub const fn cycle_policy(&self) -> CycleResetPolicy {
500 self.cycle_policy
501 }
502
503 #[must_use]
505 pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetDomainPolicy> {
506 self.domains.get(&domain)
507 }
508
509 pub fn iter(&self) -> impl Iterator<Item = &ResetDomainPolicy> {
511 self.domains.values()
512 }
513
514 fn verify(
515 &self,
516 restore: &CanisterRestoreReceipt,
517 receipt: &ResetReceipt,
518 ) -> Result<(), BaselinePoolContractError> {
519 if restore.cycle_policy != self.cycle_policy {
520 return Err(BaselinePoolContractError::CyclePolicyMismatch {
521 required: self.cycle_policy,
522 achieved: restore.cycle_policy,
523 });
524 }
525 for (domain, requirement) in &self.domains {
526 let Some(achievement) = receipt.0.get(domain) else {
527 return Err(BaselinePoolContractError::MissingResetDomain { domain: *domain });
528 };
529 if achievement != requirement {
530 return Err(BaselinePoolContractError::ResetPolicyMismatch {
531 requirement: requirement.clone(),
532 achievement: achievement.clone(),
533 });
534 }
535 }
536 Ok(())
537 }
538}
539
540impl ResetReceipt {
541 pub fn try_new<I>(achievements: I) -> Result<Self, BaselinePoolContractError>
546 where
547 I: IntoIterator<Item = ResetDomainPolicy>,
548 {
549 Ok(Self(collect_reset_domains(achievements)?))
550 }
551
552 #[must_use]
554 pub const fn empty() -> Self {
555 Self(BTreeMap::new())
556 }
557
558 #[must_use]
560 pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetDomainPolicy> {
561 self.0.get(&domain)
562 }
563
564 pub fn iter(&self) -> impl Iterator<Item = &ResetDomainPolicy> {
566 self.0.values()
567 }
568}
569
570impl CanisterRestoreReceipt {
571 pub fn try_new<I>(
573 canister_ids: I,
574 cycle_policy: CycleResetPolicy,
575 ) -> Result<Self, BaselinePoolContractError>
576 where
577 I: IntoIterator<Item = Principal>,
578 {
579 let mut unique = BTreeSet::new();
580 for canister_id in canister_ids {
581 if !unique.insert(canister_id) {
582 return Err(BaselinePoolContractError::DuplicateCanisterId { canister_id });
583 }
584 }
585 if unique.is_empty() {
586 return Err(BaselinePoolContractError::EmptyCanisterSet);
587 }
588 Ok(Self {
589 canister_ids: unique.into_iter().collect(),
590 cycle_policy,
591 })
592 }
593
594 pub(super) fn from_restore(
597 snapshots: &ControllerSnapshots,
598 funding: SnapshotRestoreFunding,
599 ) -> Self {
600 Self {
601 canister_ids: snapshots.canister_ids().collect(),
602 cycle_policy: funding.into(),
603 }
604 }
605
606 #[must_use]
608 pub fn canister_ids(&self) -> &[Principal] {
609 &self.canister_ids
610 }
611
612 #[must_use]
614 pub const fn cycle_policy(&self) -> CycleResetPolicy {
615 self.cycle_policy
616 }
617}
618
619impl ReadinessReceipt {
620 pub fn try_new(identity: impl Into<String>) -> Result<Self, BaselinePoolContractError> {
622 Ok(Self {
623 identity: nonempty_receipt_identity("readiness", identity.into())?,
624 })
625 }
626
627 #[must_use]
629 pub fn identity(&self) -> &str {
630 &self.identity
631 }
632}
633
634impl ValidationReceipt {
635 pub fn try_new(
637 recipe_id: FixtureRecipeId,
638 invariant_identity: impl Into<String>,
639 ) -> Result<Self, BaselinePoolContractError> {
640 Ok(Self {
641 recipe_id,
642 invariant_identity: nonempty_receipt_identity("validation", invariant_identity.into())?,
643 })
644 }
645
646 #[must_use]
648 pub const fn recipe_id(&self) -> &FixtureRecipeId {
649 &self.recipe_id
650 }
651
652 #[must_use]
654 pub fn invariant_identity(&self) -> &str {
655 &self.invariant_identity
656 }
657}
658
659impl BaselinePreparationStage {
660 #[must_use]
665 pub fn default_rebuild_reason(self) -> RebuildReason {
666 match self {
667 Self::RestoreCanisters => RebuildReason::SnapshotRestoreFailure,
668 Self::ResetNonSnapshotState => RebuildReason::ResetFailure,
669 Self::DriveToReadiness => RebuildReason::ReadinessFailure,
670 Self::ValidateRestored | Self::ValidateBuilt => {
671 RebuildReason::InvariantValidationFailure
672 }
673 Self::Build => RebuildReason::RecipeClassified {
674 code: "build".to_owned(),
675 },
676 }
677 }
678}
679
680impl BaselinePoolTimings {
681 #[must_use]
683 pub const fn wait(self) -> Duration {
684 self.wait
685 }
686
687 #[must_use]
689 pub const fn build(self) -> Option<Duration> {
690 self.build
691 }
692
693 #[must_use]
695 pub const fn restore(self) -> Option<Duration> {
696 self.restore
697 }
698
699 #[must_use]
701 pub const fn reset(self) -> Option<Duration> {
702 self.reset
703 }
704
705 #[must_use]
707 pub const fn readiness(self) -> Option<Duration> {
708 self.readiness
709 }
710
711 #[must_use]
713 pub const fn validation(self) -> Option<Duration> {
714 self.validation
715 }
716
717 #[must_use]
719 pub const fn stale_teardown(self) -> Option<Duration> {
720 self.stale_teardown
721 }
722
723 #[must_use]
725 pub const fn total(self) -> Duration {
726 self.total
727 }
728}
729
730impl BaselinePoolOutcome {
731 #[must_use]
733 pub const fn slot(&self) -> usize {
734 match self {
735 Self::Built { slot, .. } | Self::Restored { slot, .. } | Self::Rebuilt { slot, .. } => {
736 *slot
737 }
738 }
739 }
740
741 #[must_use]
743 pub const fn timings(&self) -> BaselinePoolTimings {
744 match self {
745 Self::Built { timings, .. }
746 | Self::Restored { timings, .. }
747 | Self::Rebuilt { timings, .. } => *timings,
748 }
749 }
750}
751
752impl std::fmt::Display for BaselinePoolTimings {
753 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
754 write!(
755 formatter,
756 "total={:?} wait={:?} build={:?} restore={:?} reset={:?} readiness={:?} validation={:?} stale_teardown={:?}",
757 self.total,
758 self.wait,
759 self.build,
760 self.restore,
761 self.reset,
762 self.readiness,
763 self.validation,
764 self.stale_teardown,
765 )
766 }
767}
768
769impl std::fmt::Display for BaselinePoolOutcome {
770 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
771 match self {
772 Self::Built { slot, timings } => write!(formatter, "built slot={slot} {timings}"),
773 Self::Restored { slot, timings } => {
774 write!(formatter, "restored slot={slot} {timings}")
775 }
776 Self::Rebuilt {
777 slot,
778 reason,
779 timings,
780 } => write!(formatter, "rebuilt slot={slot} reason={reason:?} {timings}"),
781 }
782 }
783}
784
785impl<E> BaselinePoolError<E> {
786 #[must_use]
788 pub const fn timings(&self) -> BaselinePoolTimings {
789 match self {
790 Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
791 }
792 }
793}
794
795impl<R> CachedPocketIcBaselinePool<R>
796where
797 R: PocketIcBaselineRecipe,
798{
799 #[must_use]
804 pub const fn new(capacity: NonZeroUsize, recipe: R) -> Self {
805 Self {
806 recipe,
807 slots: BoundedSlotPool::new(capacity),
808 }
809 }
810
811 #[must_use]
813 pub fn recipe_id(&self) -> &FixtureRecipeId {
814 self.recipe.id()
815 }
816
817 #[must_use]
819 pub const fn capacity(&self) -> NonZeroUsize {
820 self.slots.capacity()
821 }
822
823 pub fn acquire(
835 &self,
836 ) -> Result<
837 (CachedPocketIcBaselinePoolGuard<'_, R>, BaselinePoolOutcome),
838 BaselinePoolError<R::Error>,
839 > {
840 let total_started = Instant::now();
841 let mut slot = self.slots.acquire();
842 let mut timings = BaselinePoolTimings {
843 wait: slot.wait(),
844 ..BaselinePoolTimings::default()
845 };
846 let slot_index = slot.slot_index();
847
848 let (rebuild_reason, original) = if slot.is_reusable() {
849 match self.prepare_reused(&slot, &mut timings) {
850 Ok(()) => {
851 timings.total = total_started.elapsed();
852 return Ok((
853 CachedPocketIcBaselinePoolGuard { slot },
854 BaselinePoolOutcome::Restored {
855 slot: slot_index,
856 timings,
857 },
858 ));
859 }
860 Err(original) => match self.failure_disposition(&original) {
861 FailureDisposition::Fatal => {
862 Self::discard_stale_slot(&mut slot, &mut timings);
865 timings.total = total_started.elapsed();
866 return Err(BaselinePoolError::Preparation {
867 error: original,
868 timings: Box::new(timings),
869 });
870 }
871 FailureDisposition::Rebuild(reason) => (Some(reason), Some(original)),
872 },
873 }
874 } else {
875 let reason = if slot.invalidated_by_unwind() {
876 Some(RebuildReason::UnwindWhileLeased)
877 } else {
878 slot.invalidation_reason().cloned()
879 };
880 (reason, None)
881 };
882
883 if slot.is_populated() {
884 Self::discard_stale_slot(&mut slot, &mut timings);
885 }
886 let build = self.build_slot(&mut slot, &mut timings);
887 timings.total = total_started.elapsed();
888 if let Err(error) = build {
889 return Err(match original {
890 Some(original) => BaselinePoolError::RecoveryFailed {
891 original: Box::new(original),
892 rebuild: Box::new(error),
893 timings: Box::new(timings),
894 },
895 None => BaselinePoolError::Preparation {
896 error,
897 timings: Box::new(timings),
898 },
899 });
900 }
901
902 let outcome = rebuild_reason.map_or_else(
903 || BaselinePoolOutcome::Built {
904 slot: slot_index,
905 timings,
906 },
907 |reason| BaselinePoolOutcome::Rebuilt {
908 slot: slot_index,
909 reason,
910 timings,
911 },
912 );
913 Ok((CachedPocketIcBaselinePoolGuard { slot }, outcome))
914 }
915
916 fn prepare_reused(
917 &self,
918 slot: &BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
919 timings: &mut BaselinePoolTimings,
920 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
921 let baseline = slot
922 .get()
923 .expect("reusable baseline slot must be populated");
924
925 let started = Instant::now();
926 let restore = self.recipe.restore_canisters(baseline);
927 add_timing(&mut timings.restore, started.elapsed());
928 let canisters = restore.map_err(|source| BaselinePoolPreparationError::Recipe {
929 stage: BaselinePreparationStage::RestoreCanisters,
930 source,
931 })?;
932 if !baseline
933 .snapshot_canister_ids()
934 .eq(canisters.canister_ids().iter().copied())
935 {
936 return Err(BaselinePoolPreparationError::Contract(
937 BaselinePoolContractError::RestoreCanisterSetMismatch {
938 expected: baseline.snapshot_canister_ids().collect(),
939 actual: canisters.canister_ids().to_vec(),
940 },
941 ));
942 }
943
944 let started = Instant::now();
945 let reset_result = self.recipe.reset_non_snapshot_state(baseline);
946 add_timing(&mut timings.reset, started.elapsed());
947 let reset = reset_result.map_err(|source| BaselinePoolPreparationError::Recipe {
948 stage: BaselinePreparationStage::ResetNonSnapshotState,
949 source,
950 })?;
951
952 let started = Instant::now();
953 let readiness_result = self.recipe.drive_to_readiness(baseline);
954 add_timing(&mut timings.readiness, started.elapsed());
955 let readiness =
956 readiness_result.map_err(|source| BaselinePoolPreparationError::Recipe {
957 stage: BaselinePreparationStage::DriveToReadiness,
958 source,
959 })?;
960 self.recipe
961 .reset_requirements()
962 .verify(&canisters, &reset)
963 .map_err(BaselinePoolPreparationError::Contract)?;
964
965 let preparation = PreparedBaseline::Restored {
966 canisters,
967 reset,
968 readiness,
969 };
970 self.validate_baseline(
971 baseline,
972 &preparation,
973 BaselinePreparationStage::ValidateRestored,
974 timings,
975 )?;
976 Ok(())
977 }
978
979 fn build_slot(
980 &self,
981 slot: &mut BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
982 timings: &mut BaselinePoolTimings,
983 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
984 let started = Instant::now();
985 let build = self.recipe.build();
986 add_timing(&mut timings.build, started.elapsed());
987 let baseline = build.map_err(|source| BaselinePoolPreparationError::Recipe {
988 stage: BaselinePreparationStage::Build,
989 source,
990 })?;
991
992 if let Err(error) = self.validate_baseline(
993 &baseline,
994 &PreparedBaseline::Built,
995 BaselinePreparationStage::ValidateBuilt,
996 timings,
997 ) {
998 drop_baseline_safely(baseline);
999 return Err(error);
1000 }
1001 let replaced = slot.replace(baseline);
1002 debug_assert!(replaced.is_none());
1003 Ok(())
1004 }
1005
1006 fn validate_baseline(
1007 &self,
1008 baseline: &CachedPocketIcBaseline<R::Metadata>,
1009 preparation: &PreparedBaseline,
1010 stage: BaselinePreparationStage,
1011 timings: &mut BaselinePoolTimings,
1012 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
1013 let started = Instant::now();
1014 let validation = self.recipe.validate(baseline, preparation);
1015 add_timing(&mut timings.validation, started.elapsed());
1016 let receipt =
1017 validation.map_err(|source| BaselinePoolPreparationError::Recipe { stage, source })?;
1018 if receipt.recipe_id() != self.recipe.id() {
1019 return Err(BaselinePoolPreparationError::Contract(
1020 BaselinePoolContractError::RecipeIdentityMismatch {
1021 expected: self.recipe.id().clone(),
1022 actual: receipt.recipe_id().clone(),
1023 },
1024 ));
1025 }
1026 Ok(())
1027 }
1028
1029 fn failure_disposition(
1030 &self,
1031 error: &BaselinePoolPreparationError<R::Error>,
1032 ) -> FailureDisposition {
1033 match error {
1034 BaselinePoolPreparationError::Recipe { stage, source } => {
1035 self.recipe.classify_failure(*stage, source)
1036 }
1037 BaselinePoolPreparationError::Contract(
1038 BaselinePoolContractError::RecipeIdentityMismatch { .. },
1039 ) => FailureDisposition::Fatal,
1040 BaselinePoolPreparationError::Contract(_) => {
1041 FailureDisposition::Rebuild(rebuild_reason_for_error(error))
1042 }
1043 }
1044 }
1045
1046 fn discard_stale_slot(
1047 slot: &mut BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
1048 timings: &mut BaselinePoolTimings,
1049 ) {
1050 let started = Instant::now();
1051 if let Some(stale) = slot.take() {
1052 drop_baseline_safely(stale);
1053 }
1054 timings.stale_teardown = Some(started.elapsed());
1055 }
1056}
1057
1058impl<R> CachedPocketIcBaselinePoolGuard<'_, R>
1059where
1060 R: PocketIcBaselineRecipe,
1061{
1062 #[must_use]
1064 pub const fn slot(&self) -> usize {
1065 self.slot.slot_index()
1066 }
1067
1068 pub fn invalidate(&mut self, reason: RebuildReason) {
1070 self.slot.invalidate(reason);
1071 }
1072}
1073
1074impl<R> Deref for CachedPocketIcBaselinePoolGuard<'_, R>
1075where
1076 R: PocketIcBaselineRecipe,
1077{
1078 type Target = CachedPocketIcBaseline<R::Metadata>;
1079
1080 fn deref(&self) -> &Self::Target {
1081 self.slot
1082 .get()
1083 .expect("leased baseline pool slot must be populated")
1084 }
1085}
1086
1087fn nonempty_receipt_identity(
1088 receipt: &'static str,
1089 identity: String,
1090) -> Result<String, BaselinePoolContractError> {
1091 if identity.trim().is_empty() {
1092 return Err(BaselinePoolContractError::EmptyReceiptIdentity { receipt });
1093 }
1094 Ok(identity)
1095}
1096
1097const fn add_timing(total: &mut Option<Duration>, elapsed: Duration) {
1098 *total = Some(match *total {
1099 None => elapsed,
1100 Some(previous) => previous.saturating_add(elapsed),
1101 });
1102}
1103
1104fn rebuild_reason_for_error<E>(error: &BaselinePoolPreparationError<E>) -> RebuildReason {
1105 match error {
1106 BaselinePoolPreparationError::Recipe { stage, .. } => stage.default_rebuild_reason(),
1107 BaselinePoolPreparationError::Contract(
1108 BaselinePoolContractError::MissingResetDomain { .. }
1109 | BaselinePoolContractError::ResetPolicyMismatch { .. }
1110 | BaselinePoolContractError::CyclePolicyMismatch { .. }
1111 | BaselinePoolContractError::DuplicateResetDomain { .. }
1112 | BaselinePoolContractError::RestoreCanisterSetMismatch { .. },
1113 ) => RebuildReason::ResetCoverageMismatch,
1114 BaselinePoolPreparationError::Contract(_) => RebuildReason::InvariantValidationFailure,
1115 }
1116}
1117
1118fn drop_baseline_safely<M>(baseline: CachedPocketIcBaseline<M>) {
1119 let _ = catch_unwind(AssertUnwindSafe(|| drop(baseline)));
1120}
1121
1122impl std::fmt::Display for FixtureRecipeId {
1123 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1124 formatter.write_str(&self.0)
1125 }
1126}
1127
1128impl std::fmt::Display for BaselinePreparationStage {
1129 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1130 formatter.write_str(match self {
1131 Self::Build => "build",
1132 Self::RestoreCanisters => "canister restore",
1133 Self::ResetNonSnapshotState => "non-snapshot reset",
1134 Self::DriveToReadiness => "readiness",
1135 Self::ValidateBuilt => "built-baseline validation",
1136 Self::ValidateRestored => "restored-baseline validation",
1137 })
1138 }
1139}
1140
1141impl std::fmt::Display for BaselinePoolContractError {
1142 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1143 match self {
1144 Self::EmptyRecipeIdentity => formatter.write_str("fixture recipe identity is empty"),
1145 Self::EmptyReceiptIdentity { receipt } => {
1146 write!(formatter, "{receipt} receipt identity is empty")
1147 }
1148 Self::DuplicateResetDomain { domain } => {
1149 write!(
1150 formatter,
1151 "reset domain {domain:?} was reported more than once"
1152 )
1153 }
1154 Self::DuplicateCanisterId { canister_id } => {
1155 write!(formatter, "restore receipt repeats canister {canister_id}")
1156 }
1157 Self::EmptyCanisterSet => formatter.write_str("restore receipt contains no canisters"),
1158 Self::CyclePolicyMismatch { required, achieved } => write!(
1159 formatter,
1160 "restore cycle policy {achieved:?} does not satisfy {required:?}",
1161 ),
1162 Self::RestoreCanisterSetMismatch { expected, actual } => write!(
1163 formatter,
1164 "restore receipt identified canisters {actual:?}, expected captured set {expected:?}",
1165 ),
1166 Self::MissingResetDomain { domain } => {
1167 write!(
1168 formatter,
1169 "required reset domain {domain:?} was not achieved"
1170 )
1171 }
1172 Self::ResetPolicyMismatch {
1173 requirement,
1174 achievement,
1175 } => write!(
1176 formatter,
1177 "reset achievement {achievement:?} does not satisfy {requirement:?}",
1178 ),
1179 Self::RecipeIdentityMismatch { expected, actual } => write!(
1180 formatter,
1181 "validation receipt used recipe `{actual}` instead of `{expected}`",
1182 ),
1183 }
1184 }
1185}
1186
1187impl std::error::Error for BaselinePoolContractError {}
1188
1189impl<E> std::fmt::Display for BaselinePoolPreparationError<E>
1190where
1191 E: std::fmt::Display,
1192{
1193 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1194 match self {
1195 Self::Recipe { stage, source } => {
1196 write!(formatter, "baseline {stage} failed: {source}")
1197 }
1198 Self::Contract(error) => write!(formatter, "baseline pool contract failed: {error}"),
1199 }
1200 }
1201}
1202
1203impl<E> std::error::Error for BaselinePoolPreparationError<E>
1204where
1205 E: std::error::Error + 'static,
1206{
1207 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1208 match self {
1209 Self::Recipe { source, .. } => Some(source),
1210 Self::Contract(error) => Some(error),
1211 }
1212 }
1213}
1214
1215impl<E> std::fmt::Display for BaselinePoolError<E>
1216where
1217 E: std::fmt::Display,
1218{
1219 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1220 match self {
1221 Self::Preparation { error, .. } => error.fmt(formatter),
1222 Self::RecoveryFailed {
1223 original, rebuild, ..
1224 } => write!(
1225 formatter,
1226 "baseline preparation failed ({original}); rebuilding the slot also failed: {rebuild}",
1227 ),
1228 }
1229 }
1230}
1231
1232impl<E> std::error::Error for BaselinePoolError<E>
1233where
1234 E: std::error::Error + 'static,
1235{
1236 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1237 match self {
1238 Self::Preparation { error, .. } => Some(error),
1239 Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
1240 }
1241 }
1242}
1243
1244#[cfg(test)]
1245mod tests {
1246 use super::{
1247 BaselinePoolContractError, BaselinePoolTimings, CanisterRestoreReceipt, CycleResetPolicy,
1248 ExtraCanisterPolicy, FixtureRecipeId, ResetDomainKind, ResetDomainPolicy, ResetReceipt,
1249 ResetRequirements, StateResetPolicy, TimeResetPolicy, add_timing,
1250 };
1251 use candid::Principal;
1252 use std::time::Duration;
1253
1254 #[test]
1255 fn phase_timing_preserves_absence_zero_and_saturation() {
1256 let mut timings = BaselinePoolTimings::default();
1257 assert_eq!(timings.restore(), None);
1258 add_timing(&mut timings.restore, Duration::ZERO);
1259 assert_eq!(timings.restore(), Some(Duration::ZERO));
1260 add_timing(&mut timings.restore, Duration::from_secs(2));
1261 assert_eq!(timings.restore(), Some(Duration::from_secs(2)));
1262 add_timing(&mut timings.restore, Duration::MAX);
1263 assert_eq!(timings.restore(), Some(Duration::MAX));
1264 assert_eq!(timings.build(), None);
1265 }
1266
1267 #[test]
1268 fn recipe_identity_must_be_nonempty() {
1269 assert!(matches!(
1270 FixtureRecipeId::try_new(" "),
1271 Err(BaselinePoolContractError::EmptyRecipeIdentity)
1272 ));
1273 }
1274
1275 #[test]
1276 fn caller_restore_receipts_validate_and_order_canister_ids() {
1277 let first = Principal::from_slice(&[1]);
1278 let second = Principal::from_slice(&[2]);
1279 let policy = CycleResetPolicy::TopUpTo(123);
1280 let receipt = CanisterRestoreReceipt::try_new([second, first], policy).unwrap();
1281 assert_eq!(receipt.canister_ids(), [first, second]);
1282 assert_eq!(receipt.cycle_policy(), policy);
1283 assert_eq!(
1284 CanisterRestoreReceipt::try_new([], policy),
1285 Err(BaselinePoolContractError::EmptyCanisterSet),
1286 );
1287 assert_eq!(
1288 CanisterRestoreReceipt::try_new([second, first, second], policy),
1289 Err(BaselinePoolContractError::DuplicateCanisterId {
1290 canister_id: second,
1291 }),
1292 );
1293 }
1294
1295 #[test]
1296 fn reset_declarations_and_receipts_reject_duplicate_domains() {
1297 let policies = [
1298 ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent),
1299 ResetDomainPolicy::PocketIcTime(TimeResetPolicy::RebuildOnMutation),
1300 ];
1301 let error = BaselinePoolContractError::DuplicateResetDomain {
1302 domain: ResetDomainKind::PocketIcTime,
1303 };
1304 assert_eq!(
1305 ResetRequirements::try_new(CycleResetPolicy::PreserveCurrent, policies.clone()),
1306 Err(error.clone()),
1307 );
1308 assert_eq!(ResetReceipt::try_new(policies), Err(error));
1309 }
1310
1311 #[test]
1312 fn required_policy_must_match_achieved_policy() {
1313 let restore = CanisterRestoreReceipt::try_new(
1314 [Principal::anonymous()],
1315 CycleResetPolicy::PreserveCurrent,
1316 )
1317 .unwrap();
1318 for (requirement, achievement) in [
1319 (
1320 ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent),
1321 ResetDomainPolicy::PocketIcTime(TimeResetPolicy::RebuildOnMutation),
1322 ),
1323 (
1324 ResetDomainPolicy::ExtraCanisters(ExtraCanisterPolicy::RequireBaselineSet),
1325 ResetDomainPolicy::ExtraCanisters(ExtraCanisterPolicy::RemoveTracked),
1326 ),
1327 (
1328 ResetDomainPolicy::PendingMessages(StateResetPolicy::ValidateUnchanged),
1329 ResetDomainPolicy::PendingMessages(StateResetPolicy::IrrelevantByRecipeContract),
1330 ),
1331 (
1332 ResetDomainPolicy::SubnetState(StateResetPolicy::ResetByRecipe),
1333 ResetDomainPolicy::SubnetState(StateResetPolicy::RebuildOnChange),
1334 ),
1335 (
1336 ResetDomainPolicy::ExternalResources(StateResetPolicy::ValidateUnchanged),
1337 ResetDomainPolicy::ExternalResources(StateResetPolicy::ResetByRecipe),
1338 ),
1339 ] {
1340 let requirements = ResetRequirements::try_new(
1341 CycleResetPolicy::PreserveCurrent,
1342 [requirement.clone()],
1343 )
1344 .unwrap();
1345 let receipt = ResetReceipt::try_new([requirement.clone()]).unwrap();
1346 assert_eq!(requirements.verify(&restore, &receipt), Ok(()));
1347 assert_eq!(
1348 requirements.verify(&restore, &ResetReceipt::empty()),
1349 Err(BaselinePoolContractError::MissingResetDomain {
1350 domain: requirement.domain(),
1351 }),
1352 );
1353 let receipt = ResetReceipt::try_new([achievement.clone()]).unwrap();
1354 assert_eq!(
1355 requirements.verify(&restore, &receipt),
1356 Err(BaselinePoolContractError::ResetPolicyMismatch {
1357 requirement,
1358 achievement,
1359 }),
1360 );
1361 }
1362 }
1363
1364 #[test]
1365 fn restore_cycle_policy_must_match_required_policy() {
1366 let requirements =
1367 ResetRequirements::try_new(CycleResetPolicy::RestoreExactBaseline, []).unwrap();
1368 let restore = CanisterRestoreReceipt::try_new(
1369 [Principal::anonymous()],
1370 CycleResetPolicy::PreserveCurrent,
1371 )
1372 .unwrap();
1373 assert!(matches!(
1374 requirements.verify(&restore, &ResetReceipt::empty()),
1375 Err(BaselinePoolContractError::CyclePolicyMismatch {
1376 required: CycleResetPolicy::RestoreExactBaseline,
1377 achieved: CycleResetPolicy::PreserveCurrent,
1378 })
1379 ));
1380 }
1381}