This is an automated email from the ASF dual-hosted git repository.
luoyuxia pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 81e94943 [feat] Support subscribing to primary-key table changelog
(#639)
81e94943 is described below
commit 81e94943f1ac5af6d2956a81b0943b243a51e7c9
Author: Anton Borisov <[email protected]>
AuthorDate: Mon Jun 29 04:40:43 2026 +0100
[feat] Support subscribing to primary-key table changelog (#639)
---
bindings/cpp/CMakeLists.txt | 6 +
bindings/cpp/examples/kv_changelog_example.cpp | 130 ++++++++
bindings/cpp/include/fluss.hpp | 14 +
bindings/cpp/test/test_kv_changelog.cpp | 182 +++++++++++
bindings/python/example/kv_changelog.py | 103 +++++++
bindings/python/fluss/__init__.pyi | 9 +
bindings/python/test/test_kv_changelog.py | 112 +++++++
crates/examples/Cargo.toml | 4 +
crates/examples/src/example_kv_changelog.rs | 96 ++++++
crates/fluss/src/client/table/log_fetch_buffer.rs | 145 +++++++--
crates/fluss/src/client/table/scanner.rs | 84 ++++--
crates/fluss/src/record/arrow.rs | 336 ++++++++++++++++++---
crates/fluss/tests/integration/kv_changelog.rs | 170 +++++++++++
crates/fluss/tests/test_fluss.rs | 1 +
website/docs/user-guide/cpp/api-reference.md | 4 +-
.../user-guide/cpp/example/primary-key-tables.md | 30 ++
website/docs/user-guide/python/api-reference.md | 4 +-
.../python/example/primary-key-tables.md | 22 ++
website/docs/user-guide/rust/api-reference.md | 21 +-
.../user-guide/rust/example/primary-key-tables.md | 42 +++
20 files changed, 1417 insertions(+), 98 deletions(-)
diff --git a/bindings/cpp/CMakeLists.txt b/bindings/cpp/CMakeLists.txt
index 44407ac8..c72b3675 100644
--- a/bindings/cpp/CMakeLists.txt
+++ b/bindings/cpp/CMakeLists.txt
@@ -236,6 +236,12 @@ target_link_libraries(fluss_cpp_kv_example PRIVATE
Arrow::arrow_shared)
target_compile_definitions(fluss_cpp_kv_example PRIVATE ARROW_FOUND)
target_include_directories(fluss_cpp_kv_example PUBLIC ${CPP_INCLUDE_DIR})
+add_executable(fluss_cpp_kv_changelog_example
examples/kv_changelog_example.cpp)
+target_link_libraries(fluss_cpp_kv_changelog_example PRIVATE fluss_cpp)
+target_link_libraries(fluss_cpp_kv_changelog_example PRIVATE
Arrow::arrow_shared)
+target_compile_definitions(fluss_cpp_kv_changelog_example PRIVATE ARROW_FOUND)
+target_include_directories(fluss_cpp_kv_changelog_example PUBLIC
${CPP_INCLUDE_DIR})
+
if (CARGO_TARGET_DIR)
set_target_properties(fluss_cpp
PROPERTIES ADDITIONAL_CLEAN_FILES "${CARGO_TARGET_DIR}"
diff --git a/bindings/cpp/examples/kv_changelog_example.cpp
b/bindings/cpp/examples/kv_changelog_example.cpp
new file mode 100644
index 00000000..f3a6ab91
--- /dev/null
+++ b/bindings/cpp/examples/kv_changelog_example.cpp
@@ -0,0 +1,130 @@
+// 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.
+
+// Primary-key table changelog (CDC) example: subscribe to a KV table's
+// changelog and print each row-level change as a +I / -U / +U / -D event.
+
+#include <iostream>
+#include <string>
+
+#include "fluss.hpp"
+
+static void check(const char* step, const fluss::Result& r) {
+ if (!r.Ok()) {
+ std::cerr << step << " failed: code=" << r.error_code << " msg=" <<
r.error_message
+ << std::endl;
+ std::exit(1);
+ }
+}
+
+static const char* change_symbol(fluss::ChangeType ct) {
+ switch (ct) {
+ case fluss::ChangeType::AppendOnly:
+ return "+A";
+ case fluss::ChangeType::Insert:
+ return "+I";
+ case fluss::ChangeType::UpdateBefore:
+ return "-U";
+ case fluss::ChangeType::UpdateAfter:
+ return "+U";
+ case fluss::ChangeType::Delete:
+ return "-D";
+ }
+ return "?";
+}
+
+int main() {
+ fluss::Configuration config;
+ config.bootstrap_servers = "127.0.0.1:9123";
+
+ fluss::Connection conn;
+ check("create", fluss::Connection::Create(config, conn));
+
+ fluss::Admin admin;
+ check("get_admin", conn.GetAdmin(admin));
+
+ fluss::TablePath table_path("fluss", "kv_changelog_cpp");
+ admin.DropTable(table_path, true);
+
+ // A single bucket keeps the changelog on one bucket and in order, which
+ // makes the CDC output easy to follow.
+ auto schema = fluss::Schema::NewBuilder()
+ .AddColumn("id", fluss::DataType::Int())
+ .AddColumn("name", fluss::DataType::String())
+ .SetPrimaryKeys({"id"})
+ .Build();
+
+ auto descriptor = fluss::TableDescriptor::NewBuilder()
+ .SetSchema(schema)
+ .SetBucketCount(1)
+ .SetComment("cpp kv changelog example")
+ .Build();
+
+ check("create_table", admin.CreateTable(table_path, descriptor, false));
+ std::cout << "Created PK table: " << table_path.ToString() << std::endl;
+
+ fluss::Table table;
+ check("get_table", conn.GetTable(table_path, table));
+
+ fluss::UpsertWriter writer;
+ check("new_upsert_writer", table.NewUpsert().CreateWriter(writer));
+
+ // Insert three keys (+I), update one (-U / +U) and delete one (-D).
+ for (const auto& kv : {std::pair<int32_t, const char*>{1, "alice"},
+ {2, "bob"}, {3, "carol"}}) {
+ fluss::GenericRow row(2);
+ row.SetInt32(0, kv.first);
+ row.SetString(1, kv.second);
+ check("upsert", writer.Upsert(row));
+ }
+ {
+ fluss::GenericRow row(2);
+ row.SetInt32(0, 2);
+ row.SetString(1, "bob-v2");
+ check("update", writer.Upsert(row));
+ }
+ {
+ fluss::GenericRow del(2);
+ del.SetInt32(0, 3);
+ check("delete", writer.Delete(del));
+ }
+ check("flush", writer.Flush());
+
+ // Subscribe from the start of the changelog and print each CDC event until
+ // we reach the end of the log.
+ auto table_scan = table.NewScan();
+ fluss::LogScanner log_scanner;
+ check("create_log_scanner", table_scan.CreateLogScanner(log_scanner));
+ check("subscribe", log_scanner.Subscribe(0, fluss::EARLIEST_OFFSET));
+
+ std::cout << "Changelog (change_type id name):" << std::endl;
+ while (true) {
+ fluss::ScanRecords records;
+ check("poll", log_scanner.Poll(3000, records));
+ if (records.IsEmpty()) {
+ break;
+ }
+ for (auto rec : records) {
+ std::cout << " " << change_symbol(rec.change_type) << " " <<
rec.row.GetInt32(0) << " "
+ << rec.row.GetString(1) << std::endl;
+ }
+ }
+
+ check("drop_table", admin.DropTable(table_path, true));
+ std::cout << "\nKV changelog example completed successfully!" << std::endl;
+ return 0;
+}
diff --git a/bindings/cpp/include/fluss.hpp b/bindings/cpp/include/fluss.hpp
index 6987e683..9e727d6d 100644
--- a/bindings/cpp/include/fluss.hpp
+++ b/bindings/cpp/include/fluss.hpp
@@ -1630,7 +1630,21 @@ class TableScan {
TableScan& Limit(int32_t row_number);
+ /// Creates a record-mode log scanner, polled for individual `ScanRecord`s.
+ ///
+ /// Works on log tables and on primary-key (KV) tables. For a primary-key
+ /// table this subscribes to its CDC changelog: each `ScanRecord` carries a
+ /// `ChangeType` -- `+I` (insert), `-U` (update-before), `+U`
(update-after)
+ /// or `-D` (delete). A log table yields `+A` (append-only). Requires the
+ /// ARROW log format.
Result CreateLogScanner(LogScanner& out);
+
+ /// Creates a batch-mode log scanner that yields Arrow record batches.
+ ///
+ /// Log tables only. Primary-key tables are rejected because the Arrow
batch
+ /// path carries no per-record change types; read a primary-key table's
+ /// changelog with `CreateLogScanner()` instead. Requires the ARROW log
+ /// format.
Result CreateRecordBatchLogScanner(LogScanner& out);
Result CreateBucketBatchScanner(const TableBucket& bucket, BatchScanner&
out);
diff --git a/bindings/cpp/test/test_kv_changelog.cpp
b/bindings/cpp/test/test_kv_changelog.cpp
new file mode 100644
index 00000000..8be2aca8
--- /dev/null
+++ b/bindings/cpp/test/test_kv_changelog.cpp
@@ -0,0 +1,182 @@
+/*
+ * 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.
+ */
+
+// Integration tests for primary-key (KV) table changelog (CDC) scanning.
+// Mirrors crates/fluss/tests/integration/kv_changelog.rs.
+
+#include <gtest/gtest.h>
+
+#include <algorithm>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "test_utils.h"
+
+class KvChangelogTest : public ::testing::Test {
+ protected:
+ fluss::Admin& admin() { return
fluss_test::FlussTestEnvironment::Instance()->GetAdmin(); }
+
+ fluss::Connection& connection() {
+ return fluss_test::FlussTestEnvironment::Instance()->GetConnection();
+ }
+};
+
+// A record-mode scanner over a PK table yields its CDC changelog. With the
+// default FULL changelog image: inserting a new key emits +I, overwriting an
+// existing key emits -U (old image) then +U (new image), and a delete emits -D
+// (old image). A single bucket keeps the offsets contiguous.
+TEST_F(KvChangelogTest, SubscribeKvTableChangelog) {
+ auto& adm = admin();
+ auto& conn = connection();
+
+ fluss::TablePath table_path("fluss", "test_kv_changelog_cpp");
+
+ auto schema = fluss::Schema::NewBuilder()
+ .AddColumn("id", fluss::DataType::Int())
+ .AddColumn("name", fluss::DataType::String())
+ .SetPrimaryKeys({"id"})
+ .Build();
+
+ auto table_descriptor = fluss::TableDescriptor::NewBuilder()
+ .SetSchema(schema)
+ .SetBucketCount(1)
+ .SetProperty("table.replication.factor", "1")
+ .Build();
+
+ fluss_test::CreateTable(adm, table_path, table_descriptor);
+
+ fluss::Table table;
+ ASSERT_OK(conn.GetTable(table_path, table));
+
+ auto table_upsert = table.NewUpsert();
+ fluss::UpsertWriter upsert_writer;
+ ASSERT_OK(table_upsert.CreateWriter(upsert_writer));
+
+ // Await each write so the changelog offsets are produced in a fixed order.
+ { // +I (1, alice)
+ fluss::GenericRow row(2);
+ row.SetInt32(0, 1);
+ row.SetString(1, "alice");
+ fluss::WriteResult wr;
+ ASSERT_OK(upsert_writer.Upsert(row, wr));
+ ASSERT_OK(wr.Wait());
+ }
+ { // +I (2, bob)
+ fluss::GenericRow row(2);
+ row.SetInt32(0, 2);
+ row.SetString(1, "bob");
+ fluss::WriteResult wr;
+ ASSERT_OK(upsert_writer.Upsert(row, wr));
+ ASSERT_OK(wr.Wait());
+ }
+ { // overwrite id=1 -> -U (1, alice) then +U (1, alice2)
+ fluss::GenericRow row(2);
+ row.SetInt32(0, 1);
+ row.SetString(1, "alice2");
+ fluss::WriteResult wr;
+ ASSERT_OK(upsert_writer.Upsert(row, wr));
+ ASSERT_OK(wr.Wait());
+ }
+ { // -D (2, bob)
+ fluss::GenericRow del(2);
+ del.SetInt32(0, 2);
+ fluss::WriteResult wr;
+ ASSERT_OK(upsert_writer.Delete(del, wr));
+ ASSERT_OK(wr.Wait());
+ }
+
+ auto table_scan = table.NewScan();
+ fluss::LogScanner log_scanner;
+ ASSERT_OK(table_scan.CreateLogScanner(log_scanner));
+ ASSERT_OK(log_scanner.Subscribe(0, fluss::EARLIEST_OFFSET));
+
+ struct Decoded {
+ int64_t offset;
+ fluss::ChangeType change_type;
+ int32_t id;
+ std::string name;
+ };
+
+ std::vector<Decoded> records;
+ fluss_test::PollRecords(
+ log_scanner, 5,
+ [](const fluss::ScanRecord& rec) {
+ return Decoded{rec.offset, rec.change_type, rec.row.GetInt32(0),
+ std::string(rec.row.GetString(1))};
+ },
+ records);
+
+ ASSERT_EQ(records.size(), 5u);
+ std::sort(records.begin(), records.end(),
+ [](const Decoded& a, const Decoded& b) { return a.offset <
b.offset; });
+
+ const std::vector<fluss::ChangeType> expected_types = {
+ fluss::ChangeType::Insert, fluss::ChangeType::Insert,
fluss::ChangeType::UpdateBefore,
+ fluss::ChangeType::UpdateAfter, fluss::ChangeType::Delete};
+ const std::vector<std::pair<int32_t, std::string>> expected_rows = {
+ {1, "alice"}, // +I
+ {2, "bob"}, // +I
+ {1, "alice"}, // -U (old image)
+ {1, "alice2"}, // +U (new image)
+ {2, "bob"}, // -D (old image)
+ };
+
+ for (size_t i = 0; i < records.size(); ++i) {
+ EXPECT_EQ(records[i].offset, static_cast<int64_t>(i));
+ EXPECT_EQ(static_cast<int>(records[i].change_type),
static_cast<int>(expected_types[i]))
+ << "change_type mismatch at " << i;
+ EXPECT_EQ(records[i].id, expected_rows[i].first) << "id mismatch at "
<< i;
+ EXPECT_EQ(records[i].name, expected_rows[i].second) << "name mismatch
at " << i;
+ }
+
+ ASSERT_OK(adm.DropTable(table_path, false));
+}
+
+// The Arrow batch scanner carries no per-record change types, so it rejects
+// primary-key tables (mirrors the core / Java restriction).
+TEST_F(KvChangelogTest, RecordBatchScannerRejectsPrimaryKey) {
+ auto& adm = admin();
+ auto& conn = connection();
+
+ fluss::TablePath table_path("fluss", "test_kv_changelog_batch_reject_cpp");
+
+ auto schema = fluss::Schema::NewBuilder()
+ .AddColumn("id", fluss::DataType::Int())
+ .AddColumn("name", fluss::DataType::String())
+ .SetPrimaryKeys({"id"})
+ .Build();
+
+ auto table_descriptor = fluss::TableDescriptor::NewBuilder()
+ .SetSchema(schema)
+ .SetProperty("table.replication.factor", "1")
+ .Build();
+
+ fluss_test::CreateTable(adm, table_path, table_descriptor);
+
+ fluss::Table table;
+ ASSERT_OK(conn.GetTable(table_path, table));
+
+ auto table_scan = table.NewScan();
+ fluss::LogScanner batch_scanner;
+ auto result = table_scan.CreateRecordBatchLogScanner(batch_scanner);
+ EXPECT_FALSE(result.Ok()) << "batch scanner should reject a primary-key
table";
+
+ ASSERT_OK(adm.DropTable(table_path, false));
+}
diff --git a/bindings/python/example/kv_changelog.py
b/bindings/python/example/kv_changelog.py
new file mode 100644
index 00000000..bc129c6d
--- /dev/null
+++ b/bindings/python/example/kv_changelog.py
@@ -0,0 +1,103 @@
+# 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.
+
+"""Primary-key table changelog (CDC) example.
+
+Subscribe to a primary-key table's changelog and print each row-level change as
+a ``+I`` / ``-U`` / ``+U`` / ``-D`` event.
+
+Run standalone against a local cluster:
+
+ python example/kv_changelog.py
+
+Or point it at a specific cluster:
+
+ FLUSS_BOOTSTRAP_SERVERS=host:port python example/kv_changelog.py
+"""
+
+import asyncio
+import os
+from typing import Optional
+
+import pyarrow as pa
+
+import fluss
+
+DEFAULT_BOOTSTRAP_SERVERS = "127.0.0.1:9123"
+
+
+async def main(bootstrap_servers: Optional[str] = None):
+ bootstrap_servers = bootstrap_servers or os.environ.get(
+ "FLUSS_BOOTSTRAP_SERVERS", DEFAULT_BOOTSTRAP_SERVERS
+ )
+
+ config = fluss.Config({"bootstrap.servers": bootstrap_servers})
+ conn = await fluss.FlussConnection.create(config)
+ try:
+ await _run(conn)
+ finally:
+ await conn.close()
+ print("\nConnection closed")
+
+
+async def _run(conn):
+ schema = fluss.Schema(
+ pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())]),
+ primary_keys=["id"],
+ )
+ # A single bucket keeps the changelog on one bucket and in order, which
makes
+ # the CDC output easy to follow.
+ table_descriptor = fluss.TableDescriptor(schema, bucket_count=1)
+
+ admin = conn.get_admin()
+ table_path = fluss.TablePath("fluss", "example_kv_changelog")
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+ await admin.create_table(table_path, table_descriptor,
ignore_if_exists=True)
+ print(f"Created PK table: {table_path}")
+
+ table = await conn.get_table(table_path)
+ writer = table.new_upsert().create_writer()
+
+ # Insert three keys (+I), update one (-U / +U) and delete one (-D).
+ for id_, name in [(1, "alice"), (2, "bob"), (3, "carol")]:
+ writer.upsert({"id": id_, "name": name})
+ writer.upsert({"id": 2, "name": "bob-v2"})
+ writer.delete({"id": 3})
+ await writer.flush()
+
+ # Subscribe from the start of the changelog and print each CDC event until
we
+ # reach the end of the log.
+ scanner = await table.new_scan().create_log_scanner()
+ scanner.subscribe(bucket_id=0, start_offset=fluss.EARLIEST_OFFSET)
+
+ print("Changelog (change_type id name):")
+ while True:
+ records = await scanner.poll(3000)
+ if records.is_empty():
+ break
+ for record in records:
+ print(
+ f" {record.change_type.short_string()} "
+ f"{record.row['id']} {record.row['name']}"
+ )
+
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+ print(f"\nDropped table: {table_path}")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/bindings/python/fluss/__init__.pyi
b/bindings/python/fluss/__init__.pyi
index 2ba50a42..3f7e94b4 100644
--- a/bindings/python/fluss/__init__.pyi
+++ b/bindings/python/fluss/__init__.pyi
@@ -537,6 +537,11 @@ class TableScan:
Use this scanner with `poll()` to get individual records with metadata
(offset, timestamp, change_type).
+ Works on log tables and on primary-key (KV) tables. For a primary-key
+ table it subscribes to the CDC changelog: each record's ``change_type``
+ is ``+I`` (insert), ``-U`` (update-before), ``+U`` (update-after) or
+ ``-D`` (delete). A log table yields ``+A`` (append-only).
+
Returns:
LogScanner for record-by-record scanning with `poll()`
"""
@@ -547,6 +552,10 @@ class TableScan:
Use this scanner with `poll_arrow()` to get Arrow Tables, or with
`poll_record_batch()` to get individual batches with metadata.
+ Log tables only. Primary-key tables are rejected because the Arrow
batch
+ path carries no per-record change types; read a primary-key table's
+ changelog with `create_log_scanner()` instead.
+
Returns:
LogScanner for batch-based scanning with `poll_arrow()` or
`poll_record_batch()`
"""
diff --git a/bindings/python/test/test_kv_changelog.py
b/bindings/python/test/test_kv_changelog.py
new file mode 100644
index 00000000..3df3ca44
--- /dev/null
+++ b/bindings/python/test/test_kv_changelog.py
@@ -0,0 +1,112 @@
+# 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.
+
+"""Integration tests for primary-key (KV) table changelog (CDC) scanning.
+
+Mirrors crates/fluss/tests/integration/kv_changelog.rs.
+"""
+
+import time
+
+import pyarrow as pa
+import pytest
+
+import fluss
+
+
+async def test_subscribe_kv_table_changelog(connection, admin,
wait_for_table_ready):
+ """A record-mode scanner over a PK table yields its CDC changelog.
+
+ With the default FULL changelog image: inserting a new key emits +I,
+ overwriting an existing key emits -U (old image) then +U (new image), and a
+ delete emits -D (old image). A single bucket keeps the offsets contiguous.
+ """
+ table_path = fluss.TablePath("fluss", "py_test_kv_changelog")
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+
+ schema = fluss.Schema(
+ pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())]),
+ primary_keys=["id"],
+ )
+ table_descriptor = fluss.TableDescriptor(schema, bucket_count=1)
+ await admin.create_table(table_path, table_descriptor,
ignore_if_exists=False)
+ await wait_for_table_ready(table_path)
+
+ table = await connection.get_table(table_path)
+ writer = table.new_upsert().create_writer()
+
+ # Await each write so the changelog offsets are produced in a fixed order.
+ await writer.upsert({"id": 1, "name": "alice"}).wait() # +I
+ await writer.upsert({"id": 2, "name": "bob"}).wait() # +I
+ await writer.upsert({"id": 1, "name": "alice2"}).wait() # -U / +U
+ await writer.delete({"id": 2}).wait() # -D
+
+ scanner = await table.new_scan().create_log_scanner()
+ scanner.subscribe(bucket_id=0, start_offset=fluss.EARLIEST_OFFSET)
+
+ records = await _poll_records(scanner, expected_count=5)
+ assert len(records) == 5
+
+ records.sort(key=lambda r: r.offset)
+ assert [r.offset for r in records] == [0, 1, 2, 3, 4]
+ assert [r.change_type for r in records] == [
+ fluss.ChangeType.Insert,
+ fluss.ChangeType.Insert,
+ fluss.ChangeType.UpdateBefore,
+ fluss.ChangeType.UpdateAfter,
+ fluss.ChangeType.Delete,
+ ]
+ assert [(r.row["id"], r.row["name"]) for r in records] == [
+ (1, "alice"), # +I
+ (2, "bob"), # +I
+ (1, "alice"), # -U (old image)
+ (1, "alice2"), # +U (new image)
+ (2, "bob"), # -D (old image)
+ ]
+
+ await admin.drop_table(table_path, ignore_if_not_exists=False)
+
+
+async def test_record_batch_scanner_rejects_primary_key(connection, admin):
+ """The Arrow batch scanner carries no per-record change types, so it
rejects
+ primary-key tables (mirrors the core / Java restriction)."""
+ table_path = fluss.TablePath("fluss", "py_test_kv_changelog_batch_reject")
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+
+ schema = fluss.Schema(
+ pa.schema([pa.field("id", pa.int32()), pa.field("name", pa.string())]),
+ primary_keys=["id"],
+ )
+ await admin.create_table(
+ table_path, fluss.TableDescriptor(schema), ignore_if_exists=False
+ )
+
+ table = await connection.get_table(table_path)
+ with pytest.raises(fluss.FlussError):
+ await table.new_scan().create_record_batch_log_scanner()
+
+ await admin.drop_table(table_path, ignore_if_not_exists=False)
+
+
+async def _poll_records(scanner, expected_count, timeout_s=10):
+ """Poll a record-based scanner until expected_count records are
collected."""
+ collected = []
+ deadline = time.monotonic() + timeout_s
+ while len(collected) < expected_count and time.monotonic() < deadline:
+ records = await scanner.poll(5000)
+ collected.extend(records)
+ return collected
diff --git a/crates/examples/Cargo.toml b/crates/examples/Cargo.toml
index 7203095b..b335e1e4 100644
--- a/crates/examples/Cargo.toml
+++ b/crates/examples/Cargo.toml
@@ -41,6 +41,10 @@ path = "src/example_table.rs"
name = "example-upsert-lookup"
path = "src/example_kv_table.rs"
+[[example]]
+name = "example-kv-changelog"
+path = "src/example_kv_changelog.rs"
+
[[example]]
name = "example-partitioned-upsert-lookup"
path = "src/example_partitioned_kv_table.rs"
diff --git a/crates/examples/src/example_kv_changelog.rs
b/crates/examples/src/example_kv_changelog.rs
new file mode 100644
index 00000000..1554e66a
--- /dev/null
+++ b/crates/examples/src/example_kv_changelog.rs
@@ -0,0 +1,96 @@
+// 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 clap::Parser;
+use fluss::client::{EARLIEST_OFFSET, FlussConnection};
+use fluss::config::Config;
+use fluss::error::Result;
+use fluss::metadata::{DataTypes, Schema, TableDescriptor, TablePath};
+use fluss::row::{DataGetters, GenericRow};
+use std::time::Duration;
+
+#[tokio::main]
+#[allow(dead_code)]
+pub async fn main() -> Result<()> {
+ let mut config = Config::parse();
+ config.bootstrap_servers = "127.0.0.1:9123".to_string();
+
+ let conn = FlussConnection::new(config).await?;
+ let admin = conn.get_admin()?;
+
+ // A single-bucket primary key table keeps the changelog on one bucket and
in
+ // order, which makes the CDC output easy to follow.
+ let table_descriptor = TableDescriptor::builder()
+ .schema(
+ Schema::builder()
+ .column("id", DataTypes::int())
+ .column("name", DataTypes::string())
+ .primary_key(vec!["id"])
+ .build()?,
+ )
+ .distributed_by(Some(1), vec!["id".to_string()])
+ .build()?;
+
+ let table_path = TablePath::new("fluss", "rust_kv_changelog_example");
+ admin
+ .create_table(&table_path, &table_descriptor, true)
+ .await?;
+
+ let table = conn.get_table(&table_path).await?;
+ let upsert_writer = table.new_upsert()?.create_writer()?;
+
+ // Insert three keys (+I), update one (-U / +U) and delete one (-D).
+ for (id, name) in [(1, "alice"), (2, "bob"), (3, "carol")] {
+ let mut row = GenericRow::new(2);
+ row.set_field(0, id);
+ row.set_field(1, name);
+ upsert_writer.upsert(&row)?;
+ }
+ let mut updated = GenericRow::new(2);
+ updated.set_field(0, 2);
+ updated.set_field(1, "bob-v2");
+ upsert_writer.upsert(&updated)?;
+
+ let mut deleted = GenericRow::new(2);
+ deleted.set_field(0, 3);
+ upsert_writer.delete(&deleted)?;
+ upsert_writer.flush().await?;
+
+ // Subscribe to the changelog from the start and print each CDC event until
+ // we reach the end of the log.
+ let log_scanner = table.new_scan().create_log_scanner()?;
+ log_scanner.subscribe(0, EARLIEST_OFFSET).await?;
+
+ println!("Changelog (change_type id name):");
+ loop {
+ let records = log_scanner.poll(Duration::from_secs(3)).await?;
+ if records.is_empty() {
+ break;
+ }
+ for record in records {
+ let row = record.row();
+ println!(
+ " {} {} {}",
+ record.change_type().short_string(),
+ row.get_int(0)?,
+ row.get_string(1)?,
+ );
+ }
+ }
+
+ Ok(())
+}
diff --git a/crates/fluss/src/client/table/log_fetch_buffer.rs
b/crates/fluss/src/client/table/log_fetch_buffer.rs
index 9d45abad..dee68c95 100644
--- a/crates/fluss/src/client/table/log_fetch_buffer.rs
+++ b/crates/fluss/src/client/table/log_fetch_buffer.rs
@@ -24,7 +24,7 @@ use crate::client::table::remote_log::{
use crate::error::{ApiError, Error, Result};
use crate::metadata::TableBucket;
use crate::record::{
- LogRecordBatch, LogRecordIterator, LogRecordsBatches, ReadContext,
ScanRecord,
+ ChangeType, LogRecordBatch, LogRecordIterator, LogRecordsBatches,
ReadContext, ScanRecord,
};
use std::{
collections::{HashMap, VecDeque},
@@ -463,6 +463,38 @@ impl DefaultCompletedFetch {
}
}
+ /// Pulls the next decoded record into `out`, returning whether one was
+ /// pushed. A fetch error is cached and surfaced on a later call rather
than
+ /// propagated mid-batch (mirrors one iteration of Java
+ /// `CompletedFetch.fetchRecord`).
+ fn fetch_one_record(&mut self, out: &mut Vec<ScanRecord>) -> bool {
+ if self.cached_record_error.is_none() {
+ self.corrupt_last_record = true;
+ match self.next_fetched_record() {
+ Ok(Some(record)) => {
+ self.corrupt_last_record = false;
+ self.last_record = Some(record);
+ }
+ Ok(None) => {
+ self.corrupt_last_record = false;
+ self.last_record = None;
+ }
+ Err(e) => {
+ self.cached_record_error = Some(e.to_string());
+ }
+ }
+ }
+
+ let Some(record) = self.last_record.take() else {
+ return false;
+ };
+
+ self.next_fetch_offset = record.offset() + 1;
+ self.records_read += 1;
+ out.push(record);
+ true
+ }
+
fn fetch_error(&self) -> Error {
let mut message = format!(
"Received exception when fetching the next record from
{table_bucket}. If needed, please back to past the record to continue
scanning.",
@@ -556,30 +588,20 @@ impl CompletedFetch for DefaultCompletedFetch {
let mut scan_records = Vec::new();
for _ in 0..max_records {
- if self.cached_record_error.is_none() {
- self.corrupt_last_record = true;
- match self.next_fetched_record() {
- Ok(Some(record)) => {
- self.corrupt_last_record = false;
- self.last_record = Some(record);
- }
- Ok(None) => {
- self.corrupt_last_record = false;
- self.last_record = None;
- }
- Err(e) => {
- self.cached_record_error = Some(e.to_string());
- }
- }
- }
-
- let Some(record) = self.last_record.take() else {
+ if !self.fetch_one_record(&mut scan_records) {
break;
- };
+ }
+ }
- self.next_fetch_offset = record.offset() + 1;
- self.records_read += 1;
- scan_records.push(record);
+ // Keep a -U paired with its +U in the same poll batch: a KV changelog
+ // writes the pair as consecutive records, and splitting it across
polls
+ // would expose an orphaned -U to retract-based CDC consumers. Mirrors
+ // Java's `CompletedFetch.fetchRecords`.
+ if scan_records
+ .last()
+ .is_some_and(|record| *record.change_type() ==
ChangeType::UpdateBefore)
+ {
+ self.fetch_one_record(&mut scan_records);
}
if self.cached_record_error.is_some() && scan_records.is_empty() {
@@ -835,7 +857,10 @@ mod tests {
DEFAULT_NON_ZSTD_COMPRESSION_LEVEL,
};
use crate::metadata::{DataField, DataTypes, PhysicalTablePath, RowType,
TablePath};
- use crate::record::{MemoryLogRecordsArrowBuilder, ReadContext,
to_arrow_schema};
+ use crate::record::{
+ APPEND_ONLY_FLAG_MASK, ATTRIBUTES_OFFSET, LENGTH_LENGTH,
LENGTH_OFFSET, LOG_OVERHEAD,
+ MemoryLogRecordsArrowBuilder, RECORDS_OFFSET, ReadContext,
to_arrow_schema,
+ };
use crate::row::GenericRow;
use crate::test_utils::build_table_info;
use std::sync::Arc;
@@ -944,4 +969,76 @@ mod tests {
Ok(())
}
+
+ /// A `-U`/`+U` pair must not be split across polls: even when
`max_records`
+ /// falls between them, `fetch_records` pulls the matching `+U` so the
batch
+ /// ends on a complete pair (mirrors Java `CompletedFetch.fetchRecords`).
+ #[test]
+ fn fetch_records_keeps_update_before_after_pair_together() -> Result<()> {
+ let row_type = RowType::new(vec![DataField::new("id",
DataTypes::int(), None)]);
+ let table_path = TablePath::new("db".to_string(), "tbl".to_string());
+ let table_info = Arc::new(build_table_info(table_path.clone(), 1, 1));
+ let physical_table_path =
Arc::new(PhysicalTablePath::of(Arc::new(table_path)));
+
+ let mut builder = MemoryLogRecordsArrowBuilder::new(
+ 1,
+ &row_type,
+ false,
+ ArrowCompressionInfo {
+ compression_type: ArrowCompressionType::None,
+ compression_level: DEFAULT_NON_ZSTD_COMPRESSION_LEVEL,
+ },
+ usize::MAX,
+ Arc::new(ArrowCompressionRatioEstimator::default()),
+ )?;
+ for id in [10_i32, 20, 20] {
+ let mut row = GenericRow::new(1);
+ row.set_field(0, id);
+ let record = WriteRecord::for_append(
+ Arc::clone(&table_info),
+ physical_table_path.clone(),
+ 1,
+ &row,
+ );
+ builder.append(&record)?;
+ }
+ let append_only = builder.build()?;
+
+ // Synthesize a changelog batch carrying +I, -U, +U.
+ let change_types = [
+ ChangeType::Insert,
+ ChangeType::UpdateBefore,
+ ChangeType::UpdateAfter,
+ ];
+ let mut data = Vec::with_capacity(append_only.len() +
change_types.len());
+ data.extend_from_slice(&append_only[..RECORDS_OFFSET]);
+ data.extend(change_types.iter().map(ChangeType::to_byte_value));
+ data.extend_from_slice(&append_only[RECORDS_OFFSET..]);
+ data[ATTRIBUTES_OFFSET] &= !APPEND_ONLY_FLAG_MASK;
+ let new_len = ((data.len() - LOG_OVERHEAD) as i32).to_le_bytes();
+ data[LENGTH_OFFSET..LENGTH_OFFSET +
LENGTH_LENGTH].copy_from_slice(&new_len);
+
+ let read_context = ReadContext::new(to_arrow_schema(&row_type)?,
Arc::new(row_type), false);
+ let mut fetch = DefaultCompletedFetch::new(
+ TableBucket::new(1, 0),
+ LogRecordsBatches::new(data.clone()),
+ data.len(),
+ read_context,
+ 0,
+ 0,
+ );
+
+ // max_records=2 cuts between -U (offset 1) and +U (offset 2); the
pairing
+ // guard pulls the +U so the batch ends on a complete pair.
+ let records = fetch.fetch_records(2)?;
+ assert_eq!(records.len(), 3, "pair guard should append the matching
+U");
+ assert_eq!(*records[0].change_type(), ChangeType::Insert);
+ assert_eq!(*records[1].change_type(), ChangeType::UpdateBefore);
+ assert_eq!(*records[2].change_type(), ChangeType::UpdateAfter);
+
+ let rest = fetch.fetch_records(10)?;
+ assert!(rest.is_empty(), "all records consumed");
+
+ Ok(())
+ }
}
diff --git a/crates/fluss/src/client/table/scanner.rs
b/crates/fluss/src/client/table/scanner.rs
index b36b0e42..287fcd06 100644
--- a/crates/fluss/src/client/table/scanner.rs
+++ b/crates/fluss/src/client/table/scanner.rs
@@ -316,9 +316,16 @@ impl<'a> TableScan<'a> {
Ok(self)
}
+ /// Creates a record-mode log scanner, polled for individual
[`ScanRecord`]s.
+ ///
+ /// Works on log tables and on primary-key (KV) tables. For a primary-key
+ /// table this subscribes to its CDC changelog: each [`ScanRecord`]
carries a
+ /// [`ChangeType`](crate::record::ChangeType) — `+I` (insert), `-U`
+ /// (update-before), `+U` (update-after) or `-D` (delete). A log table
yields
+ /// `+A` (append-only) for every record. Requires the ARROW log format.
pub fn create_log_scanner(self) -> Result<LogScanner> {
self.reject_limit("LogScanner")?;
- validate_scan_support(&self.table_info.table_path, &self.table_info)?;
+ validate_scan_support_inner(&self.table_info.table_path,
&self.table_info, true)?;
let inner = LogScannerInner::new(
&self.table_info,
self.metadata.clone(),
@@ -331,6 +338,12 @@ impl<'a> TableScan<'a> {
})
}
+ /// Creates a batch-mode log scanner that yields Arrow `RecordBatch`es.
+ ///
+ /// Log tables only. Primary-key tables are rejected because the Arrow
batch
+ /// path carries no per-record change types; read a primary-key table's
+ /// changelog with [`create_log_scanner`](Self::create_log_scanner)
instead.
+ /// Requires the ARROW log format.
pub fn create_record_batch_log_scanner(self) ->
Result<RecordBatchLogScanner> {
self.reject_limit("RecordBatchLogScanner")?;
validate_scan_support(&self.table_info.table_path, &self.table_info)?;
@@ -2225,10 +2238,29 @@ impl BucketScanStatus {
}
}
+/// Validates that `table_info` can be scanned as a log, rejecting primary-key
+/// tables (the default for batch-mode scans).
fn validate_scan_support(table_path: &TablePath, table_info: &TableInfo) ->
Result<()> {
- if table_info.schema.primary_key().is_some() {
+ validate_scan_support_inner(table_path, table_info, false)
+}
+
+/// Validates that `table_info` can be scanned as a log. ARROW log format is
+/// required; INDEXED is not supported by the client decoder.
+///
+/// When `allow_primary_key` is set, a primary-key table is accepted and its
+/// changelog is read as a CDC stream (each record carries a `ChangeType`). It
is
+/// cleared for the Arrow batch path, which has no slot for per-record change
+/// types, so reading a changelog there would silently drop them.
+fn validate_scan_support_inner(
+ table_path: &TablePath,
+ table_info: &TableInfo,
+ allow_primary_key: bool,
+) -> Result<()> {
+ if !allow_primary_key && table_info.schema.primary_key().is_some() {
return Err(UnsupportedOperation {
- message: format!("Table {table_path} is not a Log Table and
doesn't support scan."),
+ message: format!(
+ "Batch-mode log scan does not support primary-key table
{table_path}; use create_log_scanner() to read its changelog record by record"
+ ),
});
}
@@ -2236,7 +2268,7 @@ fn validate_scan_support(table_path: &TablePath,
table_info: &TableInfo) -> Resu
if LogFormat::ARROW != log_format {
return Err(UnsupportedOperation {
message: format!(
- "Scan is only supported for ARROW format and table
{table_path} uses {log_format} format"
+ "Log scan is only supported for ARROW log format, but table
{table_path} uses {log_format} format"
),
});
}
@@ -2529,35 +2561,33 @@ mod tests {
#[test]
fn test_validate_scan_support() {
- // Primary key table
+ // Record mode (allow_primary_key = true): a primary-key table's
changelog
+ // is scannable on ARROW or the default format.
let (table_info, table_path) = create_test_table_info(true,
Some("ARROW"));
- let result = validate_scan_support(&table_path, &table_info);
+ assert!(validate_scan_support_inner(&table_path, &table_info,
true).is_ok());
+ let (table_info, table_path) = create_test_table_info(true, None);
+ assert!(validate_scan_support_inner(&table_path, &table_info,
true).is_ok());
- assert!(result.is_err());
- let err = result.unwrap_err();
- assert!(matches!(err, UnsupportedOperation { .. }));
- assert!(err.to_string().contains(
- format!("Table {table_path} is not a Log Table and doesn't support
scan.").as_str()
- ));
-
- // Indexed format
- let (table_info, table_path) = create_test_table_info(false,
Some("INDEXED"));
- let result = validate_scan_support(&table_path, &table_info);
-
- assert!(result.is_err());
- let err = result.unwrap_err();
+ // Batch mode (allow_primary_key = false): a primary-key table is
rejected.
+ let (table_info, table_path) = create_test_table_info(true,
Some("ARROW"));
+ let err = validate_scan_support(&table_path, &table_info).unwrap_err();
assert!(matches!(err, UnsupportedOperation { .. }));
- assert!(err.to_string().contains(format!("Scan is only supported for
ARROW format and table {table_path} uses INDEXED format").as_str()));
+ assert!(err.to_string().contains("primary-key table"));
+
+ // INDEXED is unsupported regardless of table type or mode.
+ for allow_primary_key in [false, true] {
+ let (table_info, table_path) = create_test_table_info(false,
Some("INDEXED"));
+ let err = validate_scan_support_inner(&table_path, &table_info,
allow_primary_key)
+ .unwrap_err();
+ assert!(matches!(err, UnsupportedOperation { .. }));
+ assert!(err.to_string().contains("ARROW log format"));
+ }
- // Default format
+ // Log tables scan on ARROW or the default format.
let (table_info, table_path) = create_test_table_info(false, None);
- let result = validate_scan_support(&table_path, &table_info);
- assert!(result.is_ok());
-
- // Arrow format
+ assert!(validate_scan_support(&table_path, &table_info).is_ok());
let (table_info, table_path) = create_test_table_info(false,
Some("ARROW"));
- let result = validate_scan_support(&table_path, &table_info);
- assert!(result.is_ok());
+ assert!(validate_scan_support(&table_path, &table_info).is_ok());
}
#[tokio::test]
diff --git a/crates/fluss/src/record/arrow.rs b/crates/fluss/src/record/arrow.rs
index 33a0f6f4..025fe0d6 100644
--- a/crates/fluss/src/record/arrow.rs
+++ b/crates/fluss/src/record/arrow.rs
@@ -84,6 +84,12 @@ pub const RECORD_BATCH_HEADER_SIZE: usize = RECORDS_OFFSET;
pub const ARROW_CHANGETYPE_OFFSET: usize = RECORD_BATCH_HEADER_SIZE;
pub const LOG_OVERHEAD: usize = LENGTH_OFFSET + LENGTH_LENGTH;
+/// Bit 0 of the attributes byte. When set, the batch is append-only and
carries
+/// no change-type vector; when clear, a `record_count`-byte change-type vector
+/// precedes the Arrow IPC payload (the changelog of a primary-key table).
Shares
+/// the wire layout of the Java client's `DefaultLogRecordBatch`.
+pub const APPEND_ONLY_FLAG_MASK: u8 = 0x01;
+
/// Maximum batch size matches Java's Integer.MAX_VALUE limit.
/// Java uses int type for batch size, so max value is 2^31 - 1 =
2,147,483,647 bytes (~2GB).
/// This is the implicit limit in FileLogRecords.java and other Java
components.
@@ -952,6 +958,11 @@ impl LogRecordBatch {
self.data[ATTRIBUTES_OFFSET]
}
+ /// Whether this batch is append-only (see [`APPEND_ONLY_FLAG_MASK`]).
+ fn is_append_only(&self) -> bool {
+ self.attributes() & APPEND_ONLY_FLAG_MASK != 0
+ }
+
pub fn next_log_offset(&self) -> i64 {
self.last_log_offset() + 1
}
@@ -990,28 +1001,77 @@ impl LogRecordBatch {
LittleEndian::read_i32(&self.data[offset..offset +
RECORDS_COUNT_LENGTH])
}
+ /// Splits the batch body into its per-record change types and the trailing
+ /// Arrow IPC payload (see [`APPEND_ONLY_FLAG_MASK`] for the layout).
+ fn decode_change_types(&self) -> Result<(BatchChangeTypes, &[u8])> {
+ let body = self
+ .data
+ .get(RECORDS_OFFSET..)
+ .ok_or_else(|| Error::UnexpectedError {
+ message: format!(
+ "Corrupt log record batch: data length {} is less than
RECORDS_OFFSET {}",
+ self.data.len(),
+ RECORDS_OFFSET
+ ),
+ source: None,
+ })?;
+
+ if self.is_append_only() {
+ return Ok((BatchChangeTypes::Uniform(ChangeType::AppendOnly),
body));
+ }
+
+ let record_count = self.record_count();
+ if record_count < 0 {
+ return Err(Error::UnexpectedError {
+ message: format!("Corrupt changelog batch: negative record
count {record_count}"),
+ source: None,
+ });
+ }
+ let record_count = record_count as usize;
+ let (change_type_bytes, arrow_data) =
+ body.split_at_checked(record_count)
+ .ok_or_else(|| Error::UnexpectedError {
+ message: format!(
+ "Corrupt changelog batch: body length {} is smaller
than its \
+ {record_count}-record change-type vector",
+ body.len()
+ ),
+ source: None,
+ })?;
+
+ let mut change_types = Vec::with_capacity(record_count);
+ for &byte in change_type_bytes {
+ let change_type =
+ ChangeType::from_byte_value(byte).map_err(|message|
Error::UnexpectedError {
+ message,
+ source: None,
+ })?;
+ change_types.push(change_type);
+ }
+
+ Ok((BatchChangeTypes::PerRecord(change_types), arrow_data))
+ }
+
pub fn records(&self, read_context: &ReadContext) ->
Result<LogRecordIterator> {
if self.record_count() == 0 {
return Ok(LogRecordIterator::empty());
}
- let data = &self.data[RECORDS_OFFSET..];
-
- let record_batch = read_context.record_batch(data)?;
+ let (change_types, arrow_data) = self.decode_change_types()?;
+ let record_batch = read_context.record_batch(arrow_data)?;
let arrow_reader = ArrowReader::new_with_fluss_row_type(
Arc::new(record_batch),
read_context.row_type.clone(),
read_context.fluss_row_type().cloned(),
)?;
- let log_record_iterator =
LogRecordIterator::Arrow(ArrowLogRecordIterator {
- reader: arrow_reader,
- base_offset: self.base_log_offset(),
- timestamp: self.commit_timestamp(),
- row_id: 0,
- change_type: ChangeType::AppendOnly,
- });
+ let iterator = ArrowLogRecordIterator::new(
+ arrow_reader,
+ self.base_log_offset(),
+ self.commit_timestamp(),
+ change_types,
+ )?;
- Ok(log_record_iterator)
+ Ok(LogRecordIterator::Arrow(iterator))
}
pub fn records_for_remote_log(&self, read_context: &ReadContext) ->
Result<LogRecordIterator> {
@@ -1019,9 +1079,8 @@ impl LogRecordBatch {
return Ok(LogRecordIterator::empty());
}
- let data = &self.data[RECORDS_OFFSET..];
-
- let record_batch = read_context.record_batch_for_remote_log(data)?;
+ let (change_types, arrow_data) = self.decode_change_types()?;
+ let record_batch =
read_context.record_batch_for_remote_log(arrow_data)?;
let log_record_iterator = match record_batch {
None => LogRecordIterator::empty(),
Some(record_batch) => {
@@ -1030,13 +1089,13 @@ impl LogRecordBatch {
read_context.row_type.clone(),
read_context.fluss_row_type().cloned(),
)?;
- LogRecordIterator::Arrow(ArrowLogRecordIterator {
- reader: arrow_reader,
- base_offset: self.base_log_offset(),
- timestamp: self.commit_timestamp(),
- row_id: 0,
- change_type: ChangeType::AppendOnly,
- })
+ let iterator = ArrowLogRecordIterator::new(
+ arrow_reader,
+ self.base_log_offset(),
+ self.commit_timestamp(),
+ change_types,
+ )?;
+ LogRecordIterator::Arrow(iterator)
}
};
Ok(log_record_iterator)
@@ -1051,18 +1110,9 @@ impl LogRecordBatch {
return
Ok(RecordBatch::new_empty(read_context.target_schema.clone()));
}
- let data = self
- .data
- .get(RECORDS_OFFSET..)
- .ok_or_else(|| Error::UnexpectedError {
- message: format!(
- "Corrupt log record batch: data length {} is less than
RECORDS_OFFSET {}",
- self.data.len(),
- RECORDS_OFFSET
- ),
- source: None,
- })?;
- read_context.record_batch(data)
+ // Batch access drops the change-type vector; use `records()` for CDC.
+ let (_, arrow_data) = self.decode_change_types()?;
+ read_context.record_batch(arrow_data)
}
}
@@ -1645,24 +1695,63 @@ impl Iterator for LogRecordIterator {
}
}
+/// Per-record change types decoded from a log batch.
+///
+/// Append-only batches carry no change-type vector on the wire, so a single
+/// `AppendOnly` value covers every record without allocating. Changelog
batches
+/// (the CDC stream of a primary-key table) decode one change type per record,
+/// in record order.
+enum BatchChangeTypes {
+ /// Every record shares this change type (append-only batches).
+ Uniform(ChangeType),
+ /// One change type per record, indexed by row id (changelog batches).
+ PerRecord(Vec<ChangeType>),
+}
+
+impl BatchChangeTypes {
+ fn get(&self, row_id: usize) -> ChangeType {
+ match self {
+ BatchChangeTypes::Uniform(change_type) => *change_type,
+ BatchChangeTypes::PerRecord(change_types) => change_types[row_id],
+ }
+ }
+}
+
pub struct ArrowLogRecordIterator {
reader: ArrowReader,
base_offset: i64,
timestamp: i64,
row_id: usize,
- change_type: ChangeType,
+ change_types: BatchChangeTypes,
}
-#[allow(dead_code)]
impl ArrowLogRecordIterator {
- fn new(reader: ArrowReader, base_offset: i64, timestamp: i64, change_type:
ChangeType) -> Self {
- Self {
+ fn new(
+ reader: ArrowReader,
+ base_offset: i64,
+ timestamp: i64,
+ change_types: BatchChangeTypes,
+ ) -> Result<Self> {
+ if let BatchChangeTypes::PerRecord(ref change_types) = change_types {
+ if change_types.len() != reader.row_count() {
+ return Err(Error::UnexpectedError {
+ message: format!(
+ "Changelog batch decode mismatch: {} change types for
{} Arrow rows",
+ change_types.len(),
+ reader.row_count()
+ ),
+ source: None,
+ });
+ }
+ }
+
+ Ok(Self {
reader,
base_offset,
timestamp,
row_id: 0,
- change_type,
- }
+ change_types,
+ })
}
}
@@ -1679,7 +1768,7 @@ impl Iterator for ArrowLogRecordIterator {
columnar_row,
self.base_offset + self.row_id as i64,
self.timestamp,
- self.change_type,
+ self.change_types.get(self.row_id),
);
self.row_id += 1;
Some(scan_record)
@@ -1720,7 +1809,12 @@ pub struct MyVec<T>(pub StreamReader<T>);
#[cfg(test)]
mod tests {
use super::*;
- use crate::metadata::{DataField, DataTypes, RowType};
+ use crate::client::WriteRecord;
+ use crate::compression::{
+ ArrowCompressionInfo, ArrowCompressionType,
DEFAULT_NON_ZSTD_COMPRESSION_LEVEL,
+ };
+ use crate::metadata::{DataField, DataTypes, PhysicalTablePath, RowType,
TablePath};
+ use crate::row::{DataGetters, GenericRow};
use crate::test_utils::build_table_info;
#[test]
@@ -2349,4 +2443,164 @@ mod tests {
Ok(())
}
+
+ /// Builds an append-only `(id INT, name STRING)` Arrow log batch from
`rows`.
+ /// The writer always emits append-only batches, so changelog tests derive
+ /// their bytes from this with [`splice_change_type_vector`].
+ fn build_append_only_batch(rows: &[(i32, &str)]) -> (RowType, Vec<u8>) {
+ let row_type = RowType::new(vec![
+ DataField::new("id".to_string(), DataTypes::int(), None),
+ DataField::new("name".to_string(), DataTypes::string(), None),
+ ]);
+ let table_path = TablePath::new("db".to_string(), "tbl".to_string());
+ let table_info = Arc::new(build_table_info(table_path.clone(), 1, 1));
+ let physical_table_path =
Arc::new(PhysicalTablePath::of(Arc::new(table_path)));
+
+ let mut builder = MemoryLogRecordsArrowBuilder::new(
+ 1,
+ &row_type,
+ false,
+ ArrowCompressionInfo {
+ compression_type: ArrowCompressionType::None,
+ compression_level: DEFAULT_NON_ZSTD_COMPRESSION_LEVEL,
+ },
+ usize::MAX,
+ Arc::new(ArrowCompressionRatioEstimator::default()),
+ )
+ .unwrap();
+
+ for (id, name) in rows {
+ let mut row = GenericRow::new(2);
+ row.set_field(0, *id);
+ row.set_field(1, *name);
+ let record = WriteRecord::for_append(
+ Arc::clone(&table_info),
+ physical_table_path.clone(),
+ 1,
+ &row,
+ );
+ builder.append(&record).unwrap();
+ }
+
+ (row_type, builder.build().unwrap())
+ }
+
+ /// Turns an append-only batch into a wire-valid changelog batch: clears
the
+ /// append-only flag, splices one change-type byte per record between the
+ /// header and the Arrow payload, then fixes up the length field and CRC.
+ fn splice_change_type_vector(append_only: &[u8], change_types:
&[ChangeType]) -> Vec<u8> {
+ let mut data = append_only.to_vec();
+ data[ATTRIBUTES_OFFSET] &= !APPEND_ONLY_FLAG_MASK;
+ let change_bytes = change_types.iter().map(|ct| ct.to_byte_value());
+ data.splice(RECORDS_OFFSET..RECORDS_OFFSET, change_bytes);
+
+ let new_length = (data.len() - LOG_OVERHEAD) as i32;
+ data[LENGTH_OFFSET..LENGTH_OFFSET + LENGTH_LENGTH]
+ .copy_from_slice(&new_length.to_le_bytes());
+
+ let crc = crc32c(&data[SCHEMA_ID_OFFSET..]);
+ data[CRC_OFFSET..CRC_OFFSET +
CRC_LENGTH].copy_from_slice(&crc.to_le_bytes());
+ data
+ }
+
+ #[test]
+ fn decode_changelog_batch_applies_per_record_change_types() -> Result<()> {
+ let (row_type, append_only) =
+ build_append_only_batch(&[(1, "alice"), (2, "bob"), (3, "carol")]);
+ let read_context = ReadContext::new(to_arrow_schema(&row_type)?,
Arc::new(row_type), false);
+
+ // Append-only batch: every record decodes as AppendOnly (regression
guard).
+ let batch = LogRecordsBatches::new(append_only.clone())
+ .next()
+ .expect("append-only batch")?;
+ assert!(batch.is_append_only());
+ let records: Vec<_> = batch.records(&read_context)?.collect();
+ assert_eq!(records.len(), 3);
+ assert!(
+ records
+ .iter()
+ .all(|r| *r.change_type() == ChangeType::AppendOnly)
+ );
+
+ // Changelog variant: the spliced change-type vector drives per-record
types.
+ let change_types = [
+ ChangeType::Insert,
+ ChangeType::UpdateAfter,
+ ChangeType::Delete,
+ ];
+ let changelog = splice_change_type_vector(&append_only, &change_types);
+ let batch = LogRecordsBatches::new(changelog)
+ .next()
+ .expect("changelog batch")?;
+ assert!(!batch.is_append_only());
+ assert_eq!(batch.record_count(), 3);
+
+ let records: Vec<_> = batch.records(&read_context)?.collect();
+ let got: Vec<ChangeType> = records.iter().map(|r|
*r.change_type()).collect();
+ assert_eq!(got, change_types.to_vec());
+
+ // The row payload and offsets survive the splice unchanged.
+ let mut ids = Vec::new();
+ for record in &records {
+ ids.push(record.row().get_int(0)?);
+ }
+ assert_eq!(ids, vec![1, 2, 3]);
+ let offsets: Vec<i64> = records.iter().map(|r| r.offset()).collect();
+ assert_eq!(offsets, vec![0, 1, 2]);
+
+ // Batch-level access skips the change-type vector and still decodes
rows.
+ let batch =
LogRecordsBatches::new(splice_change_type_vector(&append_only, &change_types))
+ .next()
+ .expect("changelog batch")?;
+ assert_eq!(batch.record_batch(&read_context)?.num_rows(), 3);
+
+ Ok(())
+ }
+
+ #[test]
+ fn decode_changelog_batch_rejects_invalid_change_type_byte() {
+ let (row_type, append_only) = build_append_only_batch(&[(1, "a"), (2,
"b")]);
+ let read_context = ReadContext::new(
+ to_arrow_schema(&row_type).unwrap(),
+ Arc::new(row_type),
+ false,
+ );
+
+ let mut changelog =
+ splice_change_type_vector(&append_only, &[ChangeType::Insert,
ChangeType::Insert]);
+ // Corrupt the second change-type byte to an out-of-range value.
+ changelog[RECORDS_OFFSET + 1] = 99;
+
+ let batch = LogRecordBatch::new(Bytes::from(changelog));
+ let err = batch
+ .records(&read_context)
+ .err()
+ .expect("expected decode to reject an invalid change-type byte");
+ assert!(matches!(err, Error::UnexpectedError { .. }));
+ assert!(err.to_string().contains("change type"));
+ }
+
+ #[test]
+ fn decode_changelog_batch_rejects_truncated_change_type_vector() {
+ let (row_type, append_only) = build_append_only_batch(&[(1, "a"), (2,
"b")]);
+ let read_context = ReadContext::new(
+ to_arrow_schema(&row_type).unwrap(),
+ Arc::new(row_type),
+ false,
+ );
+
+ // Clear the append-only flag, then cut the body shorter than the
+ // record_count change-type bytes the decoder now expects.
+ let mut data = append_only;
+ data[ATTRIBUTES_OFFSET] &= !APPEND_ONLY_FLAG_MASK;
+ data.truncate(RECORDS_OFFSET + 1);
+
+ let batch = LogRecordBatch::new(Bytes::from(data));
+ assert_eq!(batch.record_count(), 2);
+ let err = batch
+ .records(&read_context)
+ .err()
+ .expect("expected decode to reject a truncated change-type
vector");
+ assert!(matches!(err, Error::UnexpectedError { .. }));
+ }
}
diff --git a/crates/fluss/tests/integration/kv_changelog.rs
b/crates/fluss/tests/integration/kv_changelog.rs
new file mode 100644
index 00000000..30a85fd3
--- /dev/null
+++ b/crates/fluss/tests/integration/kv_changelog.rs
@@ -0,0 +1,170 @@
+/*
+ * 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.
+ */
+
+#[cfg(test)]
+mod kv_changelog_test {
+ use crate::integration::utils::{create_table, get_shared_cluster};
+ use fluss::client::EARLIEST_OFFSET;
+ use fluss::metadata::{DataTypes, Schema, TableDescriptor, TablePath};
+ use fluss::record::{ChangeType, ScanRecord};
+ use fluss::row::{DataGetters, GenericRow};
+ use std::time::Duration;
+
+ /// Subscribing to a primary-key (KV) table yields its CDC changelog.
+ ///
+ /// With the default `FULL` changelog image: upserting a new key emits
`+I`,
+ /// overwriting an existing key emits `-U` (old image) then `+U` (new
image),
+ /// and a delete emits `-D` (old image). A single bucket keeps the
changelog
+ /// offsets contiguous so the sequence is deterministic. This exercises the
+ /// per-record change-type vector decode on a real cluster end to end.
+ #[tokio::test]
+ async fn subscribe_kv_table_changelog() {
+ let cluster = get_shared_cluster();
+ let connection = cluster.get_fluss_connection().await;
+ let admin = connection.get_admin().expect("Failed to get admin");
+
+ let table_path = TablePath::new("fluss",
"test_kv_changelog_subscribe");
+
+ let table_descriptor = TableDescriptor::builder()
+ .schema(
+ Schema::builder()
+ .column("id", DataTypes::int())
+ .column("name", DataTypes::string())
+ .primary_key(vec!["id"])
+ .build()
+ .expect("Failed to build schema"),
+ )
+ .distributed_by(Some(1), vec!["id".to_string()])
+ .build()
+ .expect("Failed to build table");
+
+ create_table(&admin, &table_path, &table_descriptor).await;
+
+ let table = connection
+ .get_table(&table_path)
+ .await
+ .expect("Failed to get table");
+ let upsert_writer = table
+ .new_upsert()
+ .expect("Failed to create upsert")
+ .create_writer()
+ .expect("Failed to create writer");
+
+ let make_row = |id: i32, name: &'static str| {
+ let mut row = GenericRow::new(2);
+ row.set_field(0, id);
+ row.set_field(1, name);
+ row
+ };
+
+ // Await each write so the changelog offsets are produced in a fixed
order.
+ // +I (1, "alice")
+ upsert_writer
+ .upsert(&make_row(1, "alice"))
+ .expect("upsert (1, alice)")
+ .await
+ .expect("ack (1, alice)");
+ // +I (2, "bob")
+ upsert_writer
+ .upsert(&make_row(2, "bob"))
+ .expect("upsert (2, bob)")
+ .await
+ .expect("ack (2, bob)");
+ // Overwrite id=1 -> -U (1, "alice") then +U (1, "alice2")
+ upsert_writer
+ .upsert(&make_row(1, "alice2"))
+ .expect("update (1, alice2)")
+ .await
+ .expect("ack (1, alice2)");
+ // -D (2, "bob")
+ let mut delete_key = GenericRow::new(2);
+ delete_key.set_field(0, 2);
+ upsert_writer
+ .delete(&delete_key)
+ .expect("delete (2)")
+ .await
+ .expect("ack delete (2)");
+
+ // Subscribe to the single bucket from the earliest changelog offset.
+ let log_scanner = table
+ .new_scan()
+ .create_log_scanner()
+ .expect("Failed to create log scanner");
+ log_scanner
+ .subscribe(0, EARLIEST_OFFSET)
+ .await
+ .expect("Failed to subscribe to bucket 0");
+
+ // Poll until the whole changelog is collected (or time out).
+ let mut records: Vec<ScanRecord> = Vec::new();
+ let start = std::time::Instant::now();
+ while records.len() < 5 && start.elapsed() < Duration::from_secs(15) {
+ let polled = log_scanner
+ .poll(Duration::from_millis(500))
+ .await
+ .expect("Failed to poll changelog");
+ records.extend(polled);
+ }
+
+ assert_eq!(records.len(), 5, "expected 5 changelog records");
+
+ let change_types: Vec<ChangeType> = records.iter().map(|r|
*r.change_type()).collect();
+ assert_eq!(
+ change_types,
+ vec![
+ ChangeType::Insert,
+ ChangeType::Insert,
+ ChangeType::UpdateBefore,
+ ChangeType::UpdateAfter,
+ ChangeType::Delete,
+ ],
+ "unexpected change-type sequence"
+ );
+
+ // Single bucket -> contiguous, in-order offsets.
+ let offsets: Vec<i64> = records.iter().map(|r| r.offset()).collect();
+ assert_eq!(offsets, vec![0, 1, 2, 3, 4]);
+
+ // Each record carries the correct before/after row image.
+ let rows: Vec<(i32, String)> = records
+ .iter()
+ .map(|r| {
+ let row = r.row();
+ (
+ row.get_int(0).unwrap(),
+ row.get_string(1).unwrap().to_string(),
+ )
+ })
+ .collect();
+ assert_eq!(
+ rows,
+ vec![
+ (1, "alice".to_string()), // +I
+ (2, "bob".to_string()), // +I
+ (1, "alice".to_string()), // -U (old image)
+ (1, "alice2".to_string()), // +U (new image)
+ (2, "bob".to_string()), // -D (old image)
+ ]
+ );
+
+ admin
+ .drop_table(&table_path, false)
+ .await
+ .expect("Failed to drop table");
+ }
+}
diff --git a/crates/fluss/tests/test_fluss.rs b/crates/fluss/tests/test_fluss.rs
index 2d2bd152..eb1e4070 100644
--- a/crates/fluss/tests/test_fluss.rs
+++ b/crates/fluss/tests/test_fluss.rs
@@ -23,6 +23,7 @@ mod integration {
mod admin;
mod batch_scanner;
mod fluss_cluster;
+ mod kv_changelog;
mod kv_table;
mod log_table;
mod record_batch_log_reader;
diff --git a/website/docs/user-guide/cpp/api-reference.md
b/website/docs/user-guide/cpp/api-reference.md
index c845010f..c5640a89 100644
--- a/website/docs/user-guide/cpp/api-reference.md
+++ b/website/docs/user-guide/cpp/api-reference.md
@@ -151,8 +151,8 @@ Complete API reference for the Fluss C++ client.
| `ProjectByIndex(std::vector<size_t> column_indices) -> TableScan&` |
Project columns by index |
| `ProjectByName(std::vector<std::string> column_names) -> TableScan&` |
Project columns by name |
| `Limit(int32_t row_number) -> TableScan&` | Set a
positive row limit (enables `CreateBucketBatchScanner`; rejected by log
scanners) |
-| `CreateLogScanner(LogScanner& out) -> Result` |
Create a record-based log scanner |
-| `CreateRecordBatchLogScanner(LogScanner& out) -> Result` |
Create an Arrow RecordBatch-based log scanner |
+| `CreateLogScanner(LogScanner& out) -> Result` |
Create a record-based log scanner; on a primary-key table, subscribes to its
CDC changelog (per-record `change_type`) |
+| `CreateRecordBatchLogScanner(LogScanner& out) -> Result` |
Create an Arrow RecordBatch-based log scanner (log tables only — no per-record
change types) |
| `CreateBucketBatchScanner(const TableBucket& bucket, BatchScanner& out) ->
Result` | Bounded scan of one bucket (requires `Limit`) |
## `AppendWriter`
diff --git a/website/docs/user-guide/cpp/example/primary-key-tables.md
b/website/docs/user-guide/cpp/example/primary-key-tables.md
index f0dfad90..1e5aa744 100644
--- a/website/docs/user-guide/cpp/example/primary-key-tables.md
+++ b/website/docs/user-guide/cpp/example/primary-key-tables.md
@@ -131,6 +131,36 @@ if (result.Found()) {
}
```
+## Subscribing to the Changelog (CDC)
+
+Every primary key table maintains a changelog of its row-level changes. Read
it with a record-mode `LogScanner` — the same API used for [log
tables](./log-tables.md) — to stream CDC events. Each `ScanRecord` carries a
`change_type`: `+I` (insert), `-U` / `+U` (the old and new images of an
update), and `-D` (delete, carrying the old row).
+
+```cpp
+fluss::LogScanner log_scanner;
+table.NewScan().CreateLogScanner(log_scanner);
+
+// Subscribe to every bucket from the start of the changelog.
+int32_t num_buckets = table.GetTableInfo().num_buckets;
+for (int32_t bucket_id = 0; bucket_id < num_buckets; ++bucket_id) {
+ log_scanner.Subscribe(bucket_id, fluss::EARLIEST_OFFSET);
+}
+
+while (true) {
+ fluss::ScanRecords records;
+ log_scanner.Poll(3000, records);
+ if (records.IsEmpty()) {
+ break; // caught up to the end of the changelog
+ }
+ for (auto rec : records) {
+ // rec.change_type is +I / -U / +U / -D (see ChangeTypeShortString in
the API reference)
+ std::cout << ChangeTypeShortString(rec.change_type) << " " <<
rec.row.GetInt32(0) << " "
+ << rec.row.GetString(1) << std::endl;
+ }
+}
+```
+
+With the default changelog mode (`'table.changelog.image' = 'FULL'`), updating
a key emits a `-U`/`+U` pair and deleting it emits `-D`; the `WAL` mode emits
only `+U` on update. To resume from a committed position instead of the start,
`Subscribe` at a specific offset.
+
## Limit Scan
To read up to `n` rows of a bucket's current state without supplying keys, use
a batch scanner. The server returns the deduplicated current rows as Arrow
batches, convenient for previews or analytics.
diff --git a/website/docs/user-guide/python/api-reference.md
b/website/docs/user-guide/python/api-reference.md
index eeac5acc..54d8af66 100644
--- a/website/docs/user-guide/python/api-reference.md
+++ b/website/docs/user-guide/python/api-reference.md
@@ -97,8 +97,8 @@ Supports `async with` statement (async context manager).
| `.project(indices) -> TableScan` | Project columns
by index |
| `.project_by_name(names) -> TableScan` | Project columns
by name |
| `.limit(n) -> TableScan` | Set a positive
row limit (enables `create_bucket_batch_scanner`; rejected by log scanners) |
-| `await .create_log_scanner() -> LogScanner` | Create
record-based scanner (for `poll()`) |
-| `await .create_record_batch_log_scanner() -> LogScanner` | Create
batch-based scanner (for `poll_arrow()`, `to_arrow()`, etc.) |
+| `await .create_log_scanner() -> LogScanner` | Create
record-based scanner (for `poll()`); on a primary-key table, subscribes to its
CDC changelog (per-record `change_type`) |
+| `await .create_record_batch_log_scanner() -> LogScanner` | Create
batch-based scanner (for `poll_arrow()`, `to_arrow()`, etc.); log tables only —
no per-record change types |
| `.create_bucket_batch_scanner(bucket) -> BatchScanner` | Bounded scan of
one bucket (requires `limit`; runs on first `next_batch()`) |
## `TableAppend`
diff --git a/website/docs/user-guide/python/example/primary-key-tables.md
b/website/docs/user-guide/python/example/primary-key-tables.md
index 3cbb8d57..ca8a82a0 100644
--- a/website/docs/user-guide/python/example/primary-key-tables.md
+++ b/website/docs/user-guide/python/example/primary-key-tables.md
@@ -60,6 +60,28 @@ partial_writer.upsert({"id": 1, "age": 27}) # only updates
age
await partial_writer.flush()
```
+## Subscribing to the Changelog (CDC)
+
+Every primary key table maintains a changelog of its row-level changes. Read
it with a record-mode scanner — the same API used for [log
tables](./log-tables.md) — to stream CDC events. Each `ScanRecord` carries a
`change_type`: `+I` (insert), `-U` / `+U` (the old and new images of an
update), and `-D` (delete, carrying the old row).
+
+```python
+table = await conn.get_table(table_path)
+scanner = await table.new_scan().create_log_scanner()
+
+# Subscribe to every bucket from the start of the changelog.
+num_buckets = (await admin.get_table_info(table_path)).num_buckets
+scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in
range(num_buckets)})
+
+while True:
+ records = await scanner.poll(3000)
+ if records.is_empty():
+ break
+ for record in records:
+ print(record.change_type.short_string(), record.row) # +I / -U / +U /
-D
+```
+
+With the default changelog mode (`'table.changelog.image' = 'FULL'`), updating
a key emits a `-U`/`+U` pair and deleting it emits `-D`; the `WAL` mode emits
only `+U` on update. To resume from a committed position instead of the start,
`subscribe` at a specific offset.
+
## Limit Scan
To read up to `n` rows of a bucket's current state without supplying keys, use
a batch scanner. The server returns the deduplicated current rows as Arrow
batches — convenient for previews or DataFusion sources.
diff --git a/website/docs/user-guide/rust/api-reference.md
b/website/docs/user-guide/rust/api-reference.md
index 49c54823..09618e5a 100644
--- a/website/docs/user-guide/rust/api-reference.md
+++ b/website/docs/user-guide/rust/api-reference.md
@@ -145,8 +145,8 @@ series are shared by `AppendWriter` (log tables) and
`UpsertWriter` (PK tables).
| `fn project(self, indices: &[usize]) -> Result<Self>`
| Project columns by index |
| `fn project_by_name(self, names: &[&str]) -> Result<Self>`
| Project columns by name |
| `fn limit(self, n: i32) -> Result<Self>`
| Set a row limit (enables `create_bucket_batch_scanner`; rejected by log
scanners) |
-| `fn create_log_scanner(self) -> Result<LogScanner>`
| Create a record-based log scanner |
-| `fn create_record_batch_log_scanner(self) -> Result<RecordBatchLogScanner>`
| Create an Arrow batch-based log scanner |
+| `fn create_log_scanner(self) -> Result<LogScanner>`
| Create a record-based log scanner; on a primary-key table, subscribes to its
CDC changelog (per-record `ChangeType`) |
+| `fn create_record_batch_log_scanner(self) -> Result<RecordBatchLogScanner>`
| Create an Arrow batch-based log scanner (log tables only — no per-record
change types) |
| `fn create_bucket_batch_scanner(self, bucket: TableBucket) ->
Result<LimitBatchScanner>` | Bounded scan of one bucket (requires `limit`; runs
on first `next_batch`) |
## `LogScanner`
@@ -245,6 +245,23 @@ server-deduplicated state); yields a single batch of at
most `n` rows.
| `fn timestamp(&self) -> i64` | Record timestamp
|
| `fn change_type(&self) -> &ChangeType` | Change type (AppendOnly, Insert,
etc.) |
+## `ChangeType`
+
+The kind of change a `ScanRecord` represents. Log (append-only) tables always
+produce `AppendOnly`; scanning a primary-key table's changelog produces the CDC
+variants below.
+
+| Variant | Symbol | Meaning |
+|----------------|--------|---------------------------------------------|
+| `AppendOnly` | `+A` | Append-only record (log tables) |
+| `Insert` | `+I` | A new primary key was inserted |
+| `UpdateBefore` | `-U` | Row image *before* an update |
+| `UpdateAfter` | `+U` | Row image *after* an update |
+| `Delete` | `-D` | A primary key was deleted (carries old row) |
+
+With the default `FULL` changelog mode (`table.changelog.image`), updating an
+existing key emits `-U` then `+U`; the `WAL` mode emits only `+U`.
+
## `ScanRecords`
| Method |
Description |
diff --git a/website/docs/user-guide/rust/example/primary-key-tables.md
b/website/docs/user-guide/rust/example/primary-key-tables.md
index 01836e29..b3d03823 100644
--- a/website/docs/user-guide/rust/example/primary-key-tables.md
+++ b/website/docs/user-guide/rust/example/primary-key-tables.md
@@ -121,6 +121,48 @@ let batch = result.to_record_batch()?;
println!("Rows: {}", batch.num_rows());
```
+## Subscribing to the Changelog (CDC)
+
+Every primary key table maintains a changelog of its row-level changes. Read it
+with a record-mode `LogScanner` — the same API used for [log
tables](./log-tables.md) —
+to stream CDC events. Each `ScanRecord` carries a `ChangeType`:
+
+- `+I` — a new key was inserted
+- `-U` / `+U` — the old and new images of an updated key
+- `-D` — a key was deleted (the record carries the old row)
+
+```rust
+use fluss::client::EARLIEST_OFFSET;
+use std::time::Duration;
+
+let table = conn.get_table(&table_path).await?;
+let log_scanner = table.new_scan().create_log_scanner()?;
+
+// Subscribe to every bucket from the start of the changelog.
+let num_buckets = table.get_table_info().get_num_buckets();
+for bucket in 0..num_buckets {
+ log_scanner.subscribe(bucket, EARLIEST_OFFSET).await?;
+}
+
+loop {
+ let records = log_scanner.poll(Duration::from_secs(1)).await?;
+ for record in records {
+ let row = record.row();
+ println!(
+ "{} id={} name={}",
+ record.change_type().short_string(), // +I / -U / +U / -D
+ row.get_int(0)?,
+ row.get_string(1)?,
+ );
+ }
+}
+```
+
+With the default changelog mode (`'table.changelog.image' = 'FULL'`), updating
a
+key emits a `-U`/`+U` pair and deleting it emits `-D`; the `WAL` mode emits
only
+`+U` on update. To resume from a previously committed position rather than the
+start, `subscribe` at a specific offset instead of `EARLIEST_OFFSET`.
+
## Prefix Lookup
To fetch all rows sharing a common primary-key prefix (by choosing a bucket
key that's a strict prefix of the primary key), see [Prefix
Lookup](./prefix-lookup.md).