Skip to main content

iceberg/catalog/
metadata_location.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::collections::HashMap;
19use std::fmt::Display;
20use std::str::FromStr;
21
22use uuid::Uuid;
23
24use crate::compression::CompressionCodec;
25use crate::spec::{TableMetadata, parse_metadata_file_compression};
26use crate::{Error, ErrorKind, Result};
27
28/// Default folder name for metadata files under the table location, used when the
29/// `write.metadata.path` table property is not set.
30pub(crate) const METADATA_FOLDER_NAME: &str = "metadata";
31
32/// Helper for parsing a location of the format: `<metadata-dir>/<version>-<uuid>.metadata.json`
33/// or with compression: `<metadata-dir>/<version>-<uuid>.gz.metadata.json`
34///
35/// `<metadata-dir>` is set to the `write.metadata.path` table property and
36/// it defaults to the `<location>/metadata` when the property is not set.
37#[derive(Clone, Debug, PartialEq)]
38pub struct MetadataLocation {
39    location: String,
40    version: i32,
41    id: Uuid,
42    compression_codec: CompressionCodec,
43}
44
45impl MetadataLocation {
46    /// Determines the compression codec from table properties.
47    /// Parse errors result in CompressionCodec::None.
48    fn compression_from_properties(properties: &HashMap<String, String>) -> CompressionCodec {
49        parse_metadata_file_compression(properties).unwrap_or(CompressionCodec::None)
50    }
51
52    /// Creates a completely new metadata location starting at version 0, deriving the
53    /// metadata directory and compression settings from the table metadata.
54    /// Only used for creating a new table. For updates, see `with_next_version` and
55    /// `try_with_new_metadata`.
56    pub fn try_new_with_metadata(metadata: &TableMetadata) -> Result<Self> {
57        Ok(Self {
58            location: metadata.metadata_location()?,
59            version: 0,
60            id: Uuid::new_v4(),
61            compression_codec: Self::compression_from_properties(metadata.properties()),
62        })
63    }
64
65    /// Creates a new metadata location for an updated metadata file.
66    /// Increments the version number and generates a new UUID.
67    pub fn with_next_version(&self) -> Self {
68        Self {
69            location: self.location.clone(),
70            version: self.version + 1,
71            id: Uuid::new_v4(),
72            compression_codec: self.compression_codec,
73        }
74    }
75
76    /// Updates the metadata location with the metadata directory
77    /// and compression settings from the new metadata.
78    pub fn try_with_new_metadata(&self, new_metadata: &TableMetadata) -> Result<Self> {
79        Ok(Self {
80            location: new_metadata.metadata_location()?,
81            version: self.version,
82            id: self.id,
83            compression_codec: Self::compression_from_properties(new_metadata.properties()),
84        })
85    }
86
87    /// Returns the compression codec used for this metadata location.
88    pub fn compression_codec(&self) -> CompressionCodec {
89        self.compression_codec
90    }
91
92    /// Parses a file name of the format `<version>-<uuid>.metadata.json`
93    /// or with compression: `<version>-<uuid>.gz.metadata.json`.
94    /// Parse errors for compression codec result in CompressionCodec::None.
95    fn parse_file_name(file_name: &str) -> Result<(i32, Uuid, CompressionCodec)> {
96        let stripped = file_name.strip_suffix(".metadata.json").ok_or(Error::new(
97            ErrorKind::Unexpected,
98            format!("Invalid metadata file ending: {file_name}"),
99        ))?;
100
101        // Check for compression suffix (e.g., .gz)
102        let gzip_suffix = CompressionCodec::gzip_default().suffix()?;
103        let (stripped, compression_codec) = if let Some(s) = stripped.strip_suffix(gzip_suffix) {
104            (s, CompressionCodec::gzip_default())
105        } else {
106            (stripped, CompressionCodec::None)
107        };
108
109        let (version, id) = stripped.split_once('-').ok_or(Error::new(
110            ErrorKind::Unexpected,
111            format!("Invalid metadata file name format: {file_name}"),
112        ))?;
113
114        Ok((
115            version.parse::<i32>()?,
116            Uuid::parse_str(id)?,
117            compression_codec,
118        ))
119    }
120}
121
122impl Display for MetadataLocation {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        let suffix = self.compression_codec.suffix().unwrap_or("");
125        write!(
126            f,
127            "{}/{:0>5}-{}{}.metadata.json",
128            self.location, self.version, self.id, suffix
129        )
130    }
131}
132
133impl FromStr for MetadataLocation {
134    type Err = Error;
135
136    fn from_str(s: &str) -> Result<Self> {
137        let (location, file_name) = s.rsplit_once('/').ok_or(Error::new(
138            ErrorKind::Unexpected,
139            format!("Invalid metadata location: {s}"),
140        ))?;
141
142        let (version, id, compression_codec) = Self::parse_file_name(file_name)?;
143
144        Ok(MetadataLocation {
145            location: location.to_string(),
146            version,
147            id,
148            compression_codec,
149        })
150    }
151}
152
153#[cfg(test)]
154mod test {
155    use std::collections::HashMap;
156    use std::str::FromStr;
157
158    use uuid::Uuid;
159
160    use crate::compression::CompressionCodec;
161    use crate::spec::{Schema, TableMetadata, TableMetadataBuilder, TableProperties};
162    use crate::{MetadataLocation, TableCreation};
163
164    fn create_test_metadata(properties: HashMap<String, String>) -> TableMetadata {
165        let table_creation = TableCreation::builder()
166            .name("test_table".to_string())
167            .location("/test/table".to_string())
168            .schema(Schema::builder().build().unwrap())
169            .properties(properties)
170            .build();
171        TableMetadataBuilder::from_table_creation(table_creation)
172            .unwrap()
173            .build()
174            .unwrap()
175            .metadata
176    }
177
178    #[test]
179    fn test_metadata_location_from_string() {
180        let test_cases = vec![
181            // No prefix
182            (
183                "/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
184                Ok(MetadataLocation {
185                    location: "/metadata".to_string(),
186                    version: 1234567,
187                    id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
188                    compression_codec: CompressionCodec::None,
189                }),
190            ),
191            // Some prefix
192            (
193                "/abc/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
194                Ok(MetadataLocation {
195                    location: "/abc/metadata".to_string(),
196                    version: 1234567,
197                    id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
198                    compression_codec: CompressionCodec::None,
199                }),
200            ),
201            // Longer prefix
202            (
203                "/abc/def/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
204                Ok(MetadataLocation {
205                    location: "/abc/def/metadata".to_string(),
206                    version: 1234567,
207                    id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
208                    compression_codec: CompressionCodec::None,
209                }),
210            ),
211            // Prefix with special characters
212            (
213                "https://127.0.0.1/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
214                Ok(MetadataLocation {
215                    location: "https://127.0.0.1/metadata".to_string(),
216                    version: 1234567,
217                    id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
218                    compression_codec: CompressionCodec::None,
219                }),
220            ),
221            // Another id
222            (
223                "/abc/metadata/1234567-81056704-ce5b-41c4-bb83-eb6408081af6.metadata.json",
224                Ok(MetadataLocation {
225                    location: "/abc/metadata".to_string(),
226                    version: 1234567,
227                    id: Uuid::from_str("81056704-ce5b-41c4-bb83-eb6408081af6").unwrap(),
228                    compression_codec: CompressionCodec::None,
229                }),
230            ),
231            // Version 0
232            (
233                "/abc/metadata/00000-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
234                Ok(MetadataLocation {
235                    location: "/abc/metadata".to_string(),
236                    version: 0,
237                    id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
238                    compression_codec: CompressionCodec::None,
239                }),
240            ),
241            // With gzip compression
242            (
243                "/abc/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.gz.metadata.json",
244                Ok(MetadataLocation {
245                    location: "/abc/metadata".to_string(),
246                    version: 1234567,
247                    id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
248                    compression_codec: CompressionCodec::gzip_default(),
249                }),
250            ),
251            // Negative version
252            (
253                "/metadata/-123-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
254                Err("".to_string()),
255            ),
256            // Invalid uuid
257            (
258                "/metadata/1234567-no-valid-id.metadata.json",
259                Err("".to_string()),
260            ),
261            // Non-numeric version
262            (
263                "/metadata/noversion-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
264                Err("".to_string()),
265            ),
266            // Metadata dir does not need to be named "metadata" (e.g. a `write.metadata.path` location)
267            (
268                "/wrongsubdir/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
269                Ok(MetadataLocation {
270                    location: "/wrongsubdir".to_string(),
271                    version: 1234567,
272                    id: Uuid::from_str("2cd22b57-5127-4198-92ba-e4e67c79821b").unwrap(),
273                    compression_codec: CompressionCodec::None,
274                }),
275            ),
276            // No .metadata.json suffix
277            (
278                "/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata",
279                Err("".to_string()),
280            ),
281            (
282                "/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.wrong.file",
283                Err("".to_string()),
284            ),
285        ];
286
287        for (input, expected) in test_cases {
288            match MetadataLocation::from_str(input) {
289                Ok(metadata_location) => {
290                    assert!(expected.is_ok());
291                    assert_eq!(metadata_location, expected.unwrap());
292                }
293                Err(_) => assert!(expected.is_err()),
294            }
295        }
296    }
297
298    #[test]
299    fn test_metadata_location_with_next_version() {
300        let metadata = create_test_metadata(HashMap::new());
301        let test_cases = vec![
302            MetadataLocation::try_new_with_metadata(&metadata).unwrap(),
303            MetadataLocation::from_str(
304                "/abc/def/metadata/1234567-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
305            )
306            .unwrap(),
307        ];
308
309        for input in test_cases {
310            let next = MetadataLocation::from_str(&input.to_string())
311                .unwrap()
312                .with_next_version();
313            assert_eq!(next.location, input.location);
314            assert_eq!(next.version, input.version + 1);
315            assert_ne!(next.id, input.id);
316        }
317    }
318
319    #[test]
320    fn test_with_next_version_preserves_compression() {
321        // Start from a parsed location with no compression
322        let location_none = MetadataLocation::from_str(
323            "/test/table/metadata/00000-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
324        )
325        .unwrap();
326        assert_eq!(location_none.compression_codec, CompressionCodec::None);
327
328        let next_none = location_none.with_next_version();
329        assert_eq!(next_none.compression_codec, CompressionCodec::None);
330        assert_eq!(next_none.version, 1);
331
332        // Start from a parsed location with gzip compression
333        let location_gzip = MetadataLocation::from_str(
334            "/test/table/metadata/00005-81056704-ce5b-41c4-bb83-eb6408081af6.gz.metadata.json",
335        )
336        .unwrap();
337        assert_eq!(
338            location_gzip.compression_codec,
339            CompressionCodec::gzip_default()
340        );
341
342        let next_gzip = location_gzip.with_next_version();
343        assert_eq!(
344            next_gzip.compression_codec,
345            CompressionCodec::gzip_default()
346        );
347        assert_eq!(next_gzip.version, 6);
348    }
349
350    #[test]
351    fn test_with_new_metadata_updates_compression() {
352        // Start from a parsed location with no compression
353        let location = MetadataLocation::from_str(
354            "/test/table/metadata/00000-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
355        )
356        .unwrap();
357        assert_eq!(location.compression_codec, CompressionCodec::None);
358
359        // Update to gzip compression
360        let mut props_gzip = HashMap::new();
361        props_gzip.insert(
362            "write.metadata.compression-codec".to_string(),
363            "gzip".to_string(),
364        );
365        let metadata_gzip = create_test_metadata(props_gzip);
366        let updated_gzip = location.try_with_new_metadata(&metadata_gzip).unwrap();
367        assert_eq!(
368            updated_gzip.compression_codec,
369            CompressionCodec::gzip_default()
370        );
371        assert_eq!(updated_gzip.version, 0);
372        assert_eq!(
373            updated_gzip.to_string(),
374            "/test/table/metadata/00000-2cd22b57-5127-4198-92ba-e4e67c79821b.gz.metadata.json"
375        );
376
377        // Update back to no compression
378        let props_none = HashMap::new();
379        let metadata_none = create_test_metadata(props_none);
380        let updated_none = updated_gzip.try_with_new_metadata(&metadata_none).unwrap();
381        assert_eq!(updated_none.compression_codec, CompressionCodec::None);
382        assert_eq!(updated_none.version, 0);
383        assert_eq!(
384            updated_none.to_string(),
385            "/test/table/metadata/00000-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json"
386        );
387
388        // Test explicit "none" codec
389        let mut props_explicit_none = HashMap::new();
390        props_explicit_none.insert(
391            "write.metadata.compression-codec".to_string(),
392            "none".to_string(),
393        );
394        let metadata_explicit_none = create_test_metadata(props_explicit_none);
395        let updated_explicit = updated_gzip
396            .try_with_new_metadata(&metadata_explicit_none)
397            .unwrap();
398        assert_eq!(updated_explicit.compression_codec, CompressionCodec::None);
399        assert_eq!(
400            updated_explicit.to_string(),
401            "/test/table/metadata/00000-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json"
402        );
403    }
404
405    #[test]
406    fn test_with_new_metadata_re_derives_location() {
407        // Start from a parsed location under some existing metadata directory
408        let location = MetadataLocation::from_str(
409            "/old/table/metadata/00003-2cd22b57-5127-4198-92ba-e4e67c79821b.metadata.json",
410        )
411        .unwrap();
412
413        // A new metadata (no `write.metadata.path`) re-derives to `<location>/metadata`,
414        // preserving the version and id of the existing location
415        let relocated = create_test_metadata(HashMap::new());
416        let updated = location.try_with_new_metadata(&relocated).unwrap();
417        assert!(
418            updated.to_string().starts_with("/test/table/metadata/"),
419            "unexpected location: {updated}"
420        );
421        assert_eq!(updated.version, location.version);
422        assert_eq!(updated.id, location.id);
423
424        // A configured `write.metadata.path` is honored on updates too
425        let props = HashMap::from([(
426            TableProperties::PROPERTY_WRITE_METADATA_PATH.to_string(),
427            "s3://bucket/custom-meta".to_string(),
428        )]);
429        let with_meta_path = create_test_metadata(props);
430        let updated = location.try_with_new_metadata(&with_meta_path).unwrap();
431        assert!(
432            updated.to_string().starts_with("s3://bucket/custom-meta/"),
433            "unexpected location: {updated}"
434        );
435    }
436
437    #[test]
438    fn test_new_with_metadata_honors_write_metadata_path() {
439        // Test metadata lives under `<location>/metadata` by default
440        let default_meta = create_test_metadata(HashMap::new());
441        let default_loc = MetadataLocation::try_new_with_metadata(&default_meta).unwrap();
442        assert!(
443            default_loc
444                .to_string()
445                .starts_with("/test/table/metadata/00000-"),
446            "unexpected location: {default_loc}"
447        );
448
449        // Test a configured `write.metadata.path` is honored
450        let props = HashMap::from([(
451            TableProperties::PROPERTY_WRITE_METADATA_PATH.to_string(),
452            "s3://bucket/custom-meta".to_string(),
453        )]);
454        let custom_meta = create_test_metadata(props);
455        let custom_loc = MetadataLocation::try_new_with_metadata(&custom_meta).unwrap();
456        assert!(
457            custom_loc
458                .to_string()
459                .starts_with("s3://bucket/custom-meta/00000-"),
460            "unexpected location: {custom_loc}"
461        );
462        assert!(custom_loc.to_string().ends_with(".metadata.json"));
463    }
464}