forked from
smokesignal.events/quickdid
QuickDID is a high-performance AT Protocol identity resolution service written in Rust. It provides handle-to-DID resolution with Redis-backed caching and queue processing.
1//! In-memory caching handle resolver.
2//!
3//! This module provides a handle resolver that caches resolution results in memory
4//! with a configurable TTL. This is useful for reducing DNS/HTTP lookups and
5//! improving performance when Redis is not available.
6
7use super::errors::HandleResolverError;
8use super::traits::HandleResolver;
9use crate::metrics::SharedMetricsPublisher;
10use async_trait::async_trait;
11use std::collections::HashMap;
12use std::sync::Arc;
13use std::time::{SystemTime, UNIX_EPOCH};
14use tokio::sync::RwLock;
15
16/// Result of a handle resolution cached in memory.
17#[derive(Clone, Debug)]
18enum ResolveHandleResult {
19 /// Handle was successfully resolved to a DID
20 Found(u64, String),
21 /// Handle resolution failed
22 NotFound(u64, String),
23}
24
25/// In-memory caching wrapper for handle resolvers.
26///
27/// This resolver wraps another resolver and caches results in memory with
28/// a configurable TTL. Both successful and failed resolutions are cached
29/// to avoid repeated lookups.
30///
31/// # Example
32///
33/// ```no_run
34/// use std::sync::Arc;
35/// use quickdid::handle_resolver::{create_caching_resolver, create_base_resolver, HandleResolver};
36/// use quickdid::metrics::NoOpMetricsPublisher;
37///
38/// # async fn example() {
39/// # use atproto_identity::resolve::HickoryDnsResolver;
40/// # use reqwest::Client;
41/// # let dns_resolver = Arc::new(HickoryDnsResolver::create_resolver(&[]));
42/// # let http_client = Client::new();
43/// # let metrics = Arc::new(NoOpMetricsPublisher);
44/// let base_resolver = create_base_resolver(dns_resolver, http_client, metrics.clone());
45/// let caching_resolver = create_caching_resolver(
46/// base_resolver,
47/// 300, // 5 minute TTL
48/// metrics
49/// );
50///
51/// // First call hits the underlying resolver
52/// let (did1, timestamp1) = caching_resolver.resolve("alice.bsky.social").await.unwrap();
53///
54/// // Second call returns cached result
55/// let (did2, timestamp2) = caching_resolver.resolve("alice.bsky.social").await.unwrap();
56/// # }
57/// ```
58pub(super) struct CachingHandleResolver {
59 inner: Arc<dyn HandleResolver>,
60 cache: Arc<RwLock<HashMap<String, ResolveHandleResult>>>,
61 ttl_seconds: u64,
62 metrics: SharedMetricsPublisher,
63}
64
65impl CachingHandleResolver {
66 /// Create a new caching handle resolver.
67 ///
68 /// # Arguments
69 ///
70 /// * `inner` - The underlying resolver to use for actual resolution
71 /// * `ttl_seconds` - How long to cache results in seconds
72 /// * `metrics` - Metrics publisher for telemetry
73 pub fn new(
74 inner: Arc<dyn HandleResolver>,
75 ttl_seconds: u64,
76 metrics: SharedMetricsPublisher,
77 ) -> Self {
78 Self {
79 inner,
80 cache: Arc::new(RwLock::new(HashMap::new())),
81 ttl_seconds,
82 metrics,
83 }
84 }
85
86 fn current_timestamp() -> u64 {
87 SystemTime::now()
88 .duration_since(UNIX_EPOCH)
89 .unwrap_or_default()
90 .as_secs()
91 }
92
93 fn is_expired(&self, timestamp: u64) -> bool {
94 let current = Self::current_timestamp();
95 current > timestamp && (current - timestamp) > self.ttl_seconds
96 }
97}
98
99#[async_trait]
100impl HandleResolver for CachingHandleResolver {
101 async fn resolve(&self, s: &str) -> Result<(String, u64), HandleResolverError> {
102 let handle = s.to_string();
103
104 // Check cache first
105 {
106 let cache = self.cache.read().await;
107 if let Some(cached) = cache.get(&handle) {
108 match cached {
109 ResolveHandleResult::Found(timestamp, did) => {
110 if !self.is_expired(*timestamp) {
111 tracing::debug!("Cache hit for handle {}: {}", handle, did);
112 self.metrics.incr("resolver.memory.cache_hit").await;
113 return Ok((did.clone(), *timestamp));
114 }
115 tracing::debug!("Cache entry expired for handle {}", handle);
116 self.metrics.incr("resolver.memory.cache_expired").await;
117 }
118 ResolveHandleResult::NotFound(timestamp, error) => {
119 if !self.is_expired(*timestamp) {
120 tracing::debug!(
121 "Cache hit (not found) for handle {}: {}",
122 handle,
123 error
124 );
125 self.metrics
126 .incr("resolver.memory.cache_hit_not_resolved")
127 .await;
128 return Err(HandleResolverError::HandleNotFoundCached(error.clone()));
129 }
130 tracing::debug!("Cache entry expired for handle {}", handle);
131 self.metrics.incr("resolver.memory.cache_expired").await;
132 }
133 }
134 }
135 }
136
137 // Not in cache or expired, resolve through inner resolver
138 tracing::debug!("Cache miss for handle {}, resolving...", handle);
139 self.metrics.incr("resolver.memory.cache_miss").await;
140 let result = self.inner.resolve(s).await;
141
142 // Store in cache
143 {
144 let mut cache = self.cache.write().await;
145 match &result {
146 Ok((did, timestamp)) => {
147 cache.insert(
148 handle.clone(),
149 ResolveHandleResult::Found(*timestamp, did.clone()),
150 );
151 self.metrics.incr("resolver.memory.cache_set").await;
152 tracing::debug!(
153 "Cached successful resolution for handle {}: {}",
154 handle,
155 did
156 );
157 }
158 Err(e) => {
159 let timestamp = Self::current_timestamp();
160 cache.insert(
161 handle.clone(),
162 ResolveHandleResult::NotFound(timestamp, e.to_string()),
163 );
164 self.metrics.incr("resolver.memory.cache_set_error").await;
165 tracing::debug!("Cached failed resolution for handle {}: {}", handle, e);
166 }
167 }
168
169 // Track cache size
170 let cache_size = cache.len() as u64;
171 self.metrics
172 .gauge("resolver.memory.cache_entries", cache_size)
173 .await;
174 }
175
176 result
177 }
178
179 async fn set(&self, handle: &str, did: &str) -> Result<(), HandleResolverError> {
180 // Normalize the handle to lowercase
181 let handle = handle.to_lowercase();
182
183 // Update the in-memory cache
184 {
185 let mut cache = self.cache.write().await;
186 let timestamp = Self::current_timestamp();
187 cache.insert(
188 handle.clone(),
189 ResolveHandleResult::Found(timestamp, did.to_string()),
190 );
191 self.metrics.incr("resolver.memory.set").await;
192 tracing::debug!("Set handle {} -> DID {} in memory cache", handle, did);
193
194 // Track cache size
195 let cache_size = cache.len() as u64;
196 self.metrics
197 .gauge("resolver.memory.cache_entries", cache_size)
198 .await;
199 }
200
201 // Chain to inner resolver
202 self.inner.set(&handle, did).await
203 }
204}
205
206/// Create a new in-memory caching handle resolver.
207///
208/// This factory function creates a resolver that caches resolution results
209/// in memory with a configurable TTL.
210///
211/// # Arguments
212///
213/// * `inner` - The underlying resolver to use for actual resolution
214/// * `ttl_seconds` - How long to cache results in seconds
215/// * `metrics` - Metrics publisher for telemetry
216///
217/// # Example
218///
219/// ```no_run
220/// use std::sync::Arc;
221/// use quickdid::handle_resolver::{create_base_resolver, create_caching_resolver, HandleResolver};
222/// use quickdid::metrics::NoOpMetricsPublisher;
223///
224/// # async fn example() {
225/// # use atproto_identity::resolve::HickoryDnsResolver;
226/// # use reqwest::Client;
227/// # let dns_resolver = Arc::new(HickoryDnsResolver::create_resolver(&[]));
228/// # let http_client = Client::new();
229/// # let metrics = Arc::new(NoOpMetricsPublisher);
230/// let base = create_base_resolver(
231/// dns_resolver,
232/// http_client,
233/// metrics.clone(),
234/// );
235///
236/// let resolver = create_caching_resolver(base, 300, metrics); // 5 minute TTL
237/// let did = resolver.resolve("alice.bsky.social").await.unwrap();
238/// # }
239/// ```
240pub fn create_caching_resolver(
241 inner: Arc<dyn HandleResolver>,
242 ttl_seconds: u64,
243 metrics: SharedMetricsPublisher,
244) -> Arc<dyn HandleResolver> {
245 Arc::new(CachingHandleResolver::new(inner, ttl_seconds, metrics))
246}