From 586860f6f6af2383bf87678ad75f6b4efb68c08c Mon Sep 17 00:00:00 2001 From: scanash00 Date: Fri, 12 Jun 2026 09:51:25 -0800 Subject: [PATCH] perf: cache identity/records + lite feed hydration (fix one-by-one loading) The trickle was hydratePost firing ~7 requests/post (record + author identity + author record + 4 counts + viewer) with NO caching, so a 30-post feed = ~210 uncached requests resolving the same authors over and over. - Add in-memory TTL cache + in-flight dedupe to slingshot resolveMiniDoc (5min) and getRecordByUri (30s): repeated authors/records across a feed are now free - Add hydratePost {lite} mode: skip the 4 count calls + viewer state for feeds (~5 fewer requests/post); feeds use it. Counts fill in when a post is opened. - Also: follower lists via Constellation getBacklinkDids + hydrate (people.ts) --- src/lib/microcosm/feed.ts | 6 +- src/lib/microcosm/hydrate.ts | 28 ++++++++-- src/lib/microcosm/people.ts | 77 ++++++++++++++++++++++++++ src/lib/microcosm/slingshot.ts | 56 ++++++++++++++++--- src/state/queries/profile-followers.ts | 22 +++++++- 5 files changed, 172 insertions(+), 17 deletions(-) create mode 100644 src/lib/microcosm/people.ts diff --git a/src/lib/microcosm/feed.ts b/src/lib/microcosm/feed.ts index a2230f8..2d58abd 100644 --- a/src/lib/microcosm/feed.ts +++ b/src/lib/microcosm/feed.ts @@ -116,7 +116,7 @@ export async function buildFollowingFeed( const posts = await Promise.all( top.map(c => - hydratePost(c.uri, viewerDid, signal).catch(() => undefined), + hydratePost(c.uri, viewerDid, signal, 1, {lite: true}).catch(() => undefined), ), ) @@ -181,7 +181,7 @@ export async function buildCustomFeed( skeleton.feed.map(item => // Skip malformed entries. item.post?.startsWith('at://') - ? hydratePost(item.post, opts.viewerDid, signal).catch(() => undefined) + ? hydratePost(item.post, opts.viewerDid, signal, 1, {lite: true}).catch(() => undefined) : Promise.resolve(undefined), ), ) @@ -219,7 +219,7 @@ export async function buildAuthorFeed( const posts = await Promise.all( page.records.map(rec => - hydratePost(rec.uri, opts.viewerDid, signal).catch(() => undefined), + hydratePost(rec.uri, opts.viewerDid, signal, 1, {lite: true}).catch(() => undefined), ), ) diff --git a/src/lib/microcosm/hydrate.ts b/src/lib/microcosm/hydrate.ts index 4fee298..e3fcd87 100644 --- a/src/lib/microcosm/hydrate.ts +++ b/src/lib/microcosm/hydrate.ts @@ -321,19 +321,37 @@ export async function hydratePost( viewerDid?: string, signal?: AbortSignal, depth: number = MAX_QUOTE_DEPTH, + opts?: { + /** + * Feed mode: skip the 4 interaction-count calls and viewer-state lookup + * (5 of the ~7 requests per post). Use for feeds where dozens of posts are + * hydrated at once; the counts/viewer can be filled in lazily by the shadow + * cache or a single-post query when the post is opened. + */ + lite?: boolean + }, ): Promise { const parsed = parseAtUri(postUri) if (!parsed) return undefined + const lite = opts?.lite ?? false const [recordRes, author, likes, reposts, replies, quotes, viewer] = await Promise.all([ getRecordByUri(postUri, undefined, signal).catch(() => undefined), hydrateProfileBasic(parsed.did, signal).catch(() => undefined), - constellation.likeCount(postUri, signal).catch(() => undefined), - constellation.repostCount(postUri, signal).catch(() => undefined), - constellation.replyCount(postUri, signal).catch(() => undefined), - constellation.quoteCount(postUri, signal).catch(() => undefined), - viewerDid + lite + ? Promise.resolve(undefined) + : constellation.likeCount(postUri, signal).catch(() => undefined), + lite + ? Promise.resolve(undefined) + : constellation.repostCount(postUri, signal).catch(() => undefined), + lite + ? Promise.resolve(undefined) + : constellation.replyCount(postUri, signal).catch(() => undefined), + lite + ? Promise.resolve(undefined) + : constellation.quoteCount(postUri, signal).catch(() => undefined), + !lite && viewerDid ? hydrateViewerState(postUri, viewerDid, signal).catch(() => undefined) : Promise.resolve(undefined), ]) diff --git a/src/lib/microcosm/people.ts b/src/lib/microcosm/people.ts new file mode 100644 index 0000000..a8e4305 --- /dev/null +++ b/src/lib/microcosm/people.ts @@ -0,0 +1,77 @@ +/** + * Follower / following lists from Constellation, independent of the AppView. + * + * Constellation gives us the DIDs of accounts linking to a subject (followers) + * via getBacklinkDids; we then hydrate each DID into a profile view. The + * "following" direction (who X follows) is NOT a backlink (X is the source), so + * it can't come from Constellation — that one still needs the AppView / PDS. + */ +import {type AppBskyActorDefs} from '@atproto/api' + +import {constellation} from '#/lib/microcosm' +import {hydrateProfileBasic} from '#/lib/microcosm/hydrate' + +export type ProfileListPage = { + profiles: AppBskyActorDefs.ProfileView[] + cursor?: string +} + +/** One page of the followers of `did`, as hydrated profile views. */ +export async function buildFollowersPage( + did: string, + opts: {limit?: number; cursor?: string} = {}, + signal?: AbortSignal, +): Promise { + const page = await constellation.getBacklinkDids( + { + subject: did, + source: constellation.Sources.followers, + limit: opts.limit ?? 30, + cursor: opts.cursor, + }, + signal, + ) + + const profiles = await Promise.all( + page.linking_dids.map(d => + hydrateProfileBasic(d, signal).catch(() => undefined), + ), + ) + + return { + cursor: page.cursor ?? undefined, + // ProfileViewBasic is structurally a subset of ProfileView; the list UIs + // accept it. Cast at this boundary. + profiles: profiles.filter(Boolean) as AppBskyActorDefs.ProfileView[], + } +} + +/** One page of the accounts who liked a post, as hydrated profile views. */ +export async function buildLikedByPage( + postUri: string, + opts: {limit?: number; cursor?: string} = {}, + signal?: AbortSignal, +): Promise { + const page = await constellation.getBacklinkDids( + { + subject: postUri, + source: constellation.Sources.likes, + limit: opts.limit ?? 30, + cursor: opts.cursor, + }, + signal, + ) + + const profiles = await Promise.all( + page.linking_dids.map(d => + hydrateProfileBasic(d, signal).catch(() => undefined), + ), + ) + + return { + cursor: page.cursor ?? undefined, + // ProfileViewBasic is structurally a subset of ProfileView; the list UIs + // accept it. Cast at this boundary. + profiles: profiles.filter(Boolean) as AppBskyActorDefs.ProfileView[], + } +} diff --git a/src/lib/microcosm/slingshot.ts b/src/lib/microcosm/slingshot.ts index 26849d1..eca27e8 100644 --- a/src/lib/microcosm/slingshot.ts +++ b/src/lib/microcosm/slingshot.ts @@ -26,6 +26,38 @@ export type MiniDoc = { signing_key: string } +/** + * Tiny in-memory TTL cache + in-flight dedupe. Critical for feed rendering: the + * same authors/records appear across many posts, so without this every post + * re-resolves the same identity and re-fetches the same profile record. + */ +function makeCache(ttlMs: number) { + const entries = new Map() + const inflight = new Map>() + return (key: string, fetcher: () => Promise): Promise => { + const hit = entries.get(key) + if (hit && Date.now() - hit.at < ttlMs) return Promise.resolve(hit.value) + const pending = inflight.get(key) + if (pending) return pending + const p = fetcher() + .then(value => { + entries.set(key, {value, at: Date.now()}) + inflight.delete(key) + return value + }) + .catch(e => { + inflight.delete(key) + throw e + }) + inflight.set(key, p) + return p + } +} + +// Identity rarely changes -> long TTL. Records change more -> short TTL. +const miniDocCache = makeCache(5 * 60 * 1000) +const recordCache = makeCache(30 * 1000) + export class SlingshotError extends Error { constructor( message: string, @@ -92,10 +124,20 @@ export function getRecordByUri( cid?: string, signal?: AbortSignal, ): Promise { - return get( - '/xrpc/blue.microcosm.repo.getRecordByUri', - {at_uri: atUri, cid}, - signal, + // Only cache the common (latest-version) case; a specific cid bypasses. + if (cid) { + return get( + '/xrpc/blue.microcosm.repo.getRecordByUri', + {at_uri: atUri, cid}, + signal, + ) + } + return recordCache(atUri, () => + get( + '/xrpc/blue.microcosm.repo.getRecordByUri', + {at_uri: atUri, cid}, + signal, + ), ) } @@ -124,10 +166,8 @@ export function resolveMiniDoc( identifier: string, signal?: AbortSignal, ): Promise { - return get( - '/xrpc/blue.microcosm.identity.resolveMiniDoc', - {identifier}, - signal, + return miniDocCache(identifier, () => + get('/xrpc/blue.microcosm.identity.resolveMiniDoc', {identifier}, signal), ) } diff --git a/src/state/queries/profile-followers.ts b/src/state/queries/profile-followers.ts index 9c4c518..6601389 100644 --- a/src/state/queries/profile-followers.ts +++ b/src/state/queries/profile-followers.ts @@ -9,6 +9,8 @@ import { useInfiniteQuery, } from '@tanstack/react-query' +import {MICROCOSM_ENABLED} from '#/lib/microcosm/config' +import {buildFollowersPage} from '#/lib/microcosm/people' import {useAgent} from '#/state/session' const PAGE_SIZE = 30 @@ -27,7 +29,25 @@ export function useProfileFollowersQuery(did: string | undefined) { RQPageParam >({ queryKey: RQKEY(did || ''), - async queryFn({pageParam}: {pageParam: RQPageParam}) { + async queryFn({pageParam, signal}) { + if (MICROCOSM_ENABLED && did) { + try { + const page = await buildFollowersPage( + did, + {limit: PAGE_SIZE, cursor: pageParam}, + signal, + ) + if (page.profiles.length) { + return { + subject: {did, handle: ''}, + followers: page.profiles, + cursor: page.cursor, + } + } + } catch { + // fall through to the AppView + } + } const res = await agent.app.bsky.graph.getFollowers({ actor: did || '', limit: PAGE_SIZE, -- 2.51.2