Skip to main content

acyclic_fs/kernel/
transfer.rs

1//! Resumable bounded generation transfer over the asynchronous object boundary.
2
3use super::{
4    ClosureError, ClosureLimits, GenerationExportManifest, GenerationExportManifestError,
5    prove_generation_closure_async, validate_generation_export_manifest,
6};
7use crate::async_storage::AsyncObjectStore;
8use crate::cancellation::{CancellationError, CancellationToken};
9use crate::foundation::VolumeId;
10use crate::model::VolumeConfig;
11use crate::performance::{
12    MeasuredResult, OperationFailure, OperationReceipt, WorkBudget, WorkCounters, WorkError,
13};
14use crate::storage::{ObjectId, ObjectRead, ObjectReadRequest, ObjectStoreError};
15use bytes::Bytes;
16use std::mem::size_of;
17use thiserror::Error;
18
19/// Stable next-object position in a canonical generation manifest.
20#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
21pub struct TransferCursor(u64);
22
23impl TransferCursor {
24    /// Initial transfer position.
25    pub const START: Self = Self(0);
26
27    /// Constructs one manifest-relative object position.
28    #[must_use]
29    pub const fn new(next_object: u64) -> Self {
30        Self(next_object)
31    }
32
33    /// Returns the manifest-relative next-object position.
34    #[must_use]
35    pub const fn next_object(self) -> u64 {
36        self.0
37    }
38}
39
40/// One ordered bounded export page. Object identities are the corresponding
41/// manifest slice beginning at `first_object`.
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct GenerationTransferBatch {
44    /// First manifest object represented by `objects`.
45    pub first_object: TransferCursor,
46    /// Cursor for the next request, absent at the canonical end.
47    pub next: Option<TransferCursor>,
48    /// Ordered immutable bodies with explicit retention evidence.
49    pub objects: Vec<ObjectRead>,
50}
51
52/// Transfer validation, storage, closure, cancellation, and work failures.
53#[derive(Debug, Error)]
54pub enum GenerationTransferError {
55    /// Operation was cancelled before further transfer work.
56    #[error(transparent)]
57    Cancelled(#[from] CancellationError),
58    /// Manifest bytes or fields are noncanonical or out of bounds.
59    #[error(transparent)]
60    Manifest(#[from] GenerationExportManifestError),
61    /// Imported or exported closure authentication failed.
62    #[error(transparent)]
63    Closure(#[from] ClosureError),
64    /// Immutable object storage failed.
65    #[error(transparent)]
66    Object(#[from] ObjectStoreError),
67    /// Authenticated closure facts do not exactly match the manifest.
68    #[error("generation transfer manifest does not match its authenticated closure")]
69    ManifestMismatch,
70    /// Cursor is beyond the canonical manifest end.
71    #[error("generation transfer cursor is outside the manifest")]
72    InvalidCursor,
73    /// A nonterminal transfer page must request at least one object.
74    #[error("generation transfer batch bound must be positive")]
75    EmptyBatch,
76    /// A transfer page exceeds its caller or manifest object bound.
77    #[error("generation transfer batch exceeds its object bound")]
78    TooManyObjects,
79    /// Request-vector allocation failed after bounded admission.
80    #[error("generation transfer request allocation failed")]
81    AllocationFailed,
82    /// Exact work overflowed or exceeded the admitted budget.
83    #[error(transparent)]
84    Work(#[from] WorkError),
85}
86
87/// Receipt-bearing generation transfer result.
88pub type GenerationTransferResult<T> = MeasuredResult<OperationReceipt<T>, GenerationTransferError>;
89
90/// Authenticates a generation closure and builds its canonical transfer manifest.
91///
92/// # Errors
93///
94/// Returns typed cancellation, storage, closure, identity, configuration, or
95/// bounded-work failures.
96pub async fn build_generation_export_manifest_async<S: AsyncObjectStore>(
97    store: &S,
98    volume_id: VolumeId,
99    config: VolumeConfig,
100    generation_root: ObjectId,
101    closure_limits: ClosureLimits,
102    budget: WorkBudget,
103    cancellation: &CancellationToken,
104) -> GenerationTransferResult<GenerationExportManifest> {
105    cancellation
106        .check()
107        .map_err(|error| OperationFailure::before_work(error.into()))?;
108    config
109        .validate()
110        .map_err(GenerationExportManifestError::from)
111        .map_err(|error| OperationFailure::before_work(error.into()))?;
112    let proof = prove_generation_closure_async(
113        store,
114        generation_root,
115        closure_limits,
116        budget,
117        cancellation,
118    )
119    .await
120    .map_err(|failure| OperationFailure::new(failure.error.into(), *failure.work))?;
121    let manifest = GenerationExportManifest {
122        volume_id,
123        config,
124        generation_root,
125        generation_id: proof.generation_id,
126        objects: proof.objects,
127        file_count: proof.file_count,
128    };
129    validate_generation_export_manifest(&manifest, config.limits.maximum_objects_per_generation)
130        .map_err(|error| OperationFailure::new(error.into(), proof.work))?;
131    if proof.root.volume_id != volume_id {
132        return Err(OperationFailure::new(
133            GenerationTransferError::ManifestMismatch,
134            proof.work,
135        ));
136    }
137    Ok(OperationReceipt {
138        value: manifest,
139        work: proof.work,
140    })
141}
142
143/// Reauthenticates a completely imported generation against its manifest.
144///
145/// # Errors
146///
147/// Returns before backend work for malformed manifests, otherwise preserving
148/// exact closure work on missing, corrupt, foreign, or mismatched objects.
149pub async fn authenticate_generation_export_manifest_async<S: AsyncObjectStore>(
150    store: &S,
151    manifest: &GenerationExportManifest,
152    closure_limits: ClosureLimits,
153    budget: WorkBudget,
154    cancellation: &CancellationToken,
155) -> GenerationTransferResult<()> {
156    cancellation
157        .check()
158        .map_err(|error| OperationFailure::before_work(error.into()))?;
159    validate_generation_export_manifest(
160        manifest,
161        manifest.config.limits.maximum_objects_per_generation,
162    )
163    .map_err(|error| OperationFailure::before_work(error.into()))?;
164    let proof = prove_generation_closure_async(
165        store,
166        manifest.generation_root,
167        closure_limits,
168        budget,
169        cancellation,
170    )
171    .await
172    .map_err(|failure| OperationFailure::new(failure.error.into(), *failure.work))?;
173    if proof.root.volume_id != manifest.volume_id
174        || proof.generation_id != manifest.generation_id
175        || proof.objects != manifest.objects
176        || proof.file_count != manifest.file_count
177    {
178        return Err(OperationFailure::new(
179            GenerationTransferError::ManifestMismatch,
180            proof.work,
181        ));
182    }
183    Ok(OperationReceipt {
184        value: (),
185        work: proof.work,
186    })
187}
188
189/// Reads one resumable manifest page using the backend's ordered batch primitive.
190///
191/// The request vector is bounded and accounted before allocation. A terminal
192/// cursor returns an empty terminal page without backend work.
193///
194/// # Errors
195///
196/// Rejects malformed manifests, invalid cursors/bounds, cancellation,
197/// allocation, storage, and work-budget failures without losing spent work.
198pub async fn export_generation_batch_async<S: AsyncObjectStore + ?Sized>(
199    store: &S,
200    manifest: &GenerationExportManifest,
201    cursor: TransferCursor,
202    maximum_objects: u32,
203    maximum_object_bytes: u64,
204    budget: WorkBudget,
205    cancellation: &CancellationToken,
206) -> GenerationTransferResult<GenerationTransferBatch> {
207    cancellation
208        .check()
209        .map_err(|error| OperationFailure::before_work(error.into()))?;
210    validate_generation_export_manifest(
211        manifest,
212        manifest.config.limits.maximum_objects_per_generation,
213    )
214    .map_err(|error| OperationFailure::before_work(error.into()))?;
215    if maximum_objects == 0 || maximum_object_bytes == 0 {
216        return Err(OperationFailure::before_work(
217            GenerationTransferError::EmptyBatch,
218        ));
219    }
220    let object_count = u64::try_from(manifest.objects.len()).unwrap_or(u64::MAX);
221    if cursor.0 > object_count {
222        return Err(OperationFailure::before_work(
223            GenerationTransferError::InvalidCursor,
224        ));
225    }
226    if cursor.0 == object_count {
227        return Ok(OperationReceipt {
228            value: GenerationTransferBatch {
229                first_object: cursor,
230                next: None,
231                objects: Vec::new(),
232            },
233            work: WorkCounters::default(),
234        });
235    }
236    let remaining_objects = object_count - cursor.0;
237    let count = remaining_objects.min(u64::from(maximum_objects));
238    let start = usize::try_from(cursor.0)
239        .map_err(|_| OperationFailure::before_work(GenerationTransferError::InvalidCursor))?;
240    let count_usize = usize::try_from(count)
241        .map_err(|_| OperationFailure::before_work(GenerationTransferError::TooManyObjects))?;
242    let request_bytes = count
243        .checked_mul(u64::try_from(size_of::<ObjectReadRequest>()).unwrap_or(u64::MAX))
244        .ok_or_else(|| {
245            OperationFailure::before_work(GenerationTransferError::Work(WorkError::Overflow))
246        })?;
247    let mut work = WorkCounters {
248        bytes_copied: request_bytes,
249        allocation_operations: 1,
250        peak_allocation_bytes: request_bytes,
251        ..WorkCounters::default()
252    };
253    work.verify(budget)
254        .map_err(|error| OperationFailure::before_work(error.into()))?;
255    let mut requests = Vec::new();
256    requests
257        .try_reserve_exact(count_usize)
258        .map_err(|_| OperationFailure::new(GenerationTransferError::AllocationFailed, work))?;
259    let admitted_object_bytes =
260        maximum_object_bytes.min(manifest.config.limits.maximum_object_bytes);
261    #[allow(
262        clippy::indexing_slicing,
263        reason = "`cursor.0 > object_count` was already rejected above, and `cursor.0 == \
264                  object_count` returns early, so `cursor.0 < object_count`; `count = \
265                  remaining_objects.min(maximum_objects)` where `remaining_objects = \
266                  object_count - cursor.0`, so `count <= remaining_objects`, giving `start + \
267                  count_usize <= start + remaining_objects == object_count == \
268                  manifest.objects.len()`"
269    )]
270    for object_id in &manifest.objects[start..start + count_usize] {
271        requests.push(ObjectReadRequest {
272            object_id: *object_id,
273            maximum_bytes: admitted_object_bytes,
274        });
275    }
276    let nested = store
277        .read_many(
278            &requests,
279            work.remaining(budget).map_err(|error| {
280                OperationFailure::new(GenerationTransferError::Work(error), work)
281            })?,
282            cancellation,
283        )
284        .await
285        .map_err(|failure| failure.map_with_prior_work(work, GenerationTransferError::from))?;
286    let nested_peak = request_bytes.saturating_add(nested.work.peak_allocation_bytes);
287    work = work
288        .checked_add(nested.work)
289        .map_err(|error| OperationFailure::new(error.into(), work))?;
290    work.peak_allocation_bytes = work.peak_allocation_bytes.max(nested_peak);
291    work.verify(budget)
292        .map_err(|error| OperationFailure::new(error.into(), work))?;
293    let next_index = cursor.0 + count;
294    Ok(OperationReceipt {
295        value: GenerationTransferBatch {
296            first_object: cursor,
297            next: (next_index < object_count).then_some(TransferCursor(next_index)),
298            objects: nested.value,
299        },
300        work,
301    })
302}
303
304/// Idempotently imports one manifest-aligned page of immutable object bodies.
305///
306/// A failure may leave a valid immutable prefix, so callers retry the same
307/// cursor and bodies. Canonical object identity makes that retry exactly safe.
308///
309/// # Errors
310///
311/// Rejects malformed manifests, cursor/body bounds, cancellation, storage, or
312/// exact-work failures while preserving work spent on an imported prefix.
313pub async fn import_generation_batch_async<S: AsyncObjectStore + ?Sized>(
314    store: &S,
315    manifest: &GenerationExportManifest,
316    cursor: TransferCursor,
317    bodies: &[Bytes],
318    maximum_objects: u32,
319    budget: WorkBudget,
320    cancellation: &CancellationToken,
321) -> GenerationTransferResult<TransferCursor> {
322    cancellation
323        .check()
324        .map_err(|error| OperationFailure::before_work(error.into()))?;
325    validate_generation_export_manifest(
326        manifest,
327        manifest.config.limits.maximum_objects_per_generation,
328    )
329    .map_err(|error| OperationFailure::before_work(error.into()))?;
330    if maximum_objects == 0 {
331        return Err(OperationFailure::before_work(
332            GenerationTransferError::EmptyBatch,
333        ));
334    }
335    let object_count = u64::try_from(manifest.objects.len()).unwrap_or(u64::MAX);
336    let body_count = u64::try_from(bodies.len()).unwrap_or(u64::MAX);
337    if cursor.0 > object_count {
338        return Err(OperationFailure::before_work(
339            GenerationTransferError::InvalidCursor,
340        ));
341    }
342    if cursor.0 == object_count && bodies.is_empty() {
343        return Ok(OperationReceipt {
344            value: cursor,
345            work: WorkCounters::default(),
346        });
347    }
348    if bodies.is_empty() {
349        return Err(OperationFailure::before_work(
350            GenerationTransferError::EmptyBatch,
351        ));
352    }
353    if body_count > u64::from(maximum_objects)
354        || cursor
355            .0
356            .checked_add(body_count)
357            .is_none_or(|end| end > object_count)
358    {
359        return Err(OperationFailure::before_work(
360            GenerationTransferError::TooManyObjects,
361        ));
362    }
363    let start = usize::try_from(cursor.0)
364        .map_err(|_| OperationFailure::before_work(GenerationTransferError::InvalidCursor))?;
365    let mut work = WorkCounters::default();
366    for (offset, body) in bodies.iter().enumerate() {
367        cancellation
368            .check()
369            .map_err(|error| OperationFailure::new(error.into(), work))?;
370        #[allow(
371            clippy::indexing_slicing,
372            reason = "the check above rejects `body_count > maximum_objects` or \
373                      `cursor.0.checked_add(body_count)` overflowing or exceeding `object_count`, \
374                      so `cursor.0 + body_count <= object_count` (== manifest.objects.len()); \
375                      `start == cursor.0` as usize and `offset < bodies.len() == body_count`, so \
376                      `start + offset < start + body_count <= manifest.objects.len()`"
377        )]
378        let object_id = manifest.objects[start + offset];
379        let receipt = store
380            .put(
381                object_id,
382                body.clone(),
383                work.remaining(budget).map_err(|error| {
384                    OperationFailure::new(GenerationTransferError::Work(error), work)
385                })?,
386                cancellation,
387            )
388            .await
389            .map_err(|failure| failure.map_with_prior_work(work, GenerationTransferError::from))?;
390        work = work
391            .checked_add(receipt.work)
392            .map_err(|error| OperationFailure::new(error.into(), work))?;
393    }
394    Ok(OperationReceipt {
395        value: TransferCursor(cursor.0 + body_count),
396        work,
397    })
398}
399
400#[cfg(test)]
401#[path = "tests/transfer.rs"]
402mod tests;