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}