Skip to main content

tokio_prompt_orchestrator/enhanced/
dedup.rs

1//! Request Deduplication
2//!
3//! Prevents duplicate requests from being processed multiple times.
4//! Useful for cost savings when users accidentally submit the same request.
5//!
6//! ## Usage
7//!
8//! ```no_run
9//! use std::time::Duration;
10//! use tokio_prompt_orchestrator::enhanced::{Deduplicator, DeduplicationResult};
11//! # async fn process_request() -> String { String::new() }
12//! # #[tokio::main]
13//! # async fn main() {
14//! let dedup = Deduplicator::new(Duration::from_secs(300)); // 5 minute window
15//!
16//! // Check if request is duplicate
17//! match dedup.check_and_register("prompt_hash").await {
18//!     DeduplicationResult::New(token) => {
19//!         // Process new request
20//!         let result = process_request().await;
21//!         dedup.complete(token, result).await;
22//!     }
23//!     DeduplicationResult::InProgress => {
24//!         // Wait for in-progress request
25//!         let _result = dedup.wait_for_result("prompt_hash").await;
26//!     }
27//!     DeduplicationResult::Cached(result) => {
28//!         // Use cached result
29//!         println!("{result}");
30//!     }
31//! }
32//! # }
33//! ```
34
35use dashmap::DashMap;
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::sync::Arc;
38use std::time::{Duration, SystemTime};
39use tokio::sync::broadcast;
40use tracing::{debug, info};
41use uuid::Uuid;
42
43/// Outcome of a [`Deduplicator::check_and_register`] call.
44///
45/// Three-way classification allows callers to decide whether to do the work
46/// themselves, wait for a concurrent worker, or immediately reuse a cached
47/// result.
48///
49/// # Examples
50///
51/// ```no_run
52/// use std::time::Duration;
53/// use tokio_prompt_orchestrator::enhanced::{Deduplicator, DeduplicationResult};
54///
55/// # #[tokio::main]
56/// # async fn main() {
57/// let dedup = Deduplicator::new(Duration::from_secs(60));
58/// match dedup.check_and_register("my-key").await {
59///     DeduplicationResult::New(token) => {
60///         let result = "computed".to_string();
61///         dedup.complete(token, result).await;
62///     }
63///     DeduplicationResult::InProgress => {
64///         // another task is already working; wait for it
65///         let _ = dedup.wait_for_result("my-key").await;
66///     }
67///     DeduplicationResult::Cached(result) => {
68///         println!("reused: {result}");
69///     }
70/// }
71/// # }
72/// ```
73#[derive(Debug, Clone)]
74pub enum DeduplicationResult {
75    /// New request — should be processed by the caller.
76    ///
77    /// The caller must eventually call [`Deduplicator::complete`] or
78    /// [`Deduplicator::fail`] with the returned [`DeduplicationToken`] so that
79    /// any tasks blocked in [`Deduplicator::wait_for_result`] are unblocked.
80    New(DeduplicationToken),
81    /// An identical request is already being processed by another task.
82    ///
83    /// The caller should call [`Deduplicator::wait_for_result`] to block until
84    /// that task completes and then reuse its result.
85    InProgress,
86    /// The request was recently completed and the result is still within the
87    /// cache window.  The cached response string is returned directly.
88    Cached(String),
89}
90
91/// Ownership token issued when a new request is registered with the
92/// [`Deduplicator`].
93///
94/// The holder of a `DeduplicationToken` is the *authoritative worker* for
95/// that request key.  It must call either [`Deduplicator::complete`] or
96/// [`Deduplicator::fail`] to resolve the pending state.
97///
98/// # Drop behaviour
99///
100/// If a token is dropped without calling `complete` or `fail`, the
101/// `InProgress` entry is automatically removed from the deduplicator so that
102/// subsequent callers are not permanently blocked.  A `WARN`-level log line
103/// is emitted in this case.
104///
105/// # Cloning
106///
107/// Tokens are `Clone` because they are cheaply cloneable (`Arc`-backed), but
108/// only the **first** clone to call `complete` or `fail` takes effect;
109/// subsequent calls on other clones are no-ops.
110#[derive(Debug, Clone)]
111pub struct DeduplicationToken {
112    /// Unique identifier for this deduplication token.
113    ///
114    /// Useful for structured log correlation.
115    pub id: String,
116    key: String,
117    completed: Arc<std::sync::atomic::AtomicBool>,
118    requests: Arc<DashMap<String, RequestState>>,
119}
120
121/// # Behavior on Drop
122///
123/// When a `DeduplicationToken` is dropped without calling `complete()`, the
124/// following sequence occurs:
125///
126/// 1. **Cancellation signal**: The `Drop` impl sends a sentinel cancellation
127///    string (`"\x00CANCELLED"`) over the broadcast channel before removing the
128///    entry.  Any tasks already blocked in `wait_for_result()` receive this
129///    value via `rx.recv()` and return `Some("\x00CANCELLED")` rather than
130///    `None`.  Callers of `wait_for_result` that inspect the returned string
131///    can detect cancellation by checking for this sentinel.
132///
133/// 2. **Entry removal**: After the cancellation broadcast the `InProgress`
134///    entry is removed from the shared map.  This drops the `Sender`, closing
135///    the broadcast channel.  Any tasks that subscribe *after* the removal will
136///    find no entry and `wait_for_result` will return `None`.
137///
138/// 3. **Re-registrability**: Because the entry is removed, the *next* caller
139///    to invoke `check_and_register` for the same key will receive a fresh
140///    `New` token and can retry processing.
141///
142/// **Waiters are NOT left hanging indefinitely.**  They either receive the
143/// cancellation sentinel or `None` (if they race with the removal), both of
144/// which are finite outcomes that unblock the awaiting task promptly.
145///
146/// The sentinel value `"\x00CANCELLED"` uses a NUL prefix which cannot appear
147/// in normal LLM output, making it safe to use as a reserved signal.
148pub const DEDUP_CANCELLED_SENTINEL: &str = "\x00CANCELLED";
149
150/// Returns `true` if the dedup result string represents a cancellation signal.
151///
152/// Callers that receive a result from [`Deduplicator::wait_for_result`] should
153/// use this function instead of comparing to the raw sentinel directly, so
154/// that internal implementation details remain hidden.
155pub fn is_cancelled_result(result: &str) -> bool {
156    result == DEDUP_CANCELLED_SENTINEL
157}
158
159impl Drop for DeduplicationToken {
160    fn drop(&mut self) {
161        // Only act if this is the last clone and complete() was never called.
162        if Arc::strong_count(&self.completed) == 1
163            && !self.completed.load(std::sync::atomic::Ordering::Acquire)
164        {
165            // Broadcast a cancellation sentinel so tasks already blocked in
166            // wait_for_result() are unblocked immediately rather than hanging
167            // until the Sender is dropped by the map removal below.
168            if let Some(state) = self.requests.get(&self.key) {
169                if let RequestState::InProgress { waiter_tx, .. } = state.value() {
170                    // Ignore send errors — if there are no receivers, that's fine.
171                    let _ = waiter_tx.send(DEDUP_CANCELLED_SENTINEL.to_string());
172                }
173            }
174
175            // Remove the in-progress entry.  This drops the broadcast Sender,
176            // closing the channel for any tasks that subscribe after this point.
177            self.requests.remove(&self.key);
178            tracing::warn!(
179                key = %self.key,
180                "DeduplicationToken dropped without complete() — cancellation sent and in-progress entry removed"
181            );
182        }
183    }
184}
185
186/// Deduplicator state
187#[derive(Debug, Clone)]
188enum RequestState {
189    InProgress {
190        started_at: SystemTime,
191        waiter_tx: broadcast::Sender<String>,
192    },
193    Completed {
194        result: String,
195        completed_at: SystemTime,
196    },
197}
198
199/// In-process request deduplicator that coalesces identical concurrent
200/// requests and caches recently completed results.
201///
202/// # How it works
203///
204/// 1. The caller derives a stable cache key (see [`dedup_key`]) from the
205///    prompt and session.
206/// 2. [`Deduplicator::check_and_register`] atomically checks the shared
207///    state map and returns one of three outcomes:
208///    - [`DeduplicationResult::New`] — the caller is the first to see this
209///      key; it receives a [`DeduplicationToken`] and must process the request.
210///    - [`DeduplicationResult::InProgress`] — another task is already working;
211///      the caller should call [`Deduplicator::wait_for_result`] to block.
212///    - [`DeduplicationResult::Cached`] — a prior result is still within the
213///      `cache_duration` TTL; the caller can return it immediately.
214/// 3. On success the worker calls [`Deduplicator::complete`]; on failure it
215///    calls [`Deduplicator::fail`], which removes the entry.
216///
217/// # Thread safety
218///
219/// `Deduplicator` is `Clone + Send + Sync`.  All clones share the same
220/// underlying `Arc<DashMap>`.  A background task cleans up expired entries
221/// every 60 seconds; it stops when the *last* `Deduplicator` clone is dropped.
222///
223/// # Examples
224///
225/// ```no_run
226/// use std::time::Duration;
227/// use tokio_prompt_orchestrator::enhanced::{Deduplicator, DeduplicationResult};
228///
229/// # #[tokio::main]
230/// # async fn main() {
231/// let dedup = Deduplicator::new(Duration::from_secs(300));
232/// let key = "dedup:g:abc123";
233///
234/// match dedup.check_and_register(key).await {
235///     DeduplicationResult::New(token) => {
236///         let result = "hello".to_string();
237///         dedup.complete(token, result).await;
238///     }
239///     DeduplicationResult::InProgress => {
240///         let _ = dedup.wait_for_result(key).await;
241///     }
242///     DeduplicationResult::Cached(result) => println!("{result}"),
243/// }
244/// # }
245/// ```
246#[derive(Clone)]
247pub struct Deduplicator {
248    requests: Arc<DashMap<String, RequestState>>,
249    cache_duration: Duration,
250    /// Signals the background cleanup task to stop when set to `true`.
251    shutdown: Arc<AtomicBool>,
252    /// Handle to the background cleanup task, used by [`shutdown`](Self::shutdown).
253    cleanup_handle: Arc<tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>>,
254    /// Optional embedding store for semantic (cosine-similarity) deduplication.
255    embeddings: Arc<DashMap<String, Vec<f32>>>,
256    /// Minimum cosine similarity score to treat a new prompt as a duplicate.
257    similarity_threshold: f32,
258}
259
260impl Deduplicator {
261    /// Create a new `Deduplicator` with the given cache TTL.
262    ///
263    /// A background cleanup task is spawned immediately.  It wakes up every
264    /// 60 seconds to evict expired entries and exits when the last
265    /// `Deduplicator` clone is dropped.
266    ///
267    /// # Arguments
268    ///
269    /// * `cache_duration` — How long a completed result remains cached before
270    ///   being treated as a fresh request.  Common choices: 5 minutes for
271    ///   interactive use, 1 hour for batch/idempotent workloads.
272    ///
273    /// # Examples
274    ///
275    /// ```
276    /// use std::time::Duration;
277    /// use tokio_prompt_orchestrator::enhanced::Deduplicator;
278    ///
279    /// # #[tokio::main]
280    /// # async fn main() {
281    /// let dedup = Deduplicator::new(Duration::from_secs(300));
282    /// # }
283    /// ```
284    ///
285    /// # Panics
286    ///
287    /// Spawns a background cleanup task, so it must be called from within a
288    /// Tokio runtime.
289    pub fn new(cache_duration: Duration) -> Self {
290        let shutdown = Arc::new(AtomicBool::new(false));
291        let cleanup_handle = Arc::new(tokio::sync::Mutex::new(None::<tokio::task::JoinHandle<()>>));
292        let dedup = Self {
293            requests: Arc::new(DashMap::new()),
294            cache_duration,
295            shutdown: shutdown.clone(),
296            cleanup_handle: cleanup_handle.clone(),
297            embeddings: Arc::new(DashMap::new()),
298            similarity_threshold: 1.0, // disabled by default: exact match only
299        };
300
301        // Start cleanup task; checks `shutdown` flag each iteration so it
302        // stops promptly when the last Deduplicator handle is dropped or
303        // shutdown() is called.
304        let requests = dedup.requests.clone();
305        let cache_duration = dedup.cache_duration;
306        let handle = tokio::spawn(async move {
307            loop {
308                tokio::time::sleep(Duration::from_secs(60)).await;
309                if shutdown.load(Ordering::Relaxed) {
310                    break;
311                }
312                cleanup_expired(&requests, cache_duration);
313            }
314        });
315        if let Ok(mut slot) = cleanup_handle.try_lock() {
316            *slot = Some(handle);
317        }
318
319        dedup
320    }
321
322    /// Signal the background cleanup task to stop without waiting for it.
323    ///
324    /// Sets the shutdown `AtomicBool` to `true`.  The cleanup loop exits on its
325    /// next wake-up.  Call [`shutdown`](Self::shutdown) if you need to await
326    /// completion.
327    pub fn signal_shutdown(&self) {
328        self.shutdown.store(true, Ordering::Relaxed);
329    }
330
331    /// Gracefully shut down the background cleanup task.
332    ///
333    /// Sets the shutdown flag so the cleanup loop exits on its next wake-up,
334    /// then waits for the task to finish.  Safe to call multiple times.
335    pub async fn shutdown(&self) {
336        self.shutdown.store(true, Ordering::Relaxed);
337        let handle = self.cleanup_handle.lock().await.take();
338        if let Some(h) = handle {
339            h.abort();
340        }
341    }
342
343    /// Atomically check whether a request is new, in-progress, or cached, and
344    /// register it as in-progress if it is new.
345    ///
346    /// Uses `DashMap::entry()` for a compare-and-insert that prevents multiple
347    /// concurrent callers from each receiving [`DeduplicationResult::New`] for
348    /// the same key — only one will win the race.
349    ///
350    /// # Arguments
351    ///
352    /// * `key` — Stable cache key; derive one with [`dedup_key`].
353    ///
354    /// # Returns
355    ///
356    /// A [`DeduplicationResult`] indicating whether the caller should process
357    /// the request, wait for another task, or reuse a cached result.
358    ///
359    /// # Examples
360    ///
361    /// ```no_run
362    /// use std::time::Duration;
363    /// use tokio_prompt_orchestrator::enhanced::{Deduplicator, DeduplicationResult};
364    ///
365    /// # #[tokio::main]
366    /// # async fn main() {
367    /// let dedup = Deduplicator::new(Duration::from_secs(60));
368    /// if let DeduplicationResult::New(token) = dedup.check_and_register("key").await {
369    ///     dedup.complete(token, "result".to_string()).await;
370    /// }
371    /// # }
372    /// ```
373    pub async fn check_and_register(&self, key: &str) -> DeduplicationResult {
374        use dashmap::mapref::entry::Entry;
375
376        // First, handle the already-present cases through a read-side fast path.
377        // We still need the atomic entry() below for the insert path.
378        if let Some(state) = self.requests.get(key) {
379            return match state.value() {
380                RequestState::InProgress { .. } => {
381                    info!(key = key, "duplicate request detected (in progress)");
382                    crate::metrics::inc_dedup_hit();
383                    DeduplicationResult::InProgress
384                }
385                RequestState::Completed {
386                    result,
387                    completed_at,
388                } => {
389                    if completed_at.elapsed().unwrap_or_default() < self.cache_duration {
390                        info!(key = key, "duplicate request detected (cached)");
391                        crate::metrics::inc_dedup_hit();
392                        DeduplicationResult::Cached(result.clone())
393                    } else {
394                        // Expired — fall through to the atomic entry path below.
395                        drop(state);
396                        // Remove the expired entry so the entry() call below sees it as absent.
397                        self.requests.remove(key);
398                        // Fall through to atomic insert.
399                        return self.atomic_register_new(key);
400                    }
401                }
402            };
403        }
404
405        // Key is absent — use entry() for an atomic check-and-insert so that
406        // concurrent callers cannot both see "absent" and both get New.
407        match self.requests.entry(key.to_string()) {
408            Entry::Occupied(occ) => {
409                // Another task raced us and inserted first.
410                match occ.get() {
411                    RequestState::InProgress { .. } => {
412                        info!(key = key, "duplicate request detected (in progress, raced)");
413                        crate::metrics::inc_dedup_hit();
414                        DeduplicationResult::InProgress
415                    }
416                    RequestState::Completed { result, .. } => {
417                        info!(key = key, "duplicate request detected (cached, raced)");
418                        crate::metrics::inc_dedup_hit();
419                        DeduplicationResult::Cached(result.clone())
420                    }
421                }
422            }
423            Entry::Vacant(vac) => {
424                // NOTE: buffer size 16 — allows up to 16 waiters to receive the
425                // completion notification without the sender blocking.  If more
426                // than 16 tasks subscribe and the sender falls behind, `recv()`
427                // returns `Err(RecvError::Lagged)` and the waiter must treat the
428                // request as a miss and re-check the map.  A buffer of 1 would
429                // drop notifications under moderate concurrency.
430                let (tx, _) = broadcast::channel(16);
431                vac.insert(RequestState::InProgress {
432                    started_at: SystemTime::now(),
433                    waiter_tx: tx,
434                });
435                let token = DeduplicationToken {
436                    id: Uuid::new_v4().to_string(),
437                    key: key.to_string(),
438                    completed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
439                    requests: Arc::clone(&self.requests),
440                };
441                debug!(key = key, token_id = %token.id, "new request registered");
442                DeduplicationResult::New(token)
443            }
444        }
445    }
446
447    /// Atomically insert a new `InProgress` entry.  Called only when we have
448    /// already removed an expired entry and need a fresh registration.
449    fn atomic_register_new(&self, key: &str) -> DeduplicationResult {
450        use dashmap::mapref::entry::Entry;
451        match self.requests.entry(key.to_string()) {
452            Entry::Occupied(occ) => match occ.get() {
453                RequestState::InProgress { .. } => {
454                    crate::metrics::inc_dedup_hit();
455                    DeduplicationResult::InProgress
456                }
457                RequestState::Completed { result, .. } => {
458                    crate::metrics::inc_dedup_hit();
459                    DeduplicationResult::Cached(result.clone())
460                }
461            },
462            Entry::Vacant(vac) => {
463                // NOTE: same buffer-size rationale as check_and_register — see above.
464                let (tx, _) = broadcast::channel(16);
465                vac.insert(RequestState::InProgress {
466                    started_at: SystemTime::now(),
467                    waiter_tx: tx,
468                });
469                let token = DeduplicationToken {
470                    id: Uuid::new_v4().to_string(),
471                    key: key.to_string(),
472                    completed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
473                    requests: Arc::clone(&self.requests),
474                };
475                debug!(key = key, "new request registered (after expiry)");
476                DeduplicationResult::New(token)
477            }
478        }
479    }
480
481    /// Wait for an in-progress request to complete and return its result.
482    ///
483    /// Subscribes to the internal broadcast channel for the given key.  If the
484    /// request has already completed by the time this is called, the cached
485    /// result is returned immediately without waiting.
486    ///
487    /// # Arguments
488    ///
489    /// * `key` — The same key passed to [`Deduplicator::check_and_register`].
490    ///
491    /// # Returns
492    ///
493    /// `Some(result)` when the pending request completes, or `None` if the
494    /// key is not tracked (e.g. the worker called [`Deduplicator::fail`]).
495    pub async fn wait_for_result(&self, key: &str) -> Option<String> {
496        let mut rx = {
497            let state = self.requests.get(key)?;
498            match state.value() {
499                RequestState::InProgress { waiter_tx, .. } => waiter_tx.subscribe(),
500                RequestState::Completed { result, .. } => {
501                    return Some(result.clone());
502                }
503            }
504        };
505
506        let result = rx.recv().await.ok();
507        if result.is_some() {
508            crate::metrics::inc_dedup_waiter_unblocked();
509        }
510        result
511    }
512
513    /// Mark a request as successfully completed and cache its result.
514    ///
515    /// Notifies all tasks currently blocked in [`Deduplicator::wait_for_result`]
516    /// for the same key.  The result is retained in the cache for
517    /// `cache_duration` so subsequent callers receive
518    /// [`DeduplicationResult::Cached`].
519    ///
520    /// # Arguments
521    ///
522    /// * `token` — The [`DeduplicationToken`] returned by
523    ///   [`Deduplicator::check_and_register`].
524    /// * `result` — The serialised response to cache and broadcast.
525    pub async fn complete(&self, token: DeduplicationToken, result: String) {
526        token
527            .completed
528            .store(true, std::sync::atomic::Ordering::Release);
529        if let Some(mut entry) = self.requests.get_mut(&token.key) {
530            if let RequestState::InProgress { waiter_tx, .. } = entry.value() {
531                let _ = waiter_tx.send(result.clone());
532            }
533
534            *entry = RequestState::Completed {
535                result,
536                completed_at: SystemTime::now(),
537            };
538
539            info!(key = token.key, token_id = %token.id, "request completed");
540        }
541    }
542
543    /// Mark a request as failed and remove it from tracking.
544    ///
545    /// After this call, the next [`Deduplicator::check_and_register`] for the
546    /// same key will receive [`DeduplicationResult::New`] so the request can
547    /// be retried.  Any tasks waiting in [`Deduplicator::wait_for_result`] will
548    /// receive `None` on their next `recv()` after the sender is dropped.
549    ///
550    /// # Arguments
551    ///
552    /// * `token` — The [`DeduplicationToken`] returned by
553    ///   [`Deduplicator::check_and_register`].
554    pub async fn fail(&self, token: DeduplicationToken) {
555        self.requests.remove(&token.key);
556        debug!(key = token.key, token_id = %token.id, "request failed, removed from dedup");
557    }
558
559    /// Return a snapshot of current deduplication statistics.
560    ///
561    /// The counts are computed by iterating the internal map in O(n).
562    /// Use sparingly on hot paths; prefer Prometheus counters for high-frequency
563    /// monitoring.
564    pub fn stats(&self) -> DeduplicationStats {
565        let mut stats = DeduplicationStats {
566            total: self.requests.len(),
567            in_progress: 0,
568            cached: 0,
569        };
570
571        for entry in self.requests.iter() {
572            match entry.value() {
573                RequestState::InProgress { .. } => stats.in_progress += 1,
574                RequestState::Completed { .. } => stats.cached += 1,
575            }
576        }
577
578        stats
579    }
580
581    /// Clear all cached results
582    pub fn clear(&self) {
583        self.requests.clear();
584        debug!("deduplication cache cleared");
585    }
586
587    /// Enable semantic (embedding-based) deduplication.
588    ///
589    /// When enabled, [`check_and_register_with_embedding`](Self::check_and_register_with_embedding)
590    /// compares new embeddings against all stored embeddings using cosine similarity.
591    /// Any stored embedding with similarity ≥ `threshold` is treated as a cache hit.
592    ///
593    /// # Arguments
594    ///
595    /// * `threshold` — Cosine similarity score in `[0.0, 1.0]`.  `1.0` requires
596    ///   exact vector match (default); `0.95` catches near-paraphrases.
597    ///
598    /// # Example
599    ///
600    /// ```
601    /// use std::time::Duration;
602    /// use tokio_prompt_orchestrator::enhanced::Deduplicator;
603    ///
604    /// # #[tokio::main]
605    /// # async fn main() {
606    /// let dedup = Deduplicator::new(Duration::from_secs(300))
607    ///     .with_semantic(0.95);
608    /// # }
609    /// ```
610    pub fn with_semantic(mut self, threshold: f32) -> Self {
611        self.similarity_threshold = threshold;
612        self
613    }
614
615    /// Like [`check_and_register`](Self::check_and_register) but also performs
616    /// a semantic similarity scan against previously registered embeddings.
617    ///
618    /// If `embedding` is `Some` and semantic deduplication is enabled (threshold < 1.0),
619    /// all stored embeddings are scanned.  The first match whose cosine similarity
620    /// meets the threshold is returned as [`DeduplicationResult::Cached`] with an
621    /// empty string (the caller should use `wait_for_result` with the matched key
622    /// to obtain the actual cached value).
623    ///
624    /// Falls back to exact-key lookup when `embedding` is `None` or the threshold
625    /// equals `1.0`.
626    ///
627    /// # Arguments
628    ///
629    /// * `key` — Exact cache key for this request.
630    /// * `embedding` — Optional dense vector embedding of the prompt.
631    pub async fn check_and_register_with_embedding(
632        &self,
633        key: &str,
634        embedding: Option<Vec<f32>>,
635    ) -> DeduplicationResult {
636        // Semantic scan first (only when an embedding is provided and threshold < 1.0)
637        if let Some(ref emb) = embedding {
638            if self.similarity_threshold < 1.0 {
639                for entry in self.embeddings.iter() {
640                    let sim = cosine_similarity(emb, entry.value());
641                    if sim >= self.similarity_threshold {
642                        debug!(
643                            key = key,
644                            matched_key = entry.key().as_str(),
645                            similarity = sim,
646                            "semantic duplicate detected"
647                        );
648                        crate::metrics::inc_dedup_hit();
649                        return DeduplicationResult::Cached(String::new());
650                    }
651                }
652                // No semantic match — store embedding for future lookups.
653                self.embeddings.insert(key.to_string(), emb.clone());
654            }
655        }
656
657        self.check_and_register(key).await
658    }
659}
660
661impl Drop for Deduplicator {
662    fn drop(&mut self) {
663        // Signal the background cleanup task to stop on its next wake-up.
664        // Only the last owner sets this; clones share the same Arc.
665        if Arc::strong_count(&self.shutdown) == 1 {
666            self.shutdown.store(true, Ordering::Relaxed);
667        }
668    }
669}
670
671/// Compute the cosine similarity between two dense vectors.
672///
673/// Returns a value in `[-1.0, 1.0]`.  Returns `0.0` if either vector has zero norm
674/// so that zero-length embeddings never falsely match.
675///
676/// # Panics
677///
678/// Does not panic.  Mismatched lengths are handled by iterating the shorter vector.
679pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
680    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
681    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
682    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
683    if norm_a == 0.0 || norm_b == 0.0 {
684        0.0
685    } else {
686        dot / (norm_a * norm_b)
687    }
688}
689
690fn cleanup_expired(requests: &DashMap<String, RequestState>, cache_duration: Duration) {
691    let _now = SystemTime::now();
692    let mut removed = 0;
693
694    requests.retain(|_, state| {
695        match state {
696            RequestState::InProgress { started_at, .. } => {
697                // Remove stale in-progress requests (10x cache duration)
698                if started_at.elapsed().unwrap_or_default() > cache_duration * 10 {
699                    removed += 1;
700                    return false;
701                }
702            }
703            RequestState::Completed { completed_at, .. } => {
704                // Remove expired cached results
705                if completed_at.elapsed().unwrap_or_default() > cache_duration {
706                    removed += 1;
707                    return false;
708                }
709            }
710        }
711        true
712    });
713
714    if removed > 0 {
715        debug!(
716            removed = removed,
717            "cleaned up expired deduplication entries"
718        );
719    }
720}
721
722/// A point-in-time snapshot of [`Deduplicator`] state.
723///
724/// Obtain via [`Deduplicator::stats`].
725#[derive(Debug)]
726pub struct DeduplicationStats {
727    /// Total number of tracked requests (in-progress + cached).
728    pub total: usize,
729    /// Requests that are currently being processed.
730    pub in_progress: usize,
731    /// Completed requests whose results are still cached.
732    pub cached: usize,
733}
734
735/// Generate a deduplication key scoped to a session.
736///
737/// Including `session_id` in the key prevents two different sessions from
738/// colliding on the same cached result even when their prompts are identical.
739/// Use `None` only for anonymous/global dedup where cross-session sharing is
740/// intentional (e.g. read-only reference data queries).
741///
742/// # Example
743///
744/// ```
745/// use std::collections::HashMap;
746/// use tokio_prompt_orchestrator::enhanced::dedup_key;
747///
748/// let meta = HashMap::new();
749/// // Same prompt, different sessions → different keys.
750/// let k1 = dedup_key("hello", &meta, Some("session-alice"));
751/// let k2 = dedup_key("hello", &meta, Some("session-bob"));
752/// assert_ne!(k1, k2);
753///
754/// // Same prompt, same session → same key (deterministic).
755/// let k3 = dedup_key("hello", &meta, Some("session-alice"));
756/// assert_eq!(k1, k3);
757///
758/// // No session → global key (backward-compatible).
759/// let k4 = dedup_key("hello", &meta, None);
760/// assert_ne!(k1, k4);
761/// ```
762/// FNV-1a hash (64-bit). Deterministic across process restarts — unlike
763/// `DefaultHasher` which uses a randomised seed since Rust 1.36.
764fn fnv1a(bytes: &[u8]) -> u64 {
765    const PRIME: u64 = 1_099_511_628_211;
766    const BASIS: u64 = 14_695_981_039_346_656_037;
767    bytes
768        .iter()
769        .fold(BASIS, |acc, &b| acc.wrapping_mul(PRIME) ^ b as u64)
770}
771
772pub fn dedup_key(
773    prompt: &str,
774    metadata: &std::collections::HashMap<String, String>,
775    session_id: Option<&str>,
776) -> String {
777    // Build a canonical byte sequence: optional session prefix + prompt + sorted metadata.
778    let mut buf = String::new();
779
780    if let Some(sid) = session_id {
781        buf.push_str(sid);
782        buf.push('\x00');
783    }
784
785    buf.push_str(prompt);
786
787    // Include relevant metadata in key (sorted for determinism).
788    let mut meta_keys: Vec<_> = metadata.keys().collect();
789    meta_keys.sort();
790    for key in meta_keys {
791        if let Some(value) = metadata.get(key) {
792            buf.push('\x00');
793            buf.push_str(key);
794            buf.push('=');
795            buf.push_str(value);
796        }
797    }
798
799    let hash = fnv1a(buf.as_bytes());
800    match session_id {
801        Some(_) => format!("dedup:s:{hash:x}"),
802        None => format!("dedup:g:{hash:x}"),
803    }
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809    use std::collections::HashMap;
810
811    #[tokio::test]
812    async fn test_new_request() {
813        let dedup = Deduplicator::new(Duration::from_secs(60));
814
815        match dedup.check_and_register("test-key").await {
816            DeduplicationResult::New(token) => {
817                assert_eq!(token.key, "test-key");
818            }
819            _ => unreachable!("Expected new request"),
820        }
821    }
822
823    #[tokio::test]
824    async fn test_duplicate_detection() {
825        let dedup = Deduplicator::new(Duration::from_secs(60));
826
827        // First request
828        let token = match dedup.check_and_register("test-key").await {
829            DeduplicationResult::New(t) => t,
830            _ => unreachable!("Expected new request"),
831        };
832
833        // Second request (while first is in progress)
834        match dedup.check_and_register("test-key").await {
835            DeduplicationResult::InProgress => {} // Expected
836            _ => unreachable!("Expected in-progress"),
837        }
838
839        // Complete first request
840        dedup.complete(token, "result".to_string()).await;
841
842        // Third request (should get cached result)
843        match dedup.check_and_register("test-key").await {
844            DeduplicationResult::Cached(result) => {
845                assert_eq!(result, "result");
846            }
847            _ => unreachable!("Expected cached result"),
848        }
849    }
850
851    #[tokio::test]
852    async fn test_wait_for_result() {
853        let dedup = Deduplicator::new(Duration::from_secs(60));
854
855        // Register request
856        let token = match dedup.check_and_register("test-key").await {
857            DeduplicationResult::New(t) => t,
858            _ => unreachable!("Expected new request"),
859        };
860
861        // Spawn task to wait
862        let dedup_clone = dedup.clone();
863        let wait_task = tokio::spawn(async move { dedup_clone.wait_for_result("test-key").await });
864
865        // Complete request
866        tokio::time::sleep(Duration::from_millis(100)).await;
867        dedup.complete(token, "result".to_string()).await;
868
869        // Check waiter got result
870        let result = wait_task.await.unwrap();
871        assert_eq!(result, Some("result".to_string()));
872    }
873
874    #[tokio::test]
875    async fn test_cleanup_removes_expired_entries() {
876        let dedup = Deduplicator::new(Duration::from_millis(50)); // very short TTL
877
878        // Register and complete a request
879        let result = dedup.check_and_register("test-key").await;
880        if let DeduplicationResult::New(token) = result {
881            dedup.complete(token, "done".to_string()).await;
882        }
883
884        // Verify it's cached
885        match dedup.check_and_register("test-key").await {
886            DeduplicationResult::Cached(_) => {} // expected
887            other => unreachable!("expected Cached, got {:?}", other),
888        }
889
890        // Wait for TTL to expire
891        tokio::time::sleep(Duration::from_millis(100)).await;
892
893        // Now it should be treated as new
894        match dedup.check_and_register("test-key").await {
895            DeduplicationResult::New(_) => {} // expected
896            other => unreachable!("expected New after expiry, got {:?}", other),
897        }
898    }
899
900    #[test]
901    fn test_dedup_key_generation() {
902        let empty = HashMap::new();
903
904        // Deterministic: same inputs → same key.
905        let key1 = dedup_key("hello", &empty, None);
906        let key2 = dedup_key("hello", &empty, None);
907        assert_eq!(key1, key2);
908
909        // Different prompt → different key.
910        let key3 = dedup_key("world", &empty, None);
911        assert_ne!(key1, key3);
912
913        // With metadata → different from without.
914        let mut meta = HashMap::new();
915        meta.insert("user".to_string(), "alice".to_string());
916        let key4 = dedup_key("hello", &meta, None);
917        assert_ne!(key1, key4);
918
919        // Global keys carry the "g:" prefix.
920        assert!(key1.starts_with("dedup:g:"), "key={key1}");
921    }
922
923    #[test]
924    fn test_dedup_key_session_isolation() {
925        let empty = HashMap::new();
926
927        // Same prompt, different sessions → different keys.
928        let k_alice = dedup_key("hello", &empty, Some("session-alice"));
929        let k_bob = dedup_key("hello", &empty, Some("session-bob"));
930        assert_ne!(
931            k_alice, k_bob,
932            "different sessions must not share a dedup key"
933        );
934
935        // Same prompt, same session → same key (deterministic).
936        let k_alice2 = dedup_key("hello", &empty, Some("session-alice"));
937        assert_eq!(k_alice, k_alice2);
938
939        // Session key != global key for the same prompt.
940        let k_global = dedup_key("hello", &empty, None);
941        assert_ne!(k_alice, k_global);
942
943        // Session keys carry the "s:" prefix.
944        assert!(k_alice.starts_with("dedup:s:"), "key={k_alice}");
945        assert!(k_global.starts_with("dedup:g:"), "key={k_global}");
946    }
947
948    #[tokio::test]
949    async fn test_shutdown_does_not_hang() {
950        let dedup = Deduplicator::new(Duration::from_secs(60));
951        // Register and complete a request so there is some state.
952        let token = match dedup.check_and_register("key").await {
953            DeduplicationResult::New(t) => t,
954            _ => unreachable!("expected New"),
955        };
956        dedup.complete(token, "result".into()).await;
957        // shutdown() must return promptly (background task wakes every 60 s,
958        // but the shutdown flag makes it exit on the *next* wake-up; since the
959        // task is sleeping we just verify the flag is set and the handle is taken).
960        tokio::time::timeout(std::time::Duration::from_secs(5), dedup.shutdown())
961            .await
962            .expect("shutdown() must complete within 5 s");
963    }
964
965    #[test]
966    fn test_dedup_key_session_with_metadata() {
967        let mut meta = HashMap::new();
968        meta.insert("model".to_string(), "gpt-4".to_string());
969
970        // Session + metadata combination is unique.
971        let k1 = dedup_key("prompt", &meta, Some("sess-1"));
972        let k2 = dedup_key("prompt", &meta, Some("sess-2"));
973        let k3 = dedup_key("prompt", &meta, None);
974        assert_ne!(k1, k2);
975        assert_ne!(k1, k3);
976        assert_ne!(k2, k3);
977    }
978}