feat(at-identity): PdsHandleResolver for cluster-local DID→handle resolution
The AppView's handle_sync worker consulted the public PLC directory and the did:web: HTTPS resolver only. DIDs hosted on the local PDS (notably did🔑 users and any other operator-hosted method) weren't reachable without an external round trip, and unresolvable DIDs (did🔑 not on this PDS, did:foo: anything) blocked the 100-row batch forever because did🔑 sorts lexicographically before did:plc: / did:web:. This commit adds: * `PdsHandleResolver` (at-identity) — POSTs the DID as the `handle` field to the PDS's resolveHandle XRPC method. The PDS now recognises a `did:` prefix and does a PK lookup on `users.did`, returning `{did, handle}`. The resolver reads the `handle` field, so the AppView finally gets a real local handle for did🔑 users without ever dialing plc.directory. * A 2 s timeout per request (was 10 s) and `DISPATCH_CONCURRENCY = 8` so the worker caps a 100-DID batch at ~2 s with parallel dispatch instead of the ~17 min worst case the old serial + 10 s setup allowed. * A new `posts.handle_sync_attempted_at` column (migration 0006) and `mark_attempted()` helper. The SELECT filter excludes rows attempted within the last hour, so an unresolvable DID dominates at most one batch before the worker advances. Cleared on success. * `PDS_INTERNAL_URL` config so the AppView can reach the PDS via a cluster-internal hostname when the public URL isn't routable from inside the cluster. Tests: * `crates/at-identity/src/pds_handle.rs` — 4 unit tests against a stub HTTP server (200/404/5xx/missing-did-field). * Existing handle_sync integration tests updated to wire in the new `pds_resolver` field.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
pub mod handle;
|
||||
pub mod pds_handle;
|
||||
pub mod plc;
|
||||
pub mod web;
|
||||
|
||||
pub use handle::{resolve_handle, DidHandleResolver, HandleResolver};
|
||||
pub use pds_handle::PdsHandleResolver;
|
||||
pub use plc::{submit_op, PlcClient};
|
||||
pub use web::WebResolver;
|
||||
@@ -0,0 +1,184 @@
|
||||
//! PDS-first handle resolver.
|
||||
//!
|
||||
//! The local PDS is the authoritative source for `did:key:` users (and
|
||||
//! for any DID the operator hosts on this PDS). The `AppView`'s
|
||||
//! `handle_sync` worker consults this resolver *before* falling through
|
||||
//! to the public PLC directory / `did:web:` HTTPS resolver, so local
|
||||
//! users get their handle without ever dialing out to plc.directory.
|
||||
//!
|
||||
//! ### Wire shape
|
||||
//!
|
||||
//! PDS endpoint: `POST /xrpc/com.atproto.identity.resolveHandle`
|
||||
//! with body `{ "handle": "<did>" }`. The PDS's handler is
|
||||
//! polymorphic on the `handle` field: if it starts with `did:`
|
||||
//! the PDS looks the row up by `did` (PK), otherwise by `handle`.
|
||||
//! On success the PDS returns `{ "did": "...", "handle": "..." }`
|
||||
//! — we read the `handle` field, which is what the worker
|
||||
//! actually needs to fill `posts.handle`. A 404 means the DID
|
||||
//! isn't hosted here, and the worker falls through to PLC/Web.
|
||||
//!
|
||||
//! Network: `reqwest::Client` with a 10 s timeout. The same client
|
||||
//! is reused across requests — handle with `Arc<PdsHandleResolver>`
|
||||
//! in the worker.
|
||||
|
||||
use crate::handle::DidHandleResolver;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct PdsHandleResolver {
|
||||
pub base_url: String,
|
||||
pub client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl PdsHandleResolver {
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
// 2 s is plenty for a colocated PDS (typical round-trip
|
||||
// < 100 ms in dev) but bounds the per-DID cost when the
|
||||
// PDS is unreachable — at 100 DIDs/pass that caps a
|
||||
// single pass at ~2 s with parallel dispatch, vs the
|
||||
// ~17 min worst-case the old 10 s timeout allowed with
|
||||
// serial dispatch.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.expect("reqwest client build should never fail");
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
client,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DidHandleResolver for PdsHandleResolver {
|
||||
async fn resolve_handle(&self, did: &str) -> Result<Option<String>> {
|
||||
// POST /xrpc/com.atproto.identity.resolveHandle with
|
||||
// { "handle": "<did>" } in the body. The PDS's handler
|
||||
// recognises a `did:` prefix and does a PK lookup on
|
||||
// `users.did`, returning `{ did, handle }` on match.
|
||||
let url = format!(
|
||||
"{}/xrpc/com.atproto.identity.resolveHandle",
|
||||
self.base_url
|
||||
);
|
||||
let r = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&json!({ "handle": did }))
|
||||
.send()
|
||||
.await?;
|
||||
if r.status().as_u16() == 404 {
|
||||
return Ok(None);
|
||||
}
|
||||
if !r.status().is_success() {
|
||||
// Non-2xx, non-404: a real error — propagate it so the
|
||||
// worker logs `failed` instead of silently treating the
|
||||
// DID as `skipped`.
|
||||
anyhow::bail!(
|
||||
"pds handle resolver returned {}",
|
||||
r.status()
|
||||
);
|
||||
}
|
||||
let v: serde_json::Value = r.json().await?;
|
||||
// The PDS returns `{ "did": "...", "handle": "..." }` on
|
||||
// success. We read `handle` (what we actually want for
|
||||
// `posts.handle`) and ignore the echoed `did`.
|
||||
Ok(v.get("handle").and_then(|x| x.as_str()).map(String::from))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
/// Spawn a one-shot HTTP listener that responds to the
|
||||
/// resolveHandle XRPC call with the configured body + status.
|
||||
/// Returns the bound address (so the resolver under test can hit
|
||||
/// `http://127.0.0.1:<port>`).
|
||||
async fn spawn_stub(
|
||||
status: u16,
|
||||
body: serde_json::Value,
|
||||
) -> SocketAddr {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
loop {
|
||||
let (mut sock, _) = match listener.accept().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => break,
|
||||
};
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
let body_s = body.to_string();
|
||||
let resp = format!(
|
||||
"HTTP/1.1 {status} {}\r\n\
|
||||
Content-Type: application/json\r\n\
|
||||
Content-Length: {}\r\n\
|
||||
Connection: close\r\n\r\n{body_s}",
|
||||
status_text(status),
|
||||
body_s.len(),
|
||||
);
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
fn status_text(s: u16) -> &'static str {
|
||||
match s {
|
||||
200 => "OK",
|
||||
404 => "Not Found",
|
||||
500 => "Internal Server Error",
|
||||
_ => "Status",
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_handle_on_match() {
|
||||
let addr = spawn_stub(
|
||||
200,
|
||||
json!({ "did": "did:plc:abc", "handle": "alice.bsky" }),
|
||||
)
|
||||
.await;
|
||||
let r = PdsHandleResolver::new(format!("http://{addr}"));
|
||||
let h = r.resolve_handle("did:plc:abc").await.unwrap();
|
||||
assert_eq!(h.as_deref(), Some("alice.bsky"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_none_on_404() {
|
||||
let addr = spawn_stub(404, json!({ "error": "NotFound" })).await;
|
||||
let r = PdsHandleResolver::new(format!("http://{addr}"));
|
||||
let h = r.resolve_handle("did:plc:unknown").await.unwrap();
|
||||
assert!(h.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_err_on_5xx() {
|
||||
let addr = spawn_stub(500, json!({ "error": "oops" })).await;
|
||||
let r = PdsHandleResolver::new(format!("http://{addr}"));
|
||||
let result = r.resolve_handle("did:plc:abc").await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"5xx must propagate as Err so the worker logs `failed`, not `skipped`"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_handle_even_when_did_field_missing() {
|
||||
// Some PDS implementations might return `{ "handle": "x" }`
|
||||
// without echoing the DID. We must still extract the handle.
|
||||
let addr = spawn_stub(200, json!({ "handle": "bob.bsky" })).await;
|
||||
let r = PdsHandleResolver::new(format!("http://{addr}"));
|
||||
let h = r.resolve_handle("did:plc:bob").await.unwrap();
|
||||
assert_eq!(h.as_deref(), Some("bob.bsky"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user