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.

feature: purge

+343
+3
src/handle_resolver/errors.rs
··· 23 23 /// Mock resolver failure for testing 24 24 #[error("error-quickdid-resolve-4 Mock resolution failure")] 25 25 MockResolutionFailure, 26 + 27 + #[error("error-quickdid-resolve-5 Invalid subject: {0}")] 28 + InvalidSubject(String), 26 29 }
+292
src/handle_resolver/redis.rs
··· 9 9 use crate::handle_resolution_result::HandleResolutionResult; 10 10 use crate::metrics::SharedMetricsPublisher; 11 11 use async_trait::async_trait; 12 + use atproto_identity::resolve::{parse_input, InputType}; 12 13 use deadpool_redis::{Pool as RedisPool, redis::AsyncCommands}; 13 14 use metrohash::MetroHash64; 14 15 use std::hash::Hasher as _; ··· 117 118 fn ttl_seconds(&self) -> u64 { 118 119 self.ttl_seconds 119 120 } 121 + 122 + /// Purge a handle and its associated DID from the cache. 123 + /// 124 + /// This method removes both the handle->DID mapping and the reverse DID->handle mapping. 125 + async fn purge_handle(&self, handle: &str) -> Result<(), HandleResolverError> { 126 + let handle_key = self.make_key(handle); 127 + 128 + match self.pool.get().await { 129 + Ok(mut conn) => { 130 + // First, try to get the cached result to find the associated DID 131 + let cached: Option<Vec<u8>> = match conn.get(&handle_key).await { 132 + Ok(value) => value, 133 + Err(e) => { 134 + tracing::warn!("Failed to get handle from Redis for purging: {}", e); 135 + self.metrics.incr("resolver.redis.purge_get_error").await; 136 + None 137 + } 138 + }; 139 + 140 + // If we found a cached result, extract the DID and delete both keys 141 + if let Some(cached_bytes) = cached { 142 + if let Ok(cached_result) = HandleResolutionResult::from_bytes(&cached_bytes) { 143 + if let Some(did) = cached_result.to_did() { 144 + let did_key = self.make_key(&did); 145 + 146 + // Delete both the handle key and the DID key 147 + let _: Result<(), _> = conn.del(&[&handle_key, &did_key]).await; 148 + 149 + tracing::debug!("Purged handle {} and associated DID {}", handle, did); 150 + self.metrics.incr("resolver.redis.purge_handle_success").await; 151 + } else { 152 + // Just delete the handle key if no DID was resolved 153 + let _: Result<(), _> = conn.del(&handle_key).await; 154 + tracing::debug!("Purged unresolved handle {}", handle); 155 + self.metrics.incr("resolver.redis.purge_handle_unresolved").await; 156 + } 157 + } else { 158 + // If we can't deserialize, just delete the handle key 159 + let _: Result<(), _> = conn.del(&handle_key).await; 160 + tracing::warn!("Purged handle {} with undeserializable data", handle); 161 + self.metrics.incr("resolver.redis.purge_handle_corrupt").await; 162 + } 163 + } else { 164 + tracing::debug!("Handle {} not found in cache for purging", handle); 165 + self.metrics.incr("resolver.redis.purge_handle_not_found").await; 166 + } 167 + 168 + Ok(()) 169 + } 170 + Err(e) => { 171 + tracing::warn!("Failed to get Redis connection for purging: {}", e); 172 + self.metrics.incr("resolver.redis.purge_connection_error").await; 173 + Err(HandleResolverError::ResolutionFailed(format!("Redis connection error: {}", e))) 174 + } 175 + } 176 + } 177 + 178 + /// Purge a DID and its associated handle from the cache. 179 + /// 180 + /// This method removes both the DID->handle mapping and the handle->DID mapping. 181 + async fn purge_did(&self, did: &str) -> Result<(), HandleResolverError> { 182 + let did_key = self.make_key(did); 183 + 184 + match self.pool.get().await { 185 + Ok(mut conn) => { 186 + // First, try to get the associated handle from the reverse mapping 187 + let handle_bytes: Option<Vec<u8>> = match conn.get(&did_key).await { 188 + Ok(value) => value, 189 + Err(e) => { 190 + tracing::warn!("Failed to get DID from Redis for purging: {}", e); 191 + self.metrics.incr("resolver.redis.purge_get_error").await; 192 + None 193 + } 194 + }; 195 + 196 + // If we found a handle, delete both keys 197 + if let Some(handle_bytes) = handle_bytes { 198 + if let Ok(handle) = String::from_utf8(handle_bytes) { 199 + let handle_key = self.make_key(&handle); 200 + 201 + // Delete both the DID key and the handle key 202 + let _: Result<(), _> = conn.del(&[&did_key, &handle_key]).await; 203 + 204 + tracing::debug!("Purged DID {} and associated handle {}", did, handle); 205 + self.metrics.incr("resolver.redis.purge_did_success").await; 206 + } else { 207 + // If we can't parse the handle, just delete the DID key 208 + let _: Result<(), _> = conn.del(&did_key).await; 209 + tracing::warn!("Purged DID {} with unparseable handle data", did); 210 + self.metrics.incr("resolver.redis.purge_did_corrupt").await; 211 + } 212 + } else { 213 + tracing::debug!("DID {} not found in cache for purging", did); 214 + self.metrics.incr("resolver.redis.purge_did_not_found").await; 215 + } 216 + 217 + Ok(()) 218 + } 219 + Err(e) => { 220 + tracing::warn!("Failed to get Redis connection for purging: {}", e); 221 + self.metrics.incr("resolver.redis.purge_connection_error").await; 222 + Err(HandleResolverError::ResolutionFailed(format!("Redis connection error: {}", e))) 223 + } 224 + } 225 + } 120 226 } 121 227 122 228 #[async_trait] ··· 217 323 self.metrics.incr("resolver.redis.cache_set_error").await; 218 324 } else { 219 325 self.metrics.incr("resolver.redis.cache_set").await; 326 + 327 + // For successful resolutions, also store reverse DID -> handle mapping 328 + if let Ok((did, _)) = &result { 329 + let did_key = self.make_key(did); 330 + if let Err(e) = conn 331 + .set_ex::<_, _, ()>(&did_key, handle.as_bytes(), self.ttl_seconds()) 332 + .await 333 + { 334 + tracing::warn!("Failed to cache reverse DID->handle mapping in Redis: {}", e); 335 + self.metrics.incr("resolver.redis.reverse_cache_set_error").await; 336 + } else { 337 + tracing::debug!("Cached reverse mapping for DID {}: {}", did, handle); 338 + self.metrics.incr("resolver.redis.reverse_cache_set").await; 339 + } 340 + } 220 341 } 221 342 } 222 343 Err(e) => { ··· 239 360 ); 240 361 self.metrics.incr("resolver.redis.connection_error").await; 241 362 self.inner.resolve(s).await 363 + } 364 + } 365 + } 366 + 367 + async fn purge(&self, subject: &str) -> Result<(), HandleResolverError> { 368 + // Use atproto_identity's parse_input to properly identify the input type 369 + let parsed_input = parse_input(subject).map_err(|_| HandleResolverError::InvalidSubject(subject.to_string()))?; 370 + match parsed_input { 371 + InputType::Handle(handle) => { 372 + // It's a handle, purge using the lowercase version 373 + self.purge_handle(&handle.to_lowercase()).await 374 + } 375 + InputType::Plc(did) | InputType::Web(did) => { 376 + // It's a DID, purge the DID 377 + self.purge_did(&did).await 242 378 } 243 379 } 244 380 } ··· 383 519 h.write(test_handle.as_bytes()); 384 520 let key = format!("{}{}", test_prefix, h.finish()); 385 521 let _: Result<(), _> = conn.del(key).await; 522 + } 523 + } 524 + 525 + #[tokio::test] 526 + async fn test_redis_handle_resolver_bidirectional_purge() { 527 + let pool = match crate::test_helpers::get_test_redis_pool() { 528 + Some(p) => p, 529 + None => return, 530 + }; 531 + 532 + // Create mock resolver 533 + let mock_resolver = Arc::new(MockHandleResolver { 534 + should_fail: false, 535 + expected_did: "did:plc:testuser456".to_string(), 536 + }); 537 + 538 + // Create metrics publisher 539 + let metrics = Arc::new(crate::metrics::NoOpMetricsPublisher); 540 + 541 + // Create Redis-backed resolver with a unique key prefix for testing 542 + let test_prefix = format!( 543 + "test:handle:{}:", 544 + std::time::SystemTime::now() 545 + .duration_since(std::time::UNIX_EPOCH) 546 + .unwrap() 547 + .as_nanos() 548 + ); 549 + let redis_resolver = RedisHandleResolver::with_full_config( 550 + mock_resolver, 551 + pool.clone(), 552 + test_prefix.clone(), 553 + 3600, 554 + metrics, 555 + ); 556 + 557 + let test_handle = "bob.bsky.social"; 558 + let expected_did = "did:plc:testuser456"; 559 + 560 + // First resolution - should call inner resolver and cache both directions 561 + let (result1, _) = redis_resolver.resolve(test_handle).await.unwrap(); 562 + assert_eq!(result1, expected_did); 563 + 564 + // Verify both keys exist in Redis 565 + if let Ok(mut conn) = pool.get().await { 566 + let mut h = MetroHash64::default(); 567 + h.write(test_handle.as_bytes()); 568 + let handle_key = format!("{}{}", test_prefix, h.finish()); 569 + 570 + let mut h2 = MetroHash64::default(); 571 + h2.write(expected_did.as_bytes()); 572 + let did_key = format!("{}{}", test_prefix, h2.finish()); 573 + 574 + // Check handle -> DID mapping exists 575 + let handle_exists: bool = conn.exists(&handle_key).await.unwrap(); 576 + assert!(handle_exists, "Handle key should exist in cache"); 577 + 578 + // Check DID -> handle mapping exists 579 + let did_exists: bool = conn.exists(&did_key).await.unwrap(); 580 + assert!(did_exists, "DID key should exist in cache"); 581 + 582 + // Test purge by handle using the trait method 583 + redis_resolver.purge(test_handle).await.unwrap(); 584 + 585 + // Verify both keys were deleted 586 + let handle_exists_after: bool = conn.exists(&handle_key).await.unwrap(); 587 + assert!(!handle_exists_after, "Handle key should be deleted after purge"); 588 + 589 + let did_exists_after: bool = conn.exists(&did_key).await.unwrap(); 590 + assert!(!did_exists_after, "DID key should be deleted after purge"); 591 + } 592 + 593 + // Re-resolve to cache again 594 + let (result2, _) = redis_resolver.resolve(test_handle).await.unwrap(); 595 + assert_eq!(result2, expected_did); 596 + 597 + // Test purge by DID using the trait method 598 + redis_resolver.purge(expected_did).await.unwrap(); 599 + 600 + // Verify both keys were deleted again 601 + if let Ok(mut conn) = pool.get().await { 602 + let mut h = MetroHash64::default(); 603 + h.write(test_handle.as_bytes()); 604 + let handle_key = format!("{}{}", test_prefix, h.finish()); 605 + 606 + let mut h2 = MetroHash64::default(); 607 + h2.write(expected_did.as_bytes()); 608 + let did_key = format!("{}{}", test_prefix, h2.finish()); 609 + 610 + let handle_exists: bool = conn.exists(&handle_key).await.unwrap(); 611 + assert!(!handle_exists, "Handle key should be deleted after DID purge"); 612 + 613 + let did_exists: bool = conn.exists(&did_key).await.unwrap(); 614 + assert!(!did_exists, "DID key should be deleted after DID purge"); 615 + } 616 + } 617 + 618 + #[tokio::test] 619 + async fn test_redis_handle_resolver_purge_input_types() { 620 + let pool = match crate::test_helpers::get_test_redis_pool() { 621 + Some(p) => p, 622 + None => return, 623 + }; 624 + 625 + // Create mock resolver 626 + let mock_resolver = Arc::new(MockHandleResolver { 627 + should_fail: false, 628 + expected_did: "did:plc:testuser789".to_string(), 629 + }); 630 + 631 + // Create metrics publisher 632 + let metrics = Arc::new(crate::metrics::NoOpMetricsPublisher); 633 + 634 + // Create Redis-backed resolver with a unique key prefix for testing 635 + let test_prefix = format!( 636 + "test:handle:{}:", 637 + std::time::SystemTime::now() 638 + .duration_since(std::time::UNIX_EPOCH) 639 + .unwrap() 640 + .as_nanos() 641 + ); 642 + let redis_resolver = RedisHandleResolver::with_full_config( 643 + mock_resolver, 644 + pool.clone(), 645 + test_prefix.clone(), 646 + 3600, 647 + metrics, 648 + ); 649 + 650 + // Test different input formats 651 + let test_cases = vec![ 652 + ("alice.bsky.social", "alice.bsky.social"), // Handle 653 + ("ALICE.BSKY.SOCIAL", "alice.bsky.social"), // Handle (uppercase) 654 + ("did:plc:abc123", "did:plc:abc123"), // PLC DID 655 + ("did:web:example.com", "did:web:example.com"), // Web DID 656 + ]; 657 + 658 + for (input, expected_key) in test_cases { 659 + // Resolve first to cache it 660 + if !input.starts_with("did:") { 661 + let _ = redis_resolver.resolve(input).await; 662 + } 663 + 664 + // Test purging with different input formats 665 + let result = redis_resolver.purge(input).await; 666 + assert!(result.is_ok(), "Failed to purge {}: {:?}", input, result); 667 + 668 + // Verify the key was handled correctly based on type 669 + if let Ok(mut conn) = pool.get().await { 670 + let mut h = MetroHash64::default(); 671 + h.write(expected_key.as_bytes()); 672 + let key = format!("{}{}", test_prefix, h.finish()); 673 + 674 + // After purge, key should not exist 675 + let exists: bool = conn.exists(&key).await.unwrap_or(false); 676 + assert!(!exists, "Key for {} should not exist after purge", input); 677 + } 386 678 } 387 679 } 388 680
+48
src/handle_resolver/traits.rs
··· 55 55 /// - Network errors occur during resolution 56 56 /// - The handle is invalid or doesn't exist 57 57 async fn resolve(&self, s: &str) -> Result<(String, u64), HandleResolverError>; 58 + 59 + /// Purge a handle or DID from the cache. 60 + /// 61 + /// This method removes cached entries for the given identifier, which can be 62 + /// either a handle (e.g., "alice.bsky.social") or a DID (e.g., "did:plc:xyz123"). 63 + /// Implementations should handle bidirectional purging where applicable. 64 + /// 65 + /// # Arguments 66 + /// 67 + /// * `identifier` - Either a handle or DID to purge from cache 68 + /// 69 + /// # Returns 70 + /// 71 + /// Ok(()) if the purge was successful or if the identifier wasn't cached. 72 + /// Most implementations will simply return Ok(()) as a no-op. 73 + /// 74 + /// # Default Implementation 75 + /// 76 + /// The default implementation is a no-op that always returns Ok(()). 77 + async fn purge(&self, _subject: &str) -> Result<(), HandleResolverError> { 78 + Ok(()) 79 + } 80 + } 81 + 82 + #[cfg(test)] 83 + mod tests { 84 + use super::*; 85 + 86 + // Simple test resolver that doesn't cache anything 87 + struct NoOpTestResolver; 88 + 89 + #[async_trait] 90 + impl HandleResolver for NoOpTestResolver { 91 + async fn resolve(&self, _s: &str) -> Result<(String, u64), HandleResolverError> { 92 + Ok(("did:test:123".to_string(), 1234567890)) 93 + } 94 + // Uses default purge implementation 95 + } 96 + 97 + #[tokio::test] 98 + async fn test_default_purge_implementation() { 99 + let resolver = NoOpTestResolver; 100 + 101 + // Default implementation should always return Ok(()) 102 + assert!(resolver.purge("alice.bsky.social").await.is_ok()); 103 + assert!(resolver.purge("did:plc:xyz123").await.is_ok()); 104 + assert!(resolver.purge("").await.is_ok()); 105 + } 58 106 }