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.
···99use crate::handle_resolution_result::HandleResolutionResult;
1010use crate::metrics::SharedMetricsPublisher;
1111use async_trait::async_trait;
1212+use atproto_identity::resolve::{parse_input, InputType};
1213use deadpool_redis::{Pool as RedisPool, redis::AsyncCommands};
1314use metrohash::MetroHash64;
1415use std::hash::Hasher as _;
···117118 fn ttl_seconds(&self) -> u64 {
118119 self.ttl_seconds
119120 }
121121+122122+ /// Purge a handle and its associated DID from the cache.
123123+ ///
124124+ /// This method removes both the handle->DID mapping and the reverse DID->handle mapping.
125125+ async fn purge_handle(&self, handle: &str) -> Result<(), HandleResolverError> {
126126+ let handle_key = self.make_key(handle);
127127+128128+ match self.pool.get().await {
129129+ Ok(mut conn) => {
130130+ // First, try to get the cached result to find the associated DID
131131+ let cached: Option<Vec<u8>> = match conn.get(&handle_key).await {
132132+ Ok(value) => value,
133133+ Err(e) => {
134134+ tracing::warn!("Failed to get handle from Redis for purging: {}", e);
135135+ self.metrics.incr("resolver.redis.purge_get_error").await;
136136+ None
137137+ }
138138+ };
139139+140140+ // If we found a cached result, extract the DID and delete both keys
141141+ if let Some(cached_bytes) = cached {
142142+ if let Ok(cached_result) = HandleResolutionResult::from_bytes(&cached_bytes) {
143143+ if let Some(did) = cached_result.to_did() {
144144+ let did_key = self.make_key(&did);
145145+146146+ // Delete both the handle key and the DID key
147147+ let _: Result<(), _> = conn.del(&[&handle_key, &did_key]).await;
148148+149149+ tracing::debug!("Purged handle {} and associated DID {}", handle, did);
150150+ self.metrics.incr("resolver.redis.purge_handle_success").await;
151151+ } else {
152152+ // Just delete the handle key if no DID was resolved
153153+ let _: Result<(), _> = conn.del(&handle_key).await;
154154+ tracing::debug!("Purged unresolved handle {}", handle);
155155+ self.metrics.incr("resolver.redis.purge_handle_unresolved").await;
156156+ }
157157+ } else {
158158+ // If we can't deserialize, just delete the handle key
159159+ let _: Result<(), _> = conn.del(&handle_key).await;
160160+ tracing::warn!("Purged handle {} with undeserializable data", handle);
161161+ self.metrics.incr("resolver.redis.purge_handle_corrupt").await;
162162+ }
163163+ } else {
164164+ tracing::debug!("Handle {} not found in cache for purging", handle);
165165+ self.metrics.incr("resolver.redis.purge_handle_not_found").await;
166166+ }
167167+168168+ Ok(())
169169+ }
170170+ Err(e) => {
171171+ tracing::warn!("Failed to get Redis connection for purging: {}", e);
172172+ self.metrics.incr("resolver.redis.purge_connection_error").await;
173173+ Err(HandleResolverError::ResolutionFailed(format!("Redis connection error: {}", e)))
174174+ }
175175+ }
176176+ }
177177+178178+ /// Purge a DID and its associated handle from the cache.
179179+ ///
180180+ /// This method removes both the DID->handle mapping and the handle->DID mapping.
181181+ async fn purge_did(&self, did: &str) -> Result<(), HandleResolverError> {
182182+ let did_key = self.make_key(did);
183183+184184+ match self.pool.get().await {
185185+ Ok(mut conn) => {
186186+ // First, try to get the associated handle from the reverse mapping
187187+ let handle_bytes: Option<Vec<u8>> = match conn.get(&did_key).await {
188188+ Ok(value) => value,
189189+ Err(e) => {
190190+ tracing::warn!("Failed to get DID from Redis for purging: {}", e);
191191+ self.metrics.incr("resolver.redis.purge_get_error").await;
192192+ None
193193+ }
194194+ };
195195+196196+ // If we found a handle, delete both keys
197197+ if let Some(handle_bytes) = handle_bytes {
198198+ if let Ok(handle) = String::from_utf8(handle_bytes) {
199199+ let handle_key = self.make_key(&handle);
200200+201201+ // Delete both the DID key and the handle key
202202+ let _: Result<(), _> = conn.del(&[&did_key, &handle_key]).await;
203203+204204+ tracing::debug!("Purged DID {} and associated handle {}", did, handle);
205205+ self.metrics.incr("resolver.redis.purge_did_success").await;
206206+ } else {
207207+ // If we can't parse the handle, just delete the DID key
208208+ let _: Result<(), _> = conn.del(&did_key).await;
209209+ tracing::warn!("Purged DID {} with unparseable handle data", did);
210210+ self.metrics.incr("resolver.redis.purge_did_corrupt").await;
211211+ }
212212+ } else {
213213+ tracing::debug!("DID {} not found in cache for purging", did);
214214+ self.metrics.incr("resolver.redis.purge_did_not_found").await;
215215+ }
216216+217217+ Ok(())
218218+ }
219219+ Err(e) => {
220220+ tracing::warn!("Failed to get Redis connection for purging: {}", e);
221221+ self.metrics.incr("resolver.redis.purge_connection_error").await;
222222+ Err(HandleResolverError::ResolutionFailed(format!("Redis connection error: {}", e)))
223223+ }
224224+ }
225225+ }
120226}
121227122228#[async_trait]
···217323 self.metrics.incr("resolver.redis.cache_set_error").await;
218324 } else {
219325 self.metrics.incr("resolver.redis.cache_set").await;
326326+327327+ // For successful resolutions, also store reverse DID -> handle mapping
328328+ if let Ok((did, _)) = &result {
329329+ let did_key = self.make_key(did);
330330+ if let Err(e) = conn
331331+ .set_ex::<_, _, ()>(&did_key, handle.as_bytes(), self.ttl_seconds())
332332+ .await
333333+ {
334334+ tracing::warn!("Failed to cache reverse DID->handle mapping in Redis: {}", e);
335335+ self.metrics.incr("resolver.redis.reverse_cache_set_error").await;
336336+ } else {
337337+ tracing::debug!("Cached reverse mapping for DID {}: {}", did, handle);
338338+ self.metrics.incr("resolver.redis.reverse_cache_set").await;
339339+ }
340340+ }
220341 }
221342 }
222343 Err(e) => {
···239360 );
240361 self.metrics.incr("resolver.redis.connection_error").await;
241362 self.inner.resolve(s).await
363363+ }
364364+ }
365365+ }
366366+367367+ async fn purge(&self, subject: &str) -> Result<(), HandleResolverError> {
368368+ // Use atproto_identity's parse_input to properly identify the input type
369369+ let parsed_input = parse_input(subject).map_err(|_| HandleResolverError::InvalidSubject(subject.to_string()))?;
370370+ match parsed_input {
371371+ InputType::Handle(handle) => {
372372+ // It's a handle, purge using the lowercase version
373373+ self.purge_handle(&handle.to_lowercase()).await
374374+ }
375375+ InputType::Plc(did) | InputType::Web(did) => {
376376+ // It's a DID, purge the DID
377377+ self.purge_did(&did).await
242378 }
243379 }
244380 }
···383519 h.write(test_handle.as_bytes());
384520 let key = format!("{}{}", test_prefix, h.finish());
385521 let _: Result<(), _> = conn.del(key).await;
522522+ }
523523+ }
524524+525525+ #[tokio::test]
526526+ async fn test_redis_handle_resolver_bidirectional_purge() {
527527+ let pool = match crate::test_helpers::get_test_redis_pool() {
528528+ Some(p) => p,
529529+ None => return,
530530+ };
531531+532532+ // Create mock resolver
533533+ let mock_resolver = Arc::new(MockHandleResolver {
534534+ should_fail: false,
535535+ expected_did: "did:plc:testuser456".to_string(),
536536+ });
537537+538538+ // Create metrics publisher
539539+ let metrics = Arc::new(crate::metrics::NoOpMetricsPublisher);
540540+541541+ // Create Redis-backed resolver with a unique key prefix for testing
542542+ let test_prefix = format!(
543543+ "test:handle:{}:",
544544+ std::time::SystemTime::now()
545545+ .duration_since(std::time::UNIX_EPOCH)
546546+ .unwrap()
547547+ .as_nanos()
548548+ );
549549+ let redis_resolver = RedisHandleResolver::with_full_config(
550550+ mock_resolver,
551551+ pool.clone(),
552552+ test_prefix.clone(),
553553+ 3600,
554554+ metrics,
555555+ );
556556+557557+ let test_handle = "bob.bsky.social";
558558+ let expected_did = "did:plc:testuser456";
559559+560560+ // First resolution - should call inner resolver and cache both directions
561561+ let (result1, _) = redis_resolver.resolve(test_handle).await.unwrap();
562562+ assert_eq!(result1, expected_did);
563563+564564+ // Verify both keys exist in Redis
565565+ if let Ok(mut conn) = pool.get().await {
566566+ let mut h = MetroHash64::default();
567567+ h.write(test_handle.as_bytes());
568568+ let handle_key = format!("{}{}", test_prefix, h.finish());
569569+570570+ let mut h2 = MetroHash64::default();
571571+ h2.write(expected_did.as_bytes());
572572+ let did_key = format!("{}{}", test_prefix, h2.finish());
573573+574574+ // Check handle -> DID mapping exists
575575+ let handle_exists: bool = conn.exists(&handle_key).await.unwrap();
576576+ assert!(handle_exists, "Handle key should exist in cache");
577577+578578+ // Check DID -> handle mapping exists
579579+ let did_exists: bool = conn.exists(&did_key).await.unwrap();
580580+ assert!(did_exists, "DID key should exist in cache");
581581+582582+ // Test purge by handle using the trait method
583583+ redis_resolver.purge(test_handle).await.unwrap();
584584+585585+ // Verify both keys were deleted
586586+ let handle_exists_after: bool = conn.exists(&handle_key).await.unwrap();
587587+ assert!(!handle_exists_after, "Handle key should be deleted after purge");
588588+589589+ let did_exists_after: bool = conn.exists(&did_key).await.unwrap();
590590+ assert!(!did_exists_after, "DID key should be deleted after purge");
591591+ }
592592+593593+ // Re-resolve to cache again
594594+ let (result2, _) = redis_resolver.resolve(test_handle).await.unwrap();
595595+ assert_eq!(result2, expected_did);
596596+597597+ // Test purge by DID using the trait method
598598+ redis_resolver.purge(expected_did).await.unwrap();
599599+600600+ // Verify both keys were deleted again
601601+ if let Ok(mut conn) = pool.get().await {
602602+ let mut h = MetroHash64::default();
603603+ h.write(test_handle.as_bytes());
604604+ let handle_key = format!("{}{}", test_prefix, h.finish());
605605+606606+ let mut h2 = MetroHash64::default();
607607+ h2.write(expected_did.as_bytes());
608608+ let did_key = format!("{}{}", test_prefix, h2.finish());
609609+610610+ let handle_exists: bool = conn.exists(&handle_key).await.unwrap();
611611+ assert!(!handle_exists, "Handle key should be deleted after DID purge");
612612+613613+ let did_exists: bool = conn.exists(&did_key).await.unwrap();
614614+ assert!(!did_exists, "DID key should be deleted after DID purge");
615615+ }
616616+ }
617617+618618+ #[tokio::test]
619619+ async fn test_redis_handle_resolver_purge_input_types() {
620620+ let pool = match crate::test_helpers::get_test_redis_pool() {
621621+ Some(p) => p,
622622+ None => return,
623623+ };
624624+625625+ // Create mock resolver
626626+ let mock_resolver = Arc::new(MockHandleResolver {
627627+ should_fail: false,
628628+ expected_did: "did:plc:testuser789".to_string(),
629629+ });
630630+631631+ // Create metrics publisher
632632+ let metrics = Arc::new(crate::metrics::NoOpMetricsPublisher);
633633+634634+ // Create Redis-backed resolver with a unique key prefix for testing
635635+ let test_prefix = format!(
636636+ "test:handle:{}:",
637637+ std::time::SystemTime::now()
638638+ .duration_since(std::time::UNIX_EPOCH)
639639+ .unwrap()
640640+ .as_nanos()
641641+ );
642642+ let redis_resolver = RedisHandleResolver::with_full_config(
643643+ mock_resolver,
644644+ pool.clone(),
645645+ test_prefix.clone(),
646646+ 3600,
647647+ metrics,
648648+ );
649649+650650+ // Test different input formats
651651+ let test_cases = vec![
652652+ ("alice.bsky.social", "alice.bsky.social"), // Handle
653653+ ("ALICE.BSKY.SOCIAL", "alice.bsky.social"), // Handle (uppercase)
654654+ ("did:plc:abc123", "did:plc:abc123"), // PLC DID
655655+ ("did:web:example.com", "did:web:example.com"), // Web DID
656656+ ];
657657+658658+ for (input, expected_key) in test_cases {
659659+ // Resolve first to cache it
660660+ if !input.starts_with("did:") {
661661+ let _ = redis_resolver.resolve(input).await;
662662+ }
663663+664664+ // Test purging with different input formats
665665+ let result = redis_resolver.purge(input).await;
666666+ assert!(result.is_ok(), "Failed to purge {}: {:?}", input, result);
667667+668668+ // Verify the key was handled correctly based on type
669669+ if let Ok(mut conn) = pool.get().await {
670670+ let mut h = MetroHash64::default();
671671+ h.write(expected_key.as_bytes());
672672+ let key = format!("{}{}", test_prefix, h.finish());
673673+674674+ // After purge, key should not exist
675675+ let exists: bool = conn.exists(&key).await.unwrap_or(false);
676676+ assert!(!exists, "Key for {} should not exist after purge", input);
677677+ }
386678 }
387679 }
388680
+48
src/handle_resolver/traits.rs
···5555 /// - Network errors occur during resolution
5656 /// - The handle is invalid or doesn't exist
5757 async fn resolve(&self, s: &str) -> Result<(String, u64), HandleResolverError>;
5858+5959+ /// Purge a handle or DID from the cache.
6060+ ///
6161+ /// This method removes cached entries for the given identifier, which can be
6262+ /// either a handle (e.g., "alice.bsky.social") or a DID (e.g., "did:plc:xyz123").
6363+ /// Implementations should handle bidirectional purging where applicable.
6464+ ///
6565+ /// # Arguments
6666+ ///
6767+ /// * `identifier` - Either a handle or DID to purge from cache
6868+ ///
6969+ /// # Returns
7070+ ///
7171+ /// Ok(()) if the purge was successful or if the identifier wasn't cached.
7272+ /// Most implementations will simply return Ok(()) as a no-op.
7373+ ///
7474+ /// # Default Implementation
7575+ ///
7676+ /// The default implementation is a no-op that always returns Ok(()).
7777+ async fn purge(&self, _subject: &str) -> Result<(), HandleResolverError> {
7878+ Ok(())
7979+ }
8080+}
8181+8282+#[cfg(test)]
8383+mod tests {
8484+ use super::*;
8585+8686+ // Simple test resolver that doesn't cache anything
8787+ struct NoOpTestResolver;
8888+8989+ #[async_trait]
9090+ impl HandleResolver for NoOpTestResolver {
9191+ async fn resolve(&self, _s: &str) -> Result<(String, u64), HandleResolverError> {
9292+ Ok(("did:test:123".to_string(), 1234567890))
9393+ }
9494+ // Uses default purge implementation
9595+ }
9696+9797+ #[tokio::test]
9898+ async fn test_default_purge_implementation() {
9999+ let resolver = NoOpTestResolver;
100100+101101+ // Default implementation should always return Ok(())
102102+ assert!(resolver.purge("alice.bsky.social").await.is_ok());
103103+ assert!(resolver.purge("did:plc:xyz123").await.is_ok());
104104+ assert!(resolver.purge("").await.is_ok());
105105+ }
58106}