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