Skip to main content

ic_testkit/pic/
snapshot.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    panic::{AssertUnwindSafe, catch_unwind},
4};
5
6use candid::Principal;
7use pocket_ic::{PocketIc, RejectResponse};
8
9use super::transport;
10
11#[derive(Clone, Debug, Eq, PartialEq)]
12struct ControllerSnapshot {
13    snapshot_id: Vec<u8>,
14    sender: Option<Principal>,
15}
16
17/// Deterministically ordered snapshots retaining each successful capture sender.
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct ControllerSnapshots(BTreeMap<Principal, ControllerSnapshot>);
20
21/// One canister and exact management-call sender used for snapshot capture.
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub struct CanisterSnapshotTarget {
24    canister_id: Principal,
25    sender: Option<Principal>,
26}
27
28/// One rejected sender attempt for a snapshot operation.
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct SnapshotAttemptFailure {
31    sender: Option<Principal>,
32    response: RejectResponse,
33}
34
35/// Failure to remove a snapshot while rolling back a partial capture.
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct SnapshotCleanupFailure {
38    canister_id: Principal,
39    sender: Option<Principal>,
40    response: Option<Box<RejectResponse>>,
41    panic_message: Option<String>,
42}
43
44/// Caller-selected cycle funding applied immediately before snapshot restore.
45#[non_exhaustive]
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum SnapshotRestoreFunding {
48    /// Do not add cycles before the restore operation.
49    Preserve,
50    /// Add cycles only when needed to reach the given minimum balance.
51    TopUpTo {
52        /// Minimum balance established immediately before each restore attempt.
53        minimum_cycles: u128,
54    },
55}
56
57/// Structured controller-snapshot failure.
58#[non_exhaustive]
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub enum ControllerSnapshotError {
61    /// Input contained the same canister more than once.
62    DuplicateCanisterId {
63        /// Repeated canister id.
64        canister_id: Principal,
65    },
66    /// PocketIC rejected every sender attempted for capture.
67    CaptureFailed {
68        /// Canister whose capture failed.
69        canister_id: Principal,
70        /// Rejected sender attempts in execution order.
71        attempts: Vec<SnapshotAttemptFailure>,
72        /// Failures while deleting snapshots captured earlier in the set.
73        cleanup_failures: Vec<SnapshotCleanupFailure>,
74    },
75    /// PocketIC panicked while capturing a snapshot.
76    CapturePanicked {
77        /// Canister whose capture panicked.
78        canister_id: Principal,
79        /// Captured panic message.
80        message: String,
81        /// Failures while deleting snapshots captured earlier in the set.
82        cleanup_failures: Vec<SnapshotCleanupFailure>,
83    },
84    /// PocketIC rejected every sender attempted for restore.
85    RestoreFailed {
86        /// Canister whose restore failed.
87        canister_id: Principal,
88        /// Rejected sender attempts in execution order.
89        attempts: Vec<SnapshotAttemptFailure>,
90    },
91    /// PocketIC panicked while restoring a snapshot.
92    RestorePanicked {
93        /// Canister whose restore panicked.
94        canister_id: Principal,
95        /// Captured panic message.
96        message: String,
97    },
98}
99
100enum SnapshotCaptureFailure {
101    Rejected(Vec<SnapshotAttemptFailure>),
102    Panicked(String),
103}
104
105/// Controller-aware capture and restore of related canister snapshots.
106pub trait PocketIcSnapshotExt {
107    /// Capture snapshots with one explicit sender per canister.
108    ///
109    /// Unlike [`Self::capture_controller_snapshots`], this performs no
110    /// rejected-sender fallback attempts. Input is duplicate-checked and
111    /// captured in deterministic canister-id order.
112    fn capture_snapshots_with_senders<I>(
113        &self,
114        targets: I,
115    ) -> Result<ControllerSnapshots, ControllerSnapshotError>
116    where
117        I: IntoIterator<Item = CanisterSnapshotTarget>;
118
119    /// Capture one restorable snapshot per unique canister.
120    ///
121    /// Input is validated before capture begins. If a later capture fails,
122    /// snapshots already captured by this operation are deleted before the
123    /// structured error is returned.
124    fn capture_controller_snapshots<I>(
125        &self,
126        controller_id: Principal,
127        canister_ids: I,
128    ) -> Result<ControllerSnapshots, ControllerSnapshotError>
129    where
130        I: IntoIterator<Item = Principal>;
131
132    /// Restore a previously captured snapshot set using the same controller.
133    ///
134    /// This default path never funds the canister before restore. PocketIC may
135    /// still charge cycles as part of the restore operation itself.
136    fn restore_controller_snapshots(
137        &self,
138        controller_id: Principal,
139        snapshots: &ControllerSnapshots,
140    ) -> Result<(), ControllerSnapshotError>;
141
142    /// Restore a snapshot set with an explicit cycle-funding policy.
143    ///
144    /// `TopUpTo` is evaluated immediately before each restore attempt. No
145    /// cycles are removed when the current balance already meets the minimum.
146    fn restore_controller_snapshots_with_funding(
147        &self,
148        controller_id: Principal,
149        snapshots: &ControllerSnapshots,
150        funding: SnapshotRestoreFunding,
151    ) -> Result<(), ControllerSnapshotError>;
152}
153
154impl PocketIcSnapshotExt for PocketIc {
155    fn capture_snapshots_with_senders<I>(
156        &self,
157        targets: I,
158    ) -> Result<ControllerSnapshots, ControllerSnapshotError>
159    where
160        I: IntoIterator<Item = CanisterSnapshotTarget>,
161    {
162        let targets = ordered_unique_snapshot_targets(targets)?;
163        capture_snapshot_set(
164            self,
165            targets.into_iter().map(|target| {
166                (
167                    target.canister_id,
168                    std::iter::once(target.sender).collect::<Vec<_>>(),
169                )
170            }),
171        )
172    }
173
174    fn capture_controller_snapshots<I>(
175        &self,
176        controller_id: Principal,
177        canister_ids: I,
178    ) -> Result<ControllerSnapshots, ControllerSnapshotError>
179    where
180        I: IntoIterator<Item = Principal>,
181    {
182        let canister_ids = ordered_unique_canister_ids(canister_ids)?;
183        capture_snapshot_set(
184            self,
185            canister_ids.into_iter().map(|canister_id| {
186                (
187                    canister_id,
188                    controller_sender_candidates(controller_id, canister_id).to_vec(),
189                )
190            }),
191        )
192    }
193
194    fn restore_controller_snapshots(
195        &self,
196        controller_id: Principal,
197        snapshots: &ControllerSnapshots,
198    ) -> Result<(), ControllerSnapshotError> {
199        self.restore_controller_snapshots_with_funding(
200            controller_id,
201            snapshots,
202            SnapshotRestoreFunding::Preserve,
203        )
204    }
205
206    fn restore_controller_snapshots_with_funding(
207        &self,
208        controller_id: Principal,
209        snapshots: &ControllerSnapshots,
210        funding: SnapshotRestoreFunding,
211    ) -> Result<(), ControllerSnapshotError> {
212        for (canister_id, snapshot_id, sender) in snapshots.iter() {
213            restore_controller_snapshot(
214                self,
215                controller_id,
216                canister_id,
217                sender,
218                snapshot_id,
219                funding,
220            )?;
221        }
222        Ok(())
223    }
224}
225
226impl CanisterSnapshotTarget {
227    /// Select one canister and exact sender for snapshot capture.
228    #[must_use]
229    pub const fn new(canister_id: Principal, sender: Option<Principal>) -> Self {
230        Self {
231            canister_id,
232            sender,
233        }
234    }
235
236    /// Canister whose snapshot will be captured.
237    #[must_use]
238    pub const fn canister_id(self) -> Principal {
239        self.canister_id
240    }
241
242    /// Exact management-call sender, including `None` for the default sender.
243    #[must_use]
244    pub const fn sender(self) -> Option<Principal> {
245        self.sender
246    }
247}
248
249fn capture_snapshot_set<I>(
250    pocket_ic: &PocketIc,
251    targets: I,
252) -> Result<ControllerSnapshots, ControllerSnapshotError>
253where
254    I: IntoIterator<Item = (Principal, Vec<Option<Principal>>)>,
255{
256    let mut snapshots = BTreeMap::new();
257    for (canister_id, senders) in targets {
258        match try_take_snapshot(pocket_ic, canister_id, senders) {
259            Ok(snapshot) => {
260                snapshots.insert(canister_id, snapshot);
261            }
262            Err(SnapshotCaptureFailure::Rejected(attempts)) => {
263                let cleanup_failures = cleanup_captured_snapshots(pocket_ic, &snapshots);
264                return Err(ControllerSnapshotError::CaptureFailed {
265                    canister_id,
266                    attempts,
267                    cleanup_failures,
268                });
269            }
270            Err(SnapshotCaptureFailure::Panicked(message)) => {
271                let cleanup_failures = cleanup_captured_snapshots(pocket_ic, &snapshots);
272                return Err(ControllerSnapshotError::CapturePanicked {
273                    canister_id,
274                    message,
275                    cleanup_failures,
276                });
277            }
278        }
279    }
280    Ok(ControllerSnapshots(snapshots))
281}
282
283impl ControllerSnapshots {
284    /// Return the number of captured canisters.
285    #[must_use]
286    pub fn len(&self) -> usize {
287        self.0.len()
288    }
289
290    /// Report whether the set contains no snapshots.
291    #[must_use]
292    pub fn is_empty(&self) -> bool {
293        self.0.is_empty()
294    }
295
296    /// Iterate over captured canister ids in deterministic principal order.
297    pub fn canister_ids(&self) -> impl Iterator<Item = Principal> + '_ {
298        self.0.keys().copied()
299    }
300
301    pub(super) fn iter(&self) -> impl Iterator<Item = (Principal, &[u8], Option<Principal>)> + '_ {
302        self.0.iter().map(|(canister_id, snapshot)| {
303            (
304                *canister_id,
305                snapshot.snapshot_id.as_slice(),
306                snapshot.sender,
307            )
308        })
309    }
310}
311
312impl SnapshotAttemptFailure {
313    /// Read the sender used for this rejected attempt.
314    #[must_use]
315    pub const fn sender(&self) -> Option<Principal> {
316        self.sender
317    }
318
319    /// Read PocketIC's structured rejection.
320    #[must_use]
321    pub const fn response(&self) -> &RejectResponse {
322        &self.response
323    }
324}
325
326impl SnapshotCleanupFailure {
327    /// Read the canister whose captured snapshot could not be removed.
328    #[must_use]
329    pub const fn canister_id(&self) -> Principal {
330        self.canister_id
331    }
332
333    /// Read the sender used for the rejected cleanup.
334    #[must_use]
335    pub const fn sender(&self) -> Option<Principal> {
336        self.sender
337    }
338
339    /// Read PocketIC's structured rejection.
340    #[must_use]
341    pub fn response(&self) -> Option<&RejectResponse> {
342        self.response.as_deref()
343    }
344
345    /// Read a captured PocketIC panic message, when cleanup did not return a rejection.
346    #[must_use]
347    pub fn panic_message(&self) -> Option<&str> {
348        self.panic_message.as_deref()
349    }
350}
351
352impl std::fmt::Display for ControllerSnapshotError {
353    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
354        match self {
355            Self::DuplicateCanisterId { canister_id } => {
356                write!(f, "duplicate canister id in snapshot set: {canister_id}")
357            }
358            Self::CaptureFailed {
359                canister_id,
360                attempts,
361                cleanup_failures,
362            } => write!(
363                f,
364                "failed to capture snapshot for {canister_id} after {} sender attempts; {} partial snapshots could not be cleaned up",
365                attempts.len(),
366                cleanup_failures.len()
367            ),
368            Self::CapturePanicked {
369                canister_id,
370                message,
371                cleanup_failures,
372            } => write!(
373                f,
374                "snapshot capture panicked for {canister_id}: {message}; {} partial snapshots could not be cleaned up",
375                cleanup_failures.len()
376            ),
377            Self::RestoreFailed {
378                canister_id,
379                attempts,
380            } => write!(
381                f,
382                "failed to restore snapshot for {canister_id} after {} sender attempts",
383                attempts.len()
384            ),
385            Self::RestorePanicked {
386                canister_id,
387                message,
388            } => write!(f, "snapshot restore panicked for {canister_id}: {message}"),
389        }
390    }
391}
392
393impl std::error::Error for ControllerSnapshotError {}
394
395fn ordered_unique_canister_ids<I>(
396    canister_ids: I,
397) -> Result<Vec<Principal>, ControllerSnapshotError>
398where
399    I: IntoIterator<Item = Principal>,
400{
401    let mut unique = BTreeSet::new();
402    for canister_id in canister_ids {
403        if !unique.insert(canister_id) {
404            return Err(ControllerSnapshotError::DuplicateCanisterId { canister_id });
405        }
406    }
407    Ok(unique.into_iter().collect())
408}
409
410fn ordered_unique_snapshot_targets<I>(
411    targets: I,
412) -> Result<Vec<CanisterSnapshotTarget>, ControllerSnapshotError>
413where
414    I: IntoIterator<Item = CanisterSnapshotTarget>,
415{
416    let mut unique = BTreeMap::new();
417    for target in targets {
418        if unique.insert(target.canister_id, target).is_some() {
419            return Err(ControllerSnapshotError::DuplicateCanisterId {
420                canister_id: target.canister_id,
421            });
422        }
423    }
424    Ok(unique.into_values().collect())
425}
426
427fn try_take_snapshot(
428    pocket_ic: &PocketIc,
429    canister_id: Principal,
430    candidates: impl IntoIterator<Item = Option<Principal>>,
431) -> Result<ControllerSnapshot, SnapshotCaptureFailure> {
432    let mut attempts = Vec::new();
433
434    for sender in candidates {
435        let capture = catch_unwind(AssertUnwindSafe(|| {
436            pocket_ic.take_canister_snapshot(canister_id, sender, None)
437        }));
438        match capture {
439            Err(payload) => {
440                return Err(SnapshotCaptureFailure::Panicked(
441                    transport::panic_payload_to_string(payload.as_ref()),
442                ));
443            }
444            Ok(snapshot) => match snapshot {
445                Ok(snapshot) => {
446                    return Ok(ControllerSnapshot {
447                        snapshot_id: snapshot.id,
448                        sender,
449                    });
450                }
451                Err(response) => attempts.push(SnapshotAttemptFailure { sender, response }),
452            },
453        }
454    }
455
456    Err(SnapshotCaptureFailure::Rejected(attempts))
457}
458
459fn cleanup_captured_snapshots(
460    pocket_ic: &PocketIc,
461    snapshots: &BTreeMap<Principal, ControllerSnapshot>,
462) -> Vec<SnapshotCleanupFailure> {
463    let mut failures = Vec::new();
464    for (canister_id, snapshot) in snapshots {
465        let cleanup = catch_unwind(AssertUnwindSafe(|| {
466            pocket_ic.delete_canister_snapshot(
467                *canister_id,
468                snapshot.sender,
469                snapshot.snapshot_id.clone(),
470            )
471        }));
472        match cleanup {
473            Ok(Ok(())) => {}
474            Ok(Err(response)) => failures.push(SnapshotCleanupFailure {
475                canister_id: *canister_id,
476                sender: snapshot.sender,
477                response: Some(Box::new(response)),
478                panic_message: None,
479            }),
480            Err(payload) => failures.push(SnapshotCleanupFailure {
481                canister_id: *canister_id,
482                sender: snapshot.sender,
483                response: None,
484                panic_message: Some(transport::panic_payload_to_string(payload.as_ref())),
485            }),
486        }
487    }
488    failures
489}
490
491fn restore_controller_snapshot(
492    pocket_ic: &PocketIc,
493    controller_id: Principal,
494    canister_id: Principal,
495    snapshot_sender: Option<Principal>,
496    snapshot_id: &[u8],
497    funding: SnapshotRestoreFunding,
498) -> Result<(), ControllerSnapshotError> {
499    let fallback_sender = if snapshot_sender.is_some() {
500        None
501    } else {
502        Some(controller_id)
503    };
504    let candidates = [snapshot_sender, fallback_sender];
505    let mut attempts = Vec::new();
506
507    for sender in candidates {
508        let restore = catch_unwind(AssertUnwindSafe(|| {
509            apply_snapshot_restore_funding(pocket_ic, canister_id, funding);
510            pocket_ic.load_canister_snapshot(canister_id, sender, snapshot_id.to_vec())
511        }));
512        match restore {
513            Err(payload) => {
514                return Err(ControllerSnapshotError::RestorePanicked {
515                    canister_id,
516                    message: transport::panic_payload_to_string(payload.as_ref()),
517                });
518            }
519            Ok(Ok(())) => return Ok(()),
520            Ok(Err(response)) => attempts.push(SnapshotAttemptFailure { sender, response }),
521        }
522    }
523
524    Err(ControllerSnapshotError::RestoreFailed {
525        canister_id,
526        attempts,
527    })
528}
529
530fn apply_snapshot_restore_funding(
531    pocket_ic: &PocketIc,
532    canister_id: Principal,
533    funding: SnapshotRestoreFunding,
534) {
535    if funding == SnapshotRestoreFunding::Preserve {
536        return;
537    }
538
539    let balance = pocket_ic.cycle_balance(canister_id);
540    let top_up = snapshot_restore_top_up(balance, funding);
541    if top_up > 0 {
542        let _ = pocket_ic.add_cycles(canister_id, top_up);
543    }
544}
545
546const fn snapshot_restore_top_up(balance: u128, funding: SnapshotRestoreFunding) -> u128 {
547    match funding {
548        SnapshotRestoreFunding::Preserve => 0,
549        SnapshotRestoreFunding::TopUpTo { minimum_cycles } => {
550            minimum_cycles.saturating_sub(balance)
551        }
552    }
553}
554
555fn controller_sender_candidates(
556    controller_id: Principal,
557    canister_id: Principal,
558) -> [Option<Principal>; 2] {
559    if canister_id == controller_id {
560        [None, Some(controller_id)]
561    } else {
562        [Some(controller_id), None]
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use candid::Principal;
569
570    use super::{
571        ControllerSnapshotError, SnapshotRestoreFunding, ordered_unique_canister_ids,
572        snapshot_restore_top_up,
573    };
574
575    #[test]
576    fn duplicate_canister_ids_are_rejected_before_capture() {
577        let canister_id = Principal::from_slice(&[1]);
578        let error = ordered_unique_canister_ids([canister_id, canister_id]).unwrap_err();
579
580        assert_eq!(
581            error,
582            ControllerSnapshotError::DuplicateCanisterId { canister_id }
583        );
584    }
585
586    #[test]
587    fn canister_ids_are_sorted_deterministically() {
588        let first = Principal::from_slice(&[1]);
589        let second = Principal::from_slice(&[2]);
590
591        assert_eq!(
592            ordered_unique_canister_ids([second, first]).unwrap(),
593            vec![first, second]
594        );
595    }
596
597    #[test]
598    fn snapshot_restore_funding_is_explicit() {
599        assert_eq!(
600            snapshot_restore_top_up(10, SnapshotRestoreFunding::Preserve),
601            0
602        );
603        assert_eq!(
604            snapshot_restore_top_up(10, SnapshotRestoreFunding::TopUpTo { minimum_cycles: 25 }),
605            15
606        );
607        assert_eq!(
608            snapshot_restore_top_up(30, SnapshotRestoreFunding::TopUpTo { minimum_cycles: 25 }),
609            0
610        );
611    }
612}