This is an automated email from the ASF dual-hosted git repository.
fresh-borzoni 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 3b9a0f36 [rust][writer] Add DynamicWriteBatchSizeEstimator (#532)
3b9a0f36 is described below
commit 3b9a0f3674d1ed757403503ea48ed83eb1db740c
Author: Anton Borisov <[email protected]>
AuthorDate: Sun May 10 12:08:21 2026 +0100
[rust][writer] Add DynamicWriteBatchSizeEstimator (#532)
* [rust][writer] Add DynamicWriteBatchSizeEstimator
* [rust][writer] Match Java defaults and key shape
* address feedback
---
bindings/cpp/include/fluss.hpp | 6 +-
bindings/cpp/src/ffi_converter.hpp | 2 +
bindings/cpp/src/lib.rs | 4 +
bindings/elixir/lib/fluss/config.ex | 17 +-
bindings/elixir/native/fluss_nif/src/config.rs | 8 +
bindings/python/fluss/__init__.pyi | 8 +
bindings/python/src/config.rs | 43 ++++
crates/fluss/src/client/write/accumulator.rs | 239 +++++++++++++++++++--
.../fluss/src/client/write/dynamic_batch_size.rs | 147 +++++++++++++
crates/fluss/src/client/write/mod.rs | 1 +
crates/fluss/src/config.rs | 30 +++
website/docs/user-guide/cpp/api-reference.md | 4 +-
website/docs/user-guide/python/api-reference.md | 4 +-
website/docs/user-guide/rust/api-reference.md | 4 +-
14 files changed, 488 insertions(+), 29 deletions(-)
diff --git a/bindings/cpp/include/fluss.hpp b/bindings/cpp/include/fluss.hpp
index 3d39783f..d019b427 100644
--- a/bindings/cpp/include/fluss.hpp
+++ b/bindings/cpp/include/fluss.hpp
@@ -1232,8 +1232,12 @@ struct Configuration {
std::string writer_acks{"all"};
// Max number of writer retries
int32_t writer_retries{std::numeric_limits<int32_t>::max()};
- // Writer batch size in bytes (2 MB)
+ // Writer batch size in bytes (2 MB), also the upper bound when dynamic
sizing is on
int32_t writer_batch_size{2 * 1024 * 1024};
+ // Tune the per-table writer batch size from observed fill ratios
+ bool writer_dynamic_batch_size_enabled{true};
+ // Lower bound (256 KB) for the dynamic batch size estimator
+ int32_t writer_dynamic_batch_size_min{256 * 1024};
// Bucket assigner for tables without bucket keys: "sticky" or
"round_robin"
std::string writer_bucket_no_key_assigner{"sticky"};
// Number of remote log batches to prefetch during scanning
diff --git a/bindings/cpp/src/ffi_converter.hpp
b/bindings/cpp/src/ffi_converter.hpp
index 430dd199..47453d99 100644
--- a/bindings/cpp/src/ffi_converter.hpp
+++ b/bindings/cpp/src/ffi_converter.hpp
@@ -135,6 +135,8 @@ inline ffi::FfiConfig to_ffi_config(const Configuration&
config) {
ffi_config.writer_acks = rust::String(config.writer_acks);
ffi_config.writer_retries = config.writer_retries;
ffi_config.writer_batch_size = config.writer_batch_size;
+ ffi_config.writer_dynamic_batch_size_enabled =
config.writer_dynamic_batch_size_enabled;
+ ffi_config.writer_dynamic_batch_size_min =
config.writer_dynamic_batch_size_min;
ffi_config.writer_bucket_no_key_assigner =
rust::String(config.writer_bucket_no_key_assigner);
ffi_config.scanner_remote_log_prefetch_num =
config.scanner_remote_log_prefetch_num;
ffi_config.remote_file_download_thread_num =
config.remote_file_download_thread_num;
diff --git a/bindings/cpp/src/lib.rs b/bindings/cpp/src/lib.rs
index 52df3686..ed575244 100644
--- a/bindings/cpp/src/lib.rs
+++ b/bindings/cpp/src/lib.rs
@@ -46,6 +46,8 @@ mod ffi {
writer_acks: String,
writer_retries: i32,
writer_batch_size: i32,
+ writer_dynamic_batch_size_enabled: bool,
+ writer_dynamic_batch_size_min: i32,
writer_bucket_no_key_assigner: String,
scanner_remote_log_prefetch_num: usize,
remote_file_download_thread_num: usize,
@@ -907,6 +909,8 @@ fn new_connection(config: &ffi::FfiConfig) ->
ffi::FfiPtrResult {
writer_acks: config.writer_acks.to_string(),
writer_retries: config.writer_retries,
writer_batch_size: config.writer_batch_size,
+ writer_dynamic_batch_size_enabled:
config.writer_dynamic_batch_size_enabled,
+ writer_dynamic_batch_size_min: config.writer_dynamic_batch_size_min,
writer_batch_timeout_ms: config.writer_batch_timeout_ms,
writer_bucket_no_key_assigner: assigner_type,
scanner_remote_log_prefetch_num:
config.scanner_remote_log_prefetch_num,
diff --git a/bindings/elixir/lib/fluss/config.ex
b/bindings/elixir/lib/fluss/config.ex
index 1120a5f4..f12f61e4 100644
--- a/bindings/elixir/lib/fluss/config.ex
+++ b/bindings/elixir/lib/fluss/config.ex
@@ -34,12 +34,16 @@ defmodule Fluss.Config do
@enforce_keys [:bootstrap_servers]
defstruct bootstrap_servers: nil,
writer_batch_size: nil,
- writer_batch_timeout_ms: nil
+ writer_batch_timeout_ms: nil,
+ writer_dynamic_batch_size_enabled: nil,
+ writer_dynamic_batch_size_min: nil
@type t :: %__MODULE__{
bootstrap_servers: String.t(),
writer_batch_size: non_neg_integer() | nil,
- writer_batch_timeout_ms: non_neg_integer() | nil
+ writer_batch_timeout_ms: non_neg_integer() | nil,
+ writer_dynamic_batch_size_enabled: boolean() | nil,
+ writer_dynamic_batch_size_min: non_neg_integer() | nil
}
@spec new(String.t()) :: t()
@@ -62,6 +66,15 @@ defmodule Fluss.Config do
def set_writer_batch_timeout_ms(%__MODULE__{} = config, ms) when
is_integer(ms),
do: %{config | writer_batch_timeout_ms: ms}
+ @spec set_writer_dynamic_batch_size_enabled(t(), boolean()) :: t()
+ def set_writer_dynamic_batch_size_enabled(%__MODULE__{} = config, enabled)
+ when is_boolean(enabled),
+ do: %{config | writer_dynamic_batch_size_enabled: enabled}
+
+ @spec set_writer_dynamic_batch_size_min(t(), non_neg_integer()) :: t()
+ def set_writer_dynamic_batch_size_min(%__MODULE__{} = config, size) when
is_integer(size),
+ do: %{config | writer_dynamic_batch_size_min: size}
+
@spec get_bootstrap_servers(t()) :: String.t()
def get_bootstrap_servers(%__MODULE__{bootstrap_servers: servers}), do:
servers
end
diff --git a/bindings/elixir/native/fluss_nif/src/config.rs
b/bindings/elixir/native/fluss_nif/src/config.rs
index 536ed68f..8bbdfad9 100644
--- a/bindings/elixir/native/fluss_nif/src/config.rs
+++ b/bindings/elixir/native/fluss_nif/src/config.rs
@@ -25,6 +25,8 @@ pub struct NifConfig {
pub bootstrap_servers: String,
pub writer_batch_size: Option<i32>,
pub writer_batch_timeout_ms: Option<i64>,
+ pub writer_dynamic_batch_size_enabled: Option<bool>,
+ pub writer_dynamic_batch_size_min: Option<i32>,
}
impl NifConfig {
@@ -39,6 +41,12 @@ impl NifConfig {
if let Some(ms) = self.writer_batch_timeout_ms {
config.writer_batch_timeout_ms = ms;
}
+ if let Some(enabled) = self.writer_dynamic_batch_size_enabled {
+ config.writer_dynamic_batch_size_enabled = enabled;
+ }
+ if let Some(size) = self.writer_dynamic_batch_size_min {
+ config.writer_dynamic_batch_size_min = size;
+ }
config
}
}
diff --git a/bindings/python/fluss/__init__.pyi
b/bindings/python/fluss/__init__.pyi
index fc713973..5a59ea51 100644
--- a/bindings/python/fluss/__init__.pyi
+++ b/bindings/python/fluss/__init__.pyi
@@ -164,6 +164,14 @@ class Config:
@writer_batch_size.setter
def writer_batch_size(self, size: int) -> None: ...
@property
+ def writer_dynamic_batch_size_enabled(self) -> bool: ...
+ @writer_dynamic_batch_size_enabled.setter
+ def writer_dynamic_batch_size_enabled(self, enabled: bool) -> None: ...
+ @property
+ def writer_dynamic_batch_size_min(self) -> int: ...
+ @writer_dynamic_batch_size_min.setter
+ def writer_dynamic_batch_size_min(self, size: int) -> None: ...
+ @property
def writer_bucket_no_key_assigner(self) -> str: ...
@writer_bucket_no_key_assigner.setter
def writer_bucket_no_key_assigner(self, value: str) -> None: ...
diff --git a/bindings/python/src/config.rs b/bindings/python/src/config.rs
index 65bcc9ad..11188bf3 100644
--- a/bindings/python/src/config.rs
+++ b/bindings/python/src/config.rs
@@ -60,6 +60,25 @@ impl Config {
FlussError::new_err(format!("Invalid value
'{value}' for '{key}': {e}"))
})?;
}
+ "writer.dynamic-batch-size.enabled" => {
+ config.writer_dynamic_batch_size_enabled = match
value.as_str() {
+ "true" => true,
+ "false" => false,
+ other => {
+ return Err(FlussError::new_err(format!(
+ "Invalid value '{other}' for '{key}',
expected 'true' or 'false'"
+ )));
+ }
+ };
+ }
+ "writer.dynamic-batch-size-min" => {
+ config.writer_dynamic_batch_size_min =
+ value.parse::<i32>().map_err(|e| {
+ FlussError::new_err(format!(
+ "Invalid value '{value}' for '{key}': {e}"
+ ))
+ })?;
+ }
"writer.batch-timeout-ms" => {
config.writer_batch_timeout_ms =
value.parse::<i64>().map_err(|e| {
FlussError::new_err(format!("Invalid value
'{value}' for '{key}': {e}"))
@@ -250,6 +269,30 @@ impl Config {
self.inner.writer_batch_size = size;
}
+ /// Get whether the per-table dynamic batch size estimator is enabled
+ #[getter]
+ fn writer_dynamic_batch_size_enabled(&self) -> bool {
+ self.inner.writer_dynamic_batch_size_enabled
+ }
+
+ /// Set whether the per-table dynamic batch size estimator is enabled
+ #[setter]
+ fn set_writer_dynamic_batch_size_enabled(&mut self, enabled: bool) {
+ self.inner.writer_dynamic_batch_size_enabled = enabled;
+ }
+
+ /// Get the lower bound used by the dynamic batch size estimator
+ #[getter]
+ fn writer_dynamic_batch_size_min(&self) -> i32 {
+ self.inner.writer_dynamic_batch_size_min
+ }
+
+ /// Set the lower bound used by the dynamic batch size estimator
+ #[setter]
+ fn set_writer_dynamic_batch_size_min(&mut self, size: i32) {
+ self.inner.writer_dynamic_batch_size_min = size;
+ }
+
/// Get the scanner remote log prefetch num
#[getter]
fn scanner_remote_log_prefetch_num(&self) -> usize {
diff --git a/crates/fluss/src/client/write/accumulator.rs
b/crates/fluss/src/client/write/accumulator.rs
index 019d3b05..244edf73 100644
--- a/crates/fluss/src/client/write/accumulator.rs
+++ b/crates/fluss/src/client/write/accumulator.rs
@@ -19,6 +19,7 @@ use crate::client::broadcast;
use crate::client::write::IdempotenceManager;
use crate::client::write::batch::WriteBatch::{ArrowLog, Kv};
use crate::client::write::batch::{ArrowLogWriteBatch, KvWriteBatch,
WriteBatch};
+use crate::client::write::dynamic_batch_size::DynamicWriteBatchSizeEstimator;
use crate::client::{LogWriteRecord, Record, ResultHandle, WriteRecord};
use crate::cluster::{BucketLocation, Cluster, ServerNode};
use crate::compression::ArrowCompressionRatioEstimator;
@@ -307,28 +308,32 @@ impl RecordAccumulator {
None
};
- let (dq, compression_ratio_estimator) = {
- let mut binding =
- self.write_batches
- .entry(Arc::clone(physical_table_path))
- .or_insert_with(|| BucketAndWriteBatches {
- table_id: table_info.table_id,
+ let (dq, compression_ratio_estimator, dynamic_target) = {
+ let mut binding = self
+ .write_batches
+ .entry(Arc::clone(physical_table_path))
+ .or_insert_with(|| {
+ BucketAndWriteBatches::new(
+ table_info.table_id,
is_partitioned_table,
partition_id,
- batches: Default::default(),
- compression_ratio_estimator: Arc::new(
- ArrowCompressionRatioEstimator::default(),
- ),
- });
+ &self.config,
+ )
+ });
let bucket_and_batches = binding.value_mut();
let dq = bucket_and_batches
.batches
.entry(bucket_id)
.or_insert_with(|| Arc::new(Mutex::new(VecDeque::new())))
.clone();
+ let dynamic_target = bucket_and_batches
+ .dynamic_batch_size
+ .as_ref()
+ .map(|est| est.current());
(
dq,
Arc::clone(&bucket_and_batches.compression_ratio_estimator),
+ dynamic_target,
)
};
@@ -347,12 +352,7 @@ impl RecordAccumulator {
// producer holds dq + blocks on memory, while sender needs dq to
drain.
drop(dq_guard);
- // TODO: Implement DynamicWriteBatchSizeEstimator matching Java's
- // client.writer.dynamic-batch-size-enabled. Adjusts the batch size
target
- // per table based on observed actual batch sizes (grow 10% when >80%
full,
- // shrink 5% when <50% full, clamped to [2*pageSize, maxBatchSize]).
- // This would improve memory limiter utilization for tables with small
rows.
- let batch_size = self.config.writer_batch_size as usize;
+ let batch_size =
dynamic_target.unwrap_or(self.config.writer_batch_size as usize);
let record_size = record.estimated_record_size();
let alloc_size = batch_size.max(record_size);
let permit = self.memory_limiter.acquire(alloc_size)?;
@@ -664,6 +664,8 @@ impl RecordAccumulator {
let current_batch_size = batch.estimated_size_in_bytes();
size += current_batch_size;
+ self.record_actual_batch_size(table_path,
current_batch_size);
+
// mark the batch as drained.
batch.drained(current_time_ms());
ready.push(ReadyWriteBatch {
@@ -690,6 +692,34 @@ impl RecordAccumulator {
self.incomplete_batches.write().remove(&batch_id);
}
+ fn record_actual_batch_size(&self, table_path: &Arc<PhysicalTablePath>,
actual: usize) {
+ let Some(entry) = self.write_batches.get(table_path) else {
+ return;
+ };
+ let Some(estimator) = entry.dynamic_batch_size.as_ref() else {
+ return;
+ };
+ let prev = estimator.current();
+ let next = estimator.update(actual);
+ if next != prev {
+ log::debug!(
+ "Set estimated batch size for {} from {} to {}",
+ table_path.as_ref(),
+ prev,
+ next
+ );
+ }
+ }
+
+ #[cfg(test)]
+ fn estimated_batch_size(&self, table_path: &Arc<PhysicalTablePath>) ->
Option<usize> {
+ self.write_batches
+ .get(table_path)?
+ .dynamic_batch_size
+ .as_ref()
+ .map(|est| est.current())
+ }
+
pub fn re_enqueue(&self, mut ready_write_batch: ReadyWriteBatch) {
ready_write_batch.write_batch.re_enqueued();
@@ -785,12 +815,13 @@ impl RecordAccumulator {
let mut binding = self
.write_batches
.entry(Arc::clone(physical_table_path))
- .or_insert_with(|| BucketAndWriteBatches {
- table_id,
- is_partitioned_table,
- partition_id,
- batches: Default::default(),
- compression_ratio_estimator:
Arc::new(ArrowCompressionRatioEstimator::default()),
+ .or_insert_with(|| {
+ BucketAndWriteBatches::new(
+ table_id,
+ is_partitioned_table,
+ partition_id,
+ &self.config,
+ )
});
let bucket_and_batches = binding.value_mut();
bucket_and_batches
@@ -938,6 +969,32 @@ struct BucketAndWriteBatches {
batches: HashMap<BucketId, Arc<Mutex<VecDeque<WriteBatch>>>>,
/// Compression ratio estimator shared across Arrow log batches for this
table.
compression_ratio_estimator: Arc<ArrowCompressionRatioEstimator>,
+ /// `None` when `writer_dynamic_batch_size_enabled` is false.
+ dynamic_batch_size: Option<DynamicWriteBatchSizeEstimator>,
+}
+
+impl BucketAndWriteBatches {
+ fn new(
+ table_id: TableId,
+ is_partitioned_table: bool,
+ partition_id: Option<PartitionId>,
+ config: &Config,
+ ) -> Self {
+ let dynamic_batch_size =
config.writer_dynamic_batch_size_enabled.then(|| {
+ DynamicWriteBatchSizeEstimator::new(
+ config.writer_dynamic_batch_size_min as usize,
+ config.writer_batch_size as usize,
+ )
+ });
+ Self {
+ table_id,
+ is_partitioned_table,
+ partition_id,
+ batches: Default::default(),
+ compression_ratio_estimator:
Arc::new(ArrowCompressionRatioEstimator::default()),
+ dynamic_batch_size,
+ }
+ }
}
pub struct RecordAppendResult {
@@ -999,9 +1056,12 @@ impl ReadyCheckResult {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::client::write::write_format::WriteFormat;
+ use crate::client::write::{RowBytes, WriteRecord};
use crate::metadata::TablePath;
use crate::row::{Datum, GenericRow};
use crate::test_utils::{build_cluster, build_table_info};
+ use bytes::Bytes;
use std::sync::Arc;
fn disabled_idempotence() -> Arc<IdempotenceManager> {
@@ -1563,4 +1623,137 @@ mod tests {
.await
.expect("notified should complete after wakeup_sender");
}
+
+ #[test]
+ fn dynamic_batch_size_shrinks_after_small_drained_batch() {
+ let target = 256 * 1024;
+ let config = Config {
+ writer_dynamic_batch_size_enabled: true,
+ writer_batch_size: target,
+ writer_dynamic_batch_size_min: 4 * 1024,
+ writer_buffer_memory_size: 1024 * 1024,
+ ..Config::default()
+ };
+ let accumulator = RecordAccumulator::new(config,
disabled_idempotence());
+ 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.clone())));
+ let cluster = Arc::new(build_cluster(&table_path, 1, 1));
+ let row = GenericRow {
+ values: vec![Datum::Int32(1)],
+ };
+ let record = WriteRecord::for_append(table_info, physical_table_path,
1, &row);
+
+ accumulator.append(&record, 0, &cluster, false).unwrap();
+ assert_eq!(*accumulator.memory_limiter.state.lock(), target as usize);
+
+ let server = cluster.get_tablet_server(1).expect("server");
+ let nodes = HashSet::from([server.clone()]);
+ let mut drained = accumulator
+ .drain(cluster.clone(), &nodes, 1024 * 1024)
+ .unwrap();
+ let mut batches = drained.remove(&1).expect("drained batches");
+ let batch = batches.pop().expect("batch");
+ accumulator.remove_incomplete_batches(batch.write_batch.batch_id());
+ assert_eq!(*accumulator.memory_limiter.state.lock(), 0);
+
+ accumulator.append(&record, 0, &cluster, false).unwrap();
+ let second = *accumulator.memory_limiter.state.lock();
+ assert!(second < target as usize, "{second} >= {target}");
+ }
+
+ #[test]
+ fn dynamic_batch_size_grows_after_full_drained_batch() {
+ let max = 256 * 1024;
+ let config = Config {
+ writer_dynamic_batch_size_enabled: true,
+ writer_batch_size: max,
+ writer_dynamic_batch_size_min: 4 * 1024,
+ writer_buffer_memory_size: 4 * 1024 * 1024,
+ ..Config::default()
+ };
+ let accumulator = RecordAccumulator::new(config,
disabled_idempotence());
+ 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.clone())));
+ let cluster = Arc::new(build_cluster(&table_path, 1, 1));
+ let nodes =
HashSet::from([cluster.get_tablet_server(1).unwrap().clone()]);
+
+ let kv = |size: usize| {
+ WriteRecord::for_upsert(
+ Arc::clone(&table_info),
+ Arc::clone(&physical_table_path),
+ 1,
+ Bytes::from(vec![0u8; 32]),
+ None,
+ WriteFormat::CompactedKv,
+ None,
+ Some(RowBytes::Owned(Bytes::from(vec![0u8; size]))),
+ )
+ };
+ let drain_one = || {
+ let mut d = accumulator.drain(cluster.clone(), &nodes,
max).unwrap();
+ let b = d.remove(&1).unwrap().pop().unwrap();
+ accumulator.remove_incomplete_batches(b.write_batch.batch_id());
+ };
+ let target = || {
+ accumulator
+ .estimated_batch_size(&physical_table_path)
+ .unwrap()
+ };
+
+ accumulator.append(&kv(1), 0, &cluster, false).unwrap();
+ drain_one();
+ let after_shrink = target();
+ assert!(
+ after_shrink < max as usize,
+ "shrink failed: after_shrink={after_shrink} max={max}"
+ );
+
+ // 0.9 sits safely above GROW_THRESHOLD (0.8) to avoid f64 boundary
noise.
+ accumulator
+ .append(&kv(after_shrink * 9 / 10), 0, &cluster, false)
+ .unwrap();
+ drain_one();
+ let after_grow = target();
+ assert!(
+ after_grow > after_shrink,
+ "grow failed: after_grow={after_grow} after_shrink={after_shrink}"
+ );
+ }
+
+ #[test]
+ fn dynamic_batch_size_disabled_keeps_static_target() {
+ let target = 256 * 1024;
+ let config = Config {
+ writer_dynamic_batch_size_enabled: false,
+ writer_batch_size: target,
+ writer_dynamic_batch_size_min: 4 * 1024,
+ writer_buffer_memory_size: 1024 * 1024,
+ ..Config::default()
+ };
+ let accumulator = RecordAccumulator::new(config,
disabled_idempotence());
+ 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.clone())));
+ let cluster = Arc::new(build_cluster(&table_path, 1, 1));
+ let row = GenericRow {
+ values: vec![Datum::Int32(1)],
+ };
+ let record = WriteRecord::for_append(table_info, physical_table_path,
1, &row);
+
+ let server = cluster.get_tablet_server(1).expect("server");
+ let nodes = HashSet::from([server.clone()]);
+ for _ in 0..3 {
+ accumulator.append(&record, 0, &cluster, false).unwrap();
+ assert_eq!(*accumulator.memory_limiter.state.lock(), target as
usize);
+
+ let mut drained = accumulator
+ .drain(cluster.clone(), &nodes, 1024 * 1024)
+ .unwrap();
+ let mut batches = drained.remove(&1).expect("drained batches");
+ let batch = batches.pop().expect("batch");
+
accumulator.remove_incomplete_batches(batch.write_batch.batch_id());
+ }
+ }
}
diff --git a/crates/fluss/src/client/write/dynamic_batch_size.rs
b/crates/fluss/src/client/write/dynamic_batch_size.rs
new file mode 100644
index 00000000..408263ee
--- /dev/null
+++ b/crates/fluss/src/client/write/dynamic_batch_size.rs
@@ -0,0 +1,147 @@
+// 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.
+
+//! Per-table batch size estimator. Mirrors Java's
`DynamicWriteBatchSizeEstimator`:
+//! grow 10% above 80% fill, shrink 5% below 50%, clamped to `[min, max]`.
+
+use std::sync::atomic::{AtomicUsize, Ordering};
+
+const GROW_THRESHOLD: f64 = 0.8;
+const SHRINK_THRESHOLD: f64 = 0.5;
+const GROW_FACTOR: f64 = 1.1;
+const SHRINK_FACTOR: f64 = 0.95;
+
+#[derive(Debug)]
+pub(crate) struct DynamicWriteBatchSizeEstimator {
+ current: AtomicUsize,
+ min: usize,
+ max: usize,
+}
+
+impl DynamicWriteBatchSizeEstimator {
+ pub fn new(min_size: usize, max_size: usize) -> Self {
+ Self {
+ current: AtomicUsize::new(max_size),
+ min: min_size.min(max_size),
+ max: max_size,
+ }
+ }
+
+ pub fn current(&self) -> usize {
+ self.current.load(Ordering::Relaxed)
+ }
+
+ /// Last-write-wins on races, matching Java's `ConcurrentHashMap.put`.
+ pub fn update(&self, actual: usize) -> usize {
+ let prev = self.current.load(Ordering::Relaxed);
+ let cur = prev as f64;
+ let actual = actual as f64;
+ let next = if actual > cur * GROW_THRESHOLD {
+ cur * GROW_FACTOR
+ } else if actual < cur * SHRINK_THRESHOLD {
+ cur * SHRINK_FACTOR
+ } else {
+ cur
+ };
+ let clamped = (next as usize).clamp(self.min, self.max);
+ if clamped != prev {
+ self.current.store(clamped, Ordering::Relaxed);
+ }
+ clamped
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const MIN: usize = 256 * 1024;
+ const MAX: usize = 2 * 1024 * 1024;
+ /// ~41 shrink steps, ~22 grow steps; 50 covers both with margin.
+ const CONVERGENCE_STEPS: usize = 50;
+
+ #[test]
+ fn starts_at_max() {
+ let est = DynamicWriteBatchSizeEstimator::new(MIN, MAX);
+ assert_eq!(est.current(), MAX);
+ }
+
+ #[test]
+ fn min_clamped_to_max_when_misconfigured() {
+ let est = DynamicWriteBatchSizeEstimator::new(MAX * 2, MAX);
+ assert_eq!(est.current(), MAX);
+ assert_eq!(est.update(0), MAX);
+ }
+
+ #[test]
+ fn grows_when_above_grow_threshold() {
+ let est = DynamicWriteBatchSizeEstimator::new(MIN, MAX);
+ for _ in 0..CONVERGENCE_STEPS {
+ est.update(0);
+ }
+ assert_eq!(est.current(), MIN);
+
+ // 0.9 sits safely past the 0.8 threshold and avoids f64 boundary
noise.
+ let next = est.update((MIN as f64 * 0.9) as usize);
+ assert_eq!(next, ((MIN as f64) * GROW_FACTOR) as usize);
+ }
+
+ #[test]
+ fn shrinks_when_below_shrink_threshold() {
+ let est = DynamicWriteBatchSizeEstimator::new(MIN, MAX);
+ // 0.4 sits safely below the strict 0.5 threshold.
+ let next = est.update((MAX as f64 * 0.4) as usize);
+ assert_eq!(next, ((MAX as f64) * SHRINK_FACTOR) as usize);
+ }
+
+ #[test]
+ fn shrink_clamps_to_min() {
+ let est = DynamicWriteBatchSizeEstimator::new(MIN, MAX);
+ for _ in 0..CONVERGENCE_STEPS {
+ est.update(0);
+ }
+ assert_eq!(est.current(), MIN);
+ }
+
+ #[test]
+ fn grow_clamps_to_max() {
+ let est = DynamicWriteBatchSizeEstimator::new(MIN, MAX);
+ for _ in 0..CONVERGENCE_STEPS {
+ est.update(0);
+ }
+ for _ in 0..CONVERGENCE_STEPS {
+ est.update(est.current());
+ }
+ assert_eq!(est.current(), MAX);
+ }
+
+ #[test]
+ fn oversized_actual_clamps_at_max() {
+ let est = DynamicWriteBatchSizeEstimator::new(MIN, MAX);
+ assert_eq!(est.update(MAX * 4), MAX);
+ }
+
+ #[test]
+ fn dead_zone_is_a_fixed_point() {
+ let est = DynamicWriteBatchSizeEstimator::new(MIN, MAX);
+ let initial = est.current();
+ for _ in 0..20 {
+ est.update((est.current() as f64 * 0.65) as usize);
+ }
+ assert_eq!(est.current(), initial);
+ }
+}
diff --git a/crates/fluss/src/client/write/mod.rs
b/crates/fluss/src/client/write/mod.rs
index bc324e18..a65b5d5a 100644
--- a/crates/fluss/src/client/write/mod.rs
+++ b/crates/fluss/src/client/write/mod.rs
@@ -17,6 +17,7 @@
mod accumulator;
mod batch;
+mod dynamic_batch_size;
mod idempotence;
use crate::client::broadcast::{self as client_broadcast, BatchWriteResult,
BroadcastOnceReceiver};
diff --git a/crates/fluss/src/config.rs b/crates/fluss/src/config.rs
index 09a17f83..cad8d9cb 100644
--- a/crates/fluss/src/config.rs
+++ b/crates/fluss/src/config.rs
@@ -22,6 +22,9 @@ use strum_macros::{Display, EnumString};
const DEFAULT_BOOTSTRAP_SERVER: &str = "127.0.0.1:9123";
const DEFAULT_REQUEST_MAX_SIZE: i32 = 10 * 1024 * 1024;
const DEFAULT_WRITER_BATCH_SIZE: i32 = 2 * 1024 * 1024;
+// Mirrors Java's `2 * pageSize` floor with default pageSize = 128 KB.
+const DEFAULT_WRITER_DYNAMIC_BATCH_SIZE_MIN: i32 = 256 * 1024;
+const DEFAULT_WRITER_DYNAMIC_BATCH_SIZE_ENABLED: bool = true;
const DEFAULT_RETRIES: i32 = i32::MAX;
const DEFAULT_PREFETCH_NUM: usize = 4;
const DEFAULT_DOWNLOAD_THREADS: usize = 3;
@@ -76,6 +79,17 @@ pub struct Config {
#[arg(long, default_value_t = DEFAULT_WRITER_BATCH_SIZE)]
pub writer_batch_size: i32,
+ /// Tune the per-table writer batch size from observed fill ratios.
+ /// Default: true (matching Java
`client.writer.dynamic-batch-size.enabled`).
+ #[arg(long, default_value_t = DEFAULT_WRITER_DYNAMIC_BATCH_SIZE_ENABLED)]
+ pub writer_dynamic_batch_size_enabled: bool,
+
+ /// Lower bound for the dynamic batch size estimator.
+ /// Default: 262144 (256 KB), matching Java's `2 * pageSize` floor.
+ /// Ignored when `writer_dynamic_batch_size_enabled` is false.
+ #[arg(long, default_value_t = DEFAULT_WRITER_DYNAMIC_BATCH_SIZE_MIN)]
+ pub writer_dynamic_batch_size_min: i32,
+
#[arg(long, value_enum, default_value_t = NoKeyAssigner::Sticky)]
pub writer_bucket_no_key_assigner: NoKeyAssigner,
@@ -199,6 +213,14 @@ impl std::fmt::Debug for Config {
.field("writer_acks", &self.writer_acks)
.field("writer_retries", &self.writer_retries)
.field("writer_batch_size", &self.writer_batch_size)
+ .field(
+ "writer_dynamic_batch_size_enabled",
+ &self.writer_dynamic_batch_size_enabled,
+ )
+ .field(
+ "writer_dynamic_batch_size_min",
+ &self.writer_dynamic_batch_size_min,
+ )
.field(
"writer_bucket_no_key_assigner",
&self.writer_bucket_no_key_assigner,
@@ -267,6 +289,8 @@ impl Default for Config {
writer_acks: String::from(DEFAULT_ACKS),
writer_retries: i32::MAX,
writer_batch_size: DEFAULT_WRITER_BATCH_SIZE,
+ writer_dynamic_batch_size_enabled:
DEFAULT_WRITER_DYNAMIC_BATCH_SIZE_ENABLED,
+ writer_dynamic_batch_size_min:
DEFAULT_WRITER_DYNAMIC_BATCH_SIZE_MIN,
writer_bucket_no_key_assigner: NoKeyAssigner::Sticky,
scanner_remote_log_prefetch_num: DEFAULT_PREFETCH_NUM,
remote_file_download_thread_num: DEFAULT_DOWNLOAD_THREADS,
@@ -388,6 +412,12 @@ impl Config {
if self.writer_batch_size as usize > self.writer_buffer_memory_size {
return Err("writer_batch_size must be <=
writer_buffer_memory_size".to_string());
}
+ if self.writer_dynamic_batch_size_min <= 0 {
+ return Err("writer_dynamic_batch_size_min must be >
0".to_string());
+ }
+ if self.writer_dynamic_batch_size_min > self.writer_batch_size {
+ return Err("writer_dynamic_batch_size_min must be <=
writer_batch_size".to_string());
+ }
// idempotence checks
if !self.writer_enable_idempotence {
return Ok(());
diff --git a/website/docs/user-guide/cpp/api-reference.md
b/website/docs/user-guide/cpp/api-reference.md
index ae4e9490..e9b94c9d 100644
--- a/website/docs/user-guide/cpp/api-reference.md
+++ b/website/docs/user-guide/cpp/api-reference.md
@@ -21,7 +21,9 @@ Complete API reference for the Fluss C++ client.
| `writer_request_max_size` | `int32_t` | `10485760` (10 MB)
| Maximum request size in bytes
|
| `writer_acks` | `std::string` | `"all"`
| Acknowledgment setting (`"all"`, `"0"`, `"1"`, or `"-1"`)
|
| `writer_retries` | `int32_t` | `INT32_MAX`
| Number of retries on failure
|
-| `writer_batch_size` | `int32_t` | `2097152` (2 MB)
| Batch size for writes in bytes
|
+| `writer_batch_size` | `int32_t` | `2097152` (2 MB)
| Batch size for writes in bytes. Upper bound when dynamic sizing is on; fixed
batch size when off |
+| `writer_dynamic_batch_size_enabled` | `bool` | `true`
| Enable per-table dynamic batch sizing: target grows 10% above 80% fill,
shrinks 5% below 50% |
+| `writer_dynamic_batch_size_min` | `int32_t` | `262144` (256 KB)
| Lower bound for the dynamic batch size estimator (ignored when disabled)
|
| `writer_batch_timeout_ms` | `int64_t` | `100`
| Maximum time in ms to wait for a writer batch to fill up before sending
|
| `writer_bucket_no_key_assigner` | `std::string` | `"sticky"`
| Bucket assignment strategy for tables without bucket keys: `"sticky"` or
`"round_robin"` |
| `scanner_remote_log_prefetch_num` | `size_t` | `4`
| Number of remote log segments to prefetch
|
diff --git a/website/docs/user-guide/python/api-reference.md
b/website/docs/user-guide/python/api-reference.md
index 32f23a59..aec4412d 100644
--- a/website/docs/user-guide/python/api-reference.md
+++ b/website/docs/user-guide/python/api-reference.md
@@ -14,7 +14,9 @@ Complete API reference for the Fluss Python client.
| `writer_request_max_size` | `writer.request-max-size`
| Get/set max request size in bytes
|
| `writer_acks` | `writer.acks`
| Get/set acknowledgment setting (`"all"` for all replicas)
|
| `writer_retries` | `writer.retries`
| Get/set number of retries on failure
|
-| `writer_batch_size` | `writer.batch-size`
| Get/set write batch size in bytes
|
+| `writer_batch_size` | `writer.batch-size`
| Get/set write batch size in bytes. Upper bound when dynamic sizing is on;
fixed batch size when off |
+| `writer_dynamic_batch_size_enabled` | `writer.dynamic-batch-size.enabled`
| Get/set whether the per-table dynamic batch size estimator is enabled
(default `true`) |
+| `writer_dynamic_batch_size_min` | `writer.dynamic-batch-size-min`
| Get/set the lower bound for the dynamic batch size estimator (default 256
KB; ignored when disabled) |
| `writer_batch_timeout_ms` | `writer.batch-timeout-ms`
| Get/set max time in ms to wait for a writer batch to fill up before sending
|
| `writer_bucket_no_key_assigner` | `writer.bucket.no-key-assigner`
| Get/set bucket assignment strategy (`"sticky"` or `"round_robin"`)
|
| `scanner_remote_log_prefetch_num` | `scanner.remote-log.prefetch-num`
| Get/set number of remote log segments to prefetch
|
diff --git a/website/docs/user-guide/rust/api-reference.md
b/website/docs/user-guide/rust/api-reference.md
index 9f2994ad..03054f0f 100644
--- a/website/docs/user-guide/rust/api-reference.md
+++ b/website/docs/user-guide/rust/api-reference.md
@@ -13,7 +13,9 @@ Complete API reference for the Fluss Rust client.
| `writer_request_max_size` | `i32` | `10485760` (10 MB)
| Maximum request size in bytes
|
| `writer_acks` | `String` | `"all"`
| Acknowledgment setting (`"all"` waits for all replicas)
|
| `writer_retries` | `i32` | `i32::MAX`
| Number of retries on failure
|
-| `writer_batch_size` | `i32` | `2097152` (2 MB)
| Batch size for writes in bytes
|
+| `writer_batch_size` | `i32` | `2097152` (2 MB)
| Batch size for writes in bytes. Upper bound when dynamic sizing is on; fixed
batch size when off. |
+| `writer_dynamic_batch_size_enabled` | `bool` | `true`
| Enable per-table dynamic batch sizing: target grows 10% above 80% fill,
shrinks 5% below 50%, clamped to `[writer_dynamic_batch_size_min,
writer_batch_size]` |
+| `writer_dynamic_batch_size_min` | `i32` | `262144` (256 KB)
| Lower bound for the dynamic batch size estimator (ignored when
`writer_dynamic_batch_size_enabled` is `false`) |
| `writer_batch_timeout_ms` | `i64` | `100`
| Maximum time in ms to wait for a writer batch to fill up before sending
|
| `writer_bucket_no_key_assigner` | `NoKeyAssigner` | `sticky`
| Bucket assignment strategy for tables without bucket keys: `sticky` or
`round_robin` |
| `scanner_remote_log_prefetch_num` | `usize` | `4`
| Number of remote log segments to prefetch
|