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