Skip to content

Commit 3b4055e

Browse files
Merge branch 'apache:main' into fix-partition-transform-align-with-java
2 parents dd8cbdd + 7d2dfce commit 3b4055e

4 files changed

Lines changed: 334 additions & 3 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ bytes = "1.11"
7777
cfg-if = "1"
7878
chrono = "0.4.41"
7979
clap = { version = "4.5.48", features = ["derive", "cargo"] }
80+
crc32fast = "1"
8081
dashmap = "6"
8182
datafusion = "54.1.0"
8283
datafusion-cli = "54.1.0"

crates/iceberg/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ base64 = { workspace = true }
5252
bimap = { workspace = true }
5353
bytes = { workspace = true }
5454
chrono = { workspace = true }
55+
crc32fast = { workspace = true }
5556
derive_builder = { workspace = true }
5657
expect-test = { workspace = true }
5758
fastnum = { workspace = true }

crates/iceberg/src/delete_vector.rs

Lines changed: 329 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,21 @@
1717

1818
use std::ops::BitOrAssign;
1919

20-
use roaring::RoaringTreemap;
20+
use bytes::Buf;
2121
use roaring::bitmap::Iter;
2222
use roaring::treemap::BitmapIter;
23+
use roaring::{RoaringBitmap, RoaringTreemap};
2324

2425
use crate::{Error, ErrorKind, Result};
2526

27+
/// Magic bytes prefixing a serialized `deletion-vector-v1` bitmap, per the Iceberg Puffin spec.
28+
/// Iceberg-Java stores these as the little-endian int 1681511377 (0x6439D3D1).
29+
const DV_MAGIC: [u8; 4] = [0xD1, 0xD3, 0x39, 0x64];
30+
const DV_LENGTH_PREFIX_BYTES: usize = 4;
31+
const DV_MAGIC_BYTES: usize = 4;
32+
const DV_CRC_BYTES: usize = 4;
33+
const DV_MIN_BLOB_BYTES: usize = DV_LENGTH_PREFIX_BYTES + DV_MAGIC_BYTES + DV_CRC_BYTES;
34+
2635
#[derive(Debug, Default)]
2736
pub struct DeleteVector {
2837
inner: RoaringTreemap,
@@ -68,6 +77,171 @@ impl DeleteVector {
6877
pub fn len(&self) -> u64 {
6978
self.inner.len()
7079
}
80+
81+
/// Parses a `deletion-vector-v1` Puffin blob into a `DeleteVector`.
82+
///
83+
/// The layout, defined by the Iceberg Puffin spec and matching Iceberg-Java's
84+
/// `BitmapPositionDeleteIndex`, is:
85+
///
86+
/// ```text
87+
/// [length: u32 big-endian][magic: D1 D3 39 64][vector][crc: u32 big-endian]
88+
/// ```
89+
///
90+
/// `length` counts the magic and vector bytes (not itself or the CRC). The CRC-32 is
91+
/// computed over the magic and vector. `vector` is a roaring bitmap in the portable 64-bit
92+
/// format: a directory of 32-bit key / 32-bit roaring bitmap pairs, ordered by unsigned
93+
/// comparison of the keys, one bitmap per key.
94+
///
95+
/// Cardinality is not checked here. The caller validates the decoded length against the
96+
/// delete file's `record_count`, where the manifest metadata is available.
97+
///
98+
/// # Errors
99+
///
100+
/// Returns [`ErrorKind::DataInvalid`] if the blob is shorter than the minimum, the length
101+
/// prefix or CRC does not match, the magic is wrong, the roaring bitmap count exceeds the
102+
/// portable format's maximum, the roaring directory's keys are not ordered by unsigned
103+
/// comparison, or the roaring payload fails to decode.
104+
// Consumed by the scan delete loader once the deletion-vector read path is wired up.
105+
#[allow(dead_code)]
106+
pub fn deserialize(blob: &[u8]) -> Result<Self> {
107+
if blob.len() < DV_MIN_BLOB_BYTES {
108+
return Err(Error::new(
109+
ErrorKind::DataInvalid,
110+
format!(
111+
"deletion-vector-v1 blob is {} bytes, shorter than the {DV_MIN_BLOB_BYTES}-byte minimum",
112+
blob.len()
113+
),
114+
));
115+
}
116+
117+
// The magic and vector, i.e. the bytes covered by both the length prefix and the CRC.
118+
let body = &blob[DV_LENGTH_PREFIX_BYTES..blob.len() - DV_CRC_BYTES];
119+
verify_length_prefix(&blob[..DV_LENGTH_PREFIX_BYTES], body)?;
120+
121+
// Verify the CRC before interpreting any bytes so a corrupt blob yields a single clear
122+
// error rather than an opaque roaring decode failure.
123+
verify_crc(body, &blob[blob.len() - DV_CRC_BYTES..])?;
124+
125+
let (magic, vector) = body.split_at(DV_MAGIC_BYTES);
126+
verify_magic(magic)?;
127+
128+
let inner = decode_roaring_directory(vector)?;
129+
130+
Ok(DeleteVector { inner })
131+
}
132+
}
133+
134+
fn verify_length_prefix(mut prefix: &[u8], body: &[u8]) -> Result<()> {
135+
let declared_len = prefix.try_get_u32().map_err(|e| {
136+
Error::new(
137+
ErrorKind::Unexpected,
138+
"failed to read the deletion-vector-v1 length prefix",
139+
)
140+
.with_source(e)
141+
})? as usize;
142+
if declared_len != body.len() {
143+
return Err(Error::new(
144+
ErrorKind::DataInvalid,
145+
format!(
146+
"deletion-vector-v1 length prefix is {declared_len}, expected {}",
147+
body.len()
148+
),
149+
));
150+
}
151+
Ok(())
152+
}
153+
154+
fn verify_crc(body: &[u8], mut crc_bytes: &[u8]) -> Result<()> {
155+
let stored_crc = crc_bytes.try_get_u32().map_err(|e| {
156+
Error::new(
157+
ErrorKind::Unexpected,
158+
"failed to read the deletion-vector-v1 CRC",
159+
)
160+
.with_source(e)
161+
})?;
162+
let computed_crc = crc32fast::hash(body);
163+
if computed_crc != stored_crc {
164+
return Err(Error::new(
165+
ErrorKind::DataInvalid,
166+
format!(
167+
"deletion-vector-v1 CRC mismatch: computed {computed_crc:#010x}, stored {stored_crc:#010x}"
168+
),
169+
));
170+
}
171+
Ok(())
172+
}
173+
174+
fn verify_magic(magic: &[u8]) -> Result<()> {
175+
if magic != DV_MAGIC {
176+
return Err(Error::new(
177+
ErrorKind::DataInvalid,
178+
format!("deletion-vector-v1 magic mismatch: {magic:02x?}, expected {DV_MAGIC:02x?}"),
179+
));
180+
}
181+
Ok(())
182+
}
183+
184+
// The Puffin spec defines the roaring directory as the bitmaps "ordered by unsigned comparison
185+
// of the 32-bit keys", with one bitmap per key. Walk it ourselves (rather than
186+
// `RoaringTreemap::deserialize_from`, which stores keys in a `BTreeMap` via a plain insert and
187+
// would silently accept a stream with duplicate or out-of-order keys, discarding the earlier
188+
// bitmap on a duplicate) so a non-conformant blob is rejected instead of decoded into a value
189+
// that doesn't match what was actually written.
190+
fn decode_roaring_directory(mut reader: &[u8]) -> Result<RoaringTreemap> {
191+
let bitmap_count = reader.try_get_u64_le().map_err(|e| {
192+
Error::new(
193+
ErrorKind::DataInvalid,
194+
"failed to decode deletion-vector-v1 roaring payload",
195+
)
196+
.with_source(e)
197+
})?;
198+
// The roaring portable format restricts the bitmap count to [0, 2^32 - 1] (it is stored as a
199+
// u64 with the upper 32 bits reserved as zero padding).
200+
if bitmap_count > u32::MAX as u64 {
201+
return Err(Error::new(
202+
ErrorKind::DataInvalid,
203+
format!(
204+
"deletion-vector-v1 roaring bitmap count {bitmap_count} exceeds the {}-key maximum",
205+
u32::MAX
206+
),
207+
));
208+
}
209+
210+
let mut bitmaps = Vec::new();
211+
let mut last_key: Option<u32> = None;
212+
for _ in 0..bitmap_count {
213+
let key = reader.try_get_u32_le().map_err(|e| {
214+
Error::new(
215+
ErrorKind::DataInvalid,
216+
"failed to decode deletion-vector-v1 roaring payload",
217+
)
218+
.with_source(e)
219+
})?;
220+
if let Some(last) = last_key
221+
&& key <= last
222+
{
223+
return Err(Error::new(
224+
ErrorKind::DataInvalid,
225+
format!(
226+
"deletion-vector-v1 roaring keys must be ordered by unsigned comparison, got key {key} after {last}"
227+
),
228+
));
229+
}
230+
last_key = Some(key);
231+
232+
let bitmap = RoaringBitmap::deserialize_from(&mut reader).map_err(|e| {
233+
Error::new(
234+
ErrorKind::DataInvalid,
235+
"failed to decode deletion-vector-v1 roaring payload",
236+
)
237+
.with_source(e)
238+
})?;
239+
bitmaps.push((key, bitmap));
240+
}
241+
242+
// `bitmaps` is already sorted by key, but `roaring` has no constructor that accepts
243+
// pre-sorted pairs without re-sorting them; revisit if that changes upstream.
244+
Ok(RoaringTreemap::from_bitmaps(bitmaps))
71245
}
72246

73247
// Ideally, we'd just wrap `roaring::RoaringTreemap`'s iterator, `roaring::treemap::Iter` here.
@@ -198,4 +372,158 @@ mod tests {
198372
let res = dv.insert_positions(&positions);
199373
assert!(res.is_err());
200374
}
375+
376+
// Reproduces Iceberg-Java's `deletion-vector-v1` framing so tests can round-trip through
377+
// `deserialize` without a Java writer. Cross-implementation golden fixtures produced by
378+
// Iceberg-Java are tracked separately; this only checks that our decode matches our encode.
379+
fn frame_dv_blob(vector: &[u8]) -> Vec<u8> {
380+
let body_len = DV_MAGIC_BYTES + vector.len();
381+
let mut blob = Vec::with_capacity(DV_LENGTH_PREFIX_BYTES + body_len + DV_CRC_BYTES);
382+
blob.extend_from_slice(&(body_len as u32).to_be_bytes());
383+
blob.extend_from_slice(&DV_MAGIC);
384+
blob.extend_from_slice(vector);
385+
let crc = crc32fast::hash(&blob[DV_LENGTH_PREFIX_BYTES..]);
386+
blob.extend_from_slice(&crc.to_be_bytes());
387+
blob
388+
}
389+
390+
fn encode_dv_blob(dv: &DeleteVector) -> Vec<u8> {
391+
let mut vector = Vec::with_capacity(dv.inner.serialized_size());
392+
dv.inner.serialize_into(&mut vector).unwrap();
393+
frame_dv_blob(&vector)
394+
}
395+
396+
fn dv_of(positions: impl IntoIterator<Item = u64>) -> DeleteVector {
397+
let mut dv = DeleteVector::default();
398+
for pos in positions {
399+
dv.insert(pos);
400+
}
401+
dv
402+
}
403+
404+
fn sorted(dv: &DeleteVector) -> Vec<u64> {
405+
let mut positions: Vec<u64> = dv.iter().collect();
406+
positions.sort_unstable();
407+
positions
408+
}
409+
410+
#[test]
411+
fn test_deserialize_roundtrip_empty() {
412+
let blob = encode_dv_blob(&DeleteVector::default());
413+
assert_eq!(DeleteVector::deserialize(&blob).unwrap().len(), 0);
414+
}
415+
416+
#[test]
417+
fn test_deserialize_roundtrip_small() {
418+
let positions = [0u64, 5, 100, 1000];
419+
let dv = DeleteVector::deserialize(&encode_dv_blob(&dv_of(positions))).unwrap();
420+
assert_eq!(sorted(&dv), positions);
421+
}
422+
423+
#[test]
424+
fn test_deserialize_roundtrip_spanning_64bit_keys() {
425+
let positions = [1u64, 1 << 33, (1 << 33) + 5, 1 << 34];
426+
let dv = DeleteVector::deserialize(&encode_dv_blob(&dv_of(positions))).unwrap();
427+
assert_eq!(sorted(&dv), positions);
428+
}
429+
430+
// Java run-optimizes every deletion vector before writing, so real blobs carry RUN
431+
// containers, which use the SERIAL_COOKIE roaring layout. Force that layout so decode
432+
// exercises the run-container path rather than only array and bitmap containers.
433+
#[test]
434+
fn test_deserialize_roundtrip_run_optimized() {
435+
let mut dv = dv_of(0..10_000);
436+
assert!(
437+
dv.inner.optimize(),
438+
"expected a dense range to run-length encode"
439+
);
440+
let decoded = DeleteVector::deserialize(&encode_dv_blob(&dv)).unwrap();
441+
assert_eq!(decoded.len(), 10_000);
442+
let positions = sorted(&decoded);
443+
assert_eq!(positions.first(), Some(&0));
444+
assert_eq!(positions.last(), Some(&9_999));
445+
}
446+
447+
#[test]
448+
fn test_deserialize_rejects_short_blob() {
449+
let err = DeleteVector::deserialize(&[0u8; DV_MIN_BLOB_BYTES - 1]).unwrap_err();
450+
assert_eq!(err.kind(), ErrorKind::DataInvalid);
451+
}
452+
453+
#[test]
454+
fn test_deserialize_rejects_bad_magic() {
455+
let mut blob = encode_dv_blob(&dv_of([1]));
456+
blob[DV_LENGTH_PREFIX_BYTES] ^= 0xFF;
457+
// Recompute the CRC so the magic check, not the CRC check, is what fails.
458+
let end = blob.len() - DV_CRC_BYTES;
459+
let crc = crc32fast::hash(&blob[DV_LENGTH_PREFIX_BYTES..end]);
460+
blob[end..].copy_from_slice(&crc.to_be_bytes());
461+
let err = DeleteVector::deserialize(&blob).unwrap_err();
462+
assert!(err.message().contains("magic mismatch"), "got: {err}");
463+
}
464+
465+
#[test]
466+
fn test_deserialize_rejects_bad_crc() {
467+
let mut blob = encode_dv_blob(&dv_of([1, 2, 3]));
468+
let end = blob.len() - DV_CRC_BYTES;
469+
blob[end] ^= 0xFF;
470+
let err = DeleteVector::deserialize(&blob).unwrap_err();
471+
assert!(err.message().contains("CRC mismatch"), "got: {err}");
472+
}
473+
474+
#[test]
475+
fn test_deserialize_rejects_length_prefix_mismatch() {
476+
let mut blob = encode_dv_blob(&dv_of([1]));
477+
let declared = u32::from_be_bytes(blob[..DV_LENGTH_PREFIX_BYTES].try_into().unwrap());
478+
blob[..DV_LENGTH_PREFIX_BYTES].copy_from_slice(&(declared + 1).to_be_bytes());
479+
let err = DeleteVector::deserialize(&blob).unwrap_err();
480+
assert!(err.message().contains("length prefix"), "got: {err}");
481+
}
482+
483+
// Crafts a raw roaring treemap directory (bitmap count header + key/bitmap entries) so tests
484+
// can exercise key-ordering violations that `dv_of`/`encode_dv_blob` can never produce, since
485+
// `DeleteVector::insert` always keeps keys unique and ascending.
486+
fn raw_roaring_vector(entries: &[(u32, &[u32])]) -> Vec<u8> {
487+
let mut vector = Vec::new();
488+
vector.extend_from_slice(&(entries.len() as u64).to_le_bytes());
489+
for (key, positions) in entries {
490+
let mut bitmap = RoaringBitmap::new();
491+
for &pos in *positions {
492+
bitmap.insert(pos);
493+
}
494+
vector.extend_from_slice(&key.to_le_bytes());
495+
bitmap.serialize_into(&mut vector).unwrap();
496+
}
497+
vector
498+
}
499+
500+
// `RoaringTreemap::deserialize_from` stores keys in a `BTreeMap` via a plain insert, so
501+
// without our own ordering check, decoding this would silently keep only the second bitmap
502+
// for key 5 (position 2) and drop the first (position 1).
503+
#[test]
504+
fn test_deserialize_rejects_duplicate_keys() {
505+
let vector = raw_roaring_vector(&[(5, &[1]), (5, &[2])]);
506+
let err = DeleteVector::deserialize(&frame_dv_blob(&vector)).unwrap_err();
507+
assert!(err.message().contains("unsigned comparison"), "got: {err}");
508+
}
509+
510+
// The Puffin spec requires the roaring directory's keys to be "ordered by unsigned
511+
// comparison"; a stream with unique but out-of-order keys is not a conformant blob even
512+
// though `BTreeMap` would happily reorder it into a correct-looking result.
513+
#[test]
514+
fn test_deserialize_rejects_out_of_order_keys() {
515+
let vector = raw_roaring_vector(&[(5, &[1]), (3, &[2])]);
516+
let err = DeleteVector::deserialize(&frame_dv_blob(&vector)).unwrap_err();
517+
assert!(err.message().contains("unsigned comparison"), "got: {err}");
518+
}
519+
520+
// The roaring portable format stores the bitmap count as a u64 with the upper 32 bits
521+
// reserved as zero padding, restricting it to [0, 2^32 - 1]; a value above that is not a
522+
// conformant blob, regardless of whether any key/bitmap entries follow.
523+
#[test]
524+
fn test_deserialize_rejects_bitmap_count_overflow() {
525+
let vector = (u32::MAX as u64 + 1).to_le_bytes().to_vec();
526+
let err = DeleteVector::deserialize(&frame_dv_blob(&vector)).unwrap_err();
527+
assert!(err.message().contains("exceeds the"), "got: {err}");
528+
}
201529
}

0 commit comments

Comments
 (0)