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 483b2c18 [metrics] Add last_poll_seconds_ago scanner metric (#581)
483b2c18 is described below
commit 483b2c18b91c4617e02e6296e13fff8ac0c71054
Author: Kaiqi Dong <[email protected]>
AuthorDate: Mon Jun 1 21:34:02 2026 +0200
[metrics] Add last_poll_seconds_ago scanner metric (#581)
* add last_poll_seconds_ago scanner metric
* commit
---
crates/fluss/Cargo.toml | 4 +
crates/fluss/src/client/table/scanner.rs | 402 ++++++++++++++++++++++++++++++-
crates/fluss/src/metrics.rs | 55 ++++-
3 files changed, 450 insertions(+), 11 deletions(-)
diff --git a/crates/fluss/Cargo.toml b/crates/fluss/Cargo.toml
index feac8309..821ee52e 100644
--- a/crates/fluss/Cargo.toml
+++ b/crates/fluss/Cargo.toml
@@ -82,6 +82,10 @@ jiff = { workspace = true, features = ["js"] }
[dev-dependencies]
metrics-util = "0.20"
fluss-test-cluster = { path = "../fluss-test-cluster" }
+# `test-util` (paused clock + `tokio::time::advance`) is not part of the
+# workspace `full` feature set; enable it for tests so the
+# `last_poll_seconds_ago` ticker loop can be driven deterministically.
+tokio = { workspace = true, features = ["test-util"] }
[build-dependencies]
prost-build = "0.14"
diff --git a/crates/fluss/src/client/table/scanner.rs
b/crates/fluss/src/client/table/scanner.rs
index e40435fa..8578daa4 100644
--- a/crates/fluss/src/client/table/scanner.rs
+++ b/crates/fluss/src/client/table/scanner.rs
@@ -48,10 +48,15 @@ use prost::Message;
use std::{
collections::{HashMap, HashSet},
slice::from_ref,
- sync::Arc,
- time::{Duration, Instant},
+ sync::{
+ Arc,
+ atomic::{AtomicI64, Ordering},
+ },
+ time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tempfile::TempDir;
+use tokio::task::JoinHandle;
+use tokio::time::MissedTickBehavior;
pub struct TableScan<'a> {
conn: &'a FlussConnection,
@@ -388,6 +393,23 @@ struct LogScannerInner {
/// Per-table scanner metric handles, pre-bound with `database`/`table`
/// labels.
metrics: Arc<ScannerMetrics>,
+ /// Wall-clock millis (since `UNIX_EPOCH`) of the most recent
+ /// `record_poll_start`. Sentinel `0` means "no poll yet" — the
+ /// `last_poll_seconds_ago` ticker skips emission while this is `0`,
+ /// deviating from Java's unguarded `(now - 0)/1000` startup value.
+ ///
+ /// Written by `record_poll_start` with `Release` ordering, read by
+ /// the ticker task with `Acquire` ordering. Cloned (`Arc`) into the
+ /// ticker so the task does not hold a back-reference to
+ /// `LogScannerInner` (avoids a reference cycle that would block
+ /// `Drop`, and hence the ticker abort, until tokio runtime
+ /// shutdown).
+ last_poll_unix_ms: Arc<AtomicI64>,
+ /// Handle to the 1-second background tokio task that pushes
+ /// `last_poll_seconds_ago` into the gauge. Aborted from
+ /// `impl Drop for LogScannerInner` so the gauge stops emitting once
+ /// the scanner is closed.
+ last_poll_seconds_ago_task: JoinHandle<()>,
}
/// Snapshot state used to derive the scanner poll-timing metrics.
@@ -437,6 +459,64 @@ impl Drop for PollGuard<'_> {
}
}
+/// Single-tick emission for the `last_poll_seconds_ago` gauge. Reads the
+/// last-poll timestamp from the shared atomic and pushes the elapsed
+/// integer-seconds into the gauge.
+///
+/// Emission is skipped while the atomic still holds the sentinel `0` (no
+/// `record_poll_start` yet) — Java's `(System.currentTimeMillis() - 0) /
+/// 1000` startup nonsense (see `ScannerMetricGroup.java:121`) would trip
+/// every consumer-liveness alert on startup. Java parity note: Java's
+/// expression is integer-truncating (`long / long`); we preserve that with
+/// `i64` division before the `f64` cast so dashboards built against Java
+/// behave the same.
+///
+/// Extracted from the ticker loop so unit tests can exercise the emission
+/// logic without depending on real-time scheduling.
+fn emit_last_poll_seconds_ago_once(last_poll_unix_ms: &AtomicI64, metrics:
&ScannerMetrics) {
+ let stored = last_poll_unix_ms.load(Ordering::Acquire);
+ if stored == 0 {
+ return;
+ }
+ let Ok(now_ms) = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_millis() as i64)
+ else {
+ return;
+ };
+ let seconds = ((now_ms - stored).max(0) / 1000) as f64;
+ metrics.record_last_poll_seconds_ago(seconds);
+}
+
+/// Spawn the 1-second background tokio task that pushes
+/// `last_poll_seconds_ago` into the gauge. The task holds only the shared
+/// atomic timestamp and the metric handle — never an `Arc<LogScannerInner>`
+/// — so it does not create a reference cycle that would block the scanner's
+/// `Drop` (and hence the abort that stops this task).
+///
+/// `MissedTickBehavior::Delay` is used so a stalled runtime (e.g. test
+/// pausing/advancing time) does not produce a burst of catch-up ticks when
+/// it resumes.
+fn spawn_last_poll_seconds_ago_ticker(
+ last_poll_unix_ms: Arc<AtomicI64>,
+ metrics: Arc<ScannerMetrics>,
+) -> JoinHandle<()> {
+ tokio::spawn(async move {
+ let mut interval = tokio::time::interval(Duration::from_secs(1));
+ interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
+ loop {
+ interval.tick().await;
+ emit_last_poll_seconds_ago_once(&last_poll_unix_ms, &metrics);
+ }
+ })
+}
+
+impl Drop for LogScannerInner {
+ fn drop(&mut self) {
+ self.last_poll_seconds_ago_task.abort();
+ }
+}
+
impl LogScannerInner {
fn new(
table_info: &TableInfo,
@@ -461,6 +541,11 @@ impl LogScannerInner {
};
let metrics = Arc::new(ScannerMetrics::new(&table_info.table_path));
+ let last_poll_unix_ms = Arc::new(AtomicI64::new(0));
+ let last_poll_seconds_ago_task = spawn_last_poll_seconds_ago_ticker(
+ Arc::clone(&last_poll_unix_ms),
+ Arc::clone(&metrics),
+ );
Ok(Self {
table_path: table_info.table_path.clone(),
table_id: table_info.table_id,
@@ -480,6 +565,8 @@ impl LogScannerInner {
reader_active: std::sync::atomic::AtomicBool::new(false),
poll_state: Mutex::new(PollState::default()),
metrics,
+ last_poll_unix_ms,
+ last_poll_seconds_ago_task,
})
}
@@ -584,6 +671,21 @@ impl LogScannerInner {
);
}
self.metrics.record_time_between_poll_ms(between_ms);
+
+ // Publish the wall-clock timestamp the ticker uses to compute
+ // `last_poll_seconds_ago`. Use `SystemTime` rather than `Instant`
+ // because the ticker needs an absolute clock to diff against
+ // `SystemTime::now()` at arbitrary moments. `Release` pairs with the
+ // ticker's `Acquire` load. If the system clock is somehow before
+ // `UNIX_EPOCH` (vanishingly rare; pre-1970 wall clock), we keep the
+ // existing value so we never publish a negative timestamp that would
+ // produce a bogus gauge reading on the next tick.
+ if let Ok(unix_ms) = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_millis() as i64)
+ {
+ self.last_poll_unix_ms.store(unix_ms, Ordering::Release);
+ }
}
/// Computes `poll_idle_ratio = poll_time / (poll_time + between_time)`.
@@ -2755,4 +2857,300 @@ mod tests {
// `ScannerMetrics`), this assertion catches it.
assert_scanner_entries_labeled(&entries, "db", "tbl");
}
+
+ /// `emit_last_poll_seconds_ago_once` must skip emission while the
+ /// shared atomic still holds the sentinel `0` — that's the
+ /// pre-first-poll guard that prevents Java's
+ /// `(System.currentTimeMillis() - 0) / 1000` startup nonsense from
+ /// tripping consumer-liveness alerts before any poll happens.
+ ///
+ /// `ScannerMetrics::new` already registers the gauge with the
+ /// recorder, so it appears in the snapshot with the default `0.0`
+ /// even without any emission. The discriminating assertion is that
+ /// the value stays near zero rather than blowing up to ~1.7 billion
+ /// (current Unix-epoch seconds), which is what a broken skip would
+ /// produce.
+ #[test]
+ fn emit_last_poll_seconds_ago_skips_sentinel_value() {
+ use crate::metrics::SCANNER_LAST_POLL_SECONDS_AGO;
+ use metrics_util::debugging::DebuggingRecorder;
+
+ let recorder = DebuggingRecorder::new();
+ let snapshotter = recorder.snapshotter();
+
+ metrics::with_local_recorder(&recorder, || {
+ let table_path = TablePath::new("db".to_string(),
"tbl".to_string());
+ let metrics = ScannerMetrics::new(&table_path);
+ let last_poll = AtomicI64::new(0);
+
+ for _ in 0..3 {
+ emit_last_poll_seconds_ago_once(&last_poll, &metrics);
+ }
+ });
+
+ let value = snapshot_gauge(&snapshotter, SCANNER_LAST_POLL_SECONDS_AGO)
+ .expect("ScannerMetrics::new registers the gauge so it appears in
the snapshot");
+ assert!(
+ value < 1.0,
+ "pre-first-poll emission must be skipped; broken skip would push
~unix-epoch \
+ seconds (~1.7e9) into the gauge, got {value}"
+ );
+ }
+
+ /// Once a real timestamp has been published, the helper must emit
+ /// `floor((now - stored) / 1000)` matching Java's integer-truncating
+ /// `(System.currentTimeMillis() - lastPollMs) / 1000`. Tolerance
+ /// allows for real wall-clock progression between the test setting
+ /// up `stored` and the helper reading `SystemTime::now()`.
+ ///
+ /// Also covers the reset-after-fresh-poll case: updating the
+ /// stored timestamp to "now" must drop the next emission back near
+ /// zero, matching the property "gauge resets when a new poll
+ /// happens".
+ #[test]
+ fn emit_last_poll_seconds_ago_publishes_integer_truncated_elapsed() {
+ use crate::metrics::SCANNER_LAST_POLL_SECONDS_AGO;
+ use metrics_util::debugging::DebuggingRecorder;
+
+ let recorder = DebuggingRecorder::new();
+ let snapshotter = recorder.snapshotter();
+
+ metrics::with_local_recorder(&recorder, || {
+ let table_path = TablePath::new("db".to_string(),
"tbl".to_string());
+ let metrics = ScannerMetrics::new(&table_path);
+
+ let now_ms = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_millis() as i64)
+ .expect("wall clock after UNIX_EPOCH");
+ let last_poll = AtomicI64::new(now_ms - 5_500);
+
+ emit_last_poll_seconds_ago_once(&last_poll, &metrics);
+
+ let value = snapshot_gauge(&snapshotter,
SCANNER_LAST_POLL_SECONDS_AGO)
+ .expect("gauge must emit once a real timestamp is published");
+ assert!(
+ (5.0..=6.0).contains(&value),
+ "gauge must be ~5 (5500ms truncated to 5s, plus test
scheduling slack), got {value}"
+ );
+
+ // Simulate a fresh poll: update the shared atomic to "right
+ // now". The next emission must collapse the gauge back near
+ // zero.
+ let fresh_now_ms = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_millis() as i64)
+ .expect("wall clock after UNIX_EPOCH");
+ last_poll.store(fresh_now_ms, Ordering::Release);
+
+ emit_last_poll_seconds_ago_once(&last_poll, &metrics);
+
+ let reset = snapshot_gauge(&snapshotter,
SCANNER_LAST_POLL_SECONDS_AGO)
+ .expect("gauge must still be present after the second
emission");
+ assert!(
+ (0.0..=1.0).contains(&reset),
+ "fresh poll must reset gauge near zero, got {reset}"
+ );
+ });
+
+ assert_scanner_entries_labeled(&snapshotter.snapshot().into_vec(),
"db", "tbl");
+ }
+
+ /// Negative `now - stored` (e.g. wall-clock jumps backwards via NTP)
+ /// must clamp to 0, not produce a negative gauge reading.
+ #[test]
+ fn emit_last_poll_seconds_ago_clamps_negative_delta_to_zero() {
+ use crate::metrics::SCANNER_LAST_POLL_SECONDS_AGO;
+ use metrics_util::debugging::DebuggingRecorder;
+
+ let recorder = DebuggingRecorder::new();
+ let snapshotter = recorder.snapshotter();
+
+ metrics::with_local_recorder(&recorder, || {
+ let table_path = TablePath::new("db".to_string(),
"tbl".to_string());
+ let metrics = ScannerMetrics::new(&table_path);
+
+ let now_ms = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_millis() as i64)
+ .expect("wall clock after UNIX_EPOCH");
+ // Stored timestamp in the future → negative delta.
+ let last_poll = AtomicI64::new(now_ms + 60_000);
+
+ emit_last_poll_seconds_ago_once(&last_poll, &metrics);
+ });
+
+ let value = snapshot_gauge(&snapshotter, SCANNER_LAST_POLL_SECONDS_AGO)
+ .expect("gauge must emit even when delta is clamped to 0");
+ assert_eq!(value, 0.0, "negative delta must clamp to 0, got {value}");
+ }
+
+ /// `spawn_last_poll_seconds_ago_ticker` returns a `JoinHandle` whose
+ /// `abort()` cleanly terminates the loop. Pins the lifecycle pattern
+ /// that `impl Drop for LogScannerInner` relies on.
+ #[tokio::test(flavor = "current_thread")]
+ async fn spawn_last_poll_seconds_ago_ticker_aborts_cleanly() {
+ let table_path = TablePath::new("db".to_string(), "tbl".to_string());
+ let metrics = Arc::new(ScannerMetrics::new(&table_path));
+ let last_poll_unix_ms = Arc::new(AtomicI64::new(0));
+
+ let handle = spawn_last_poll_seconds_ago_ticker(
+ Arc::clone(&last_poll_unix_ms),
+ Arc::clone(&metrics),
+ );
+ assert!(
+ !handle.is_finished(),
+ "freshly spawned ticker must be alive"
+ );
+
+ handle.abort();
+ let join_result = handle.await;
+ assert!(
+ join_result.is_err() && join_result.unwrap_err().is_cancelled(),
+ "abort must cancel the ticker, not let it complete normally"
+ );
+ }
+
+ /// End-to-end test of the *spawned* ticker (not just the extracted
+ /// `emit_last_poll_seconds_ago_once` helper): the interval loop must
+ /// emit on its first tick and keep emitting on subsequent ticks,
+ /// reflecting the latest published timestamp each time.
+ ///
+ /// Uses a paused-clock `current_thread` runtime so the second tick can
+ /// be driven deterministically with `tokio::time::advance` instead of
+ /// sleeping a real second. Note the elapsed value is derived from
+ /// wall-clock `SystemTime`, which `advance` does *not* move — so the
+ /// gauge value is controlled by what we store in the atomic (a known
+ /// past / present wall-clock timestamp), and `advance` is used only to
+ /// fire the parked 1-second interval timer.
+ ///
+ /// Built inside `with_local_recorder` (rather than `#[tokio::test]`)
+ /// because the metrics facade installs a thread-local recorder; the
+ /// spawned task is polled on the same thread during `block_on`, so its
+ /// `gauge!` calls resolve to this local recorder.
+ #[test]
+ fn spawned_ticker_emits_on_first_and_subsequent_ticks() {
+ use crate::metrics::SCANNER_LAST_POLL_SECONDS_AGO;
+ use metrics_util::debugging::DebuggingRecorder;
+
+ let recorder = DebuggingRecorder::new();
+ let snapshotter = recorder.snapshotter();
+
+ metrics::with_local_recorder(&recorder, || {
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .start_paused(true)
+ .build()
+ .expect("build paused current_thread runtime");
+
+ rt.block_on(async {
+ let table_path = TablePath::new("db".to_string(),
"tbl".to_string());
+ let metrics = Arc::new(ScannerMetrics::new(&table_path));
+ let last_poll_unix_ms = Arc::new(AtomicI64::new(0));
+
+ // Simulate a poll that started ~5s ago (wall clock) before
+ // the ticker runs, so the first (immediate) tick emits ~5.
+ let now_ms = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_millis() as i64)
+ .expect("wall clock after UNIX_EPOCH");
+ last_poll_unix_ms.store(now_ms - 5_000, Ordering::Release);
+
+ let handle = spawn_last_poll_seconds_ago_ticker(
+ Arc::clone(&last_poll_unix_ms),
+ Arc::clone(&metrics),
+ );
+
+ // `tokio::time::interval` fires its first tick immediately,
+ // so a few yields let the spawned task run that first
+ // emission without advancing the clock.
+ for _ in 0..8 {
+ tokio::task::yield_now().await;
+ }
+
+ let first = snapshot_gauge(&snapshotter,
SCANNER_LAST_POLL_SECONDS_AGO)
+ .expect("spawned ticker must emit on its first (immediate)
tick");
+ assert!(
+ (5.0..=6.0).contains(&first),
+ "first tick must reflect ~5s elapsed, got {first}"
+ );
+
+ // Simulate a fresh poll "now", then advance the paused clock
+ // by 1s to fire the parked second interval tick. The loop
+ // must emit again, this time near zero.
+ let fresh_ms = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_millis() as i64)
+ .expect("wall clock after UNIX_EPOCH");
+ last_poll_unix_ms.store(fresh_ms, Ordering::Release);
+
+ tokio::time::advance(Duration::from_secs(1)).await;
+ for _ in 0..8 {
+ tokio::task::yield_now().await;
+ }
+
+ let second = snapshot_gauge(&snapshotter,
SCANNER_LAST_POLL_SECONDS_AGO)
+ .expect("spawned ticker must keep emitting on subsequent
ticks");
+ assert!(
+ (0.0..=1.0).contains(&second),
+ "second tick after a fresh poll must reset gauge near
zero, got {second}"
+ );
+
+ handle.abort();
+ });
+ });
+
+ assert_scanner_entries_labeled(&snapshotter.snapshot().into_vec(),
"db", "tbl");
+ }
+
+ /// `LogScannerInner::drop` must abort the ticker task so the gauge
+ /// stops emitting once the scanner is closed — mirrors Java's
+ /// `ScannerMetricGroup.close()`. The atomic is shared with the task,
+ /// so we use its `Arc::strong_count` as an indirect liveness probe:
+ /// once the runtime processes the abort and drops the task's future,
+ /// the task's clone of the `Arc` is released, leaving only the one
+ /// we hold here.
+ #[test]
+ fn log_scanner_inner_drop_aborts_ticker_task() {
+ 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 inner = LogScannerInner::new(
+ &table_info,
+ metadata,
+ Arc::new(RpcClient::new()),
+ &Config::default(),
+ None,
+ )
+ .expect("build LogScannerInner");
+
+ let abort_handle = inner.last_poll_seconds_ago_task.abort_handle();
+ assert!(
+ !abort_handle.is_finished(),
+ "ticker must be alive before scanner drop"
+ );
+
+ drop(inner);
+
+ // Yield repeatedly so the runtime can process the abort.
+ // Cap at a generous iteration count to avoid hanging the test
+ // if Drop ever stops calling `abort()`.
+ for _ in 0..32 {
+ tokio::task::yield_now().await;
+ if abort_handle.is_finished() {
+ break;
+ }
+ }
+ assert!(
+ abort_handle.is_finished(),
+ "Drop for LogScannerInner must abort the last_poll_seconds_ago
ticker"
+ );
+ });
+ }
}
diff --git a/crates/fluss/src/metrics.rs b/crates/fluss/src/metrics.rs
index 7c62738c..9f820ffe 100644
--- a/crates/fluss/src/metrics.rs
+++ b/crates/fluss/src/metrics.rs
@@ -60,16 +60,15 @@ pub const CLIENT_REQUESTS_IN_FLIGHT: &str =
"fluss.client.requests_in_flight";
// Java reference: ScannerMetricGroup.java, LogScannerImpl.java
//
// These track consumer liveness and processing efficiency at the `poll()`
-// boundary. Java records via `volatile long` fields read by gauge suppliers;
-// Rust snapshots the values at poll start/end.
+// boundary. Java records via `volatile long` fields read by gauge suppliers
+// at scrape time; Rust pushes values via the `metrics` facade.
//
-// Java's `lastPollSecondsAgo` gauge is intentionally NOT ported. Java
-// implements it as a gauge supplier evaluated at scrape time, which the
-// `metrics` crate facade has no equivalent for. A snapshot-at-poll-start
-// port would just duplicate `time_between_poll_ms / 1000` and would not
-// advance while a consumer is hung — defeating the metric's purpose
-// (detecting a stuck consumer). Revisit if the `metrics` crate gains a
-// supplier abstraction or we add a background liveness task.
+// `time_between_poll_ms` and `poll_idle_ratio` are snapshot at poll
+// start / poll end. `last_poll_seconds_ago` must keep advancing between
+// polls (it measures elapsed time, not activity), so it is emitted by a
+// per-scanner 1-second background tokio task spawned in
+// `LogScannerInner::new`. The task is aborted when the last scanner
+// `Arc` is dropped, matching Java's `ScannerMetricGroup.close()`.
// ---------------------------------------------------------------------------
/// Gauge: milliseconds between the start of consecutive `poll()` calls. A
@@ -82,6 +81,17 @@ pub const SCANNER_TIME_BETWEEN_POLL_MS: &str =
"fluss.client.scanner.time_betwee
/// the bottleneck.
pub const SCANNER_POLL_IDLE_RATIO: &str =
"fluss.client.scanner.poll_idle_ratio";
+/// Gauge: integer seconds since the most recent `poll()` started. Advances
+/// monotonically between polls — the primary stuck-consumer signal.
+///
+/// Pushed every second by a per-scanner background tokio task. Emission is
+/// skipped until the first `poll()` happens; Java's equivalent
+/// `lastPollSecondsAgo` returns roughly the current Unix-epoch seconds
+/// before the first poll (an unguarded `(now - 0)/1000`), which would trip
+/// every consumer-liveness alert on startup.
+///
+pub const SCANNER_LAST_POLL_SECONDS_AGO: &str =
"fluss.client.scanner.last_poll_seconds_ago";
+
// ---------------------------------------------------------------------------
// Scanner fetch + remote download metrics
//
@@ -143,6 +153,7 @@ pub const SCANNER_REMOTE_FETCH_ERRORS_TOTAL: &str =
pub(crate) struct ScannerMetrics {
time_between_poll_ms: metrics::Gauge,
poll_idle_ratio: metrics::Gauge,
+ last_poll_seconds_ago: metrics::Gauge,
fetch_requests_total: metrics::Counter,
fetch_latency_ms: metrics::Histogram,
bytes_per_request: metrics::Histogram,
@@ -160,6 +171,7 @@ impl ScannerMetrics {
Self {
time_between_poll_ms: scanner_gauge(SCANNER_TIME_BETWEEN_POLL_MS,
database, table),
poll_idle_ratio: scanner_gauge(SCANNER_POLL_IDLE_RATIO, database,
table),
+ last_poll_seconds_ago:
scanner_gauge(SCANNER_LAST_POLL_SECONDS_AGO, database, table),
fetch_requests_total:
scanner_counter(SCANNER_FETCH_REQUESTS_TOTAL, database, table),
fetch_latency_ms: scanner_histogram(SCANNER_FETCH_LATENCY_MS,
database, table),
bytes_per_request: scanner_histogram(SCANNER_BYTES_PER_REQUEST,
database, table),
@@ -189,6 +201,10 @@ impl ScannerMetrics {
self.poll_idle_ratio.set(value);
}
+ pub(crate) fn record_last_poll_seconds_ago(&self, value: f64) {
+ self.last_poll_seconds_ago.set(value);
+ }
+
pub(crate) fn record_fetch_request(&self) {
self.fetch_requests_total.increment(1);
}
@@ -486,6 +502,27 @@ mod tests {
assert_scanner_entries_labeled(&entries, "db", "tbl");
}
+ #[test]
+ fn scanner_last_poll_seconds_ago_emits_correctly() {
+ let recorder = DebuggingRecorder::new();
+ let snapshotter = recorder.snapshotter();
+
+ metrics::with_local_recorder(&recorder, || {
+ let table_path = TablePath::new("db", "tbl");
+ let m = ScannerMetrics::new(&table_path);
+ m.record_last_poll_seconds_ago(42.0);
+ });
+
+ let snapshot = snapshotter.snapshot();
+ let entries: Vec<_> = snapshot.into_vec();
+
+ assert_eq!(
+ find_gauge!(entries, SCANNER_LAST_POLL_SECONDS_AGO),
+ Some(42.0)
+ );
+ assert_scanner_entries_labeled(&entries, "db", "tbl");
+ }
+
#[test]
fn scanner_fetch_metrics_emit_correctly() {
let recorder = DebuggingRecorder::new();