Skip to content

Commit c0d1a4e

Browse files
mprammergatesnclaude
authored
Add file-level user metadata (#8740)
Adds optional file-level metadata to the Vortex file format: a set of string-keyed, opaque byte segments referenced from the postscript, for consumers that need to attach identity or annotation to a file (Iceberg field IDs, an Arrow-metadata round-trip, provenance). Keys and their segment locators live in the postscript and are read at open; the opaque values load only when a reader opts in with `include_metadata`, resolved one locator at a time through the file's existing segment source — served from the initial footer read when they fall inside it, otherwise a targeted read. A default open never materializes them and makes no metadata-driven read. The `DType`, its FlatBuffers and protobuf serialization, and the scan path are unchanged; the wire change is a single additive `Postscript` field, so old readers skip it and the file version stays `1`. This revives #7954 (the original file-metadata-segments work) rebased onto develop, with the read path reshaped so metadata is never folded into the footer's contiguous tail read and never keeps that buffer alive: values are resolved per-locator and copied out. `Footer` carries only the locators, so a cached footer is identical whether or not the opener asked for metadata, and one file's metadata can't surface for another through the multi-file cache. Parsing a segment alignment from an untrusted postscript now returns an error rather than panicking on an out-of-range exponent (the TUI inspector included). The public read API is additive — `VortexFile` gains metadata accessors and `VortexOpenOptions` an `include_metadata` opt-in — and `Footer::new` is unchanged; the generated `Postscript` FlatBuffer gains a field, as any additive schema change does. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: mprammer <martin@spiraldb.com> Co-authored-by: Nicholas Gates <nick@nickgates.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 037b1a8 commit c0d1a4e

15 files changed

Lines changed: 1394 additions & 58 deletions

File tree

docs/specs/file-format.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ The postscript contains the locations of:
5454
2. a `layout` segment containing the root `Layout`
5555
3. a `statistics` segment containing file-level per-field statistics (e.g., minima and maxima of each field/column, for whole-file pruning)
5656
4. a `footer` segment containing a dictionary-encoded _segment map_, and other shared configuration such as compression and encryption schemes
57+
5. up to 16 user-defined `metadata` segments, each identified by a unique, non-empty UTF-8 key of at most 64 bytes
58+
59+
The postscript carries a locator (offset, length, and alignment) for each metadata segment that is
60+
present; a file written without user metadata (and any file predating this feature) carries none.
61+
Readers do not load the opaque metadata values by default. Opt-in metadata reads resolve each
62+
locator separately, allowing values outside the initial file-tail read to be fetched without reading
63+
the intervening file contents.
5764

5865
:::{literalinclude} ../../vortex-flatbuffers/flatbuffers/vortex-file/footer.fbs
5966
:start-after: [postscript]

vortex-buffer/src/alignment.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,14 @@ impl Alignment {
117117
///
118118
/// ## Panics
119119
///
120-
/// Panics if `1 << exponent` overflows `usize`.
120+
/// Panics if `1 << exponent` overflows `usize`. Use [`Self::try_from_exponent`] when parsing
121+
/// untrusted input.
121122
#[inline]
122123
pub const fn from_exponent(exponent: u8) -> Self {
124+
assert!(
125+
(exponent as u32) < usize::BITS,
126+
"Alignment exponent must fit in usize"
127+
);
123128
Self::new(1 << exponent)
124129
}
125130

vortex-file/src/file.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use vortex_array::ArrayRef;
1515
use vortex_array::dtype::DType;
1616
use vortex_array::dtype::FieldMask;
1717
use vortex_array::expr::Expression;
18+
use vortex_buffer::ByteBuffer;
1819
use vortex_error::VortexResult;
1920
use vortex_layout::LayoutReader;
2021
use vortex_layout::scan::layout::LayoutReaderDataSource;
@@ -23,6 +24,7 @@ use vortex_layout::scan::split_by::SplitBy;
2324
use vortex_layout::segments::SegmentSource;
2425
use vortex_scan::DataSourceRef;
2526
use vortex_session::VortexSession;
27+
use vortex_utils::aliases::hash_map::HashMap;
2628

2729
use crate::FileStatistics;
2830
use crate::footer::Footer;
@@ -42,6 +44,8 @@ pub struct VortexFile {
4244
segment_source: Arc<dyn SegmentSource>,
4345
/// The Vortex session used to open this file.
4446
session: VortexSession,
47+
/// User-defined metadata values resolved for this file open.
48+
metadata: Arc<HashMap<String, ByteBuffer>>,
4549
/// None id LayoutReader caching is turned off
4650
layout_reader_cache: Option<OnceLock<Arc<dyn LayoutReader>>>,
4751
}
@@ -78,10 +82,16 @@ impl VortexFile {
7882
footer,
7983
segment_source,
8084
session,
85+
metadata: Arc::new(HashMap::new()),
8186
layout_reader_cache: None,
8287
}
8388
}
8489

90+
pub(crate) fn with_metadata(mut self, metadata: Arc<HashMap<String, ByteBuffer>>) -> Self {
91+
self.metadata = metadata;
92+
self
93+
}
94+
8595
/// Enable layout reader caching.
8696
///
8797
/// Repeated calls to [`layout_reader`](Self::layout_reader), [`scan`](Self::scan), and
@@ -91,6 +101,7 @@ impl VortexFile {
91101
footer: self.footer,
92102
segment_source: self.segment_source,
93103
session: self.session,
104+
metadata: self.metadata,
94105
layout_reader_cache: Some(OnceLock::new()),
95106
}
96107
}
@@ -117,6 +128,22 @@ impl VortexFile {
117128
self.footer.statistics()
118129
}
119130

131+
/// Returns the user-defined metadata segments loaded for this file.
132+
///
133+
/// Metadata is only loaded when requested during open. Iteration order is unspecified.
134+
pub fn metadata_segments(&self) -> impl Iterator<Item = (&str, &ByteBuffer)> {
135+
self.metadata
136+
.iter()
137+
.map(|(key, metadata)| (key.as_str(), metadata))
138+
}
139+
140+
/// Returns the loaded user-defined metadata segment for the given key.
141+
///
142+
/// Returns `None` when the key is absent or metadata was not loaded.
143+
pub fn metadata_segment(&self, key: &str) -> Option<&ByteBuffer> {
144+
self.metadata.get(key)
145+
}
146+
120147
/// Create a new segment source for reading from the file.
121148
///
122149
/// This may spawn a background I/O driver that will exit when the returned segment source

vortex-file/src/footer/deserializer.rs

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// SPDX-License-Identifier: Apache-2.0
22
// SPDX-FileCopyrightText: Copyright the Vortex contributors
33

4+
use std::sync::Arc;
5+
46
use flatbuffers::root;
57
use vortex_array::dtype::DType;
68
use vortex_buffer::ByteBuffer;
@@ -18,6 +20,7 @@ use crate::Footer;
1820
use crate::MAGIC_BYTES;
1921
use crate::VERSION;
2022
use crate::footer::FileStatistics;
23+
use crate::footer::SegmentSpec;
2124
use crate::footer::postscript::Postscript;
2225
use crate::footer::postscript::PostscriptSegment;
2326

@@ -177,14 +180,50 @@ impl FooterDeserializer {
177180
)
178181
})
179182
.transpose()?;
183+
let metadata = postscript
184+
.metadata
185+
.iter()
186+
.map(|metadata| {
187+
let segment = SegmentSpec {
188+
offset: metadata.segment.offset,
189+
length: metadata.segment.length,
190+
alignment: metadata.segment.alignment,
191+
};
192+
let end = segment
193+
.offset
194+
.checked_add(u64::from(segment.length))
195+
.ok_or_else(|| {
196+
vortex_err!("Metadata segment {} range overflowed u64", metadata.key)
197+
})?;
198+
if end > file_size {
199+
vortex_bail!(
200+
"Metadata segment {} range {}..{} exceeds file size {}",
201+
metadata.key,
202+
segment.offset,
203+
end,
204+
file_size
205+
);
206+
}
207+
let offset = usize::try_from(segment.offset)?;
208+
if !segment.alignment.is_offset_aligned(offset) {
209+
vortex_bail!(
210+
"Metadata segment {} offset {} is not aligned to {}",
211+
metadata.key,
212+
segment.offset,
213+
segment.alignment
214+
);
215+
}
216+
Ok((metadata.key.clone(), segment))
217+
})
218+
.collect::<VortexResult<Arc<[_]>>>()?;
180219

181220
Ok(DeserializeStep::Done(self.parse_footer(
182221
initial_offset,
183222
&self.buffer,
184-
&postscript.footer,
185-
&postscript.layout,
223+
postscript,
186224
dtype,
187225
file_stats,
226+
metadata,
188227
)?))
189228
}
190229

@@ -269,19 +308,29 @@ impl FooterDeserializer {
269308
&self,
270309
initial_offset: u64,
271310
initial_read: &[u8],
272-
footer_segment: &PostscriptSegment,
273-
layout_segment: &PostscriptSegment,
311+
postscript: &Postscript,
274312
dtype: DType,
275313
file_stats: Option<FileStatistics>,
314+
metadata: Arc<[(String, SegmentSpec)]>,
276315
) -> VortexResult<Footer> {
316+
let footer_segment = &postscript.footer;
277317
let footer_bytes = checked_segment_slice(initial_read, initial_offset, footer_segment)?;
318+
319+
let layout_segment = &postscript.layout;
278320
let layout_bytes = FlatBuffer::copy_from(checked_segment_slice(
279321
initial_read,
280322
initial_offset,
281323
layout_segment,
282324
)?);
283325

284-
Footer::from_flatbuffer(footer_bytes, layout_bytes, dtype, file_stats, &self.session)
326+
Footer::from_flatbuffer(
327+
footer_bytes,
328+
layout_bytes,
329+
dtype,
330+
file_stats,
331+
metadata,
332+
&self.session,
333+
)
285334
}
286335
}
287336

@@ -374,6 +423,7 @@ mod tests {
374423
layout: segment(0, 1),
375424
statistics: None,
376425
footer: footer_segment,
426+
metadata: Vec::new(),
377427
};
378428
let buffer = eof_buffer(&postscript)?;
379429
let file_size = buffer.len() as u64;
@@ -393,6 +443,7 @@ mod tests {
393443
layout: segment(0, 1),
394444
statistics: None,
395445
footer: segment(1, 1),
446+
metadata: Vec::new(),
396447
};
397448
let buffer = eof_buffer(&postscript)?;
398449
let declared_size = buffer.len() as u64 - 1;

vortex-file/src/footer/mod.rs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,27 @@ use vortex_layout::layout_from_flatbuffer_with_options;
3939
use vortex_session::VortexSession;
4040
use vortex_session::registry::ReadContext;
4141

42+
/// Maximum number of user-defined metadata segments. Keeps postscript bookkeeping small so the
43+
/// footer and required segments still fit the initial tail read.
44+
pub(crate) const MAX_METADATA_SEGMENTS: usize = 16;
45+
46+
/// Maximum length, in UTF-8 bytes, of a user-defined metadata key (keys live in the postscript).
47+
///
48+
/// 64 bytes covers reverse-DNS query-engine keys, not just short Iceberg-style keys: e.g.
49+
/// `org.apache.spark.sql.parquet.row.metadata` (41 bytes), which Spark writes into every Parquet
50+
/// file. With [`MAX_METADATA_SEGMENTS`] keys this bounds the postscript key budget at 1 KiB.
51+
pub(crate) const MAX_METADATA_KEY_BYTES: usize = 64;
52+
53+
/// User-defined metadata segment locators stored as `(key, locator)` pairs.
54+
pub(crate) type MetadataSegments = Arc<[(String, SegmentSpec)]>;
55+
4256
/// Captures the layout information of a Vortex file.
4357
#[derive(Debug, Clone)]
4458
pub struct Footer {
4559
root_layout: LayoutRef,
4660
segments: Arc<[SegmentSpec]>,
4761
statistics: Option<FileStatistics>,
62+
metadata: Arc<[(String, SegmentSpec)]>,
4863
// The specific arrays used within the file, in the order they were registered.
4964
array_read_ctx: ReadContext,
5065
// The approximate size of the footer in bytes, used for caching and memory management.
@@ -62,6 +77,7 @@ impl Footer {
6277
root_layout,
6378
segments,
6479
statistics,
80+
metadata: Arc::from([]),
6581
array_read_ctx,
6682
approx_byte_size: None,
6783
}
@@ -78,9 +94,14 @@ impl Footer {
7894
layout_bytes: FlatBuffer,
7995
dtype: DType,
8096
statistics: Option<FileStatistics>,
97+
metadata: Arc<[(String, SegmentSpec)]>,
8198
session: &VortexSession,
8299
) -> VortexResult<Self> {
83-
let approx_byte_size = footer_bytes.len() + layout_bytes.len();
100+
let metadata_bytes: usize = metadata
101+
.iter()
102+
.map(|(key, _segment)| key.len() + size_of::<SegmentSpec>())
103+
.sum();
104+
let approx_byte_size = footer_bytes.len() + layout_bytes.len() + metadata_bytes;
84105
let fb_footer = root::<fb::Footer>(footer_bytes)?;
85106

86107
// Create a LayoutContext from the registry.
@@ -128,6 +149,7 @@ impl Footer {
128149
root_layout,
129150
segments,
130151
statistics,
152+
metadata,
131153
array_read_ctx,
132154
approx_byte_size: Some(approx_byte_size),
133155
})
@@ -148,6 +170,33 @@ impl Footer {
148170
self.statistics.as_ref()
149171
}
150172

173+
/// Returns the user-defined metadata segment locators stored in the postscript.
174+
pub fn metadata_segments(&self) -> impl Iterator<Item = (&str, &SegmentSpec)> {
175+
self.metadata
176+
.iter()
177+
.map(|(key, segment)| (key.as_str(), segment))
178+
}
179+
180+
/// Returns the user-defined metadata segment locator for the given key.
181+
pub fn metadata_segment(&self, key: &str) -> Option<&SegmentSpec> {
182+
self.metadata
183+
.iter()
184+
.find_map(|(candidate, segment)| (candidate == key).then_some(segment))
185+
}
186+
187+
pub(crate) fn with_metadata_segments(mut self, metadata: Arc<[(String, SegmentSpec)]>) -> Self {
188+
self.metadata = metadata;
189+
self
190+
}
191+
192+
pub(crate) fn segment_specs_with_metadata(&self) -> Arc<[SegmentSpec]> {
193+
self.segments
194+
.iter()
195+
.copied()
196+
.chain(self.metadata.iter().map(|(_, segment)| *segment))
197+
.collect()
198+
}
199+
151200
/// Computes the compressed size in bytes of every field in the file, keyed by field path.
152201
///
153202
/// Sizes are derived by attributing each segment in the [segment map][Self::segment_map] to a

0 commit comments

Comments
 (0)