Skip to main content

ic_backup/model/ic_snapshot_upload/plan/
mod.rs

1//! Exact bounded data-upload declarations, retaining no aggregate payload bytes.
2
3use super::{
4    IcSnapshotUploadError, IcSnapshotUploadReply, IcSnapshotUploadReplyKind,
5    IcSnapshotUploadRequest,
6};
7use crate::model::{
8    artifacts::ArtifactChecksumRecord,
9    attempt_journal::AttemptJournalRecordError,
10    data_stage_plan::{self, DataStagePlanError, LearnedDataStage},
11    effect_graph::EffectGraphError,
12    execution_workflow::{
13        ExecutionStageBindingRecord, ExecutionStagePredecessorRecord, ExecutionWorkflowError,
14        ExecutionWorkflowRecord,
15    },
16    ic_snapshot_data::{ExtentPlanningError, planned_extents},
17    operation_plan::{OperationPlanError, OperationPlanRecord, PlannedOperationRecord},
18};
19use ic_platform::management_canister_types::SnapshotDataKind;
20use thiserror::Error;
21
22/// Complete ordered extents and source-bound request digests for one original data stage.
23///
24/// Guarded local source preparation creates this declaration. Module, heap and stable
25/// regions precede known chunks in original metadata order. Every operation has one
26/// update and zero observations with an explicit predecessor dependency. Original
27/// stage headroom stays unassigned. No upload bytes, spending, authenticated allocation
28/// or complete-transfer proof are retained here. Empty data needs no placeholder plan.
29pub struct IcSnapshotDataUploadPlan<'workflow, 'request, 'source> {
30    binding: LearnedDataStage<'workflow>,
31    metadata: &'request IcSnapshotUploadRequest<'source>,
32    destination: Vec<u8>,
33    writes: DataWrites,
34}
35
36// Empty data has neither extents nor a placeholder plan; writes retain both together.
37#[expect(
38    clippy::large_enum_variant,
39    reason = "keep the original plan inline rather than add a heap allocation to every nonempty upload"
40)]
41enum DataWrites {
42    Empty,
43    Writes {
44        kinds: Vec<SnapshotDataKind>,
45        plan: OperationPlanRecord,
46    },
47}
48// Completion records the admitted operation, never a new effect or persisted mode.
49#[derive(Clone)]
50pub(crate) enum UploadCompletion {
51    Data,
52    Allocation {
53        sequence: u64,
54        evidence: ArtifactChecksumRecord,
55    },
56}
57
58// Carry the admitted allocation and bounded extents across guarded source IO.
59// Only finish can turn their exact ordered byte bindings into a public plan.
60pub(crate) struct DataUploadPreparation<'workflow, 'request, 'source> {
61    workflow: &'workflow ExecutionWorkflowRecord,
62    stage: &'workflow PlannedOperationRecord,
63    metadata: &'request IcSnapshotUploadRequest<'source>,
64    destination: Vec<u8>,
65    allocation_evidence: ArtifactChecksumRecord,
66    kinds: Vec<SnapshotDataKind>,
67}
68
69impl<'workflow, 'request, 'source> DataUploadPreparation<'workflow, 'request, 'source> {
70    pub(crate) fn new(
71        workflow: &'workflow ExecutionWorkflowRecord,
72        sequence: u64,
73        allocation: &IcSnapshotUploadReply<'request, 'source>,
74        chunk_bytes: u64,
75    ) -> Result<Self, IcSnapshotDataUploadPlanningError> {
76        let metadata = allocation.request();
77        let stage = workflow.stage(sequence)?;
78        let IcSnapshotUploadReplyKind::Metadata { snapshot_id } = allocation.kind() else {
79            return Err(IcSnapshotDataUploadPlanningError::OriginalMismatch);
80        };
81        metadata.validate_data_destination(snapshot_id)?;
82        if stage.target() != metadata.target()
83            || !super::attempt::source_context_matches(workflow.allocation(), metadata)
84        {
85            return Err(IcSnapshotDataUploadPlanningError::OriginalMismatch);
86        }
87        let kinds = planned_extents(metadata.source(), chunk_bytes, stage.budget().mutations())?;
88        Ok(Self {
89            workflow,
90            stage,
91            metadata,
92            destination: snapshot_id.clone(),
93            allocation_evidence: allocation.digest(),
94            kinds,
95        })
96    }
97
98    pub(crate) const fn metadata(&self) -> &'request IcSnapshotUploadRequest<'source> {
99        self.metadata
100    }
101    pub(crate) fn destination(&self) -> &[u8] {
102        &self.destination
103    }
104    pub(crate) fn kinds(&self) -> &[SnapshotDataKind] {
105        &self.kinds
106    }
107
108    pub(crate) fn finish(
109        self,
110        bindings: &[ArtifactChecksumRecord],
111    ) -> Result<
112        IcSnapshotDataUploadPlan<'workflow, 'request, 'source>,
113        IcSnapshotDataUploadPlanningError,
114    > {
115        if bindings.len() != self.kinds.len() {
116            return Err(IcSnapshotDataUploadPlanningError::OriginalMismatch);
117        }
118        let plan = data_stage_plan::build(
119            self.workflow.allocation(),
120            self.stage,
121            bindings.iter().cloned(),
122        )?;
123        Ok(IcSnapshotDataUploadPlan {
124            binding: LearnedDataStage {
125                workflow: self.workflow,
126                sequence: self.stage.operation_sequence(),
127                evidence: self.allocation_evidence,
128            },
129            metadata: self.metadata,
130            destination: self.destination,
131            writes: match plan {
132                Some(plan) => DataWrites::Writes {
133                    kinds: self.kinds,
134                    plan,
135                },
136                None => DataWrites::Empty,
137            },
138        })
139    }
140}
141
142impl<'request, 'source> IcSnapshotDataUploadPlan<'_, 'request, 'source> {
143    pub(crate) fn workflow_digest(&self) -> ArtifactChecksumRecord {
144        self.binding.workflow.digest()
145    }
146    pub(crate) fn validate_completion(
147        &self,
148        binding: &ExecutionStageBindingRecord,
149        plan: &OperationPlanRecord,
150        learned: &ArtifactChecksumRecord,
151    ) -> Result<UploadCompletion, IcSnapshotDataUploadPlanningError> {
152        if let Some(expected) = self.plan() {
153            self.validate_binding(binding)?;
154            if plan != expected {
155                return Err(IcSnapshotDataUploadPlanningError::OriginalMismatch);
156            }
157            Ok(UploadCompletion::Data)
158        } else {
159            binding.validate(self.binding.workflow, plan)?;
160            let [operation] = plan.operations() else {
161                return Err(IcSnapshotDataUploadPlanningError::OriginalMismatch);
162            };
163            if operation.target() != self.metadata.target()
164                || operation.request() != self.metadata.binding_digest().hash()
165                || learned != &self.binding.evidence
166            {
167                return Err(IcSnapshotDataUploadPlanningError::OriginalMismatch);
168            }
169            Ok(UploadCompletion::Allocation {
170                sequence: operation.operation_sequence(),
171                evidence: self.binding.evidence.clone(),
172            })
173        }
174    }
175    /// Read exact ordered source extents. Operation sequences are zero-based indices.
176    #[must_use]
177    pub fn kinds(&self) -> &[SnapshotDataKind] {
178        match &self.writes {
179            DataWrites::Empty => &[],
180            DataWrites::Writes { kinds, .. } => kinds,
181        }
182    }
183    /// Read the original metadata allocation declaration and retained source association.
184    #[must_use]
185    pub const fn metadata(&self) -> &'request IcSnapshotUploadRequest<'source> {
186        self.metadata
187    }
188    /// Read exact declared destination bytes; they grant no allocation authority.
189    #[must_use]
190    pub fn destination(&self) -> &[u8] {
191        &self.destination
192    }
193    /// Read the immutable child plan, or None when there are no data writes.
194    #[must_use]
195    pub const fn plan(&self) -> Option<&OperationPlanRecord> {
196        match &self.writes {
197            DataWrites::Empty => None,
198            DataWrites::Writes { plan, .. } => Some(plan),
199        }
200    }
201
202    /// Bind the exact original singleton allocation and request/raw-reply evidence.
203    ///
204    /// Existing stage persistence requires complete Applied predecessor journals and
205    /// their chronological checkpoint. This check authenticates neither allocation
206    /// attribution nor byte custody and grants no dispatch/load/start authority.
207    /// # Errors
208    /// Rejects an empty plan, changed allocation/request/evidence or original dependency.
209    pub fn bind(
210        &self,
211        allocation_binding: &ExecutionStageBindingRecord,
212        allocation_plan: &OperationPlanRecord,
213        predecessors: Vec<ExecutionStagePredecessorRecord>,
214    ) -> Result<ExecutionStageBindingRecord, IcSnapshotDataUploadPlanningError> {
215        let plan = self
216            .plan()
217            .ok_or(IcSnapshotDataUploadPlanningError::NoDataWrites)?;
218        self.binding
219            .bind(
220                plan,
221                allocation_binding,
222                allocation_plan,
223                self.metadata.binding_digest().hash(),
224                predecessors,
225            )?
226            .ok_or(IcSnapshotDataUploadPlanningError::OriginalMismatch)
227    }
228    pub(crate) fn validate_binding(
229        &self,
230        binding: &ExecutionStageBindingRecord,
231    ) -> Result<(), IcSnapshotDataUploadPlanningError> {
232        let plan = self
233            .plan()
234            .ok_or(IcSnapshotDataUploadPlanningError::NoDataWrites)?;
235        if !self.binding.validate(binding, plan)? {
236            return Err(IcSnapshotDataUploadPlanningError::OriginalMismatch);
237        }
238        Ok(())
239    }
240}
241
242/// Planning rejection changes no source bytes, journal, allowance or remote effects.
243#[derive(Debug, Error)]
244pub enum IcSnapshotDataUploadPlanningError {
245    /// Chunk size is outside the existing 1..=1 MiB data boundary.
246    #[error("invalid snapshot data upload chunk size")]
247    InvalidChunkSize,
248    /// Complete declared extent counts overflow nat64 or host indexing.
249    #[error("snapshot data upload count overflow")]
250    CountOverflow,
251    /// Complete data writes cannot fit their original stage allocation.
252    #[error("snapshot data upload needs {required} updates; original allocation is {original}")]
253    InsufficientAllowance {
254        /// Complete required update count, excluding metadata allocation.
255        required: u64,
256        /// Original data-stage ceiling, excluding unassigned workflow headroom.
257        original: u32,
258    },
259    /// Original context, source, allocation, request or predecessor evidence differs.
260    #[error("snapshot data upload originals differ")]
261    OriginalMismatch,
262    /// Empty data has no placeholder child plan or checkpoint.
263    #[error("snapshot data upload has no data writes to bind")]
264    NoDataWrites,
265    /// Existing immutable operation plan admission.
266    #[error(transparent)]
267    Plan(#[from] OperationPlanError),
268    /// Existing explicit graph admission.
269    #[error(transparent)]
270    Graph(#[from] EffectGraphError),
271    /// Existing original stage binding admission.
272    #[error(transparent)]
273    Workflow(#[from] ExecutionWorkflowError),
274    /// Existing exact source/destination codec admission.
275    #[error(transparent)]
276    Upload(#[from] IcSnapshotUploadError),
277    /// Existing original per-operation attempt admission.
278    #[error(transparent)]
279    Budget(#[from] AttemptJournalRecordError),
280}
281impl From<ExtentPlanningError> for IcSnapshotDataUploadPlanningError {
282    fn from(error: ExtentPlanningError) -> Self {
283        match error {
284            ExtentPlanningError::InvalidChunkSize => Self::InvalidChunkSize,
285            ExtentPlanningError::CountOverflow => Self::CountOverflow,
286            ExtentPlanningError::InsufficientAllowance { required, original } => {
287                Self::InsufficientAllowance { required, original }
288            }
289        }
290    }
291}
292
293impl From<DataStagePlanError> for IcSnapshotDataUploadPlanningError {
294    fn from(error: DataStagePlanError) -> Self {
295        match error {
296            DataStagePlanError::CountOverflow => Self::CountOverflow,
297            DataStagePlanError::Graph(error) => Self::Graph(error),
298            DataStagePlanError::Plan(error) => Self::Plan(error),
299            DataStagePlanError::Budget(error) => Self::Budget(error),
300        }
301    }
302}
303
304#[cfg(test)]
305mod tests;