diff --git a/bobbin/crates/resolver/src/identity.rs b/bobbin/crates/resolver/src/identity.rs index 2979e38da..c4b53ebc9 100644 --- a/bobbin/crates/resolver/src/identity.rs +++ b/bobbin/crates/resolver/src/identity.rs @@ -101,21 +101,54 @@ impl From for IdentityResolveError { } } +type ResolveCell = Arc>>; + #[derive(Clone)] enum IdentityState { Cached(MiniDoc), - Inactive, + Inactive { + handle: Option>, + }, + Revalidating { + handle: Option>, + cell: ResolveCell, + }, } impl IdentityState { fn doc(&self) -> Option<&MiniDoc> { match self { Self::Cached(doc) => Some(doc), - Self::Inactive => None, + Self::Inactive { .. } | Self::Revalidating { .. } => None, + } + } + + fn handle(&self) -> Option<&Handle> { + match self { + Self::Cached(doc) => Some(&doc.handle), + Self::Inactive { handle } | Self::Revalidating { handle, .. } => handle.as_ref(), } } } +enum Revalidation { + Cached(MiniDoc), + Flight(ResolveCell), + Missing, + Suppressed, +} + +struct WarmQueueGuard<'a> { + queued: &'a SccSet, RuntimeHasher>, + did: &'a Did, +} + +impl Drop for WarmQueueGuard<'_> { + fn drop(&mut self) { + self.queued.remove_sync(self.did); + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct IdentityResolverStatsSnapshot { pub entries: usize, @@ -143,7 +176,7 @@ struct IdentityResolverStats { pub struct IdentityResolver { by_did: SccMap, IdentityState, RuntimeHasher>, by_handle: SccMap, Did, RuntimeHasher>, - in_flight: SccMap>>, RuntimeHasher>, + in_flight: SccMap, // dids queued for background warming, keeps the queue free of duplicates queued: SccSet, RuntimeHasher>, // dids whose last warm failed, mapped to when we may try again @@ -215,10 +248,14 @@ impl IdentityResolver { } pub fn warm(&self, did: &Did) { - if self.by_did.contains_sync(did) { - return; - } - self.enqueue(did, WarmKind::Fill); + let kind = match self.by_did.get_sync(did).as_deref() { + Some(IdentityState::Cached(_)) => return, + Some(IdentityState::Inactive { .. } | IdentityState::Revalidating { .. }) => { + WarmKind::Refresh + } + None => WarmKind::Fill, + }; + self.enqueue(did, kind); } pub fn refresh(&self, did: &Did) { @@ -226,15 +263,7 @@ impl IdentityResolver { } fn enqueue(&self, did: &Did, kind: WarmKind) { - let Some(clock) = self.clock.as_ref() else { - return; - }; - let now = clock.now_instant(); - if self - .cooldown - .read_sync(did, |_, until| *until > now) - .unwrap_or(false) - { + if self.cooling_down(did) { return; } if self.queued.insert_sync(did.clone()).is_err() { @@ -248,6 +277,22 @@ impl IdentityResolver { self.stats.warm_queued.fetch_add(1, Ordering::Relaxed); } + fn cooling_down(&self, did: &Did) -> bool { + let Some(clock) = self.clock.as_ref() else { + return false; + }; + self.cooldown + .read_sync(did, |_, until| *until > clock.now_instant()) + .unwrap_or(false) + } + + fn arm_cooldown(&self, did: &Did) { + if let Some(clock) = self.clock.as_ref() { + self.cooldown + .upsert_sync(did.clone(), clock.now_instant() + WARM_RETRY_AFTER); + } + } + pub async fn run_warming(self: Arc, cancel: CancellationToken) { let Some(mut rx) = self .warm_rx @@ -279,12 +324,22 @@ impl IdentityResolver { } async fn warm_one(&self, did: Did, kind: WarmKind) { - let outcome = match kind { - WarmKind::Refresh => self - .fetch_upstream(&AtIdentifier::Did(did.clone())) - .await - .map(|doc| self.observe_doc(doc)), - WarmKind::Fill => self.fill_one(&did).await, + let _queued = WarmQueueGuard { + queued: &self.queued, + did: &did, + }; + let (outcome, arm_on_error) = match kind { + _ if self.needs_revalidation(&did) => { + (self.revalidate_did(&did).await.map(drop), false) + } + WarmKind::Refresh => ( + match self.fetch_upstream(&AtIdentifier::Did(did.clone())).await { + Ok(doc) => self.accept_fetched(doc, true).await.map(drop), + Err(error) => Err(error), + }, + true, + ), + WarmKind::Fill => (self.fill_one(&did).await, true), }; match outcome { @@ -293,15 +348,13 @@ impl IdentityResolver { self.stats.warm_resolved.fetch_add(1, Ordering::Relaxed); } Err(error) => { - if let Some(clock) = self.clock.as_ref() { - self.cooldown - .upsert_sync(did.clone(), clock.now_instant() + WARM_RETRY_AFTER); + if arm_on_error { + self.arm_cooldown(&did); } self.stats.warm_failed.fetch_add(1, Ordering::Relaxed); debug!(did = did.as_ref(), %error, "identity warm failed"); } } - self.queued.remove_sync(&did); } async fn fill_one(&self, did: &Did) -> Result<(), IdentityResolveError> { @@ -313,10 +366,7 @@ impl IdentityResolver { None => None, }; match known { - Some(RepoIdentity::Live(doc)) => { - self.observe_doc(doc); - Ok(()) - } + Some(RepoIdentity::Live(doc)) => self.accept_fetched(doc, true).await.map(drop), Some(RepoIdentity::Dead) => { self.deactivate(did.clone()); Ok(()) @@ -329,46 +379,49 @@ impl IdentityResolver { } pub fn observe(&self, did: Did, handle: Handle) { - let (inactive, incomplete) = self - .by_did - .read_sync(&did, |_, state| match state { - IdentityState::Inactive => (true, false), - IdentityState::Cached(doc) => (false, doc.pds.is_none()), - }) - .unwrap_or((false, false)); - if inactive { - self.refresh(&did); - return; - } - if incomplete { - self.refresh(&did); - } - self.observe_doc(MiniDoc { - did, + let observed = MiniDoc { + did: did.clone(), handle, pds: None, signing_key: None, - }); + }; + let refresh = { + let _mutation = self + .mutations + .lock() + .expect("identity mutation mutex poisoned"); + let state = self.by_did.get_sync(&did).map(|state| state.get().clone()); + match state { + Some(IdentityState::Inactive { .. }) => true, + Some(IdentityState::Revalidating { .. }) => true, + Some(IdentityState::Cached(doc)) => { + let incomplete = doc.pds.is_none(); + self.observe_doc_locked(observed); + incomplete + } + None => { + self.observe_doc_locked(observed); + false + } + } + }; + if refresh { + self.refresh(&did); + } } - fn observe_doc(&self, doc: MiniDoc) { - let _mutation = self - .mutations - .lock() - .expect("identity mutation mutex poisoned"); + fn observe_doc_locked(&self, doc: MiniDoc) { let (did, handle) = (doc.did.clone(), doc.handle.clone()); let mut previous_handle = None; match self.by_did.entry_sync(did.clone()) { MapEntry::Occupied(mut occupied) => { + previous_handle = occupied.get().handle().cloned(); let merged = match occupied.get().doc() { - Some(previous) => { - previous_handle = Some(previous.handle.clone()); - MiniDoc { - pds: doc.pds.or_else(|| previous.pds.clone()), - signing_key: doc.signing_key.or_else(|| previous.signing_key.clone()), - ..doc - } - } + Some(previous) => MiniDoc { + pds: doc.pds.or_else(|| previous.pds.clone()), + signing_key: doc.signing_key.or_else(|| previous.signing_key.clone()), + ..doc + }, None => doc, }; occupied.insert(IdentityState::Cached(merged)); @@ -389,17 +442,16 @@ impl IdentityResolver { .mutations .lock() .expect("identity mutation mutex poisoned"); - let mut previous_handle = None; - match self.by_did.entry_sync(did.clone()) { - MapEntry::Occupied(mut occupied) => { - previous_handle = occupied.get().doc().map(|doc| doc.handle.clone()); - occupied.insert(IdentityState::Inactive); - } - MapEntry::Vacant(vacant) => { - vacant.insert_entry(IdentityState::Inactive); - } - } - self.remove_by_handle_if_owned(&did, previous_handle.as_ref()); + let previous_handle = self + .by_did + .get_sync(&did) + .and_then(|state| state.get().handle().cloned()); + self.by_did.upsert_sync( + did.clone(), + IdentityState::Inactive { + handle: previous_handle.clone(), + }, + ); if let Some(sink) = self.sink.as_ref() { sink.identity_removed(&did, previous_handle.as_ref()); } @@ -420,10 +472,10 @@ impl IdentityResolver { let Some((did, state)) = removed else { return; }; - let Some(doc) = state.doc() else { + let Some(handle) = state.handle() else { return; }; - self.remove_by_handle_if_owned(&did, Some(&doc.handle)); + self.remove_by_handle_if_owned(&did, Some(handle)); } fn insert_by_handle(&self, did: Did, handle: Handle) { @@ -455,14 +507,10 @@ impl IdentityResolver { identifier: &AtIdentifier, ) -> Result, IdentityResolveError> { match identifier { - AtIdentifier::Did(did) => match self.by_did.get_sync(did).as_deref() { - Some(IdentityState::Cached(doc)) => Ok(Some(doc.clone())), - Some(IdentityState::Inactive) => { - self.refresh(did); - Ok(None) - } - None => Ok(None), - }, + AtIdentifier::Did(did) => Ok(self + .by_did + .get_sync(did) + .and_then(|state| state.get().doc().cloned())), AtIdentifier::Handle(handle) => { let Some(did) = self .by_handle @@ -472,13 +520,16 @@ impl IdentityResolver { return Ok(None); }; let state = self.by_did.get_sync(&did).map(|state| state.get().clone()); - if let Some(IdentityState::Cached(doc)) = state - && doc.handle == *handle - { - return Ok(Some(doc)); + match state.as_ref() { + Some(IdentityState::Cached(doc)) if doc.handle == *handle => { + Ok(Some(doc.clone())) + } + Some(state) if state.handle() == Some(handle) => Ok(None), + _ => { + self.remove_by_handle_if_owned(&did, Some(handle)); + Ok(None) + } } - self.remove_by_handle_if_owned(&did, Some(handle)); - Ok(None) } } } @@ -495,6 +546,28 @@ impl IdentityResolver { result } + fn needs_revalidation(&self, did: &Did) -> bool { + self.by_did.get_sync(did).is_some_and(|state| { + matches!( + state.get(), + IdentityState::Inactive { .. } | IdentityState::Revalidating { .. } + ) + }) + } + + fn inactive_did_for_handle(&self, handle: &Handle) -> Option> { + let did = self.by_handle.get_sync(handle)?.get().clone(); + self.by_did + .get_sync(&did) + .is_some_and(|state| { + matches!( + state.get(), + IdentityState::Inactive { .. } | IdentityState::Revalidating { .. } + ) && state.get().handle() == Some(handle) + }) + .then_some(did) + } + /// reads the cache without waiting, a miss queues a background warm pub fn get_by_did(&self, did: &Did) -> Result { match self.get_cached(&AtIdentifier::Did(did.clone()))? { @@ -514,7 +587,27 @@ impl IdentityResolver { if let Some(doc) = self.get_cached(identifier)? { return Ok(doc); } + match identifier { + AtIdentifier::Did(did) if self.needs_revalidation(did) => { + self.revalidate_did(did).await + } + AtIdentifier::Handle(handle) => { + if let Some(did) = self.inactive_did_for_handle(handle) { + let doc = self.revalidate_did(&did).await?; + return (doc.handle == *handle) + .then_some(doc) + .ok_or(IdentityResolveError::NotFound); + } + self.resolve_uncached(identifier).await + } + AtIdentifier::Did(_) => self.resolve_uncached(identifier).await, + } + } + async fn resolve_uncached( + &self, + identifier: &AtIdentifier, + ) -> Result { let key = identifier.as_str().to_owned(); let cell = self .in_flight @@ -527,7 +620,9 @@ impl IdentityResolver { .get_or_init(|| async { self.fetch_minidoc(identifier).await }) .await .clone(); - self.in_flight.remove_async(&key).await; + self.in_flight + .remove_if_async(&key, |current| Arc::ptr_eq(current, &cell)) + .await; result } @@ -551,33 +646,121 @@ impl IdentityResolver { identifier: &AtIdentifier, ) -> Result { let doc = self.fetch_upstream(identifier).await?; - self.insert_fetched_by_did(doc) + self.accept_fetched(doc, false).await } - fn insert_fetched_by_did(&self, doc: MiniDoc) -> Result { + async fn accept_fetched( + &self, + doc: MiniDoc, + overwrite_cached: bool, + ) -> Result { + { + let _mutation = self + .mutations + .lock() + .expect("identity mutation mutex poisoned"); + let state = self + .by_did + .get_sync(&doc.did) + .map(|state| state.get().clone()); + match state { + Some(IdentityState::Inactive { .. } | IdentityState::Revalidating { .. }) => {} + Some(IdentityState::Cached(current)) if !overwrite_cached => return Ok(current), + Some(IdentityState::Cached(_)) | None => { + self.observe_doc_locked(doc.clone()); + return Ok(doc); + } + } + } + self.revalidate_did(&doc.did).await + } + + fn start_revalidation(&self, did: &Did) -> Revalidation { let _mutation = self .mutations .lock() .expect("identity mutation mutex poisoned"); - let (did, handle) = match self.by_did.entry_sync(doc.did.clone()) { - MapEntry::Occupied(occupied) => match occupied.get() { - IdentityState::Inactive => { - self.refresh(&doc.did); - return Err(IdentityResolveError::NotFound); + match self.by_did.entry_sync(did.clone()) { + MapEntry::Occupied(mut occupied) => match occupied.get() { + IdentityState::Cached(doc) => Revalidation::Cached(doc.clone()), + IdentityState::Inactive { .. } if self.cooling_down(did) => { + Revalidation::Suppressed + } + IdentityState::Inactive { handle } => { + let handle = handle.clone(); + let cell = Arc::new(OnceCell::new()); + occupied.insert(IdentityState::Revalidating { + handle, + cell: cell.clone(), + }); + Revalidation::Flight(cell) } - IdentityState::Cached(current) => return Ok(current.clone()), + IdentityState::Revalidating { cell, .. } => Revalidation::Flight(cell.clone()), }, - MapEntry::Vacant(vacant) => { - let (did, handle) = (doc.did.clone(), doc.handle.clone()); - vacant.insert_entry(IdentityState::Cached(doc.clone())); - (did, handle) + MapEntry::Vacant(_) => Revalidation::Missing, + } + } + + async fn revalidate_did(&self, did: &Did) -> Result { + match self.start_revalidation(did) { + Revalidation::Cached(doc) => Ok(doc), + Revalidation::Flight(cell) => { + let owner = cell.clone(); + // share whether the fetch was accepted, not just what it returned + cell.get_or_init(|| async { + let result = self.fetch_upstream(&AtIdentifier::Did(did.clone())).await; + self.finish_revalidation(did, &owner, result) + }) + .await + .clone() + } + Revalidation::Missing | Revalidation::Suppressed => Err(IdentityResolveError::NotFound), + } + } + + fn finish_revalidation( + &self, + did: &Did, + owner: &ResolveCell, + result: Result, + ) -> Result { + let _mutation = self + .mutations + .lock() + .expect("identity mutation mutex poisoned"); + let state = self.by_did.get_sync(did).map(|state| state.get().clone()); + let handle = match state { + Some(IdentityState::Revalidating { handle, cell }) if Arc::ptr_eq(&cell, owner) => { + handle + } + Some(IdentityState::Cached(doc)) => return Ok(doc), + Some(IdentityState::Inactive { .. } | IdentityState::Revalidating { .. }) | None => { + return Err(IdentityResolveError::NotFound); } }; - self.insert_by_handle(did.clone(), handle.clone()); - if let Some(sink) = self.sink.as_ref() { - sink.identity_changed(&did, None, &handle); + match result { + Ok(doc) if doc.did != *did => { + self.by_did + .upsert_sync(did.clone(), IdentityState::Inactive { handle }); + self.arm_cooldown(did); + Err(IdentityResolveError::Decode(format!( + "resolved {} while revalidating {}", + doc.did.as_str(), + did.as_str() + ))) + } + Ok(doc) => { + self.cooldown.remove_sync(did); + self.observe_doc_locked(doc.clone()); + Ok(doc) + } + Err(error) => { + self.by_did + .upsert_sync(did.clone(), IdentityState::Inactive { handle }); + self.arm_cooldown(did); + Err(error) + } } - Ok(doc) } } @@ -749,23 +932,27 @@ mod tests { } #[test] - fn inactive_identity_rejects_an_in_flight_result() { + fn deactivation_cancels_an_in_flight_revalidation() { let resolver = resolver(); let identity = did("did:plc:dawn"); resolver.observe(identity.clone(), handle("ptr.pet")); resolver.deactivate(identity.clone()); + let Revalidation::Flight(owner) = resolver.start_revalidation(&identity) else { + panic!("inactive identity did not start revalidation"); + }; + resolver.deactivate(identity.clone()); assert_eq!( - resolver.get_by_did(&identity), - Err(IdentityResolveError::NotFound) - ); - assert_eq!( - resolver.insert_fetched_by_did(MiniDoc { - did: identity, - handle: handle("ptr.pet"), - pds: Some(Url::parse("https://pds.example.com").unwrap()), - signing_key: None, - }), + resolver.finish_revalidation( + &identity, + &owner, + Ok(MiniDoc { + did: identity.clone(), + handle: handle("ptr.pet"), + pds: Some(Url::parse("https://pds.example.com").unwrap()), + signing_key: None, + }) + ), Err(IdentityResolveError::NotFound) ); assert!( @@ -776,6 +963,33 @@ mod tests { ); } + #[test] + fn an_identity_frame_does_not_cancel_a_live_revalidation() { + let resolver = resolver(); + let identity = did("did:plc:dawn"); + resolver.observe(identity.clone(), handle("ptr.pet")); + resolver.deactivate(identity.clone()); + let Revalidation::Flight(owner) = resolver.start_revalidation(&identity) else { + panic!("inactive identity did not start revalidation"); + }; + + resolver.observe(identity.clone(), handle("ptr.pet")); + let doc = MiniDoc { + did: identity.clone(), + handle: handle("ptr.pet"), + pds: Some(Url::parse("https://pds.example.com").unwrap()), + signing_key: None, + }; + assert_eq!( + resolver.finish_revalidation(&identity, &owner, Ok(doc.clone())), + Ok(doc) + ); + assert_eq!( + resolver.get_by_did(&identity).unwrap().handle, + handle("ptr.pet") + ); + } + #[tokio::test] async fn warming_resolves_a_did_we_only_saw_on_a_record() { let server = MockServer::start().await; @@ -1032,11 +1246,15 @@ mod tests { Mock::given(method("GET")) .and(path("/xrpc/blue.microcosm.identity.resolveMiniDoc")) .and(query_param("identifier", "did:plc:dawn")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "did": "did:plc:dawn", - "handle": "ptr.pet", - "pds": "https://pds.example.com" - }))) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_millis(100)) + .set_body_json(serde_json::json!({ + "did": "did:plc:dawn", + "handle": "ptr.pet", + "pds": "https://pds.example.com" + })), + ) .expect(1) .mount(&server) .await; @@ -1052,10 +1270,35 @@ mod tests { ); assert_eq!(resolver.stats().warm_queued, 1); - drain_warming(&resolver, &identity).await; + let cancel = CancellationToken::new(); + let warmer = tokio::spawn(resolver.clone().run_warming(cancel.clone())); + tokio::time::timeout(Duration::from_secs(5), async { + while !matches!( + resolver.by_did.get_sync(&identity).as_deref(), + Some(IdentityState::Revalidating { .. }) + ) { + tokio::task::yield_now().await; + } + }) + .await + .expect("background revalidation never started"); + + let doc = resolver + .resolve_minidoc(&AtIdentifier::Did(identity.clone())) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + while resolver.stats().warm_resolved == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("background revalidation never settled"); + cancel.cancel(); + warmer.await.unwrap(); - let doc = resolver.get_by_did(&identity).unwrap(); assert_eq!(doc.handle, handle("ptr.pet")); + assert_eq!(resolver.stats().upstream_requests, 1); assert_eq!( doc.pds, Some(Url::parse("https://pds.example.com").unwrap()) @@ -1097,42 +1340,105 @@ mod tests { } #[tokio::test] - async fn a_handle_lookup_for_a_deactivated_did_queues_a_revalidation() { + async fn failed_foreground_revalidation_obeys_the_cooldown() { let server = MockServer::start().await; - for identifier in ["dawn.example.com", "did:plc:dawn"] { - Mock::given(method("GET")) - .and(path("/xrpc/blue.microcosm.identity.resolveMiniDoc")) - .and(query_param("identifier", identifier)) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "did": "did:plc:dawn", - "handle": "dawn.example.com", - "pds": "https://pds.example.com" - }))) - .expect(1) - .mount(&server) - .await; + Mock::given(method("GET")) + .and(path("/xrpc/blue.microcosm.identity.resolveMiniDoc")) + .and(query_param("identifier", "did:plc:gone")) + .respond_with(ResponseTemplate::new(404)) + .expect(1) + .mount(&server) + .await; + + let resolver = hydrant_backed(&server); + let identity = did("did:plc:gone"); + resolver.deactivate(identity.clone()); + let identifier = AtIdentifier::Did(identity); + + for _ in 0..3 { + assert_eq!( + resolver.resolve_minidoc(&identifier).await, + Err(IdentityResolveError::NotFound) + ); } + assert_eq!(resolver.stats().upstream_requests, 1); + } + + #[tokio::test] + async fn did_and_handle_lookups_share_an_inactive_revalidation() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/xrpc/blue.microcosm.identity.resolveMiniDoc")) + .and(query_param("identifier", "dawn.example.com")) + .respond_with(ResponseTemplate::new(404)) + .expect(0) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/xrpc/blue.microcosm.identity.resolveMiniDoc")) + .and(query_param("identifier", "did:plc:dawn")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "did": "did:plc:dawn", + "handle": "dawn.example.com", + "pds": "https://pds.example.com" + }))) + .expect(1) + .mount(&server) + .await; let resolver = hydrant_backed(&server); let identity = did("did:plc:dawn"); resolver.observe(identity.clone(), handle("dawn.example.com")); resolver.deactivate(identity.clone()); + let by_handle = AtIdentifier::Handle(handle("dawn.example.com")); + let by_did = AtIdentifier::Did(identity); + + let (handle_result, did_result) = tokio::join!( + resolver.resolve_minidoc(&by_handle), + resolver.resolve_minidoc(&by_did) + ); + assert_eq!(handle_result.unwrap().handle, handle("dawn.example.com")); + assert_eq!(did_result.unwrap().handle, handle("dawn.example.com")); + assert_eq!(resolver.stats().upstream_requests, 1); + assert_eq!(resolver.stats().warm_queued, 0); + } + + #[tokio::test] + async fn an_inactive_handle_hint_cannot_claim_a_renamed_identity() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/xrpc/blue.microcosm.identity.resolveMiniDoc")) + .and(query_param("identifier", "did:plc:dawn")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "did": "did:plc:dawn", + "handle": "new.example.com", + "pds": "https://pds.example.com" + }))) + .expect(1) + .mount(&server) + .await; + + let resolver = hydrant_backed(&server); + let identity = did("did:plc:dawn"); + let old_handle = handle("old.example.com"); + resolver.observe(identity.clone(), old_handle.clone()); + resolver.deactivate(identity); assert_eq!( resolver - .resolve_minidoc(&AtIdentifier::Handle(handle("dawn.example.com"))) + .resolve_minidoc(&AtIdentifier::Handle(old_handle.clone())) .await, Err(IdentityResolveError::NotFound) ); - assert_eq!(resolver.stats().warm_queued, 1); - - drain_warming(&resolver, &identity).await; - - let doc = resolver - .resolve_minidoc(&AtIdentifier::Handle(handle("dawn.example.com"))) - .await - .unwrap(); - assert_eq!(doc.handle, handle("dawn.example.com")); + assert!(resolver.by_handle.get_sync(&old_handle).is_none()); + assert_eq!( + resolver + .cached(&AtIdentifier::Handle(handle("new.example.com"))) + .unwrap() + .unwrap() + .handle, + handle("new.example.com") + ); } #[tokio::test]