Files
maarcadetweet/crates/appview/src/handle_sync.rs
T
tomdebone 3aa5d5c0e3 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.
2026-07-18 17:56:11 +02:00

682 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Background worker that resolves empty `handle` columns in the `posts`
//! table to real `@handle.bsky.social` style strings.
//!
//! ## Why
//!
//! Jetstream events carry no `handle` — only the `did`. The AppView's
//! indexer inserts posts with an empty placeholder (`handle = ''`) and a
//! separate worker is responsible for back-filling it. The UI can render
//! `@<did-prefix>…` as a fallback, but a real handle is far nicer and
//! makes the timeline readable for accounts that post anonymously.
//!
//! ## How
//!
//! `HandleSyncWorker::run_forever()` runs [`Self::run_once`] in a loop,
//! sleeping `interval_secs` between passes. Each pass:
//!
//! 1. Reads up to [`BATCH_SIZE`] distinct DIDs from `posts` where
//! `handle = ''`.
//! 2. For each DID, dispatches by method:
//! * `did:plc:` → [`HandleSyncWorker::plc_resolver`]
//! * `did:web:` → [`HandleSyncWorker::web_resolver`]
//! * anything else (e.g. `did:garbage:`) → skipped
//! A resolver returning `Ok(None)` counts as `skipped`, not `failed`.
//! Before any of these the local PDS is consulted, so a `did:key:`
//! user on this PDS gets resolved without dialing plc.directory.
//! 3. `UPDATE posts SET handle = $1 WHERE did = $2 AND handle = ''` so
//! concurrent syncs (or the `/internal/ingest-commit` path, which can
//! populate handle separately) can't clobber a value written by
//! someone else in the meantime.
//!
//! ## Testability
//!
//! The two resolvers are type-erased `Arc<dyn DidHandleResolver>`s so
//! tests can swap stubs that map DID → handle without hitting the
//! network. `PlcClient` and `WebResolver` are the production impls.
use anyhow::Result;
use at_identity::DidHandleResolver;
use sqlx::PgPool;
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, info, warn};
/// Max DIDs processed per pass. Keeps individual runs bounded so a
/// back-fill of thousands of empty-handle posts doesn't hammer the PLC.
pub const BATCH_SIZE: i64 = 100;
/// Max concurrent handle-resolve network calls per pass. Each
/// DID in a batch triggers a `POST /xrpc/com.atproto.identity
/// .resolveHandle` to the PDS (then PLC, then Web) — serial
/// dispatch would block the worker for `BATCH_SIZE ×
/// per-request-timeout` (worst case ~17 min with the old 10 s
/// timeout). Capped at 8 to bound peak concurrency on the PDS
/// and on the worker's open-socket count.
const DISPATCH_CONCURRENCY: usize = 8;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct SyncReport {
/// Rows whose `handle` column was newly populated this pass.
pub resolved: usize,
/// DIDs where the resolver returned `Err(_)` (network / 5xx).
pub failed: usize,
/// DIDs where the resolver returned `Ok(None)` (unknown DID,
/// unsupported method) **or** rows that already had a non-empty
/// handle when the UPDATE landed.
pub skipped: usize,
}
pub struct HandleSyncWorker {
pub db: PgPool,
/// Local-PDS handle resolver. Consulted first for every DID —
/// the AppView's own PDS is the authoritative source for
/// `did:key:` users and any DID the operator hosts. A 404 from
/// the PDS falls through to the public resolvers below.
pub pds_resolver: Arc<dyn DidHandleResolver>,
pub plc_resolver: Arc<dyn DidHandleResolver>,
pub web_resolver: Arc<dyn DidHandleResolver>,
pub interval_secs: u64,
}
impl HandleSyncWorker {
/// Pick the right resolver based on the DID's method prefix and
/// return its result. The local PDS is consulted first (cheap,
/// authoritative for users on this PDS); the PLC / web resolvers
/// are the fallback for DIDs the PDS doesn't host.
async fn dispatch(&self, did: &str) -> Result<Option<String>> {
// PDS first: the user's home PDS already knows its own
// users — local-PDS users (did:key: or any host that
// doesn't publish to plc.directory) get resolved here
// without a network round trip to third parties.
if let Some(h) = self.pds_resolver.resolve_handle(did).await? {
if !h.is_empty() {
return Ok(Some(h));
}
}
if did.starts_with("did:plc:") {
self.plc_resolver.resolve_handle(did).await
} else if did.starts_with("did:web:") {
self.web_resolver.resolve_handle(did).await
} else {
Ok(None)
}
}
/// Drive [`Self::run_once`] on a fixed-interval loop until the
/// process exits. Intended for `tokio::spawn`.
pub async fn run_forever(self) {
info!(
interval_secs = self.interval_secs,
"handle-sync worker started"
);
loop {
match self.run_once().await {
Ok(report) => {
if report.resolved > 0
|| report.failed > 0
|| report.skipped > 0
{
info!(
resolved = report.resolved,
failed = report.failed,
skipped = report.skipped,
"handle-sync pass complete"
);
} else {
debug!("handle-sync pass: nothing to do");
}
}
Err(e) => {
warn!(error = %e, "handle-sync pass aborted; will retry");
}
}
tokio::time::sleep(Duration::from_secs(self.interval_secs)).await;
}
}
/// One bounded scan: find up to [`BATCH_SIZE`] distinct DIDs whose
/// posts have an empty handle, resolve them, and update the rows
/// where the handle is still empty (race-safe).
///
/// Unresolvable DIDs (e.g. `did:key:` not on the local PDS, or
/// any unknown method) get their empty-handle rows marked with
/// `handle_sync_attempted_at = now()`. The SELECT filter excludes
/// rows attempted within the last hour, so an unresolvable DID
/// dominates at most one batch before the worker advances to
/// other DIDs. The column is reset to NULL when the row's
/// `handle` is filled, so a DID that becomes resolvable later
/// (e.g. the user joins the local PDS) gets re-attempted.
pub async fn run_once(&self) -> Result<SyncReport> {
let dids: Vec<(String,)> = sqlx::query_as(
r#"SELECT DISTINCT did
FROM posts
WHERE handle = ''
AND (handle_sync_attempted_at IS NULL
OR handle_sync_attempted_at < now() - interval '1 hour')
ORDER BY did
LIMIT $1"#,
)
.bind(BATCH_SIZE)
.fetch_all(&self.db)
.await?;
let mut report = SyncReport::default();
if dids.is_empty() {
return Ok(report);
}
// Dispatch in parallel — the PDS / PLC / Web resolvers are
// independent network calls. Capped at `DISPATCH_CONCURRENCY`
// to avoid hammering any single resolver or running out of
// file descriptors under a 100-DID batch with a slow PDS.
// DB writes below are still serial because they share the
// same `posts` rows and the contention cost would outweigh
// the parallel-write benefit at this batch size.
use futures::stream::{self, StreamExt};
let dispatch_results: Vec<(String, anyhow::Result<Option<String>>)> = stream::iter(dids)
.map(|(did,)| async move {
let r = self.dispatch(&did).await;
(did, r)
})
.buffer_unordered(DISPATCH_CONCURRENCY)
.collect()
.await;
for (did, result) in dispatch_results {
match result {
Ok(Some(handle)) => {
if handle.is_empty() {
report.skipped += 1;
mark_attempted(&self.db, &did).await?;
continue;
}
let res = sqlx::query(
"UPDATE posts SET handle = $1, \
handle_sync_attempted_at = NULL \
WHERE did = $2 AND handle = ''",
)
.bind(&handle)
.bind(&did)
.execute(&self.db)
.await?;
if res.rows_affected() > 0 {
report.resolved += res.rows_affected() as usize;
} else {
// Another worker / ingest path already filled it
// between our SELECT and UPDATE.
report.skipped += 1;
}
}
Ok(None) => {
report.skipped += 1;
mark_attempted(&self.db, &did).await?;
}
Err(e) => {
warn!(did = %did, error = %e, "handle resolve failed");
report.failed += 1;
// Mark attempted so a transient PDS outage doesn't
// burn the batch on retries. The next pass (after
// the 1-hour cooldown, or sooner if the worker is
// restarted and the row is still empty) will try
// again.
if let Err(e) = mark_attempted(&self.db, &did).await {
warn!(did = %did, error = %e, "handle_sync mark_attempted failed");
}
}
}
}
Ok(report)
}
}
/// Stamp `handle_sync_attempted_at = now()` on every empty-handle
/// row for `did`. Called after a skip or a failed resolve so the
/// next SELECT pass skips over this DID.
async fn mark_attempted(db: &PgPool, did: &str) -> Result<()> {
sqlx::query(
"UPDATE posts SET handle_sync_attempted_at = now() \
WHERE did = $1 AND handle = ''",
)
.bind(did)
.execute(db)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use tokio::time::timeout;
/// Stub resolver driven by a fixed DID → handle map. Counts how
/// many times each DID was queried so the limit test can assert
/// the worker capped the batch correctly.
struct StubResolver {
mapping: Mutex<HashMap<String, Option<String>>>,
queried: Mutex<Vec<String>>,
}
impl StubResolver {
fn new(mapping: HashMap<String, Option<String>>) -> Self {
Self {
mapping: Mutex::new(mapping),
queried: Mutex::new(Vec::new()),
}
}
fn into_arc(self) -> Arc<dyn DidHandleResolver> {
Arc::new(self)
}
}
#[async_trait]
impl DidHandleResolver for StubResolver {
async fn resolve_handle(&self, did: &str) -> Result<Option<String>> {
self.queried.lock().unwrap().push(did.to_string());
Ok(self.mapping.lock().unwrap().get(did).cloned().flatten())
}
}
/// Convenience: a worker whose `plc_resolver` and `web_resolver`
/// both point at the same stub. Existing tests don't care which
/// method the DIDs use because the stub is method-agnostic.
fn worker_with(db: PgPool, stub: Arc<dyn DidHandleResolver>) -> HandleSyncWorker {
HandleSyncWorker {
db,
pds_resolver: Arc::clone(&stub),
plc_resolver: Arc::clone(&stub),
web_resolver: Arc::clone(&stub),
interval_secs: 999,
}
}
async fn try_test_db() -> Option<PgPool> {
let url = std::env::var("DATABASE_URL_APPVIEW").ok()?;
match timeout(Duration::from_secs(2), sqlx::PgPool::connect(&url)).await {
Ok(Ok(pool)) => match sqlx::migrate!("../../migrations/appview")
.run(&pool)
.await
{
Ok(()) => Some(pool),
Err(_) => None,
},
_ => None,
}
}
fn unique_did(suffix: &str) -> String {
format!("did:plc:stubsync_{}_{}", suffix, uuid::Uuid::new_v4().simple())
}
async fn seed_post(
db: &PgPool,
did: &str,
rkey: &str,
handle: &str,
) -> Result<()> {
let uri = format!("at://{did}/app.twi.post/{rkey}");
sqlx::query(
r#"INSERT INTO posts
(uri, did, handle, rkey, collection, text, cid,
parent_uri, root_uri, langs, created_at)
VALUES ($1,$2,$3,$4,'app.twi.post','stub','bafy',NULL,NULL,NULL, now())
ON CONFLICT (uri) DO NOTHING"#,
)
.bind(&uri)
.bind(did)
.bind(handle)
.bind(rkey)
.execute(db)
.await?;
Ok(())
}
async fn get_handle(db: &PgPool, did: &str) -> Option<String> {
sqlx::query_scalar::<_, String>(
"SELECT handle FROM posts WHERE did = $1 ORDER BY indexed_at DESC LIMIT 1",
)
.bind(did)
.fetch_optional(db)
.await
.ok()
.flatten()
.filter(|s| !s.is_empty())
}
/// Smoke test: a stub resolver maps one DID → handle. After
/// `run_once()` the worker should populate the `handle` column on
/// every empty post for that DID and report it as `resolved`.
#[tokio::test]
async fn run_once_returns_report() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = unique_did("report");
// Wipe any previous stub rows for this slot.
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
// Seed two posts for the same DID, both with empty handle.
for rk in ["rka", "rkb"] {
seed_post(&db, &did, rk, "").await.unwrap();
}
let expected_handle = format!("handle.{}", uuid::Uuid::new_v4().simple());
let resolver = StubResolver::new(HashMap::from([(
did.clone(),
Some(expected_handle.clone()),
)]))
.into_arc();
let worker = worker_with(db, resolver);
let report = worker.run_once().await.unwrap();
assert!(report.resolved >= 2, "expected ≥2 resolved, got {report:?}");
assert_eq!(report.failed, 0);
assert_eq!(report.skipped, 0);
let h = get_handle(&worker.db, &did).await;
assert_eq!(h.as_deref(), Some(expected_handle.as_str()));
}
/// DIDs that already have a non-empty handle on **every** post must
/// not be re-queried — the worker scans `WHERE handle = ''`.
#[tokio::test]
async fn skip_dids_with_handle() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = unique_did("skip");
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
// Insert one post with a pre-filled handle.
seed_post(&db, &did, "rkA", "pre-existing.handle").await.unwrap();
// The stub would return a different handle if asked.
let resolver = StubResolver::new(HashMap::from([(
did.clone(),
Some("different.handle".into()),
)]))
.into_arc();
let worker = worker_with(db, resolver);
let report = worker.run_once().await.unwrap();
assert_eq!(report.resolved, 0);
assert_eq!(report.failed, 0);
assert_eq!(report.skipped, 0); // DID was already filtered out by the SELECT
let h = get_handle(&worker.db, &did).await;
assert_eq!(h.as_deref(), Some("pre-existing.handle"));
}
/// When `run_once()` finds more than [`BATCH_SIZE`] empty-handle DIDs,
/// only the first batch is processed this pass; the rest stay empty
/// for the next pass.
#[tokio::test]
async fn respects_batch_limit() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
// Seed BATCH_SIZE + 5 distinct DIDs, all empty handles. We
// can't seed 100+000 for real so we use the BATCH_SIZE bound
// directly. The same code path runs with any row count.
let prefix = unique_did("batch");
let mut all_dids = Vec::new();
// Insert a separate DIDs table-style marker so we can clean
// them all up afterwards without touching other test data.
for i in 0..(BATCH_SIZE as usize + 5) {
let did = format!("{prefix}_{i}");
all_dids.push(did.clone());
seed_post(&db, &did, "rk", "").await.unwrap();
}
let mut mapping = HashMap::new();
for did in &all_dids {
mapping.insert(did.clone(), Some(format!("h.{}", &did[did.len()-6..])));
}
let resolver = StubResolver::new(mapping).into_arc();
let worker = HandleSyncWorker {
db: db.clone(),
pds_resolver: Arc::clone(&resolver),
plc_resolver: Arc::clone(&resolver),
web_resolver: Arc::clone(&resolver),
interval_secs: 999,
};
let report = worker.run_once().await.unwrap();
// Exactly BATCH_SIZE rows updated (one post per DID).
assert_eq!(
report.resolved as i64,
BATCH_SIZE,
"resolved should equal batch size: {report:?}"
);
assert_eq!(report.failed, 0);
// The remaining 5 DIDs must still have empty handles.
let remaining: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM posts WHERE handle = '' AND did LIKE $1")
.bind(format!("{prefix}_%"))
.fetch_one(&db)
.await
.unwrap();
assert_eq!(
remaining, 5,
"expected 5 unresolved DIDs left, got {remaining}"
);
// Cleanup so repeated test runs stay hygienic.
let _ = sqlx::query("DELETE FROM posts WHERE did LIKE $1")
.bind(format!("{prefix}_%"))
.execute(&db)
.await;
}
/// The `UPDATE … WHERE handle = ''` clause must guard against races
/// with another writer: if /internal/ingest-commit fills the
/// handle between our SELECT and UPDATE, our UPDATE is a no-op.
#[tokio::test]
async fn update_does_not_overwrite_concurrent_write() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = unique_did("race");
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
seed_post(&db, &did, "rk", "").await.unwrap();
let resolver = StubResolver::new(HashMap::from([(
did.clone(),
Some("from-resolver".into()),
)]))
.into_arc();
let worker = HandleSyncWorker {
db: db.clone(),
pds_resolver: Arc::clone(&resolver),
plc_resolver: Arc::clone(&resolver),
web_resolver: Arc::clone(&resolver),
interval_secs: 999,
};
// Concurrent writer: race with the worker's UPDATE by setting
// handle directly while run_once is reading it.
// In practice the SELECT happens first, so the UPDATE WHERE
// clause is what protects us. Simulate the "other writer won"
// outcome directly here: write a handle, then call run_once —
// since the SELECT excludes non-empty rows, run_once sees an
// empty batch.
sqlx::query("UPDATE posts SET handle = 'from-ingest' WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
let report = worker.run_once().await.unwrap();
assert_eq!(report.resolved, 0, "must not touch already-handled rows");
let h = get_handle(&worker.db, &did).await;
assert_eq!(h.as_deref(), Some("from-ingest"));
}
/// Dispatch test: a `did:web:` DID must be routed to the
/// `web_resolver` (not the PLC one). Without this routing, every
/// `did:web:` post would stay `@<did-prefix>…` forever.
#[tokio::test]
async fn dispatches_did_web_to_web_resolver() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = format!("did:web:example.com:user:{}", uuid::Uuid::new_v4().simple());
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
seed_post(&db, &did, "rk", "").await.unwrap();
// Two stubs that disagree on the answer. The dispatcher's
// job is to pick the right one based on the DID method.
let plc = StubResolver::new(HashMap::from([(
did.clone(),
Some("WRONG-PLC-HANDLE".into()),
)]))
.into_arc();
let web = StubResolver::new(HashMap::from([(
did.clone(),
Some("web-handle.example.com".into()),
)]))
.into_arc();
let worker = HandleSyncWorker {
db: db.clone(),
pds_resolver: Arc::clone(&plc),
plc_resolver: plc,
web_resolver: web,
interval_secs: 999,
};
let report = worker.run_once().await.unwrap();
assert_eq!(
report.resolved, 1,
"did:web must resolve through the web resolver, got {report:?}"
);
assert_eq!(
report.failed, 0,
"did:web must not route to the PLC resolver"
);
let h = get_handle(&worker.db, &did).await;
assert_eq!(h.as_deref(), Some("web-handle.example.com"));
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
}
/// DIDs whose method isn't `did:plc:` or `did:web:` (e.g. `did:key:`)
/// are silently skipped — neither resolver is consulted.
#[tokio::test]
async fn unknown_methods_are_skipped() {
let Some(db) = try_test_db().await else {
eprintln!("appview DB unavailable; skipping");
return;
};
let did = format!("did:key:z{}", uuid::Uuid::new_v4().simple());
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await
.unwrap();
seed_post(&db, &did, "rk", "").await.unwrap();
// Both stubs would happily return a handle if asked. The
// dispatcher's prefix check must prevent that — neither
// resolver should ever see a `did:key:` DID. We share the
// query logs via `Arc<Mutex<...>>` so the test can read them
// back after the worker has run.
let plc_log: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let web_log: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let plc = TrackingResolver::new(
HashMap::from([(did.clone(), Some("plc-handle".into()))]),
Arc::clone(&plc_log),
);
let web = TrackingResolver::new(
HashMap::from([(did.clone(), Some("web-handle".into()))]),
Arc::clone(&web_log),
);
let plc_arc: Arc<dyn DidHandleResolver> = Arc::new(plc);
let web_arc: Arc<dyn DidHandleResolver> = Arc::new(web);
let worker = HandleSyncWorker {
db: db.clone(),
pds_resolver: Arc::clone(&plc_arc),
plc_resolver: plc_arc,
web_resolver: web_arc,
interval_secs: 999,
};
let report = worker.run_once().await.unwrap();
assert_eq!(report.resolved, 0, "did:key must not resolve, got {report:?}");
assert_eq!(
report.failed, 0,
"did:key must not be treated as a failure"
);
assert_eq!(report.skipped, 1, "did:key must be skipped");
// No resolver was consulted.
assert!(
plc_log.lock().unwrap().is_empty(),
"PLC resolver must not be called for did:key"
);
assert!(
web_log.lock().unwrap().is_empty(),
"web resolver must not be called for did:key"
);
let _ = sqlx::query("DELETE FROM posts WHERE did = $1")
.bind(&did)
.execute(&db)
.await;
}
/// Stub resolver that records every DID it's queried into a
/// shared log so tests can verify dispatch routing. Distinct
/// from `StubResolver`, which owns its log and would need
/// downcasting to read it back through an `Arc<dyn _>`.
struct TrackingResolver {
mapping: HashMap<String, Option<String>>,
queried: Arc<Mutex<Vec<String>>>,
}
impl TrackingResolver {
fn new(
mapping: HashMap<String, Option<String>>,
queried: Arc<Mutex<Vec<String>>>,
) -> Self {
Self { mapping, queried }
}
}
#[async_trait]
impl DidHandleResolver for TrackingResolver {
async fn resolve_handle(&self, did: &str) -> Result<Option<String>> {
self.queried.lock().unwrap().push(did.to_string());
Ok(self.mapping.get(did).cloned().flatten())
}
}
}