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