loonfs_api/control.rs
1//! Durable control-object shapes: the head, metadata root, WAL floor,
2//! checkpoint records, upload sessions, and their envelopes (format spec,
3//! "Control objects").
4
5use crate::envelope::EnvelopeCodecError;
6use crate::WriterEpoch;
7use crate::{
8 ChangeSeq, CheckpointId, Checksum, ChecksumAlgorithm, CommitId, ContentId, ContentRef,
9 ContentRefKind, ContentStoreId, InodeId, ManifestId, ManifestObjectId, MetadataCompactionId,
10 NamespaceId, UploadId, WalSegmentId,
11};
12use serde::de::DeserializeOwned;
13use serde::{Deserialize, Deserializer, Serialize};
14use std::fmt;
15use std::num::NonZeroU64;
16
17/// Selects one independently versioned mutable control-object family.
18///
19/// See [mutable control-object rules](../../../docs/specs/format.md#17-mutable-control-object-rules).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum ControlObjectKind {
23 /// Carries the sole live-visibility and writer-fencing authority.
24 WalHead,
25 /// Records the earliest sequence for which incremental history is retained.
26 WalFloor,
27 /// Points to the best known materialized metadata manifest.
28 MetadataRoot,
29 /// Pins a manifest basis for a user or fork lifecycle.
30 CheckpointRecord,
31 /// Tracks staged content through upload completion or cleanup.
32 UploadSession,
33 /// Marks one streaming metadata compaction's output as owned by a job
34 /// that is still running.
35 CompactionLease,
36}
37
38impl ControlObjectKind {
39 /// Lists every registered control-object family in stable registry order.
40 pub const ALL: [Self; 6] = [
41 Self::WalHead,
42 Self::WalFloor,
43 Self::MetadataRoot,
44 Self::CheckpointRecord,
45 Self::UploadSession,
46 Self::CompactionLease,
47 ];
48
49 /// Durable format version for this control object kind.
50 ///
51 /// Versions are tracked per kind so one kind's payload schema can make a
52 /// breaking change without invalidating every other control object.
53 /// Version 1 is a JSON envelope document carrying the current payload as
54 /// a raw JSON fragment whose checksum covers its exact bytes.
55 pub const fn format_version(self) -> u32 {
56 match self {
57 Self::WalHead => 1,
58 Self::WalFloor => 1,
59 Self::MetadataRoot => 1,
60 Self::CheckpointRecord => 1,
61 Self::UploadSession => 1,
62 Self::CompactionLease => 1,
63 }
64 }
65
66 /// Returns the frozen envelope discriminator for this control-object family.
67 pub const fn as_str(self) -> &'static str {
68 match self {
69 Self::WalHead => "wal_head",
70 Self::WalFloor => "wal_floor",
71 Self::MetadataRoot => "metadata_root",
72 Self::CheckpointRecord => "checkpoint_record",
73 Self::UploadSession => "upload_session",
74 Self::CompactionLease => "compaction_lease",
75 }
76 }
77
78 /// Parses a registered envelope discriminator, returning `None` for future families.
79 pub fn parse(value: &str) -> Option<Self> {
80 Self::ALL.into_iter().find(|kind| kind.as_str() == value)
81 }
82}
83
84/// Earliest sequence for which incremental WAL history is retained.
85///
86/// The floor advances monotonically by compare-and-swap and does not control
87/// live visibility. Missing or unverifiable floor state must retain more
88/// history. Objects below the floor are only deletion candidates; garbage
89/// collection still revalidates them before removal.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92pub struct WalFloorState {
93 /// Namespace whose retained history this floor bounds.
94 pub namespace_id: NamespaceId,
95 /// Earliest sequence at which incremental replay remains promised.
96 pub floor_seq: ChangeSeq,
97 /// Unix-millisecond wall-clock time when the referenced manifest basis was last verified.
98 pub verified_at_ms: u64,
99 /// Unix-millisecond wall-clock time stamped by the successful floor update attempt.
100 pub updated_at_ms: u64,
101}
102
103/// Cold pointer to the best known materialized metadata root.
104///
105/// Manifest publication compare-and-swaps this object, never the WAL head,
106/// so head watchers see only commits. Updates are monotonic in
107/// `manifest_head_seq`; a same-seq replacement may reference a different
108/// manifest (pure compaction), and a lower-seq replacement no-ops. This
109/// object never defines live visibility.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct MetadataRootState {
113 /// Namespace whose materialized file set this root selects.
114 pub namespace_id: NamespaceId,
115 /// Monotonic logical position of the selected manifest.
116 pub manifest_id: ManifestId,
117 /// Immutable candidate chosen at `manifest_id`.
118 pub manifest_object_id: ManifestObjectId,
119 /// Greatest namespace sequence represented by the selected manifest.
120 pub manifest_head_seq: ChangeSeq,
121 /// Must equal `payload_checksum` in the referenced manifest envelope.
122 pub manifest_payload_checksum: String,
123 /// Unix-millisecond wall-clock stamp for observability and GC grace policy, not ordering.
124 pub updated_at_ms: u64,
125}
126
127/// Lifecycle of a compaction lease: two states and one transition.
128///
129/// A lease is created `active` by the job that owns the prefix. Garbage
130/// collection moves it to `reaping` by compare-and-swap once it has expired,
131/// and that transition is the fence: the worker's next heartbeat
132/// compare-and-swap fails, so it can never publish, and only then may the
133/// collector treat the prefix as orphaned. `reaping` is terminal — nothing
134/// returns a lease to `active`, and the collector deletes the object once the
135/// prefix's unreferenced segments are gone.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138pub enum MetadataCompactionLeaseStatus {
139 /// The job owns its prefix. Whether it is still running is a question
140 /// about `heartbeat_at_ms`, not about this field.
141 Active,
142 /// Terminal: garbage collection claimed the prefix, so the job that
143 /// wrote it is fenced and the objects under it are orphans.
144 Reaping,
145}
146
147impl fmt::Display for MetadataCompactionLeaseStatus {
148 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149 formatter.write_str(match self {
150 Self::Active => "active",
151 Self::Reaping => "reaping",
152 })
153 }
154}
155
156/// Says who owns the objects under one streaming metadata compaction's job
157/// prefix.
158///
159/// The lease carries lifecycle ownership and nothing else: no cursor, no
160/// output descriptor, no offset, no progress. Two parties write it. The job
161/// creates it, refreshes it by compare-and-swap while it runs, and stops
162/// writing it once its output is published. Garbage collection claims an
163/// expired lease by compare-and-swap and reclaims the prefix behind it
164/// (format spec, "Garbage collection", rule 12). Whoever wins that
165/// compare-and-swap owns the prefix; the loser has lost it for good.
166///
167/// Nothing about a job's correctness depends on the object surviving — a job
168/// that loses its lease loses its output to a later pass and runs again,
169/// which is what every other way a job can end already costs.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(deny_unknown_fields)]
172pub struct MetadataCompactionLeaseState {
173 /// Job this lease belongs to, which is also the prefix its output sits
174 /// under.
175 pub job_id: MetadataCompactionId,
176 /// Namespace whose family group the job is rebuilding.
177 pub namespace_id: NamespaceId,
178 /// Writer identity the job runs under, for an operator reading the
179 /// object.
180 pub owner_id: String,
181 /// Who owns the prefix: the job that wrote the lease, or the collector
182 /// that claimed it.
183 pub status: MetadataCompactionLeaseStatus,
184 /// Unix-millisecond stamp of the job's first lease write.
185 pub started_at_ms: u64,
186 /// Unix-millisecond stamp of the most recent lease write, and the only
187 /// input to whether an `active` lease has expired.
188 pub heartbeat_at_ms: u64,
189}
190
191/// Monotonic lifecycle of a durable checkpoint record.
192///
193/// A new record starts active and pins its basis. Explicit release or expiry
194/// moves it to the terminal released state by compare-and-swap. Released
195/// records serve no reads and are deleted after the release grace period.
196/// Creating another pin always creates a new record id.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
199pub enum CheckpointRecordLifecycle {
200 /// Protects the checkpoint basis. The sole state a read may serve from.
201 ///
202 /// The braces make serde reject a stray `released_at_ms`; a unit variant
203 /// would silently accept and discard that field.
204 Active {},
205 /// Terminal: the pin is gone and the record is waiting to be deleted.
206 Released {
207 /// Unix-millisecond stamp written by the release compare-and-swap,
208 /// and the only input to when the record may be deleted.
209 released_at_ms: u64,
210 },
211}
212
213impl std::fmt::Display for CheckpointRecordLifecycle {
214 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215 let state = match self {
216 Self::Active {} => "active",
217 Self::Released { .. } => "released",
218 };
219 formatter.write_str(state)
220 }
221}
222
223/// Durable owner and expiry policy of a checkpoint record.
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
226pub enum CheckpointOwner {
227 /// An operator-created pin, released explicitly by checkpoint id or by
228 /// its declared expiry. The name is a label, not a key: several records
229 /// may carry the same name over different bases.
230 User {
231 /// Operator-facing label that need not be unique.
232 name: String,
233 /// When garbage collection may release the pin without an explicit request.
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 expires_at_ms: Option<u64>,
236 },
237 /// A fork target keeping its source basis alive. Released once the
238 /// target namespace is terminally deleted, or once the attempt's lease
239 /// expires with no target head to show for it. A live target keeps the
240 /// record whatever the lease says.
241 Fork {
242 /// Fork namespace whose continued existence keeps the source basis pinned.
243 target_namespace_id: NamespaceId,
244 /// Lease bounding the fork attempt before its target head is installed.
245 expires_at_ms: u64,
246 },
247}
248
249impl CheckpointOwner {
250 /// When garbage collection may release this record without asking its owner.
251 pub fn expires_at_ms(&self) -> Option<u64> {
252 match self {
253 Self::User { expires_at_ms, .. } => *expires_at_ms,
254 Self::Fork { expires_at_ms, .. } => Some(*expires_at_ms),
255 }
256 }
257}
258
259/// A checkpoint record: pins one metadata manifest (its basis) so garbage
260/// collection keeps everything the manifest references.
261///
262/// Stored as its own object under `checkpoints/`; never part of a manifest
263/// and never an input to latest visibility. Created write-then-verify: the
264/// record is written `active`, then the basis manifest is re-verified
265/// against the floor, and a failed verification flips the record to
266/// `released`.
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(deny_unknown_fields)]
269pub struct CheckpointRecordState {
270 /// Freshly generated record identity, one per logical pin. Nothing
271 /// derives it, and no caller supplies it, so a new pin can never land on
272 /// a released record's key.
273 pub checkpoint_id: CheckpointId,
274 /// Source namespace whose manifest and metadata remain pinned.
275 pub namespace_id: NamespaceId,
276 /// Logical manifest position of the pinned basis.
277 pub manifest_id: ManifestId,
278 /// Immutable manifest candidate selected at `manifest_id`.
279 pub manifest_object_id: ManifestObjectId,
280 /// Greatest source sequence materialized by the pinned manifest.
281 pub manifest_head_seq: ChangeSeq,
282 /// Must equal `payload_checksum` in the referenced manifest envelope.
283 pub manifest_payload_checksum: String,
284 /// Commit identity at the pinned manifest head, verified against its payload.
285 pub head_commit_id: CommitId,
286 /// Unix-millisecond creation stamp used by GC grace policy, never validity ordering.
287 pub created_at_ms: u64,
288 /// Party and expiry policy that determine when this pin can be released.
289 pub owner: CheckpointOwner,
290 /// Current lifecycle, advanced only by the one-way release compare-and-swap.
291 pub state: CheckpointRecordLifecycle,
292}
293
294/// Links one accepted WAL segment identity to its verified sequence range.
295///
296/// See [WAL segment rules](../../../docs/specs/format.md#15-wal-segment-rules).
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298#[serde(deny_unknown_fields)]
299pub struct WalSegmentPointer {
300 /// Segment identity used to derive the immutable object key and expected
301 /// to agree with the decoded payload.
302 pub segment_id: WalSegmentId,
303 /// First logical commit sequence carried by the segment.
304 pub start_seq: ChangeSeq,
305 /// Final logical commit sequence carried by the segment.
306 pub end_seq: ChangeSeq,
307 /// Checksum of the referenced segment's payload bytes, in `sha256:<hex>`
308 /// form. Must equal the `payload_checksum` in the referenced envelope.
309 pub payload_checksum: String,
310}
311
312/// Who most recently acquired the writer epoch, and when.
313///
314/// Observability only, written during the epoch-acquisition CAS. Fencing
315/// authority is `writer_epoch` + CAS; nothing may consult this block for
316/// commit validity, takeover permission, or expiry, and no wall-clock
317/// comparison may gate a publish.
318///
319/// There is no session identity here: two runs of the same writer are told
320/// apart by `acquired_at_ms`, not by an id.
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(deny_unknown_fields)]
323pub struct WriterBlock {
324 /// Stable writer label supplied by the embedding process for diagnostics.
325 pub writer_id: String,
326 /// Unix-millisecond stamp of the successful epoch-acquisition CAS.
327 pub acquired_at_ms: u64,
328}
329
330/// Captures the writer identity and fencing epoch a session must retain while publishing.
331///
332/// See [mutable control-object rules](../../../docs/specs/format.md#17-mutable-control-object-rules).
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334pub struct AcquiredWriter {
335 /// Stable writer label copied into the head's observability block.
336 pub writer_id: String,
337 /// Fencing epoch every commit publication from this session must match.
338 pub writer_epoch: WriterEpoch,
339}
340
341/// Lifecycle state recorded in the namespace head.
342///
343/// There is no initialization state: the head is published complete by one
344/// conditional write, so a namespace either has a head (active or deleted)
345/// or does not exist. The one transition the head must record is deletion,
346/// because a deleted namespace keeps its head forever as the id-reuse
347/// tombstone.
348///
349/// Decoding is fail-closed: a reader presented with a state it does not
350/// recognize fails with a typed decode error instead of serving the
351/// namespace.
352#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354pub enum NamespaceState {
355 /// The namespace serves reads and accepts commits.
356 #[default]
357 Active,
358 /// Terminal: the namespace's history has ended. Reads, commits, forks,
359 /// and re-creation of the same id are all refused.
360 Deleted,
361}
362
363impl NamespaceState {
364 /// Whether this is the default state, used to keep active heads encoded
365 /// exactly as before the field existed.
366 pub fn is_active(&self) -> bool {
367 matches!(self, NamespaceState::Active)
368 }
369}
370
371/// Where a fork target's metadata basis lives before the target publishes
372/// its own manifest, and the permanent record of what it was forked from.
373///
374/// Present in every successor head of a fork target, absent in every head of
375/// a created namespace. The basis is head-authorized: a reader that resolves
376/// through it must verify the loaded manifest against both the namespace id
377/// and the checksum recorded here, and report corruption on any mismatch —
378/// there is no fallback (format spec, "Resolving the metadata basis").
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380#[serde(deny_unknown_fields)]
381pub struct ForkBasis {
382 /// Namespace whose durable tree owns the basis manifest and its tables.
383 pub source_namespace_id: NamespaceId,
384 /// Immutable manifest the target starts from, under the source's prefix.
385 pub source_manifest_object_id: ManifestObjectId,
386 /// Must equal `payload_checksum` in the referenced manifest envelope.
387 pub source_manifest_checksum: String,
388 /// Source checkpoint record pinning the basis for as long as the target lives.
389 pub source_checkpoint_id: CheckpointId,
390 /// Source sequence the target's history begins at: its birth seq, and
391 /// the floor below which the target never had WAL history of its own.
392 pub fork_seq: ChangeSeq,
393}
394
395/// Carries the authoritative visibility, allocation, and fencing state of a namespace.
396///
397/// See [head update authority](../../../docs/specs/format.md#14-head-update-authority).
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
399#[serde(deny_unknown_fields)]
400pub struct HeadState {
401 /// Namespace whose live history this head governs.
402 pub namespace_id: NamespaceId,
403 /// Immutable content store in which the namespace publishes file bytes.
404 /// Minted at creation; a fork target carries its source's, sharing the
405 /// content keyspace copy-on-write.
406 pub content_store_id: ContentStoreId,
407 /// Time the namespace was created, in Unix milliseconds. Sequence numbers
408 /// determine order; this value is for display.
409 pub created_at_ms: u64,
410 /// Provenance and pre-first-flush basis of a fork target; absent for a
411 /// created namespace. Immutable for the namespace's life.
412 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub fork_basis: Option<ForkBasis>,
414 /// Greatest visible logical commit sequence.
415 pub seq: ChangeSeq,
416 /// Commit id assigned to `seq`, or the fixed genesis id at sequence zero.
417 pub head_commit_id: CommitId,
418 /// Current fencing generation; a publisher holding any other epoch is rejected.
419 pub writer_epoch: WriterEpoch,
420 /// Non-authoritative record of the most recent epoch acquisition.
421 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub writer: Option<WriterBlock>,
423 /// First namespace-scoped inode identity available for allocation.
424 pub next_inode_id: InodeId,
425 /// Accepted tip of the visible WAL chain, or `None` before the first commit.
426 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub visible_wal_tip: Option<WalSegmentPointer>,
428 /// Bounded newest-first predecessor accelerator below `visible_wal_tip`.
429 /// Chain links remain the only history authority — any disagreement
430 /// resolves in favor of the chain, and this array never protects anything
431 /// from GC.
432 #[serde(default, skip_serializing_if = "Vec::is_empty")]
433 pub recent_segments: Vec<WalSegmentPointer>,
434 /// Lifecycle state. Absent means active, on read and on write, so the
435 /// field appears only in deleted heads.
436 #[serde(default, skip_serializing_if = "NamespaceState::is_active")]
437 pub state: NamespaceState,
438}
439
440const GENESIS_COMMIT_ID: &str = "c_00000000000000000000000000000000";
441
442/// The commit id every namespace's sequence zero carries, before any commit
443/// has landed.
444pub fn genesis_commit_id() -> CommitId {
445 CommitId::parse(GENESIS_COMMIT_ID).expect("genesis commit id is valid")
446}
447
448/// A successor head changed one of the namespace's immutable identity
449/// fields. Every head a namespace ever publishes carries them forward
450/// verbatim from the head that created it.
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
452pub struct HeadIdentityDrift {
453 /// Which field the successor changed.
454 pub field: String,
455}
456
457impl fmt::Display for HeadIdentityDrift {
458 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
459 write!(
460 formatter,
461 "successor head changes the namespace's immutable `{}`",
462 self.field
463 )
464 }
465}
466
467impl HeadState {
468 /// Constructs the active sequence-zero head with the root inode already reserved.
469 pub fn initial(
470 namespace_id: NamespaceId,
471 content_store_id: ContentStoreId,
472 created_at_ms: u64,
473 ) -> Self {
474 Self {
475 namespace_id,
476 content_store_id,
477 created_at_ms,
478 fork_basis: None,
479 seq: ChangeSeq(0),
480 head_commit_id: CommitId::parse(GENESIS_COMMIT_ID).expect("genesis commit id is valid"),
481 writer_epoch: WriterEpoch(0),
482 writer: None,
483 // Inode 1 is the root directory; inode 2 is the first assignable id.
484 next_inode_id: crate::FIRST_ALLOCATABLE_INODE_ID,
485 visible_wal_tip: None,
486 recent_segments: Vec::new(),
487 state: NamespaceState::Active,
488 }
489 }
490
491 /// Checks that `successor` carries this head's immutable identity
492 /// forward verbatim.
493 ///
494 /// The head is the only durable home of the namespace's content store
495 /// and fork provenance, so every publication that rewrites
496 /// the head must copy them unchanged. Publishers call this before the
497 /// compare-and-swap: a drifting successor is a construction bug, not a
498 /// state to persist.
499 pub fn ensure_successor_identity(
500 &self,
501 successor: &HeadState,
502 ) -> Result<(), HeadIdentityDrift> {
503 let drift = |field: &str| {
504 Err(HeadIdentityDrift {
505 field: field.to_owned(),
506 })
507 };
508 if successor.namespace_id != self.namespace_id {
509 return drift("namespace_id");
510 }
511 if successor.content_store_id != self.content_store_id {
512 return drift("content_store_id");
513 }
514 if successor.created_at_ms != self.created_at_ms {
515 return drift("created_at_ms");
516 }
517 if successor.fork_basis != self.fork_basis {
518 return drift("fork_basis");
519 }
520 Ok(())
521 }
522}
523
524/// Staging progress for a service-proxied upload.
525#[derive(Debug, Clone, PartialEq, Eq)]
526pub enum ProxiedStaging {
527 /// No request owns the staging slot and no content has been staged.
528 Idle,
529 /// One request owns the staging slot.
530 Claimed,
531 /// Content that passed validation and was recorded by the session.
532 Staged(ContentRef),
533}
534
535impl Serialize for ProxiedStaging {
536 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
537 where
538 S: serde::Serializer,
539 {
540 #[derive(Serialize)]
541 #[serde(tag = "kind", rename_all = "snake_case")]
542 enum Shape<'a> {
543 Idle {},
544 Claimed {},
545 Staged { content_ref: &'a ContentRef },
546 }
547
548 match self {
549 Self::Idle => Shape::Idle {}.serialize(serializer),
550 Self::Claimed => Shape::Claimed {}.serialize(serializer),
551 Self::Staged(content_ref) => Shape::Staged { content_ref }.serialize(serializer),
552 }
553 }
554}
555
556impl<'de> Deserialize<'de> for ProxiedStaging {
557 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
558 where
559 D: Deserializer<'de>,
560 {
561 StrictProxiedStaging::deserialize(deserializer).map(Into::into)
562 }
563}
564
565/// The transport never changes, and each variant stores only the state
566/// required by that upload path.
567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
569pub enum UploadSessionTransport {
570 /// The service receives the bytes and writes the content object itself,
571 /// so it learns size and digest from the bytes as they pass.
572 ServiceProxied {
573 /// Exclusive staging progress, which applies only to this transport.
574 staging: ProxiedStaging,
575 },
576 /// The client writes the whole object through one presigned request.
577 DirectPut {
578 /// The reference that signed write is minted for.
579 ///
580 /// A direct-put client declares its byte length and SHA-256 before
581 /// the write is authorized, because both are signed into the
582 /// request and the provider refuses any body that does not match.
583 /// Completion reads the stored object back against this same
584 /// reference rather than believing it.
585 promised_content: ContentRef,
586 },
587 /// The client uploads parts and the provider assembles the object.
588 ///
589 /// Multipart sessions do not store a content reference at creation because
590 /// one-pass and streaming clients may not know the final size or checksum.
591 /// The client supplies those values at completion, when LoonFS verifies the
592 /// assembled object.
593 DirectMultipart {
594 /// The provider-side upload the parts assemble through, and the
595 /// only provider handle LoonFS keeps: parts are the client's
596 /// bookkeeping, exactly as they are in the provider's own API, so
597 /// there is no durable record per part.
598 provider_upload_id: String,
599 /// Byte length of every part except the last, settled at begin.
600 ///
601 /// A session resumed after a lost begin response reads its geometry
602 /// from here rather than being told a second, possibly different,
603 /// one. Zero is not a geometry, so it is not representable.
604 part_size_bytes: NonZeroU64,
605 /// Checksum algorithm frozen when the session began. Part signing
606 /// and completion must use this decision after any process restart.
607 checksum_algorithm: ChecksumAlgorithm,
608 },
609}
610
611impl UploadSessionTransport {
612 /// The content reference this transport names, when it names one.
613 fn content_ref(&self) -> Option<&ContentRef> {
614 match self {
615 Self::DirectPut { promised_content } => Some(promised_content),
616 Self::ServiceProxied {
617 staging: ProxiedStaging::Staged(content_ref),
618 } => Some(content_ref),
619 Self::ServiceProxied { .. } | Self::DirectMultipart { .. } => None,
620 }
621 }
622}
623
624/// Monotonic lifecycle of a durable upload session.
625///
626/// A session starts open and ends either completed or aborted. The
627/// compare-and-swap that writes the terminal state decides which transition
628/// won; provider cleanup happens afterward. Terminal sessions never reopen,
629/// so another attempt requires a new session and content id.
630#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
631#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
632pub enum UploadSessionLifecycle {
633 /// The one state that may stage bytes and complete. Live until its lease
634 /// passes, after which garbage collection aborts it.
635 Open {
636 /// Unix-millisecond instant after which the session is abandoned.
637 /// The record carries it so no session transition depends on an
638 /// object's provider timestamp.
639 expires_at_ms: u64,
640 },
641 /// Terminal: the content is durable and verified. This is the only state
642 /// a receipt may be minted from, and the content reference it carries is
643 /// what every re-mint and idempotent completion retry answers with.
644 Completed {
645 /// Unix-millisecond stamp written by the completing compare-and-swap,
646 /// and the only input to when the content may be reclaimed.
647 completed_at_ms: u64,
648 /// Verified immutable content this session settled on, and the one
649 /// place a completed session's reference exists.
650 content_ref: ContentRef,
651 },
652 /// Terminal: the session will never select content. Its content
653 /// identity was never published — a receipt exists only for a completed
654 /// session — so the object it named belongs to nobody and is deleted.
655 Aborted {
656 /// Unix-millisecond stamp written by the aborting compare-and-swap,
657 /// and the only input to when the record may be deleted.
658 aborted_at_ms: u64,
659 },
660}
661
662impl UploadSessionLifecycle {
663 /// The content reference this state names, whichever state names one.
664 fn content_ref(&self) -> Option<&ContentRef> {
665 match self {
666 Self::Open { .. } => None,
667 Self::Completed { content_ref, .. } => Some(content_ref),
668 Self::Aborted { .. } => None,
669 }
670 }
671}
672
673impl std::fmt::Display for UploadSessionLifecycle {
674 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
675 let state = match self {
676 Self::Open { .. } => "open",
677 Self::Completed { .. } => "completed",
678 Self::Aborted { .. } => "aborted",
679 };
680 formatter.write_str(state)
681 }
682}
683
684/// Tracks one durable content-upload workflow independently of commit publication.
685///
686/// The record is an identity, a transport, and a state. Everything a
687/// transport needs lives in its own variant and everything a state needs
688/// lives in its own variant, so the combinations that used to be spelled
689/// with independent optional fields — a proxied session holding a provider
690/// upload or a multipart session promising content — are not shapes this
691/// type has.
692///
693/// See [upload before publish](../../../docs/specs/format.md#242-upload-before-publish).
694#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
695pub struct UploadSessionState {
696 /// Namespace authorized to consume the staged content.
697 pub namespace_id: NamespaceId,
698 /// Durable session identity used by staging and completion requests.
699 pub upload_id: UploadId,
700 /// Content object this session writes, allocated when the session began.
701 ///
702 /// The identity exists before any byte is read, so the final object key
703 /// is known up front and belongs to exactly this session. Every
704 /// reference the record holds names this object; see `validate` below,
705 /// which refuses a record that disagrees with itself.
706 pub content_id: ContentId,
707 /// Unix-millisecond creation stamp.
708 pub created_at_ms: u64,
709 /// How the bytes reach object storage, settled when the session opened.
710 pub transport: UploadSessionTransport,
711 /// The session's lifecycle, and the field every upload operation
712 /// compare-and-swaps against.
713 pub state: UploadSessionLifecycle,
714}
715
716impl UploadSessionState {
717 /// Proves the relationships this record's shape cannot express but every
718 /// reader of one depends on.
719 ///
720 /// Every reference the record holds is about the same content object,
721 /// whose identity the session allocated before any byte moved. A record
722 /// whose references disagree with its own `content_id` describes two
723 /// objects and cannot be acted on — a completion would verify one key
724 /// and publish another.
725 ///
726 /// A direct-put session must also settle on exactly the reference its
727 /// write was signed against, because that reference is what the provider
728 /// enforced and what completion read back. A record that says otherwise
729 /// describes an upload that did not happen.
730 fn validate(&self) -> Result<(), String> {
731 for content_ref in self
732 .transport
733 .content_ref()
734 .into_iter()
735 .chain(self.state.content_ref())
736 {
737 content_ref.validate().map_err(|error| {
738 format!(
739 "upload session `{}` holds an invalid content ref: {error}",
740 self.upload_id
741 )
742 })?;
743 if content_ref.content_id != self.content_id {
744 return Err(format!(
745 "upload session `{}` owns content `{}` but holds a reference to `{}`",
746 self.upload_id, self.content_id, content_ref.content_id
747 ));
748 }
749 }
750 match (&self.transport, &self.state) {
751 (
752 UploadSessionTransport::DirectPut { promised_content },
753 UploadSessionLifecycle::Completed { content_ref, .. },
754 ) if content_ref != promised_content => Err(format!(
755 "upload session `{}` completed on content its direct write never promised",
756 self.upload_id
757 )),
758 _ => Ok(()),
759 }
760 }
761}
762
763#[derive(Deserialize)]
764#[serde(deny_unknown_fields)]
765struct StrictUploadSessionState {
766 namespace_id: NamespaceId,
767 upload_id: UploadId,
768 content_id: ContentId,
769 created_at_ms: u64,
770 transport: StrictUploadSessionTransport,
771 state: StrictUploadSessionLifecycle,
772}
773
774/// The transport read back through the same strict content-ref decoder the
775/// rest of the record uses.
776#[derive(Deserialize)]
777#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
778enum StrictUploadSessionTransport {
779 ServiceProxied {
780 staging: StrictProxiedStaging,
781 },
782 DirectPut {
783 promised_content: StrictContentRef,
784 },
785 DirectMultipart {
786 provider_upload_id: String,
787 part_size_bytes: NonZeroU64,
788 checksum_algorithm: ChecksumAlgorithm,
789 },
790}
791
792impl From<StrictUploadSessionTransport> for UploadSessionTransport {
793 fn from(transport: StrictUploadSessionTransport) -> Self {
794 match transport {
795 StrictUploadSessionTransport::ServiceProxied { staging } => Self::ServiceProxied {
796 staging: staging.into(),
797 },
798 StrictUploadSessionTransport::DirectPut { promised_content } => Self::DirectPut {
799 promised_content: promised_content.into(),
800 },
801 StrictUploadSessionTransport::DirectMultipart {
802 provider_upload_id,
803 part_size_bytes,
804 checksum_algorithm,
805 } => Self::DirectMultipart {
806 provider_upload_id,
807 part_size_bytes,
808 checksum_algorithm,
809 },
810 }
811 }
812}
813
814#[derive(Deserialize)]
815#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
816enum StrictProxiedStaging {
817 Idle {},
818 Claimed {},
819 Staged { content_ref: StrictContentRef },
820}
821
822impl From<StrictProxiedStaging> for ProxiedStaging {
823 fn from(staging: StrictProxiedStaging) -> Self {
824 match staging {
825 StrictProxiedStaging::Idle {} => Self::Idle,
826 StrictProxiedStaging::Claimed {} => Self::Claimed,
827 StrictProxiedStaging::Staged { content_ref } => Self::Staged(content_ref.into()),
828 }
829 }
830}
831
832/// The lifecycle read back through the same strict content-ref decoder the
833/// rest of the record uses, so a completed session's reference is held to
834/// the durable schema rather than the wire one.
835#[derive(Deserialize)]
836#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
837enum StrictUploadSessionLifecycle {
838 Open {
839 expires_at_ms: u64,
840 },
841 Completed {
842 completed_at_ms: u64,
843 content_ref: StrictContentRef,
844 },
845 Aborted {
846 aborted_at_ms: u64,
847 },
848}
849
850impl From<StrictUploadSessionLifecycle> for UploadSessionLifecycle {
851 fn from(state: StrictUploadSessionLifecycle) -> Self {
852 match state {
853 StrictUploadSessionLifecycle::Open { expires_at_ms } => Self::Open { expires_at_ms },
854 StrictUploadSessionLifecycle::Completed {
855 completed_at_ms,
856 content_ref,
857 } => Self::Completed {
858 completed_at_ms,
859 content_ref: content_ref.into(),
860 },
861 StrictUploadSessionLifecycle::Aborted { aborted_at_ms } => {
862 Self::Aborted { aborted_at_ms }
863 }
864 }
865 }
866}
867
868#[derive(Deserialize)]
869#[serde(deny_unknown_fields)]
870struct StrictContentRef {
871 kind: MutableContentRefKind,
872 content_id: ContentId,
873 size_bytes: u64,
874 checksum: Checksum,
875}
876
877#[derive(Deserialize)]
878#[serde(rename_all = "snake_case")]
879enum MutableContentRefKind {
880 BlobV1,
881}
882
883impl From<StrictContentRef> for ContentRef {
884 fn from(content_ref: StrictContentRef) -> Self {
885 let kind = match content_ref.kind {
886 MutableContentRefKind::BlobV1 => ContentRefKind::BlobV1,
887 };
888 Self {
889 kind,
890 content_id: content_ref.content_id,
891 size_bytes: content_ref.size_bytes,
892 checksum: content_ref.checksum,
893 }
894 }
895}
896
897impl<'de> Deserialize<'de> for UploadSessionState {
898 /// Reads one session record and refuses one that `validate` finds
899 /// disagreeing with itself, like any other corruption and with no shim
900 /// or salvage.
901 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
902 where
903 D: Deserializer<'de>,
904 {
905 let state = StrictUploadSessionState::deserialize(deserializer)?;
906 let session = Self {
907 namespace_id: state.namespace_id,
908 upload_id: state.upload_id,
909 content_id: state.content_id,
910 created_at_ms: state.created_at_ms,
911 transport: state.transport.into(),
912 state: state.state.into(),
913 };
914 session.validate().map_err(serde::de::Error::custom)?;
915 Ok(session)
916 }
917}
918
919/// In-memory view of a control object envelope.
920///
921/// This struct is not the durable layout; durable bytes are produced only by
922/// [`encode_control_object`] and validated only by [`decode_control_object`].
923#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
924pub struct ControlObjectEnvelope<T> {
925 /// Durable-family discriminator that selects `T` and its independent version.
926 pub kind: ControlObjectKind,
927 /// Family-local format version obtained from `kind`.
928 pub format_version: u32,
929 /// Digest of the payload JSON exactly as stored in the durable document,
930 /// in `sha256:<hex>` form.
931 pub payload_checksum: String,
932 /// Decoded control state protected by `payload_checksum`.
933 pub state: T,
934}
935
936impl<T> ControlObjectEnvelope<T>
937where
938 T: Serialize,
939{
940 /// Builds a family-versioned envelope and computes its checksum from canonical state JSON.
941 ///
942 /// Construction fails when `state` cannot be encoded.
943 pub fn from_state(kind: ControlObjectKind, state: T) -> Result<Self, EnvelopeCodecError> {
944 Ok(Self {
945 kind,
946 format_version: kind.format_version(),
947 payload_checksum: control_payload_checksum(&state)?,
948 state,
949 })
950 }
951}
952
953/// Specializes a control envelope for the authoritative namespace head.
954pub type HeadStateEnvelope = ControlObjectEnvelope<HeadState>;
955/// Specializes a control envelope for a durable upload workflow.
956pub type UploadSessionEnvelope = ControlObjectEnvelope<UploadSessionState>;
957/// Specializes a control envelope for the selected materialized manifest.
958pub type MetadataRootEnvelope = ControlObjectEnvelope<MetadataRootState>;
959/// Specializes a control envelope for the retained-history floor.
960pub type WalFloorEnvelope = ControlObjectEnvelope<WalFloorState>;
961/// Specializes a control envelope for a durable manifest pin.
962pub type CheckpointRecordEnvelope = ControlObjectEnvelope<CheckpointRecordState>;
963/// Specializes a control envelope for a running compaction's ownership of
964/// its staged output.
965pub type MetadataCompactionLeaseEnvelope = ControlObjectEnvelope<MetadataCompactionLeaseState>;
966
967/// Computes the checksum stored beside canonical JSON for a control state.
968///
969/// Computation fails when `state` cannot be serialized.
970pub fn control_payload_checksum<T>(state: &T) -> Result<String, EnvelopeCodecError>
971where
972 T: Serialize,
973{
974 crate::envelope::json_payload_checksum(state)
975}
976
977/// Encodes a control-object envelope as its durable JSON representation.
978///
979/// Encoding fails when the family version is unsupported, the in-memory
980/// checksum is stale, or JSON serialization fails. See
981/// [mutable control-object rules](../../../docs/specs/format.md#17-mutable-control-object-rules).
982pub fn encode_control_object<T>(
983 envelope: &ControlObjectEnvelope<T>,
984) -> Result<Vec<u8>, EnvelopeCodecError>
985where
986 T: Serialize,
987{
988 crate::envelope::encode_json_envelope(
989 envelope.kind.as_str(),
990 envelope.format_version,
991 envelope.kind.format_version(),
992 &envelope.payload_checksum,
993 &envelope.state,
994 )
995}
996
997/// Encodes state in a control-object envelope.
998pub fn encode_control_state<T: Serialize>(
999 kind: ControlObjectKind,
1000 state: &T,
1001) -> Result<Vec<u8>, EnvelopeCodecError> {
1002 let envelope = ControlObjectEnvelope::from_state(kind, state)?;
1003 encode_control_object(&envelope)
1004}
1005
1006/// Decodes and verifies a durable JSON control object of `expected_kind`.
1007///
1008/// Decoding fails for invalid JSON, an unknown or mismatched kind, an
1009/// unsupported family version, a checksum mismatch, or an invalid `T`. See
1010/// [mutable control-object rules](../../../docs/specs/format.md#17-mutable-control-object-rules).
1011pub fn decode_control_object<T>(
1012 bytes: &[u8],
1013 expected_kind: ControlObjectKind,
1014) -> Result<ControlObjectEnvelope<T>, EnvelopeCodecError>
1015where
1016 T: DeserializeOwned,
1017{
1018 let decoded = crate::envelope::decode_strict_json_envelope(
1019 bytes,
1020 expected_kind.format_version(),
1021 // The kind registry reports unknown kinds distinctly from
1022 // registered-but-mismatched ones.
1023 |found| match ControlObjectKind::parse(found) {
1024 None => Err(EnvelopeCodecError::UnknownKind {
1025 found: found.to_owned(),
1026 }),
1027 Some(kind) if kind != expected_kind => Err(EnvelopeCodecError::KindMismatch {
1028 expected: expected_kind.as_str().to_owned(),
1029 found: found.to_owned(),
1030 }),
1031 Some(_) => Ok(()),
1032 },
1033 )?;
1034
1035 Ok(ControlObjectEnvelope {
1036 kind: expected_kind,
1037 format_version: decoded.format_version,
1038 payload_checksum: decoded.payload_checksum,
1039 state: decoded.payload,
1040 })
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045 use super::*;
1046
1047 #[test]
1048 fn control_object_kind_strings_round_trip_and_match_serde() {
1049 for kind in ControlObjectKind::ALL {
1050 assert_eq!(ControlObjectKind::parse(kind.as_str()), Some(kind));
1051 let serialized = serde_json::to_value(kind).expect("serialize kind");
1052 assert_eq!(serialized, serde_json::Value::from(kind.as_str()));
1053 }
1054 assert_eq!(ControlObjectKind::parse("not_a_kind"), None);
1055 }
1056
1057 fn sample_head() -> HeadState {
1058 HeadState::initial(
1059 NamespaceId::parse("demo").expect("valid namespace id"),
1060 ContentStoreId::parse("cs_0123456789abcdef0123456789abcdef")
1061 .expect("valid content store id"),
1062 1_000,
1063 )
1064 }
1065
1066 #[test]
1067 fn head_without_content_store_is_rejected() {
1068 // The content store is the namespace's addressing-semantics
1069 // authority; a head that omits it is malformed, never defaulted.
1070 let missing = serde_json::json!({
1071 "namespace_id": "demo",
1072 "seq": 0,
1073 "head_commit_id": GENESIS_COMMIT_ID,
1074 "writer_epoch": 0,
1075 "next_inode_id": 2
1076 });
1077
1078 serde_json::from_value::<HeadState>(missing)
1079 .expect_err("head without its immutable identity must be rejected");
1080 }
1081
1082 /// One WAL pointer as it appears inside a durable head payload.
1083 fn wal_pointer_json(segment_id: &str, start_seq: u64, end_seq: u64) -> serde_json::Value {
1084 serde_json::json!({
1085 "segment_id": segment_id,
1086 "start_seq": start_seq,
1087 "end_seq": end_seq,
1088 "payload_checksum": format!("sha256:{}", "b".repeat(64)),
1089 })
1090 }
1091
1092 /// A decodable head payload carrying whatever tip and accelerator the
1093 /// caller wants to present. Both fields are omitted when empty, exactly
1094 /// as the encoder writes them.
1095 fn head_json(
1096 visible_wal_tip: Option<serde_json::Value>,
1097 recent_segments: Vec<serde_json::Value>,
1098 ) -> serde_json::Value {
1099 let mut head = serde_json::json!({
1100 "namespace_id": "demo",
1101 "content_store_id": "cs_0123456789abcdef0123456789abcdef",
1102 "created_at_ms": 1_000,
1103 "seq": 2,
1104 "head_commit_id": GENESIS_COMMIT_ID,
1105 "writer_epoch": 0,
1106 "next_inode_id": 2
1107 });
1108 if let Some(tip) = visible_wal_tip {
1109 head["visible_wal_tip"] = tip;
1110 }
1111 if !recent_segments.is_empty() {
1112 head["recent_segments"] = serde_json::Value::Array(recent_segments);
1113 }
1114 head
1115 }
1116
1117 #[test]
1118 fn head_decodes_with_a_tip_and_no_predecessor_hints() {
1119 let tip = wal_pointer_json("00000000000000000002-fedcba9876543210", 2, 2);
1120
1121 let head = serde_json::from_value::<HeadState>(head_json(Some(tip), Vec::new()))
1122 .expect("the first published segment has no predecessor hints");
1123 assert!(head.visible_wal_tip.is_some());
1124 assert!(head.recent_segments.is_empty());
1125 }
1126
1127 #[test]
1128 fn predecessor_hints_do_not_repeat_the_tip() {
1129 let tip = wal_pointer_json("00000000000000000002-fedcba9876543210", 2, 2);
1130 let older = wal_pointer_json("00000000000000000001-0123456789abcdef", 1, 1);
1131
1132 let head = serde_json::from_value::<HeadState>(head_json(Some(tip), vec![older.clone()]))
1133 .expect("predecessor hints decode independently of the authoritative tip");
1134 assert_eq!(
1135 head.recent_segments,
1136 vec![serde_json::from_value(older).expect("valid predecessor pointer")]
1137 );
1138 }
1139
1140 #[test]
1141 fn old_pointer_object_key_is_rejected() {
1142 let mut tip = wal_pointer_json("00000000000000000002-fedcba9876543210", 2, 2);
1143 tip["object_key"] = serde_json::json!(
1144 "namespaces/demo/wal/segments/00000000000000000002-fedcba9876543210.wal.zst"
1145 );
1146
1147 serde_json::from_value::<HeadState>(head_json(Some(tip), Vec::new()))
1148 .expect_err("the v1 hard cutover rejects the former stored object key");
1149 }
1150
1151 #[test]
1152 fn genesis_head_decodes_without_a_tip_or_hints() {
1153 let genesis = serde_json::from_value::<HeadState>(head_json(None, Vec::new()))
1154 .expect("a head with no visible tip decodes");
1155 assert_eq!(genesis.visible_wal_tip, None);
1156 assert!(genesis.recent_segments.is_empty());
1157 }
1158
1159 #[test]
1160 fn control_object_codec_round_trips_and_validates() {
1161 let envelope = HeadStateEnvelope::from_state(ControlObjectKind::WalHead, sample_head())
1162 .expect("envelope");
1163
1164 let encoded = encode_control_object(&envelope).expect("encode");
1165 let decoded: HeadStateEnvelope =
1166 decode_control_object(&encoded, ControlObjectKind::WalHead).expect("decode");
1167 assert_eq!(decoded, envelope);
1168
1169 let mismatch =
1170 decode_control_object::<MetadataRootState>(&encoded, ControlObjectKind::MetadataRoot)
1171 .expect_err("kind mismatch");
1172 assert!(matches!(mismatch, EnvelopeCodecError::KindMismatch { .. }));
1173 }
1174
1175 #[test]
1176 fn successor_head_must_carry_the_namespace_identity_forward() {
1177 let head = sample_head();
1178 let mut successor = head.clone();
1179 successor.seq = ChangeSeq(4);
1180 head.ensure_successor_identity(&successor)
1181 .expect("advancing the sequence keeps the identity");
1182
1183 let mut drifted = head.clone();
1184 drifted.content_store_id = ContentStoreId::parse("cs_fedcba9876543210fedcba9876543210")
1185 .expect("valid content store id");
1186 assert_eq!(
1187 head.ensure_successor_identity(&drifted)
1188 .expect_err("content store drift is rejected")
1189 .field,
1190 "content_store_id"
1191 );
1192
1193 let mut forked = head.clone();
1194 forked.fork_basis = Some(ForkBasis {
1195 source_namespace_id: NamespaceId::parse("source").expect("valid namespace id"),
1196 source_manifest_object_id: ManifestObjectId::parse(
1197 "00000000000000000007-0123456789abcdef",
1198 )
1199 .expect("valid manifest object id"),
1200 source_manifest_checksum: "sha256:test".to_owned(),
1201 source_checkpoint_id: CheckpointId::parse("chk_00000000000000000000000000000002")
1202 .expect("valid checkpoint id"),
1203 fork_seq: ChangeSeq(7),
1204 });
1205 assert_eq!(
1206 head.ensure_successor_identity(&forked)
1207 .expect_err("gaining a fork basis is rejected")
1208 .field,
1209 "fork_basis"
1210 );
1211 }
1212}