perf: identity lookups over HTTP/2, with a handle answer cache #198
No reviewers
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
5 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!198
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-197-identity-latency"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #197.
What changed
IdentityFetch.GetAsync): every request asks for HTTP/2 withRequestVersionOrLower, so it falls back to 1.1 where the host doesn't offer h2. Concurrent lookups against one host now share a connection instead of each paying for a TCP+TLS handshake. The SSRF connect callback is unchanged: ALPN runs over the stream it returns.HandleResolver): an LRU (capacityCache.Capacity) keyed by the normalized handle, expiring afterIdentityResolverOptions.HandleCacheTtl(new; default 5 min,TimeSpan.Zerodisables). Only answers are cached, never "no answer" or aHandleConflict, so a newly set-up handle resolves at once.IdentityResolver.ResolveUncachedAsync, which OAuth uses for its post-login check, forgets the claimed handle before verifying it, so that path stays fully fresh.Measurements (live network, this machine)
DidResolver), p50Not included
I also tried starting the DID document fetch as soon as the first handle authority answers. It was within noise on real handles:
*.bsky.socialhandles only answer over HTTPS, so there is nothing to overlap. I dropped it rather than add the code.The DNS-first policy from #193 is left for the owner's decision.
🤖 Generated with Claude Code
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
✅ Verdict: approve — no blocking issues found.
Good to merge. The correctness-sensitive parts hold up:
Version/VersionPolicy.RequestVersionOrLoweris the right shape — it downgrade-negotiates per host, works over the SDK's customConnectCallback(ALPN is performed bySocketsHttpHandleron the stream it returns), and is harmless where a caller-supplied handler (or a WASMHttpClientHandler) ignores it.ResolveUncachedAsyncforgets the claimed handle before verifying, keeping the OAuth post-login check fully fresh — covered by a test.LruCache,Cache.Capacity, andValidate()cleanly; public API file and docs/CHANGELOG updated.Minor, non-blocking notes:
TimeProviderfor the TTL. Nearly every other timer in the SDK (CachingDidResolver,AuthorizationServerDiscovery,OAuthClient, …) takes aTimeProvider, which makes TTL expiry testable — hereEnvironment.TickCount64means expiry can't be exercised in tests. Also slightly less robust if a system clock skew mattered (TickCount64 is monotonic of course). Worth a follow-up ticket if the codebase cares about that seam.Capacitya burst of one-shot handles can crowd out still-fresh answers. At the default 10 000 this is theoretical; flagged only in case someone tunesCapacity down sharply.HandleCacheTtlcapacity sharing with the DID-document cache (Cache.Capacity) is documented in the remark — just be aware tuning the DOC cache silently moves the handle cache's ceiling.The DNS-first policy from #193 being left for the owner's decision is reasonable.
⏱ 1m 54s · 22 steps · $0.0296 · tokens: 48.9k in / 1.4k out (+452.0k cache-read) · Go: 5h 0% · wk 0% · mo 90%
📜 Full session transcript
@ -82,6 +86,9 @@ public sealed class HandleResolver : IHandleResolver, IDisposablereturn null;}if (_answers is not null && _answers.TryGetValue(handle, out var cached) && Environment.TickCount64 < cached.ExpiresAt)Non-blocking:
Environment.TickCount64makes the TTL untestable and diverges from the rest of the SDK, which usesTimeProvidereverywhere (CachingDidResolver, AuthorizationServerDiscovery, OAuthStateStore...). Suggest accepting aTimeProvider?(defaulting toTimeProvider.System) so expiry can be exercised — the rest of the codebase already has the pattern. Not a merge blocker; filing as a follow-up candidate.@claude /review
64fb4039a56fc044e563🤖 Reviewing this PR — the review will link the full session transcript.
6fc044e56308264614ee🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
✅ Verdict: approve — no blocking issues found.
Re-review: the implementation is unchanged since my previous approval — I re-read
HandleResolver.cs,IdentityFetch.cs,IdentityResolver.cs, and the tests, and they match the reviewed diff exactly. The correctness-sensitive parts still hold: HTTP/2 is requested per-request withRequestVersionOrLower(safe downgrade, works over the custom connect callback, harmless where a caller-supplied handler ignores it); the handle cache stores only positive answers after the HTTPS/DNS conflict check, never "no answer" or a conflict; andResolveUncachedAsyncforgets the claimed handle before verifying, keeping the OAuth post-login path fresh — all covered by tests.The two non-blocking notes from my last pass stand as follow-up candidates, unchanged:
TimeProviderseam forHandleCacheTtl(Environment.TickCount64directly), so TTL expiry isn't testable and the class diverges from the rest of the SDK's timer pattern.Cache.Capacityvalues (theoretical at the default 10 000).Both are worth a follow-up ticket, neither blocks merging. Good to merge.
⏱ 0m 36s · 12 steps · $0.0088 · tokens: 32.6k in / 918 out (+106.4k cache-read) · Go: 5h 1% · wk 0% · mo 91%
📜 Full session transcript
@claude /review
🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
✅ Verdict: approve — no blocking issues found.
The change is small, matches the description, and I found nothing blocking.
RequestVersionOrLowerfalls back to 1.1, and the SSRF connect callback is untouched.LruCache.HandleCacheTtlis validated and zero disables it. Only answers are cached, never "no answer" or a conflict.ResolveUncachedAsynccallsForgeton the claimed handle. This is a no-op for a customIHandleResolver, which is acceptable.PublicAPI.Unshipped.txt: all updated, and the tests cover the new behaviour.Minor, not blocking:
Forgetruns can re-insert its older answer, soResolveUncachedAsyncis not strictly fresh under concurrency. Its own verification still uses a fresh result in that call.🧠 claude-sonnet-5-5 · ⏱ 0m 9s · 3 turns · tokens: 30.4k in / 844 out (+29.1k cache-read)
📜 Full session transcript