Skip to main content

ringline_ping/
lib.rs

1//! ringline-native Ping client for use inside the ringline async runtime.
2//!
3//! This client wraps a [`ringline::ConnCtx`] and provides a typed `ping()`
4//! method that sends `PING\r\n` and waits for a `PONG` response.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use ringline::ConnCtx;
10//! use ringline_ping::Client;
11//!
12//! async fn example(conn: ConnCtx) -> Result<(), ringline_ping::Error> {
13//!     let mut client = Client::new(conn);
14//!     client.ping().await?;
15//!     Ok(())
16//! }
17//! ```
18//!
19//! # Copy Semantics
20//!
21//! | Path | Copies | Mechanism |
22//! |------|--------|-----------|
23//! | **Recv** | **0** | `with_data()` pattern-matches `PONG\r\n` -- no value extraction. |
24//! | **Send** | 1 | 6-byte `PING\r\n` copied into the send pool. |
25
26pub mod pool;
27pub use pool::{Pool, PoolConfig};
28
29use std::cell::Cell;
30use std::io;
31use std::time::Instant;
32
33use ping_proto::{Request as PingRequest, Response as PingResponse};
34use ringline::{ConnCtx, ParseResult};
35
36// -- Error -------------------------------------------------------------------
37
38/// Errors returned by the ringline Ping client.
39#[derive(Debug, thiserror::Error)]
40pub enum Error {
41    /// The connection was closed before a response was received.
42    #[error("connection closed")]
43    ConnectionClosed,
44
45    /// The response type did not match the expected type for the command.
46    #[error("unexpected response")]
47    UnexpectedResponse,
48
49    /// Ping protocol parse error.
50    #[error("protocol error: {0}")]
51    Protocol(#[from] ping_proto::ParseError),
52
53    /// I/O error during send.
54    #[error("io error: {0}")]
55    Io(#[from] io::Error),
56
57    /// All connections in the pool are down and reconnection failed.
58    #[error("all connections failed")]
59    AllConnectionsFailed,
60}
61
62// ── Command types ───────────────────────────────────────────────────────
63
64/// The type of Ping command that completed.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum CommandType {
67    Ping,
68}
69
70/// Result metadata for a completed command, passed to the `on_result` callback.
71#[derive(Debug, Clone)]
72pub struct CommandResult {
73    /// The command type.
74    pub command: CommandType,
75    /// Latency in nanoseconds (send → response parsed).
76    pub latency_ns: u64,
77    /// Whether the command succeeded (no error response).
78    pub success: bool,
79    /// Time-to-first-byte in nanoseconds (not available in sequential mode).
80    pub ttfb_ns: Option<u64>,
81    /// Bytes transmitted for this command (protocol-encoded request size).
82    pub tx_bytes: u32,
83    /// Bytes received for this command (protocol-encoded response size).
84    pub rx_bytes: u32,
85}
86
87// ── ClientMetrics ───────────────────────────────────────────────────────
88
89/// Built-in histogram-based metrics, available when the `metrics` feature is
90/// enabled.
91#[cfg(feature = "metrics")]
92pub struct ClientMetrics {
93    /// Ping latency histogram.
94    pub latency: histogram::Histogram,
95    /// Total requests completed.
96    pub requests: u64,
97    /// Total errors.
98    pub errors: u64,
99}
100
101#[cfg(feature = "metrics")]
102impl ClientMetrics {
103    fn new() -> Self {
104        Self {
105            latency: histogram::Histogram::new(7, 64).unwrap(),
106            requests: 0,
107            errors: 0,
108        }
109    }
110
111    fn record(&mut self, result: &CommandResult) {
112        self.requests += 1;
113        let _ = self.latency.increment(result.latency_ns);
114
115        if !result.success {
116            self.errors += 1;
117        }
118    }
119}
120
121// ── ClientBuilder ───────────────────────────────────────────────────────
122
123type ResultCallback = Box<dyn Fn(&CommandResult)>;
124
125/// Builder for creating a [`Client`] with per-request callbacks and metrics.
126pub struct ClientBuilder {
127    conn: ConnCtx,
128    on_result: Option<ResultCallback>,
129    #[cfg(feature = "timestamps")]
130    use_kernel_ts: bool,
131    #[cfg(feature = "metrics")]
132    with_metrics: bool,
133}
134
135impl ClientBuilder {
136    pub(crate) fn new(conn: ConnCtx) -> Self {
137        Self {
138            conn,
139            on_result: None,
140            #[cfg(feature = "timestamps")]
141            use_kernel_ts: false,
142            #[cfg(feature = "metrics")]
143            with_metrics: false,
144        }
145    }
146
147    /// Register a callback invoked after each command completes.
148    pub fn on_result<F: Fn(&CommandResult) + 'static>(mut self, f: F) -> Self {
149        self.on_result = Some(Box::new(f));
150        self
151    }
152
153    /// Enable kernel SO_TIMESTAMPING for latency measurement (requires `timestamps` feature).
154    #[cfg(feature = "timestamps")]
155    pub fn kernel_timestamps(mut self, enabled: bool) -> Self {
156        self.use_kernel_ts = enabled;
157        self
158    }
159
160    /// Enable built-in histogram tracking (requires `metrics` feature).
161    #[cfg(feature = "metrics")]
162    pub fn with_metrics(mut self) -> Self {
163        self.with_metrics = true;
164        self
165    }
166
167    /// Build the client.
168    pub fn build(self) -> Client {
169        Client {
170            conn: self.conn,
171            on_result: self.on_result,
172            last_rx_bytes: Cell::new(0),
173            #[cfg(feature = "timestamps")]
174            use_kernel_ts: self.use_kernel_ts,
175            #[cfg(feature = "metrics")]
176            metrics: if self.with_metrics {
177                Some(ClientMetrics::new())
178            } else {
179                None
180            },
181        }
182    }
183}
184
185// -- Client ------------------------------------------------------------------
186
187/// A ringline-native Ping client wrapping a single connection.
188///
189/// `Client::new(conn)` creates a zero-overhead client with no callbacks or
190/// metrics. Use `Client::builder(conn)` to configure per-request callbacks,
191/// kernel timestamps, and built-in histogram tracking.
192pub struct Client {
193    conn: ConnCtx,
194    on_result: Option<ResultCallback>,
195    last_rx_bytes: Cell<u32>,
196    #[cfg(feature = "timestamps")]
197    use_kernel_ts: bool,
198    #[cfg(feature = "metrics")]
199    metrics: Option<ClientMetrics>,
200}
201
202impl Client {
203    /// Create a new client wrapping an established connection.
204    ///
205    /// No callbacks, no metrics, no kernel timestamps — zero overhead.
206    pub fn new(conn: ConnCtx) -> Self {
207        Self {
208            conn,
209            on_result: None,
210            last_rx_bytes: Cell::new(0),
211            #[cfg(feature = "timestamps")]
212            use_kernel_ts: false,
213            #[cfg(feature = "metrics")]
214            metrics: None,
215        }
216    }
217
218    /// Create a builder for a client with per-request callbacks.
219    pub fn builder(conn: ConnCtx) -> ClientBuilder {
220        ClientBuilder::new(conn)
221    }
222
223    /// Returns the underlying connection context.
224    pub fn conn(&self) -> ConnCtx {
225        self.conn
226    }
227
228    /// Returns a reference to the built-in metrics, if enabled.
229    #[cfg(feature = "metrics")]
230    pub fn metrics(&self) -> Option<&ClientMetrics> {
231        self.metrics.as_ref()
232    }
233
234    /// Returns a mutable reference to the built-in metrics, if enabled.
235    #[cfg(feature = "metrics")]
236    pub fn metrics_mut(&mut self) -> Option<&mut ClientMetrics> {
237        self.metrics.as_mut()
238    }
239
240    // ── Timing helpers (private) ────────────────────────────────────────
241
242    #[inline]
243    fn is_instrumented(&self) -> bool {
244        if self.on_result.is_some() {
245            return true;
246        }
247        #[cfg(feature = "metrics")]
248        if self.metrics.is_some() {
249            return true;
250        }
251        false
252    }
253
254    #[cfg(feature = "timestamps")]
255    #[inline]
256    fn send_timestamp(&self) -> u64 {
257        if self.use_kernel_ts {
258            now_realtime_ns()
259        } else {
260            0
261        }
262    }
263
264    #[cfg(not(feature = "timestamps"))]
265    #[inline]
266    fn send_timestamp(&self) -> u64 {
267        0
268    }
269
270    #[cfg(feature = "timestamps")]
271    #[inline]
272    fn finish_timing(&self, send_ts: u64, start: Instant) -> u64 {
273        if self.use_kernel_ts {
274            let recv_ts = self.conn.recv_timestamp();
275            if recv_ts > 0 && recv_ts > send_ts {
276                return recv_ts - send_ts;
277            }
278        }
279        start.elapsed().as_nanos() as u64
280    }
281
282    #[cfg(not(feature = "timestamps"))]
283    #[inline]
284    fn finish_timing(&self, _send_ts: u64, start: Instant) -> u64 {
285        start.elapsed().as_nanos() as u64
286    }
287
288    fn record(&mut self, result: &CommandResult) {
289        if let Some(ref cb) = self.on_result {
290            cb(result);
291        }
292        #[cfg(feature = "metrics")]
293        if let Some(ref mut m) = self.metrics {
294            m.record(result);
295        }
296    }
297
298    // ── Internal I/O (unchanged) ────────────────────────────────────────
299
300    /// Read and parse a single Ping response from the connection.
301    pub(crate) async fn read_response(&self) -> Result<PingResponse, Error> {
302        let mut result: Option<Result<PingResponse, Error>> = None;
303        let n = self
304            .conn
305            .with_data(|data| match PingResponse::parse(data) {
306                Ok((response, consumed)) => {
307                    result = Some(Ok(response));
308                    ParseResult::Consumed(consumed)
309                }
310                Err(ping_proto::ParseError::Incomplete) => ParseResult::Consumed(0),
311                Err(e) => {
312                    result = Some(Err(Error::Protocol(e)));
313                    ParseResult::Consumed(data.len())
314                }
315            })
316            .await;
317        self.last_rx_bytes.set(n as u32);
318        if n == 0 {
319            return result.unwrap_or(Err(Error::ConnectionClosed));
320        }
321        result.unwrap()
322    }
323
324    /// Send an encoded command and read the response.
325    async fn execute(&self, encoded: &[u8]) -> Result<PingResponse, Error> {
326        self.conn.send(encoded)?;
327        self.read_response().await
328    }
329
330    // -- Commands -------------------------------------------------------------
331
332    /// Send a PING and wait for a PONG response.
333    pub async fn ping(&mut self) -> Result<(), Error> {
334        let mut buf = [0u8; 6];
335        let len = PingRequest::Ping.encode(&mut buf);
336
337        if !self.is_instrumented() {
338            let response = self.execute(&buf[..len]).await?;
339            return match response {
340                PingResponse::Pong => Ok(()),
341                #[allow(unreachable_patterns)]
342                _ => Err(Error::UnexpectedResponse),
343            };
344        }
345
346        let tx_bytes = len as u32;
347        let send_ts = self.send_timestamp();
348        let start = Instant::now();
349        let response = self.execute(&buf[..len]).await;
350        let latency_ns = self.finish_timing(send_ts, start);
351        let rx_bytes = self.last_rx_bytes.get();
352
353        let result = match response {
354            Ok(PingResponse::Pong) => Ok(()),
355            Ok(_) => Err(Error::UnexpectedResponse),
356            Err(e) => Err(e),
357        };
358        self.record(&CommandResult {
359            command: CommandType::Ping,
360            latency_ns,
361            success: result.is_ok(),
362            ttfb_ns: None,
363            tx_bytes,
364            rx_bytes,
365        });
366        result
367    }
368}
369
370// ── Kernel timestamp helper ─────────────────────────────────────────────
371
372#[cfg(feature = "timestamps")]
373fn now_realtime_ns() -> u64 {
374    let mut ts = libc::timespec {
375        tv_sec: 0,
376        tv_nsec: 0,
377    };
378    unsafe {
379        libc::clock_gettime(libc::CLOCK_REALTIME, &mut ts);
380    }
381    ts.tv_sec as u64 * 1_000_000_000 + ts.tv_nsec as u64
382}