Skip to main content

ic_testkit/pic/
snapshot.rs

1use std::collections::BTreeMap;
2
3use candid::Principal;
4use pocket_ic::{PocketIc, RejectResponse};
5
6use super::{PocketIcOperationError, transport};
7
8#[derive(Clone, Debug, Eq, PartialEq)]
9struct ControllerSnapshot {
10    snapshot_id: Vec<u8>,
11    sender: Option<Principal>,
12}
13
14/// Deterministically ordered snapshots retaining each successful capture sender.
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct ControllerSnapshots(BTreeMap<Principal, ControllerSnapshot>);
17
18/// One canister and exact management-call sender used for snapshot capture.
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub struct CanisterSnapshotTarget {
21    canister_id: Principal,
22    sender: Option<Principal>,
23}
24
25/// Failure to remove a snapshot while rolling back a partial capture.
26#[non_exhaustive]
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub enum SnapshotCleanupFailure {
29    /// PocketIC rejected snapshot deletion with the exact captured sender.
30    Rejected {
31        /// Canister whose snapshot could not be removed.
32        canister_id: Principal,
33        /// Exact sender selected for cleanup.
34        sender: Option<Principal>,
35        /// Complete upstream rejection.
36        response: Box<RejectResponse>,
37    },
38    /// PocketIC panicked while deleting a captured snapshot.
39    Panicked {
40        /// Canister whose snapshot could not be removed.
41        canister_id: Principal,
42        /// Exact sender selected for cleanup.
43        sender: Option<Principal>,
44        /// Original operation failure, retaining transport classification.
45        source: PocketIcOperationError,
46    },
47}
48
49/// Caller-selected cycle funding applied immediately before snapshot restore.
50#[non_exhaustive]
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52pub enum SnapshotRestoreFunding {
53    /// Do not add cycles before the restore operation.
54    Preserve,
55    /// Add cycles only when needed to reach the given minimum balance.
56    TopUpTo {
57        /// Minimum balance established immediately before each restore.
58        minimum_cycles: u128,
59    },
60}
61
62/// Structured controller-snapshot failure.
63#[non_exhaustive]
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub enum ControllerSnapshotError {
66    /// A cached baseline requires at least one canister.
67    EmptyCanisterSet,
68    /// Input contained the same canister more than once.
69    DuplicateCanisterId {
70        /// Repeated canister id.
71        canister_id: Principal,
72    },
73    /// PocketIC rejected the exact sender selected for capture.
74    CaptureFailed {
75        /// Canister whose capture failed.
76        canister_id: Principal,
77        /// Exact sender used for the rejected call.
78        sender: Option<Principal>,
79        /// Complete upstream rejection.
80        response: Box<RejectResponse>,
81        /// Failures while deleting snapshots captured earlier in the set.
82        cleanup_failures: Vec<SnapshotCleanupFailure>,
83    },
84    /// PocketIC panicked while capturing a snapshot.
85    CapturePanicked {
86        /// Canister whose capture panicked.
87        canister_id: Principal,
88        /// Original operation failure and transport classification.
89        source: PocketIcOperationError,
90        /// Failures while deleting snapshots captured earlier in the set.
91        cleanup_failures: Vec<SnapshotCleanupFailure>,
92    },
93    /// PocketIC rejected the retained capture sender during restore.
94    RestoreFailed {
95        /// Canister whose restore failed.
96        canister_id: Principal,
97        /// Exact sender used for the rejected call.
98        sender: Option<Principal>,
99        /// Complete upstream rejection.
100        response: Box<RejectResponse>,
101    },
102    /// PocketIC panicked while restoring a snapshot.
103    RestorePanicked {
104        /// Canister whose restore panicked.
105        canister_id: Principal,
106        /// Original operation failure and transport classification.
107        source: PocketIcOperationError,
108    },
109}
110
111enum SnapshotCaptureFailure {
112    Rejected(RejectResponse),
113    Panicked(PocketIcOperationError),
114}
115
116/// Exact-sender capture and restore of related canister snapshots.
117pub trait PocketIcSnapshotExt {
118    /// Capture snapshots with one explicit sender per canister.
119    ///
120    /// Input is duplicate-checked before any effects and captured in deterministic
121    /// canister-id order. A later failure rolls back snapshots captured earlier
122    /// in the set. No other sender is attempted after a rejection.
123    fn capture_snapshots<I>(
124        &self,
125        targets: I,
126    ) -> Result<ControllerSnapshots, ControllerSnapshotError>
127    where
128        I: IntoIterator<Item = CanisterSnapshotTarget>;
129
130    /// Restore each snapshot with its exact captured sender and selected funding.
131    ///
132    /// `Preserve` never adds cycles; `TopUpTo` is evaluated before each restore.
133    /// PocketIC may charge cycles for the restore itself. Rejection or panic stops
134    /// the set without retrying another sender or rewinding earlier restores.
135    fn restore_snapshots(
136        &self,
137        snapshots: &ControllerSnapshots,
138        funding: SnapshotRestoreFunding,
139    ) -> Result<(), ControllerSnapshotError>;
140}
141
142impl PocketIcSnapshotExt for PocketIc {
143    fn capture_snapshots<I>(
144        &self,
145        targets: I,
146    ) -> Result<ControllerSnapshots, ControllerSnapshotError>
147    where
148        I: IntoIterator<Item = CanisterSnapshotTarget>,
149    {
150        capture_snapshot_set(self, targets)
151    }
152
153    fn restore_snapshots(
154        &self,
155        snapshots: &ControllerSnapshots,
156        funding: SnapshotRestoreFunding,
157    ) -> Result<(), ControllerSnapshotError> {
158        for (canister_id, snapshot_id, sender) in snapshots.iter() {
159            restore_controller_snapshot(self, canister_id, snapshot_id, funding, sender)?;
160        }
161        Ok(())
162    }
163}
164
165impl CanisterSnapshotTarget {
166    /// Select one canister and exact sender for snapshot capture.
167    #[must_use]
168    pub const fn new(canister_id: Principal, sender: Option<Principal>) -> Self {
169        Self {
170            canister_id,
171            sender,
172        }
173    }
174
175    /// Canister whose snapshot will be captured.
176    #[must_use]
177    pub const fn canister_id(self) -> Principal {
178        self.canister_id
179    }
180
181    /// Exact management-call sender, including `None` for the default sender.
182    #[must_use]
183    pub const fn sender(self) -> Option<Principal> {
184        self.sender
185    }
186}
187
188fn capture_snapshot_set<I>(
189    pocket_ic: &PocketIc,
190    targets: I,
191) -> Result<ControllerSnapshots, ControllerSnapshotError>
192where
193    I: IntoIterator<Item = CanisterSnapshotTarget>,
194{
195    // Validate the entire input before issuing any management calls. The map
196    // also owns the deterministic canister order.
197    let mut ordered_targets = BTreeMap::new();
198    for target in targets {
199        if ordered_targets
200            .insert(target.canister_id, target.sender)
201            .is_some()
202        {
203            return Err(ControllerSnapshotError::DuplicateCanisterId {
204                canister_id: target.canister_id,
205            });
206        }
207    }
208    let mut snapshots = BTreeMap::new();
209    for (canister_id, sender) in ordered_targets {
210        let failure = match try_take_snapshot(pocket_ic, canister_id, sender) {
211            Ok(snapshot) => {
212                snapshots.insert(canister_id, snapshot);
213                continue;
214            }
215            Err(failure) => failure,
216        };
217        let cleanup_failures =
218            cleanup_captured_snapshots(snapshots, |canister_id, sender, snapshot_id| {
219                pocket_ic.delete_canister_snapshot(canister_id, sender, snapshot_id)
220            });
221        return Err(match failure {
222            SnapshotCaptureFailure::Rejected(response) => ControllerSnapshotError::CaptureFailed {
223                canister_id,
224                sender,
225                response: Box::new(response),
226                cleanup_failures,
227            },
228            SnapshotCaptureFailure::Panicked(source) => ControllerSnapshotError::CapturePanicked {
229                canister_id,
230                source,
231                cleanup_failures,
232            },
233        });
234    }
235    Ok(ControllerSnapshots(snapshots))
236}
237
238impl ControllerSnapshots {
239    /// Return the number of captured canisters.
240    #[must_use]
241    pub fn len(&self) -> usize {
242        self.0.len()
243    }
244
245    /// Report whether the set contains no snapshots.
246    #[must_use]
247    pub fn is_empty(&self) -> bool {
248        self.0.is_empty()
249    }
250
251    /// Iterate over captured canister ids in deterministic principal order.
252    pub fn canister_ids(&self) -> impl Iterator<Item = Principal> + '_ {
253        self.0.keys().copied()
254    }
255
256    pub(super) fn iter(&self) -> impl Iterator<Item = (Principal, &[u8], Option<Principal>)> + '_ {
257        self.0.iter().map(|(canister_id, snapshot)| {
258            (
259                *canister_id,
260                snapshot.snapshot_id.as_slice(),
261                snapshot.sender,
262            )
263        })
264    }
265}
266
267impl std::fmt::Display for ControllerSnapshotError {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        match self {
270            Self::EmptyCanisterSet => {
271                f.write_str("a cached baseline requires at least one canister")
272            }
273            Self::DuplicateCanisterId { canister_id } => {
274                write!(f, "duplicate canister id in snapshot set: {canister_id}")
275            }
276            Self::CaptureFailed {
277                canister_id,
278                sender,
279                response,
280                cleanup_failures,
281            } => write!(
282                f,
283                "failed to capture snapshot for {canister_id} with sender {sender:?}: {response:?}; {} partial snapshots could not be cleaned up",
284                cleanup_failures.len()
285            ),
286            Self::CapturePanicked {
287                canister_id,
288                source,
289                cleanup_failures,
290            } => write!(
291                f,
292                "snapshot capture panicked for {canister_id}: {source}; {} partial snapshots could not be cleaned up",
293                cleanup_failures.len()
294            ),
295            Self::RestoreFailed {
296                canister_id,
297                sender,
298                response,
299            } => write!(
300                f,
301                "failed to restore snapshot for {canister_id} with sender {sender:?}: {response:?}",
302            ),
303            Self::RestorePanicked {
304                canister_id,
305                source,
306            } => write!(f, "snapshot restore panicked for {canister_id}: {source}"),
307        }
308    }
309}
310
311impl std::error::Error for ControllerSnapshotError {
312    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
313        match self {
314            Self::CapturePanicked { source, .. } | Self::RestorePanicked { source, .. } => {
315                Some(source)
316            }
317            _ => None,
318        }
319    }
320}
321
322fn try_take_snapshot(
323    pocket_ic: &PocketIc,
324    canister_id: Principal,
325    sender: Option<Principal>,
326) -> Result<ControllerSnapshot, SnapshotCaptureFailure> {
327    let snapshot = transport::capture_operation(|| {
328        pocket_ic.take_canister_snapshot(canister_id, sender, None)
329    })
330    .map_err(SnapshotCaptureFailure::Panicked)?
331    .map_err(SnapshotCaptureFailure::Rejected)?;
332    Ok(ControllerSnapshot {
333        snapshot_id: snapshot.id,
334        sender,
335    })
336}
337
338fn cleanup_captured_snapshots(
339    snapshots: BTreeMap<Principal, ControllerSnapshot>,
340    mut remove: impl FnMut(Principal, Option<Principal>, Vec<u8>) -> Result<(), RejectResponse>,
341) -> Vec<SnapshotCleanupFailure> {
342    let mut failures = Vec::new();
343    for (canister_id, snapshot) in snapshots {
344        let cleanup = transport::capture_operation(|| {
345            remove(canister_id, snapshot.sender, snapshot.snapshot_id)
346        });
347        match cleanup {
348            Ok(Ok(())) => {}
349            Ok(Err(response)) => failures.push(SnapshotCleanupFailure::Rejected {
350                canister_id,
351                sender: snapshot.sender,
352                response: Box::new(response),
353            }),
354            Err(source) => failures.push(SnapshotCleanupFailure::Panicked {
355                canister_id,
356                sender: snapshot.sender,
357                source,
358            }),
359        }
360    }
361    failures
362}
363
364fn restore_controller_snapshot(
365    pocket_ic: &PocketIc,
366    canister_id: Principal,
367    snapshot_id: &[u8],
368    funding: SnapshotRestoreFunding,
369    sender: Option<Principal>,
370) -> Result<(), ControllerSnapshotError> {
371    transport::capture_operation(|| {
372        apply_snapshot_restore_funding(pocket_ic, canister_id, funding);
373        pocket_ic.load_canister_snapshot(canister_id, sender, snapshot_id.to_vec())
374    })
375    .map_err(|source| ControllerSnapshotError::RestorePanicked {
376        canister_id,
377        source,
378    })?
379    .map_err(|response| ControllerSnapshotError::RestoreFailed {
380        canister_id,
381        sender,
382        response: Box::new(response),
383    })
384}
385
386fn apply_snapshot_restore_funding(
387    pocket_ic: &PocketIc,
388    canister_id: Principal,
389    funding: SnapshotRestoreFunding,
390) {
391    if funding == SnapshotRestoreFunding::Preserve {
392        return;
393    }
394
395    let balance = pocket_ic.cycle_balance(canister_id);
396    let top_up = snapshot_restore_top_up(balance, funding);
397    if top_up > 0 {
398        let _ = pocket_ic.add_cycles(canister_id, top_up);
399    }
400}
401
402const fn snapshot_restore_top_up(balance: u128, funding: SnapshotRestoreFunding) -> u128 {
403    match funding {
404        SnapshotRestoreFunding::Preserve => 0,
405        SnapshotRestoreFunding::TopUpTo { minimum_cycles } => {
406            minimum_cycles.saturating_sub(balance)
407        }
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::{SnapshotRestoreFunding, snapshot_restore_top_up};
414
415    #[test]
416    fn cleanup_preserves_causes_and_senders_and_continues_after_each_failure() {
417        use super::{ControllerSnapshot, SnapshotCleanupFailure, cleanup_captured_snapshots};
418        use candid::Principal;
419        use pocket_ic::{ErrorCode, RejectCode, RejectResponse};
420
421        let ids = [1, 2, 3, 4].map(|byte| Principal::from_slice(&[byte]));
422        let sender = Some(Principal::anonymous());
423        let snapshots = ids
424            .map(|id| {
425                (
426                    id,
427                    ControllerSnapshot {
428                        snapshot_id: vec![7],
429                        sender,
430                    },
431                )
432            })
433            .into_iter()
434            .collect();
435        let rejection = RejectResponse {
436            reject_code: RejectCode::CanisterReject,
437            reject_message: "cleanup rejected".into(),
438            error_code: ErrorCode::CanisterCalledTrap,
439            certified: false,
440        };
441        let transport = "reqwest::Error { kind: Request, url: \"http://127.0.0.1:1234/instances/0/update/delete_canister_snapshot\", source: hyper::Error(IncompleteMessage) }";
442        let mut calls = Vec::new();
443        let failures = cleanup_captured_snapshots(snapshots, |id, selected_sender, snapshot_id| {
444            assert_eq!(snapshot_id, [7]);
445            calls.push((id, selected_sender));
446            if id == ids[1] {
447                return Err(rejection.clone());
448            }
449            if id == ids[2] {
450                std::panic::panic_any(transport.to_owned());
451            }
452            Ok(())
453        });
454        assert_eq!(calls, ids.map(|id| (id, sender)));
455        let [
456            SnapshotCleanupFailure::Rejected {
457                canister_id: rejected_id,
458                sender: rejected_sender,
459                response,
460            },
461            SnapshotCleanupFailure::Panicked {
462                canister_id: panicked_id,
463                sender: panicked_sender,
464                source,
465            },
466        ] = failures.as_slice()
467        else {
468            panic!("expected both cleanup failures: {failures:?}");
469        };
470        assert_eq!(*rejected_id, ids[1]);
471        assert_eq!(*rejected_sender, sender);
472        assert_eq!(response.as_ref(), &rejection);
473        assert_eq!(*panicked_id, ids[2]);
474        assert_eq!(*panicked_sender, sender);
475        assert!(source.is_transport());
476        assert_eq!(source.message(), transport);
477    }
478
479    #[test]
480    fn snapshot_restore_funding_is_explicit() {
481        assert_eq!(
482            snapshot_restore_top_up(10, SnapshotRestoreFunding::Preserve),
483            0
484        );
485        assert_eq!(
486            snapshot_restore_top_up(10, SnapshotRestoreFunding::TopUpTo { minimum_cycles: 25 }),
487            15
488        );
489        assert_eq!(
490            snapshot_restore_top_up(30, SnapshotRestoreFunding::TopUpTo { minimum_cycles: 25 }),
491            0
492        );
493    }
494}