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 87303ea7 [metrics] Add writer pipeline metrics (#609)
87303ea7 is described below

commit 87303ea7500efd21885a90ea8cb58562474175f4
Author: Kaiqi Dong <[email protected]>
AuthorDate: Mon Jun 8 23:34:56 2026 +0200

    [metrics] Add writer pipeline metrics (#609)
    
    * add writer pipeline metrics
    
    * Address comments
    
    * lint
    
    * fmt
---
 crates/fluss/src/client/write/accumulator.rs       |  30 ++
 crates/fluss/src/client/write/batch.rs             | 127 ++++++
 crates/fluss/src/client/write/sender.rs            | 424 ++++++++++++++++++++-
 crates/fluss/src/client/write/writer_client.rs     |   6 +
 crates/fluss/src/metrics.rs                        | 221 +++++++++++
 crates/fluss/src/record/arrow.rs                   |   5 +
 .../fluss/src/record/kv/kv_record_batch_builder.rs |   5 +
 crates/fluss/src/test_utils.rs                     |  20 +-
 website/docs/user-guide/rust/api-reference.md      |   7 +
 9 files changed, 834 insertions(+), 11 deletions(-)

diff --git a/crates/fluss/src/client/write/accumulator.rs 
b/crates/fluss/src/client/write/accumulator.rs
index 244edf73..b41759f2 100644
--- a/crates/fluss/src/client/write/accumulator.rs
+++ b/crates/fluss/src/client/write/accumulator.rs
@@ -133,6 +133,21 @@ impl MemoryLimiter {
         self.waiting_count.load(Ordering::Relaxed) > 0
     }
 
+    /// Total buffer memory in bytes (constant)
+    pub(crate) fn total_bytes(&self) -> usize {
+        self.max_memory
+    }
+
+    /// Currently-available buffer memory in bytes
+    pub(crate) fn available_bytes(&self) -> usize {
+        self.max_memory.saturating_sub(*self.state.lock())
+    }
+
+    /// Number of producer threads currently blocked waiting for buffer memory.
+    pub(crate) fn waiting_threads(&self) -> usize {
+        self.waiting_count.load(Ordering::Relaxed)
+    }
+
     /// Mark the limiter as closed and wake all blocked producers.
     fn close(&self) {
         self.closed.store(true, Ordering::Release);
@@ -209,6 +224,21 @@ impl RecordAccumulator {
         }
     }
 
+    /// Total writer buffer memory in bytes (constant).
+    pub(crate) fn buffer_total_bytes(&self) -> usize {
+        self.memory_limiter.total_bytes()
+    }
+
+    /// Currently-available writer buffer memory in bytes.
+    pub(crate) fn buffer_available_bytes(&self) -> usize {
+        self.memory_limiter.available_bytes()
+    }
+
+    /// Number of producer threads blocked waiting for buffer memory.
+    pub(crate) fn buffer_waiting_threads(&self) -> usize {
+        self.memory_limiter.waiting_threads()
+    }
+
     fn try_append(
         &self,
         record: &WriteRecord,
diff --git a/crates/fluss/src/client/write/batch.rs 
b/crates/fluss/src/client/write/batch.rs
index fd70cb97..50148835 100644
--- a/crates/fluss/src/client/write/batch.rs
+++ b/crates/fluss/src/client/write/batch.rs
@@ -71,6 +71,14 @@ impl InnerWriteBatch {
         max(0i64, now - self.create_ms)
     }
 
+    /// Time the batch spent queued in the accumulator, from creation until it
+    /// was drained for sending. Returns 0 if the batch has not been drained
+    /// yet (`drained_ms == -1`). Distinct from [`Self::waited_time_ms`], which
+    /// measures the batch's current age for timeout purposes.
+    fn queue_time_ms(&self) -> i64 {
+        max(0i64, self.drained_ms - self.create_ms)
+    }
+
     fn complete(&self, write_result: BatchWriteResult) -> bool {
         if self
             .completed
@@ -135,6 +143,20 @@ impl WriteBatch {
         self.inner_batch().waited_time_ms(now)
     }
 
+    /// Time the batch spent queued in the accumulator before being drained
+    /// (`drained_ms - create_ms`). See [`InnerWriteBatch::queue_time_ms`].
+    pub(crate) fn queue_time_ms(&self) -> i64 {
+        self.inner_batch().queue_time_ms()
+    }
+
+    /// Number of records currently in this batch. Used for writer metrics.
+    pub(crate) fn record_count(&self) -> i32 {
+        match self {
+            WriteBatch::ArrowLog(batch) => batch.arrow_builder.records_count(),
+            WriteBatch::Kv(batch) => batch.record_count(),
+        }
+    }
+
     pub fn close(&mut self) -> Result<()> {
         match self {
             WriteBatch::ArrowLog(batch) => {
@@ -408,6 +430,11 @@ impl KvWriteBatch {
     pub fn estimated_size_in_bytes(&self) -> usize {
         self.kv_batch_builder.get_size_in_bytes()
     }
+
+    /// Number of records appended to this KV batch. Used for writer metrics.
+    pub(crate) fn record_count(&self) -> i32 {
+        self.kv_batch_builder.record_count()
+    }
 }
 
 #[cfg(test)]
@@ -436,6 +463,106 @@ mod tests {
         assert_eq!(batch.attempts(), 1);
     }
 
+    #[test]
+    fn queue_time_ms_is_drained_minus_create() {
+        let table_path = TablePath::new("db".to_string(), "tbl".to_string());
+        let physical_path = PhysicalTablePath::of(Arc::new(table_path));
+        let mut batch = InnerWriteBatch::new(1, Arc::new(physical_path), 
1_000);
+        // Not drained yet -> 0 (drained_ms == -1).
+        assert_eq!(batch.queue_time_ms(), 0);
+        batch.drained(1_150);
+        assert_eq!(batch.queue_time_ms(), 150);
+        // drained() keeps the max; an earlier timestamp does not shrink it.
+        batch.drained(1_120);
+        assert_eq!(batch.queue_time_ms(), 150);
+    }
+
+    #[test]
+    fn record_count_reflects_appended_rows() {
+        use crate::client::WriteRecord;
+        use crate::compression::{
+            ArrowCompressionInfo, ArrowCompressionType, 
DEFAULT_NON_ZSTD_COMPRESSION_LEVEL,
+        };
+        use crate::metadata::{DataField, DataTypes, RowType};
+        use crate::row::GenericRow;
+
+        let row_type = RowType::new(vec![DataField::new(
+            "id".to_string(),
+            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 arrow_batch = ArrowLogWriteBatch::new(
+            1,
+            Arc::clone(&physical_table_path),
+            1,
+            ArrowCompressionInfo {
+                compression_type: ArrowCompressionType::None,
+                compression_level: DEFAULT_NON_ZSTD_COMPRESSION_LEVEL,
+            },
+            &row_type,
+            0,
+            false,
+            2 * 1024 * 1024,
+            Arc::new(ArrowCompressionRatioEstimator::default()),
+        )
+        .unwrap();
+        let mut batch = WriteBatch::ArrowLog(arrow_batch);
+
+        assert_eq!(batch.record_count(), 0);
+        for _ in 0..5 {
+            let mut row = GenericRow::new(1);
+            row.set_field(0, 1_i32);
+            let record = WriteRecord::for_append(
+                Arc::clone(&table_info),
+                Arc::clone(&physical_table_path),
+                1,
+                &row,
+            );
+            batch.try_append(&record).unwrap();
+        }
+        assert_eq!(batch.record_count(), 5);
+    }
+
+    #[test]
+    fn kv_record_count_reflects_appended_records() {
+        use crate::client::WriteRecord;
+        use crate::metadata::KvFormat;
+
+        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_path = 
Arc::new(PhysicalTablePath::of(Arc::new(table_path)));
+
+        let mut batch = WriteBatch::Kv(KvWriteBatch::new(
+            1,
+            Arc::clone(&physical_path),
+            1,
+            64 * 1024,
+            KvFormat::COMPACTED,
+            None,
+            0,
+        ));
+
+        assert_eq!(batch.record_count(), 0);
+        for _ in 0..3 {
+            let record = WriteRecord::for_upsert(
+                Arc::clone(&table_info),
+                Arc::clone(&physical_path),
+                1,
+                Bytes::from(vec![1_u8, 2_u8, 3_u8]),
+                None,
+                WriteFormat::CompactedKv,
+                None,
+                Some(RowBytes::Owned(Bytes::from(vec![1_u8, 2_u8, 3_u8]))),
+            );
+            batch.try_append(&record).unwrap();
+        }
+        assert_eq!(batch.record_count(), 3);
+    }
+
     #[test]
     fn test_arrow_log_write_batch_estimated_size() {
         use crate::client::WriteRecord;
diff --git a/crates/fluss/src/client/write/sender.rs 
b/crates/fluss/src/client/write/sender.rs
index 8e738d0d..baf5a9b3 100644
--- a/crates/fluss/src/client/write/sender.rs
+++ b/crates/fluss/src/client/write/sender.rs
@@ -23,6 +23,7 @@ use crate::client::{ReadyWriteBatch, RecordAccumulator};
 use crate::error::Error::UnexpectedError;
 use crate::error::{FlussError, Result};
 use crate::metadata::{PhysicalTablePath, TableBucket, TablePath};
+use crate::metrics::WriterMetrics;
 use crate::proto::{
     PbProduceLogRespForBucket, PbPutKvRespForBucket, PbTablePath, 
ProduceLogResponse, PutKvResponse,
 };
@@ -39,7 +40,7 @@ use std::future::Future;
 use std::pin::Pin;
 use std::sync::Arc;
 use std::sync::atomic::{AtomicBool, Ordering};
-use std::time::Duration;
+use std::time::{Duration, Instant};
 use tokio::sync::mpsc;
 
 type SendFuture<'a> = Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
@@ -62,9 +63,11 @@ pub struct Sender {
     max_request_timeout_ms: i32,
     retries: i32,
     idempotence_manager: Arc<IdempotenceManager>,
+    metrics: Arc<WriterMetrics>,
 }
 
 impl Sender {
+    #[allow(clippy::too_many_arguments)]
     pub fn new(
         metadata: Arc<Metadata>,
         accumulator: Arc<RecordAccumulator>,
@@ -73,6 +76,7 @@ impl Sender {
         ack: i16,
         retries: i32,
         idempotence_manager: Arc<IdempotenceManager>,
+        metrics: Arc<WriterMetrics>,
     ) -> Self {
         Self {
             running: AtomicBool::new(true),
@@ -84,6 +88,7 @@ impl Sender {
             max_request_timeout_ms,
             retries,
             idempotence_manager,
+            metrics,
         }
     }
 
@@ -306,6 +311,15 @@ impl Sender {
         if batches.is_empty() {
             return Ok(());
         }
+
+        // Record attempted-send per-batch metrics for the whole drained set
+        // up front, before any early return (unknown leader, connection
+        // failure) can drop batches. So a leader/connection failure
+        // still counts toward `records_send_total` / `bytes_send_total`.
+        // Retries re-drain the batch and therefore contribute one sample per
+        // send attempt.
+        self.record_request_batch_metrics(&batches);
+
         let mut records_by_bucket = HashMap::new();
         let mut write_batch_by_table: HashMap<TableId, Vec<TableBucket>> = 
HashMap::new();
 
@@ -371,8 +385,8 @@ impl Sender {
                 }
             };
 
-            // let's put in back into records_by_bucket
-            // since response handle will use it.
+            // Put batches back into records_by_bucket since response handling
+            // will use them.
             for request_batch in request_batches {
                 records_by_bucket.insert(request_batch.table_bucket.clone(), 
request_batch);
             }
@@ -448,8 +462,14 @@ impl Sender {
         records_by_bucket: &mut HashMap<TableBucket, ReadyWriteBatch>,
     ) -> Result<()> {
         macro_rules! send {
-            ($request:expr) => {
-                match connection.request($request).await {
+            ($request:expr) => {{
+                // Record send latency for the request round trip regardless of
+                // outcome, so it is captured before the success/error branch.
+                let send_start = Instant::now();
+                let response_result = connection.request($request).await;
+                self.metrics
+                    .record_send_latency_ms(send_start.elapsed().as_secs_f64() 
* 1000.0);
+                match response_result {
                     Ok(response) => {
                         self.handle_write_response(
                             table_id,
@@ -471,7 +491,7 @@ impl Sender {
                         .await
                     }
                 }
-            };
+            }};
         }
 
         match write_request {
@@ -723,9 +743,28 @@ impl Sender {
         
Ok(Self::is_invalid_metadata_error(error).then_some(physical_table_path))
     }
 
+    /// Record per-batch writer throughput/queue metrics for a drained set of
+    /// batches. Invoked once at the start of `send_write_request`, before the
+    /// leader lookup / connection / serialization steps, so every drained
+    /// batch is counted exactly once per send attempt regardless of whether
+    /// the send later succeeds. Because this runs before serialization,
+    /// `estimated_size_in_bytes` is the pre-serialization estimate rather 
than  
+    /// the final encoded length.
+    fn record_request_batch_metrics(&self, request_batches: 
&[ReadyWriteBatch]) {
+        for request_batch in request_batches {
+            let batch = &request_batch.write_batch;
+            self.metrics.record_sent_batch(
+                batch.record_count(),
+                batch.estimated_size_in_bytes(),
+                batch.queue_time_ms(),
+            );
+        }
+    }
+
     fn re_enqueue_batch(&self, ready_write_batch: ReadyWriteBatch) {
         self.remove_from_inflight_batches(&ready_write_batch);
-        // TODO: add retry metrics (Java: 
writerMetricGroup.recordsRetryTotal().inc(recordCount))
+        self.metrics
+            
.record_records_retry(ready_write_batch.write_batch.record_count());
         self.accumulator.re_enqueue(ready_write_batch);
     }
 
@@ -842,6 +881,16 @@ impl Sender {
         let mut next_delay_ms: u64 = 1;
 
         loop {
+            // Sample buffer-pool gauges once per loop iteration. Cheap (three
+            // field reads) and naturally sampled. Java registers these as
+            // lazy gauge suppliers on the accumulator; the push model means
+            // we refresh them here on the sender's own cadence.
+            self.metrics.record_buffer_state(
+                self.accumulator.buffer_total_bytes(),
+                self.accumulator.buffer_available_bytes(),
+                self.accumulator.buffer_waiting_threads(),
+            );
+
             // Spawn writer-ID init task if needed and not already running.
             if init_futs.is_empty()
                 && self.idempotence_manager.is_enabled()
@@ -1020,14 +1069,19 @@ impl WriteResponse for PutKvResponse {
 mod tests {
     use super::*;
     use crate::client::WriteRecord;
-    use crate::cluster::Cluster;
+    use crate::cluster::{Cluster, ServerType};
     use crate::config::Config;
     use crate::metadata::{PhysicalTablePath, TablePath};
-    use crate::proto::{PbProduceLogRespForBucket, ProduceLogResponse};
+    use crate::proto::{
+        ApiVersionsResponse, PbApiVersion, PbProduceLogRespForBucket, 
ProduceLogResponse,
+    };
     use crate::row::{Datum, GenericRow};
     use crate::rpc::FlussError;
-    use crate::test_utils::{build_cluster_arc, build_table_info};
+    use crate::test_utils::{build_cluster_arc, build_cluster_arc_with_port, 
build_table_info};
+    use prost::Message;
     use std::collections::{HashMap, HashSet};
+    use tokio::io::{AsyncReadExt, AsyncWriteExt};
+    use tokio::net::TcpListener;
 
     fn disabled_idempotence() -> Arc<IdempotenceManager> {
         Arc::new(IdempotenceManager::new(false, 5))
@@ -1076,6 +1130,7 @@ mod tests {
             1,
             1,
             idempotence,
+            Arc::new(crate::metrics::WriterMetrics::new()),
         );
 
         let (batch, _handle) =
@@ -1100,6 +1155,350 @@ mod tests {
         Ok(())
     }
 
+    #[test]
+    fn retriable_error_records_retry_metric() {
+        use metrics_util::debugging::{DebugValue, DebuggingRecorder};
+
+        let recorder = DebuggingRecorder::new();
+        let snapshotter = recorder.snapshotter();
+
+        let result: Result<()> = metrics::with_local_recorder(&recorder, || {
+            let table_path = Arc::new(TablePath::new("db".to_string(), 
"tbl".to_string()));
+            let cluster = build_cluster_arc(table_path.as_ref(), 1, 1);
+            let metadata = Arc::new(Metadata::new_for_test(cluster.clone()));
+            let idempotence = disabled_idempotence();
+            let accumulator = Arc::new(RecordAccumulator::new(
+                Config::default(),
+                Arc::clone(&idempotence),
+            ));
+            // Construct the sender inside the recorder scope so its cached
+            // metric handles bind to the local recorder.
+            let sender = Sender::new(
+                metadata,
+                accumulator.clone(),
+                1024 * 1024,
+                1000,
+                1,
+                1,
+                idempotence,
+                Arc::new(crate::metrics::WriterMetrics::new()),
+            );
+
+            let (batch, _handle) =
+                build_ready_batch(accumulator.as_ref(), cluster.clone(), 
table_path.clone())?;
+            let mut inflight = HashMap::new();
+            inflight.insert(1, vec![batch]);
+            sender.add_to_inflight_batches(&inflight);
+            let batch = inflight.remove(&1).unwrap().pop().unwrap();
+            let record_count = batch.write_batch.record_count();
+            assert_eq!(record_count, 1, "single-record batch expected");
+
+            sender.handle_write_batch_error(
+                batch,
+                FlussError::RequestTimeOut,
+                "timeout".to_string(),
+            )?;
+            Ok(())
+        });
+        result.expect("retry handling");
+
+        let entries = snapshotter.snapshot().into_vec();
+        let retry_total = entries.iter().find_map(|(key, _, _, val)| {
+            if key.key().name() == crate::metrics::WRITER_RECORDS_RETRY_TOTAL {
+                match val {
+                    DebugValue::Counter(v) => Some(*v),
+                    _ => None,
+                }
+            } else {
+                None
+            }
+        });
+        assert_eq!(retry_total, Some(1));
+    }
+
+    #[test]
+    fn record_request_batch_metrics_emits_per_batch_send_stats() {
+        use metrics_util::debugging::{DebugValue, DebuggingRecorder};
+
+        let recorder = DebuggingRecorder::new();
+        let snapshotter = recorder.snapshotter();
+
+        metrics::with_local_recorder(&recorder, || -> Result<()> {
+            let table_path = Arc::new(TablePath::new("db".to_string(), 
"tbl".to_string()));
+            let cluster = build_cluster_arc(table_path.as_ref(), 1, 1);
+            let metadata = Arc::new(Metadata::new_for_test(cluster.clone()));
+            let idempotence = disabled_idempotence();
+            let accumulator = Arc::new(RecordAccumulator::new(
+                Config::default(),
+                Arc::clone(&idempotence),
+            ));
+            // Construct the sender inside the recorder scope so its cached
+            // metric handles bind to the local recorder.
+            let sender = Sender::new(
+                metadata,
+                accumulator.clone(),
+                1024 * 1024,
+                1000,
+                1,
+                1,
+                idempotence,
+                Arc::new(crate::metrics::WriterMetrics::new()),
+            );
+
+            // build_ready_batch drains the batch (sets drained_ms) and 
appends a
+            // single record, mirroring the state batches are in when
+            // `send_write_request` records their metrics.
+            let (batch, _handle) =
+                build_ready_batch(accumulator.as_ref(), cluster.clone(), 
table_path.clone())?;
+            assert_eq!(batch.write_batch.record_count(), 1);
+
+            sender.record_request_batch_metrics(std::slice::from_ref(&batch));
+            Ok(())
+        })
+        .expect("record batch metrics");
+
+        let entries = snapshotter.snapshot().into_vec();
+
+        let counter = |name: &str| {
+            entries.iter().find_map(|(key, _, _, val)| {
+                if key.key().name() == name {
+                    match val {
+                        DebugValue::Counter(v) => Some(*v),
+                        _ => None,
+                    }
+                } else {
+                    None
+                }
+            })
+        };
+        let histogram = |name: &str| {
+            entries.iter().find_map(|(key, _, _, val)| {
+                if key.key().name() == name {
+                    match val {
+                        DebugValue::Histogram(v) => {
+                            Some(v.iter().map(|f| 
f.into_inner()).collect::<Vec<f64>>())
+                        }
+                        _ => None,
+                    }
+                } else {
+                    None
+                }
+            })
+        };
+
+        // One batch with a single record -> records counter is 1, bytes > 0.
+        assert_eq!(counter(crate::metrics::WRITER_RECORDS_SEND_TOTAL), 
Some(1));
+        let bytes_send =
+            counter(crate::metrics::WRITER_BYTES_SEND_TOTAL).expect("bytes 
send counter emitted");
+        assert!(
+            bytes_send > 0,
+            "expected non-zero bytes_send, got {bytes_send}"
+        );
+
+        // Each histogram observes exactly one sample for the single batch.
+        assert_eq!(
+            histogram(crate::metrics::WRITER_RECORDS_PER_BATCH),
+            Some(vec![1.0])
+        );
+        let bytes_per_batch =
+            
histogram(crate::metrics::WRITER_BYTES_PER_BATCH).expect("bytes_per_batch 
emitted");
+        assert_eq!(bytes_per_batch.len(), 1);
+        assert!(bytes_per_batch[0] > 0.0);
+        let queue_time =
+            
histogram(crate::metrics::WRITER_BATCH_QUEUE_TIME_MS).expect("queue_time 
emitted");
+        assert_eq!(queue_time.len(), 1);
+        assert!(queue_time[0] >= 0.0);
+    }
+
+    #[test]
+    fn send_write_request_error_still_records_attempted_send_metrics() {
+        use metrics_util::debugging::{DebugValue, DebuggingRecorder};
+
+        let recorder = DebuggingRecorder::new();
+        let snapshotter = recorder.snapshotter();
+
+        metrics::with_local_recorder(&recorder, || -> Result<()> {
+            let rt = tokio::runtime::Builder::new_current_thread()
+                .enable_all()
+                .build()
+                .expect("runtime");
+
+            rt.block_on(async {
+                let listener = TcpListener::bind("127.0.0.1:0")
+                    .await
+                    .expect("bind mock server");
+                let port = listener.local_addr().expect("listener 
addr").port();
+
+                // Handshake-only mock server:
+                // respond to ApiVersions so connection setup succeeds, but do
+                // not advertise ProduceLog. That makes produce request fail
+                // during version resolution in `connection.request(...)`.
+                let server_task = tokio::spawn(async move {
+                    let (mut stream, _) = 
listener.accept().await.expect("accept");
+                    let mut len_buf = [0u8; 4];
+                    if stream.read_exact(&mut len_buf).await.is_err() {
+                        return;
+                    }
+                    let len = i32::from_be_bytes(len_buf) as usize;
+                    let mut payload = vec![0u8; len];
+                    if stream.read_exact(&mut payload).await.is_err() {
+                        return;
+                    }
+
+                    // Header layout: api_key(2) + api_version(2) + 
request_id(4)
+                    let request_id =
+                        i32::from_be_bytes([payload[4], payload[5], 
payload[6], payload[7]]);
+
+                    let mut body = Vec::new();
+                    ApiVersionsResponse {
+                        api_versions: vec![PbApiVersion {
+                            api_key: 1000, // ApiVersion
+                            min_version: 0,
+                            max_version: 0,
+                        }],
+                        server_type: 
Some(ServerType::TabletServer.to_type_id()),
+                    }
+                    .encode(&mut body)
+                    .expect("encode ApiVersionsResponse");
+
+                    let mut resp = Vec::with_capacity(5 + body.len());
+                    resp.push(0u8); // success response type
+                    resp.extend_from_slice(&request_id.to_be_bytes());
+                    resp.extend_from_slice(&body);
+                    let resp_len = (resp.len() as i32).to_be_bytes();
+                    let _ = stream.write_all(&resp_len).await;
+                    let _ = stream.write_all(&resp).await;
+                    let _ = stream.flush().await;
+                });
+
+                let table_path = Arc::new(TablePath::new("db".to_string(), 
"tbl".to_string()));
+                let cluster = build_cluster_arc_with_port(table_path.as_ref(), 
1, 1, port as u32);
+                let metadata = 
Arc::new(Metadata::new_for_test(cluster.clone()));
+                let idempotence = disabled_idempotence();
+                let accumulator = Arc::new(RecordAccumulator::new(
+                    Config::default(),
+                    Arc::clone(&idempotence),
+                ));
+                let sender = Sender::new(
+                    metadata,
+                    accumulator.clone(),
+                    1024 * 1024,
+                    1000,
+                    1,
+                    1,
+                    idempotence,
+                    Arc::new(crate::metrics::WriterMetrics::new()),
+                );
+
+                let (batch, _handle) =
+                    build_ready_batch(accumulator.as_ref(), cluster.clone(), 
table_path)?;
+                sender.send_write_request(1, 1, vec![batch]).await?;
+                let _ = server_task.await;
+                Ok(())
+            })
+        })
+        .expect("sender attempted-send metrics");
+
+        let entries = snapshotter.snapshot().into_vec();
+
+        let send_latency_samples = entries.iter().find_map(|(key, _, _, val)| {
+            if key.key().name() == crate::metrics::WRITER_SEND_LATENCY_MS {
+                match val {
+                    DebugValue::Histogram(v) => Some(v.len()),
+                    _ => None,
+                }
+            } else {
+                None
+            }
+        });
+        assert_eq!(
+            send_latency_samples,
+            Some(1),
+            "send latency must be recorded even when request attempt fails"
+        );
+
+        let attempted_records = entries.iter().find_map(|(key, _, _, val)| {
+            if key.key().name() == crate::metrics::WRITER_RECORDS_SEND_TOTAL {
+                match val {
+                    DebugValue::Counter(v) => Some(*v),
+                    _ => None,
+                }
+            } else {
+                None
+            }
+        });
+        assert_eq!(
+            attempted_records,
+            Some(1),
+            "records_send_total should count attempted sends"
+        );
+    }
+
+    #[test]
+    fn 
send_write_request_unknown_leader_still_records_attempted_send_metrics() {
+        use metrics_util::debugging::{DebugValue, DebuggingRecorder};
+
+        let recorder = DebuggingRecorder::new();
+        let snapshotter = recorder.snapshotter();
+
+        metrics::with_local_recorder(&recorder, || -> Result<()> {
+            let rt = tokio::runtime::Builder::new_current_thread()
+                .enable_all()
+                .build()
+                .expect("runtime");
+
+            rt.block_on(async {
+                let table_path = Arc::new(TablePath::new("db".to_string(), 
"tbl".to_string()));
+                let cluster = build_cluster_arc(table_path.as_ref(), 1, 1);
+                let metadata = 
Arc::new(Metadata::new_for_test(cluster.clone()));
+                let idempotence = disabled_idempotence();
+                let accumulator = Arc::new(RecordAccumulator::new(
+                    Config::default(),
+                    Arc::clone(&idempotence),
+                ));
+                let sender = Sender::new(
+                    metadata,
+                    accumulator.clone(),
+                    1024 * 1024,
+                    1000,
+                    1,
+                    1,
+                    idempotence,
+                    Arc::new(crate::metrics::WriterMetrics::new()),
+                );
+
+                let (batch, _handle) =
+                    build_ready_batch(accumulator.as_ref(), cluster.clone(), 
table_path)?;
+                // Destination 999 is absent from cluster metadata, so the send
+                // bails out with LeaderNotAvailableException before the batch 
is
+                // serialized or dispatched. Metrics must still be recorded so 
the
+                // count matches Java, which updates writer metrics over the 
whole
+                // drained set regardless of send outcome.
+                sender.send_write_request(999, 1, vec![batch]).await?;
+                Ok(())
+            })
+        })
+        .expect("sender attempted-send metrics");
+
+        let entries = snapshotter.snapshot().into_vec();
+
+        let attempted_records = entries.iter().find_map(|(key, _, _, val)| {
+            if key.key().name() == crate::metrics::WRITER_RECORDS_SEND_TOTAL {
+                match val {
+                    DebugValue::Counter(v) => Some(*v),
+                    _ => None,
+                }
+            } else {
+                None
+            }
+        });
+        assert_eq!(
+            attempted_records,
+            Some(1),
+            "records_send_total must count batches dropped before send on 
unknown leader"
+        );
+    }
+
     #[tokio::test]
     async fn handle_write_batch_error_fails() -> Result<()> {
         let table_path = Arc::new(TablePath::new("db".to_string(), 
"tbl".to_string()));
@@ -1118,6 +1517,7 @@ mod tests {
             1,
             0,
             idempotence,
+            Arc::new(crate::metrics::WriterMetrics::new()),
         );
 
         let (batch, handle) = build_ready_batch(accumulator.as_ref(), 
cluster.clone(), table_path)?;
@@ -1154,6 +1554,7 @@ mod tests {
             1,
             0,
             idempotence,
+            Arc::new(crate::metrics::WriterMetrics::new()),
         );
 
         let (batch, handle) = build_ready_batch(accumulator.as_ref(), cluster, 
table_path)?;
@@ -1198,6 +1599,7 @@ mod tests {
             -1,
             i32::MAX,
             Arc::clone(&idempotence),
+            Arc::new(crate::metrics::WriterMetrics::new()),
         );
 
         // build_ready_batch drains the batch, which assigns seq=0 and adds 
in-flight
@@ -1244,6 +1646,7 @@ mod tests {
             -1,
             0,
             Arc::clone(&idempotence),
+            Arc::new(crate::metrics::WriterMetrics::new()),
         );
 
         // build_ready_batch drains the batch, which assigns seq=0 and adds 
in-flight
@@ -1289,6 +1692,7 @@ mod tests {
             -1,
             i32::MAX,
             Arc::clone(&idempotence),
+            Arc::new(crate::metrics::WriterMetrics::new()),
         );
 
         // build_ready_batch drains the batch, which assigns seq=0 and adds 
in-flight
diff --git a/crates/fluss/src/client/write/writer_client.rs 
b/crates/fluss/src/client/write/writer_client.rs
index ffdf96b1..73d91c7c 100644
--- a/crates/fluss/src/client/write/writer_client.rs
+++ b/crates/fluss/src/client/write/writer_client.rs
@@ -29,6 +29,7 @@ use crate::config::Config;
 use crate::config::NoKeyAssigner;
 use crate::error::{Error, Result};
 use crate::metadata::{PhysicalTablePath, TableInfo};
+use crate::metrics::WriterMetrics;
 use bytes::Bytes;
 use dashmap::DashMap;
 use log::warn;
@@ -66,6 +67,10 @@ impl WriterClient {
             Arc::clone(&idempotence_manager),
         ));
 
+        // Writer metrics are emitted unlabeled (global per process). Resolve 
the
+        // recorder once and share the cached handles with the sender.
+        let metrics = Arc::new(WriterMetrics::new());
+
         let sender = Arc::new(Sender::new(
             metadata.clone(),
             accumulator.clone(),
@@ -74,6 +79,7 @@ impl WriterClient {
             ack,
             config.writer_retries,
             Arc::clone(&idempotence_manager),
+            Arc::clone(&metrics),
         ));
 
         let join_handle = tokio::spawn(async move {
diff --git a/crates/fluss/src/metrics.rs b/crates/fluss/src/metrics.rs
index 9f820ffe..e707f0e6 100644
--- a/crates/fluss/src/metrics.rs
+++ b/crates/fluss/src/metrics.rs
@@ -259,6 +259,138 @@ fn scanner_histogram(name: &'static str, database: &str, 
table: &str) -> metrics
     )
 }
 
+// ---------------------------------------------------------------------------
+// Writer pipeline metrics
+//
+//
+// Java's `WriterMetricGroup` carries only the `client_id`
+// variable inherited from `ClientMetricGroup` -- no table or bucket label
+// (one series per client). The Rust `metrics` facade has no `client_id`
+// concept, so writer metrics are emitted UNLABELED (global per process).
+// TODO: A future `client.id` config option can attach a `client_id` label 
without
+// breaking these series (it only splits the existing global series).
+//
+// Semantic deviations from Java:
+//   * Java `sendLatencyMs` / `batchQueueTimeMs` are volatile-long gauges
+//     (latest sample only); Rust uses histograms for full p50/p95/p99.
+//   * Java `recordSendPerSecond` / `bytesSendPerSecond` / 
`recordsRetryPerSecond`
+//     are `MeterView` rates; Rust emits raw counters and lets the exporter
+//     compute `rate()`.
+// ---------------------------------------------------------------------------
+
+/// Histogram: elapsed ms for each write request (ProduceLog / PutKv) round
+/// trip.
+pub const WRITER_SEND_LATENCY_MS: &str = "fluss.client.writer.send_latency_ms";
+
+/// Histogram: ms a batch spent queued in the accumulator (`drained_ms -
+/// create_ms`).
+pub const WRITER_BATCH_QUEUE_TIME_MS: &str = 
"fluss.client.writer.batch_queue_time_ms";
+
+/// Counter: total records handed to the cluster across all sent batches.
+pub const WRITER_RECORDS_SEND_TOTAL: &str = 
"fluss.client.writer.records_send.total";
+
+/// Counter: total serialized batch bytes sent.
+pub const WRITER_BYTES_SEND_TOTAL: &str = 
"fluss.client.writer.bytes_send.total";
+
+/// Counter: total records re-enqueued for retry.
+pub const WRITER_RECORDS_RETRY_TOTAL: &str = 
"fluss.client.writer.records_retry.total";
+
+/// Histogram: records per sent batch.
+pub const WRITER_RECORDS_PER_BATCH: &str = 
"fluss.client.writer.records_per_batch";
+
+/// Histogram: serialized bytes per sent batch.
+pub const WRITER_BYTES_PER_BATCH: &str = "fluss.client.writer.bytes_per_batch";
+
+/// Gauge: total writer buffer memory in bytes (constant).
+pub const WRITER_BUFFER_TOTAL_BYTES: &str = 
"fluss.client.writer.buffer_total_bytes";
+
+/// Gauge: currently-available writer buffer memory in bytes.
+pub const WRITER_BUFFER_AVAILABLE_BYTES: &str = 
"fluss.client.writer.buffer_available_bytes";
+
+/// Gauge: number of producer threads blocked waiting for buffer memory --
+/// a high-signal backpressure indicator.
+pub const WRITER_BUFFER_WAITING_THREADS: &str = 
"fluss.client.writer.buffer_waiting_threads";
+
+/// Cached, unlabeled writer-pipeline metric handles.
+///
+/// Constructed once per [`crate::client::write::WriterClient`] and shared
+/// (`Arc`) into the `Sender`.
+/// Like [`ScannerMetrics`], every cached handle is bound to whichever
+/// recorder is installed when [`Self::new`] runs. Construct it *after*
+/// installing the production recorder; in tests, construct it inside the
+/// `metrics::with_local_recorder(...)` closure. With no recorder installed,
+/// all `record_*` calls are zero-overhead no-ops.
+pub(crate) struct WriterMetrics {
+    send_latency_ms: metrics::Histogram,
+    batch_queue_time_ms: metrics::Histogram,
+    records_send_total: metrics::Counter,
+    bytes_send_total: metrics::Counter,
+    records_retry_total: metrics::Counter,
+    records_per_batch: metrics::Histogram,
+    bytes_per_batch: metrics::Histogram,
+    buffer_total_bytes: metrics::Gauge,
+    buffer_available_bytes: metrics::Gauge,
+    buffer_waiting_threads: metrics::Gauge,
+}
+
+impl WriterMetrics {
+    /// Build a fresh handle cache. Resolves the currently installed recorder
+    /// once per metric.
+    pub(crate) fn new() -> Self {
+        Self {
+            send_latency_ms: metrics::histogram!(WRITER_SEND_LATENCY_MS),
+            batch_queue_time_ms: 
metrics::histogram!(WRITER_BATCH_QUEUE_TIME_MS),
+            records_send_total: metrics::counter!(WRITER_RECORDS_SEND_TOTAL),
+            bytes_send_total: metrics::counter!(WRITER_BYTES_SEND_TOTAL),
+            records_retry_total: metrics::counter!(WRITER_RECORDS_RETRY_TOTAL),
+            records_per_batch: metrics::histogram!(WRITER_RECORDS_PER_BATCH),
+            bytes_per_batch: metrics::histogram!(WRITER_BYTES_PER_BATCH),
+            buffer_total_bytes: metrics::gauge!(WRITER_BUFFER_TOTAL_BYTES),
+            buffer_available_bytes: 
metrics::gauge!(WRITER_BUFFER_AVAILABLE_BYTES),
+            buffer_waiting_threads: 
metrics::gauge!(WRITER_BUFFER_WAITING_THREADS),
+        }
+    }
+
+    pub(crate) fn record_send_latency_ms(&self, value: f64) {
+        self.send_latency_ms.record(value);
+    }
+
+    /// Record per-batch send statistics (records, bytes, queue time) for one
+    /// built+sent batch.
+    pub(crate) fn record_sent_batch(
+        &self,
+        record_count: i32,
+        batch_bytes: usize,
+        queue_time_ms: i64,
+    ) {
+        let records = record_count.max(0) as u64;
+        let bytes = batch_bytes as u64;
+        self.records_send_total.increment(records);
+        self.bytes_send_total.increment(bytes);
+        self.records_per_batch.record(record_count.max(0) as f64);
+        self.bytes_per_batch.record(batch_bytes as f64);
+        self.batch_queue_time_ms.record(queue_time_ms.max(0) as f64);
+    }
+
+    pub(crate) fn record_records_retry(&self, record_count: i32) {
+        self.records_retry_total
+            .increment(record_count.max(0) as u64);
+    }
+
+    /// Push the current buffer-pool gauges. Called once per sender poll-loop
+    /// iteration (Java registers these as lazy suppliers on the accumulator).
+    pub(crate) fn record_buffer_state(
+        &self,
+        total_bytes: usize,
+        available_bytes: usize,
+        waiting_threads: usize,
+    ) {
+        self.buffer_total_bytes.set(total_bytes as f64);
+        self.buffer_available_bytes.set(available_bytes as f64);
+        self.buffer_waiting_threads.set(waiting_threads as f64);
+    }
+}
+
 /// Returns a label value for reportable API keys, matching Java's
 /// `ConnectionMetrics.REPORT_API_KEYS` filter (`ProduceLog`, `FetchLog`,
 /// `PutKv`, `Lookup`). Returns `None` for admin/metadata/auth calls to
@@ -651,4 +783,93 @@ mod tests {
             Some(&3),
         );
     }
+
+    #[test]
+    fn writer_metrics_emit_all_writer_series() {
+        let recorder = DebuggingRecorder::new();
+        let snapshotter = recorder.snapshotter();
+
+        metrics::with_local_recorder(&recorder, || {
+            let m = WriterMetrics::new();
+            // Two sent batches: (3 records, 300 bytes, 12ms queue) and
+            // (2 records, 200 bytes, 8ms queue).
+            m.record_sent_batch(3, 300, 12);
+            m.record_sent_batch(2, 200, 8);
+            m.record_send_latency_ms(5.5);
+            m.record_records_retry(4);
+            m.record_buffer_state(64 * 1024 * 1024, 32 * 1024 * 1024, 2);
+        });
+
+        let snapshot = snapshotter.snapshot();
+        let entries: Vec<_> = snapshot.into_vec();
+
+        // Counters accumulate across both batches.
+        assert_eq!(find_counter!(entries, WRITER_RECORDS_SEND_TOTAL), Some(5));
+        assert_eq!(find_counter!(entries, WRITER_BYTES_SEND_TOTAL), Some(500));
+        assert_eq!(find_counter!(entries, WRITER_RECORDS_RETRY_TOTAL), 
Some(4));
+
+        // Histograms capture one sample per batch.
+        assert_eq!(
+            find_histogram!(entries, WRITER_RECORDS_PER_BATCH),
+            Some(vec![3.0, 2.0])
+        );
+        assert_eq!(
+            find_histogram!(entries, WRITER_BYTES_PER_BATCH),
+            Some(vec![300.0, 200.0])
+        );
+        assert_eq!(
+            find_histogram!(entries, WRITER_BATCH_QUEUE_TIME_MS),
+            Some(vec![12.0, 8.0])
+        );
+        assert_eq!(
+            find_histogram!(entries, WRITER_SEND_LATENCY_MS),
+            Some(vec![5.5])
+        );
+
+        // Buffer gauges hold the latest pushed value.
+        assert_eq!(
+            find_gauge!(entries, WRITER_BUFFER_TOTAL_BYTES),
+            Some((64 * 1024 * 1024) as f64)
+        );
+        assert_eq!(
+            find_gauge!(entries, WRITER_BUFFER_AVAILABLE_BYTES),
+            Some((32 * 1024 * 1024) as f64)
+        );
+        assert_eq!(
+            find_gauge!(entries, WRITER_BUFFER_WAITING_THREADS),
+            Some(2.0)
+        );
+    }
+
+    /// Writer metrics carry no labels.
+    #[test]
+    fn writer_metrics_are_unlabeled() {
+        let recorder = DebuggingRecorder::new();
+        let snapshotter = recorder.snapshotter();
+
+        metrics::with_local_recorder(&recorder, || {
+            let m = WriterMetrics::new();
+            m.record_sent_batch(1, 10, 1);
+        });
+
+        let snapshot = snapshotter.snapshot();
+        let entries: Vec<_> = snapshot.into_vec();
+
+        let writer_entries: Vec<_> = entries
+            .iter()
+            .filter(|(key, _, _, _)| 
key.key().name().starts_with("fluss.client.writer."))
+            .collect();
+        assert!(
+            !writer_entries.is_empty(),
+            "expected writer metrics to be emitted"
+        );
+        for (key, _, _, _) in writer_entries {
+            assert_eq!(
+                key.key().labels().count(),
+                0,
+                "writer metric {} must be unlabeled",
+                key.key().name()
+            );
+        }
+    }
 }
diff --git a/crates/fluss/src/record/arrow.rs b/crates/fluss/src/record/arrow.rs
index 5bd75924..33a0f6f4 100644
--- a/crates/fluss/src/record/arrow.rs
+++ b/crates/fluss/src/record/arrow.rs
@@ -561,6 +561,11 @@ impl MemoryLogRecordsArrowBuilder {
         let estimated_body = self.estimated_compressed_size(body);
         RECORD_BATCH_HEADER_SIZE + self.ipc_overhead + estimated_body
     }
+
+    /// Number of records appended so far. Used for writer throughput metrics.
+    pub(crate) fn records_count(&self) -> i32 {
+        self.arrow_record_batch_builder.records_count()
+    }
 }
 
 /// Estimate the Arrow IPC overhead (metadata + body framing) for a given 
schema.
diff --git a/crates/fluss/src/record/kv/kv_record_batch_builder.rs 
b/crates/fluss/src/record/kv/kv_record_batch_builder.rs
index a2e472a3..f9500509 100644
--- a/crates/fluss/src/record/kv/kv_record_batch_builder.rs
+++ b/crates/fluss/src/record/kv/kv_record_batch_builder.rs
@@ -241,6 +241,11 @@ impl KvRecordBatchBuilder {
         self.size_in_bytes
     }
 
+    /// Number of records appended so far. Used for writer throughput metrics.
+    pub(crate) fn record_count(&self) -> i32 {
+        self.current_record_number
+    }
+
     // ----------------------- Internal methods -------------------------------
 
     /// Write the batch header.
diff --git a/crates/fluss/src/test_utils.rs b/crates/fluss/src/test_utils.rs
index f1e17e5f..ec192a50 100644
--- a/crates/fluss/src/test_utils.rs
+++ b/crates/fluss/src/test_utils.rs
@@ -37,7 +37,16 @@ pub(crate) fn build_table_info(table_path: TablePath, 
table_id: i64, buckets: i3
 }
 
 pub(crate) fn build_cluster(table_path: &TablePath, table_id: i64, buckets: 
i32) -> Cluster {
-    let server = ServerNode::new(1, "127.0.0.1".to_string(), 9092, 
ServerType::TabletServer);
+    build_cluster_with_port(table_path, table_id, buckets, 9092)
+}
+
+pub(crate) fn build_cluster_with_port(
+    table_path: &TablePath,
+    table_id: i64,
+    buckets: i32,
+    port: u32,
+) -> Cluster {
+    let server = ServerNode::new(1, "127.0.0.1".to_string(), port, 
ServerType::TabletServer);
 
     let mut servers = HashMap::new();
     servers.insert(server.id(), server.clone());
@@ -89,6 +98,15 @@ pub(crate) fn build_cluster_arc(
     Arc::new(build_cluster(table_path, table_id, buckets))
 }
 
+pub(crate) fn build_cluster_arc_with_port(
+    table_path: &TablePath,
+    table_id: i64,
+    buckets: i32,
+    port: u32,
+) -> Arc<Cluster> {
+    Arc::new(build_cluster_with_port(table_path, table_id, buckets, port))
+}
+
 /// Build an `Arc<ScannerMetrics>` for tests. Most callers don't install
 /// a recorder, so the cached handles are no-ops; tests that *do* install
 /// `metrics::with_local_recorder(...)` must call this *inside* the
diff --git a/website/docs/user-guide/rust/api-reference.md 
b/website/docs/user-guide/rust/api-reference.md
index 285e84e7..7ef39577 100644
--- a/website/docs/user-guide/rust/api-reference.md
+++ b/website/docs/user-guide/rust/api-reference.md
@@ -128,6 +128,13 @@ Complete API reference for the Fluss Rust client.
 | `fn append_arrow_batch(&self, batch: RecordBatch) -> 
Result<WriteResultFuture>` | Append an Arrow RecordBatch                       |
 | `async fn flush(&self) -> Result<()>`                                        
   | Flush all pending writes to the server            |
 
+The writer pipeline emits `fluss.client.writer.*` metrics (send latency, batch
+queue time, records/bytes sent, retries, per-batch size, and buffer-pool
+gauges). Unlike scanner metrics, these are **unlabeled** (global per process),
+matching Java's single `WriterMetricGroup` per client (Java scopes only by
+`client_id`, which the Rust `metrics` facade has no concept of yet). The same
+series are shared by `AppendWriter` (log tables) and `UpsertWriter` (PK 
tables).
+
 ## `TableScan<'a>`
 
 | Method                                                                      
| Description                             |


Reply via email to