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