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#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct ControllerSnapshots(BTreeMap<Principal, ControllerSnapshot>);
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub struct CanisterSnapshotTarget {
21 canister_id: Principal,
22 sender: Option<Principal>,
23}
24
25#[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#[non_exhaustive]
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37pub enum SnapshotRestoreFunding {
38 Preserve,
40 TopUpTo {
42 minimum_cycles: u128,
44 },
45}
46
47#[non_exhaustive]
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub enum ControllerSnapshotError {
51 EmptyCanisterSet,
53 DuplicateCanisterId {
55 canister_id: Principal,
57 },
58 CaptureFailed {
60 canister_id: Principal,
62 sender: Option<Principal>,
64 response: Box<RejectResponse>,
66 cleanup_failures: Vec<SnapshotCleanupFailure>,
68 },
69 CapturePanicked {
71 canister_id: Principal,
73 source: PocketIcOperationError,
75 cleanup_failures: Vec<SnapshotCleanupFailure>,
77 },
78 RestoreFailed {
80 canister_id: Principal,
82 sender: Option<Principal>,
84 response: Box<RejectResponse>,
86 },
87 RestorePanicked {
89 canister_id: Principal,
91 source: PocketIcOperationError,
93 },
94}
95
96enum SnapshotCaptureFailure {
97 Rejected(RejectResponse),
98 Panicked(PocketIcOperationError),
99}
100
101pub trait PocketIcSnapshotExt {
103 fn capture_snapshots<I>(
109 &self,
110 targets: I,
111 ) -> Result<ControllerSnapshots, ControllerSnapshotError>
112 where
113 I: IntoIterator<Item = CanisterSnapshotTarget>;
114
115 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 #[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 #[must_use]
162 pub const fn canister_id(self) -> Principal {
163 self.canister_id
164 }
165
166 #[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 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 #[must_use]
223 pub fn len(&self) -> usize {
224 self.0.len()
225 }
226
227 #[must_use]
229 pub fn is_empty(&self) -> bool {
230 self.0.is_empty()
231 }
232
233 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 #[must_use]
252 pub const fn canister_id(&self) -> Principal {
253 self.canister_id
254 }
255
256 #[must_use]
258 pub const fn sender(&self) -> Option<Principal> {
259 self.sender
260 }
261
262 #[must_use]
264 pub fn response(&self) -> Option<&RejectResponse> {
265 self.response.as_deref()
266 }
267
268 #[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}