Skip to main content

loonfs_objectstore/
layout.rs

1//! The durable key grammar: object families, their path shapes, and
2//! parsing keys back into classified families.
3
4use loonfs_api::{ContentId, ManifestObjectId};
5
6#[derive(Debug, Clone, Copy, Default)]
7pub(crate) struct ObjectLayout;
8
9/// One family in the [durable object key grammar].
10///
11/// [durable object key grammar]: ../../../docs/specs/format.md#12-durable-object-families
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum DurableObjectFamily {
14    /// Classifies the mutable visibility and fencing head.
15    ///
16    /// See [durable object families](../../../docs/specs/format.md#12-durable-object-families).
17    WalHead,
18    /// Classifies the mutable retained-history floor.
19    ///
20    /// See [durable object families](../../../docs/specs/format.md#12-durable-object-families).
21    WalFloor,
22    /// Classifies an immutable segment in a namespace's WAL chain.
23    ///
24    /// See [durable object families](../../../docs/specs/format.md#12-durable-object-families).
25    WalSegment,
26    /// Classifies the mutable materialized metadata pointer.
27    ///
28    /// See [durable object families](../../../docs/specs/format.md#12-durable-object-families).
29    MetadataRoot,
30    /// Classifies an immutable namespace-manifest candidate.
31    ///
32    /// See [durable object families](../../../docs/specs/format.md#12-durable-object-families).
33    MetadataManifest,
34    /// Classifies an immutable metadata SST segment.
35    ///
36    /// See [durable object families](../../../docs/specs/format.md#12-durable-object-families).
37    MetadataTable,
38    /// Classifies a mutable checkpoint lifecycle record.
39    ///
40    /// See [durable object families](../../../docs/specs/format.md#12-durable-object-families).
41    CheckpointRecord,
42    /// Classifies a mutable upload-session lifecycle record.
43    ///
44    /// See [durable object families](../../../docs/specs/format.md#12-durable-object-families).
45    UploadSession,
46    /// Classifies immutable whole-file content bytes.
47    ///
48    /// See [durable object families](../../../docs/specs/format.md#12-durable-object-families).
49    ContentBlob,
50}
51
52/// Reports the durable family and namespace ownership recoverable from a recognized key.
53///
54/// See [durable object families](../../../docs/specs/format.md#12-durable-object-families).
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct ParsedObjectKey<'a> {
57    family: DurableObjectFamily,
58    owner_namespace_id: Option<&'a str>,
59}
60
61impl<'a> ParsedObjectKey<'a> {
62    /// Returns the durable family selected by the key's path shape.
63    pub fn family(&self) -> DurableObjectFamily {
64        self.family
65    }
66
67    /// Returns the namespace path component, or `None` for content-store-owned families.
68    pub fn owner_namespace_id(&self) -> Option<&'a str> {
69        self.owner_namespace_id
70    }
71}
72
73impl ObjectLayout {
74    pub(crate) fn new() -> Self {
75        Self
76    }
77
78    pub(crate) fn namespace_root_prefix(&self, namespace: &str) -> String {
79        format!("namespaces/{namespace}/")
80    }
81
82    /// Hot head of the semantic commit stream: the only object whose CAS
83    /// gates user-write throughput.
84    pub(crate) fn wal_head(&self, namespace: &str) -> String {
85        format!("namespaces/{namespace}/wal/head.json")
86    }
87
88    /// Cold lower bound of retained WAL/change history.
89    pub(crate) fn wal_floor(&self, namespace: &str) -> String {
90        format!("namespaces/{namespace}/wal/floor.json")
91    }
92
93    pub(crate) fn wal_segment(&self, namespace: &str, segment_id: &str) -> String {
94        format!("namespaces/{namespace}/wal/segments/{segment_id}.wal.zst")
95    }
96
97    /// Listing prefix that contains every WAL segment of `namespace` and
98    /// nothing else: `wal/head.json` and `wal/floor.json` live outside it,
99    /// so a GC listing yields only segment keys.
100    ///
101    /// Segment file names start with the segment's 20-digit `start_seq` as
102    /// an operator/GC convenience; no protocol depends on listing order.
103    pub(crate) fn wal_segment_prefix(&self, namespace: &str) -> String {
104        format!("namespaces/{namespace}/wal/segments/")
105    }
106
107    /// Extracts the WAL segment id from a listed object key.
108    ///
109    /// Returns `None` for keys that are not current-format WAL segments, so
110    /// listings can skip foreign objects.
111    pub(crate) fn wal_segment_id_from_key<'a>(&self, key: &'a str) -> Option<&'a str> {
112        let (_, file_name) = key.rsplit_once('/')?;
113        file_name.strip_suffix(".wal.zst")
114    }
115
116    /// Cold pointer to the best known materialized metadata root.
117    pub(crate) fn metadata_root(&self, namespace: &str) -> String {
118        format!("namespaces/{namespace}/metadata/root.json")
119    }
120
121    pub(crate) fn metadata_manifest_object(
122        &self,
123        namespace: &str,
124        manifest_object_id: &ManifestObjectId,
125    ) -> String {
126        format!(
127            "namespaces/{namespace}/metadata/manifests/{}.manifest.json",
128            manifest_object_id.as_str()
129        )
130    }
131
132    pub(crate) fn metadata_manifest_prefix(&self, namespace: &str) -> String {
133        format!("namespaces/{namespace}/metadata/manifests/")
134    }
135
136    pub(crate) fn metadata_table(&self, namespace: &str, table_id: &str) -> String {
137        format!("namespaces/{namespace}/metadata/tables/{table_id}.sst.zst")
138    }
139
140    pub(crate) fn metadata_table_prefix(&self, namespace: &str) -> String {
141        format!("namespaces/{namespace}/metadata/tables/")
142    }
143
144    /// Durable stable-view pin to a metadata manifest.
145    pub(crate) fn checkpoint_record(&self, namespace: &str, checkpoint_id: &str) -> String {
146        format!("namespaces/{namespace}/checkpoints/{checkpoint_id}.json")
147    }
148
149    pub(crate) fn checkpoint_prefix(&self, namespace: &str) -> String {
150        format!("namespaces/{namespace}/checkpoints/")
151    }
152
153    pub(crate) fn upload_session(&self, namespace: &str, upload_id: &str) -> String {
154        format!("namespaces/{namespace}/uploads/{upload_id}.json")
155    }
156
157    pub(crate) fn upload_session_prefix(&self, namespace: &str) -> String {
158        format!("namespaces/{namespace}/uploads/")
159    }
160
161    /// Content objects shard on the content id's leading characters, which
162    /// are random, so ingest spreads evenly across provider partitions.
163    pub(crate) fn content_blob(&self, content_store: &str, content_id: &ContentId) -> String {
164        format!(
165            "content-stores/{content_store}/objects/{}/{}",
166            content_id.shard_prefix(),
167            content_id.as_str()
168        )
169    }
170}
171
172/// Classifies a current or reserved durable object key without validating identifier text.
173///
174/// Returns `None` for private, foreign, or unrecognized paths. See
175/// [durable object families](../../../docs/specs/format.md#12-durable-object-families).
176pub fn parse_object_key(key: &str) -> Option<ParsedObjectKey<'_>> {
177    let segments: Vec<_> = key.split('/').collect();
178    match segments.as_slice() {
179        ["content-stores", _, "objects", _, _] => parsed(DurableObjectFamily::ContentBlob, None),
180        ["namespaces", namespace, "wal", "head.json"] => {
181            parsed(DurableObjectFamily::WalHead, Some(namespace))
182        }
183        ["namespaces", namespace, "wal", "floor.json"] => {
184            parsed(DurableObjectFamily::WalFloor, Some(namespace))
185        }
186        ["namespaces", namespace, "wal", "segments", segment] if segment.ends_with(".wal.zst") => {
187            parsed(DurableObjectFamily::WalSegment, Some(namespace))
188        }
189        ["namespaces", namespace, "metadata", "root.json"] => {
190            parsed(DurableObjectFamily::MetadataRoot, Some(namespace))
191        }
192        ["namespaces", namespace, "metadata", "manifests", manifest]
193            if manifest.ends_with(".json") =>
194        {
195            parsed(DurableObjectFamily::MetadataManifest, Some(namespace))
196        }
197        ["namespaces", namespace, "metadata", "tables", table] if table.ends_with(".sst.zst") => {
198            parsed(DurableObjectFamily::MetadataTable, Some(namespace))
199        }
200        ["namespaces", namespace, "checkpoints", checkpoint] if checkpoint.ends_with(".json") => {
201            parsed(DurableObjectFamily::CheckpointRecord, Some(namespace))
202        }
203        ["namespaces", namespace, "uploads", upload] if upload.ends_with(".json") => {
204            parsed(DurableObjectFamily::UploadSession, Some(namespace))
205        }
206        _ => None,
207    }
208}
209
210fn parsed(
211    family: DurableObjectFamily,
212    owner_namespace_id: Option<&str>,
213) -> Option<ParsedObjectKey<'_>> {
214    Some(ParsedObjectKey {
215        family,
216        owner_namespace_id,
217    })
218}
219
220#[cfg(test)]
221mod tests {
222    use super::{parse_object_key, DurableObjectFamily, ObjectLayout};
223    use loonfs_api::{ContentId, ManifestObjectId};
224
225    fn content_id() -> ContentId {
226        ContentId::parse("con_abcdef0123456789abcdef0123456789").expect("valid content id")
227    }
228
229    #[test]
230    fn layout_golden_tree_matches_target_paths() {
231        let layout = ObjectLayout::new();
232
233        assert_eq!(layout.namespace_root_prefix("ns-1"), "namespaces/ns-1/");
234        assert_eq!(
235            layout.wal_head("ns-1").as_str(),
236            "namespaces/ns-1/wal/head.json"
237        );
238        assert_eq!(
239            layout.wal_floor("ns-1").as_str(),
240            "namespaces/ns-1/wal/floor.json"
241        );
242        assert_eq!(
243            layout
244                .wal_segment("ns-1", "seg_00000000000000000000000000000001")
245                .as_str(),
246            "namespaces/ns-1/wal/segments/seg_00000000000000000000000000000001.wal.zst"
247        );
248        assert_eq!(
249            layout.metadata_root("ns-1").as_str(),
250            "namespaces/ns-1/metadata/root.json"
251        );
252        let manifest_object_id = ManifestObjectId::parse("00000000000000000400-0123456789abcdef")
253            .expect("valid manifest object id");
254        assert_eq!(
255            layout
256                .metadata_manifest_object("ns-1", &manifest_object_id)
257                .as_str(),
258            "namespaces/ns-1/metadata/manifests/00000000000000000400-0123456789abcdef.manifest.json"
259        );
260        assert_eq!(
261            layout
262                .metadata_table("ns-1", "tbl_00000000000000000000000000000001")
263                .as_str(),
264            "namespaces/ns-1/metadata/tables/tbl_00000000000000000000000000000001.sst.zst"
265        );
266        assert_eq!(
267            layout
268                .checkpoint_record("ns-1", "chk_00000000000000000000000000000001")
269                .as_str(),
270            "namespaces/ns-1/checkpoints/chk_00000000000000000000000000000001.json"
271        );
272        assert_eq!(
273            layout
274                .upload_session("ns-1", "upl_00000000000000000000000000000001")
275                .as_str(),
276            "namespaces/ns-1/uploads/upl_00000000000000000000000000000001.json"
277        );
278        assert_eq!(
279            layout
280                .content_blob("cs_00000000000000000000000000000001", &content_id())
281                .as_str(),
282            "content-stores/cs_00000000000000000000000000000001/objects/ab/con_abcdef0123456789abcdef0123456789"
283        );
284    }
285
286    #[test]
287    fn control_objects_live_outside_the_segment_listing_prefix() {
288        let layout = ObjectLayout::new();
289        let prefix = layout.wal_segment_prefix("ns-1");
290        assert_eq!(prefix, "namespaces/ns-1/wal/segments/");
291        assert!(!layout.wal_head("ns-1").as_str().starts_with(&prefix));
292        assert!(!layout.wal_floor("ns-1").as_str().starts_with(&prefix));
293        assert!(layout
294            .wal_segment("ns-1", "seg_1")
295            .as_str()
296            .starts_with(&prefix));
297    }
298
299    #[test]
300    fn parse_build_round_trips_for_namespace_key_families() {
301        let layout = ObjectLayout::new();
302        let cases = [
303            (layout.wal_head("ns-1"), DurableObjectFamily::WalHead),
304            (layout.wal_floor("ns-1"), DurableObjectFamily::WalFloor),
305            (
306                layout.wal_segment("ns-1", "seg_00000000000000000000000000000001"),
307                DurableObjectFamily::WalSegment,
308            ),
309            (
310                layout.metadata_root("ns-1"),
311                DurableObjectFamily::MetadataRoot,
312            ),
313            (
314                layout.metadata_manifest_object(
315                    "ns-1",
316                    &ManifestObjectId::parse("00000000000000000001-0123456789abcdef")
317                        .expect("valid manifest object id"),
318                ),
319                DurableObjectFamily::MetadataManifest,
320            ),
321            (
322                layout.metadata_table("ns-1", "tbl_abc"),
323                DurableObjectFamily::MetadataTable,
324            ),
325            (
326                layout.checkpoint_record("ns-1", "chk_00000000000000000000000000000001"),
327                DurableObjectFamily::CheckpointRecord,
328            ),
329            (
330                layout.upload_session("ns-1", "upl_00000000000000000000000000000001"),
331                DurableObjectFamily::UploadSession,
332            ),
333        ];
334
335        for (key, family) in cases {
336            let parsed = parse_object_key(&key).expect("known namespace key parses");
337            assert_eq!(parsed.family(), family);
338            assert_eq!(parsed.owner_namespace_id(), Some("ns-1"));
339        }
340    }
341
342    #[test]
343    fn parser_rejects_retired_layout_paths() {
344        for old in [
345            "namespaces/ns-1/descriptor.json",
346            "namespaces/ns-1/control/head.json",
347            "namespaces/ns-1/control/lease.json",
348            "namespaces/ns-1/wal/seg_00000000000000000000000000000001.wal.zst",
349            "namespaces/ns-1/manifest/00000000000000000400.manifest.json",
350            "namespaces/ns-1/tables/metadata/tbl_abc.sst.zst",
351            "namespaces/ns-1/gc/manifest.boundary.json",
352            "namespaces/ns-1/gc/pins/pin_00000000000000000000000000000001.json",
353            "namespaces/ns-1/pins/pin_00000000000000000000000000000001.json",
354        ] {
355            assert!(
356                parse_object_key(old).is_none(),
357                "retired path parsed: {old}"
358            );
359        }
360    }
361
362    #[test]
363    fn parse_wal_segment_requires_current_wal_suffix() {
364        let parsed = parse_object_key(
365            "namespaces/ns-1/wal/segments/seg_00000000000000000000000000000001.wal.zst",
366        )
367        .expect("current WAL key parses");
368        assert_eq!(parsed.family(), DurableObjectFamily::WalSegment);
369        assert_eq!(parsed.owner_namespace_id(), Some("ns-1"));
370
371        assert!(parse_object_key(
372            "namespaces/ns-1/wal/segments/seg_00000000000000000000000000000001.sst"
373        )
374        .is_none());
375        assert!(parse_object_key("namespaces/ns-1/wal/segments/random.tmp").is_none());
376    }
377
378    #[test]
379    fn parse_build_round_trips_for_global_key_families() {
380        let layout = ObjectLayout::new();
381        let content_key = layout.content_blob("cs_00000000000000000000000000000001", &content_id());
382        let cases = [(content_key, DurableObjectFamily::ContentBlob)];
383
384        for (key, family) in cases {
385            let parsed = parse_object_key(&key).expect("known global key parses");
386            assert_eq!(parsed.family(), family);
387            assert_eq!(parsed.owner_namespace_id(), None);
388        }
389
390        assert!(parse_object_key("namespaces/ns-1/unknown/file").is_none());
391    }
392
393    /// One content layout exists. Anything else under `content-stores/`,
394    /// including the deeper digest-partitioned shape this format replaced,
395    /// classifies as nothing at all rather than as content.
396    #[test]
397    fn parser_admits_exactly_one_content_layout() {
398        for foreign in [
399            "content-stores/cs-1/blobs/ab/cd/deadbeef",
400            "content-stores/cs-1/objects/ab/cd/deadbeef",
401            "content-stores/cs-1/objects/deadbeef",
402            "content-stores/cs-1/objects/",
403        ] {
404            assert!(
405                parse_object_key(foreign).is_none(),
406                "foreign content path parsed: {foreign}"
407            );
408        }
409    }
410}