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