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