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 3bbfa95d [metrics] Add scanner fetch and remote download metrics (#553)
3bbfa95d is described below

commit 3bbfa95db04e5be002d42dce600a2ce22f115a07
Author: Kaiqi Dong <[email protected]>
AuthorDate: Tue May 19 02:46:33 2026 +0200

    [metrics] Add scanner fetch and remote download metrics (#553)
    
    * add scanner fetch and remote download metrics
    
    * add scanner fetch and remote download metrics
---
 crates/fluss/src/client/table/remote_log.rs |  13 +++
 crates/fluss/src/client/table/scanner.rs    | 149 ++++++++++++++++++++++++++--
 crates/fluss/src/metrics.rs                 |  93 +++++++++++++++++
 3 files changed, 247 insertions(+), 8 deletions(-)

diff --git a/crates/fluss/src/client/table/remote_log.rs 
b/crates/fluss/src/client/table/remote_log.rs
index 4d96ce96..8957aced 100644
--- a/crates/fluss/src/client/table/remote_log.rs
+++ b/crates/fluss/src/client/table/remote_log.rs
@@ -18,6 +18,10 @@ use crate::client::credentials::CredentialsReceiver;
 use crate::error::{Error, Result};
 use crate::io::{FileIO, Storage};
 use crate::metadata::TableBucket;
+use crate::metrics::{
+    SCANNER_REMOTE_FETCH_BYTES_TOTAL, SCANNER_REMOTE_FETCH_ERRORS_TOTAL,
+    SCANNER_REMOTE_FETCH_REQUESTS_TOTAL,
+};
 use crate::proto::{PbRemoteLogFetchInfo, PbRemoteLogSegment};
 use futures::TryStreamExt;
 use parking_lot::Mutex;
@@ -494,12 +498,19 @@ async fn spawn_download_task(
         return DownloadResult::Cancelled;
     }
 
+    // Java reference: RemoteLogDownloader.java increments 
`remoteFetchRequestCount`
+    // immediately before initiating the download. Each retry of the same 
segment
+    // counts as a separate request (matches Java behavior).
+    metrics::counter!(SCANNER_REMOTE_FETCH_REQUESTS_TOTAL).increment(1);
+
     // Try download ONCE
     let download_result = fetcher.fetch(&request).await;
 
     match download_result {
         Ok(fetch_result) => {
             // Success - permit will be released on drop (FileSource handles 
file deletion)
+            metrics::counter!(SCANNER_REMOTE_FETCH_BYTES_TOTAL)
+                .increment(fetch_result.file_size as u64);
             DownloadResult::Success {
                 result: RemoteLogFile {
                     file_path: fetch_result.file_path,
@@ -516,6 +527,8 @@ async fn spawn_download_task(
         }
         Err(e) => {
             // Download failed - check if we should retry or give up
+            // Counted per attempt, so retries each contribute one error.
+            metrics::counter!(SCANNER_REMOTE_FETCH_ERRORS_TOTAL).increment(1);
             let retry_count = request.retry_count + 1;
 
             if retry_count > MAX_RETRY_COUNT {
diff --git a/crates/fluss/src/client/table/scanner.rs 
b/crates/fluss/src/client/table/scanner.rs
index a4164b99..0167af02 100644
--- a/crates/fluss/src/client/table/scanner.rs
+++ b/crates/fluss/src/client/table/scanner.rs
@@ -27,6 +27,9 @@ use crate::config::Config;
 use crate::error::Error::UnsupportedOperation;
 use crate::error::{ApiError, Error, FlussError, Result};
 use crate::metadata::{LogFormat, PhysicalTablePath, RowType, TableBucket, 
TableInfo, TablePath};
+use crate::metrics::{
+    SCANNER_BYTES_PER_REQUEST, SCANNER_FETCH_LATENCY_MS, 
SCANNER_FETCH_REQUESTS_TOTAL,
+};
 use crate::proto::{
     ErrorResponse, FetchLogRequest, FetchLogResponse, PbFetchLogReqForBucket, 
PbFetchLogReqForTable,
 };
@@ -39,6 +42,7 @@ use crate::{PartitionId, TableId};
 use arrow_schema::SchemaRef;
 use log::{debug, warn};
 use parking_lot::{Mutex, RwLock};
+use prost::Message;
 use std::{
     collections::{HashMap, HashSet},
     slice::from_ref,
@@ -779,6 +783,9 @@ struct FetchResponseContext {
     read_context: ReadContext,
     remote_read_context: ReadContext,
     remote_log_downloader: Arc<RemoteLogDownloader>,
+    /// `Instant` captured immediately before the FetchLog RPC; used to compute
+    /// `scanner.fetch_latency_ms` on a successful response.
+    request_start_time: Instant,
 }
 
 impl LogFetcher {
@@ -1021,14 +1028,6 @@ impl LogFetcher {
             let remote_log_downloader = 
Arc::clone(&self.remote_log_downloader);
             let nodes_with_pending = 
self.nodes_with_pending_fetch_requests.clone();
             let metadata = self.metadata.clone();
-            let response_context = FetchResponseContext {
-                metadata: metadata.clone(),
-                log_fetch_buffer,
-                log_scanner_status,
-                read_context,
-                remote_read_context,
-                remote_log_downloader,
-            };
             // Spawn async task to handle the fetch request
             // Note: These tasks are not explicitly tracked or cancelled when 
LogFetcher is dropped.
             // This is acceptable because:
@@ -1060,6 +1059,11 @@ impl LogFetcher {
                     }
                 };
 
+                // Java increment the fetch counter and capture 
`requestStartTime` immediately
+                // before the RPC. Failed connection acquisition above is not 
counted.
+                let request_start_time = Instant::now();
+                metrics::counter!(SCANNER_FETCH_REQUESTS_TOTAL).increment(1);
+
                 let fetch_response = match con
                     
.request(message::FetchLogRequest::new(fetch_request.clone()))
                     .await
@@ -1074,6 +1078,18 @@ impl LogFetcher {
                     }
                 };
 
+                // Build the context after the RPC so `request_start_time` 
measures only RPC wall-clock
+                // — not tablet-server lookup or connection acquisition, which 
is matching Java's bebaviour
+                // Building it here also skips the allocation on the 
early-return error paths above.
+                let response_context = FetchResponseContext {
+                    metadata: metadata.clone(),
+                    log_fetch_buffer,
+                    log_scanner_status,
+                    read_context,
+                    remote_read_context,
+                    remote_log_downloader,
+                    request_start_time,
+                };
                 Self::handle_fetch_response(fetch_response, 
response_context).await;
             });
         }
@@ -1102,8 +1118,17 @@ impl LogFetcher {
             read_context,
             remote_read_context,
             remote_log_downloader,
+            request_start_time,
         } = context;
 
+        // `encoded_len()` mirrors Java's `fetchLogResponse.totalSize()`:
+        // both report the serialized API message body size, excluding protocol
+        // headers and framing. Recorded unconditionally (including zero-record
+        // responses) to match Java's histogram semantics.
+        metrics::histogram!(SCANNER_FETCH_LATENCY_MS)
+            .record(request_start_time.elapsed().as_secs_f64() * 1000.0);
+        
metrics::histogram!(SCANNER_BYTES_PER_REQUEST).record(fetch_response.encoded_len()
 as f64);
+
         for pb_fetch_log_resp in fetch_response.tables_resp {
             let table_id = pb_fetch_log_resp.table_id;
             let fetch_log_for_buckets = pb_fetch_log_resp.buckets_resp;
@@ -2029,6 +2054,7 @@ mod tests {
             read_context: fetcher.read_context.clone(),
             remote_read_context: fetcher.remote_read_context.clone(),
             remote_log_downloader: fetcher.remote_log_downloader.clone(),
+            request_start_time: Instant::now(),
         };
 
         LogFetcher::handle_fetch_response(response, response_context).await;
@@ -2082,6 +2108,7 @@ mod tests {
             read_context: fetcher.read_context.clone(),
             remote_read_context: fetcher.remote_read_context.clone(),
             remote_log_downloader: fetcher.remote_log_downloader.clone(),
+            request_start_time: Instant::now(),
         };
 
         LogFetcher::handle_fetch_response(response, response_context).await;
@@ -2204,4 +2231,110 @@ mod tests {
         }
         Ok(())
     }
+
+    /// Drives `handle_fetch_response` against a local metrics recorder and
+    /// asserts that latency + bytes-per-request histograms are emitted with
+    /// values that mirror what Java would record. This complements the unit
+    /// tests in `metrics.rs` (which only verify the facade) by exercising
+    /// the actual instrumented call path.
+    ///
+    /// Note: uses a `current_thread` runtime inside `with_local_recorder`
+    /// (rather than `#[tokio::test]`) because the metrics facade installs a
+    /// thread-local recorder; running the async work on the same thread is
+    /// the only way to observe the emitted metrics in the snapshot. Both
+    /// the fetcher construction and the `handle_fetch_response` call run
+    /// inside the runtime (the security-token manager and remote-log
+    /// downloader require a Tokio reactor).
+    #[test]
+    fn handle_fetch_response_emits_latency_and_bytes_metrics() {
+        use crate::metrics::{SCANNER_BYTES_PER_REQUEST, 
SCANNER_FETCH_LATENCY_MS};
+        use metrics_util::debugging::{DebugValue, DebuggingRecorder};
+
+        let recorder = DebuggingRecorder::new();
+        let snapshotter = recorder.snapshotter();
+
+        let expected_bytes = metrics::with_local_recorder(&recorder, || {
+            let rt = tokio::runtime::Builder::new_current_thread()
+                .enable_all()
+                .build()
+                .expect("build current_thread runtime");
+
+            rt.block_on(async {
+                let table_path = TablePath::new("db".to_string(), 
"tbl".to_string());
+                let table_info = build_table_info(table_path.clone(), 1, 1);
+                let cluster = build_cluster_arc(&table_path, 1, 1);
+                let metadata = Arc::new(Metadata::new_for_test(cluster));
+                let status = Arc::new(LogScannerStatus::new());
+                status.assign_scan_bucket(TableBucket::new(1, 0), 5);
+                let fetcher = LogFetcher::new(
+                    table_info,
+                    Arc::new(RpcClient::new()),
+                    metadata.clone(),
+                    status,
+                    &Config::default(),
+                    None,
+                )
+                .expect("build LogFetcher");
+
+                let response = FetchLogResponse {
+                    tables_resp: vec![PbFetchLogRespForTable {
+                        table_id: 1,
+                        buckets_resp: vec![PbFetchLogRespForBucket {
+                            partition_id: None,
+                            bucket_id: 0,
+                            error_code: Some(FlussError::None.code()),
+                            error_message: None,
+                            high_watermark: Some(7),
+                            log_start_offset: Some(0),
+                            remote_log_fetch_info: None,
+                            records: None,
+                        }],
+                    }],
+                };
+                let expected_bytes = response.encoded_len() as f64;
+                let response_context = FetchResponseContext {
+                    metadata: metadata.clone(),
+                    log_fetch_buffer: fetcher.log_fetch_buffer.clone(),
+                    log_scanner_status: fetcher.log_scanner_status.clone(),
+                    read_context: fetcher.read_context.clone(),
+                    remote_read_context: fetcher.remote_read_context.clone(),
+                    remote_log_downloader: 
fetcher.remote_log_downloader.clone(),
+                    request_start_time: Instant::now(),
+                };
+
+                LogFetcher::handle_fetch_response(response, 
response_context).await;
+                expected_bytes
+            })
+        });
+
+        let entries: Vec<_> = snapshotter.snapshot().into_vec();
+        let find_histogram = |name: &str| -> Vec<f64> {
+            entries
+                .iter()
+                .find_map(|(key, _, _, val)| {
+                    if key.key().name() == name {
+                        if let DebugValue::Histogram(v) = val {
+                            return Some(v.iter().map(|f| 
f.into_inner()).collect());
+                        }
+                    }
+                    None
+                })
+                .unwrap_or_default()
+        };
+
+        let latency_samples = find_histogram(SCANNER_FETCH_LATENCY_MS);
+        assert_eq!(latency_samples.len(), 1, "expected one latency sample");
+        assert!(
+            latency_samples[0] >= 0.0,
+            "latency must be non-negative, got {}",
+            latency_samples[0]
+        );
+
+        let bytes_samples = find_histogram(SCANNER_BYTES_PER_REQUEST);
+        assert_eq!(
+            bytes_samples,
+            vec![expected_bytes],
+            "bytes histogram must record encoded_len() for parity with Java 
fetchLogResponse.totalSize()",
+        );
+    }
 }
diff --git a/crates/fluss/src/metrics.rs b/crates/fluss/src/metrics.rs
index 756e2db5..a533abd5 100644
--- a/crates/fluss/src/metrics.rs
+++ b/crates/fluss/src/metrics.rs
@@ -49,6 +49,43 @@ pub const CLIENT_BYTES_RECEIVED_TOTAL: &str = 
"fluss.client.bytes_received.total
 pub const CLIENT_REQUEST_LATENCY_MS: &str = "fluss.client.request_latency_ms";
 pub const CLIENT_REQUESTS_IN_FLIGHT: &str = "fluss.client.requests_in_flight";
 
+// ---------------------------------------------------------------------------
+// Scanner fetch + remote download metrics
+//
+// Fetch metrics are recorded in the LogFetcher fetch loop on response
+// completion. Remote metrics are recorded inside RemoteLogDownloader's
+// download task.
+//
+// Java uses a volatile-long gauge for fetch latency and Counter+MeterView
+// for rates. Rust uses a histogram for latency (richer percentile data)
+// and counters for throughput; the recorder/exporter handles rate
+// computation (e.g. Prometheus `rate()`).
+//
+// Java emits one `ScannerMetricGroup` per (database, table). Rust currently
+// emits without per-table labels — adding `database`/`table` labels is
+// tracked separately and intentionally deferred to keep this PR minimal.
+// ---------------------------------------------------------------------------
+
+/// Histogram: elapsed ms for each successful FetchLog RPC.
+pub const SCANNER_FETCH_LATENCY_MS: &str = 
"fluss.client.scanner.fetch_latency_ms";
+
+/// Counter: total FetchLog RPC requests attempted after connection 
acquisition.
+pub const SCANNER_FETCH_REQUESTS_TOTAL: &str = 
"fluss.client.scanner.fetch_requests.total";
+
+/// Histogram: serialized bytes per successful FetchLog response.
+pub const SCANNER_BYTES_PER_REQUEST: &str = 
"fluss.client.scanner.bytes_per_request";
+
+/// Counter: total remote log download attempts (includes per-segment retries).
+pub const SCANNER_REMOTE_FETCH_REQUESTS_TOTAL: &str =
+    "fluss.client.scanner.remote_fetch_requests.total";
+
+/// Counter: total bytes downloaded from remote log storage.
+pub const SCANNER_REMOTE_FETCH_BYTES_TOTAL: &str = 
"fluss.client.scanner.remote_fetch_bytes.total";
+
+/// Counter: total remote log download failures (each retry attempt counts).
+pub const SCANNER_REMOTE_FETCH_ERRORS_TOTAL: &str =
+    "fluss.client.scanner.remote_fetch_errors.total";
+
 /// 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
@@ -267,4 +304,60 @@ mod tests {
         assert_eq!(counter_by_api_key.get("produce_log"), Some(&5));
         assert_eq!(counter_by_api_key.get("fetch_log"), Some(&3));
     }
+
+    #[test]
+    fn scanner_fetch_metrics_emit_correctly() {
+        let recorder = DebuggingRecorder::new();
+        let snapshotter = recorder.snapshotter();
+
+        metrics::with_local_recorder(&recorder, || {
+            metrics::counter!(SCANNER_FETCH_REQUESTS_TOTAL).increment(1);
+            metrics::histogram!(SCANNER_FETCH_LATENCY_MS).record(15.5);
+            metrics::histogram!(SCANNER_BYTES_PER_REQUEST).record(4096.0);
+        });
+
+        let snapshot = snapshotter.snapshot();
+        let entries: Vec<_> = snapshot.into_vec();
+
+        assert_eq!(
+            find_counter!(entries, SCANNER_FETCH_REQUESTS_TOTAL),
+            Some(1)
+        );
+        assert_eq!(
+            find_histogram!(entries, SCANNER_FETCH_LATENCY_MS),
+            Some(vec![15.5])
+        );
+        assert_eq!(
+            find_histogram!(entries, SCANNER_BYTES_PER_REQUEST),
+            Some(vec![4096.0])
+        );
+    }
+
+    #[test]
+    fn scanner_remote_fetch_metrics_emit_correctly() {
+        let recorder = DebuggingRecorder::new();
+        let snapshotter = recorder.snapshotter();
+
+        metrics::with_local_recorder(&recorder, || {
+            
metrics::counter!(SCANNER_REMOTE_FETCH_REQUESTS_TOTAL).increment(3);
+            
metrics::counter!(SCANNER_REMOTE_FETCH_BYTES_TOTAL).increment(1024);
+            metrics::counter!(SCANNER_REMOTE_FETCH_ERRORS_TOTAL).increment(1);
+        });
+
+        let snapshot = snapshotter.snapshot();
+        let entries: Vec<_> = snapshot.into_vec();
+
+        assert_eq!(
+            find_counter!(entries, SCANNER_REMOTE_FETCH_REQUESTS_TOTAL),
+            Some(3)
+        );
+        assert_eq!(
+            find_counter!(entries, SCANNER_REMOTE_FETCH_BYTES_TOTAL),
+            Some(1024)
+        );
+        assert_eq!(
+            find_counter!(entries, SCANNER_REMOTE_FETCH_ERRORS_TOTAL),
+            Some(1)
+        );
+    }
 }

Reply via email to