Skip to main content

ic_backup/model/ic_snapshot_download/
mod.rs

1//! Bounded metadata-derived exact download plans; no calls, spending or transfer proof.
2
3use crate::model::{
4    attempt_journal::{AttemptBudgetRecord, AttemptJournalRecordError},
5    effect_graph::{EffectGraphError, EffectGraphRecord, EffectNodeRecord, EffectNodeRequest},
6    execution_workflow::{
7        ExecutionStageBindingRecord, ExecutionStagePredecessorRecord, ExecutionWorkflowError,
8        ExecutionWorkflowRecord,
9    },
10    ic_snapshot_data::{
11        IcSnapshotDataError, IcSnapshotDataRequest, MAX_IC_SNAPSHOT_DATA_CHUNK_BYTES,
12    },
13    ic_snapshot_metadata::IcSnapshotMetadataReply,
14    operation_plan::{
15        OperationPlanError, OperationPlanRecord, OperationPlanRequest, PlanBudgetRecord,
16        PlannedOperationRecord, PlannedOperationRequest,
17    },
18};
19use ic_management_canister_types::SnapshotDataKind;
20use thiserror::Error;
21
22/// Exact finite data requests and their original allocation-bound ordinary child plan.
23///
24/// Requests cover module, heap and stable regions in that order, then every known
25/// chunk in original metadata order. Empty regions need no requests; even empty
26/// known chunks require a hash-checked reply. Opaque operation IDs are zero-based
27/// ordinals with explicit sequential dependencies for the contiguous writer.
28/// Every request has one mutation-lane replicated update and zero recovery calls.
29/// Lost replies therefore stop with pending spending; this is not a retry schedule.
30/// The child aggregate retains original stage ceilings and leaves spare allowance
31/// unassigned. An entirely read-free snapshot has no child plan or binding.
32pub struct IcSnapshotDownloadPlan<'workflow, 'metadata> {
33    workflow: &'workflow ExecutionWorkflowRecord,
34    sequence: u64,
35    metadata: &'metadata IcSnapshotMetadataReply<'metadata>,
36    requests: Vec<IcSnapshotDataRequest<'metadata>>,
37    plan: Option<OperationPlanRecord>,
38}
39impl<'workflow, 'metadata> IcSnapshotDownloadPlan<'workflow, 'metadata> {
40    /// Derive exact data payloads within the original target and mutation allocation.
41    ///
42    /// Count checked region ceilings and known chunks before allocating/encoding
43    /// requests. No iteration or allocation grows with an unadmitted remote nat64.
44    /// Fresh access, authentic metadata/extent custody and execution remain separate.
45    /// # Errors
46    /// Rejects unknown/wrong stages, invalid chunks, arithmetic overflow, insufficient
47    /// original allowance and existing bounded request/plan admission failures.
48    pub fn new(
49        workflow: &'workflow ExecutionWorkflowRecord,
50        sequence: u64,
51        metadata: &'metadata IcSnapshotMetadataReply<'metadata>,
52        chunk_bytes: u64,
53    ) -> Result<Self, IcSnapshotDownloadPlanningError> {
54        let stage = workflow.stage(sequence)?;
55        if stage.target() != metadata.request().target() {
56            return Err(IcSnapshotDownloadPlanningError::MetadataMismatch);
57        }
58        if chunk_bytes == 0 || chunk_bytes > MAX_IC_SNAPSHOT_DATA_CHUNK_BYTES as u64 {
59            return Err(IcSnapshotDownloadPlanningError::InvalidChunkSize);
60        }
61        let values = metadata.metadata();
62        let sizes = [
63            values.wasm_module_size,
64            values.wasm_memory_size,
65            values.stable_memory_size,
66        ];
67        let mut count = u64::try_from(values.wasm_chunk_store.len())
68            .map_err(|_| IcSnapshotDownloadPlanningError::CountOverflow)?;
69        for size in sizes {
70            let region = size / chunk_bytes + u64::from(size % chunk_bytes != 0);
71            count = count
72                .checked_add(region)
73                .ok_or(IcSnapshotDownloadPlanningError::CountOverflow)?;
74        }
75        if count > u64::from(stage.budget().mutations()) {
76            return Err(IcSnapshotDownloadPlanningError::InsufficientAllowance {
77                required: count,
78                original: stage.budget().mutations(),
79            });
80        }
81        let capacity =
82            usize::try_from(count).map_err(|_| IcSnapshotDownloadPlanningError::CountOverflow)?;
83        let mut requests = Vec::with_capacity(capacity);
84        for (region, total) in sizes.into_iter().enumerate() {
85            let mut offset = 0;
86            while offset < total {
87                let size = (total - offset).min(chunk_bytes);
88                let kind = match region {
89                    0 => SnapshotDataKind::WasmModule { offset, size },
90                    1 => SnapshotDataKind::WasmMemory { offset, size },
91                    _ => SnapshotDataKind::StableMemory { offset, size },
92                };
93                requests.push(IcSnapshotDataRequest::new(metadata, kind)?);
94                offset += size; // The admitted size never exceeds total - offset.
95            }
96        }
97        for chunk in &values.wasm_chunk_store {
98            requests.push(IcSnapshotDataRequest::new(
99                metadata,
100                SnapshotDataKind::WasmChunk {
101                    hash: chunk.hash.clone(),
102                },
103            )?);
104        }
105        let plan = if requests.is_empty() {
106            None
107        } else {
108            let mut nodes = Vec::with_capacity(capacity);
109            let mut operations = Vec::with_capacity(capacity);
110            for (index, request) in requests.iter().enumerate() {
111                let sequence = u64::try_from(index)
112                    .map_err(|_| IcSnapshotDownloadPlanningError::CountOverflow)?;
113                nodes.push(EffectNodeRecord::new(EffectNodeRequest {
114                    operation_sequence: sequence,
115                    depends_on: sequence.checked_sub(1).into_iter().collect(),
116                })?);
117                operations.push(PlannedOperationRecord::new(PlannedOperationRequest {
118                    operation_sequence: sequence,
119                    target: stage.target().into(),
120                    request: request.digest().hash().into(),
121                    budget: AttemptBudgetRecord::new(1, 0)?,
122                })?);
123            }
124            let original = workflow.allocation();
125            Some(OperationPlanRecord::new(OperationPlanRequest {
126                context: original.context().clone(),
127                inventory: original.inventory().clone(),
128                selected_targets: vec![stage.target().into()],
129                graph: EffectGraphRecord::new(nodes)?,
130                operations,
131                budget: PlanBudgetRecord::new(
132                    stage.budget().mutations(),
133                    stage.budget().observations(),
134                )?,
135            })?)
136        };
137        Ok(Self {
138            workflow,
139            sequence,
140            metadata,
141            requests,
142            plan,
143        })
144    }
145    /// Read exact ordered requests; operation IDs are their zero-based indices.
146    #[must_use]
147    pub fn requests(&self) -> &[IcSnapshotDataRequest<'metadata>] {
148        &self.requests
149    }
150    /// Read the original-allocation child plan. None means no data calls are needed,
151    /// not that capture, durable publication or complete transfer is qualified.
152    #[must_use]
153    pub const fn plan(&self) -> Option<&OperationPlanRecord> {
154        self.plan.as_ref()
155    }
156    /// Bind the exact original metadata stage and reply evidence before stage creation.
157    ///
158    /// The source stage must contain exactly one metadata request under this original
159    /// workflow. Its direct predecessor row must retain its binding and this exact
160    /// metadata request/raw-reply evidence digest. Existing stage persistence still
161    /// admits the complete Applied source journals and settlement. These checks do
162    /// not authenticate metadata or prove that an opaque receipt describes its reply.
163    /// # Errors
164    /// Rejects a read-free plan, wrong metadata stage/plan/input or original dependencies.
165    pub fn bind(
166        &self,
167        metadata_binding: &ExecutionStageBindingRecord,
168        metadata_plan: &OperationPlanRecord,
169        predecessors: Vec<ExecutionStagePredecessorRecord>,
170    ) -> Result<ExecutionStageBindingRecord, IcSnapshotDownloadPlanningError> {
171        let plan = self
172            .plan
173            .as_ref()
174            .ok_or(IcSnapshotDownloadPlanningError::NoDataReads)?;
175        metadata_binding.validate(self.workflow, metadata_plan)?;
176        if metadata_plan.operations().len() != 1
177            || metadata_plan.operations()[0].request() != self.metadata.request().digest().hash()
178            || !predecessors.iter().any(|row| {
179                row.stage_sequence() == metadata_binding.stage_sequence()
180                    && row.binding() == &metadata_binding.digest()
181                    && row.learned_evidence() == &self.metadata.digest()
182            })
183        {
184            return Err(IcSnapshotDownloadPlanningError::MetadataMismatch);
185        }
186        Ok(ExecutionStageBindingRecord::new(
187            self.workflow,
188            self.sequence,
189            plan,
190            predecessors,
191        )?)
192    }
193}
194
195/// Pure planning failure; no filesystem, provider, reservation or receipt changes.
196#[derive(Debug, Error)]
197pub enum IcSnapshotDownloadPlanningError {
198    /// Chunk size is outside the existing 1..=1 MiB request boundary.
199    #[error("invalid snapshot download chunk size")]
200    InvalidChunkSize,
201    /// Combined remote declared region counts cannot fit nat64.
202    #[error("snapshot download request count overflow")]
203    CountOverflow,
204    /// Original stage cannot pay for every exact required data request.
205    #[error("snapshot download needs {required} updates; original allocation is {original}")]
206    InsufficientAllowance {
207        /// Complete exact required update count.
208        required: u64,
209        /// Original stage mutation ceiling, excluding workflow headroom.
210        original: u32,
211    },
212    /// Target, original metadata stage/request or exact reply evidence changed.
213    #[error("snapshot download metadata differs from original stage")]
214    MetadataMismatch,
215    /// There is no data call to bind; no placeholder operation is created.
216    #[error("snapshot download has no data reads to bind")]
217    NoDataReads,
218    /// Canonical original plan or allocation rejected.
219    #[error(transparent)]
220    Plan(#[from] OperationPlanError),
221    /// Existing explicit graph owner rejected.
222    #[error(transparent)]
223    Graph(#[from] EffectGraphError),
224    /// Existing immutable stage owner rejected.
225    #[error(transparent)]
226    Workflow(#[from] ExecutionWorkflowError),
227    /// Existing exact metadata-bound data request owner rejected.
228    #[error(transparent)]
229    Data(#[from] IcSnapshotDataError),
230    /// Existing per-operation budget owner rejected.
231    #[error(transparent)]
232    Budget(#[from] AttemptJournalRecordError),
233}
234
235#[cfg(test)]
236mod tests;