Skip to main content

loonfs_api/
wal.rs

1//! The WAL segment format: envelopes, commit payloads, and the delta
2//! records replay applies (format spec, "WAL segments").
3
4use crate::control::WalSegmentPointer;
5use crate::digest::sha256_digest;
6use crate::envelope::{self, EnvelopeCodecError, EnvelopeProbe};
7use crate::{
8    ChangeSeq, CommitId, ContentRef, DisplayName, InodeId, InodeKind, NameKey, NamespaceId,
9    RevisionNo, WalSegmentId, WriterEpoch,
10};
11use ciborium::{de::from_reader, ser::into_writer};
12use serde::{Deserialize, Serialize};
13
14/// Version 1: a zstd-compressed CBOR envelope document carrying the payload
15/// as an opaque CBOR byte string. `payload_checksum` covers exactly those
16/// bytes, and delta/precondition tags use the snake_case names the format
17/// spec fixes ("Standard mutation operations" and "Preconditions").
18pub const WAL_FORMAT_VERSION: u32 = 1;
19
20/// Identifies the durable payload family carried by a WAL envelope.
21///
22/// See [WAL segment rules](../../../docs/specs/format.md#15-wal-segment-rules).
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum WalEnvelopeKind {
26    /// Marks an immutable segment in one namespace's authoritative WAL chain.
27    NamespaceWalSegment,
28}
29
30impl WalEnvelopeKind {
31    /// Returns the frozen envelope discriminator written to durable storage.
32    pub const fn as_str(self) -> &'static str {
33        match self {
34            Self::NamespaceWalSegment => "namespace_wal_segment",
35        }
36    }
37}
38
39/// Records one replayable metadata mutation materialized from a semantic commit operation.
40///
41/// See [standard mutation operations](../../../docs/specs/format.md#35-standard-mutation-operations).
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(tag = "kind", rename_all = "snake_case")]
44pub enum WalDelta {
45    /// Introduces an inode whose identity and kind remain fixed for its lifetime.
46    CreateInode {
47        /// Stable position of this delta within its commit, used in row ordering and identity.
48        delta_index: u32,
49        /// Newly allocated durable inode identity.
50        inode_id: InodeId,
51        /// File-or-directory classification established at creation.
52        inode_kind: InodeKind,
53    },
54    /// Makes a child reachable under one canonical name in a directory.
55    BindDirentry {
56        /// Stable position of this delta within its commit, used to identify the binding.
57        delta_index: u32,
58        /// Directory receiving the new name binding.
59        parent_inode_id: InodeId,
60        /// Policy-derived lookup key on which directory uniqueness is enforced.
61        name_key: NameKey,
62        /// User-facing spelling preserved independently of `name_key`.
63        display_name: DisplayName,
64        /// Inode made reachable by the binding.
65        child_inode_id: InodeId,
66    },
67    /// Removes one exact historical directory binding without affecting a later rebind.
68    UnbindDirentry {
69        /// Stable position of this unbind within its commit.
70        delta_index: u32,
71        /// Directory from which the binding is removed.
72        parent_inode_id: InodeId,
73        /// Canonical lookup key of the binding being removed.
74        name_key: NameKey,
75        /// User-facing spelling the removed binding carried, so feed
76        /// consumers see the name a person typed without a second lookup.
77        display_name: DisplayName,
78        /// Child identity expected on the targeted binding.
79        child_inode_id: InodeId,
80        /// Commit sequence that created the exact binding being removed.
81        bind_seq: ChangeSeq,
82        /// Delta position that disambiguates the binding within `bind_seq`.
83        bind_delta_index: u32,
84    },
85    /// Publishes the next immutable content revision of a file inode.
86    AppendFileRevision {
87        /// Stable position of this revision delta within its commit.
88        delta_index: u32,
89        /// File inode receiving the revision.
90        inode_id: InodeId,
91        /// Monotonic per-file revision number validated against visible history.
92        revision_no: RevisionNo,
93        /// Immutable content that must already be durable before publication.
94        content_ref: ContentRef,
95    },
96    /// Hides a rooted subtree from snapshots at this delta's sequence or later.
97    TombstoneSubtree {
98        /// Stable position that identifies this tombstone within its commit.
99        delta_index: u32,
100        /// Inode at the root of the newly hidden subtree.
101        root_inode_id: InodeId,
102        /// Directory that held the deleted binding, when the tombstone came
103        /// from a path delete. Carried so the deleted name survives on the
104        /// immortal tombstone row after unbind rows age out.
105        #[serde(default, skip_serializing_if = "Option::is_none")]
106        parent_inode_id: Option<InodeId>,
107        /// Canonical key of the deleted binding, when known.
108        #[serde(default, skip_serializing_if = "Option::is_none")]
109        name_key: Option<NameKey>,
110        /// User-facing spelling of the deleted binding, when known.
111        #[serde(default, skip_serializing_if = "Option::is_none")]
112        display_name: Option<DisplayName>,
113    },
114    /// Revokes exactly one subtree tombstone — the one recorded at
115    /// `(target_seq, target_delta_index)` — making the subtree eligible for
116    /// visibility again once re-bound. An immutable compensating event, not
117    /// an in-place row deletion: a later `TombstoneSubtree` for the same
118    /// root supersedes the revoke.
119    RevokeSubtreeTombstone {
120        /// Stable position of this compensating delta within its commit.
121        delta_index: u32,
122        /// Root inode whose selected tombstone is being revoked.
123        root_inode_id: InodeId,
124        /// Commit sequence of the exact tombstone this delta compensates.
125        target_seq: ChangeSeq,
126        /// Delta position of the exact tombstone within `target_seq`.
127        target_delta_index: u32,
128    },
129}
130
131/// Associates a materialized WAL delta with the semantic operation that produced it.
132///
133/// See [logical commits](../../../docs/specs/format.md#33-logical-commits-sequence-numbers-and-visibility).
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct WalCommitDelta {
136    /// Zero-based request-operation position used to attribute one or more resulting deltas.
137    pub semantic_op_index: u32,
138    /// Replay mutation produced for that semantic operation.
139    pub delta: WalDelta,
140}
141
142/// Carries one accepted logical commit inside a WAL segment.
143///
144/// See [WAL segment rules](../../../docs/specs/format.md#15-wal-segment-rules).
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct WalCommitPayload {
147    /// Namespace-wide commit position; segment records must cover their range contiguously.
148    pub seq: ChangeSeq,
149    /// Caller idempotency key whose reuse must retain the same semantic fingerprint.
150    pub commit_id: CommitId,
151    /// Digest of semantic request content used to reject conflicting `commit_id` reuse.
152    pub semantic_commit_fingerprint: String,
153    /// Wall-clock stamp from the publishing writer's request context, in
154    /// Unix milliseconds. Observational only: never a validity or ordering
155    /// input — `seq` is the order — and excluded from the semantic commit
156    /// fingerprint, so replay identity is untouched by clocks.
157    pub committed_at_ms: u64,
158    /// Caller-supplied annotation, omitted when absent and excluded from filesystem semantics.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub message: Option<String>,
161    /// Materialized mutations in their authoritative `delta_index` order.
162    pub deltas: Vec<WalCommitDelta>,
163}
164
165/// Carries the namespace-specific chain metadata and commits stored in one WAL object.
166///
167/// See [WAL segment rules](../../../docs/specs/format.md#15-wal-segment-rules).
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct WalSegmentPayload {
170    /// Namespace this segment belongs to; recovery rejects cross-namespace content.
171    pub namespace_id: NamespaceId,
172    /// Immutable object identity expected to agree with the head pointer and object key.
173    pub segment_id: WalSegmentId,
174    /// Fencing epoch of the writer that proposed this segment.
175    pub writer_epoch: WriterEpoch,
176    /// Previous accepted chain member, or `None` only when no visible segment precedes this one.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub prev_visible_segment: Option<WalSegmentPointer>,
179    /// Head sequence the writer materialized against before adding these records.
180    pub base_head_seq: ChangeSeq,
181    /// Sequence of the first record, and the position encoded into `segment_id`.
182    pub start_seq: ChangeSeq,
183    /// Sequence of the final record, checked against both `records` and the head pointer.
184    pub end_seq: ChangeSeq,
185    /// Logical commits in contiguous ascending sequence order.
186    pub records: Vec<WalCommitPayload>,
187}
188
189/// In-memory view of a WAL segment envelope.
190///
191/// This struct is not the durable layout; durable bytes are produced only by
192/// [`encode_wal_segment_envelope_zstd`] and validated only by
193/// [`decode_wal_segment_envelope_zstd`].
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct WalSegmentEnvelope {
196    /// Durable-family discriminator checked before payload decoding.
197    pub kind: WalEnvelopeKind,
198    /// Family-local format version, which must equal [`WAL_FORMAT_VERSION`].
199    pub format_version: u32,
200    /// Digest of the encoded payload bytes exactly as stored in the durable
201    /// document, in `sha256:<hex>` form.
202    pub payload_checksum: String,
203    /// Decoded namespace segment content protected by `payload_checksum`.
204    pub payload: WalSegmentPayload,
205}
206
207impl WalSegmentEnvelope {
208    /// Builds a versioned envelope and computes its checksum from canonical CBOR payload bytes.
209    ///
210    /// Construction fails when the payload cannot be encoded.
211    pub fn from_payload(payload: WalSegmentPayload) -> Result<Self, EnvelopeCodecError> {
212        Ok(Self {
213            kind: WalEnvelopeKind::NamespaceWalSegment,
214            format_version: WAL_FORMAT_VERSION,
215            payload_checksum: wal_payload_checksum(&payload)?,
216            payload,
217        })
218    }
219
220    /// Projects the integrity and sequence metadata needed to link this stored segment from a head.
221    pub fn pointer(&self, object_key: String) -> WalSegmentPointer {
222        WalSegmentPointer {
223            object_key,
224            segment_id: self.payload.segment_id.clone(),
225            start_seq: self.payload.start_seq,
226            end_seq: self.payload.end_seq,
227            payload_checksum: self.payload_checksum.clone(),
228        }
229    }
230}
231
232/// Durable layout of a WAL segment object (before zstd compression): the
233/// envelope fields plus the payload as an opaque CBOR byte string.
234/// `payload_checksum` covers exactly those bytes, so integrity verification
235/// never depends on re-encoding the payload with this build's schema and a
236/// payload with unknown additive fields still verifies.
237#[derive(Serialize, Deserialize)]
238struct WalSegmentDocument {
239    kind: String,
240    format_version: u32,
241    payload_checksum: String,
242    #[serde(with = "serde_bytes")]
243    payload: Vec<u8>,
244}
245
246pub(crate) fn wal_payload_checksum(
247    payload: &WalSegmentPayload,
248) -> Result<String, EnvelopeCodecError> {
249    Ok(sha256_digest(&encode_wal_payload_cbor(payload)?))
250}
251
252pub(crate) fn encode_wal_payload_cbor(
253    payload: &WalSegmentPayload,
254) -> Result<Vec<u8>, EnvelopeCodecError> {
255    let mut encoded = Vec::new();
256    into_writer(payload, &mut encoded)
257        .map_err(|err| EnvelopeCodecError::PayloadEncode(err.to_string()))?;
258    Ok(encoded)
259}
260
261/// Encodes a WAL envelope as its durable zstd-compressed CBOR representation.
262///
263/// Encoding fails when the version is unsupported, the in-memory checksum is
264/// stale, CBOR serialization fails, or zstd cannot compress the document. See
265/// [WAL segment rules](../../../docs/specs/format.md#15-wal-segment-rules).
266pub fn encode_wal_segment_envelope_zstd(
267    envelope: &WalSegmentEnvelope,
268) -> Result<Vec<u8>, EnvelopeCodecError> {
269    envelope::verify_version(
270        envelope.kind.as_str(),
271        envelope.format_version,
272        WAL_FORMAT_VERSION,
273    )?;
274    let payload_bytes = encode_wal_payload_cbor(&envelope.payload)?;
275    envelope::verify_checksum_fresh(&envelope.payload_checksum, &payload_bytes)?;
276
277    let document = WalSegmentDocument {
278        kind: envelope.kind.as_str().to_owned(),
279        format_version: envelope.format_version,
280        payload_checksum: envelope.payload_checksum.clone(),
281        payload: payload_bytes,
282    };
283    let mut encoded = Vec::new();
284    into_writer(&document, &mut encoded)
285        .map_err(|err| EnvelopeCodecError::EnvelopeEncode(err.to_string()))?;
286    zstd::stream::encode_all(encoded.as_slice(), crate::sst_blocks::ZSTD_LEVEL)
287        .map_err(|err| EnvelopeCodecError::Compress(err.to_string()))
288}
289
290/// Decodes and verifies a durable zstd-compressed WAL segment envelope.
291///
292/// Decoding fails for invalid compression or CBOR, the wrong kind or version,
293/// a checksum mismatch, or an invalid payload. See
294/// [WAL segment rules](../../../docs/specs/format.md#15-wal-segment-rules).
295pub fn decode_wal_segment_envelope_zstd(
296    bytes: &[u8],
297) -> Result<WalSegmentEnvelope, EnvelopeCodecError> {
298    let decompressed = zstd::stream::decode_all(bytes)
299        .map_err(|err| EnvelopeCodecError::Decompress(err.to_string()))?;
300    let probe: EnvelopeProbe = from_reader(decompressed.as_slice())
301        .map_err(|err| EnvelopeCodecError::EnvelopeDecode(err.to_string()))?;
302    let expected_kind = WalEnvelopeKind::NamespaceWalSegment;
303    envelope::verify_kind(expected_kind.as_str(), &probe.kind)?;
304    envelope::verify_version(&probe.kind, probe.format_version, WAL_FORMAT_VERSION)?;
305
306    let document: WalSegmentDocument = from_reader(decompressed.as_slice())
307        .map_err(|err| EnvelopeCodecError::EnvelopeDecode(err.to_string()))?;
308    envelope::verify_payload_checksum(&document.payload_checksum, &document.payload)?;
309    let payload: WalSegmentPayload = from_reader(document.payload.as_slice())
310        .map_err(|err| EnvelopeCodecError::PayloadDecode(err.to_string()))?;
311
312    Ok(WalSegmentEnvelope {
313        kind: expected_kind,
314        format_version: document.format_version,
315        payload_checksum: document.payload_checksum,
316        payload,
317    })
318}