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