acyclic_fs/storage.rs
1//! Pluggable authority and immutable object storage interfaces.
2
3use crate::foundation::{
4 AuthorityId, Digest, DurableCommit, Epoch, Head, OperationId, ProposedCommit, Sequence,
5};
6use crate::performance::{MeasuredResult, OperationFailure, WorkBudget, WorkCounters};
7use bytes::Bytes;
8use serde::{Deserialize, Serialize};
9use std::sync::Arc;
10use thiserror::Error;
11
12/// Canonical bytes hashed in addition to one object's body.
13pub const OBJECT_DIGEST_ENVELOPE_BYTES: u64 = b"acyclic-fs-object-v1\0".len() as u64 + 1 + 8;
14
15/// Bounds one replay request before storage allocates or returns bytes.
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub struct ReplayLimit {
18 /// Maximum records returned.
19 pub records: u32,
20 /// Maximum cumulative payload bytes returned.
21 pub payload_bytes: u64,
22}
23
24/// Idempotent authority creation result.
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum CreateAuthorityOutcome {
27 /// A new empty authority was durably created.
28 Created(Head),
29 /// The authority already existed and was not changed.
30 Existing(Head),
31}
32
33/// Successful or rejected compare-and-append result.
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub enum AppendOutcome {
36 /// The proposed operation became durable at this commit.
37 Committed(DurableCommit),
38 /// The same operation and fingerprint was already durable.
39 AlreadyCommitted(DurableCommit),
40 /// The expected head was stale; no bytes were appended.
41 Conflict {
42 /// Linearizable current head.
43 actual: Head,
44 },
45 /// The caller epoch was stale; no bytes were appended.
46 Fenced {
47 /// Active authority epoch.
48 actual_epoch: Epoch,
49 },
50 /// The operation identity exists with a different fingerprint.
51 IdempotencyConflict {
52 /// Fingerprint already bound to the operation identity.
53 committed_fingerprint: Digest,
54 },
55}
56
57/// Optional authority-side admission condition for one publication.
58///
59/// Ordinary SDK callers use [`PublicationPermit::Unrestricted`]. Managed
60/// operation windows issue lease permits whose gate is checked in the same
61/// storage transaction as the generation append.
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63pub enum PublicationPermit {
64 /// No operation-window admission is required.
65 Unrestricted,
66 /// The exact durable tool lease must still be active.
67 Lease {
68 /// Authority derived from the admitted workspace identity.
69 authority_id: [u8; 16],
70 /// Workspace whose mount admitted the operation.
71 workspace_id: [u8; 16],
72 /// Unique durable lease identity.
73 lease_id: [u8; 16],
74 /// Provider-evaluated exclusive publication deadline.
75 expires_at_millis: u64,
76 },
77 /// The exact durable multi-authority publication reservation is active.
78 Reservation {
79 /// Stable logical publication identity.
80 operation_id: [u8; 16],
81 /// Tail produced when the authority gate was reserved.
82 gate_tail: u64,
83 /// Exact authority head authenticated when the gate was acquired.
84 expected: Head,
85 },
86}
87
88/// Durable exclusive authority reservation used by multi-root publication.
89#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
90pub struct PublicationReservation {
91 /// Authority protected by the reservation.
92 pub authority_id: AuthorityId,
93 /// Stable logical publication identity.
94 pub operation_id: OperationId,
95 /// Exact authority head validated while acquiring the gate.
96 pub expected: Head,
97 /// Gate tail that authenticates the reservation holder.
98 pub gate_tail: u64,
99}
100
101/// Result of atomically reserving an authority at an exact head.
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
103pub enum ReservationOutcome {
104 /// This operation owns the exclusive gate.
105 Reserved(PublicationReservation),
106 /// The identical reservation was already acquired.
107 AlreadyReserved(PublicationReservation),
108 /// The authority head or gate changed before acquisition.
109 Conflict {
110 /// Linearizable head observed while acquisition failed.
111 actual: Head,
112 },
113}
114
115/// Complete atomic authority append request, including any admission permit.
116#[derive(Clone, Debug, Eq, PartialEq)]
117pub struct GuardedAppend {
118 /// Authority receiving the operation.
119 pub authority_id: AuthorityId,
120 /// Writer epoch expected by the caller.
121 pub epoch: Epoch,
122 /// Complete expected authority head.
123 pub expected: Head,
124 /// Canonical durable operation.
125 pub commit: ProposedCommit,
126 /// Optional operation-window admission requirement.
127 pub permit: PublicationPermit,
128}
129
130/// Successful or rejected compare-and-fence result.
131#[derive(Clone, Copy, Debug, Eq, PartialEq)]
132pub enum FenceOutcome {
133 /// The exact expected head was atomically advanced to a fresh writer epoch.
134 Advanced(Head),
135 /// The authority changed before ownership could be acquired.
136 Conflict {
137 /// Linearizable current head; no fence was advanced.
138 actual: Head,
139 },
140}
141
142/// Storage-only authority interface.
143///
144/// Implementations own ordering, fencing, idempotency, durable acknowledgement,
145/// and bounded replay. They do not interpret commit payloads.
146pub trait AuthorityStore: Send + Sync {
147 /// Creates one authority at the supplied non-zero genesis epoch.
148 ///
149 /// # Errors
150 ///
151 /// Returns a typed backend error if creation cannot be made durable.
152 fn create_authority(
153 &self,
154 authority_id: AuthorityId,
155 genesis_epoch: Epoch,
156 budget: WorkBudget,
157 ) -> AuthorityResult<CreateAuthorityOutcome>;
158
159 /// Returns the linearizable current head.
160 ///
161 /// # Errors
162 ///
163 /// Returns a typed backend error when the authority is missing, corrupt,
164 /// unavailable, or cannot be read durably.
165 fn head(&self, authority_id: AuthorityId, budget: WorkBudget) -> AuthorityResult<Head>;
166
167 /// Atomically checks epoch/head/idempotency and durably appends one operation.
168 ///
169 /// # Errors
170 ///
171 /// Returns a typed backend error when durable evaluation cannot complete.
172 /// Semantic conflicts are returned as [`AppendOutcome`] variants.
173 fn compare_and_append(
174 &self,
175 authority_id: AuthorityId,
176 epoch: Epoch,
177 expected: Head,
178 commit: ProposedCommit,
179 budget: WorkBudget,
180 ) -> AuthorityResult<AppendOutcome>;
181
182 /// Replays a contiguous bounded page after `sequence`.
183 ///
184 /// # Errors
185 ///
186 /// Returns a typed backend error for invalid limits, missing/corrupt
187 /// authority state, or storage failure.
188 fn replay(
189 &self,
190 authority_id: AuthorityId,
191 after: Sequence,
192 limit: ReplayLimit,
193 budget: WorkBudget,
194 ) -> AuthorityResult<Vec<DurableCommit>>;
195
196 /// Atomically compares the complete head, advances its epoch, and
197 /// permanently fences prior writers.
198 ///
199 /// # Errors
200 ///
201 /// A stale expected head is a semantic [`FenceOutcome::Conflict`], while a
202 /// backend failure means the fence could not be durably resolved.
203 fn fence(
204 &self,
205 authority_id: AuthorityId,
206 expected: Head,
207 budget: WorkBudget,
208 ) -> AuthorityResult<FenceOutcome>;
209
210 /// Looks up a durable operation for exact retry resolution.
211 ///
212 /// # Errors
213 ///
214 /// Returns a typed backend error when the authority cannot be read safely.
215 fn find_operation(
216 &self,
217 authority_id: AuthorityId,
218 operation_id: OperationId,
219 budget: WorkBudget,
220 ) -> AuthorityResult<Option<DurableCommit>>;
221}
222
223impl<T: AuthorityStore + ?Sized> AuthorityStore for Arc<T> {
224 fn create_authority(
225 &self,
226 authority_id: AuthorityId,
227 genesis_epoch: Epoch,
228 budget: WorkBudget,
229 ) -> AuthorityResult<CreateAuthorityOutcome> {
230 (**self).create_authority(authority_id, genesis_epoch, budget)
231 }
232
233 fn head(&self, authority_id: AuthorityId, budget: WorkBudget) -> AuthorityResult<Head> {
234 (**self).head(authority_id, budget)
235 }
236
237 fn compare_and_append(
238 &self,
239 authority_id: AuthorityId,
240 epoch: Epoch,
241 expected: Head,
242 commit: ProposedCommit,
243 budget: WorkBudget,
244 ) -> AuthorityResult<AppendOutcome> {
245 (**self).compare_and_append(authority_id, epoch, expected, commit, budget)
246 }
247
248 fn replay(
249 &self,
250 authority_id: AuthorityId,
251 after: Sequence,
252 limit: ReplayLimit,
253 budget: WorkBudget,
254 ) -> AuthorityResult<Vec<DurableCommit>> {
255 (**self).replay(authority_id, after, limit, budget)
256 }
257
258 fn fence(
259 &self,
260 authority_id: AuthorityId,
261 expected: Head,
262 budget: WorkBudget,
263 ) -> AuthorityResult<FenceOutcome> {
264 (**self).fence(authority_id, expected, budget)
265 }
266
267 fn find_operation(
268 &self,
269 authority_id: AuthorityId,
270 operation_id: OperationId,
271 budget: WorkBudget,
272 ) -> AuthorityResult<Option<DurableCommit>> {
273 (**self).find_operation(authority_id, operation_id, budget)
274 }
275}
276
277/// One successful authority operation and its exact backend work.
278#[derive(Clone, Debug, Eq, PartialEq)]
279pub struct AuthorityReceipt<T> {
280 /// Operation value.
281 pub value: T,
282 /// Exact backend work.
283 pub work: WorkCounters,
284}
285
286/// One failed authority operation retaining exact spent work.
287pub type AuthorityFailure = OperationFailure<AuthorityStoreError>;
288
289/// Receipt-bearing authority result.
290pub type AuthorityResult<T> = MeasuredResult<AuthorityReceipt<T>, AuthorityStoreError>;
291
292/// Typed authority storage failures.
293#[derive(Debug, Error)]
294pub enum AuthorityStoreError {
295 /// Operation was cancelled before durable authority work began.
296 #[error("authority operation was cancelled")]
297 Cancelled,
298 /// The authority does not exist.
299 #[error("authority does not exist")]
300 Missing,
301 /// A configured replay bound is invalid.
302 #[error("replay limit must have non-zero record and payload bounds")]
303 InvalidReplayLimit,
304 /// One authority record cannot fit into the requested replay page.
305 #[error("next authority record has {observed} payload bytes; replay maximum is {maximum}")]
306 ReplayRecordTooLarge {
307 /// Next record payload bytes.
308 observed: u64,
309 /// Requested page payload bound.
310 maximum: u64,
311 },
312 /// One submitted authority payload exceeds the backend admission bound.
313 #[error("authority payload has {observed} bytes; maximum is {maximum}")]
314 PayloadTooLarge {
315 /// Submitted payload bytes.
316 observed: u64,
317 /// Configured maximum.
318 maximum: u64,
319 },
320 /// Epoch arithmetic cannot represent another fence.
321 #[error("authority epoch exhausted")]
322 EpochExhausted,
323 /// Sequence arithmetic cannot represent another authority fact.
324 #[error("authority sequence exhausted")]
325 SequenceExhausted,
326 /// Persistent state is corrupt and cannot be safely interpreted.
327 #[error("authority storage is corrupt: {0}")]
328 Corrupt(String),
329 /// Persistent I/O failed.
330 #[error("authority storage I/O failed: {0}")]
331 Io(#[from] std::io::Error),
332 /// Durable state may have changed, so the caller must reopen and resolve by identity.
333 #[error("authority {operation} outcome is indeterminate: {source}")]
334 Indeterminate {
335 /// Durable operation whose acknowledgement was lost.
336 operation: &'static str,
337 /// Underlying storage failure after mutation may have begun.
338 #[source]
339 source: std::io::Error,
340 },
341 /// Backend rejected an operation for a stable implementation-specific reason.
342 #[error("authority backend rejected operation: {0}")]
343 Rejected(String),
344 /// Backend work could not be admitted within the caller's hard budget.
345 #[error(transparent)]
346 Work(#[from] crate::performance::WorkError),
347}
348
349/// Immutable object class. Hashes are scoped by both kind and canonical bytes.
350#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
351pub enum ObjectKind {
352 /// Authenticated blob-index page.
353 Blob,
354 /// Fixed-size immutable content chunk addressed by its complete bytes.
355 BlobChunk,
356 /// Authenticated namespace page.
357 TreePage,
358 /// Authenticated file-extent page.
359 ExtentPage,
360 /// Authenticated file-table page.
361 FileTablePage,
362 /// Composite immutable generation root.
363 GenerationRoot,
364 /// Canonical metadata object.
365 Metadata,
366 /// Authenticated named extended-attribute/stream page.
367 AttributePage,
368}
369
370impl ObjectKind {
371 /// Returns the stable canonical object-class tag.
372 #[must_use]
373 pub const fn canonical_tag(self) -> u8 {
374 match self {
375 Self::Blob => 1,
376 Self::BlobChunk => 2,
377 Self::TreePage => 3,
378 Self::ExtentPage => 4,
379 Self::FileTablePage => 5,
380 Self::GenerationRoot => 6,
381 Self::Metadata => 7,
382 Self::AttributePage => 8,
383 }
384 }
385
386 /// Parses one stable canonical object-class tag.
387 ///
388 /// # Errors
389 ///
390 /// Rejects unknown tags instead of guessing a future object class.
391 pub const fn from_canonical_tag(tag: u8) -> Result<Self, ObjectKindError> {
392 match tag {
393 1 => Ok(Self::Blob),
394 2 => Ok(Self::BlobChunk),
395 3 => Ok(Self::TreePage),
396 4 => Ok(Self::ExtentPage),
397 5 => Ok(Self::FileTablePage),
398 6 => Ok(Self::GenerationRoot),
399 7 => Ok(Self::Metadata),
400 8 => Ok(Self::AttributePage),
401 value => Err(ObjectKindError::Unknown(value)),
402 }
403 }
404}
405
406/// Canonical object-kind parsing failures.
407#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
408pub enum ObjectKindError {
409 /// A newer or corrupt object used an unknown kind tag.
410 #[error("unknown object kind tag {0}")]
411 Unknown(u8),
412}
413
414/// Typed object identity.
415#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
416pub struct ObjectId {
417 /// Object class.
418 pub kind: ObjectKind,
419 /// Digest over object class and canonical bytes.
420 pub digest: Digest,
421}
422
423/// Computes the canonical digest for one typed immutable object.
424#[must_use]
425pub fn object_digest(kind: ObjectKind, bytes: &[u8]) -> Digest {
426 let mut hasher = blake3::Hasher::new();
427 hasher.update(b"acyclic-fs-object-v1\0");
428 hasher.update(&[kind.canonical_tag()]);
429 hasher.update(&u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_le_bytes());
430 hasher.update(bytes);
431 Digest::from_bytes(*hasher.finalize().as_bytes())
432}
433
434/// Exact bounded logical byte range.
435#[derive(Clone, Copy, Debug, Eq, PartialEq)]
436pub struct ByteRange {
437 /// Inclusive start offset.
438 pub offset: u64,
439 /// Exact requested byte count.
440 pub length: u64,
441}
442
443/// One successful immutable-object operation and its exact physical work.
444#[derive(Clone, Debug, Eq, PartialEq)]
445pub struct ObjectReceipt<T> {
446 /// Operation value.
447 pub value: T,
448 /// Exact backend work, including zero-copy versus copied-byte behavior.
449 pub work: WorkCounters,
450}
451
452/// How the bytes returned by one immutable-object read remain resident.
453#[derive(Clone, Copy, Debug, Eq, PartialEq)]
454pub enum ObjectReadRetention {
455 /// The returned view shares backend-owned immutable storage and adds no
456 /// operation-owned byte buffer.
457 Shared,
458 /// The backend allocated this exact logical byte capacity and transfers
459 /// ownership to the caller until the value is dropped.
460 Owned {
461 /// Exact requested capacity retained by the returned value.
462 logical_bytes: u64,
463 },
464}
465
466/// Immutable bytes plus explicit retention evidence for zero-copy and peak
467/// allocation accounting.
468#[derive(Clone, Debug, Eq, PartialEq)]
469pub struct ObjectRead {
470 /// Complete canonical object bytes.
471 pub bytes: Bytes,
472 /// Whether those bytes are a shared view or an owned returned buffer.
473 pub retention: ObjectReadRetention,
474}
475
476/// One complete immutable object requested as part of an ordered batch.
477#[derive(Clone, Copy, Debug, Eq, PartialEq)]
478pub struct ObjectReadRequest {
479 /// Exact typed immutable identity.
480 pub object_id: ObjectId,
481 /// Maximum canonical bytes admitted for this object.
482 pub maximum_bytes: u64,
483}
484
485/// One canonical immutable object submitted as part of an ordered write batch.
486#[derive(Clone, Debug, Eq, PartialEq)]
487pub struct ObjectWrite {
488 /// Exact typed immutable identity.
489 pub object_id: ObjectId,
490 /// Canonical bytes authenticated by `object_id`.
491 pub bytes: Bytes,
492}
493
494impl std::ops::Deref for ObjectRead {
495 type Target = [u8];
496
497 fn deref(&self) -> &Self::Target {
498 &self.bytes
499 }
500}
501
502/// One failed immutable-object operation and all work completed before failure.
503pub type ObjectFailure = OperationFailure<ObjectStoreError>;
504
505/// Receipt-bearing immutable-object result. Failure never erases spent work.
506pub type ObjectResult<T> = MeasuredResult<ObjectReceipt<T>, ObjectStoreError>;
507
508/// Immutable digest-addressed object storage interface.
509///
510/// Objects are deliberately read whole. Pages and blob chunks have hard size
511/// limits; logical range reads are built over authenticated chunk indexes. This
512/// prevents a backend from hiding a whole-object scan behind a tiny range read.
513pub trait ObjectStore: Send + Sync {
514 /// Admits canonical bytes under their verified identity.
515 ///
516 /// # Errors
517 ///
518 /// Returns a typed error for digest mismatch or storage failure.
519 fn put(&self, object_id: ObjectId, bytes: Bytes, budget: WorkBudget) -> ObjectResult<()>;
520
521 /// Admits an ordered bounded group through one backend batch operation.
522 ///
523 /// # Errors
524 ///
525 /// Returns the first typed failure and all work completed before it.
526 fn put_many(&self, writes: &[ObjectWrite], budget: WorkBudget) -> ObjectResult<()>;
527
528 /// Reads exactly the requested range or returns a typed error.
529 ///
530 /// # Errors
531 ///
532 /// Returns a typed error when the object is absent, corrupt, unavailable,
533 /// or the range is invalid.
534 fn read(
535 &self,
536 object_id: ObjectId,
537 maximum_bytes: u64,
538 budget: WorkBudget,
539 ) -> ObjectResult<ObjectRead>;
540
541 /// Reads an ordered batch of complete bounded objects.
542 ///
543 /// The result position always matches the request position. Implementations
544 /// should coalesce physical transactions or I/O submission where their
545 /// backend supports it. The default preserves exact semantics and work
546 /// accounting without claiming fewer backend operations.
547 ///
548 /// # Errors
549 ///
550 /// Returns the first typed object failure and all work completed before it.
551 fn read_many(
552 &self,
553 requests: &[ObjectReadRequest],
554 budget: WorkBudget,
555 ) -> ObjectResult<Vec<ObjectRead>>;
556
557 /// Returns whether the exact typed object is present and verified.
558 ///
559 /// # Errors
560 ///
561 /// Returns a typed error when presence cannot be checked safely.
562 fn contains(&self, object_id: ObjectId, budget: WorkBudget) -> ObjectResult<bool>;
563}
564
565impl<T: ObjectStore + ?Sized> ObjectStore for Arc<T> {
566 fn put(&self, object_id: ObjectId, bytes: Bytes, budget: WorkBudget) -> ObjectResult<()> {
567 (**self).put(object_id, bytes, budget)
568 }
569
570 fn put_many(&self, writes: &[ObjectWrite], budget: WorkBudget) -> ObjectResult<()> {
571 (**self).put_many(writes, budget)
572 }
573
574 fn read(
575 &self,
576 object_id: ObjectId,
577 maximum_bytes: u64,
578 budget: WorkBudget,
579 ) -> ObjectResult<ObjectRead> {
580 (**self).read(object_id, maximum_bytes, budget)
581 }
582
583 fn read_many(
584 &self,
585 requests: &[ObjectReadRequest],
586 budget: WorkBudget,
587 ) -> ObjectResult<Vec<ObjectRead>> {
588 (**self).read_many(requests, budget)
589 }
590
591 fn contains(&self, object_id: ObjectId, budget: WorkBudget) -> ObjectResult<bool> {
592 (**self).contains(object_id, budget)
593 }
594}
595
596/// Typed immutable-object failures.
597#[derive(Debug, Error)]
598pub enum ObjectStoreError {
599 /// Operation was cancelled before immutable-object work began.
600 #[error("object operation was cancelled")]
601 Cancelled,
602 /// Exact typed object is absent.
603 #[error("object is missing")]
604 Missing,
605 /// Supplied bytes do not match the typed object digest.
606 #[error("object digest does not match canonical bytes")]
607 DigestMismatch,
608 /// Object exceeds the backend's declared bound.
609 #[error("object is {observed} bytes; maximum is {maximum}")]
610 TooLarge {
611 /// Observed canonical byte length.
612 observed: u64,
613 /// Configured maximum canonical byte length.
614 maximum: u64,
615 },
616 /// Requested range is invalid or outside the object.
617 #[error("object range is outside the admitted object")]
618 InvalidRange,
619 /// Stored bytes fail verification.
620 #[error("stored object is corrupt")]
621 Corrupt,
622 /// Corrupt bytes could not be isolated from the live object namespace.
623 #[error("corrupt object quarantine failed: {0}")]
624 QuarantineFailed(#[source] std::io::Error),
625 /// Storage I/O failed.
626 #[error("object storage I/O failed: {0}")]
627 Io(#[from] std::io::Error),
628 /// Backend rejected an operation for a stable implementation-specific reason.
629 #[error("object backend rejected operation: {0}")]
630 Rejected(String),
631 /// Backend work could not be admitted without exceeding the caller budget.
632 #[error(transparent)]
633 Work(#[from] crate::performance::WorkError),
634}
635
636#[cfg(test)]
637#[path = "tests/storage.rs"]
638mod tests;