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 be6d5094 [core] Align FileType classification and mutability with Java
(#1013)
be6d5094 is described below
commit be6d5094c856b4c76967137dc1be73d0e52efa8f
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Sat Oct 3 18:06:47 2026 +0800
[core] Align FileType classification and mutability with Java (#1013)
---
crates/paimon/src/io/cache/file_type.rs | 160 ++++++++++++++++++++++++++------
crates/paimon/src/io/cache/mod.rs | 2 +
crates/paimon/src/io/file_io.rs | 2 +-
docs/src/getting-started.md | 2 +-
4 files changed, 138 insertions(+), 28 deletions(-)
diff --git a/crates/paimon/src/io/cache/file_type.rs
b/crates/paimon/src/io/cache/file_type.rs
index 2db0ae5a..e01a6e15 100644
--- a/crates/paimon/src/io/cache/file_type.rs
+++ b/crates/paimon/src/io/cache/file_type.rs
@@ -15,8 +15,12 @@
// specific language governing permissions and limitations
// under the License.
+use crate::spec::MANIFEST_SIDECAR_SUFFIX;
use std::collections::HashSet;
+// Suffix `.{uuid}.tmp` appended by Java `Path.createTempPath()`: 1 dot +
36-char UUID + `.tmp`.
+const TEMP_FILE_SUFFIX_LEN: usize = 41;
+
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(super) enum FileType {
Meta,
@@ -27,8 +31,18 @@ pub(super) enum FileType {
}
impl FileType {
+ const ALL: [Self; 5] = [
+ Self::Meta,
+ Self::Data,
+ Self::BucketIndex,
+ Self::GlobalIndex,
+ Self::FileIndex,
+ ];
+
+ /// Mirrors Java `org.apache.paimon.utils.FileType#classify`, including
its check order.
pub(super) fn classify(path: &str) -> Self {
- let name = path.rsplit('/').next().unwrap_or(path);
+ let mut segments = path.rsplit('/');
+ let name = unwrap_temp_file_name(segments.next().unwrap_or(path));
if name.starts_with("snapshot-")
|| name.starts_with("schema-")
@@ -36,7 +50,6 @@ impl FileType {
|| name.starts_with("tag-")
|| name.starts_with("consumer-")
|| name.starts_with("service-")
- || name.contains("manifest")
{
return Self::Meta;
}
@@ -49,16 +62,34 @@ impl FileType {
};
}
+ if name.contains("manifest") ||
name.ends_with(MANIFEST_SIDECAR_SUFFIX) {
+ return Self::Meta;
+ }
+
if name.starts_with("index-") {
return Self::BucketIndex;
}
+ if matches!(name, "LATEST" | "EARLIEST") || name.ends_with("_SUCCESS")
{
+ return Self::Meta;
+ }
+
+ if name.starts_with("changelog-") && segments.next() ==
Some("changelog") {
+ return Self::Meta;
+ }
+
Self::Data
}
pub(super) fn is_mutable(path: &str) -> bool {
let name = path.rsplit('/').next().unwrap_or(path);
- matches!(name, "LATEST" | "EARLIEST")
+ // Iceberg-compatible `version-hint.text`, `retire-pending` and
`v{N}.metadata.json`
+ // (on tag changes) are rewritten in place.
+ matches!(
+ name,
+ "LATEST" | "EARLIEST" | "version-hint.text" | "retire-pending"
+ ) || name.ends_with(".metadata.json")
+ || name.ends_with("_SUCCESS")
|| name.starts_with("tag-")
|| name.starts_with("consumer-")
|| name.starts_with("service-")
@@ -70,26 +101,44 @@ impl FileType {
pub(super) fn parse_whitelist(value: &str) -> HashSet<Self> {
value
.split(',')
- .filter_map(|name| match name.trim() {
- "meta" => Some(Self::Meta),
- "global-index" => Some(Self::GlobalIndex),
- "bucket-index" => Some(Self::BucketIndex),
- "data" => Some(Self::Data),
- "file-index" => Some(Self::FileIndex),
- "" => None,
- unknown => {
- log::warn!(
- "Unknown local-cache.whitelist value '{}'; supported
values are \
- meta, global-index, bucket-index, data, file-index",
- unknown
- );
- None
+ .flat_map(|name| -> &'static [Self] {
+ match name.trim() {
+ "*" => &Self::ALL,
+ "meta" => &[Self::Meta],
+ "global-index" => &[Self::GlobalIndex],
+ "bucket-index" => &[Self::BucketIndex],
+ "data" => &[Self::Data],
+ "file-index" => &[Self::FileIndex],
+ "" => &[],
+ unknown => {
+ log::warn!(
+ "Unknown local-cache.whitelist value '{}';
supported values are \
+ meta, global-index, bucket-index, data,
file-index, \
+ or * for all of them",
+ unknown
+ );
+ &[]
+ }
}
})
+ .copied()
.collect()
}
}
+/// Returns `originalName` for a Java temp name `.{originalName}.{uuid}.tmp`,
else `name`.
+fn unwrap_temp_file_name(name: &str) -> &str {
+ let bytes = name.as_bytes();
+ if bytes.len() < TEMP_FILE_SUFFIX_LEN + 2 || bytes[0] != b'.' ||
!name.ends_with(".tmp") {
+ return name;
+ }
+ let dot_before_uuid = bytes.len() - TEMP_FILE_SUFFIX_LEN;
+ if bytes[dot_before_uuid] != b'.' {
+ return name;
+ }
+ &name[1..dot_before_uuid]
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -121,6 +170,32 @@ mod tests {
"s3://bucket/table/bucket-0/data-abc.parquet",
FileType::Data,
),
+ (
+
"s3://bucket/table/manifest/manifest-123e4567-e89b-12d3-a456-426614174000-0.avro.sidecar",
+ FileType::Meta,
+ ),
+ // `.index` is checked before `manifest`, as in Java.
+ (
+ "s3://bucket/table/index/manifest-global-index-abc.index",
+ FileType::GlobalIndex,
+ ),
+ ("s3://bucket/table/changelog/changelog-5", FileType::Meta),
+ (
+
"s3://bucket/table/bucket-0/changelog-123e4567-e89b-12d3-a456-426614174000-0.parquet",
+ FileType::Data,
+ ),
+ (
+
"s3://bucket/table/snapshot/.snapshot-13.123e4567-e89b-12d3-a456-426614174000.tmp",
+ FileType::Meta,
+ ),
+ (
+
"s3://bucket/table/changelog/.changelog-5.123e4567-e89b-12d3-a456-426614174000.tmp",
+ FileType::Meta,
+ ),
+ (
+
"s3://bucket/table/bucket-0/.data-abc.parquet.123e4567-e89b-12d3-a456-426614174000.tmp",
+ FileType::Data,
+ ),
];
for (path, expected) in cases {
@@ -128,6 +203,24 @@ mod tests {
}
}
+ #[test]
+ fn test_file_type_classifies_mutable_markers_as_meta() {
+ for path in [
+ "s3://bucket/table/snapshot/LATEST",
+ "s3://bucket/table/snapshot/EARLIEST",
+ "s3://bucket/table/changelog/LATEST",
+ "s3://bucket/table/changelog/EARLIEST",
+ "s3://bucket/table/branch/branch-dev/snapshot/LATEST",
+ "s3://bucket/table/branch/branch-dev/snapshot/EARLIEST",
+ "s3://bucket/table/dt=1/_SUCCESS",
+ "s3://bucket/table/tag/tag-success-file/t1_SUCCESS",
+
"s3://bucket/table/snapshot/.snapshot-13.123e4567-e89b-12d3-a456-426614174000.tmp",
+ ] {
+ assert_eq!(FileType::classify(path), FileType::Meta, "{path}");
+ assert!(FileType::is_mutable(path), "{path}");
+ }
+ }
+
#[test]
fn test_file_type_bypasses_mutable_paths() {
for path in [
@@ -138,28 +231,43 @@ mod tests {
"s3://bucket/table/service/service-api",
"s3://bucket/table/snapshot/.snapshot-1.123e4567-e89b-12d3-a456-426614174000.tmp",
"s3://bucket/table/snapshot/snapshot-1.tmp-123e4567-e89b-12d3-a456-426614174000",
+ "s3://bucket/table/dt=1/_SUCCESS",
+ "s3://bucket/table/tag/tag-success-file/t1_SUCCESS",
+ "s3://bucket/table/metadata/version-hint.text",
+ "s3://bucket/table/metadata/retire-pending",
+ "s3://bucket/table/metadata/v3.metadata.json",
] {
assert!(FileType::is_mutable(path), "{path}");
}
- assert!(!FileType::is_mutable(
- "s3://bucket/table/snapshot/snapshot-1"
- ));
+ for path in [
+ "s3://bucket/table/snapshot/snapshot-1",
+ "s3://bucket/table/changelog/changelog-5",
+
"s3://bucket/table/metadata/snap-1-1-123e4567-e89b-12d3-a456-426614174000.avro",
+ ] {
+ assert!(!FileType::is_mutable(path), "{path}");
+ }
}
#[test]
fn test_file_type_parses_whitelist() {
- let whitelist =
- FileType::parse_whitelist(" meta,global-index,
bucket-index,data,file-index,unknown ");
-
- assert_eq!(whitelist.len(), 5);
- for file_type in [
+ let all = HashSet::from([
FileType::Meta,
FileType::GlobalIndex,
FileType::BucketIndex,
FileType::Data,
FileType::FileIndex,
+ ]);
+
+ for value in [
+ " meta,global-index, bucket-index,data,file-index,unknown ",
+ "*",
+ " meta , * ",
] {
- assert!(whitelist.contains(&file_type));
+ assert_eq!(FileType::parse_whitelist(value), all, "{value}");
}
+ assert_eq!(
+ FileType::parse_whitelist("meta,global-index"),
+ HashSet::from([FileType::Meta, FileType::GlobalIndex])
+ );
}
}
diff --git a/crates/paimon/src/io/cache/mod.rs
b/crates/paimon/src/io/cache/mod.rs
index 3f661a47..061cec19 100644
--- a/crates/paimon/src/io/cache/mod.rs
+++ b/crates/paimon/src/io/cache/mod.rs
@@ -546,6 +546,8 @@ mod tests {
assert!(!cache.is_cacheable("s3://bucket/table/data/data-1.parquet"));
assert!(!cache.is_cacheable("s3://bucket/table/snapshot/LATEST"));
assert!(!cache.is_cacheable("s3://bucket/table/tag/tag-production"));
+ assert!(cache.is_cacheable("s3://bucket/table/changelog/changelog-1"));
+ assert!(!cache.is_cacheable("s3://bucket/table/dt=1/_SUCCESS"));
}
#[test]
diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs
index 67038518..1f370b5a 100644
--- a/crates/paimon/src/io/file_io.rs
+++ b/crates/paimon/src/io/file_io.rs
@@ -178,7 +178,7 @@ impl FileIO {
/// `block_size` controls the aligned ranges presented to the cache.
/// `whitelist` uses the same comma-separated values as
/// `local-cache.whitelist`: `meta`, `global-index`, `bucket-index`,
`data`,
- /// and `file-index`.
+ /// and `file-index`, or `*` for all of them.
pub fn with_file_block_cache(
mut self,
cache: Arc<dyn FileBlockCache>,
diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md
index fa5f6434..0993d198 100644
--- a/docs/src/getting-started.md
+++ b/docs/src/getting-started.md
@@ -346,7 +346,7 @@ let catalog = CatalogFactory::create(options).await?;
| `local-cache.dir` | none | Optional base directory. When set, Paimon uses a
persistent disk cache in a private versioned child directory; otherwise it uses
memory. |
| `local-cache.max-size` | unlimited | Maximum cache size. Memory caches count
payload bytes; disk caches count encoded bytes. Values accept byte units such
as `512 MiB` or `20 GiB`. |
| `local-cache.block-size` | `1 MiB` | Block size used for cached range reads.
|
-| `local-cache.whitelist` | `meta,global-index` | Comma-separated eligible
types: `meta`, `global-index`, `bucket-index`, `data`, and `file-index`. |
+| `local-cache.whitelist` | `meta,global-index` | Comma-separated eligible
types: `meta`, `global-index`, `bucket-index`, `data`, and `file-index`, or `*`
for all of them. |
Each catalog owns its in-memory cache for the catalog's lifetime. Disk caches
are reused after process restarts. Cache keys include a catalog-configuration