Skip to main content

ic_testkit/pic/
snapshot.rs

1use std::collections::BTreeMap;
2
3use crate::pocket_ic::{ClientError, PocketIc};
4use ic_platform_types::principal::ExternalPrincipal as Principal;
5
6mod transfer;
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 whose snapshot will be captured.
22    pub canister_id: Principal,
23    /// Exact management-call sender; None selects `PocketIC`'s default.
24    pub sender: Option<Principal>,
25}
26
27/// Failure to remove a snapshot while rolling back a partial capture.
28#[non_exhaustive]
29#[derive(Debug)]
30pub struct SnapshotCleanupFailure {
31    /// Canister whose snapshot could not be removed.
32    pub canister_id: Principal,
33    /// Exact sender selected for cleanup.
34    pub sender: Option<Principal>,
35    /// Exact known snapshot whose deletion failed; no other ID was substituted.
36    pub snapshot_id: Vec<u8>,
37    /// Complete rejection or operation failure, retaining transport classification.
38    pub source: Box<ClientError>,
39}
40
41/// Caller-selected cycle funding applied immediately before snapshot restore.
42#[non_exhaustive]
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum SnapshotRestoreFunding {
45    /// Do not add cycles before the restore operation.
46    Preserve,
47    /// Add cycles only when needed to reach the given minimum balance.
48    TopUpTo {
49        /// Minimum balance established immediately before each restore.
50        minimum_cycles: u128,
51    },
52}
53
54/// Structured controller-snapshot failure.
55#[non_exhaustive]
56#[derive(Debug, thiserror::Error)]
57pub enum ControllerSnapshotError {
58    /// A cached baseline requires at least one canister.
59    #[error("a cached baseline requires at least one canister")]
60    EmptyCanisterSet,
61    /// Input contained the same canister more than once.
62    #[error("duplicate canister id in snapshot set: {canister_id}")]
63    DuplicateCanisterId {
64        /// Repeated canister id.
65        canister_id: Principal,
66    },
67    /// Snapshot transfer metadata or complete data admission failed.
68    #[error("invalid snapshot for {canister_id}: {message}; {} partial snapshots could not be cleaned up", .cleanup_failures.len())]
69    InvalidSnapshot {
70        /// Exact canister selected for capture.
71        canister_id: Principal,
72        /// Refused snapshot property, excluding source bytes.
73        message: &'static str,
74        /// Known snapshot deletions that failed during rollback.
75        cleanup_failures: Vec<SnapshotCleanupFailure>,
76    },
77    /// The external management snapshot message could not be encoded or decoded.
78    #[error("snapshot codec failed for {canister_id}: {source}")]
79    SnapshotCodec {
80        /// Exact canister selected for capture.
81        canister_id: Principal,
82        /// Original external Candid codec failure.
83        source: ic_platform::candid::contract::Error,
84        /// Known snapshot deletions that failed during rollback.
85        cleanup_failures: Vec<SnapshotCleanupFailure>,
86    },
87    /// Snapshot capture failed with the exact selected sender.
88    #[error("failed to capture snapshot for {canister_id} with sender {sender:?}: {source}; {} partial snapshots could not be cleaned up", .cleanup_failures.len())]
89    CaptureFailed {
90        /// Canister whose capture failed.
91        canister_id: Principal,
92        /// Exact sender selected for capture.
93        sender: Option<Principal>,
94        /// Complete rejection or operation failure.
95        source: Box<ClientError>,
96        /// Failures while deleting snapshots captured earlier in the set.
97        cleanup_failures: Vec<SnapshotCleanupFailure>,
98    },
99    /// Snapshot restore failed with the retained capture sender.
100    #[error("failed to restore snapshot for {canister_id} with sender {sender:?}: {source}")]
101    RestoreFailed {
102        /// Canister whose restore failed.
103        canister_id: Principal,
104        /// Exact sender selected for restore.
105        sender: Option<Principal>,
106        /// Complete rejection or operation failure.
107        source: Box<ClientError>,
108    },
109}
110
111/// Exact-sender capture and restore of related canister snapshots.
112#[expect(
113    async_fn_in_trait,
114    reason = "extension futures may retain caller-owned non-Send callbacks, iterators or writers; pool recipes separately require Send"
115)]
116pub trait PocketIcSnapshotExt {
117    /// Capture snapshots with one explicit sender per canister.
118    ///
119    /// The caller must stop and drain the complete target set before capture,
120    /// then restart it only after the complete set succeeds. This helper does
121    /// not stop/start canisters or rewind simulated time. Disable automatic
122    /// progress and exclude concurrent instance operations throughout the set.
123    /// Input is duplicate-checked before any effects and captured in deterministic
124    /// canister-id order. A later failure rolls back snapshots captured earlier
125    /// in the set, including known temporary transfer snapshots. No other sender
126    /// is attempted after a rejection. Upload semantics preserve the captured
127    /// global timer and low-memory hook alongside the module, globals, heap,
128    /// stable memory, certified data and chunk store. Transfer uses bounded
129    /// management messages; total work follows the captured state size.
130    ///
131    /// Cancellation cannot await rollback. Use isolated server custody or reconcile
132    /// any temporary snapshots explicitly when cancelling on a borrowed server.
133    ///
134    /// # Errors
135    /// Returns a target-admission or snapshot-capture error, retaining completed work.
136    async fn capture_snapshots<I>(
137        &self,
138        targets: I,
139    ) -> Result<ControllerSnapshots, ControllerSnapshotError>
140    where
141        I: IntoIterator<Item = CanisterSnapshotTarget>;
142
143    /// Restore each snapshot with its exact captured sender and selected funding.
144    ///
145    /// The caller must stop/drain the entire captured set before restoring and
146    /// restart only after every restore and non-snapshot reset succeeds. Automatic
147    /// progress and concurrent instance operations must remain disabled. Absolute
148    /// timer deadlines are restored, not shifted; an expired timer may run when
149    /// its canister restarts. Simulated time and lifecycle remain caller-owned.
150    ///
151    /// `Preserve` never adds cycles; `TopUpTo` is evaluated before each restore.
152    /// `PocketIC` may charge cycles for the restore itself. Rejection or panic stops
153    /// the set without retrying another sender or rewinding earlier restores.
154    ///
155    /// # Errors
156    /// Returns a funding or restoration error, retaining partial progress.
157    async fn restore_snapshots(
158        &self,
159        snapshots: &ControllerSnapshots,
160        funding: SnapshotRestoreFunding,
161    ) -> Result<(), ControllerSnapshotError>;
162}
163
164impl PocketIcSnapshotExt for PocketIc {
165    async fn capture_snapshots<I>(
166        &self,
167        targets: I,
168    ) -> Result<ControllerSnapshots, ControllerSnapshotError>
169    where
170        I: IntoIterator<Item = CanisterSnapshotTarget>,
171    {
172        capture_snapshot_set(self, targets).await
173    }
174
175    async fn restore_snapshots(
176        &self,
177        snapshots: &ControllerSnapshots,
178        funding: SnapshotRestoreFunding,
179    ) -> Result<(), ControllerSnapshotError> {
180        for (canister_id, snapshot_id, sender) in snapshots.iter() {
181            restore_controller_snapshot(self, canister_id, snapshot_id, funding, sender).await?;
182        }
183        Ok(())
184    }
185}
186
187async fn capture_snapshot_set<I>(
188    pocket_ic: &PocketIc,
189    targets: I,
190) -> Result<ControllerSnapshots, ControllerSnapshotError>
191where
192    I: IntoIterator<Item = CanisterSnapshotTarget>,
193{
194    // Validate the entire input before issuing any management calls. The map
195    // also owns the deterministic canister order.
196    let mut ordered_targets = BTreeMap::new();
197    for target in targets {
198        if ordered_targets
199            .insert(target.canister_id, target.sender)
200            .is_some()
201        {
202            return Err(ControllerSnapshotError::DuplicateCanisterId {
203                canister_id: target.canister_id,
204            });
205        }
206    }
207    let mut snapshots = BTreeMap::new();
208    for (canister_id, sender) in ordered_targets {
209        let mut pending = Vec::new();
210        let failure = match try_take_snapshot(pocket_ic, canister_id, sender, &mut pending).await {
211            Ok(snapshot) => {
212                snapshots.insert(canister_id, snapshot);
213                continue;
214            }
215            Err(failure) => failure,
216        };
217        let cleanup_failures = cleanup_captured_snapshots(
218            pending
219                .into_iter()
220                .map(|snapshot_id| {
221                    (
222                        canister_id,
223                        ControllerSnapshot {
224                            snapshot_id,
225                            sender,
226                        },
227                    )
228                })
229                .chain(snapshots),
230            |canister_id, sender, snapshot_id| {
231                pocket_ic.delete_canister_snapshot(canister_id, sender, snapshot_id)
232            },
233        )
234        .await;
235        return Err(match failure {
236            transfer::Failure::Call(failure) => ControllerSnapshotError::CaptureFailed {
237                canister_id,
238                sender,
239                source: Box::new(failure),
240                cleanup_failures,
241            },
242            transfer::Failure::Invalid(message) => ControllerSnapshotError::InvalidSnapshot {
243                canister_id,
244                message,
245                cleanup_failures,
246            },
247            transfer::Failure::Codec(source) => ControllerSnapshotError::SnapshotCodec {
248                canister_id,
249                source,
250                cleanup_failures,
251            },
252        });
253    }
254    Ok(ControllerSnapshots(snapshots))
255}
256
257impl ControllerSnapshots {
258    /// Return the number of captured canisters.
259    #[must_use]
260    pub fn len(&self) -> usize {
261        self.0.len()
262    }
263
264    /// Report whether the set contains no snapshots.
265    #[must_use]
266    pub fn is_empty(&self) -> bool {
267        self.0.is_empty()
268    }
269
270    /// Iterate over captured canister ids in deterministic principal order.
271    pub fn canister_ids(&self) -> impl Iterator<Item = Principal> + '_ {
272        self.0.keys().copied()
273    }
274
275    pub(super) fn iter(&self) -> impl Iterator<Item = (Principal, &[u8], Option<Principal>)> + '_ {
276        self.0.iter().map(|(canister_id, snapshot)| {
277            (
278                *canister_id,
279                snapshot.snapshot_id.as_slice(),
280                snapshot.sender,
281            )
282        })
283    }
284}
285
286async fn try_take_snapshot(
287    pocket_ic: &PocketIc,
288    canister_id: Principal,
289    sender: Option<Principal>,
290    pending: &mut Vec<Vec<u8>>,
291) -> Result<ControllerSnapshot, transfer::Failure> {
292    let snapshot = pocket_ic
293        .take_canister_snapshot(canister_id, sender, None)
294        .await?;
295    pending.push(snapshot.id.clone());
296    let uploaded = transfer::upload(pocket_ic, canister_id, sender, &snapshot.id, pending).await?;
297    pocket_ic
298        .delete_canister_snapshot(canister_id, sender, snapshot.id)
299        .await?;
300    pending.clear();
301    Ok(ControllerSnapshot {
302        snapshot_id: uploaded,
303        sender,
304    })
305}
306
307async fn cleanup_captured_snapshots<F, Fut>(
308    snapshots: impl IntoIterator<Item = (Principal, ControllerSnapshot)>,
309    mut remove: F,
310) -> Vec<SnapshotCleanupFailure>
311where
312    F: FnMut(Principal, Option<Principal>, Vec<u8>) -> Fut,
313    Fut: std::future::Future<Output = Result<(), ClientError>>,
314{
315    let mut failures = Vec::new();
316    for (canister_id, snapshot) in snapshots {
317        let cleanup = remove(canister_id, snapshot.sender, snapshot.snapshot_id.clone()).await;
318        if let Err(source) = cleanup {
319            failures.push(SnapshotCleanupFailure {
320                canister_id,
321                sender: snapshot.sender,
322                snapshot_id: snapshot.snapshot_id,
323                source: Box::new(source),
324            });
325        }
326    }
327    failures
328}
329
330async fn restore_controller_snapshot(
331    pocket_ic: &PocketIc,
332    canister_id: Principal,
333    snapshot_id: &[u8],
334    funding: SnapshotRestoreFunding,
335    sender: Option<Principal>,
336) -> Result<(), ControllerSnapshotError> {
337    async {
338        apply_snapshot_restore_funding(pocket_ic, canister_id, funding).await?;
339        pocket_ic
340            .load_canister_snapshot(canister_id, sender, snapshot_id.to_vec())
341            .await
342    }
343    .await
344    .map_err(|source| ControllerSnapshotError::RestoreFailed {
345        canister_id,
346        sender,
347        source: Box::new(source),
348    })
349}
350
351async fn apply_snapshot_restore_funding(
352    pocket_ic: &PocketIc,
353    canister_id: Principal,
354    funding: SnapshotRestoreFunding,
355) -> Result<(), ClientError> {
356    match funding {
357        SnapshotRestoreFunding::Preserve => {}
358        SnapshotRestoreFunding::TopUpTo { minimum_cycles } => {
359            let top_up = minimum_cycles.saturating_sub(pocket_ic.cycle_balance(canister_id).await?);
360            if top_up > 0 {
361                pocket_ic.add_cycles(canister_id, top_up).await?;
362            }
363        }
364    }
365    Ok(())
366}
367
368#[cfg(test)]
369mod tests;