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