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