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