Skip to content

Commit 4b6efb3

Browse files
committed
[Blog] Fluss Rust SDK introduction blog
1 parent 05b6f66 commit 4b6efb3

3 files changed

Lines changed: 160 additions & 1 deletion

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
---
2+
slug: fluss-rust-sdk
3+
title: "Why Apache Fluss Chose Rust for Its Multi-Language SDK"
4+
authors: [yuxia, keithlee, anton]
5+
image: ./assets/fluss_rust/banner.jpg
6+
---
7+
8+
![Banner](assets/fluss_rust/banner.jpg)
9+
10+
If you maintain a data system that only speaks Java, you will eventually hear from someone who doesn't. A Python team building a feature store. A C++ service that needs sub-millisecond writes. An AI agent that wants to call your system through a tool binding. They all need the same capabilities (writes, reads, lookups) and none of them want to spin up a JVM to get them.
11+
12+
Apache Fluss, streaming storage for real-time analytics and AI, hit this exact inflection point. The [Java client](/blog/fluss-java-client) works well for Flink-based compute, where the JVM is already the world you live in. But outside that world, asking consumers to run a JVM sidecar just to write a record or look up a key creates friction that compounds across every service, every pipeline, every agent in the stack.
13+
14+
We could have written a separate client for each language. Maintain five copies of the wire protocol, five implementations of the batching logic, five sets of retry semantics and idempotence tracking. That path scales linearly with languages and ends predictably: the Java client gets features first, the Python client gets them six months later with slightly different edge-case behavior, and the C++ client is perpetually "almost done."
15+
16+
We took a different path and tried to leverage the lessons of the great.
17+
18+
<!-- truncate -->
19+
20+
## The librdkafka Model
21+
22+
If you've worked with Kafka clients outside of Java, you've probably used [librdkafka](https://github.com/confluentinc/librdkafka) without knowing it. It's a single C library that powers `confluent-kafka-python`, `confluent-kafka-go`, and others. One core handles the wire protocol, batching, memory management, and delivery semantics. Each language binding is a thin wrapper, a glue on top of a battle-tested engine.
23+
24+
The model is elegant because it inverts the usual maintenance equation. Instead of N full client implementations that diverge over time, each developing its own bugs, its own subtle behavioral differences, its own backlog of features the Java client has but the Python client doesn't yet, you get one implementation and N thin bindings that stay in sync by construction. A bug gets fixed once, and every language picks it up on the next build.
25+
26+
The deeper benefit is correctness, not just code reuse. When you maintain three separate implementations of a client protocol, behavioral drift is inevitable. Edge cases in retry logic, subtle differences in how backpressure kicks in, inconsistencies in how idempotent writes handle sequence numbers. These are the bugs that don't show up in unit tests but surface in production under load, and they surface differently in each language.
27+
28+
We built fluss-rust on this same idea. A single Rust core implements the full Fluss client protocol (Protobuf-based RPC, record batching with backpressure, background I/O, Arrow serialization, idempotent writes, SASL authentication) and exposes it to three languages:
29+
30+
- **Rust**: directly, as the `fluss-rs` crate
31+
- **Python**: via [PyO3](https://pyo3.rs), the Rust-Python bridge
32+
- **C++**: via [CXX](https://cxx.rs), the Rust-C++ bridge
33+
34+
To give a sense of proportion: the Rust core is roughly 40k lines, while the Python binding is around 5k and the C++ binding around 6k. The bindings handle type conversion, async runtime bridging, and memory ownership at the language boundary, but all the protocol logic, batching, Arrow codec, and retry handling live in the shared core.
35+
36+
## Why Rust and Not C
37+
38+
C would have been the obvious choice. librdkafka already proves the model works at enormous scale, and the C ABI is the universal language of foreign function interfaces.
39+
40+
We chose Rust, and the reason is specific rather than philosophical: compile-time safety is a force multiplier for a small team maintaining a shared core that multiple languages depend on.
41+
42+
Getting memory safety right in C means manual lifetime tracking, careful code review, and years of experience knowing where the subtle bugs hide. Rust gives the same zero-overhead profile (no garbage collector, no runtime) but checks those invariants at compile time instead.
43+
44+
To make this concrete: the Fluss write path moves ownership from the caller through a concurrent map, into a background event loop, and back out to futures the caller may or may not still be holding. In C, getting that right is a matter of discipline, and getting it wrong means segfaults that only reproduce under production load. In Rust, the borrow checker and the `Send`/`Sync` traits catch those problems before the code ever runs.
45+
46+
We're not alone here. Polars, Apache OpenDAL, and delta-rs all chose Rust as a shared core with language bindings on top. Fluss's Rust SDK sits in that lineage.
47+
48+
## Relationship with the Java Client
49+
50+
The Java client remains the primary integration point for Flink, powering the SQL connector, the DataStream API, and the tightest path for JVM-based streaming compute. If your workload is Flink reading from and writing to Fluss, that's still the right client to use.
51+
52+
fluss-rust isn't trying to replace it. It exists for the consumers the Java client was never designed to serve: Python pipelines, C++ services, Rust applications, and anything else that doesn't want a JVM in the process. Both clients talk the same wire protocol and get the same server-side behavior. Most teams will end up using both, the Java client for Flink and fluss-rust for everything around it.
53+
54+
## What the Rust Core Covers
55+
56+
The Rust core implements the complete Fluss client protocol. Here's how the pieces fit together.
57+
58+
When you write a record, the call is synchronous: the record gets queued into a per-bucket batch without touching the network. A background sender task picks up ready batches and ships them as RPCs to the responsible TabletServers. This follows the same pattern as both the Fluss Java client and Kafka producers.
59+
60+
The caller gets back a `WriteResultFuture`. Await it to block until the server confirms, or drop it for fire-and-forget. Either way, the server acknowledges the write with acks=all by default, so dropping the future skips the client-side wait, not the durability guarantee.
61+
62+
Batches ship automatically when they fill up or after a short timeout (100ms by default), so `flush()` isn't needed for data to reach the server. It's there for when you need to confirm that everything in flight has landed. If the write buffer fills up, new writes block until space frees up rather than silently consuming unbounded memory.
63+
64+
Fluss has two table types (primary key tables and log tables), and the Rust core has a writer for each: `UpsertWriter` for keyed upserts and deletes, `AppendWriter` for append-only log writes. Both support idempotent delivery, and `AppendWriter` can also accept Arrow `RecordBatch` directly if you already have columnar data.
65+
66+
For reads, `LogScanner` provides streaming consumption with offset tracking. You can push column projection down to the server, so if you only need three columns out of twenty, only those three travel over the network. Each record also carries changelog metadata (offset, timestamp, and change type like `AppendOnly`, `Insert`, `UpdateBefore`, `UpdateAfter`, `Delete`), which is how Fluss exposes its CDC semantics to consumers outside of Flink.
67+
68+
`Lookuper` handles the other access pattern: point queries against primary key tables. You encode a key, and the client resolves the bucket, finds the leader, and returns the row. The response is compact binary, and the row is only deserialized when you first access a field.
69+
70+
The core also covers admin operations (creating and dropping databases and tables, schema management) and SASL/PLAIN authentication at the connection level.
71+
72+
## Arrow on the Wire
73+
74+
Arrow deserves its own section because it's the architectural decision that makes the multi-language model work end to end, not just at the API level but down to the bytes on the wire.
75+
76+
Fluss transmits data as Arrow IPC, compressed with ZSTD by default. When you write an Arrow `RecordBatch`, it goes straight into the wire format. When you scan, the response comes back as Arrow `RecordBatch`. The data stays in Arrow throughout, which means there's no serialization boundary between the Rust core and the caller.
77+
78+
This matters most at the language boundary. The Python binding already supports full Arrow interop in both directions: `poll_arrow()` returns scan results as a PyArrow Table, and `write_arrow_batch()` accepts a PyArrow RecordBatch for writes. Both cross the Rust-Python boundary without copying, because `arrow-pyarrow` shares the underlying memory buffers. A scan result goes straight from the Rust core into PyArrow, and from there into Pandas, Polars, or DuckDB with no conversion step.
79+
80+
On the C++ side, the Arrow C Data Interface handles the same zero-copy handoff for callers that export or import Arrow arrays.
81+
82+
Looking ahead, Arrow also makes [Apache DataFusion](https://datafusion.apache.org/) integration straightforward. DataFusion's table providers already expect Arrow, so wiring fluss-rust as a data source is a natural extension.
83+
84+
## What This Looks Like in Practice
85+
86+
Suppose a Flink job consumes CDC from Postgres, computes user features, and writes them into a Fluss primary key table. A Python scoring service needs to look up those features before running a model. With the Python binding, that's a few lines:
87+
88+
```python
89+
from fluss import FlussConnection, Config, TablePath
90+
91+
conn = await FlussConnection.create(Config({"bootstrap.servers": "fluss:9123"}))
92+
table = await conn.get_table(TablePath("analytics", "user_features"))
93+
94+
lookuper = table.new_lookup().create_lookuper()
95+
96+
result = await lookuper.lookup({"user_id": request.user_id})
97+
score = model.predict(result)
98+
```
99+
100+
The lookup goes through the same Protobuf RPC and hits the same KV store on the TabletServer that the Java client would use. The Python service just doesn't need a JVM to get there.
101+
102+
On the write side, consider an IoT gateway written in C++ that pushes sensor readings into a Fluss log table. It can't afford to block on each record, so it queues them and lets the Rust core handle batching and delivery:
103+
104+
```cpp
105+
fluss::AppendWriter writer;
106+
table.NewAppend().CreateWriter(writer);
107+
108+
for (const auto& event : events) {
109+
fluss::GenericRow row;
110+
// ... populate row from event ...
111+
writer.Append(row); // queued in Rust, sent automatically
112+
}
113+
```
114+
115+
## When the Caller Is a Machine
116+
117+
The examples above involve humans writing Python or C++ code. But increasingly, the caller isn't a human at all. AI agents interact with data infrastructure through tool calls, and the way they use a client library is different from how a developer does.
118+
119+
An agent that needs to check a user's subscription tier or write back a recommendation doesn't read documentation or understand batching internals. It sees a tool definition: `lookup(table, key)`, `upsert(table, row)`, `flush()`. The smaller and more predictable that interface is, the more reliably the agent uses it. If you've worked with LLM tool-calling, you've seen how quickly reliability degrades as the number of functions or the complexity of their signatures grows.
120+
121+
This is where the single-core architecture pays off in a way we didn't originally design for. Because the Rust core hides all the protocol and batching complexity, the Python binding exposes a small set of straightforward functions. An agent calls `await lookuper.lookup(key)`, gets a dict back, and moves on. No JVM to manage, no sidecar to health-check, just a Python function that happens to run compiled Rust underneath.
122+
123+
More broadly, as we discussed in [What does Apache Fluss mean in the context of AI?](/blog/fluss-for-ai), real-time intelligent systems need fresh features, evolving context, and continuously updated state. Fluss fits naturally as a context store for these systems, and the Rust SDK is what makes that context store accessible outside the JVM world.
124+
125+
## Lessons Learned
126+
127+
We built the full client in Rust first and added bindings afterward. This takes more upfront design work (which types cross the FFI boundary, what ownership looks like for shared objects, how errors propagate) but the maintenance savings compound quickly. If we'd started with bindings in parallel, the protocol logic would have drifted between languages before we even reached 1.0.
128+
129+
[PyO3](https://pyo3.rs) and [CXX](https://cxx.rs) made the binding layers practical. Both handle async runtimes, memory ownership, and type conversion with minimal boilerplate. The main thing we had to get right ourselves was where async complexity lives. Our rule: it stays in Rust. Python spawns eagerly on Tokio and returns an asyncio-compatible future. C++ blocks on the Tokio runtime. Each language gets the simplest interface for its own concurrency model. Getting the `WriteResultFuture` drop semantics right (ensuring dropped futures don't leak memory or leave batches in limbo) was one of the harder design problems, but once it worked in the Rust core, it worked in every binding automatically.
130+
131+
We also learned early on to test the full round trip, not just the core. We have integration tests for Rust, Python, and C++ that all run against a real Fluss cluster. Some of the bugs we found only showed up at the boundary (a mishandled error code in PyO3, a lifetime issue in CXX) while the Rust core's own tests all passed.
132+
133+
## What's Next
134+
135+
The core read and write paths work, but there are gaps to close before the Rust SDK reaches full parity with the Java client. Complex data types (Array, Map, Row) aren't supported yet, which limits what schemas you can work with. Limit scans and batch scanning are in progress, and once those land with Python and C++ bindings, the SDK becomes usable for a wider range of analytical workloads. We also want to support subscribing to primary key table changelogs, which would let non-Flink consumers track how keyed state evolves over time.
136+
137+
On the infrastructure side, we're adding client metrics so operators can monitor the SDK in production, and improving Python ergonomics with async iterator support for log scanning.
138+
139+
Beyond feature parity, there are two directions we're especially excited about.
140+
141+
The first is **DataFusion integration**. The Rust core already produces Arrow RecordBatches, which is exactly what DataFusion's table provider interface expects. Wiring the two together would let users run SQL queries directly over Fluss data from Rust or Python, without going through Flink.
142+
143+
The second is a **Fluss gateway service** built on top of the Rust core. Not every environment can load a native library. A lightweight Rust-based gateway could expose Fluss over HTTP or gRPC, making it accessible from any language or tool that can make a network call. The Rust SDK gives us the right foundation for that: a single process that handles the protocol, batching, and connection management, and serves multiple clients over a simple API.
144+
145+
If any of this is interesting to you, we welcome contributions, bug reports, and feedback.
146+
147+
---
148+
And before you go 😊 don't forget to give some ❤️ via ⭐ on GitHub: [Apache Fluss](https://github.com/apache/fluss) and [Fluss Rust SDK](https://github.com/apache/fluss-rust)
149+
755 KB
Loading

website/blog/authors.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,14 @@ jacopogardini:
6262
url: https://github.com/Lourousa
6363
image_url: /img/avatars/jacopogardini.png
6464

65-
65+
anton:
66+
name: Anton Borisov
67+
title: Contributor of Apache Fluss (Incubating)
68+
url: https://github.com/fresh-borzoni
69+
image_url: https://github.com/fresh-borzoni.png
70+
71+
keithlee:
72+
name: Keith Lee
73+
title: Apache Fluss (Incubating) Committer
74+
url: https://github.com/leekeiabstraction
75+
image_url: https://github.com/leekeiabstraction.png

0 commit comments

Comments
 (0)