Skip to main content

tonin_client/
client.rs

1//! Generic coalescing wrapper for tonic-generated gRPC clients.
2//!
3//! [`CoalescingClient`] wraps any tonic-generated client `C` and provides:
4//! - **Request coalescing** (singleflight) — default ON. Concurrent calls
5//!   with identical keys share one upstream RPC.
6//! - **Optional per-method TTL cache** — OFF unless configured via
7//!   [`CoalescingClientBuilder::cache`]. Only `Ok` responses are cached.
8//!
9//! Call order per unary RPC:
10//! `cache hit → (miss) coalescing → upstream → populate cache`
11//!
12//! ## Usage
13//!
14//! ```ignore
15//! use tonin_client::client::CoalescingClient;
16//! use tonin_client::cache::CacheConfig;
17//! use std::time::Duration;
18//!
19//! // Default: coalescing ON, no cache.
20//! let inner = AuthClient::connect("http://auth:50051").await?;
21//! let client = CoalescingClient::new(inner);
22//!
23//! // With TTL cache on the Check method:
24//! let client = CoalescingClient::builder(inner)
25//!     .cache("Check", CacheConfig::new(Duration::from_millis(500), 1_000))
26//!     .build();
27//!
28//! // Disable coalescing (e.g. for side-effecting write methods):
29//! let client = CoalescingClient::builder(inner)
30//!     .coalesce(false)
31//!     .build();
32//! ```
33//!
34//! ## Key derivation
35//!
36//! Default key: `(service_path, method_name, prost::encode_to_vec(request))`.
37//! Any change to any request field — including a nested `consistency` field —
38//! produces a different key, so only byte-for-byte identical requests coalesce.
39//! Override via [`CoalescingClientBuilder::key_fn`] when responses also vary by
40//! caller identity and the auth principal should be part of the key.
41//!
42//! ## Config precedence
43//!
44//! `tonin.toml` `[client]` fields are applied at code-generation time.
45//! [`CoalescingClientBuilder`] overrides them at runtime; the builder always wins.
46
47use std::any::Any;
48use std::collections::HashMap;
49use std::sync::Arc;
50
51use dashmap::DashMap;
52use tonic::{Response, Status};
53
54use crate::cache::{CacheConfig, ResponseCache};
55use crate::coalesce::{DefaultKeyFn, KeyFn, Singleflight};
56
57/// Runtime configuration for a [`CoalescingClient`].
58#[derive(Clone, Default)]
59pub struct ClientConfig {
60    /// Enable request coalescing. Default: `true`.
61    pub coalesce: bool,
62    /// Per-method TTL cache configs. Key = method name (e.g. `"Check"`).
63    pub caches: HashMap<String, CacheConfig>,
64}
65
66impl ClientConfig {
67    pub fn new() -> Self {
68        Self {
69            coalesce: true,
70            caches: HashMap::new(),
71        }
72    }
73}
74
75/// Builder for [`CoalescingClient`].
76pub struct CoalescingClientBuilder<C> {
77    inner: C,
78    coalesce: bool,
79    caches: HashMap<String, CacheConfig>,
80    key_fn: Arc<dyn KeyFn>,
81}
82
83impl<C> CoalescingClientBuilder<C> {
84    fn new(inner: C) -> Self {
85        Self {
86            inner,
87            coalesce: true,
88            caches: HashMap::new(),
89            key_fn: Arc::new(DefaultKeyFn),
90        }
91    }
92
93    /// Enable or disable coalescing for all methods. Default: `true`.
94    pub fn coalesce(mut self, enabled: bool) -> Self {
95        self.coalesce = enabled;
96        self
97    }
98
99    /// Add a TTL response cache for `method`. Off by default; must opt in.
100    pub fn cache(mut self, method: impl Into<String>, cfg: CacheConfig) -> Self {
101        self.caches.insert(method.into(), cfg);
102        self
103    }
104
105    /// Override the key derivation function. The default includes only
106    /// request bytes in the key. Supply a custom `KeyFn` when responses
107    /// vary by caller identity (e.g. include the auth principal header).
108    pub fn key_fn(mut self, f: impl KeyFn) -> Self {
109        self.key_fn = Arc::new(f);
110        self
111    }
112
113    pub fn build(self) -> CoalescingClient<C> {
114        CoalescingClient {
115            inner: self.inner,
116            coalesce: self.coalesce,
117            cache_configs: self.caches,
118            key_fn: self.key_fn,
119            singleflights: DashMap::new(),
120            response_caches: DashMap::new(),
121        }
122    }
123}
124
125/// A coalescing + optional-cache wrapper around a tonic-generated client.
126///
127/// Call [`CoalescingClient::call`] from within generated method wrappers,
128/// passing the encoded request bytes, service path, method name, and the
129/// upstream closure. Coalescing and caching are handled transparently.
130pub struct CoalescingClient<C> {
131    /// The wrapped tonic-generated client.
132    pub inner: C,
133    coalesce: bool,
134    cache_configs: HashMap<String, CacheConfig>,
135    key_fn: Arc<dyn KeyFn>,
136    /// Type-erased per-method singleflights: method → `Arc<Singleflight<R>>`.
137    /// The key→R-type contract is enforced by the generated client: a given
138    /// method name always maps to the same response type.
139    singleflights: DashMap<String, Arc<dyn Any + Send + Sync>>,
140    /// Type-erased per-method caches: method → `Arc<ResponseCache<R>>`.
141    response_caches: DashMap<String, Arc<dyn Any + Send + Sync>>,
142}
143
144impl<C> CoalescingClient<C> {
145    /// Wrap `inner` with default config (coalescing ON, no cache).
146    pub fn new(inner: C) -> Self {
147        CoalescingClientBuilder::new(inner).build()
148    }
149
150    /// Start a builder for granular per-method configuration.
151    pub fn builder(inner: C) -> CoalescingClientBuilder<C> {
152        CoalescingClientBuilder::new(inner)
153    }
154
155    /// Execute a coalesced (and optionally cached) unary call.
156    ///
157    /// - `service`: gRPC service path, e.g. `"auth.v1.AuthService"`.
158    /// - `method`: method name, e.g. `"Check"`.
159    /// - `req_bytes`: `prost::Message::encode_to_vec(&request)` — the
160    ///   caller encodes once and passes the bytes for key derivation.
161    /// - `upstream`: async closure that performs the actual tonic call and
162    ///   returns `Result<Response<R>, Status>`.
163    ///
164    /// `R` must be `Clone + Send + Sync + 'static` so it can be shared
165    /// across waiters and stored in the cache.
166    ///
167    /// **Streaming calls must not use this method.** Pass through to `self.inner` directly.
168    pub async fn call<R, F, Fut>(
169        &self,
170        service: &str,
171        method: &str,
172        req_bytes: Vec<u8>,
173        upstream: F,
174    ) -> Result<Response<R>, Status>
175    where
176        R: Clone + Send + Sync + 'static,
177        F: FnOnce() -> Fut + Send + 'static,
178        Fut: std::future::Future<Output = Result<Response<R>, Status>> + Send + 'static,
179    {
180        let key = self.key_fn.derive(service, method, &req_bytes);
181
182        // 1. TTL cache lookup.
183        if let Some(cache) = self.get_cache::<R>(method)
184            && let Some(hit) = cache.get(&key)
185        {
186            tracing::debug!(method, "coalesce: cache hit");
187            return Ok(Response::new(hit));
188        }
189
190        // 2. Coalescing (or direct call if disabled).
191        let result: Result<R, Status> = if self.coalesce {
192            let sf = self.get_or_create_sf::<R>(method);
193            sf.run(key.clone(), service, method, async move {
194                upstream().await.map(Response::into_inner)
195            })
196            .await
197        } else {
198            upstream().await.map(Response::into_inner)
199        };
200
201        // 3. Populate cache on Ok.
202        if let Ok(ref value) = result
203            && let Some(cache) = self.get_cache::<R>(method)
204        {
205            cache.insert(&key, value.clone());
206        }
207
208        result.map(Response::new)
209    }
210
211    // --- private helpers ---
212
213    /// Get or atomically create the `Singleflight<R>` for `method`.
214    ///
215    /// The `DashMap::entry()` API holds a per-key shard lock for the
216    /// duration of `or_insert_with`, so concurrent first-calls for the
217    /// same method race safely: only one `Singleflight` is ever created.
218    fn get_or_create_sf<R: Clone + Send + Sync + 'static>(
219        &self,
220        method: &str,
221    ) -> Arc<Singleflight<R>> {
222        let entry = self
223            .singleflights
224            .entry(method.to_string())
225            .or_insert_with(|| Arc::new(Singleflight::<R>::new()) as Arc<dyn Any + Send + Sync>);
226
227        entry
228            .value()
229            .clone()
230            .downcast::<Singleflight<R>>()
231            .expect("method always maps to the same response type")
232    }
233
234    /// Get the `ResponseCache<R>` for `method`, creating it on first access
235    /// if a `CacheConfig` was supplied for this method.
236    fn get_cache<R: Clone + Send + Sync + 'static>(
237        &self,
238        method: &str,
239    ) -> Option<Arc<ResponseCache<R>>> {
240        let cfg = self.cache_configs.get(method)?;
241
242        let entry = self
243            .response_caches
244            .entry(method.to_string())
245            .or_insert_with(|| {
246                Arc::new(ResponseCache::<R>::new(cfg.clone())) as Arc<dyn Any + Send + Sync>
247            });
248
249        entry.value().clone().downcast::<ResponseCache<R>>().ok()
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::cache::CacheConfig;
257    use std::sync::Arc;
258    use std::sync::atomic::{AtomicU32, Ordering};
259    use std::time::Duration;
260    use tonic::{Response, Status};
261
262    // A minimal fake inner client for tests — holds a counter and a flag
263    // controlling whether calls succeed.
264    #[derive(Clone)]
265    struct FakeClient {
266        counter: Arc<AtomicU32>,
267        fail: bool,
268    }
269
270    impl FakeClient {
271        fn new() -> Self {
272            Self {
273                counter: Arc::new(AtomicU32::new(0)),
274                fail: false,
275            }
276        }
277        fn failing() -> Self {
278            Self {
279                counter: Arc::new(AtomicU32::new(0)),
280                fail: true,
281            }
282        }
283        async fn check(&self, _: &str) -> Result<Response<String>, Status> {
284            self.counter.fetch_add(1, Ordering::SeqCst);
285            tokio::time::sleep(Duration::from_millis(20)).await;
286            if self.fail {
287                Err(Status::internal("injected failure"))
288            } else {
289                Ok(Response::new("ok".into()))
290            }
291        }
292    }
293
294    #[tokio::test]
295    async fn concurrent_identical_calls_share_one_upstream() {
296        let fake = FakeClient::new();
297        let counter = fake.counter.clone();
298        let client = CoalescingClient::new(fake.clone());
299
300        let client = Arc::new(client);
301        let mut handles = Vec::new();
302        for _ in 0..6 {
303            let c = client.inner.clone();
304            let cl = Arc::clone(&client);
305            handles.push(tokio::spawn(async move {
306                cl.call::<String, _, _>("svc", "Check", b"request".to_vec(), move || {
307                    let c = c.clone();
308                    async move { c.check("input").await }
309                })
310                .await
311            }));
312        }
313
314        let results = futures_util::future::join_all(handles).await;
315        assert_eq!(counter.load(Ordering::SeqCst), 1);
316        for r in results {
317            assert_eq!(r.unwrap().unwrap().into_inner(), "ok");
318        }
319    }
320
321    #[tokio::test]
322    async fn different_request_bytes_not_coalesced() {
323        let fake = FakeClient::new();
324        let counter = fake.counter.clone();
325        let client = Arc::new(CoalescingClient::new(fake.clone()));
326
327        let mut handles = Vec::new();
328        for i in 0u8..4 {
329            let c = fake.clone();
330            let cl = Arc::clone(&client);
331            handles.push(tokio::spawn(async move {
332                cl.call::<String, _, _>("svc", "Check", vec![i], move || {
333                    let c = c.clone();
334                    async move { c.check("x").await }
335                })
336                .await
337            }));
338        }
339        futures_util::future::join_all(handles).await;
340        assert_eq!(counter.load(Ordering::SeqCst), 4);
341    }
342
343    #[tokio::test]
344    async fn error_shared_not_cached_next_call_retries() {
345        let fake = FakeClient::failing();
346        let counter = fake.counter.clone();
347        let client = Arc::new(CoalescingClient::new(fake.clone()));
348
349        // First flight: 3 concurrent callers all get the error.
350        let mut handles = Vec::new();
351        for _ in 0..3 {
352            let c = fake.clone();
353            let cl = Arc::clone(&client);
354            handles.push(tokio::spawn(async move {
355                cl.call::<String, _, _>("svc", "Check", b"k".to_vec(), move || {
356                    let c = c.clone();
357                    async move { c.check("x").await }
358                })
359                .await
360            }));
361        }
362        let results = futures_util::future::join_all(handles).await;
363        assert_eq!(counter.load(Ordering::SeqCst), 1);
364        for r in results {
365            assert!(r.unwrap().is_err());
366        }
367
368        // Second call: fresh attempt (error was not cached).
369        // Use a non-failing client with the same singleflight map.
370        let ok_fake = FakeClient::new();
371        let ok_counter = ok_fake.counter.clone();
372        let result = client
373            .call::<String, _, _>("svc", "Check", b"k".to_vec(), move || {
374                let c = ok_fake.clone();
375                async move { c.check("x").await }
376            })
377            .await;
378        assert_eq!(ok_counter.load(Ordering::SeqCst), 1);
379        assert!(result.is_ok());
380    }
381
382    #[tokio::test]
383    async fn ttl_cache_hit_within_ttl_no_upstream() {
384        let fake = FakeClient::new();
385        let counter = fake.counter.clone();
386        let client = CoalescingClient::builder(fake.clone())
387            .cache("Check", CacheConfig::new(Duration::from_secs(10), 100))
388            .build();
389
390        // First call: upstream hit.
391        let c = fake.clone();
392        client
393            .call::<String, _, _>("svc", "Check", b"k".to_vec(), move || {
394                let c = c.clone();
395                async move { c.check("x").await }
396            })
397            .await
398            .unwrap();
399        assert_eq!(counter.load(Ordering::SeqCst), 1);
400
401        // Second call within TTL: cache hit, no upstream.
402        let c = fake.clone();
403        let result = client
404            .call::<String, _, _>("svc", "Check", b"k".to_vec(), move || {
405                let c = c.clone();
406                async move { c.check("x").await }
407            })
408            .await
409            .unwrap();
410        assert_eq!(counter.load(Ordering::SeqCst), 1); // still 1
411        assert_eq!(result.into_inner(), "ok");
412    }
413
414    #[tokio::test]
415    async fn ttl_cache_miss_after_expiry() {
416        let fake = FakeClient::new();
417        let counter = fake.counter.clone();
418        let client = CoalescingClient::builder(fake.clone())
419            .cache("Check", CacheConfig::new(Duration::from_millis(10), 100))
420            .build();
421
422        let c = fake.clone();
423        client
424            .call::<String, _, _>("svc", "Check", b"k".to_vec(), move || {
425                let c = c.clone();
426                async move { c.check("x").await }
427            })
428            .await
429            .unwrap();
430
431        tokio::time::sleep(Duration::from_millis(30)).await;
432
433        let c = fake.clone();
434        client
435            .call::<String, _, _>("svc", "Check", b"k".to_vec(), move || {
436                let c = c.clone();
437                async move { c.check("x").await }
438            })
439            .await
440            .unwrap();
441        assert_eq!(counter.load(Ordering::SeqCst), 2);
442    }
443
444    #[tokio::test]
445    async fn coalescing_disabled_passes_through() {
446        let fake = FakeClient::new();
447        let counter = fake.counter.clone();
448        let client = Arc::new(
449            CoalescingClient::builder(fake.clone())
450                .coalesce(false)
451                .build(),
452        );
453
454        let mut handles = Vec::new();
455        for _ in 0..4 {
456            let c = fake.clone();
457            let cl = Arc::clone(&client);
458            handles.push(tokio::spawn(async move {
459                cl.call::<String, _, _>("svc", "Check", b"k".to_vec(), move || {
460                    let c = c.clone();
461                    async move { c.check("x").await }
462                })
463                .await
464            }));
465        }
466        futures_util::future::join_all(handles).await;
467        // No coalescing: all 4 go upstream.
468        assert_eq!(counter.load(Ordering::SeqCst), 4);
469    }
470}