1use loonfs_api::{ContentId, ManifestObjectId};
5
6#[derive(Debug, Clone, Copy, Default)]
7pub(crate) struct ObjectLayout;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum DurableObjectFamily {
14 WalHead,
18 WalFloor,
22 WalSegment,
26 MetadataRoot,
30 MetadataManifest,
34 MetadataTable,
38 CheckpointRecord,
42 UploadSession,
46 ContentBlob,
50}
51
52#[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 pub fn family(&self) -> DurableObjectFamily {
64 self.family
65 }
66
67 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 pub(crate) fn wal_head(&self, namespace: &str) -> String {
85 format!("namespaces/{namespace}/wal/head.json")
86 }
87
88 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 pub(crate) fn wal_segment_prefix(&self, namespace: &str) -> String {
104 format!("namespaces/{namespace}/wal/segments/")
105 }
106
107 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 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 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 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
172pub 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 #[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}