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}