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