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#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct ControllerSnapshots(BTreeMap<Principal, ControllerSnapshot>);
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub struct CanisterSnapshotTarget {
21 pub canister_id: Principal,
23 pub sender: Option<Principal>,
25}
26
27#[non_exhaustive]
29#[derive(Debug)]
30pub struct SnapshotCleanupFailure {
31 pub canister_id: Principal,
33 pub sender: Option<Principal>,
35 pub snapshot_id: Vec<u8>,
37 pub source: Box<ClientError>,
39}
40
41#[non_exhaustive]
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum SnapshotRestoreFunding {
45 Preserve,
47 TopUpTo {
49 minimum_cycles: u128,
51 },
52}
53
54#[non_exhaustive]
56#[derive(Debug, thiserror::Error)]
57pub enum ControllerSnapshotError {
58 #[error("a cached baseline requires at least one canister")]
60 EmptyCanisterSet,
61 #[error("duplicate canister id in snapshot set: {canister_id}")]
63 DuplicateCanisterId {
64 canister_id: Principal,
66 },
67 #[error("invalid snapshot for {canister_id}: {message}; {} partial snapshots could not be cleaned up", .cleanup_failures.len())]
69 InvalidSnapshot {
70 canister_id: Principal,
72 message: &'static str,
74 cleanup_failures: Vec<SnapshotCleanupFailure>,
76 },
77 #[error("snapshot codec failed for {canister_id}: {source}")]
79 SnapshotCodec {
80 canister_id: Principal,
82 source: ic_platform::candid::contract::Error,
84 cleanup_failures: Vec<SnapshotCleanupFailure>,
86 },
87 #[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_id: Principal,
92 sender: Option<Principal>,
94 source: Box<ClientError>,
96 cleanup_failures: Vec<SnapshotCleanupFailure>,
98 },
99 #[error("failed to restore snapshot for {canister_id} with sender {sender:?}: {source}")]
101 RestoreFailed {
102 canister_id: Principal,
104 sender: Option<Principal>,
106 source: Box<ClientError>,
108 },
109}
110
111#[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 async fn capture_snapshots<I>(
137 &self,
138 targets: I,
139 ) -> Result<ControllerSnapshots, ControllerSnapshotError>
140 where
141 I: IntoIterator<Item = CanisterSnapshotTarget>;
142
143 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 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 #[must_use]
260 pub fn len(&self) -> usize {
261 self.0.len()
262 }
263
264 #[must_use]
266 pub fn is_empty(&self) -> bool {
267 self.0.is_empty()
268 }
269
270 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;