loonfs_api/manifest.rs
1//! The namespace manifest format: the durable document naming the
2//! metadata SST runs that materialize one namespace file-set version
3//! (format spec, "Namespace manifests").
4
5use crate::envelope::EnvelopeCodecError;
6use crate::sst_blocks::BlockHandle;
7use crate::WriterEpoch;
8use crate::{
9 ChangeSeq, CommitId, ContentRef, DisplayName, InodeId, InodeKind, ManifestId, ManifestObjectId,
10 MetadataTableId, NameKey, NamespaceId, RevisionNo,
11};
12use serde::{Deserialize, Serialize};
13
14/// Version 1: an uncompressed JSON envelope document carrying the payload as
15/// a raw JSON fragment. `payload_checksum` covers the fragment's exact bytes.
16pub const NAMESPACE_MANIFEST_FORMAT_VERSION: u32 = 1;
17
18/// Identifies the durable payload family carried by a namespace-manifest envelope.
19///
20/// See [durable object families](../../../docs/specs/format.md#12-durable-object-families).
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum NamespaceManifestKind {
24 /// Marks the file-set descriptor used to materialize a namespace snapshot.
25 NamespaceManifest,
26}
27
28impl NamespaceManifestKind {
29 /// Returns the frozen envelope discriminator written to durable storage.
30 pub const fn as_str(self) -> &'static str {
31 match self {
32 Self::NamespaceManifest => "namespace_manifest",
33 }
34 }
35}
36
37/// Selects a metadata row family and its durable lookup ordering.
38///
39/// See [metadata segments](../../../docs/specs/format.md#421-metadata-segments).
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum MetadataTableFamily {
43 /// Stores inode identity, kind, and creation position.
44 Inodes,
45 /// Orders directory bindings for parent-and-name visibility lookups.
46 DirentryBinds,
47 /// Re-indexes directory bindings by child for parent discovery.
48 DirentryChildBinds,
49 /// Stores immutable events that retire exact historical bindings.
50 DirentryUnbinds,
51 /// Stores file revisions in their canonical durable ordering.
52 Revisions,
53 /// Re-indexes file revisions for newest-first per-inode reads.
54 RevisionsByInodeDesc,
55 /// Stores set and revoke events used to determine active subtree tombstones.
56 Tombstones,
57 /// Names the deletions that are recoverable right now, derived from the
58 /// tombstone family and ordered by deletion time.
59 ActiveDeletions,
60 /// Preserves commit idempotency evidence independently of retained WAL history.
61 CommitReceipts,
62}
63
64/// Describes one immutable metadata SST object referenced by a namespace manifest.
65///
66/// See [metadata segments](../../../docs/specs/format.md#421-metadata-segments).
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct MetadataFileRef {
69 /// Namespace whose keyspace owns the object, which may be a fork source rather than the reader.
70 pub owner_namespace_id: NamespaceId,
71 /// Immutable table identity incorporated into the object's durable key.
72 pub table_id: MetadataTableId,
73 /// Fully resolved object-store key trusted only after descriptor validation.
74 pub object_key: String,
75 /// Namespace sequence at which this run was produced.
76 pub run_seq: ChangeSeq,
77 /// Compaction tier used to order overlapping runs during reads and reorganization.
78 pub level: u32,
79 /// Row schema and lookup ordering encoded in this segment.
80 pub family: MetadataTableFamily,
81 /// Zero-based shard position among segments emitted for the same family and run.
82 pub segment_index: u32,
83 /// Number of row payloads in the segment, used for validation and planning.
84 pub row_count: u64,
85 /// Inclusive least durable row key; the segment is corrupt if decoded rows disagree.
86 pub min_key: String,
87 /// Inclusive greatest durable row key; range planning skips disjoint segments.
88 pub max_key: String,
89 /// Where the segment's index block lives and how to verify it. The
90 /// descriptor is the only entry point into a segment object — there is
91 /// no footer — so a reader starts here.
92 pub index_block: BlockHandle,
93 /// Where the segment's bloom filter block lives and how to verify it.
94 pub filter_block: BlockHandle,
95 /// The filter block's stored bytes inlined as hex, present when the
96 /// filter is small (small delta runs). Point lookups consult it to skip
97 /// the segment without any object fetch; `filter_block` still names and
98 /// verifies the same bytes, so the inline copy must decode byte-for-byte
99 /// identical (same length and CRC32C) or the manifest is corrupt.
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub filter_inline: Option<String>,
102 /// Checksum of the segment object's full bytes, in `sha256:<hex>` form.
103 /// The ranged read path verifies per-block CRCs instead; this digest is
104 /// the segment's identity in the decoded-block cache.
105 pub payload_checksum: String,
106}
107
108/// Stores one materialized metadata event in an SST segment.
109///
110/// See [metadata segments](../../../docs/specs/format.md#421-metadata-segments).
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(tag = "kind", rename_all = "snake_case")]
113pub enum MetadataRow {
114 /// Establishes one inode's immutable identity and kind.
115 Inode {
116 /// Namespace-scoped inode identity allocated by the publishing writer.
117 inode_id: InodeId,
118 /// Classification fixed when the inode was created.
119 inode_kind: InodeKind,
120 /// Commit sequence from which the inode can become visible.
121 created_seq: ChangeSeq,
122 },
123 /// Records one generation of a directory name binding.
124 DirentryBind {
125 /// Directory in which the name was bound.
126 parent_inode_id: InodeId,
127 /// Policy-derived key used for uniqueness and lookup.
128 name_key: NameKey,
129 /// User-facing component spelling retained for directory responses.
130 display_name: DisplayName,
131 /// Inode reached while this binding generation remains active.
132 child_inode_id: InodeId,
133 /// Commit sequence that created this binding generation.
134 bind_seq: ChangeSeq,
135 /// Position that disambiguates the binding within `bind_seq`.
136 bind_delta_index: u32,
137 },
138 /// Retires one exact directory-binding generation.
139 DirentryUnbind {
140 /// Directory that held the targeted binding.
141 parent_inode_id: InodeId,
142 /// Canonical name key of the targeted binding.
143 name_key: NameKey,
144 /// User-facing spelling the retired binding carried.
145 display_name: DisplayName,
146 /// Child identity recorded by the targeted binding.
147 child_inode_id: InodeId,
148 /// Commit sequence that created the binding being retired.
149 bind_seq: ChangeSeq,
150 /// Delta position of the binding being retired.
151 bind_delta_index: u32,
152 /// Commit sequence from which this unbind takes effect.
153 unbind_seq: ChangeSeq,
154 /// Position that disambiguates the unbind within `unbind_seq`.
155 unbind_delta_index: u32,
156 },
157 /// Publishes one immutable content revision for a file inode.
158 Revision {
159 /// File inode whose history contains the revision.
160 inode_id: InodeId,
161 /// Monotonic revision number within that file's history.
162 revision_no: RevisionNo,
163 /// Namespace sequence that published the revision.
164 committed_seq: ChangeSeq,
165 /// The owning commit's observational wall-clock stamp, denormalized
166 /// onto the row so revision reads answer times without a receipt
167 /// join. Never a validity input; `committed_seq` is the order.
168 committed_at_ms: u64,
169 /// Delta position that disambiguates the revision within `committed_seq`.
170 revision_delta_index: u32,
171 /// Immutable bytes published by the revision.
172 content_ref: ContentRef,
173 },
174 /// Changes whether one root inode has an active subtree tombstone.
175 Tombstone {
176 /// Inode whose rooted subtree the event governs.
177 root_inode_id: InodeId,
178 /// Commit sequence that published this tombstone event.
179 tombstone_seq: ChangeSeq,
180 /// Position that disambiguates the event within `tombstone_seq`.
181 tombstone_delta_index: u32,
182 /// What this event did; readers take the newest row per root and
183 /// treat a `revoke` newest row as "no active tombstone".
184 action: TombstoneRowAction,
185 /// Wall-clock stamp of the recording commit. Observational, like
186 /// every `committed_at_ms`.
187 deleted_at_ms: u64,
188 /// Directory that held the deleted binding, for `set` rows from
189 /// path deletes; tombstone rows are immortal, so this is where a
190 /// deleted name survives after unbind rows age out.
191 #[serde(default, skip_serializing_if = "Option::is_none")]
192 parent_inode_id: Option<InodeId>,
193 /// Canonical key of the deleted binding, when known.
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 name_key: Option<NameKey>,
196 /// User-facing spelling of the deleted binding, when known.
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 display_name: Option<DisplayName>,
199 },
200 /// Names one deletion generation in the derived active-deletions family.
201 ///
202 /// The row is not a new event: the materializer derives it from the
203 /// tombstone family, adding a `listed` row for each `set` and a `removed`
204 /// row for each `revoke`, so the trash listing is a range scan instead of
205 /// a walk over every deletion the namespace ever recorded.
206 ActiveDeletion {
207 /// Subtree root the deletion covers. With `deleted_at_seq` this is
208 /// exactly the handle `undelete` addresses.
209 root_inode_id: InodeId,
210 /// Commit sequence of the deletion this row speaks for. A `removed`
211 /// row repeats its target's sequence, not the undelete's, so the two
212 /// rows sort together.
213 deleted_at_seq: ChangeSeq,
214 /// Whether the deletion is still recoverable, and the listing detail
215 /// it carries while it is.
216 action: ActiveDeletionRowAction,
217 },
218 /// Preserves the evidence needed to answer a retried logical commit.
219 CommitReceipt {
220 /// Caller idempotency key whose later reuse is checked against this row.
221 commit_id: CommitId,
222 /// Digest used to distinguish a safe retry from conflicting id reuse.
223 semantic_commit_fingerprint: String,
224 /// Namespace sequence assigned to the accepted commit.
225 committed_seq: ChangeSeq,
226 /// The commit's observational wall-clock stamp. Receipts are the
227 /// durable per-commit record once WAL history drops below the
228 /// retention floor, so the stamp lives here for every commit,
229 /// revision-bearing or not.
230 committed_at_ms: u64,
231 /// Caller annotation preserved for idempotent response reconstruction.
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 message: Option<String>,
234 },
235}
236
237/// Tombstone-row event vocabulary (format spec, "Tombstones and deletion").
238#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
239#[serde(tag = "kind", rename_all = "snake_case")]
240pub enum TombstoneRowAction {
241 /// The subtree rooted at the row's inode is deleted.
242 Set,
243 /// The deletion recorded at `(target_seq, target_delta_index)` is
244 /// revoked.
245 Revoke {
246 /// Commit sequence of the exact `Set` event being compensated.
247 target_seq: ChangeSeq,
248 /// Delta position of the exact `Set` event within `target_seq`.
249 target_delta_index: u32,
250 },
251}
252
253/// Active-deletion row vocabulary (format spec, "Tombstones and deletion").
254///
255/// The family holds current state, not history: a `listed` row means the
256/// deletion is recoverable and the trash lists it, and a `removed` row is the
257/// undelete's compensating marker that hides it. The two rows for one
258/// deletion share a key prefix and `removed` sorts first, so an ascending
259/// scan always sees the removal before the row it removes; reorganization
260/// then drops the pair, because a cancelled deletion is not state anyone can
261/// still observe.
262#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
263#[serde(tag = "kind", rename_all = "snake_case")]
264pub enum ActiveDeletionRowAction {
265 /// The deletion is recoverable; these are the fields the trash entry
266 /// renders, denormalized so a page needs no per-entry join.
267 Listed {
268 /// Wall-clock stamp of the deleting commit. Observational, like every
269 /// `committed_at_ms`.
270 deleted_at_ms: u64,
271 /// Directory that held the deleted binding, when the delete recorded
272 /// one.
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 parent_inode_id: Option<InodeId>,
275 /// Canonical key of the deleted binding, when known.
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 name_key: Option<NameKey>,
278 /// User-facing spelling of the deleted binding as of the deletion,
279 /// when known.
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 display_name: Option<DisplayName>,
282 },
283 /// An undelete at `revoked_at_seq` cancelled the deletion this row's key
284 /// names, so the listing skips the key.
285 Removed {
286 /// Commit sequence of the undelete that cancelled the deletion.
287 revoked_at_seq: ChangeSeq,
288 },
289}
290
291impl ActiveDeletionRowAction {
292 /// The row-key component that orders a removal ahead of the row it
293 /// removes.
294 fn sort_rank(&self) -> u8 {
295 match self {
296 Self::Removed { .. } => lookup_keys::ACTIVE_DELETION_RANK_REMOVED,
297 Self::Listed { .. } => lookup_keys::ACTIVE_DELETION_RANK_LISTED,
298 }
299 }
300}
301
302impl MetadataRow {
303 /// Builds this row's canonical durable key in its primary table family.
304 ///
305 /// See [metadata segments](../../../docs/specs/format.md#421-metadata-segments).
306 pub fn row_key(&self) -> String {
307 self.row_key_for_family(match self {
308 Self::Inode { .. } => MetadataTableFamily::Inodes,
309 Self::DirentryBind { .. } => MetadataTableFamily::DirentryBinds,
310 Self::DirentryUnbind { .. } => MetadataTableFamily::DirentryUnbinds,
311 Self::Revision { .. } => MetadataTableFamily::Revisions,
312 Self::Tombstone { .. } => MetadataTableFamily::Tombstones,
313 Self::ActiveDeletion { .. } => MetadataTableFamily::ActiveDeletions,
314 Self::CommitReceipt { .. } => MetadataTableFamily::CommitReceipts,
315 })
316 }
317
318 /// Builds this row's durable key using the selected primary or secondary ordering.
319 ///
320 /// See [metadata segments](../../../docs/specs/format.md#421-metadata-segments).
321 pub fn row_key_for_family(&self, family: MetadataTableFamily) -> String {
322 match self {
323 Self::Inode { inode_id, .. } => format!("inode-{:020}", inode_id.0),
324 Self::DirentryBind {
325 parent_inode_id,
326 name_key,
327 child_inode_id,
328 bind_seq,
329 bind_delta_index,
330 ..
331 } => match family {
332 MetadataTableFamily::DirentryChildBinds => {
333 let name_key = hex_encode_row_key_component(name_key.as_str());
334 format!(
335 "direntry-child-{:020}-{:020}-{:010}-{:020}-{name_key}",
336 child_inode_id.0, bind_seq.0, bind_delta_index, parent_inode_id.0
337 )
338 }
339 _ => {
340 let name_key = hex_encode_row_key_component(name_key.as_str());
341 format!(
342 "direntry-{:020}-{name_key}-{:020}-{:010}",
343 parent_inode_id.0, bind_seq.0, bind_delta_index
344 )
345 }
346 },
347 Self::DirentryUnbind {
348 parent_inode_id,
349 name_key,
350 bind_seq,
351 bind_delta_index,
352 unbind_seq,
353 unbind_delta_index,
354 ..
355 } => {
356 let name_key = hex_encode_row_key_component(name_key.as_str());
357 format!(
358 "direntry-unbind-{:020}-{name_key}-{:020}-{:010}-{:020}-{:010}",
359 parent_inode_id.0,
360 bind_seq.0,
361 bind_delta_index,
362 unbind_seq.0,
363 unbind_delta_index
364 )
365 }
366 Self::Revision {
367 inode_id,
368 revision_no,
369 committed_seq,
370 revision_delta_index,
371 ..
372 } => match family {
373 MetadataTableFamily::RevisionsByInodeDesc => {
374 let reverse_revision_no = u64::MAX - revision_no.0;
375 let reverse_committed_seq = u64::MAX - committed_seq.0;
376 let reverse_delta_index = u32::MAX - revision_delta_index;
377 format!(
378 "revision-by-inode-desc-{:020}-{:020}-{:020}-{:010}",
379 inode_id.0, reverse_revision_no, reverse_committed_seq, reverse_delta_index
380 )
381 }
382 _ => {
383 format!(
384 "revision-{:020}-{:020}-{:010}",
385 inode_id.0, revision_no.0, revision_delta_index
386 )
387 }
388 },
389 Self::Tombstone {
390 root_inode_id,
391 tombstone_seq,
392 tombstone_delta_index,
393 // The action and binding context live in the value: revoke
394 // rows sort exactly like the deletions they cancel, so
395 // newest-per-root scans see them in one prefix pass.
396 ..
397 } => {
398 format!(
399 "tombstone-{:020}-{:020}-{:010}",
400 root_inode_id.0, tombstone_seq.0, tombstone_delta_index
401 )
402 }
403 Self::ActiveDeletion {
404 root_inode_id,
405 deleted_at_seq,
406 action,
407 } => lookup_keys::active_deletion_row_key(
408 *deleted_at_seq,
409 *root_inode_id,
410 action.sort_rank(),
411 ),
412 Self::CommitReceipt {
413 committed_seq,
414 commit_id,
415 ..
416 } => {
417 let commit_id = hex_encode_row_key_component(commit_id.as_str());
418 format!("commit-receipt-{commit_id}-{:020}", committed_seq.0)
419 }
420 }
421 }
422
423 /// The exact lookup prefix a point read probes for this row in `family`,
424 /// and therefore the key inserted into the segment's bloom filter. The
425 /// two sides must agree byte-for-byte — a filter is an exact-match
426 /// structure — so both are defined here, next to the row keys they
427 /// shorten. Range scans at coarser granularity (a whole directory, a
428 /// wave of names) do not consult filters.
429 pub fn filter_key_for_family(&self, family: MetadataTableFamily) -> String {
430 match self {
431 Self::Inode { .. } => self.row_key_for_family(family),
432 Self::DirentryBind {
433 parent_inode_id,
434 name_key,
435 child_inode_id,
436 ..
437 } => match family {
438 MetadataTableFamily::DirentryChildBinds => {
439 format!("direntry-child-{:020}", child_inode_id.0)
440 }
441 _ => {
442 let name_key = hex_encode_row_key_component(name_key.as_str());
443 format!("direntry-{:020}-{name_key}", parent_inode_id.0)
444 }
445 },
446 Self::DirentryUnbind {
447 parent_inode_id,
448 name_key,
449 ..
450 } => {
451 let name_key = hex_encode_row_key_component(name_key.as_str());
452 format!("direntry-unbind-{:020}-{name_key}", parent_inode_id.0)
453 }
454 Self::Revision { inode_id, .. } => match family {
455 MetadataTableFamily::RevisionsByInodeDesc => {
456 format!("revision-by-inode-desc-{:020}", inode_id.0)
457 }
458 _ => format!("revision-{:020}", inode_id.0),
459 },
460 Self::Tombstone { root_inode_id, .. } => {
461 format!("tombstone-{:020}", root_inode_id.0)
462 }
463 // The family is only ever range-scanned in key order, never
464 // probed for one deletion, so the filter key is the row key.
465 Self::ActiveDeletion { .. } => self.row_key_for_family(family),
466 Self::CommitReceipt { commit_id, .. } => {
467 let commit_id = hex_encode_row_key_component(commit_id.as_str());
468 format!("commit-receipt-{commit_id}")
469 }
470 }
471 }
472}
473
474/// Encodes an arbitrary string so it can occupy one component of a durable row key.
475///
476/// See [metadata segments](../../../docs/specs/format.md#421-metadata-segments).
477pub fn hex_encode_row_key_component(value: &str) -> String {
478 crate::hex::hex_encode_bytes(value.as_bytes())
479}
480
481/// Reader-side lookup grammar: the probes, prefixes, and resume keys that
482/// point lookups and scans build per family. Defined beside
483/// `row_key_for_family` and `filter_key_for_family` because the pairing is
484/// byte-for-byte — a probe must equal the filter key the writer stored, and
485/// a prefix must be a prefix of the row keys it selects. Change a key
486/// format and its lookup grammar together, here.
487pub mod lookup_keys {
488 use super::hex_encode_row_key_component;
489 use crate::{ChangeSeq, InodeId, RevisionNo};
490
491 /// The prefix every inode row key starts with. Inode ids are
492 /// zero-padded to a fixed width after it, so a range scan over this
493 /// prefix walks the inode family in ascending inode-id order.
494 ///
495 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
496 pub const INODE_ROW_PREFIX: &str = "inode-";
497
498 /// The prefix every canonical revision row key starts with. A range scan
499 /// over it walks every revision the manifest records, superseded ones
500 /// included, which is what a reachability question about content needs.
501 ///
502 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
503 pub const REVISION_ROW_PREFIX: &str = "revision-";
504
505 /// Builds the exact point-lookup key for an inode row.
506 ///
507 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
508 pub fn inode_key(inode_id: InodeId) -> String {
509 format!("{INODE_ROW_PREFIX}{:020}", inode_id.0)
510 }
511
512 /// Builds the resume bound for a scan that must continue strictly after
513 /// `inode_id`'s row.
514 ///
515 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
516 pub fn inode_key_after(inode_id: InodeId) -> String {
517 format!("{}\0", inode_key(inode_id))
518 }
519
520 /// Builds the range prefix selecting directory bindings under one parent.
521 ///
522 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
523 pub fn direntry_parent_prefix(parent_inode_id: InodeId) -> String {
524 format!("direntry-{:020}-", parent_inode_id.0)
525 }
526
527 /// Builds the bloom-filter probe shared by all generations of one parent/name binding.
528 ///
529 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
530 pub fn direntry_bind_probe(parent_inode_id: InodeId, name_key: &str) -> String {
531 format!(
532 "direntry-{:020}-{}",
533 parent_inode_id.0,
534 hex_encode_row_key_component(name_key)
535 )
536 }
537
538 /// Builds the range prefix selecting every generation of one parent/name binding.
539 ///
540 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
541 pub fn direntry_bind_prefix(parent_inode_id: InodeId, name_key: &str) -> String {
542 format!("{}-", direntry_bind_probe(parent_inode_id, name_key))
543 }
544
545 /// Builds the bloom-filter probe shared by bindings that target one child inode.
546 ///
547 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
548 pub fn direntry_child_probe(child_inode_id: InodeId) -> String {
549 format!("direntry-child-{:020}", child_inode_id.0)
550 }
551
552 /// Builds the reverse-index range prefix selecting bindings to one child inode.
553 ///
554 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
555 pub fn direntry_child_prefix(child_inode_id: InodeId) -> String {
556 format!("{}-", direntry_child_probe(child_inode_id))
557 }
558
559 /// Builds the bloom-filter probe shared by unbinds for one parent/name pair.
560 ///
561 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
562 pub fn direntry_unbind_probe(parent_inode_id: InodeId, name_key: &str) -> String {
563 format!(
564 "direntry-unbind-{:020}-{}",
565 parent_inode_id.0,
566 hex_encode_row_key_component(name_key)
567 )
568 }
569
570 /// Rows for one specific binding generation under the unbind probe.
571 ///
572 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
573 pub fn direntry_unbind_binding_prefix(
574 parent_inode_id: InodeId,
575 name_key: &str,
576 bind_seq: ChangeSeq,
577 bind_delta_index: u32,
578 ) -> String {
579 format!(
580 "{}-{:020}-{:010}-",
581 direntry_unbind_probe(parent_inode_id, name_key),
582 bind_seq.0,
583 bind_delta_index
584 )
585 }
586
587 /// Builds the range prefix selecting every unbind below one parent directory.
588 ///
589 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
590 pub fn direntry_unbind_parent_prefix(parent_inode_id: InodeId) -> String {
591 format!("direntry-unbind-{:020}-", parent_inode_id.0)
592 }
593
594 /// Builds the range prefix selecting unbinds for one parent/name pair.
595 ///
596 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
597 pub fn direntry_unbind_name_prefix(parent_inode_id: InodeId, name_key: &str) -> String {
598 format!(
599 "{}{}-",
600 direntry_unbind_parent_prefix(parent_inode_id),
601 hex_encode_row_key_component(name_key)
602 )
603 }
604
605 /// Builds the bloom-filter probe shared by tombstone events for one root inode.
606 ///
607 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
608 pub fn tombstone_probe(root_inode_id: InodeId) -> String {
609 format!("tombstone-{:020}", root_inode_id.0)
610 }
611
612 /// Builds the range prefix selecting the tombstone history of one root inode.
613 ///
614 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
615 pub fn tombstone_prefix(root_inode_id: InodeId) -> String {
616 format!("{}-", tombstone_probe(root_inode_id))
617 }
618
619 /// The prefix every active-deletion row key starts with. Deletion
620 /// sequence and root inode are zero-padded to fixed widths after it, so a
621 /// range scan over this prefix walks the namespace's recoverable
622 /// deletions oldest deletion first — the trash listing's whole read.
623 ///
624 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
625 pub const ACTIVE_DELETION_ROW_PREFIX: &str = "active-deletion-";
626
627 /// Rank of an undelete's removal marker within one deletion generation.
628 /// It is the lowest rank on purpose: an ascending scan sees the removal
629 /// before the row it removes, so a page never lists a deletion whose
630 /// marker was going to arrive one page later.
631 pub const ACTIVE_DELETION_RANK_REMOVED: u8 = 0;
632
633 /// Rank of the listed row within one deletion generation, and the highest
634 /// rank the family defines.
635 pub const ACTIVE_DELETION_RANK_LISTED: u8 = 1;
636
637 /// Builds an active-deletion row key from its generation and rank.
638 ///
639 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
640 pub fn active_deletion_row_key(
641 deleted_at_seq: ChangeSeq,
642 root_inode_id: InodeId,
643 sort_rank: u8,
644 ) -> String {
645 format!(
646 "{ACTIVE_DELETION_ROW_PREFIX}{:020}-{:020}-{sort_rank}",
647 deleted_at_seq.0, root_inode_id.0
648 )
649 }
650
651 /// Builds the resume bound for a trash page that must continue strictly
652 /// after the deletion generation it last returned. The listed row is the
653 /// generation's highest-ranked row, so resuming past it skips the whole
654 /// generation and nothing else.
655 ///
656 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
657 pub fn active_deletion_key_after(deleted_at_seq: ChangeSeq, root_inode_id: InodeId) -> String {
658 format!(
659 "{}\0",
660 active_deletion_row_key(deleted_at_seq, root_inode_id, ACTIVE_DELETION_RANK_LISTED)
661 )
662 }
663
664 /// Builds the bloom-filter probe shared by receipts for one commit id.
665 ///
666 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
667 pub fn commit_receipt_probe(commit_id: &str) -> String {
668 format!("commit-receipt-{}", hex_encode_row_key_component(commit_id))
669 }
670
671 /// Builds the range prefix selecting durable receipts for one commit id.
672 ///
673 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
674 pub fn commit_receipt_prefix(commit_id: &str) -> String {
675 format!("{}-", commit_receipt_probe(commit_id))
676 }
677
678 /// Builds the bloom-filter probe shared by newest-first revisions of one inode.
679 ///
680 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
681 pub fn revision_by_inode_desc_probe(inode_id: InodeId) -> String {
682 format!("revision-by-inode-desc-{:020}", inode_id.0)
683 }
684
685 /// Builds the range prefix selecting newest-first revisions of one inode.
686 ///
687 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
688 pub fn revision_by_inode_desc_prefix(inode_id: InodeId) -> String {
689 format!("{}-", revision_by_inode_desc_probe(inode_id))
690 }
691
692 /// Revision numbers are stored inverted so newest sorts first.
693 ///
694 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
695 pub fn revision_by_inode_desc_revision_prefix(
696 inode_id: InodeId,
697 revision_no: RevisionNo,
698 ) -> String {
699 format!(
700 "{}{:020}-",
701 revision_by_inode_desc_prefix(inode_id),
702 u64::MAX - revision_no.0
703 )
704 }
705
706 /// The full descending-index row key: revision number, commit seq, and
707 /// delta index all inverted so newest sorts first.
708 ///
709 /// See [metadata segments](../../../../docs/specs/format.md#421-metadata-segments).
710 pub fn revision_by_inode_desc_row_key(
711 inode_id: InodeId,
712 revision_no: RevisionNo,
713 committed_seq: ChangeSeq,
714 revision_delta_index: u32,
715 ) -> String {
716 format!(
717 "{}{:020}-{:020}-{:010}",
718 revision_by_inode_desc_prefix(inode_id),
719 u64::MAX - revision_no.0,
720 u64::MAX - committed_seq.0,
721 u32::MAX - revision_delta_index
722 )
723 }
724}
725
726/// Carries one complete namespace file-set description inside a manifest envelope.
727///
728/// See [manifest publication](../../../docs/specs/format.md#61-manifest-publication-and-checkpoint-verification).
729#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
730pub struct NamespaceManifestPayload {
731 /// Namespace whose materialized state this manifest describes.
732 pub namespace_id: NamespaceId,
733 /// Monotonic logical manifest position selected by the namespace root.
734 pub manifest_id: ManifestId,
735 /// Immutable object identity that distinguishes speculative candidates at `manifest_id`.
736 pub manifest_object_id: ManifestObjectId,
737 /// Greatest namespace sequence materialized by the referenced file set.
738 pub head_seq: ChangeSeq,
739 /// Commit id assigned to `head_seq`, used to validate agreement with the head.
740 pub head_commit_id: CommitId,
741 /// Oldest run sequence still represented by `metadata_files`.
742 pub base_seq: ChangeSeq,
743 /// Fencing epoch of the writer that produced this candidate.
744 pub writer_epoch: WriterEpoch,
745 /// First inode identity available after replaying the manifest snapshot.
746 pub next_inode_id: InodeId,
747 /// Earliest sequence for which retained history remains readable.
748 pub retention_floor_seq: ChangeSeq,
749 /// Complete ordered set of metadata segments required to reconstruct the snapshot.
750 pub metadata_files: Vec<MetadataFileRef>,
751}
752
753/// In-memory view of a namespace manifest envelope.
754///
755/// This struct is not the durable layout; durable bytes are produced only by
756/// [`encode_namespace_manifest_json`] and validated only by
757/// [`decode_namespace_manifest_json`].
758#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
759pub struct NamespaceManifestEnvelope {
760 /// Durable-family discriminator checked before payload decoding.
761 pub kind: NamespaceManifestKind,
762 /// Family-local format version, which must equal [`NAMESPACE_MANIFEST_FORMAT_VERSION`].
763 pub format_version: u32,
764 /// Digest of the payload JSON exactly as stored in the durable document,
765 /// in `sha256:<hex>` form.
766 pub payload_checksum: String,
767 /// Decoded file-set description protected by `payload_checksum`.
768 pub payload: NamespaceManifestPayload,
769}
770
771impl NamespaceManifestEnvelope {
772 /// Builds a versioned envelope and computes its checksum from canonical payload JSON.
773 ///
774 /// Construction fails when the payload cannot be encoded.
775 pub fn from_payload(payload: NamespaceManifestPayload) -> Result<Self, EnvelopeCodecError> {
776 Ok(Self {
777 kind: NamespaceManifestKind::NamespaceManifest,
778 format_version: NAMESPACE_MANIFEST_FORMAT_VERSION,
779 payload_checksum: namespace_manifest_payload_checksum(&payload)?,
780 payload,
781 })
782 }
783}
784
785fn namespace_manifest_payload_checksum(
786 payload: &NamespaceManifestPayload,
787) -> Result<String, EnvelopeCodecError> {
788 crate::envelope::json_payload_checksum(payload)
789}
790
791/// Encodes a namespace-manifest envelope as its durable JSON representation.
792///
793/// Encoding fails when the version is unsupported, the in-memory checksum is
794/// stale, or JSON serialization fails. See
795/// [manifest publication](../../../docs/specs/format.md#61-manifest-publication-and-checkpoint-verification).
796pub fn encode_namespace_manifest_json(
797 envelope: &NamespaceManifestEnvelope,
798) -> Result<Vec<u8>, EnvelopeCodecError> {
799 crate::envelope::encode_json_envelope(
800 envelope.kind.as_str(),
801 envelope.format_version,
802 NAMESPACE_MANIFEST_FORMAT_VERSION,
803 &envelope.payload_checksum,
804 &envelope.payload,
805 )
806}
807
808/// Decodes and verifies a durable namespace-manifest JSON envelope.
809///
810/// Decoding fails for invalid JSON, the wrong kind or version, a checksum
811/// mismatch, or an invalid payload. See
812/// [manifest publication](../../../docs/specs/format.md#61-manifest-publication-and-checkpoint-verification).
813pub fn decode_namespace_manifest_json(
814 bytes: &[u8],
815) -> Result<NamespaceManifestEnvelope, EnvelopeCodecError> {
816 let expected_kind = NamespaceManifestKind::NamespaceManifest;
817 let decoded =
818 crate::envelope::decode_json_envelope(bytes, NAMESPACE_MANIFEST_FORMAT_VERSION, |found| {
819 crate::envelope::verify_kind(expected_kind.as_str(), found)
820 })?;
821
822 Ok(NamespaceManifestEnvelope {
823 kind: expected_kind,
824 format_version: decoded.format_version,
825 payload_checksum: decoded.payload_checksum,
826 payload: decoded.payload,
827 })
828}
829
830#[cfg(test)]
831mod tests {
832 use super::{
833 decode_namespace_manifest_json, encode_namespace_manifest_json, BlockHandle,
834 MetadataFileRef, MetadataTableFamily, NamespaceManifestEnvelope, NamespaceManifestPayload,
835 };
836 use crate::{
837 ChangeSeq, CommitId, InodeId, ManifestId, ManifestObjectId, MetadataTableId, NameKey,
838 NamespaceId, WriterEpoch,
839 };
840
841 #[test]
842 fn inode_row_keys_sort_by_ascending_inode_id() {
843 // The inode family's durable order IS ascending inode id, which is
844 // what lets a whole-namespace file walk resume from one bound.
845 let ids = [9_u64, 1, 100, 10, 2];
846 let key_of = |id: u64| super::lookup_keys::inode_key(InodeId(id));
847 let mut keys: Vec<String> = ids.iter().copied().map(key_of).collect();
848 keys.sort();
849
850 let mut ascending_ids = ids;
851 ascending_ids.sort_unstable();
852 assert_eq!(
853 keys,
854 ascending_ids
855 .iter()
856 .copied()
857 .map(key_of)
858 .collect::<Vec<_>>(),
859 "row-key order must agree with inode-id order"
860 );
861 assert!(keys
862 .iter()
863 .all(|key| key.starts_with(super::lookup_keys::INODE_ROW_PREFIX)));
864 }
865
866 #[test]
867 fn the_inode_resume_bound_skips_its_own_row_and_nothing_after_it() {
868 let resume = super::lookup_keys::inode_key_after(InodeId(7));
869 assert!(resume > super::lookup_keys::inode_key(InodeId(7)));
870 assert!(resume < super::lookup_keys::inode_key(InodeId(8)));
871 }
872
873 #[test]
874 fn namespace_manifest_kind_string_matches_serde() {
875 let kind = super::NamespaceManifestKind::NamespaceManifest;
876 let serialized = serde_json::to_value(kind).expect("serialize kind");
877 assert_eq!(serialized, serde_json::Value::from(kind.as_str()));
878 }
879
880 #[test]
881 fn namespace_manifest_codec_round_trips_base_only_materialization() {
882 let envelope = NamespaceManifestEnvelope::from_payload(NamespaceManifestPayload {
883 namespace_id: NamespaceId::parse("demo").expect("valid namespace id"),
884 manifest_id: ManifestId(10),
885 manifest_object_id: ManifestObjectId::parse("00000000000000000010-0123456789abcdef")
886 .expect("valid manifest object id"),
887 head_seq: ChangeSeq(10),
888 head_commit_id: CommitId::parse("c_00000000000000000000000000000001")
889 .expect("commit id"),
890 base_seq: ChangeSeq(10),
891 writer_epoch: WriterEpoch(2),
892 next_inode_id: InodeId(42),
893 retention_floor_seq: ChangeSeq(0),
894 metadata_files: vec![metadata_file_ref(
895 "demo",
896 "tbl_00000000000000000000000000000001",
897 ChangeSeq(10),
898 1,
899 "namespaces/demo/metadata/tables/tbl_00000000000000000000000000000001.sst.zst",
900 )],
901 })
902 .expect("manifest");
903
904 let encoded = encode_namespace_manifest_json(&envelope).expect("encode manifest");
905 let decoded = decode_namespace_manifest_json(&encoded).expect("decode manifest");
906
907 assert_eq!(decoded, envelope);
908 assert_eq!(decoded.payload.base_seq, ChangeSeq(10));
909 assert_eq!(decoded.payload.metadata_files.len(), 1);
910 assert_eq!(decoded.payload.metadata_files[0].run_seq, ChangeSeq(10));
911 }
912
913 /// A fork target's first own manifest keeps referencing the source's
914 /// metadata objects: ownership travels with each file reference, not
915 /// with the manifest.
916 #[test]
917 fn namespace_manifest_codec_round_trips_inherited_source_tables() {
918 let envelope = NamespaceManifestEnvelope::from_payload(
919 NamespaceManifestPayload {
920 namespace_id: NamespaceId::parse("demo").expect("valid namespace id"),
921 manifest_id: ManifestId(12),
922 manifest_object_id: ManifestObjectId::parse(
923 "00000000000000000012-0123456789abcdef",
924 )
925 .expect("valid manifest object id"),
926 head_seq: ChangeSeq(12),
927 head_commit_id: CommitId::parse("c_00000000000000000000000000000002")
928 .expect("commit id"),
929 base_seq: ChangeSeq(10),
930 writer_epoch: WriterEpoch(2),
931 next_inode_id: InodeId(42),
932 retention_floor_seq: ChangeSeq(0),
933 metadata_files: vec![
934 metadata_file_ref(
935 "source",
936 "tbl_00000000000000000000000000000001",
937 ChangeSeq(10),
938 1,
939 "namespaces/source/tables/metadata/tbl_00000000000000000000000000000001.sst.zst",
940 ),
941 metadata_file_ref(
942 "demo",
943 "tbl_00000000000000000000000000000002",
944 ChangeSeq(12),
945 0,
946 "namespaces/demo/metadata/tables/tbl_00000000000000000000000000000002.sst.zst",
947 ),
948 ],
949 },
950 )
951 .expect("manifest");
952
953 let encoded = encode_namespace_manifest_json(&envelope).expect("encode manifest");
954 let decoded = decode_namespace_manifest_json(&encoded).expect("decode manifest");
955
956 assert_eq!(decoded, envelope);
957 assert_eq!(decoded.payload.metadata_files[0].level, 1);
958 assert_eq!(decoded.payload.metadata_files[1].level, 0);
959 assert_eq!(decoded.payload.metadata_files[1].run_seq, ChangeSeq(12));
960 assert_eq!(
961 decoded.payload.metadata_files[0].owner_namespace_id,
962 NamespaceId::parse("source").expect("valid namespace id")
963 );
964 }
965
966 #[test]
967 fn direntry_bind_row_key_supports_parent_and_child_indexes() {
968 let row = super::MetadataRow::DirentryBind {
969 parent_inode_id: InodeId(9),
970 name_key: NameKey::parse("report.txt").expect("valid name key"),
971 display_name: crate::DisplayName::parse("Report.txt").expect("valid display name"),
972 child_inode_id: InodeId(42),
973 bind_seq: ChangeSeq(17),
974 bind_delta_index: 3,
975 };
976
977 assert_eq!(
978 row.row_key_for_family(MetadataTableFamily::DirentryBinds),
979 "direntry-00000000000000000009-7265706f72742e747874-00000000000000000017-0000000003"
980 );
981 assert_eq!(
982 row.row_key_for_family(MetadataTableFamily::DirentryChildBinds),
983 "direntry-child-00000000000000000042-00000000000000000017-0000000003-00000000000000000009-7265706f72742e747874"
984 );
985 }
986
987 #[test]
988 fn row_keys_hex_encode_dash_containing_variable_components() {
989 let row = super::MetadataRow::DirentryBind {
990 parent_inode_id: InodeId(9),
991 name_key: NameKey::parse("report-2024").expect("valid name key"),
992 display_name: crate::DisplayName::parse("report-2024").expect("valid display name"),
993 child_inode_id: InodeId(42),
994 bind_seq: ChangeSeq(17),
995 bind_delta_index: 3,
996 };
997
998 assert_eq!(
999 row.row_key_for_family(MetadataTableFamily::DirentryBinds),
1000 "direntry-00000000000000000009-7265706f72742d32303234-00000000000000000017-0000000003"
1001 );
1002 }
1003
1004 #[test]
1005 fn revision_row_key_supports_newest_first_inode_index() {
1006 let row = super::MetadataRow::Revision {
1007 inode_id: InodeId(42),
1008 revision_no: crate::RevisionNo(7),
1009 committed_seq: ChangeSeq(12),
1010 committed_at_ms: 12_000,
1011 revision_delta_index: 3,
1012 content_ref: crate::ContentRef::blob_v1(
1013 crate::ContentId::parse("con_0123456789abcdef0123456789abcdef")
1014 .expect("valid content id"),
1015 b"row key sample",
1016 ),
1017 };
1018
1019 assert_eq!(
1020 row.row_key_for_family(MetadataTableFamily::Revisions),
1021 "revision-00000000000000000042-00000000000000000007-0000000003"
1022 );
1023 assert_eq!(
1024 row.row_key_for_family(MetadataTableFamily::RevisionsByInodeDesc),
1025 "revision-by-inode-desc-00000000000000000042-18446744073709551608-18446744073709551603-4294967292"
1026 );
1027 }
1028
1029 fn metadata_file_ref(
1030 owner_namespace_id: &str,
1031 table_id: &str,
1032 run_seq: ChangeSeq,
1033 level: u32,
1034 object_key: &str,
1035 ) -> MetadataFileRef {
1036 MetadataFileRef {
1037 owner_namespace_id: NamespaceId::parse(owner_namespace_id).expect("valid namespace id"),
1038 table_id: MetadataTableId::parse(table_id).expect("valid table id"),
1039 object_key: object_key.to_owned(),
1040 run_seq,
1041 level,
1042 family: MetadataTableFamily::Inodes,
1043 segment_index: 0,
1044 row_count: 0,
1045 min_key: String::new(),
1046 max_key: String::new(),
1047 index_block: BlockHandle {
1048 offset: 0,
1049 stored_len: 0,
1050 decoded_len: 0,
1051 crc32c: 0,
1052 },
1053 filter_block: BlockHandle {
1054 offset: 0,
1055 stored_len: 0,
1056 decoded_len: 0,
1057 crc32c: 0,
1058 },
1059 filter_inline: None,
1060 payload_checksum: "sha256:unused".to_owned(),
1061 }
1062 }
1063}