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 b761a744 feat(datafusion): support the $consumers system table (#782)
b761a744 is described below

commit b761a744f543c819a90e3436b18df29a6b528d6c
Author: jerry <[email protected]>
AuthorDate: Thu Sep 3 17:34:11 2026 +0800

    feat(datafusion): support the $consumers system table (#782)
---
 .../datafusion/src/system_tables/consumers.rs      | 190 +++++++++++++++++++
 .../datafusion/src/system_tables/mod.rs            |   6 +
 .../integrations/datafusion/tests/system_tables.rs |  87 +++++++++
 crates/paimon/src/io/storage.rs                    |  11 +-
 crates/paimon/src/table/consumer_manager.rs        | 209 +++++++++++++++++++++
 crates/paimon/src/table/mod.rs                     |  11 ++
 docs/src/sql.md                                    |  15 ++
 7 files changed, 528 insertions(+), 1 deletion(-)

diff --git a/crates/integrations/datafusion/src/system_tables/consumers.rs 
b/crates/integrations/datafusion/src/system_tables/consumers.rs
new file mode 100644
index 00000000..b8a33d4d
--- /dev/null
+++ b/crates/integrations/datafusion/src/system_tables/consumers.rs
@@ -0,0 +1,190 @@
+// 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.
+
+//! Mirrors Java 
[ConsumersTable](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/table/system/ConsumersTable.java).
+
+use std::collections::HashSet;
+use std::sync::{Arc, OnceLock};
+
+use async_trait::async_trait;
+use datafusion::arrow::array::{Int64Array, RecordBatch, StringArray};
+use datafusion::arrow::datatypes::{DataType, Field, Schema, SchemaRef};
+use datafusion::catalog::Session;
+use datafusion::common::ScalarValue;
+use datafusion::datasource::memory::MemorySourceConfig;
+use datafusion::datasource::{TableProvider, TableType};
+use datafusion::error::Result as DFResult;
+use datafusion::logical_expr::{Expr, Operator, TableProviderFilterPushDown};
+use datafusion::physical_plan::ExecutionPlan;
+use paimon::table::Table;
+
+use crate::error::to_datafusion_error;
+
+pub(super) fn build(table: Table) -> DFResult<Arc<dyn TableProvider>> {
+    Ok(Arc::new(ConsumersTable { table }))
+}
+
+fn consumers_schema() -> SchemaRef {
+    static SCHEMA: OnceLock<SchemaRef> = OnceLock::new();
+    SCHEMA
+        .get_or_init(|| {
+            Arc::new(Schema::new(vec![
+                Field::new("consumer_id", DataType::Utf8, false),
+                Field::new("next_snapshot_id", DataType::Int64, false),
+            ]))
+        })
+        .clone()
+}
+
+#[derive(Debug)]
+struct ConsumersTable {
+    table: Table,
+}
+
+#[async_trait]
+impl TableProvider for ConsumersTable {
+    fn schema(&self) -> SchemaRef {
+        consumers_schema()
+    }
+
+    fn table_type(&self) -> TableType {
+        TableType::View
+    }
+
+    async fn scan(
+        &self,
+        _state: &dyn Session,
+        projection: Option<&Vec<usize>>,
+        filters: &[Expr],
+        _limit: Option<usize>,
+    ) -> DFResult<Arc<dyn ExecutionPlan>> {
+        let manager = self.table.consumer_manager();
+        let requested_ids = requested_consumer_ids(filters);
+        let consumers = crate::runtime::await_with_runtime(async move {
+            match requested_ids {
+                Some(ids) => manager.list_by_ids(&ids).await,
+                None => manager.list_all().await,
+            }
+        })
+        .await
+        .map_err(to_datafusion_error)?;
+
+        let schema = consumers_schema();
+        let batch = RecordBatch::try_new(
+            schema.clone(),
+            vec![
+                Arc::new(StringArray::from_iter_values(
+                    consumers.iter().map(|(id, _)| id.as_str()),
+                )),
+                Arc::new(Int64Array::from_iter_values(
+                    consumers.iter().map(|(_, next_snapshot)| *next_snapshot),
+                )),
+            ],
+        )?;
+
+        Ok(MemorySourceConfig::try_new_exec(
+            &[vec![batch]],
+            schema,
+            projection.cloned(),
+        )?)
+    }
+
+    fn supports_filters_pushdown(
+        &self,
+        filters: &[&Expr],
+    ) -> DFResult<Vec<TableProviderFilterPushDown>> {
+        Ok(filters
+            .iter()
+            .map(|filter| {
+                if consumer_ids_from_filter(filter).is_some() {
+                    TableProviderFilterPushDown::Inexact
+                } else {
+                    TableProviderFilterPushDown::Unsupported
+                }
+            })
+            .collect())
+    }
+}
+
+fn requested_consumer_ids(filters: &[Expr]) -> Option<Vec<String>> {
+    let ids = filters
+        .iter()
+        .filter_map(consumer_ids_from_filter)
+        .reduce(|mut left, right| {
+            left.retain(|id| right.contains(id));
+            left
+        })?;
+    let mut ids = ids.into_iter().collect::<Vec<_>>();
+    ids.sort_unstable();
+    Some(ids)
+}
+
+fn consumer_ids_from_filter(filter: &Expr) -> Option<HashSet<String>> {
+    match filter {
+        Expr::BinaryExpr(binary) if binary.op == Operator::Eq => {
+            consumer_id_literal(binary.left.as_ref(), binary.right.as_ref())
+                .or_else(|| consumer_id_literal(binary.right.as_ref(), 
binary.left.as_ref()))
+                .map(|id| HashSet::from([id]))
+        }
+        Expr::BinaryExpr(binary) if binary.op == Operator::And => {
+            match (
+                consumer_ids_from_filter(binary.left.as_ref()),
+                consumer_ids_from_filter(binary.right.as_ref()),
+            ) {
+                (Some(mut left), Some(right)) => {
+                    left.retain(|id| right.contains(id));
+                    Some(left)
+                }
+                (Some(ids), None) | (None, Some(ids)) => Some(ids),
+                (None, None) => None,
+            }
+        }
+        Expr::BinaryExpr(binary) if binary.op == Operator::Or => {
+            let mut left = consumer_ids_from_filter(binary.left.as_ref())?;
+            left.extend(consumer_ids_from_filter(binary.right.as_ref())?);
+            Some(left)
+        }
+        Expr::InList(in_list)
+            if !in_list.negated && 
is_consumer_id_column(in_list.expr.as_ref()) =>
+        {
+            in_list.list.iter().map(string_literal).collect()
+        }
+        _ => None,
+    }
+}
+
+fn consumer_id_literal(column: &Expr, literal: &Expr) -> Option<String> {
+    is_consumer_id_column(column)
+        .then_some(literal)
+        .and_then(string_literal)
+}
+
+fn is_consumer_id_column(expr: &Expr) -> bool {
+    matches!(expr, Expr::Column(column) if column.name == "consumer_id")
+}
+
+fn string_literal(expr: &Expr) -> Option<String> {
+    match expr {
+        Expr::Literal(
+            ScalarValue::Utf8(Some(value))
+            | ScalarValue::LargeUtf8(Some(value))
+            | ScalarValue::Utf8View(Some(value)),
+            _,
+        ) => Some(value.clone()),
+        _ => None,
+    }
+}
diff --git a/crates/integrations/datafusion/src/system_tables/mod.rs 
b/crates/integrations/datafusion/src/system_tables/mod.rs
index 392db7da..3b3a0b09 100644
--- a/crates/integrations/datafusion/src/system_tables/mod.rs
+++ b/crates/integrations/datafusion/src/system_tables/mod.rs
@@ -30,6 +30,7 @@ use paimon::table::Table;
 use crate::error::to_datafusion_error;
 
 mod branches;
+mod consumers;
 mod files;
 mod manifests;
 mod options;
@@ -49,6 +50,7 @@ type Builder = fn(Table) -> DFResult<Arc<dyn TableProvider>>;
 // metadata via `Catalog::list_partitions`).
 const TABLES: &[(&str, Builder)] = &[
     ("branches", branches::build),
+    ("consumers", consumers::build),
     ("files", files::build),
     ("manifests", manifests::build),
     ("options", options::build),
@@ -62,6 +64,7 @@ const TABLES: &[(&str, Builder)] = &[
 
 const SYSTEM_TABLE_NAMES: &[&str] = &[
     "branches",
+    "consumers",
     "files",
     "manifests",
     "options",
@@ -195,6 +198,9 @@ mod tests {
         assert!(is_registered("branches"));
         assert!(is_registered("Branches"));
         assert!(is_registered("BRANCHES"));
+        assert!(is_registered("consumers"));
+        assert!(is_registered("Consumers"));
+        assert!(is_registered("CONSUMERS"));
         assert!(is_registered("files"));
         assert!(is_registered("Files"));
         assert!(is_registered("FILES"));
diff --git a/crates/integrations/datafusion/tests/system_tables.rs 
b/crates/integrations/datafusion/tests/system_tables.rs
index 582d6ef1..1a64ccb7 100644
--- a/crates/integrations/datafusion/tests/system_tables.rs
+++ b/crates/integrations/datafusion/tests/system_tables.rs
@@ -530,6 +530,93 @@ async fn test_snapshots_system_table() {
     );
 }
 
+#[tokio::test]
+async fn test_consumers_system_table() {
+    let (ctx, _catalog, tmp) = create_context().await;
+    let table = format!("paimon.default.{FIXTURE_TABLE}$consumers");
+
+    let batches = run_sql(&ctx, &format!("SELECT * FROM {table}")).await;
+    assert!(!batches.is_empty(), "$consumers should return a batch");
+    let schema = batches[0].schema();
+    assert_eq!(schema.field(0).name(), "consumer_id");
+    assert_eq!(schema.field(0).data_type(), &DataType::Utf8);
+    assert_eq!(schema.field(1).name(), "next_snapshot_id");
+    assert_eq!(schema.field(1).data_type(), &DataType::Int64);
+    assert_eq!(
+        batches.iter().map(|batch| batch.num_rows()).sum::<usize>(),
+        0
+    );
+
+    let consumer_dir = tmp
+        .path()
+        .join("default.db")
+        .join(FIXTURE_TABLE)
+        .join("consumer");
+    std::fs::create_dir_all(&consumer_dir).unwrap();
+    std::fs::write(consumer_dir.join("consumer-id2"), 
r#"{"nextSnapshot":6}"#).unwrap();
+    std::fs::write(
+        consumer_dir.join("consumer-id1"),
+        r#"{"nextSnapshot":5,"ignored":"value"}"#,
+    )
+    .unwrap();
+    std::fs::write(consumer_dir.join("not-a-consumer"), "not json").unwrap();
+
+    let batches = run_sql(&ctx, &format!("SELECT * FROM {table} ORDER BY 
consumer_id")).await;
+    let batch = &batches[0];
+    let ids = batch
+        .column(0)
+        .as_any()
+        .downcast_ref::<StringArray>()
+        .expect("consumer_id is Utf8");
+    let snapshots = batch
+        .column(1)
+        .as_any()
+        .downcast_ref::<Int64Array>()
+        .expect("next_snapshot_id is Int64");
+    assert_eq!(ids.iter().flatten().collect::<Vec<_>>(), vec!["id1", "id2"]);
+    assert_eq!(snapshots.values(), &[5, 6]);
+
+    // A filtered point lookup must not read unrelated consumer files.
+    std::fs::write(consumer_dir.join("consumer-bad"), "{").unwrap();
+    let cases: [(&str, &[(&str, i64)]); 5] = [
+        ("consumer_id = 'id1'", &[("id1", 5)]),
+        ("consumer_id IN ('id2', 'missing')", &[("id2", 6)]),
+        ("consumer_id = 'missing'", &[]),
+        ("consumer_id = 'id1 '", &[]),
+        ("consumer_id IN ('id1', 'id1 ')", &[("id1", 5)]),
+    ];
+    for (predicate, expected) in cases {
+        let batches = run_sql(
+            &ctx,
+            &format!("SELECT consumer_id, next_snapshot_id FROM {table} WHERE 
{predicate}"),
+        )
+        .await;
+        let mut actual = Vec::new();
+        for batch in batches {
+            let ids = batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<StringArray>()
+                .unwrap();
+            let snapshots = batch
+                .column(1)
+                .as_any()
+                .downcast_ref::<Int64Array>()
+                .unwrap();
+            for row in 0..batch.num_rows() {
+                actual.push((ids.value(row).to_string(), 
snapshots.value(row)));
+            }
+        }
+        assert_eq!(
+            actual,
+            expected
+                .iter()
+                .map(|(id, snapshot)| (id.to_string(), *snapshot))
+                .collect::<Vec<_>>()
+        );
+    }
+}
+
 #[tokio::test]
 async fn test_branches_system_table_empty_when_no_branch_dir() {
     let (ctx, _catalog, _tmp) = create_context().await;
diff --git a/crates/paimon/src/io/storage.rs b/crates/paimon/src/io/storage.rs
index 5ba31e71..24885d29 100644
--- a/crates/paimon/src/io/storage.rs
+++ b/crates/paimon/src/io/storage.rs
@@ -314,7 +314,7 @@ impl Storage {
     fn fs_relative_path(path: &str) -> crate::Result<Cow<'_, str>> {
         // A `file://` / `file:/` URL is already in scheme-relative form.
         if let Some(stripped) = path.strip_prefix("file:/") {
-            return Ok(if stripped.contains('\\') {
+            return Ok(if cfg!(windows) && stripped.contains('\\') {
                 Cow::Owned(stripped.replace('\\', "/"))
             } else {
                 Cow::Borrowed(stripped)
@@ -561,6 +561,15 @@ mod fs_relative_path_tests {
         assert_eq!(rel("file:///tmp/wh"), "//tmp/wh");
     }
 
+    #[cfg(not(windows))]
+    #[test]
+    fn file_scheme_preserves_posix_backslashes() {
+        assert_eq!(
+            rel(r"file:///tmp/consumer/consumer-id\part"),
+            r"//tmp/consumer/consumer-id\part"
+        );
+    }
+
     #[test]
     fn windows_drive_path_keeps_drive_and_normalizes_separators() {
         // The historical bug dropped the drive letter (`C:\wh` -> `:\wh`); we
diff --git a/crates/paimon/src/table/consumer_manager.rs 
b/crates/paimon/src/table/consumer_manager.rs
new file mode 100644
index 00000000..35cb055b
--- /dev/null
+++ b/crates/paimon/src/table/consumer_manager.rs
@@ -0,0 +1,209 @@
+// 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.
+
+//! Consumer progress manager for Java-compatible consumer files.
+
+use std::collections::HashSet;
+use std::time::Duration;
+
+use crate::io::FileIO;
+use futures::{stream, StreamExt, TryStreamExt};
+use serde::Deserialize;
+
+const CONSUMER_DIR: &str = "consumer";
+const CONSUMER_PREFIX: &str = "consumer-";
+const CONSUMER_READ_CONCURRENCY: usize = 32;
+const READ_RETRIES: usize = 10;
+const RETRY_DELAY: Duration = Duration::from_millis(200);
+
+/// Reads consumer progress files stored under a table or branch.
+#[derive(Debug, Clone)]
+pub struct ConsumerManager {
+    file_io: FileIO,
+    table_path: String,
+}
+
+impl ConsumerManager {
+    pub fn new(file_io: FileIO, table_path: String) -> Self {
+        Self {
+            file_io,
+            table_path,
+        }
+    }
+
+    /// Create a manager for a branch of this table.
+    pub fn with_branch(&self, branch_name: &str) -> Self {
+        Self::new(
+            self.file_io.clone(),
+            format!("{}/branch/branch-{branch_name}", self.table_path),
+        )
+    }
+
+    /// Read one consumer's next snapshot id.
+    pub async fn get(&self, consumer_id: &str) -> crate::Result<Option<i64>> {
+        let ids = HashSet::from([consumer_id]);
+        Ok(self
+            .list_matching(Some(&ids))
+            .await?
+            .into_iter()
+            .next()
+            .map(|(_, next_snapshot)| next_snapshot))
+    }
+
+    async fn read_path(&self, consumer_id: &str, path: &str) -> 
crate::Result<Option<i64>> {
+        let input = self.file_io.new_input(path)?;
+        for attempt in 0..READ_RETRIES {
+            let bytes = match input.read().await {
+                Ok(bytes) => bytes,
+                Err(crate::Error::IoUnexpected { ref source, .. })
+                    if source.kind() == opendal::ErrorKind::NotFound =>
+                {
+                    return Ok(None);
+                }
+                Err(error) => return Err(error),
+            };
+            match serde_json::from_slice::<Consumer>(&bytes) {
+                Ok(consumer) => return Ok(Some(consumer.next_snapshot)),
+                Err(_) if attempt + 1 < READ_RETRIES => 
tokio::time::sleep(RETRY_DELAY).await,
+                Err(error) => {
+                    return Err(crate::Error::DataInvalid {
+                        message: format!("consumer '{consumer_id}' JSON 
invalid: {error}"),
+                        source: Some(Box::new(error)),
+                    });
+                }
+            }
+        }
+        unreachable!("READ_RETRIES is non-zero")
+    }
+
+    /// List consumer ids and their next snapshot ids, sorted by consumer id.
+    pub async fn list_all(&self) -> crate::Result<Vec<(String, i64)>> {
+        self.list_matching(None).await
+    }
+
+    /// List the requested consumers that have exactly matching directory 
entries.
+    pub async fn list_by_ids(&self, consumer_ids: &[String]) -> 
crate::Result<Vec<(String, i64)>> {
+        let ids = consumer_ids.iter().map(String::as_str).collect();
+        self.list_matching(Some(&ids)).await
+    }
+
+    async fn list_matching(
+        &self,
+        consumer_ids: Option<&HashSet<&str>>,
+    ) -> crate::Result<Vec<(String, i64)>> {
+        let directory = format!("{}/{}", self.table_path, CONSUMER_DIR);
+        let statuses = match self.file_io.list_status(&directory).await {
+            Ok(statuses) => statuses,
+            Err(crate::Error::IoUnexpected { ref source, .. })
+                if source.kind() == opendal::ErrorKind::NotFound =>
+            {
+                return Ok(Vec::new());
+            }
+            Err(error) => return Err(error),
+        };
+
+        let mut consumers = 
stream::iter(statuses.into_iter().filter_map(|status| {
+            if status.is_dir {
+                return None;
+            }
+            let name = status.path.rsplit('/').next().unwrap_or(&status.path);
+            let id = name.strip_prefix(CONSUMER_PREFIX)?;
+            if consumer_ids.is_some_and(|ids| !ids.contains(id)) {
+                return None;
+            }
+            Some((id.to_string(), status.path))
+        }))
+        .map(|(id, path)| async move {
+            self.read_path(&id, &path)
+                .await
+                .map(|next_snapshot| next_snapshot.map(|next_snapshot| (id, 
next_snapshot)))
+        })
+        .buffer_unordered(CONSUMER_READ_CONCURRENCY)
+        .try_collect::<Vec<_>>()
+        .await?
+        .into_iter()
+        .flatten()
+        .collect::<Vec<_>>();
+        consumers.sort_unstable_by(|a, b| a.0.cmp(&b.0));
+        Ok(consumers)
+    }
+}
+
+#[derive(Deserialize)]
+struct Consumer {
+    #[serde(rename = "nextSnapshot")]
+    next_snapshot: i64,
+}
+
+#[cfg(test)]
+mod tests {
+    use bytes::Bytes;
+
+    use super::*;
+    use crate::io::FileIOBuilder;
+
+    #[tokio::test]
+    async fn retries_a_consumer_being_overwritten() {
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let table_path = "memory:/consumer-retry";
+        let directory = format!("{table_path}/{CONSUMER_DIR}");
+        let path = format!("{directory}/{CONSUMER_PREFIX}job");
+        file_io.mkdirs(&directory).await.unwrap();
+        file_io
+            .new_output(&path)
+            .unwrap()
+            .write(Bytes::from_static(b"{"))
+            .await
+            .unwrap();
+
+        let manager = ConsumerManager::new(file_io.clone(), 
table_path.to_string());
+        let mut read = Box::pin(manager.get("job"));
+        tokio::select! {
+            result = &mut read => panic!("invalid JSON returned before retry: 
{result:?}"),
+            _ = tokio::time::sleep(Duration::from_millis(50)) => {}
+        }
+        file_io
+            .new_output(&path)
+            .unwrap()
+            .write(Bytes::from_static(br#"{"nextSnapshot":5}"#))
+            .await
+            .unwrap();
+
+        assert_eq!(read.await.unwrap(), Some(5));
+    }
+
+    #[cfg(not(windows))]
+    #[tokio::test]
+    async fn lists_posix_consumer_id_with_backslash() {
+        let tmp = tempfile::tempdir().unwrap();
+        let table_path = tmp.path().join("table");
+        let directory = table_path.join(CONSUMER_DIR);
+        std::fs::create_dir_all(&directory).unwrap();
+        std::fs::write(directory.join(r"consumer-id\part"), 
r#"{"nextSnapshot":5}"#).unwrap();
+
+        let manager = ConsumerManager::new(
+            FileIOBuilder::new("file").build().unwrap(),
+            format!("file://{}", table_path.display()),
+        );
+
+        assert_eq!(
+            manager.list_all().await.unwrap(),
+            vec![(r"id\part".to_string(), 5)]
+        );
+        assert_eq!(manager.get(r"id\part").await.unwrap(), Some(5));
+    }
+}
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 92c73a6f..32484924 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -33,6 +33,7 @@ mod bucket_assigner_fixed;
 mod bucket_filter;
 mod bucket_function;
 mod commit_message;
+mod consumer_manager;
 pub(crate) mod cow_writer;
 mod data_evolution_reader;
 pub mod data_evolution_writer;
@@ -113,6 +114,7 @@ pub use audit_log_table::AuditLogTable;
 pub use blob_resolver::{BlobReader, BlobStream};
 pub use branch_manager::BranchManager;
 pub use commit_message::CommitMessage;
+pub use consumer_manager::ConsumerManager;
 pub use cow_writer::{CopyOnWriteMergeWriter, FileInfo};
 pub use data_evolution_writer::{DataEvolutionDeleteWriter, 
DataEvolutionWriter};
 #[cfg(feature = "fulltext")]
@@ -322,6 +324,15 @@ impl Table {
         }
     }
 
+    pub fn consumer_manager(&self) -> ConsumerManager {
+        let manager = ConsumerManager::new(self.file_io.clone(), 
self.location.clone());
+        if self.is_main_branch() {
+            manager
+        } else {
+            manager.with_branch(&self.branch)
+        }
+    }
+
     /// Get the REST environment, if this table was loaded from a REST catalog.
     pub fn rest_env(&self) -> Option<&RESTEnv> {
         self.rest_env.as_ref()
diff --git a/docs/src/sql.md b/docs/src/sql.md
index c53bacbf..c37ac51c 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1922,6 +1922,21 @@ Columns:
 | `watermark` | BIGINT | Watermark |
 | `next_row_id` | BIGINT | Next row id |
 
+### $consumers
+
+View the stored progress of streaming consumers:
+
+```sql
+SELECT * FROM paimon.default.my_table$consumers;
+```
+
+Columns:
+
+| Column | Type | Description |
+|---|---|---|
+| `consumer_id` | STRING | Consumer identifier |
+| `next_snapshot_id` | BIGINT | Next snapshot the consumer will read |
+
 ### $tags
 
 View all named tags of a table:

Reply via email to