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}