This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 814f2929 fix(fm_index): store blocks in Java's codec envelope, not the 
SST one (#826)
814f2929 is described below

commit 814f292975f9418f0b96b8025f18da72a9170a31
Author: jackylee <[email protected]>
AuthorDate: Thu Sep 17 15:08:54 2026 +0800

    fix(fm_index): store blocks in Java's codec envelope, not the SST one (#826)
---
 crates/paimon/src/btree/block.rs     | 195 ++++++++++++++++++++++++++---------
 crates/paimon/src/btree/mod.rs       |   2 +-
 crates/paimon/src/fm_index/format.rs |  70 ++++++++++++-
 3 files changed, 214 insertions(+), 53 deletions(-)

diff --git a/crates/paimon/src/btree/block.rs b/crates/paimon/src/btree/block.rs
index 18e4ae35..950995f7 100644
--- a/crates/paimon/src/btree/block.rs
+++ b/crates/paimon/src/btree/block.rs
@@ -71,6 +71,11 @@ impl BlockCompressionType {
 /// Compress a Java Paimon block, including the outer uncompressed-size varint
 /// and the LZ4/LZO codec envelope when applicable. Compression is retained 
only
 /// when it saves at least 12.5%, matching Java's block writer.
+///
+/// The varint belongs to the SST and bitmap-index *block formats*, not to the
+/// codec: Java writes it in `SstFileWriter#writeBlock` and
+/// `BitmapGlobalIndexFormat#encodeBlock`. Formats that store `BlockCompressor`
+/// output verbatim want [`compress_codec_block`] instead.
 pub(crate) fn compress_block(
     data: &[u8],
     compression_type: BlockCompressionType,
@@ -80,25 +85,57 @@ pub(crate) fn compress_block(
         return Ok((Cow::Borrowed(data), BlockCompressionType::None));
     }
 
+    let mut encoded = Vec::with_capacity(5);
+    encode_var_int(&mut encoded, block_length(data.len())?)?;
+    append_codec_envelope(&mut encoded, data, compression_type, 
compression_level)?;
+    Ok(retain_if_smaller(encoded, data, compression_type))
+}
+
+/// Compress a block the way a format that stores `BlockCompressor` output
+/// verbatim expects: the LZ4/LZO codec header when applicable, and no outer
+/// uncompressed-size varint. Java's `FMIndexFile#writeBlock` stores the
+/// compressor's bytes unchanged and records `storedLength = compressedLength`.
+pub(crate) fn compress_codec_block(
+    data: &[u8],
+    compression_type: BlockCompressionType,
+    compression_level: i32,
+) -> io::Result<(Cow<'_, [u8]>, BlockCompressionType)> {
+    if compression_type == BlockCompressionType::None {
+        return Ok((Cow::Borrowed(data), BlockCompressionType::None));
+    }
+
+    let mut encoded = Vec::new();
+    append_codec_envelope(&mut encoded, data, compression_type, 
compression_level)?;
+    Ok(retain_if_smaller(encoded, data, compression_type))
+}
+
+fn block_length(len: usize) -> io::Result<i32> {
+    i32::try_from(len)
+        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Block is 
larger than i32::MAX"))
+}
+
+/// Append what Java's `BlockCompressor#compress` writes: for LZ4 and LZO the
+/// `[le32 compressedLength][le32 srcLen]` header and then the payload, for 
ZSTD
+/// the bare frame.
+fn append_codec_envelope(
+    encoded: &mut Vec<u8>,
+    data: &[u8],
+    compression_type: BlockCompressionType,
+    compression_level: i32,
+) -> io::Result<()> {
     let payload = match compression_type {
-        BlockCompressionType::None => unreachable!("handled above"),
+        BlockCompressionType::None => unreachable!("callers handle None"),
         BlockCompressionType::Zstd => zstd::bulk::compress(data, 
compression_level)
             .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, 
error))?,
         BlockCompressionType::Lz4 => lz4_flex::block::compress(data),
         BlockCompressionType::Lzo => lzokay_native::compress(data)
             .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, 
error))?,
     };
-    let mut encoded = Vec::with_capacity(13 + payload.len());
-    encode_var_int(
-        &mut encoded,
-        i32::try_from(data.len()).map_err(|_| {
-            io::Error::new(io::ErrorKind::InvalidData, "Block is larger than 
i32::MAX")
-        })?,
-    )?;
     if matches!(
         compression_type,
         BlockCompressionType::Lz4 | BlockCompressionType::Lzo
     ) {
+        encoded.reserve(8 + payload.len());
         encoded.extend_from_slice(
             &i32::try_from(payload.len())
                 .map_err(|_| {
@@ -109,46 +146,64 @@ pub(crate) fn compress_block(
                 })?
                 .to_le_bytes(),
         );
-        encoded.extend_from_slice(
-            &i32::try_from(data.len())
-                .map_err(|_| {
-                    io::Error::new(io::ErrorKind::InvalidData, "Block is 
larger than i32::MAX")
-                })?
-                .to_le_bytes(),
-        );
+        encoded.extend_from_slice(&block_length(data.len())?.to_le_bytes());
+    } else {
+        encoded.reserve(payload.len());
     }
     encoded.extend_from_slice(&payload);
+    Ok(())
+}
+
+/// Java keeps the compressed form only when it saves at least 12.5%; both
+/// `SstFileWriter#writeBlock` and `FMIndexFile#writeBlock` compare against the
+/// bytes they are about to store, so each caller applies this to its own 
output.
+fn retain_if_smaller<'a>(
+    encoded: Vec<u8>,
+    data: &'a [u8],
+    compression_type: BlockCompressionType,
+) -> (Cow<'a, [u8]>, BlockCompressionType) {
     if encoded.len() < data.len() - (data.len() / 8) {
-        Ok((Cow::Owned(encoded), compression_type))
+        (Cow::Owned(encoded), compression_type)
     } else {
-        Ok((Cow::Borrowed(data), BlockCompressionType::None))
+        (Cow::Borrowed(data), BlockCompressionType::None)
     }
 }
 
 /// Decode the payload written by [`compress_block`] or Java's corresponding
-/// block compressors.
+/// block compressors: an uncompressed-size varint followed by the codec block.
 pub(crate) fn decompress_block(
     data: &[u8],
     compression_type: BlockCompressionType,
 ) -> io::Result<Vec<u8>> {
-    decompress_block_inner(data, compression_type, None)
-}
+    if compression_type == BlockCompressionType::None {
+        return Ok(data.to_vec());
+    }
 
-pub(crate) fn decompress_block_with_expected_size(
-    data: &[u8],
-    compression_type: BlockCompressionType,
-    expected_size: usize,
-) -> io::Result<Vec<u8>> {
-    decompress_block_inner(data, compression_type, Some(expected_size))
+    let mut cursor = Cursor::new(data);
+    let uncompressed_size = usize::try_from(decode_var_int(&mut 
cursor)?).map_err(|_| {
+        io::Error::new(
+            io::ErrorKind::InvalidData,
+            "Invalid negative uncompressed block size",
+        )
+    })?;
+    let compressed_start = cursor.position() as usize;
+    decompress_codec_payload(
+        &data[compressed_start..],
+        compression_type,
+        uncompressed_size,
+    )
 }
 
-fn decompress_block_inner(
+/// Decode a codec block stored without the outer uncompressed-size varint, the
+/// way Java's `FMIndexFile#decodeStoredBlock` reads it. The decoded size comes
+/// from the caller's own metadata, so it also bounds the allocation.
+pub(crate) fn decompress_codec_block(
     data: &[u8],
     compression_type: BlockCompressionType,
-    expected_size: Option<usize>,
+    uncompressed_size: usize,
 ) -> io::Result<Vec<u8>> {
     if compression_type == BlockCompressionType::None {
-        if expected_size.is_some_and(|expected_size| expected_size != 
data.len()) {
+        if uncompressed_size != data.len() {
             return Err(io::Error::new(
                 io::ErrorKind::InvalidData,
                 "Uncompressed block size does not match its metadata",
@@ -156,24 +211,16 @@ fn decompress_block_inner(
         }
         return Ok(data.to_vec());
     }
+    decompress_codec_payload(data, compression_type, uncompressed_size)
+}
 
-    let mut cursor = Cursor::new(data);
-    let uncompressed_size = usize::try_from(decode_var_int(&mut 
cursor)?).map_err(|_| {
-        io::Error::new(
-            io::ErrorKind::InvalidData,
-            "Invalid negative uncompressed block size",
-        )
-    })?;
-    if expected_size.is_some_and(|expected_size| expected_size != 
uncompressed_size) {
-        return Err(io::Error::new(
-            io::ErrorKind::InvalidData,
-            "Compressed block size does not match its metadata",
-        ));
-    }
-    let compressed_start = cursor.position() as usize;
-    let compressed = &data[compressed_start..];
+fn decompress_codec_payload(
+    compressed: &[u8],
+    compression_type: BlockCompressionType,
+    uncompressed_size: usize,
+) -> io::Result<Vec<u8>> {
     match compression_type {
-        BlockCompressionType::None => unreachable!("handled above"),
+        BlockCompressionType::None => unreachable!("callers handle None"),
         BlockCompressionType::Zstd => {
             let mut decompressed = vec![0u8; uncompressed_size];
             let actual = zstd::bulk::decompress_to_buffer(compressed, &mut 
decompressed)
@@ -681,13 +728,63 @@ mod tests {
     fn constrained_decompression_rejects_header_size_before_decoding() {
         let source = vec![0u8; 4096];
         let (compressed, compression) =
-            compress_block(&source, BlockCompressionType::Zstd, 1).unwrap();
+            compress_codec_block(&source, BlockCompressionType::Zstd, 
1).unwrap();
         assert_eq!(compression, BlockCompressionType::Zstd);
 
-        let error = decompress_block_with_expected_size(&compressed, 
compression, 64)
-            .expect_err("the validated outer size must constrain allocation");
+        let error = decompress_codec_block(&compressed, compression, 64)
+            .expect_err("the caller's metadata size must constrain 
allocation");
         assert_eq!(error.kind(), io::ErrorKind::InvalidData);
-        assert!(error.to_string().contains("does not match its metadata"));
+    }
+
+    #[test]
+    fn codec_block_is_the_varint_block_without_its_prefix() {
+        // Java's FM index stores `BlockCompressor` output verbatim, while the 
SST and
+        // bitmap formats prepend an uncompressed-size varint to the same 
bytes.
+        let source: Vec<u8> = (0..32768u32).map(|value| (value / 97) as 
u8).collect();
+        for compression in [
+            BlockCompressionType::Zstd,
+            BlockCompressionType::Lz4,
+            BlockCompressionType::Lzo,
+        ] {
+            let (with_prefix, kept) = compress_block(&source, compression, 
1).unwrap();
+            assert_eq!(kept, compression, "{compression:?} must stay 
compressed");
+            let (codec_only, kept) = compress_codec_block(&source, 
compression, 1).unwrap();
+            assert_eq!(kept, compression, "{compression:?} must stay 
compressed");
+
+            let mut prefix = Vec::new();
+            encode_var_int(&mut prefix, source.len() as i32).unwrap();
+            assert_eq!(
+                with_prefix.as_ref(),
+                [prefix.as_slice(), codec_only.as_ref()].concat(),
+                "{compression:?}"
+            );
+            assert_eq!(
+                decompress_codec_block(codec_only.as_ref(), compression, 
source.len()).unwrap(),
+                source,
+                "{compression:?}"
+            );
+            assert_eq!(
+                decompress_block(with_prefix.as_ref(), compression).unwrap(),
+                source,
+                "{compression:?}"
+            );
+        }
+    }
+
+    #[test]
+    fn codec_block_rejects_the_varint_prefixed_form() {
+        // Reading a Java FM block with the SST reader, or the reverse, must 
fail
+        // rather than silently return the wrong bytes.
+        let source = vec![7u8; 4096];
+        let (with_prefix, _) = compress_block(&source, 
BlockCompressionType::Lz4, 1).unwrap();
+        assert!(decompress_codec_block(
+            with_prefix.as_ref(),
+            BlockCompressionType::Lz4,
+            source.len()
+        )
+        .is_err());
+        let (codec_only, _) = compress_codec_block(&source, 
BlockCompressionType::Lz4, 1).unwrap();
+        assert!(decompress_block(codec_only.as_ref(), 
BlockCompressionType::Lz4).is_err());
     }
 
     #[test]
diff --git a/crates/paimon/src/btree/mod.rs b/crates/paimon/src/btree/mod.rs
index 37b7040a..ad66ddfe 100644
--- a/crates/paimon/src/btree/mod.rs
+++ b/crates/paimon/src/btree/mod.rs
@@ -48,7 +48,7 @@ mod writer;
 
 pub use block::BlockCompressionType;
 pub(crate) use block::{
-    compress_block, compute_crc32, decompress_block, 
decompress_block_with_expected_size,
+    compress_block, compress_codec_block, compute_crc32, decompress_block, 
decompress_codec_block,
 };
 pub use footer::BTreeFileFooter;
 pub use key_serde::{make_key_comparator, serialize_datum};
diff --git a/crates/paimon/src/fm_index/format.rs 
b/crates/paimon/src/fm_index/format.rs
index d4bba568..62dbbe2f 100644
--- a/crates/paimon/src/fm_index/format.rs
+++ b/crates/paimon/src/fm_index/format.rs
@@ -18,7 +18,7 @@
 //! Java-compatible V1 FM-index container format.
 
 use crate::btree::{
-    compress_block, compute_crc32, decompress_block_with_expected_size, 
BlockCompressionType,
+    compress_codec_block, compute_crc32, decompress_codec_block, 
BlockCompressionType,
 };
 use crate::io::FileRead;
 use std::io;
@@ -404,8 +404,11 @@ pub(crate) fn write_block(
     let offset = base_offset
         .checked_add(out.len() as u64)
         .ok_or_else(|| invalid_input("FM output offset overflow"))?;
+    // Java's `FMIndexFile#writeBlock` stores the compressor's output verbatim 
and
+    // records `storedLength = compressedLength`, so this format carries no 
outer
+    // uncompressed-size varint — unlike the SST and bitmap block formats.
     let (stored, actual_compression) =
-        compress_block(uncompressed, compression, compression_level)?;
+        compress_codec_block(uncompressed, compression, compression_level)?;
     let checksum = compute_crc32(stored.as_ref(), actual_compression);
     let stored_length = stored.len();
     out.extend_from_slice(stored.as_ref());
@@ -1078,7 +1081,7 @@ fn decode_stored_block(stored: &[u8], block: BlockInfo) 
-> io::Result<Vec<u8>> {
         }
         return Ok(stored.to_vec());
     }
-    decompress_block_with_expected_size(stored, block.compression, 
block.uncompressed_length)
+    decompress_codec_block(stored, block.compression, 
block.uncompressed_length)
 }
 
 fn validate_footer_common(bytes: &[u8], magic: u32, scope: &str) -> 
io::Result<()> {
@@ -1638,4 +1641,65 @@ mod tests {
             0x00c4_9e49
         );
     }
+
+    /// A block the size of a packed rank block, compressible enough that both
+    /// writers keep the compressed form (Java and Rust both require a 12.5% 
saving).
+    fn compressible_block() -> Vec<u8> {
+        (0..BLOCK_WORDS as u32 * 8)
+            .map(|i| (i / 97) as u8)
+            .collect()
+    }
+
+    /// What Java's `FMIndexFile#writeBlock` stores: `BlockCompressor#compress`
+    /// output verbatim. For LZ4 that is `[le32 compressedLength][le32 srcLen]`
+    /// followed by the payload; for ZSTD the bare frame.
+    fn java_stored_block(plain: &[u8], compression: BlockCompressionType) -> 
Vec<u8> {
+        match compression {
+            BlockCompressionType::Lz4 => {
+                let payload = lz4_flex::block::compress(plain);
+                let mut stored = Vec::with_capacity(8 + payload.len());
+                stored.extend_from_slice(&(payload.len() as 
i32).to_le_bytes());
+                stored.extend_from_slice(&(plain.len() as i32).to_le_bytes());
+                stored.extend_from_slice(&payload);
+                stored
+            }
+            BlockCompressionType::Zstd => zstd::bulk::compress(plain, 
1).unwrap(),
+            other => panic!("unsupported in this fixture: {other:?}"),
+        }
+    }
+
+    #[test]
+    fn stores_blocks_in_javas_codec_envelope() {
+        let plain = compressible_block();
+        for compression in [BlockCompressionType::Lz4, 
BlockCompressionType::Zstd] {
+            let expected = java_stored_block(&plain, compression);
+
+            // Read side: a block written by Java must decode.
+            let block = BlockInfo {
+                offset: 0,
+                stored_length: expected.len(),
+                uncompressed_length: plain.len(),
+                compression,
+                checksum: compute_crc32(&expected, compression),
+            };
+            assert_eq!(
+                decode_stored_block(&expected, block).unwrap(),
+                plain,
+                "{compression:?}"
+            );
+
+            // Write side: the bytes Rust stores must be the ones Java expects.
+            let mut out = Vec::new();
+            let info = write_block(&mut out, 0, &plain, compression, 
1).unwrap();
+            assert_eq!(info.compression, compression, "{compression:?}");
+            assert_eq!(info.stored_length, expected.len(), "{compression:?}");
+            assert_eq!(info.uncompressed_length, plain.len(), 
"{compression:?}");
+            assert_eq!(out, expected, "{compression:?}");
+            assert_eq!(
+                decode_stored_block(&out, info).unwrap(),
+                plain,
+                "{compression:?}"
+            );
+        }
+    }
 }

Reply via email to