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 59bc642a fix(datafusion): surface tag create-time and retention in 
$tags (#728)
59bc642a is described below

commit 59bc642a5a870d3aadc216745a88c5cded4e8b16
Author: jackylee <[email protected]>
AuthorDate: Thu Aug 20 10:53:19 2026 +0800

    fix(datafusion): surface tag create-time and retention in $tags (#728)
---
 .../datafusion/src/system_tables/tags.rs           |  81 ++++++-
 .../integrations/datafusion/tests/system_tables.rs |  82 ++++++-
 crates/paimon/src/table/tag_manager.rs             | 245 +++++++++++++++++++++
 docs/src/sql.md                                    |   4 +-
 4 files changed, 400 insertions(+), 12 deletions(-)

diff --git a/crates/integrations/datafusion/src/system_tables/tags.rs 
b/crates/integrations/datafusion/src/system_tables/tags.rs
index 5b9a22b4..09af4231 100644
--- a/crates/integrations/datafusion/src/system_tables/tags.rs
+++ b/crates/integrations/datafusion/src/system_tables/tags.rs
@@ -20,9 +20,7 @@
 use std::sync::{Arc, OnceLock};
 
 use async_trait::async_trait;
-use datafusion::arrow::array::{
-    new_null_array, Int64Array, RecordBatch, StringArray, 
TimestampMillisecondArray,
-};
+use datafusion::arrow::array::{Int64Array, RecordBatch, StringArray, 
TimestampMillisecondArray};
 use datafusion::arrow::datatypes::{DataType, Field, Schema, SchemaRef, 
TimeUnit};
 use datafusion::catalog::Session;
 use datafusion::datasource::memory::MemorySourceConfig;
@@ -86,9 +84,10 @@ impl TableProvider for TagsTable {
         _limit: Option<usize>,
     ) -> DFResult<Arc<dyn ExecutionPlan>> {
         let tm = self.table.tag_manager();
-        let tags = crate::runtime::await_with_runtime(async move { 
tm.list_all().await })
-            .await
-            .map_err(to_datafusion_error)?;
+        let tags =
+            crate::runtime::await_with_runtime(async move { 
tm.list_all_with_metadata().await })
+                .await
+                .map_err(to_datafusion_error)?;
 
         let n = tags.len();
         let mut tag_names: Vec<String> = Vec::with_capacity(n);
@@ -96,13 +95,17 @@ impl TableProvider for TagsTable {
         let mut schema_ids = Vec::with_capacity(n);
         let mut commit_times = Vec::with_capacity(n);
         let mut record_counts: Vec<Option<i64>> = Vec::with_capacity(n);
+        let mut create_times: Vec<Option<i64>> = Vec::with_capacity(n);
+        let mut time_retained: Vec<Option<String>> = Vec::with_capacity(n);
 
-        for (name, snap) in tags {
+        for (name, snap, created, retained) in tags {
             tag_names.push(name);
             snapshot_ids.push(snap.id());
             schema_ids.push(snap.schema_id());
             commit_times.push(snap.time_millis() as i64);
             record_counts.push(snap.total_record_count());
+            create_times.push(created);
+            time_retained.push(retained.map(format_duration_iso8601));
         }
 
         let schema = tags_schema();
@@ -114,8 +117,8 @@ impl TableProvider for TagsTable {
                 Arc::new(Int64Array::from(schema_ids)),
                 Arc::new(TimestampMillisecondArray::from(commit_times)),
                 Arc::new(Int64Array::from(record_counts)),
-                new_null_array(&DataType::Timestamp(TimeUnit::Millisecond, 
None), n),
-                new_null_array(&DataType::Utf8, n),
+                Arc::new(TimestampMillisecondArray::from(create_times)),
+                Arc::new(StringArray::from(time_retained)),
             ],
         )?;
 
@@ -126,3 +129,63 @@ impl TableProvider for TagsTable {
         )?)
     }
 }
+
+/// Render a retention in seconds the way Java's `Duration.toString()` does, so
+/// the column reads the same across engines: `PT72H`, `PT1M30S`, `PT0.5S`.
+fn format_duration_iso8601(total_seconds: f64) -> String {
+    if !total_seconds.is_finite() {
+        return "PT0S".to_string();
+    }
+    let negative = total_seconds < 0.0;
+    let magnitude = total_seconds.abs();
+    let whole = magnitude.trunc() as i64;
+    let fraction = magnitude - whole as f64;
+
+    let hours = whole / 3600;
+    let minutes = (whole % 3600) / 60;
+    let seconds = whole % 60;
+
+    let mut out = String::from(if negative { "-PT" } else { "PT" });
+    if hours != 0 {
+        out.push_str(&format!("{hours}H"));
+    }
+    if minutes != 0 {
+        out.push_str(&format!("{minutes}M"));
+    }
+    if seconds != 0 || fraction != 0.0 || (hours == 0 && minutes == 0) {
+        if fraction == 0.0 {
+            out.push_str(&format!("{seconds}S"));
+        } else {
+            // Java prints up to nanosecond precision with trailing zeros 
trimmed.
+            let rendered = format!("{:.9}", seconds as f64 + fraction);
+            out.push_str(rendered.trim_end_matches('0').trim_end_matches('.'));
+            out.push('S');
+        }
+    }
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::format_duration_iso8601;
+
+    #[test]
+    fn test_format_duration_iso8601_matches_java() {
+        // Whole units, mirroring java.time.Duration.toString().
+        assert_eq!(format_duration_iso8601(259_200.0), "PT72H");
+        assert_eq!(format_duration_iso8601(3600.0), "PT1H");
+        assert_eq!(format_duration_iso8601(90.0), "PT1M30S");
+        assert_eq!(format_duration_iso8601(60.0), "PT1M");
+        assert_eq!(format_duration_iso8601(1.0), "PT1S");
+        // Zero keeps the seconds component so the string is never bare "PT".
+        assert_eq!(format_duration_iso8601(0.0), "PT0S");
+        // Sub-second retention: trailing zeros trimmed.
+        assert_eq!(format_duration_iso8601(0.5), "PT0.5S");
+        assert_eq!(format_duration_iso8601(1.25), "PT1.25S");
+        // Mixed with larger units.
+        assert_eq!(format_duration_iso8601(3661.0), "PT1H1M1S");
+        // Not a number cannot panic or produce a bogus unit.
+        assert_eq!(format_duration_iso8601(f64::NAN), "PT0S");
+        assert_eq!(format_duration_iso8601(f64::INFINITY), "PT0S");
+    }
+}
diff --git a/crates/integrations/datafusion/tests/system_tables.rs 
b/crates/integrations/datafusion/tests/system_tables.rs
index ca32da12..267f3c97 100644
--- a/crates/integrations/datafusion/tests/system_tables.rs
+++ b/crates/integrations/datafusion/tests/system_tables.rs
@@ -22,7 +22,7 @@ mod common;
 use std::sync::Arc;
 
 use datafusion::arrow::array::{
-    Array, BooleanArray, Int32Array, Int64Array, ListArray, StringArray,
+    Array, BooleanArray, Int32Array, Int64Array, ListArray, StringArray, 
TimestampMillisecondArray,
 };
 use datafusion::arrow::datatypes::{DataType, Field, TimeUnit};
 use datafusion::arrow::record_batch::RecordBatch;
@@ -672,6 +672,86 @@ async fn test_tags_system_table_with_seeded_tags() {
     assert_eq!(snap_ids, vec![earliest.id(), earliest.id()]);
 }
 
+/// A tag file written by Java carries `tagCreateTime` and `tagTimeRetained`
+/// alongside the snapshot fields; both columns must surface those values 
instead
+/// of NULL. The retention is rendered as ISO-8601, matching Java's
+/// `Duration.toString()`.
+#[tokio::test]
+async fn test_tags_system_table_surfaces_create_time_and_retention() {
+    let (ctx, catalog, tmp) = create_context().await;
+
+    let identifier = Identifier::new("default".to_string(), 
FIXTURE_TABLE.to_string());
+    let table = catalog.get_table(&identifier).await.unwrap();
+    let sm =
+        paimon::table::SnapshotManager::new(table.file_io().clone(), 
table.location().to_string());
+    let earliest = sm.list_all().await.unwrap().into_iter().next().unwrap();
+
+    let table_dir = tmp.path().join("default.db").join(FIXTURE_TABLE);
+    let tag_dir = table_dir.join("tag");
+    std::fs::create_dir_all(&tag_dir).expect("create tag dir");
+    let src = table_dir
+        .join("snapshot")
+        .join(format!("snapshot-{}", earliest.id()));
+    let snapshot_json = std::fs::read_to_string(&src).unwrap();
+
+    // Jackson emits LocalDateTime as an array and Duration as decimal seconds.
+    let mut with_meta: serde_json::Value = 
serde_json::from_str(&snapshot_json).unwrap();
+    let map = with_meta.as_object_mut().unwrap();
+    map.insert(
+        "tagCreateTime".to_string(),
+        serde_json::json!([2024, 1, 2, 3, 4, 5]),
+    );
+    map.insert("tagTimeRetained".to_string(), serde_json::json!(259_200.0));
+    std::fs::write(
+        tag_dir.join("tag-with-meta"),
+        serde_json::to_string(&with_meta).unwrap(),
+    )
+    .unwrap();
+    // A tag without the fields keeps both columns NULL.
+    std::fs::copy(&src, tag_dir.join("tag-plain")).unwrap();
+
+    let sql = format!(
+        "SELECT tag_name, create_time, time_retained \
+         FROM paimon.default.{FIXTURE_TABLE}$tags ORDER BY tag_name"
+    );
+    let batches = run_sql(&ctx, &sql).await;
+
+    let mut rows: Vec<(String, Option<i64>, Option<String>)> = Vec::new();
+    for batch in &batches {
+        let names = batch
+            .column(0)
+            .as_any()
+            .downcast_ref::<StringArray>()
+            .expect("tag_name is Utf8");
+        let created = batch
+            .column(1)
+            .as_any()
+            .downcast_ref::<TimestampMillisecondArray>()
+            .expect("create_time is Timestamp(ms)");
+        let retained = batch
+            .column(2)
+            .as_any()
+            .downcast_ref::<StringArray>()
+            .expect("time_retained is Utf8");
+        for i in 0..batch.num_rows() {
+            rows.push((
+                names.value(i).to_string(),
+                (!created.is_null(i)).then(|| created.value(i)),
+                (!retained.is_null(i)).then(|| retained.value(i).to_string()),
+            ));
+        }
+    }
+
+    assert_eq!(rows.len(), 2);
+    assert_eq!(rows[0].0, "plain");
+    assert_eq!(rows[0].1, None, "a tag without the field stays NULL");
+    assert_eq!(rows[0].2, None);
+    assert_eq!(rows[1].0, "with-meta");
+    // 2024-01-02T03:04:05 UTC
+    assert_eq!(rows[1].1, Some(1_704_164_645_000));
+    assert_eq!(rows[1].2.as_deref(), Some("PT72H"));
+}
+
 #[tokio::test]
 async fn test_manifests_system_table() {
     let (ctx, catalog, _tmp) = create_context().await;
diff --git a/crates/paimon/src/table/tag_manager.rs 
b/crates/paimon/src/table/tag_manager.rs
index 113cf142..8bf1a384 100644
--- a/crates/paimon/src/table/tag_manager.rs
+++ b/crates/paimon/src/table/tag_manager.rs
@@ -95,6 +95,64 @@ impl TagManager {
         Ok(Some(snapshot))
     }
 
+    /// Get a tag's snapshot together with the two tag-only fields Java writes
+    /// alongside it: the creation time as epoch millis and the retention as
+    /// seconds. Returns `None` when the tag file does not exist; either 
metadata
+    /// field is `None` when absent or unparsable.
+    pub async fn get_with_metadata(
+        &self,
+        tag_name: &str,
+    ) -> crate::Result<Option<(Snapshot, Option<i64>, Option<f64>)>> {
+        let path = self.tag_path(tag_name);
+        let input = self.file_io.new_input(&path)?;
+        let bytes = match input.read().await {
+            Ok(b) => b,
+            Err(crate::Error::IoUnexpected { ref source, .. })
+                if source.kind() == opendal::ErrorKind::NotFound =>
+            {
+                return Ok(None);
+            }
+            Err(e) => return Err(e),
+        };
+        let value: serde_json::Value =
+            serde_json::from_slice(&bytes).map_err(|e| 
crate::Error::DataInvalid {
+                message: format!("tag '{tag_name}' JSON invalid: {e}"),
+                source: Some(Box::new(e)),
+            })?;
+        let snapshot: Snapshot =
+            serde_json::from_value(value.clone()).map_err(|e| 
crate::Error::DataInvalid {
+                message: format!("tag '{tag_name}' JSON invalid: {e}"),
+                source: Some(Box::new(e)),
+            })?;
+        let create_time = value
+            .get(FIELD_TAG_CREATE_TIME)
+            .and_then(parse_tag_create_time_millis);
+        let time_retained = value
+            .get(FIELD_TAG_TIME_RETAINED)
+            .and_then(serde_json::Value::as_f64);
+        Ok(Some((snapshot, create_time, time_retained)))
+    }
+
+    /// Like [`Self::list_all`], but each row also carries the tag creation 
time
+    /// in epoch millis and the retention in seconds.
+    #[allow(clippy::type_complexity)]
+    pub async fn list_all_with_metadata(
+        &self,
+    ) -> crate::Result<Vec<(String, Snapshot, Option<i64>, Option<f64>)>> {
+        let names = self.list_all_names().await?;
+        try_join_all(names.into_iter().map(|name| async move {
+            let (snap, create_time, retained) =
+                self.get_with_metadata(&name)
+                    .await?
+                    .ok_or_else(|| crate::Error::DataInvalid {
+                        message: format!("tag '{name}' disappeared during 
listing"),
+                        source: None,
+                    })?;
+            Ok::<_, crate::Error>((name, snap, create_time, retained))
+        }))
+        .await
+    }
+
     /// List all tag names sorted ascending. Returns an empty vector when the
     /// tag directory does not exist.
     pub async fn list_all_names(&self) -> crate::Result<Vec<String>> {
@@ -155,6 +213,43 @@ impl TagManager {
     }
 }
 
+/// Java `Tag` adds these two fields on top of the snapshot schema.
+const FIELD_TAG_CREATE_TIME: &str = "tagCreateTime";
+const FIELD_TAG_TIME_RETAINED: &str = "tagTimeRetained";
+
+/// Decode a Jackson-serialized `LocalDateTime` into epoch millis, treating the
+/// wall-clock value as UTC.
+///
+/// Jackson's `LocalDateTimeSerializer` emits
+/// `[year, month, day, hour, minute, second, nanoOfSecond]` and omits trailing
+/// zero components, so the array may hold as few as five items. Anything that 
is
+/// not such an array -- or that does not describe a real instant -- yields
+/// `None` so one odd tag file cannot fail the whole listing.
+fn parse_tag_create_time_millis(value: &serde_json::Value) -> Option<i64> {
+    let items = value.as_array()?;
+    if items.len() < 5 || items.len() > 7 {
+        return None;
+    }
+    let mut parts = [0i64; 7];
+    for (slot, item) in parts.iter_mut().zip(items) {
+        *slot = item.as_i64()?;
+    }
+    let [year, month, day, hour, minute, second, nano] = parts;
+
+    let date = chrono::NaiveDate::from_ymd_opt(
+        i32::try_from(year).ok()?,
+        u32::try_from(month).ok()?,
+        u32::try_from(day).ok()?,
+    )?;
+    let time = chrono::NaiveTime::from_hms_nano_opt(
+        u32::try_from(hour).ok()?,
+        u32::try_from(minute).ok()?,
+        u32::try_from(second).ok()?,
+        u32::try_from(nano).ok()?,
+    )?;
+    Some(date.and_time(time).and_utc().timestamp_millis())
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -207,6 +302,156 @@ mod tests {
         assert_eq!(tm.list_all_names().await.unwrap(), vec!["v1", "v2", "v3"]);
     }
 
+    /// Write a raw tag JSON built from a snapshot plus the two Java-only
+    /// fields, so the on-disk shape matches what Flink/Spark produce.
+    async fn write_tag_json(
+        file_io: &FileIO,
+        tm: &TagManager,
+        name: &str,
+        snapshot: &Snapshot,
+        extra: &[(&str, serde_json::Value)],
+    ) {
+        let mut value = serde_json::to_value(snapshot).unwrap();
+        let map = value.as_object_mut().unwrap();
+        for (key, v) in extra {
+            map.insert((*key).to_string(), v.clone());
+        }
+        let output = file_io.new_output(&tm.tag_path(name)).unwrap();
+        output
+            .write(Bytes::from(serde_json::to_vec(&value).unwrap()))
+            .await
+            .unwrap();
+    }
+
+    #[tokio::test]
+    async fn test_get_with_metadata_reads_java_fields() {
+        let file_io = test_file_io();
+        let table_path = "memory:/test_tag_meta".to_string();
+        file_io.mkdirs(&format!("{table_path}/tag/")).await.unwrap();
+        let tm = TagManager::new(file_io.clone(), table_path);
+
+        // Jackson writes LocalDateTime as [y, mo, d, h, mi, s, nano] and omits
+        // trailing zero components; Duration is decimal seconds.
+        write_tag_json(
+            &file_io,
+            &tm,
+            "full",
+            &test_snapshot(1),
+            &[
+                (
+                    "tagCreateTime",
+                    serde_json::json!([2024, 1, 2, 3, 4, 5, 123_000_000]),
+                ),
+                ("tagTimeRetained", serde_json::json!(259_200.0)),
+            ],
+        )
+        .await;
+
+        let (snap, create_time, retained) = 
tm.get_with_metadata("full").await.unwrap().unwrap();
+        assert_eq!(snap.id(), 1);
+        // 2024-01-02T03:04:05.123 UTC
+        assert_eq!(create_time, Some(1_704_164_645_123));
+        assert_eq!(retained, Some(259_200.0));
+    }
+
+    #[tokio::test]
+    async fn test_get_with_metadata_pads_truncated_time_array() {
+        let file_io = test_file_io();
+        let table_path = "memory:/test_tag_meta_short".to_string();
+        file_io.mkdirs(&format!("{table_path}/tag/")).await.unwrap();
+        let tm = TagManager::new(file_io.clone(), table_path);
+
+        // Jackson drops the trailing zero second and nano, leaving five items.
+        write_tag_json(
+            &file_io,
+            &tm,
+            "short",
+            &test_snapshot(2),
+            &[("tagCreateTime", serde_json::json!([2024, 1, 2, 3, 4]))],
+        )
+        .await;
+
+        let (_, create_time, retained) = 
tm.get_with_metadata("short").await.unwrap().unwrap();
+        // 2024-01-02T03:04:00 UTC
+        assert_eq!(create_time, Some(1_704_164_640_000));
+        assert_eq!(retained, None, "absent retention stays absent");
+    }
+
+    #[tokio::test]
+    async fn test_get_with_metadata_absent_fields_are_none() {
+        let file_io = test_file_io();
+        let table_path = "memory:/test_tag_meta_absent".to_string();
+        file_io.mkdirs(&format!("{table_path}/tag/")).await.unwrap();
+        let tm = TagManager::new(file_io.clone(), table_path);
+        write_tag(&file_io, &tm, "plain", &test_snapshot(3)).await;
+
+        let (snap, create_time, retained) = 
tm.get_with_metadata("plain").await.unwrap().unwrap();
+        assert_eq!(snap.id(), 3);
+        assert_eq!(create_time, None);
+        assert_eq!(retained, None);
+    }
+
+    /// A malformed shape must not fail the whole read: the snapshot still 
loads
+    /// and the unparsable field is reported as absent.
+    #[tokio::test]
+    async fn test_get_with_metadata_tolerates_bad_shapes() {
+        let file_io = test_file_io();
+        let table_path = "memory:/test_tag_meta_bad".to_string();
+        file_io.mkdirs(&format!("{table_path}/tag/")).await.unwrap();
+        let tm = TagManager::new(file_io.clone(), table_path);
+
+        for (name, extra) in [
+            (
+                "iso_string",
+                vec![("tagCreateTime", 
serde_json::json!("2024-01-02T03:04:05"))],
+            ),
+            (
+                "too_short",
+                vec![("tagCreateTime", serde_json::json!([2024, 1]))],
+            ),
+            (
+                "impossible_date",
+                vec![("tagCreateTime", serde_json::json!([2024, 13, 40, 3, 
4]))],
+            ),
+            (
+                "retained_string",
+                vec![("tagTimeRetained", serde_json::json!("PT72H"))],
+            ),
+        ] {
+            write_tag_json(&file_io, &tm, name, &test_snapshot(4), 
&extra).await;
+            let (snap, create_time, retained) = 
tm.get_with_metadata(name).await.unwrap().unwrap();
+            assert_eq!(snap.id(), 4, "{name}: snapshot must still load");
+            assert!(
+                create_time.is_none() && retained.is_none(),
+                "{name}: unparsable metadata must be reported as absent"
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn test_list_all_with_metadata_keeps_order() {
+        let file_io = test_file_io();
+        let table_path = "memory:/test_tag_meta_list".to_string();
+        file_io.mkdirs(&format!("{table_path}/tag/")).await.unwrap();
+        let tm = TagManager::new(file_io.clone(), table_path);
+
+        write_tag_json(
+            &file_io,
+            &tm,
+            "a",
+            &test_snapshot(1),
+            &[("tagTimeRetained", serde_json::json!(60.0))],
+        )
+        .await;
+        write_tag(&file_io, &tm, "b", &test_snapshot(2)).await;
+
+        let rows = tm.list_all_with_metadata().await.unwrap();
+        let names: Vec<&str> = rows.iter().map(|(n, _, _, _)| 
n.as_str()).collect();
+        assert_eq!(names, vec!["a", "b"]);
+        assert_eq!(rows[0].3, Some(60.0));
+        assert_eq!(rows[1].3, None);
+    }
+
     #[tokio::test]
     async fn test_list_all_loads_pairs() {
         let file_io = test_file_io();
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 230cd067..34a033e9 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1870,8 +1870,8 @@ Columns:
 | `schema_id` | BIGINT | Schema ID |
 | `commit_time` | TIMESTAMP | Commit time |
 | `record_count` | BIGINT | Record count |
-| `create_time` | TIMESTAMP | Always `NULL`: the Rust snapshot does not carry 
a tag creation time |
-| `time_retained` | STRING | Always `NULL`: the Rust snapshot does not carry a 
tag retention |
+| `create_time` | TIMESTAMP | Tag creation time; `NULL` for tags written 
without one |
+| `time_retained` | STRING | Tag retention as an ISO-8601 duration (for 
example `PT72H`); `NULL` for tags written without one |
 
 ### $branches
 

Reply via email to