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+//! SQLite-backed caching handle resolver.
2+//!
3+//! This module provides a handle resolver that caches resolution results in SQLite
4+//! with configurable expiration times. SQLite caching provides persistence across
5+//! service restarts while remaining lightweight for single-instance deployments.
6+7+use super::errors::HandleResolverError;
8+use super::traits::HandleResolver;
9+use crate::handle_resolution_result::HandleResolutionResult;
10+use async_trait::async_trait;
11+use metrohash::MetroHash64;
12+use sqlx::{Row, SqlitePool};
13+use std::hash::Hasher as _;
14+use std::sync::Arc;
15+use std::time::{SystemTime, UNIX_EPOCH};
16+17+/// SQLite-backed caching handle resolver.
18+///
19+/// This resolver caches handle resolution results in SQLite with a configurable TTL.
20+/// Results are stored in a compact binary format using bincode serialization
21+/// to minimize storage overhead.
22+///
23+/// # Features
24+///
25+/// - Persistent caching across service restarts
26+/// - Lightweight single-file database
27+/// - Configurable TTL (default: 90 days)
28+/// - Compact binary storage format
29+/// - Automatic schema management
30+/// - Graceful fallback if SQLite is unavailable
31+///
32+/// # Example
33+///
34+/// ```no_run
35+/// use std::sync::Arc;
36+/// use sqlx::SqlitePool;
37+/// use quickdid::handle_resolver::{create_base_resolver, create_sqlite_resolver, HandleResolver};
38+///
39+/// # async fn example() {
40+/// # use atproto_identity::resolve::HickoryDnsResolver;
41+/// # use reqwest::Client;
42+/// # let dns_resolver = Arc::new(HickoryDnsResolver::create_resolver(&[]));
43+/// # let http_client = Client::new();
44+/// # let base_resolver = create_base_resolver(dns_resolver, http_client);
45+/// # let sqlite_pool: SqlitePool = todo!();
46+/// // Create with default 90-day TTL
47+/// let resolver = create_sqlite_resolver(
48+/// base_resolver,
49+/// sqlite_pool
50+/// );
51+/// # }
52+/// ```
53+pub(super) struct SqliteHandleResolver {
54+ /// Base handle resolver to perform actual resolution
55+ inner: Arc<dyn HandleResolver>,
56+ /// SQLite connection pool
57+ pool: SqlitePool,
58+ /// TTL for cache entries in seconds
59+ ttl_seconds: u64,
60+}
61+62+impl SqliteHandleResolver {
63+ /// Create a new SQLite-backed handle resolver with default 90-day TTL.
64+ fn new(inner: Arc<dyn HandleResolver>, pool: SqlitePool) -> Self {
65+ Self::with_ttl(inner, pool, 90 * 24 * 60 * 60) // 90 days default
66+ }
67+68+ /// Create a new SQLite-backed handle resolver with custom TTL.
69+ fn with_ttl(inner: Arc<dyn HandleResolver>, pool: SqlitePool, ttl_seconds: u64) -> Self {
70+ Self {
71+ inner,
72+ pool,
73+ ttl_seconds,
74+ }
75+ }
76+77+ /// Generate the cache key for a handle.
78+ ///
79+ /// Uses MetroHash64 to generate a consistent hash of the handle
80+ /// for use as the primary key. This provides better key distribution
81+ /// and avoids issues with special characters in handles.
82+ fn make_key(&self, handle: &str) -> u64 {
83+ let mut h = MetroHash64::default();
84+ h.write(handle.as_bytes());
85+ h.finish()
86+ }
87+88+ /// Check if a cache entry is expired.
89+ fn is_expired(&self, updated_timestamp: i64) -> bool {
90+ let current_timestamp = SystemTime::now()
91+ .duration_since(UNIX_EPOCH)
92+ .unwrap_or_default()
93+ .as_secs() as i64;
94+95+ (current_timestamp - updated_timestamp) > (self.ttl_seconds as i64)
96+ }
97+}
98+99+#[async_trait]
100+impl HandleResolver for SqliteHandleResolver {
101+ async fn resolve(&self, s: &str) -> Result<String, HandleResolverError> {
102+ let handle = s.to_string();
103+ let key = self.make_key(&handle) as i64; // SQLite uses signed integers
104+105+ // Try to get from SQLite cache first
106+ let cached_result = sqlx::query(
107+ "SELECT result, updated FROM handle_resolution_cache WHERE key = ?1"
108+ )
109+ .bind(key)
110+ .fetch_optional(&self.pool)
111+ .await;
112+113+ match cached_result {
114+ Ok(Some(row)) => {
115+ let cached_bytes: Vec<u8> = row.get("result");
116+ let updated_timestamp: i64 = row.get("updated");
117+118+ // Check if the entry is expired
119+ if !self.is_expired(updated_timestamp) {
120+ // Deserialize the cached result
121+ match HandleResolutionResult::from_bytes(&cached_bytes) {
122+ Ok(cached_result) => {
123+ if let Some(did) = cached_result.to_did() {
124+ tracing::debug!("Cache hit for handle {}: {}", handle, did);
125+ return Ok(did);
126+ } else {
127+ tracing::debug!("Cache hit (not resolved) for handle {}", handle);
128+ return Err(HandleResolverError::HandleNotFound);
129+ }
130+ }
131+ Err(e) => {
132+ tracing::warn!(
133+ "Failed to deserialize cached result for handle {}: {}",
134+ handle,
135+ e
136+ );
137+ // Fall through to re-resolve if deserialization fails
138+ }
139+ }
140+ } else {
141+ tracing::debug!("Cache entry expired for handle {}", handle);
142+ // Entry is expired, we'll re-resolve and update it
143+ }
144+ }
145+ Ok(None) => {
146+ tracing::debug!("Cache miss for handle {}, resolving...", handle);
147+ }
148+ Err(e) => {
149+ tracing::warn!("Failed to query SQLite cache for handle {}: {}", handle, e);
150+ // Fall through to resolve without caching on database error
151+ }
152+ }
153+154+ // Not in cache or expired, resolve through inner resolver
155+ let result = self.inner.resolve(s).await;
156+157+ // Create and serialize resolution result
158+ let resolution_result = match &result {
159+ Ok(did) => {
160+ tracing::debug!(
161+ "Caching successful resolution for handle {}: {}",
162+ handle,
163+ did
164+ );
165+ match HandleResolutionResult::success(did) {
166+ Ok(res) => res,
167+ Err(e) => {
168+ tracing::warn!("Failed to create resolution result: {}", e);
169+ return result;
170+ }
171+ }
172+ }
173+ Err(e) => {
174+ tracing::debug!("Caching failed resolution for handle {}: {}", handle, e);
175+ match HandleResolutionResult::not_resolved() {
176+ Ok(res) => res,
177+ Err(err) => {
178+ tracing::warn!("Failed to create not_resolved result: {}", err);
179+ return result;
180+ }
181+ }
182+ }
183+ };
184+185+ // Serialize to bytes
186+ match resolution_result.to_bytes() {
187+ Ok(bytes) => {
188+ let current_timestamp = SystemTime::now()
189+ .duration_since(UNIX_EPOCH)
190+ .unwrap_or_default()
191+ .as_secs() as i64;
192+193+ // Insert or update the cache entry
194+ let query_result = sqlx::query(
195+ r#"
196+ INSERT INTO handle_resolution_cache (key, result, created, updated)
197+ VALUES (?1, ?2, ?3, ?4)
198+ ON CONFLICT(key) DO UPDATE SET
199+ result = excluded.result,
200+ updated = excluded.updated
201+ "#
202+ )
203+ .bind(key)
204+ .bind(&bytes)
205+ .bind(current_timestamp)
206+ .bind(current_timestamp)
207+ .execute(&self.pool)
208+ .await;
209+210+ if let Err(e) = query_result {
211+ tracing::warn!("Failed to cache handle resolution in SQLite: {}", e);
212+ }
213+ }
214+ Err(e) => {
215+ tracing::warn!(
216+ "Failed to serialize resolution result for handle {}: {}",
217+ handle,
218+ e
219+ );
220+ }
221+ }
222+223+ result
224+ }
225+}
226+227+/// Create a new SQLite-backed handle resolver with default 90-day TTL.
228+///
229+/// # Arguments
230+///
231+/// * `inner` - The underlying resolver to use for actual resolution
232+/// * `pool` - SQLite connection pool
233+///
234+/// # Example
235+///
236+/// ```no_run
237+/// use std::sync::Arc;
238+/// use quickdid::handle_resolver::{create_base_resolver, create_sqlite_resolver, HandleResolver};
239+/// use quickdid::sqlite_schema::create_sqlite_pool;
240+///
241+/// # async fn example() -> anyhow::Result<()> {
242+/// # use atproto_identity::resolve::HickoryDnsResolver;
243+/// # use reqwest::Client;
244+/// # let dns_resolver = Arc::new(HickoryDnsResolver::create_resolver(&[]));
245+/// # let http_client = Client::new();
246+/// let base = create_base_resolver(
247+/// dns_resolver,
248+/// http_client,
249+/// );
250+///
251+/// let pool = create_sqlite_pool("sqlite:./quickdid.db").await?;
252+/// let resolver = create_sqlite_resolver(base, pool);
253+/// let did = resolver.resolve("alice.bsky.social").await.unwrap();
254+/// # Ok(())
255+/// # }
256+/// ```
257+pub fn create_sqlite_resolver(
258+ inner: Arc<dyn HandleResolver>,
259+ pool: SqlitePool,
260+) -> Arc<dyn HandleResolver> {
261+ Arc::new(SqliteHandleResolver::new(inner, pool))
262+}
263+264+/// Create a new SQLite-backed handle resolver with custom TTL.
265+///
266+/// # Arguments
267+///
268+/// * `inner` - The underlying resolver to use for actual resolution
269+/// * `pool` - SQLite connection pool
270+/// * `ttl_seconds` - TTL for cache entries in seconds
271+pub fn create_sqlite_resolver_with_ttl(
272+ inner: Arc<dyn HandleResolver>,
273+ pool: SqlitePool,
274+ ttl_seconds: u64,
275+) -> Arc<dyn HandleResolver> {
276+ Arc::new(SqliteHandleResolver::with_ttl(inner, pool, ttl_seconds))
277+}
278+279+#[cfg(test)]
280+mod tests {
281+ use super::*;
282+283+ // Mock handle resolver for testing
284+ #[derive(Clone)]
285+ struct MockHandleResolver {
286+ should_fail: bool,
287+ expected_did: String,
288+ }
289+290+ #[async_trait]
291+ impl HandleResolver for MockHandleResolver {
292+ async fn resolve(&self, _handle: &str) -> Result<String, HandleResolverError> {
293+ if self.should_fail {
294+ Err(HandleResolverError::MockResolutionFailure)
295+ } else {
296+ Ok(self.expected_did.clone())
297+ }
298+ }
299+ }
300+301+ #[tokio::test]
302+ async fn test_sqlite_handle_resolver_cache_hit() {
303+ // Create in-memory SQLite database for testing
304+ let pool = SqlitePool::connect("sqlite::memory:")
305+ .await
306+ .expect("Failed to connect to in-memory SQLite");
307+308+ // Create the schema
309+ crate::sqlite_schema::create_schema(&pool)
310+ .await
311+ .expect("Failed to create schema");
312+313+ // Create mock resolver
314+ let mock_resolver = Arc::new(MockHandleResolver {
315+ should_fail: false,
316+ expected_did: "did:plc:testuser123".to_string(),
317+ });
318+319+ // Create SQLite-backed resolver
320+ let sqlite_resolver = SqliteHandleResolver::with_ttl(mock_resolver, pool.clone(), 3600);
321+322+ let test_handle = "alice.bsky.social";
323+ let expected_key = sqlite_resolver.make_key(test_handle) as i64;
324+325+ // Verify database is empty initially
326+ let initial_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM handle_resolution_cache")
327+ .fetch_one(&pool)
328+ .await
329+ .expect("Failed to query initial count");
330+ assert_eq!(initial_count, 0);
331+332+ // First resolution - should call inner resolver and cache the result
333+ let result1 = sqlite_resolver.resolve(test_handle).await.unwrap();
334+ assert_eq!(result1, "did:plc:testuser123");
335+336+ // Verify record was inserted
337+ let count_after_first: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM handle_resolution_cache")
338+ .fetch_one(&pool)
339+ .await
340+ .expect("Failed to query count after first resolution");
341+ assert_eq!(count_after_first, 1);
342+343+ // Verify the cached record has correct key and non-empty result
344+ let cached_record = sqlx::query("SELECT key, result, created, updated FROM handle_resolution_cache WHERE key = ?1")
345+ .bind(expected_key)
346+ .fetch_one(&pool)
347+ .await
348+ .expect("Failed to fetch cached record");
349+350+ let cached_key: i64 = cached_record.get("key");
351+ let cached_result: Vec<u8> = cached_record.get("result");
352+ let cached_created: i64 = cached_record.get("created");
353+ let cached_updated: i64 = cached_record.get("updated");
354+355+ assert_eq!(cached_key, expected_key);
356+ assert!(!cached_result.is_empty(), "Cached result should not be empty");
357+ assert!(cached_created > 0, "Created timestamp should be positive");
358+ assert!(cached_updated > 0, "Updated timestamp should be positive");
359+ assert_eq!(cached_created, cached_updated, "Created and updated should be equal on first insert");
360+361+ // Verify we can deserialize the cached result
362+ let resolution_result = crate::handle_resolution_result::HandleResolutionResult::from_bytes(&cached_result)
363+ .expect("Failed to deserialize cached result");
364+ let cached_did = resolution_result.to_did().expect("Should have a DID");
365+ assert_eq!(cached_did, "did:plc:testuser123");
366+367+ // Second resolution - should hit cache (no additional database insert)
368+ let result2 = sqlite_resolver.resolve(test_handle).await.unwrap();
369+ assert_eq!(result2, "did:plc:testuser123");
370+371+ // Verify count hasn't changed (cache hit, no new insert)
372+ let count_after_second: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM handle_resolution_cache")
373+ .fetch_one(&pool)
374+ .await
375+ .expect("Failed to query count after second resolution");
376+ assert_eq!(count_after_second, 1);
377+ }
378+379+ #[tokio::test]
380+ async fn test_sqlite_handle_resolver_cache_error() {
381+ // Create in-memory SQLite database for testing
382+ let pool = SqlitePool::connect("sqlite::memory:")
383+ .await
384+ .expect("Failed to connect to in-memory SQLite");
385+386+ // Create the schema
387+ crate::sqlite_schema::create_schema(&pool)
388+ .await
389+ .expect("Failed to create schema");
390+391+ // Create mock resolver that fails
392+ let mock_resolver = Arc::new(MockHandleResolver {
393+ should_fail: true,
394+ expected_did: String::new(),
395+ });
396+397+ // Create SQLite-backed resolver
398+ let sqlite_resolver = SqliteHandleResolver::with_ttl(mock_resolver, pool.clone(), 3600);
399+400+ let test_handle = "error.bsky.social";
401+ let expected_key = sqlite_resolver.make_key(test_handle) as i64;
402+403+ // Verify database is empty initially
404+ let initial_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM handle_resolution_cache")
405+ .fetch_one(&pool)
406+ .await
407+ .expect("Failed to query initial count");
408+ assert_eq!(initial_count, 0);
409+410+ // First resolution - should fail and cache the failure
411+ let result1 = sqlite_resolver.resolve(test_handle).await;
412+ assert!(result1.is_err());
413+414+ // Match the specific error type we expect
415+ match result1 {
416+ Err(HandleResolverError::MockResolutionFailure) => {},
417+ other => panic!("Expected MockResolutionFailure, got {:?}", other),
418+ }
419+420+ // Verify the failure was cached
421+ let count_after_first: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM handle_resolution_cache")
422+ .fetch_one(&pool)
423+ .await
424+ .expect("Failed to query count after first resolution");
425+ assert_eq!(count_after_first, 1);
426+427+ // Verify the cached error record
428+ let cached_record = sqlx::query("SELECT key, result, created, updated FROM handle_resolution_cache WHERE key = ?1")
429+ .bind(expected_key)
430+ .fetch_one(&pool)
431+ .await
432+ .expect("Failed to fetch cached error record");
433+434+ let cached_key: i64 = cached_record.get("key");
435+ let cached_result: Vec<u8> = cached_record.get("result");
436+ let cached_created: i64 = cached_record.get("created");
437+ let cached_updated: i64 = cached_record.get("updated");
438+439+ assert_eq!(cached_key, expected_key);
440+ assert!(!cached_result.is_empty(), "Cached error result should not be empty");
441+ assert!(cached_created > 0, "Created timestamp should be positive");
442+ assert!(cached_updated > 0, "Updated timestamp should be positive");
443+ assert_eq!(cached_created, cached_updated, "Created and updated should be equal on first insert");
444+445+ // Verify we can deserialize the cached error result
446+ let resolution_result = crate::handle_resolution_result::HandleResolutionResult::from_bytes(&cached_result)
447+ .expect("Failed to deserialize cached error result");
448+ let cached_did = resolution_result.to_did();
449+ assert!(cached_did.is_none(), "Error result should have no DID");
450+451+ // Second resolution - should hit cache with error (no additional database operations)
452+ let result2 = sqlite_resolver.resolve(test_handle).await;
453+ assert!(result2.is_err());
454+455+ // Match the specific error type we expect from cache
456+ match result2 {
457+ Err(HandleResolverError::HandleNotFound) => {}, // Cache returns HandleNotFound for "not resolved"
458+ other => panic!("Expected HandleNotFound from cache, got {:?}", other),
459+ }
460+461+ // Verify count hasn't changed (cache hit, no new operations)
462+ let count_after_second: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM handle_resolution_cache")
463+ .fetch_one(&pool)
464+ .await
465+ .expect("Failed to query count after second resolution");
466+ assert_eq!(count_after_second, 1);
467+ }
468+}
+1
src/lib.rs
···7pub mod cache; // Only create_redis_pool exposed
8pub mod handle_resolver_task; // Factory functions and TaskConfig exposed
9pub mod queue_adapter; // Trait and factory functions exposed
010pub mod task_manager; // Only spawn_cancellable_task exposed
1112// Internal modules - crate visibility only
···7pub mod cache; // Only create_redis_pool exposed
8pub mod handle_resolver_task; // Factory functions and TaskConfig exposed
9pub mod queue_adapter; // Trait and factory functions exposed
10+pub mod sqlite_schema; // SQLite schema management functions exposed
11pub mod task_manager; // Only spawn_cancellable_task exposed
1213// Internal modules - crate visibility only