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 782a6d80 Complete manifest codec support and typed partition specs
(#1170)
782a6d80 is described below
commit 782a6d8075a57e9f93508c20f5f19ab7c9f39818
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Oct 19 18:05:35 2026 +0800
Complete manifest codec support and typed partition specs (#1170)
---
Cargo.lock | 3 +
bindings/c/DEPENDENCIES.rust.tsv | 2 +
bindings/go/DEPENDENCIES.rust.tsv | 2 +
crates/integration_tests/DEPENDENCIES.rust.tsv | 2 +
crates/paimon-rest-server/DEPENDENCIES.rust.tsv | 2 +
crates/paimon/Cargo.toml | 3 +-
crates/paimon/DEPENDENCIES.rust.tsv | 2 +
crates/paimon/src/spec/avro/codec_tests.rs | 312 +++++++++++++++++++++
crates/paimon/src/spec/avro/mod.rs | 3 +
crates/paimon/src/spec/avro/ocf.rs | 36 ++-
crates/paimon/src/spec/objects_file.rs | 40 ++-
.../src/table/key_normalization/encoded_tests.rs | 76 +++++
crates/paimon/src/table/partition_spec.rs | 67 ++++-
.../testdata/avro_codec/GenerateCodecFixtures.java | 73 +++++
crates/paimon/testdata/avro_codec/README.md | 38 +++
.../paimon/testdata/avro_codec/fastavro_bzip2.avro | Bin 0 -> 478 bytes
crates/paimon/testdata/avro_codec/fastavro_xz.avro | Bin 0 -> 520 bytes
crates/paimon/testdata/avro_codec/generate.py | 35 +++
crates/paimon/testdata/avro_codec/java_bzip2.avro | Bin 0 -> 462 bytes
crates/paimon/testdata/avro_codec/java_xz.avro | Bin 0 -> 504 bytes
crates/paimon/testdata/avro_codec/records.avsc | 9 +
scripts/release_licenses.py | 2 +-
scripts/tests/test_release_licenses.py | 39 +++
23 files changed, 723 insertions(+), 23 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 61d3be37..d73fbb31 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -153,8 +153,10 @@ checksum =
"36fa98bc79671c7981272d91a8753a928ff6a1cd8e4f20a44c45bd5d313840bf"
dependencies = [
"bigdecimal",
"bon",
+ "bzip2",
"crc32fast",
"digest 0.10.7",
+ "liblzma",
"log",
"miniz_oxide",
"num-bigint",
@@ -4680,6 +4682,7 @@ dependencies = [
"lexical-write-float",
"libc",
"libloading 0.9.0",
+ "liblzma",
"log",
"lru 0.18.2",
"lz4_flex 0.13.1",
diff --git a/bindings/c/DEPENDENCIES.rust.tsv b/bindings/c/DEPENDENCIES.rust.tsv
index d3a70ef8..9dbbc6e9 100644
--- a/bindings/c/DEPENDENCIES.rust.tsv
+++ b/bindings/c/DEPENDENCIES.rust.tsv
@@ -181,6 +181,8 @@ [email protected] X
X
[email protected]
X
[email protected] X
X
[email protected]
X
[email protected] X
X
[email protected] X
X
[email protected]
X
[email protected] X X
X
[email protected]
X
diff --git a/bindings/go/DEPENDENCIES.rust.tsv
b/bindings/go/DEPENDENCIES.rust.tsv
index d3a70ef8..9dbbc6e9 100644
--- a/bindings/go/DEPENDENCIES.rust.tsv
+++ b/bindings/go/DEPENDENCIES.rust.tsv
@@ -181,6 +181,8 @@ [email protected] X
X
[email protected]
X
[email protected] X
X
[email protected]
X
[email protected] X
X
[email protected] X
X
[email protected]
X
[email protected] X X
X
[email protected]
X
diff --git a/crates/integration_tests/DEPENDENCIES.rust.tsv
b/crates/integration_tests/DEPENDENCIES.rust.tsv
index 3c350db3..4d16dab4 100644
--- a/crates/integration_tests/DEPENDENCIES.rust.tsv
+++ b/crates/integration_tests/DEPENDENCIES.rust.tsv
@@ -171,6 +171,8 @@ [email protected] X
X
[email protected]
X
[email protected] X
X
[email protected]
X
[email protected] X
X
[email protected] X
X
[email protected]
X
[email protected] X X
X
[email protected]
X
diff --git a/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
b/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
index 55d7caf6..1ef833b0 100644
--- a/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
+++ b/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
@@ -174,6 +174,8 @@ [email protected] X
X
[email protected]
X
[email protected] X
X
[email protected]
X
[email protected] X
X
[email protected] X
X
[email protected]
X
[email protected] X X
X
[email protected]
X
diff --git a/crates/paimon/Cargo.toml b/crates/paimon/Cargo.toml
index 31302b4a..34644e6c 100644
--- a/crates/paimon/Cargo.toml
+++ b/crates/paimon/Cargo.toml
@@ -103,7 +103,7 @@ opendal-service-obs = { version = "0.58.0", optional = true
}
opendal-service-oss = { version = "0.58.0", optional = true }
opendal-service-s3 = { version = "0.58.0", optional = true }
pretty_assertions = "1"
-apache-avro = { version = "0.21", features = ["snappy", "zstandard"] }
+apache-avro = { version = "0.21", features = ["snappy", "zstandard", "bzip",
"xz"] }
indexmap = "2.5.0"
lru = "0.18.2"
roaring = "0.11"
@@ -118,6 +118,7 @@ snap = "1"
lz4_flex = "0.13"
flate2 = "1"
bzip2 = "0.6"
+liblzma = { version = "0.4", default-features = false, features = ["static"] }
lzokay-native = { version = "0.1", default-features = false, features =
["decompress"] }
arrow-array = { workspace = true }
arrow-arith = { workspace = true }
diff --git a/crates/paimon/DEPENDENCIES.rust.tsv
b/crates/paimon/DEPENDENCIES.rust.tsv
index 3a0678c2..cceabf93 100644
--- a/crates/paimon/DEPENDENCIES.rust.tsv
+++ b/crates/paimon/DEPENDENCIES.rust.tsv
@@ -263,6 +263,8 @@ [email protected]
X
[email protected]
X
[email protected]
X
[email protected]
X
[email protected] X
X
[email protected] X
X
[email protected]
X
[email protected]
X
[email protected] X X
X
diff --git a/crates/paimon/src/spec/avro/codec_tests.rs
b/crates/paimon/src/spec/avro/codec_tests.rs
new file mode 100644
index 00000000..694055b1
--- /dev/null
+++ b/crates/paimon/src/spec/avro/codec_tests.rs
@@ -0,0 +1,312 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::cursor::AvroCursor;
+use super::ocf::{parse_ocf_streaming, OcfCodec};
+use crate::spec::objects_file::new_avro_writer;
+use apache_avro::types::Value;
+use apache_avro::{Reader, Schema};
+
+const FIXTURES: &[(&str, &[u8], OcfCodec)] = &[
+ (
+ "java/bzip2",
+ include_bytes!("../../../testdata/avro_codec/java_bzip2.avro"),
+ OcfCodec::Bzip2,
+ ),
+ (
+ "java/xz",
+ include_bytes!("../../../testdata/avro_codec/java_xz.avro"),
+ OcfCodec::Xz,
+ ),
+ (
+ "fastavro/bzip2",
+ include_bytes!("../../../testdata/avro_codec/fastavro_bzip2.avro"),
+ OcfCodec::Bzip2,
+ ),
+ (
+ "fastavro/xz",
+ include_bytes!("../../../testdata/avro_codec/fastavro_xz.avro"),
+ OcfCodec::Xz,
+ ),
+];
+
+fn expected_records() -> Vec<Value> {
+ [
+ (-7, "before-epoch".to_string(), vec![0, 1, 255]),
+ (0, "\u{4e8c}".to_string(), vec![]),
+ (
+ i64::MAX,
+ format!("end{}", "x".repeat(96)),
+ (0..32).collect(),
+ ),
+ ]
+ .into_iter()
+ .map(|(id, label, payload)| {
+ Value::Record(vec![
+ ("id".to_string(), Value::Long(id)),
+ ("label".to_string(), Value::String(label)),
+ ("payload".to_string(), Value::Bytes(payload)),
+ ])
+ })
+ .collect()
+}
+
+fn assert_blocks(bytes: &[u8], expected_codec: OcfCodec) {
+ let (header, mut blocks) = parse_ocf_streaming(bytes).unwrap();
+ assert_eq!(header.codec, expected_codec);
+ let schema = Schema::parse_str(&header.schema_json).unwrap();
+ let mut encoded_end = header.encoded_len;
+ for expected in expected_records() {
+ let block = blocks.next_block().unwrap().expect("missing OCF block");
+ assert_eq!(block.object_count, 1);
+ assert_eq!(block.encoded_offset, encoded_end);
+ encoded_end += block.encoded_len;
+ assert_eq!(&bytes[encoded_end - 16..encoded_end], &header.sync_marker);
+ let mut data = block.data.as_ref();
+ assert_eq!(
+ apache_avro::from_avro_datum(&schema, &mut data, None).unwrap(),
+ expected
+ );
+ assert!(data.is_empty(), "unconsumed bytes in decoded block");
+ }
+ assert!(blocks.next_block().unwrap().is_none());
+ assert_eq!(encoded_end, bytes.len());
+
+ // Both the streaming OCF reader and the reference reader must observe the
+ // same records, rather than merely accepting the codec metadata.
+ let reference = Reader::new(bytes)
+ .unwrap()
+ .collect::<apache_avro::AvroResult<Vec<_>>>()
+ .unwrap();
+ assert_eq!(reference, expected_records());
+
+ #[derive(Debug, PartialEq, serde::Deserialize)]
+ struct Record {
+ id: i64,
+ label: String,
+ #[serde(with = "serde_bytes")]
+ payload: Vec<u8>,
+ }
+ let generic = crate::spec::from_avro_bytes::<Record>(bytes).unwrap();
+ assert_eq!(
+ generic,
+ vec![
+ Record {
+ id: -7,
+ label: "before-epoch".to_string(),
+ payload: vec![0, 1, 255],
+ },
+ Record {
+ id: 0,
+ label: "\u{4e8c}".to_string(),
+ payload: vec![],
+ },
+ Record {
+ id: i64::MAX,
+ label: format!("end{}", "x".repeat(96)),
+ payload: (0..32).collect(),
+ },
+ ]
+ );
+}
+
+#[test]
+fn test_bzip2_xz_java_and_fastavro_blocks() {
+ for (name, bytes, codec) in FIXTURES {
+ assert_eq!(
+ parse_ocf_streaming(bytes).unwrap().0.sync_marker,
+ std::array::from_fn(|i| i as u8),
+ "{name}"
+ );
+ assert_blocks(bytes, *codec);
+ }
+}
+
+#[test]
+fn test_bzip2_xz_writer_multiple_blocks() {
+ let schema =
+
Schema::parse_str(include_str!("../../../testdata/avro_codec/records.avsc")).unwrap();
+ for (compression, codec) in [
+ ("bzip2", OcfCodec::Bzip2),
+ ("xz", OcfCodec::Xz),
+ ("BZIP2", OcfCodec::Bzip2),
+ ("XZ", OcfCodec::Xz),
+ ] {
+ let mut writer = new_avro_writer(&schema, compression, 1).unwrap();
+ for record in expected_records() {
+ writer.append(record).unwrap();
+ }
+ assert_blocks(&writer.into_inner().unwrap(), codec);
+ }
+}
+
+#[test]
+fn test_bzip2_xz_public_reader_resolves_named_records() {
+ #[derive(Debug, PartialEq, serde::Deserialize, serde::Serialize)]
+ struct Child {
+ id: i64,
+ }
+ #[derive(Debug, PartialEq, serde::Deserialize, serde::Serialize)]
+ struct Parent {
+ first: Child,
+ second: Option<Child>,
+ }
+ let schema = Schema::parse_str(
+ r#"{
+ "type": "record", "name": "Parent", "fields": [
+ {"name": "first", "type": {
+ "type": "record", "name": "Child",
+ "fields": [{"name": "id", "type": "long"}]
+ }},
+ {"name": "second", "type": ["null", "Child"]}
+ ]
+ }"#,
+ )
+ .unwrap();
+ let expected = vec![
+ Parent {
+ first: Child { id: -7 },
+ second: None,
+ },
+ Parent {
+ first: Child { id: i64::MIN },
+ second: Some(Child { id: i64::MAX }),
+ },
+ ];
+ for compression in ["null", "deflate", "snappy", "zstd", "bzip2", "xz"] {
+ let mut writer = new_avro_writer(&schema, compression, 1).unwrap();
+ for record in &expected {
+ writer
+ .append(
+ apache_avro::to_value(record)
+ .unwrap()
+ .resolve(&schema)
+ .unwrap(),
+ )
+ .unwrap();
+ }
+ let bytes = writer.into_inner().unwrap();
+ assert_eq!(
+ crate::spec::from_avro_bytes::<Parent>(&bytes).unwrap(),
+ expected,
+ "{compression}"
+ );
+ }
+}
+
+#[test]
+fn test_bzip2_xz_public_reader_empty_container_and_oversized_count() {
+ for (name, bytes, _) in FIXTURES {
+ let (header, _) = parse_ocf_streaming(bytes).unwrap();
+ assert!(
+
crate::spec::from_avro_bytes::<serde_json::Value>(&bytes[..header.encoded_len])
+ .unwrap()
+ .is_empty()
+ );
+
+ let mut cursor = AvroCursor::new(&bytes[header.encoded_len..]);
+ cursor.read_long().unwrap();
+ let mut changed = bytes[..header.encoded_len].to_vec();
+ write_long(i64::MAX as usize, &mut changed);
+ changed.extend_from_slice(&bytes[header.encoded_len +
cursor.position()..]);
+ // The untrusted count must not cause a capacity-overflow allocation.
+ // Decode the actual payload and fail when the block runs out of data.
+ assert!(
+
crate::spec::from_avro_bytes::<serde::de::IgnoredAny>(&changed).is_err(),
+ "{name}"
+ );
+ }
+}
+
+fn write_long(value: usize, output: &mut Vec<u8>) {
+ let mut encoded = (value as u64) << 1;
+ while encoded >= 0x80 {
+ output.push((encoded as u8) | 0x80);
+ encoded >>= 7;
+ }
+ output.push(encoded as u8);
+}
+
+fn replace_first_payload(bytes: &[u8], update: impl FnOnce(&mut Vec<u8>)) ->
Vec<u8> {
+ let (header, _) = parse_ocf_streaming(bytes).unwrap();
+ let mut cursor = AvroCursor::new(&bytes[header.encoded_len..]);
+ let count = cursor.read_long().unwrap() as usize;
+ let size = cursor.read_long().unwrap() as usize;
+ let mut payload = cursor.read_fixed(size).unwrap().to_vec();
+ update(&mut payload);
+ let mut changed = bytes[..header.encoded_len].to_vec();
+ write_long(count, &mut changed);
+ write_long(payload.len(), &mut changed);
+ changed.extend_from_slice(&payload);
+ // Preserve the rest of the container, including the original sync markers.
+ changed.extend_from_slice(&bytes[header.encoded_len +
cursor.position()..]);
+ changed
+}
+
+#[test]
+fn test_bzip2_xz_corrupt_and_truncated_compressed_blocks_return_errors() {
+ for (name, bytes, _) in FIXTURES {
+ for damage in [0, 1, 2] {
+ let corrupted = replace_first_payload(bytes, |payload| match
damage {
+ 0 => payload.clear(),
+ 1 => payload.truncate(payload.len() / 2),
+ 2 => payload[0] ^= 0xff,
+ _ => unreachable!(),
+ });
+ let (_, mut blocks) = parse_ocf_streaming(&corrupted).unwrap();
+ let error = blocks
+ .next_block()
+ .err()
+ .expect("invalid codec block accepted");
+ assert!(
+ error.to_string().contains("decompression failed"),
+ "{name}, damage {damage}: {error}"
+ );
+ let generic_error =
crate::spec::from_avro_bytes::<serde::de::IgnoredAny>(&corrupted)
+ .expect_err("public OCF reader accepted invalid codec block");
+ assert!(
+ generic_error.to_string().contains("decompression failed"),
+ "public OCF reader, {name}, damage {damage}: {generic_error}"
+ );
+ }
+ }
+}
+
+#[test]
+fn test_bzip2_xz_container_truncation_and_sync_mismatch_return_errors() {
+ for (name, bytes, _) in FIXTURES {
+ let (_, mut blocks) = parse_ocf_streaming(&bytes[..bytes.len() -
1]).unwrap();
+ assert!(blocks.next_block().unwrap().is_some());
+ assert!(blocks.next_block().unwrap().is_some());
+ assert!(blocks.next_block().is_err(), "{name}");
+
+ let mut corrupted = bytes.to_vec();
+ *corrupted.last_mut().unwrap() ^= 1;
+ let (_, mut blocks) = parse_ocf_streaming(&corrupted).unwrap();
+ assert!(blocks.next_block().unwrap().is_some());
+ assert!(blocks.next_block().unwrap().is_some());
+ assert!(
+ blocks
+ .next_block()
+ .err()
+ .unwrap()
+ .to_string()
+ .contains("sync marker mismatch"),
+ "{name}"
+ );
+ }
+}
diff --git a/crates/paimon/src/spec/avro/mod.rs
b/crates/paimon/src/spec/avro/mod.rs
index 490c234b..b89db19f 100644
--- a/crates/paimon/src/spec/avro/mod.rs
+++ b/crates/paimon/src/spec/avro/mod.rs
@@ -24,6 +24,9 @@ mod manifest_file_meta_decode;
pub mod ocf;
pub mod schema;
+#[cfg(test)]
+mod codec_tests;
+
use cursor::AvroCursor;
use decode::AvroRecordDecode;
use ocf::parse_ocf_streaming;
diff --git a/crates/paimon/src/spec/avro/ocf.rs
b/crates/paimon/src/spec/avro/ocf.rs
index 1004c23d..a6e9d1ed 100644
--- a/crates/paimon/src/spec/avro/ocf.rs
+++ b/crates/paimon/src/spec/avro/ocf.rs
@@ -39,6 +39,8 @@ pub enum OcfCodec {
Deflate,
Snappy,
Zstandard,
+ Bzip2,
+ Xz,
}
/// A single data block from the OCF.
@@ -117,16 +119,8 @@ impl<'a> OcfBlockIter<'a> {
OcfCodec::Deflate => {
// The Avro `deflate` codec is raw DEFLATE (RFC 1951), with no
// zlib or gzip wrapper.
- use std::io::Read;
- let mut decoder = flate2::read::DeflateDecoder::new(data);
- let mut decompressed = Vec::new();
- decoder
- .read_to_end(&mut decompressed)
- .map_err(|e| Error::UnexpectedError {
- message: format!("avro ocf: deflate decompression
failed: {e}"),
- source: None,
- })?;
- Ok(Cow::Owned(decompressed))
+ read_decompressed("deflate",
flate2::read::DeflateDecoder::new(data))
+ .map(Cow::Owned)
}
OcfCodec::Snappy => {
if data.len() < 4 {
@@ -163,10 +157,30 @@ impl<'a> OcfBlockIter<'a> {
})?;
Ok(Cow::Owned(decompressed))
}
+ // The apache-avro decoders unwrap I/O errors for these two codecs.
+ // Read them directly so corrupt manifests return an error instead
+ // of panicking during a scan or commit.
+ OcfCodec::Bzip2 => {
+ read_decompressed("bzip2",
bzip2::read::BzDecoder::new(data)).map(Cow::Owned)
+ }
+ OcfCodec::Xz => {
+ read_decompressed("xz",
liblzma::read::XzDecoder::new(data)).map(Cow::Owned)
+ }
}
}
}
+fn read_decompressed(codec: &str, mut decoder: impl std::io::Read) ->
crate::Result<Vec<u8>> {
+ let mut decompressed = Vec::new();
+ decoder
+ .read_to_end(&mut decompressed)
+ .map_err(|e| Error::UnexpectedError {
+ message: format!("avro ocf: {codec} decompression failed: {e}"),
+ source: None,
+ })?;
+ Ok(decompressed)
+}
+
/// Parse an Avro OCF header and return a streaming block iterator.
pub fn parse_ocf_streaming(bytes: &[u8]) -> crate::Result<(OcfHeader,
OcfBlockIter<'_>)> {
let mut cursor = AvroCursor::new(bytes);
@@ -194,6 +208,8 @@ pub fn parse_ocf_streaming(bytes: &[u8]) ->
crate::Result<(OcfHeader, OcfBlockIt
Some("deflate") => OcfCodec::Deflate,
Some("snappy") => OcfCodec::Snappy,
Some("zstandard") => OcfCodec::Zstandard,
+ Some("bzip2") => OcfCodec::Bzip2,
+ Some("xz") => OcfCodec::Xz,
Some(other) => {
return Err(Error::UnexpectedError {
message: format!("avro ocf: unsupported codec: {other}"),
diff --git a/crates/paimon/src/spec/objects_file.rs
b/crates/paimon/src/spec/objects_file.rs
index a4e60c4f..8116ae56 100644
--- a/crates/paimon/src/spec/objects_file.rs
+++ b/crates/paimon/src/spec/objects_file.rs
@@ -16,7 +16,8 @@
// under the License.
use apache_avro::{
- from_value, to_value, Codec, DeflateSettings, Reader, Schema, Writer,
ZstandardSettings,
+ from_avro_datum, from_value, to_value, Bzip2Settings, Codec,
DeflateSettings, Schema, Writer,
+ XzSettings, ZstandardSettings,
};
use serde::de::DeserializeOwned;
use serde::Serialize;
@@ -25,12 +26,17 @@ pub(crate) const DEFAULT_AVRO_BLOCK_SIZE: usize = 16_000;
pub(crate) const DEFAULT_AVRO_COMPRESSION: &str = "zstd";
pub fn from_avro_bytes<T: DeserializeOwned>(bytes: &[u8]) ->
crate::Result<Vec<T>> {
- Reader::new(bytes)?
- .map(|r| {
- let value = r?;
- from_value::<T>(&value).map_err(crate::Error::from)
- })
- .collect()
+ let (header, mut blocks) = super::avro::ocf::parse_ocf_streaming(bytes)?;
+ let schema = Schema::parse_str(&header.schema_json)?;
+ let mut records = Vec::new();
+ while let Some(block) = blocks.next_block()? {
+ let mut data = block.data.as_ref();
+ for _ in 0..block.object_count {
+ let value = from_avro_datum(&schema, &mut data, None)?;
+ records.push(from_value::<T>(&value)?);
+ }
+ }
+ Ok(records)
}
/// Serialize records into Avro Object Container File bytes.
@@ -74,6 +80,10 @@ pub(crate) fn avro_codec(compression: &str) ->
crate::Result<Codec> {
"null" | "none" | "uncompressed" => Ok(Codec::Null),
"deflate" => Ok(Codec::Deflate(DeflateSettings::default())),
"snappy" => Ok(Codec::Snappy),
+ "bzip2" => Ok(Codec::Bzip2(Bzip2Settings::default())),
+ // Java's CodecFactory.fromString("xz") uses level 6. The Rust Avro
+ // default is 9, which also needs a much larger compression dictionary.
+ "xz" => Ok(Codec::Xz(XzSettings::new(6))),
other => Err(crate::Error::Unsupported {
message: format!("Unsupported Avro compression: {other}"),
}),
@@ -91,6 +101,7 @@ mod tests {
use crate::spec::stats::BinaryTableStats;
use crate::spec::{DataFileMeta, IndexManifestEntry, ManifestFileMeta};
use apache_avro::types::Value;
+ use apache_avro::Reader;
use chrono::{DateTime, Utc};
// Check the record decoded from the OCF writer schema, including fields
whose
@@ -348,6 +359,21 @@ mod tests {
assert_eq!(original, decoded);
}
+ #[test]
+ fn test_roundtrip_manifest_entry_bzip2_and_xz() {
+ let original = vec![manifest_entry()];
+ for compression in ["bzip2", "xz"] {
+ let bytes =
+ to_avro_bytes_with_compression(MANIFEST_ENTRY_SCHEMA,
&original, compression)
+ .unwrap();
+ assert_eq!(from_avro_bytes::<ManifestEntry>(&bytes).unwrap(),
original);
+ assert_eq!(
+ from_avro_bytes_fast::<ManifestEntry>(&bytes).unwrap(),
+ original
+ );
+ }
+ }
+
#[test]
fn test_roundtrip_index_manifest_field_order() {
let entry: IndexManifestEntry =
serde_json::from_value(serde_json::json!({
diff --git a/crates/paimon/src/table/key_normalization/encoded_tests.rs
b/crates/paimon/src/table/key_normalization/encoded_tests.rs
index 0740e3b4..efaab155 100644
--- a/crates/paimon/src/table/key_normalization/encoded_tests.rs
+++ b/crates/paimon/src/table/key_normalization/encoded_tests.rs
@@ -209,6 +209,82 @@ fn
union_key_conversion_never_uses_lossy_assignment_or_text_parsing() {
assert!(normalize_match_key(&input.slice(2, 1),
&DataType::Int32).is_err());
}
+#[test]
+fn typed_null_string_keys_match_without_casting_unselected_dictionary_values()
{
+ let plain: ArrayRef = Arc::new(StringArray::from(vec![None::<&str>; 3]));
+ let dictionary: ArrayRef = Arc::new(
+ DictionaryArray::<Int8Type>::try_new(
+ Int8Array::from(vec![Some(0), Some(1), None, Some(1), Some(0)]),
+ Arc::new(StringArray::from(vec![Some("not an integer"), None])),
+ )
+ .unwrap(),
+ );
+ for input in [plain, dictionary.slice(1, 3)] {
+ let normalized = normalize_match_key(&input,
&DataType::Int32).unwrap();
+ assert_eq!(normalized.data_type(), &DataType::Int32);
+ assert_eq!(normalized.len(), 3);
+ assert_eq!(normalized.null_count(), 3);
+ }
+ assert!(normalize_match_key(&dictionary, &DataType::Int32).is_err());
+ let non_null: ArrayRef = Arc::new(StringArray::from(vec![None,
Some("1")]));
+ assert!(normalize_match_key(&non_null, &DataType::Int32).is_err());
+}
+
+#[test]
+fn all_null_union_keys_need_no_type_compatible_child() {
+ for dense in [true, false] {
+ let fields = UnionFields::try_new(
+ [7, 42],
+ [
+ Field::new("text", DataType::Utf8, true),
+ Field::new("bytes", DataType::Binary, true),
+ ],
+ )
+ .unwrap();
+ let input: ArrayRef = Arc::new(
+ UnionArray::try_new(
+ fields,
+ ScalarBuffer::from(vec![7, 42, 7]),
+ dense.then(|| ScalarBuffer::from(vec![0, 1, 1])),
+ if dense {
+ vec![
+ Arc::new(StringArray::from(vec![Some("not an
integer"), None])) as ArrayRef,
+
Arc::new(BinaryArray::from(vec![Some(b"inactive".as_slice()), None])),
+ ]
+ } else {
+ vec![
+ Arc::new(StringArray::from(vec![
+ Some("not an integer"),
+ Some("inactive"),
+ None,
+ ])) as ArrayRef,
+ Arc::new(BinaryArray::from(vec![
+ Some(b"inactive".as_slice()),
+ None,
+ Some(b"inactive".as_slice()),
+ ])),
+ ]
+ },
+ )
+ .unwrap(),
+ );
+ assert!(normalize_match_key(&input, &DataType::Int32).is_err());
+ let selected = input.slice(1, 2);
+ let dictionary: ArrayRef = Arc::new(
+ DictionaryArray::<Int8Type>::try_new(
+ Int8Array::from(vec![Some(1), None, Some(0)]),
+ selected.clone(),
+ )
+ .unwrap(),
+ );
+ for keys in [selected, dictionary] {
+ let normalized = normalize_match_key(&keys,
&DataType::Int32).unwrap();
+ assert_eq!(normalized.data_type(), &DataType::Int32);
+ assert_eq!(normalized.null_count(), keys.len());
+ }
+ }
+}
+
#[test]
fn typed_null_union_children_match_null_keys_without_casting_their_type() {
for dense in [true, false] {
diff --git a/crates/paimon/src/table/partition_spec.rs
b/crates/paimon/src/table/partition_spec.rs
index 1d1c8615..bcba1dae 100644
--- a/crates/paimon/src/table/partition_spec.rs
+++ b/crates/paimon/src/table/partition_spec.rs
@@ -96,6 +96,23 @@ pub fn parse_partition_spec_value(
| DataType::Decimal(_) => {}
_ => return Err(invalid()),
}
+ if matches!(data_type, DataType::Timestamp(ty) if ty.precision() == 0) {
+ let digits = text.strip_prefix('-').unwrap_or(text);
+ if !digits.is_empty() && digits.bytes().all(|byte|
byte.is_ascii_digit()) {
+ // Java converts numeric seconds with long multiplication. Overflow
+ // can leave subsecond milliseconds, which a partition Datum can
+ // retain even though an Arrow second-precision array cannot.
+ return text
+ .parse::<i64>()
+ .map(|seconds| {
+ Some(Datum::Timestamp {
+ millis: seconds.wrapping_mul(1_000),
+ nanos: 0,
+ })
+ })
+ .map_err(|_| invalid());
+ }
+ }
let input: ArrayRef = Arc::new(StringArray::from(vec![text]));
let array = crate::arrow::cast_array_for_schema_evolution(
&input,
@@ -163,14 +180,19 @@ fn parse_time_spec(text: &str) -> crate::Result<i32> {
}
None => (0, ""),
};
- // Java's fraction parser rounds using the fourth character.
+ // Java's fraction parser counts UTF-16 code units, including the fourth
+ // unit used for rounding. Supplementary characters occupy two positions.
let mut millis = 0;
- let mut chars = fraction.chars();
+ let mut chars = fraction.encode_utf16();
for multiplier in [100, 10, 1] {
let Some(ch) = chars.next() else { break };
- millis += ch.to_digit(10).filter(|_| ch.is_ascii()).unwrap_or(0) as
i32 * multiplier;
+ let digit = ch
+ .checked_sub(u16::from(b'0'))
+ .filter(|digit| *digit < 10)
+ .unwrap_or(0);
+ millis += i32::from(digit) * multiplier;
}
- if chars.next().is_some_and(|ch| ch >= '5') {
+ if chars.next().is_some_and(|ch| ch >= u16::from(b'5')) {
millis += 1;
}
Ok(hours
@@ -207,6 +229,43 @@ mod tests {
}
}
+ #[test]
+ fn time_partition_fractions_use_java_utf16_character_positions() {
+ let time = DataType::Time(TimeType::new(3).unwrap());
+ for (text, millis) in [
+ ("12:34:56.😀123", 45_296_001),
+ ("12:34:56.1😀23", 45_296_100),
+ ("12:34:56.12😀3", 45_296_121),
+ ("12:34:56.123😀", 45_296_124),
+ ] {
+ assert_eq!(
+ parse_partition_spec_value(text, &time).unwrap(),
+ Some(Datum::Time(millis)),
+ "{text}"
+ );
+ }
+ }
+
+ #[test]
+ fn
numeric_second_precision_partition_specs_keep_java_wrapped_milliseconds() {
+ let data_type = DataType::Timestamp(TimestampType::new(0).unwrap());
+ for (text, millis) in [
+ ("18446744073709552", 384),
+ ("-18446744073709552", -384),
+ ("9223372036854775807", -1000),
+ ("1", 1000),
+ ] {
+ assert_eq!(
+ parse_partition_spec_value(text, &data_type).unwrap(),
+ Some(Datum::Timestamp { millis, nanos: 0 }),
+ "{text}"
+ );
+ }
+ for text in ["9223372036854775808", "+1", "1.5"] {
+ assert!(parse_partition_spec_value(text, &data_type).is_err());
+ }
+ }
+
#[test]
fn partition_specs_use_java_typed_text_conversion() {
assert_eq!(
diff --git a/crates/paimon/testdata/avro_codec/GenerateCodecFixtures.java
b/crates/paimon/testdata/avro_codec/GenerateCodecFixtures.java
new file mode 100644
index 00000000..89305c2e
--- /dev/null
+++ b/crates/paimon/testdata/avro_codec/GenerateCodecFixtures.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import org.apache.avro.Schema;
+import org.apache.avro.file.CodecFactory;
+import org.apache.avro.file.DataFileWriter;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericDatumWriter;
+import org.apache.avro.generic.GenericRecord;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.nio.ByteBuffer;
+
+/** Produces the same records and block boundaries as generate.py. */
+public final class GenerateCodecFixtures {
+ public static void main(String[] args) throws Exception {
+ File directory = new File(args[0]);
+ Schema schema = new Schema.Parser().parse(new File(directory,
"records.avsc"));
+ byte[] sync = new byte[16];
+ for (int i = 0; i < sync.length; i++) {
+ sync[i] = (byte) i;
+ }
+ byte[] payload = new byte[32];
+ for (int i = 0; i < payload.length; i++) {
+ payload[i] = (byte) i;
+ }
+ for (String codec : new String[] {"bzip2", "xz"}) {
+ try (DataFileWriter<GenericRecord> writer =
+ new DataFileWriter<>(new GenericDatumWriter<>(schema))) {
+ writer.setCodec(CodecFactory.fromString(codec));
+ writer.create(
+ schema,
+ new FileOutputStream(new File(directory, "java_" +
codec + ".avro")),
+ sync);
+ append(writer, schema, -7L, "before-epoch", new byte[] {0, 1,
(byte) 255});
+ append(writer, schema, 0L, "\u4e8c", new byte[0]);
+ append(writer, schema, Long.MAX_VALUE,
+ "end" + new String(new char[96]).replace('\0', 'x'),
payload);
+ }
+ }
+ }
+
+ private static void append(
+ DataFileWriter<GenericRecord> writer,
+ Schema schema,
+ long id,
+ String label,
+ byte[] payload) throws Exception {
+ GenericRecord record = new GenericData.Record(schema);
+ record.put("id", id);
+ record.put("label", label);
+ record.put("payload", ByteBuffer.wrap(payload));
+ writer.append(record);
+ writer.sync();
+ }
+}
diff --git a/crates/paimon/testdata/avro_codec/README.md
b/crates/paimon/testdata/avro_codec/README.md
new file mode 100644
index 00000000..374683cb
--- /dev/null
+++ b/crates/paimon/testdata/avro_codec/README.md
@@ -0,0 +1,38 @@
+# Avro manifest codec fixtures
+
+These four Avro Object Container Files use the same three records and schema
+(`records.avsc`), with one record per block and sync marker bytes `00` through
+`0f`. They exercise the `bzip2` and `xz` codec names used by Java Paimon and
+PyPaimon manifests. The records cover a negative long, zero, `Long.MAX_VALUE`,
+UTF-8 text, empty bytes, and non-text bytes.
+
+Producers:
+
+- `java_*.avro`: Apache Avro Java **1.11.4**, the version in the Paimon Java
+ build, using `CodecFactory.fromString`. Generated with
+ `GenerateCodecFixtures.java`, JDK 8, Commons Compress 1.26.2, and XZ 1.9.
+- `fastavro_*.avro`: fastavro **1.12.2**, generated with `generate.py` using
+ the ordinary `fastavro.writer` API used by PyPaimon.
+
+The Rust tests decode every record, check block boundaries and encoded offsets,
+and check damaged compressed blocks and container sync markers. The fixture
+files are static, so running the Rust tests does not require Java or Python.
+
+To regenerate the Python fixtures:
+
+```sh
+python3 crates/paimon/testdata/avro_codec/generate.py
+```
+
+To regenerate the Java fixtures, set `AVRO_CP` to a classpath containing the
+Avro version and its dependencies above, plus Jackson and SLF4J, then run from
+the repository root:
+
+```sh
+mkdir -p /tmp/paimon-avro-codec-fixtures
+javac -source 8 -target 8 -encoding UTF-8 -cp "$AVRO_CP" \
+ -d /tmp/paimon-avro-codec-fixtures \
+ crates/paimon/testdata/avro_codec/GenerateCodecFixtures.java
+java -cp "/tmp/paimon-avro-codec-fixtures:$AVRO_CP" \
+ GenerateCodecFixtures crates/paimon/testdata/avro_codec
+```
diff --git a/crates/paimon/testdata/avro_codec/fastavro_bzip2.avro
b/crates/paimon/testdata/avro_codec/fastavro_bzip2.avro
new file mode 100644
index 00000000..f4425840
Binary files /dev/null and
b/crates/paimon/testdata/avro_codec/fastavro_bzip2.avro differ
diff --git a/crates/paimon/testdata/avro_codec/fastavro_xz.avro
b/crates/paimon/testdata/avro_codec/fastavro_xz.avro
new file mode 100644
index 00000000..2222a53c
Binary files /dev/null and b/crates/paimon/testdata/avro_codec/fastavro_xz.avro
differ
diff --git a/crates/paimon/testdata/avro_codec/generate.py
b/crates/paimon/testdata/avro_codec/generate.py
new file mode 100644
index 00000000..dddaf0c3
--- /dev/null
+++ b/crates/paimon/testdata/avro_codec/generate.py
@@ -0,0 +1,35 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Regenerate the fastavro fixtures, with one record per OCF block."""
+
+import json
+from pathlib import Path
+
+import fastavro
+
+fixture_dir = Path(__file__).resolve().parent
+schema = json.loads((fixture_dir / 'records.avsc').read_text())
+records = [
+ {'id': -7, 'label': 'before-epoch', 'payload': bytes([0, 1, 255])},
+ {'id': 0, 'label': '\u4e8c', 'payload': b''},
+ {'id': (1 << 63) - 1, 'label': 'end' + 'x' * 96, 'payload':
bytes(range(32))},
+]
+for codec in ['bzip2', 'xz']:
+ with (fixture_dir / ('fastavro_' + codec + '.avro')).open('wb') as output:
+ fastavro.writer(output, schema, records, codec=codec, sync_interval=1,
+ sync_marker=bytes(range(16)))
diff --git a/crates/paimon/testdata/avro_codec/java_bzip2.avro
b/crates/paimon/testdata/avro_codec/java_bzip2.avro
new file mode 100644
index 00000000..b3152eb5
Binary files /dev/null and b/crates/paimon/testdata/avro_codec/java_bzip2.avro
differ
diff --git a/crates/paimon/testdata/avro_codec/java_xz.avro
b/crates/paimon/testdata/avro_codec/java_xz.avro
new file mode 100644
index 00000000..21dddb8c
Binary files /dev/null and b/crates/paimon/testdata/avro_codec/java_xz.avro
differ
diff --git a/crates/paimon/testdata/avro_codec/records.avsc
b/crates/paimon/testdata/avro_codec/records.avsc
new file mode 100644
index 00000000..42e6fa04
--- /dev/null
+++ b/crates/paimon/testdata/avro_codec/records.avsc
@@ -0,0 +1,9 @@
+{
+ "type": "record",
+ "name": "CodecFixture",
+ "fields": [
+ {"name": "id", "type": "long"},
+ {"name": "label", "type": "string"},
+ {"name": "payload", "type": "bytes"}
+ ]
+}
diff --git a/scripts/release_licenses.py b/scripts/release_licenses.py
index 5785cf80..7b2fda97 100644
--- a/scripts/release_licenses.py
+++ b/scripts/release_licenses.py
@@ -186,7 +186,7 @@ BUNDLED_COMPONENTS = (
component_url="https://github.com/tukaani-project/xz/tree/v5.8.3",
license_name="BSD Zero Clause License",
anchor="bundled-xz-utils-0bsd",
- components=("python",),
+ components=("python", "go"),
required=True,
relationship="statically linked through",
crate_version="0.4.7",
diff --git a/scripts/tests/test_release_licenses.py
b/scripts/tests/test_release_licenses.py
index 2cfe1b8a..ca882fb6 100644
--- a/scripts/tests/test_release_licenses.py
+++ b/scripts/tests/test_release_licenses.py
@@ -22,7 +22,9 @@ from pathlib import Path
from scripts.release_licenses import (
LicenseCorrection,
PYTHON_MIT_CORRECTIONS,
+ Report,
binary_notice,
+ bundled_component_html,
correction_html,
resolved_packages,
)
@@ -217,5 +219,42 @@ class ResolvedPackagesTest(unittest.TestCase):
self.assertNotIn("Optional dependency notice", notice)
+class ManifestCodecLicenseTest(unittest.TestCase):
+ def test_python_and_go_reports_include_statically_linked_xz_license(self)
-> None:
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ crate_dir = root / "liblzma-sys"
+ license_path = crate_dir / "xz" / "COPYING.0BSD"
+ license_path.parent.mkdir(parents=True)
+ license_path.write_text("XZ Utils bundled license\n",
encoding="utf-8")
+ package = {
+ "name": "liblzma-sys",
+ "id": "liblzma-sys-package",
+ "version": "0.4.7",
+ "manifest_path": str(crate_dir / "Cargo.toml"),
+ }
+ metadata = {
+ "normal_packages": {("liblzma-sys", "0.4.7")},
+ "packages": [package],
+ "resolve": {
+ "nodes": [{"id": package["id"], "features": ["static"]}],
+ },
+ }
+ report_text = (
+ '<ul><li><a href="https://crates.io/crates/liblzma-sys">'
+ 'liblzma-sys 0.4.7</a></li></ul>'
+ )
+ for component in ("python", "go"):
+ with self.subTest(component=component):
+ report = Report(component, "", "aarch64-apple-darwin", "")
+ result = bundled_component_html(root, report, report_text,
metadata)
+ self.assertIn("XZ Utils bundled license", result)
+ self.assertIn("statically linked through", result)
+ metadata["resolve"]["nodes"][0]["features"] = []
+ with self.assertRaisesRegex(RuntimeError, "missing
required features"):
+ bundled_component_html(root, report, report_text,
metadata)
+ metadata["resolve"]["nodes"][0]["features"] = ["static"]
+
+
if __name__ == "__main__":
unittest.main()