diff --git a/src/clients/atproto/handles.rs b/src/clients/atproto/handles.rs new file mode 100644 index 0000000..4449af0 --- /dev/null +++ b/src/clients/atproto/handles.rs @@ -0,0 +1,335 @@ +//! DID to handle, once per process, for the columns that display one. +//! +//! Four listings resolved author handles with four copies of the same +//! `buffer_unordered` over [`did::handle_from_did_doc`] — `pr list`, `issue +//! list`, `search`, and the comment thread under a `view`. They differed in +//! the parts nobody meant them to differ in (whether the fallback was the +//! DID or an absent column, whether the concurrency was a named constant) +//! and agreed on the part that mattered, which is that each one re-fetched a +//! DID document for an author the same invocation had already looked up +//! moments earlier. This is the one copy, and it remembers. +//! +//! # The cache, and everything it is not +//! +//! A handle is not evidence. From [`crate::docs::architecture`]: an account +//! can rename, and a released handle can be re-registered by somebody else, +//! so a cached handle is a hint and never proof. Everything below follows +//! from taking that seriously, and each rule is here because breaking it +//! would be a correctness bug long before anyone noticed a display glitch. +//! +//! **Process lifetime, and nothing longer.** Nothing here is written to +//! disk: no file under `~/.config/atgc`, no sidecar beside the session +//! store, nothing that survives the process. A CLI invocation is seconds +//! long, and seconds is the entire window in which "this DID's handle was +//! `@alice.example` a moment ago" is safe to reuse. Persisting it would +//! carry a claim about somebody's identity forward across days, over a +//! rename or a re-registration that atgc has no way to hear about, and the +//! first symptom would be atgc naming the wrong person in output that a user +//! then acts on. The map lives in a `static` and dies with the process. +//! +//! **DID to handle only.** The DID is the stable key — it never changes +//! hands — and the handle hanging off it is decoration: a column, a label, a +//! `--json` field. Reusing it within one invocation risks showing a name +//! that went stale in the last two seconds. The opposite direction is not +//! decoration at all, so it is not here: see below. +//! +//! **Never keyed by anything an account can shadow.** The key is a bare DID, +//! which is globally unique, and the value comes from the PLC directory or +//! the `did:web` host itself — a public document that reads the same for +//! every caller. There is no per-account view of it to collide, which is +//! what makes one process-wide map safe here and would *not* make one safe +//! for anything read out of a PDS. Nothing account-scoped may be added to +//! this map later; it would need the acting DID in the key, and at that +//! point it belongs in its own store. +//! +//! **Bounded.** The DIDs come off records written by strangers, so an +//! unbounded map is a memory-growth path with somebody else's `--limit 1000` +//! at the wheel. [`CAPACITY`] entries are remembered and the rest are +//! resolved and returned without being kept: past the cap the cache stops +//! growing rather than evicting, because an eviction policy would be a +//! guess about which stranger's handle is worth keeping, and the answer +//! either way is one extra request on a page that is already enormous. +//! +//! **Failures are never remembered.** Only a resolved handle goes in the +//! map. A DID document that would not fetch because plc.directory was +//! briefly down, or because a `did:web` host timed out, must not read as +//! "that account has no handle" for the rest of the run — the next caller +//! asks again and may get a real answer. The cost of the retry is bounded +//! by the same cap on lookups every caller already had. +//! +//! # Handle to DID is not here on purpose +//! +//! [`did::resolve_handle`] is the other direction, and it is not a display +//! question: it decides which account atgc acts as, whose PDS a record is +//! written to, and whose repository a listing is about. Every call site goes +//! through [`crate::config::account`], where a handle that now resolves to a +//! DID other than the cached one is a *refusal*, not a repair — the case +//! where a handle changed hands is exactly the case that must not be +//! answered from memory. +//! +//! So it is not memoized at all, here or anywhere. Not "cached carefully", +//! not "cached for authority-free callers": an invocation resolves a handle +//! afresh every time it needs one, which in practice is once. There is no +//! saving worth the class of bug on the other side of it. + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +use futures_util::stream::{self, StreamExt}; + +use crate::clients::atproto::did; + +/// How many identity lookups may be in flight at once. +/// +/// The lookups are per *unique* DID rather than per row, so this is already +/// a small number: a page of thirty rows is rarely more than a handful of +/// authors. Bounded anyway, because the fan-out reaches plc.directory and +/// hosts named by other people's records, and a listing is not entitled to +/// open as many sockets as it happens to have rows. Unordered, because every +/// caller collects into a map keyed by the DID and none of them reads the +/// sequence. +pub(crate) const CONCURRENCY: usize = 8; + +/// How many handles the process remembers. +/// +/// Not a tuning knob so much as a ceiling on how much memory a page of +/// somebody else's records can make atgc hold. A thousand distinct authors +/// is already an unusual session; past this the lookups still happen and the +/// answers are still returned, they are simply not kept. +const CAPACITY: usize = 1024; + +/// The process-lifetime memo: DID to handle, successes only. +/// +/// A `Mutex` and not something cleverer. It is held for the length +/// of a map lookup, never across an `await`, and the contention ceiling is +/// [`CONCURRENCY`] tasks. +struct Memo(Mutex>); + +impl Memo { + fn new() -> Self { + Memo(Mutex::new(HashMap::new())) + } + + fn get(&self, did: &str) -> Option { + self.0.lock().ok()?.get(did).cloned() + } + + /// Remember a resolved handle, unless the map is full. + /// + /// A poisoned lock is treated as no cache rather than a panic: this is + /// memoization of a display string, and the failure mode of skipping it + /// is one extra request. + fn remember(&self, did: &str, handle: &str) { + let Ok(mut map) = self.0.lock() else { return }; + if map.len() >= CAPACITY && !map.contains_key(did) { + return; + } + map.insert(did.to_string(), handle.to_string()); + } +} + +fn shared() -> &'static Memo { + static MEMO: OnceLock = OnceLock::new(); + MEMO.get_or_init(Memo::new) +} + +/// One DID's handle, from the memo or from its DID document. +pub(crate) async fn handle(did: &str) -> Option { + one(shared(), did, fetch).await +} + +/// The handle per unique DID, resolved together, missing where it would not +/// resolve. +/// +/// Bare, with no leading `@`: docs/output.md's rule for `--json`, and a +/// caller that wants the decoration adds it. Deduplication is the whole +/// point of taking the DIDs in bulk — the loops this replaced each skipped a +/// DID they had already seen, and dropping that would turn one lookup per +/// author into one per row — and the memo extends the same saving across +/// calls, so a `pr view` that prints an author and then a thread of that +/// author's comments resolves them once. +pub(crate) async fn handles(dids: impl IntoIterator) -> HashMap { + resolve(shared(), dids, fetch).await +} + +/// The fetch the real callers use: the account's DID document. +async fn fetch(did: String) -> Option { + did::handle_from_did_doc(&did).await +} + +/// [`handle`], against a given memo and fetcher. +async fn one(memo: &Memo, did: &str, fetch: F) -> Option +where + F: Fn(String) -> Fut, + Fut: Future>, +{ + if let Some(cached) = memo.get(did) { + return Some(cached); + } + let handle = fetch(did.to_string()).await?; + memo.remember(did, &handle); + Some(handle) +} + +/// [`handles`], against a given memo and fetcher. +/// +/// The seam the tests drive: caching behaviour is decided here, and +/// docs/testing.md's hermetic rule means no test may reach the network to +/// observe it. Hits are answered from the memo before the stream is built, +/// so only the misses are ever counted against [`CONCURRENCY`]. +async fn resolve( + memo: &Memo, + dids: impl IntoIterator, + fetch: F, +) -> HashMap +where + F: Fn(String) -> Fut, + Fut: Future>, +{ + let mut known: HashMap = HashMap::new(); + let mut wanted: Vec = Vec::new(); + for did in dids { + if did.is_empty() || known.contains_key(&did) || wanted.contains(&did) { + continue; + } + match memo.get(&did) { + Some(handle) => { + known.insert(did, handle); + } + None => wanted.push(did), + } + } + crate::logging::debug::log(format!( + "handles: {} to resolve, {} already known, {CONCURRENCY} at a time", + wanted.len(), + known.len() + )); + let fetched: Vec<(String, Option)> = stream::iter(wanted.into_iter().map(|did| { + let pending = fetch(did.clone()); + async move { (did, pending.await) } + })) + .buffer_unordered(CONCURRENCY) + .collect() + .await; + for (did, handle) in fetched { + // A failed lookup is dropped rather than recorded: see the module + // doc on why a transient outage must not become a permanent "no + // such handle" for the rest of the process. + if let Some(handle) = handle { + memo.remember(&did, &handle); + known.insert(did, handle); + } + } + known +} + +#[cfg(test)] +mod tests { + use super::{CAPACITY, Memo, one, resolve}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// A fetcher that answers `.example` and counts its calls, so a + /// test can tell a memo hit from a lookup without a socket — which is + /// docs/testing.md's rule, and the reason [`resolve`] takes the fetch + /// as a parameter at all. + fn counting(calls: &AtomicUsize) -> impl Fn(String) -> std::future::Ready> { + move |did: String| { + calls.fetch_add(1, Ordering::SeqCst); + std::future::ready(Some(format!("{did}.example"))) + } + } + + /// The saving the four copies did not have: a DID resolved once is not + /// resolved again, within a page or across calls. + #[tokio::test] + async fn a_did_is_resolved_once_per_process() { + let memo = Memo::new(); + let calls = AtomicUsize::new(0); + let fetch = counting(&calls); + + let first = resolve( + &memo, + ["did:plc:a", "did:plc:b", "did:plc:a"].map(String::from), + &fetch, + ) + .await; + assert_eq!(first.len(), 2, "the repeat is one lookup, not two"); + assert_eq!(calls.load(Ordering::SeqCst), 2); + + let again = resolve(&memo, ["did:plc:a", "did:plc:b"].map(String::from), &fetch).await; + assert_eq!(again, first); + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "the second call is answered from the memo" + ); + + assert_eq!( + one(&memo, "did:plc:a", &fetch).await.as_deref(), + Some("did:plc:a.example") + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "a single lookup reads the same memo the bulk one filled" + ); + } + + /// A lookup that failed because something was down must not read as + /// "that account has no handle" for the rest of the run. The next + /// caller asks again, and gets the real answer when it is available. + #[tokio::test] + async fn a_failure_is_never_remembered() { + let memo = Memo::new(); + let calls = AtomicUsize::new(0); + let down = |did: String| { + let nth = calls.fetch_add(1, Ordering::SeqCst) + 1; + std::future::ready((nth > 2).then(|| format!("{did}.example"))) + }; + + assert!( + resolve(&memo, [String::from("did:plc:a")], &down) + .await + .is_empty(), + "nothing resolved, nothing returned" + ); + assert!( + resolve(&memo, [String::from("did:plc:a")], &down) + .await + .is_empty(), + "still down, still asked" + ); + + let out = resolve(&memo, [String::from("did:plc:a")], &down).await; + assert_eq!( + out.get("did:plc:a").map(String::as_str), + Some("did:plc:a.example"), + "the outage was transient and the third ask sees through it" + ); + assert_eq!(calls.load(Ordering::SeqCst), 3); + } + + /// The map stops growing at the cap. The lookups past it still happen + /// and the answers are still returned — what is given up is only the + /// memory, and only on a page nobody reads by hand. + #[tokio::test] + async fn the_memo_is_bounded() { + let memo = Memo::new(); + let calls = AtomicUsize::new(0); + let fetch = counting(&calls); + + let filled: Vec = (0..CAPACITY + 2).map(|i| format!("did:plc:{i}")).collect(); + let out = resolve(&memo, filled.clone(), &fetch).await; + assert_eq!(out.len(), CAPACITY + 2, "every one of them was answered"); + assert_eq!(memo.0.lock().expect("the memo").len(), CAPACITY); + + let before = calls.load(Ordering::SeqCst); + let last = filled.last().expect("a DID").clone(); + resolve(&memo, [last], &fetch).await; + assert_eq!( + calls.load(Ordering::SeqCst), + before + 1, + "an entry past the cap was never kept, so it is fetched again" + ); + } +} diff --git a/src/clients/atproto/mod.rs b/src/clients/atproto/mod.rs index 9a9dde6..084a0a5 100644 --- a/src/clients/atproto/mod.rs +++ b/src/clients/atproto/mod.rs @@ -7,11 +7,18 @@ //! precondition, because every edit in atgc is a read-modify-write and two //! of them at once must not both report success. //! +//! - [`mod@handles`] — the one DID-to-handle resolver, memoized for the +//! length of the process and no longer. Every column that displays an +//! author's handle comes through it; its doc comment is where the caching +//! rules are argued, including the ones about what is deliberately not +//! cached. +//! //! Which PDS to ask is read out of the account's DID document. What a DID //! *is* — as opposed to what its document says — is //! [`crate::lexicon::identity`], which answers without a round trip. pub(crate) mod did; +pub(crate) mod handles; pub(crate) mod oauth; pub(crate) mod pds; pub(crate) mod record; diff --git a/src/cmd/issue/read.rs b/src/cmd/issue/read.rs index 52da0b1..496d563 100644 --- a/src/cmd/issue/read.rs +++ b/src/cmd/issue/read.rs @@ -716,29 +716,14 @@ pub(crate) struct ListArgs { /// The bare handle per unique author on the page, keyed by DID. /// /// Bare, with no leading `@`: docs/output.md's rule for `--json`, and the -/// table adds the decoration itself where it wants it. -/// -/// One lookup per distinct account, run together. A PDS-only listing is one -/// account and so one lookup; an index listing on a busy repo is a handful. -/// The DID is kept as the fallback, since a row whose handle will not resolve -/// is still a row. +/// table adds the decoration itself where it wants it — which is what +/// [`crate::clients::atproto::handles::handles`] returns, one lookup per +/// distinct account and a row whose handle will not resolve simply absent +/// from the map. A PDS-only listing is one account and so one lookup; an +/// index listing on a busy repo is a handful. async fn author_handles(rows: &[Listed]) -> HashMap { - use futures_util::stream::{self, StreamExt}; - let mut dids: Vec = Vec::new(); - for row in rows { - let did = row.author_did(); - if !did.is_empty() && !dids.iter().any(|d| d == did) { - dids.push(did.to_string()); - } - } - stream::iter(dids.into_iter().map(|did| async move { - let handle = crate::clients::atproto::did::handle_from_did_doc(&did).await; - (did, handle) - })) - .buffer_unordered(8) - .filter_map(|(did, handle)| async move { handle.map(|h| (did, h)) }) - .collect() - .await + crate::clients::atproto::handles::handles(rows.iter().map(|row| row.author_did().to_string())) + .await } /// Fold an index's rows into the PDS's, keyed by at-uri. @@ -1293,7 +1278,7 @@ pub(crate) async fn view(args: ViewArgs) -> Result<()> { let issue = fetch_issue(&target).await?; let state = state_of(&issue, acting.as_deref()).await?; - let handle = crate::clients::atproto::did::handle_from_did_doc(&issue.author).await; + let handle = crate::clients::atproto::handles::handle(&issue.author).await; let thread = crate::cmd::read_thread( args.comments, crate::cmd::pr::read::Source::parse_arg(args.source.as_deref())?, diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index d1244fd..0e17272 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -114,24 +114,14 @@ pub(crate) async fn read_thread( const THREAD_LIMIT: u32 = 100; /// The handles for a thread, resolved per unique commenter. +/// +/// A thread is where the shared memo pays best: the view above it has +/// usually just resolved the author, and a conversation is a handful of +/// people saying several things each. async fn thread_handles( comments: &[crate::clients::tangled::comments::Comment], ) -> std::collections::HashMap { - use futures_util::stream::{self, StreamExt}; - let mut dids: Vec = Vec::new(); - for c in comments { - if !c.author_did.is_empty() && !dids.contains(&c.author_did) { - dids.push(c.author_did.clone()); - } - } - stream::iter(dids.into_iter().map(|did| async move { - let handle = crate::clients::atproto::did::handle_from_did_doc(&did).await; - (did, handle) - })) - .buffer_unordered(8) - .filter_map(|(did, handle)| async move { handle.map(|h| (did, h)) }) - .collect() - .await + crate::clients::atproto::handles::handles(comments.iter().map(|c| c.author_did.clone())).await } /// The thread as `--json` carries it, or `None` when nobody asked. diff --git a/src/cmd/pr/read.rs b/src/cmd/pr/read.rs index 3fe7f84..0f9ef4f 100644 --- a/src/cmd/pr/read.rs +++ b/src/cmd/pr/read.rs @@ -1940,7 +1940,7 @@ async fn across_repos(args: ListArgs) -> Result<()> { // so this handle is resolved for the JSON alone, once for the page. // It is what keeps the row shape genuinely the same as `pr list // --json`'s rather than the same minus a field. - let subject_handle = crate::clients::atproto::did::handle_from_did_doc(&did).await; + let subject_handle = crate::clients::atproto::handles::handle(&did).await; let rows: Vec = items .iter() .map(|item| { @@ -1992,39 +1992,31 @@ async fn across_repos(args: ListArgs) -> Result<()> { /// How many identity lookups a listing may have in flight at once. /// -/// The columns a page needs are per *unique* DID, not per row, so this is -/// already a small number: a page of thirty rows is rarely more than a -/// handful of authors or repos. Bounded anyway, because the lookups fan out -/// to plc.directory and to the appview and a listing is not entitled to open -/// as many sockets as it happens to have rows. Unordered, because both -/// callers collect into a map keyed by the DID and never read the sequence. -const LABEL_CONCURRENCY: usize = 8; +/// The one number for both of a listing's label errands, and it is the +/// resolver's: the columns a page needs are per *unique* DID rather than per +/// row, and a listing is not entitled to open as many sockets as it happens +/// to have rows. See [`crate::clients::atproto::handles::CONCURRENCY`] for +/// the rest of the argument. +const LABEL_CONCURRENCY: usize = crate::clients::atproto::handles::CONCURRENCY; /// `@handle` per unique author DID on the page, falling back to the DID. /// -/// One `handle_from_did_doc` per distinct author, which for a page filed by -/// one person is one lookup and for a busy repo is a dozen. They used to run -/// in a `for` loop, which made the wait the sum of them; the sweep that -/// numbers the same page has used a `JoinSet` since it was written, and -/// `images.rs` the `buffer_unordered` spelling, so this is that idiom applied -/// to the listing's other two errands rather than a new one. +/// The resolution is [`crate::clients::atproto::handles`], which every +/// listing shares and which remembers a DID for the length of the process; +/// what stays here is this table's own answer to a handle that will not +/// resolve, which is to print the DID rather than leave the column empty. async fn author_handles(items: &[Listed]) -> HashMap { let dids = unique(items.iter().filter_map(|i| author_did(&i.value))); - crate::logging::debug::log(format!( - "listing labels: {} author handle(s) for {} row(s), {LABEL_CONCURRENCY} at a time", - dids.len(), - items.len() - )); - stream::iter(dids.into_iter().map(|did| async move { - let label = match crate::clients::atproto::did::handle_from_did_doc(&did).await { - Some(h) => format!("@{h}"), - None => did.clone(), - }; - (did, label) - })) - .buffer_unordered(LABEL_CONCURRENCY) - .collect() - .await + let handles = crate::clients::atproto::handles::handles(dids.clone()).await; + dids.into_iter() + .map(|did| { + let label = match handles.get(&did) { + Some(h) => format!("@{h}"), + None => did.clone(), + }; + (did, label) + }) + .collect() } /// [`RepoName`] per unique target repo DID on the page. @@ -2218,7 +2210,7 @@ async fn repo_label_from_bobbin(repo_did: &str) -> Option { // Old records carry an *owner* DID in this position, which is not a repo // DID and so resolves to nothing here. let (owner, name) = owner_and_name(&uri)?; - let owner_label = match crate::clients::atproto::did::handle_from_did_doc(owner).await { + let owner_label = match crate::clients::atproto::handles::handle(owner).await { Some(h) => format!("@{h}"), None => owner.to_string(), }; @@ -2459,7 +2451,7 @@ pub(crate) async fn view(args: ViewArgs) -> Result<()> { // instead of one string that means different things depending on // whether the lookup succeeded. let author_handle = match author_did(item) { - Some(did) => crate::clients::atproto::did::handle_from_did_doc(&did).await, + Some(did) => crate::clients::atproto::handles::handle(&did).await, None => None, }; let author = match (author_did(item), &author_handle) { diff --git a/src/cmd/pr/review.rs b/src/cmd/pr/review.rs index 2b61673..f94f622 100644 --- a/src/cmd/pr/review.rs +++ b/src/cmd/pr/review.rs @@ -589,7 +589,7 @@ fn header_for(pull: &Pull, author: &str, round: Option<&RoundRef>, total: usize) /// The author as a handle when one resolves, and as a DID when it does not. async fn author_label(did: &str) -> String { - match crate::clients::atproto::did::handle_from_did_doc(did).await { + match crate::clients::atproto::handles::handle(did).await { Some(handle) => format!("@{handle}"), None => did.to_string(), } @@ -1388,7 +1388,7 @@ pub(crate) async fn checkout(args: CheckoutArgs) -> Result<()> { let patches = read_patches(&pull.value)?; // One DID document read for both spellings: `@handle` for a person, the // bare handle for a branch name and for `--json`. - let handle = crate::clients::atproto::did::handle_from_did_doc(&pull.did).await; + let handle = crate::clients::atproto::handles::handle(&pull.did).await; let author = match &handle { Some(handle) => format!("@{handle}"), None => pull.did.clone(), diff --git a/src/cmd/pr/write.rs b/src/cmd/pr/write.rs index 4418171..5281315 100644 --- a/src/cmd/pr/write.rs +++ b/src/cmd/pr/write.rs @@ -2115,7 +2115,7 @@ pub(crate) async fn comment(args: CommentArgs) -> Result<()> { ) })?; - let author_label = match crate::clients::atproto::did::handle_from_did_doc(&pull.did).await { + let author_label = match crate::clients::atproto::handles::handle(&pull.did).await { Some(h) => format!("@{h}"), None => pull.did.clone(), }; diff --git a/src/cmd/repo/checkout.rs b/src/cmd/repo/checkout.rs index 1ce676b..51b1f45 100644 --- a/src/cmd/repo/checkout.rs +++ b/src/cmd/repo/checkout.rs @@ -45,6 +45,12 @@ pub(super) struct GitIdentity { /// first, and fall back to the cache only if that lookup fails (offline, or /// the resolver is down). The DID itself needs nothing: `Selection` already /// holds it, and it is the half that can't be wrong. +/// +/// Deliberately not [`crate::clients::atproto::handles::handle`], which +/// every *display* of a handle goes through: this one is written to disk and +/// then stamped on commits, so it asks the document itself rather than +/// whatever an earlier errand in this process happened to see. The saving +/// would be one request on a command that is about to clone a repository. async fn account_identity(selection: account::Selection) -> Result { let handle = match crate::clients::atproto::did::handle_from_did_doc(&selection.did).await { Some(handle) => handle, @@ -422,7 +428,7 @@ pub(super) async fn clone(args: CloneArgs) -> Result<()> { // handle keeps the remote URL readable and matches what `repo create` // writes, so prefer one when the DID document offers it. let owner_path = if crate::lexicon::identity::is_did(&owner) { - crate::clients::atproto::did::handle_from_did_doc(&owner) + crate::clients::atproto::handles::handle(&owner) .await .unwrap_or_else(|| owner.clone()) } else { diff --git a/src/cmd/search.rs b/src/cmd/search.rs index 0f87733..0f44393 100644 --- a/src/cmd/search.rs +++ b/src/cmd/search.rs @@ -39,7 +39,6 @@ //! worst ratio of damage to cause. use anyhow::Result; -use futures_util::stream::{self, StreamExt}; use std::collections::HashMap; /// The prefix every collection in this lexicon shares, and the only reason @@ -497,12 +496,13 @@ pub(crate) async fn search(args: SearchArgs) -> Result<()> { /// cap here would read as "these accounts have no handles". const AUTHOR_HANDLE_CAP: usize = 50; -/// How many of those lookups run at once. -const AUTHOR_HANDLE_CONCURRENCY: usize = 8; - /// `handle` per unique author DID on the page, DID-keyed. /// -/// Empty, with a note, when the page is over [`AUTHOR_HANDLE_CAP`]. +/// Empty, with a note, when the page is over [`AUTHOR_HANDLE_CAP`]. The cap +/// is counted here rather than in the resolver because it is search's +/// policy and nobody else's: every other listing resolves every author it +/// has. Past the cap nothing is asked for at all, so the note is the whole +/// story and not a partial column dressed as a complete one. async fn author_handles(hits: &[SearchHitJson]) -> HashMap { let mut dids: Vec = Vec::new(); for hit in hits { @@ -523,18 +523,11 @@ async fn author_handles(hits: &[SearchHitJson]) -> HashMap { return HashMap::new(); } crate::logging::debug::log(format!( - "search: {} author handle(s) for {} hit(s), {AUTHOR_HANDLE_CONCURRENCY} at a time", + "search: {} author handle(s) for {} hit(s)", dids.len(), hits.len() )); - stream::iter(dids.into_iter().map(|did| async move { - let handle = crate::clients::atproto::did::handle_from_did_doc(&did).await; - (did, handle) - })) - .buffer_unordered(AUTHOR_HANDLE_CONCURRENCY) - .filter_map(|(did, handle)| async move { handle.map(|h| (did, h)) }) - .collect() - .await + crate::clients::atproto::handles::handles(dids).await } /// Say when there was more and this stopped.