diff --git a/apps/web/src/lib/bot/resolve-event.ts b/apps/web/src/lib/bot/resolve-event.ts index b065626..ce24bd8 100644 --- a/apps/web/src/lib/bot/resolve-event.ts +++ b/apps/web/src/lib/bot/resolve-event.ts @@ -2,7 +2,8 @@ import type { Client } from '@atcute/client'; import type { Did } from '@atcute/lexicons'; import type { Handle, ResourceUri } from '@atcute/lexicons/syntax'; import { getEventRecordFromContrail } from '$lib/contrail'; -import { ATMO_HOSTS } from './config'; +import { getPDS } from '$lib/atproto/methods'; +import { ATMO_HOSTS, EVENT_COLLECTION } from './config'; /** The fields of a Bluesky post record we care about for link extraction. */ export type BotPostRecord = { @@ -133,29 +134,58 @@ export type ResolvedEvent = { externalRsvpUrl: string | null; }; +type EventValue = { + additionalData?: { externalSource?: { rsvpMode?: string; url?: string } }; +}; + +function toResolvedEvent(uri: string, cid: string, value: EventValue | undefined): ResolvedEvent { + const externalSource = value?.additionalData?.externalSource; + const externalOnly = externalSource?.rsvpMode === 'external_only'; + return { + uri, + cid, + externalOnly, + externalRsvpUrl: externalOnly ? (externalSource?.url ?? null) : null + }; +} + /** - * Load the event referenced by a link via contrail's in-process index, returning - * the strong-ref pieces (uri + cid) and the external-RSVP signal. Returns null - * when the event isn't indexed (treated as "no event"). + * Load the event referenced by a link. Prefers contrail's in-process index + * (fast, no network); falls back to a direct read from the owner's PDS when the + * index misses — so the bot keeps working even while contrail is fresh/behind + * (e.g. right after a DB switch, before backfill completes). Returns null only + * when the event genuinely can't be found anywhere. */ export async function loadEvent( serverClient: Client, did: Did, rkey: string ): Promise { - const event = await getEventRecordFromContrail(serverClient, { did, rkey }); - if (!event?.cid) return null; - - const value = event.value as unknown as { - additionalData?: { externalSource?: { rsvpMode?: string; url?: string } }; - }; - const externalSource = value?.additionalData?.externalSource; - const externalOnly = externalSource?.rsvpMode === 'external_only'; + try { + const event = await getEventRecordFromContrail(serverClient, { did, rkey }); + if (event?.cid) { + return toResolvedEvent(event.uri, event.cid, event.value as unknown as EventValue); + } + } catch { + // Index hiccup — fall through to the PDS. + } + return loadEventFromPds(did, rkey); +} - return { - uri: event.uri, - cid: event.cid, - externalOnly, - externalRsvpUrl: externalOnly ? (externalSource?.url ?? null) : null - }; +/** Direct public read of the event record from its owner's PDS (no auth needed). */ +async function loadEventFromPds(did: Did, rkey: string): Promise { + try { + const pds = await getPDS(did); + if (!pds) return null; + const url = + `${pds.replace(/\/$/, '')}/xrpc/com.atproto.repo.getRecord` + + `?repo=${encodeURIComponent(did)}&collection=${EVENT_COLLECTION}&rkey=${encodeURIComponent(rkey)}`; + const res = await fetch(url); + if (!res.ok) return null; + const data = (await res.json()) as { uri?: string; cid?: string; value?: EventValue }; + if (!data?.uri || !data?.cid) return null; + return toResolvedEvent(data.uri, data.cid, data.value); + } catch { + return null; + } } diff --git a/apps/web/src/lib/contrail.config.ts b/apps/web/src/lib/contrail.config.ts index 41039cd..de3afed 100644 --- a/apps/web/src/lib/contrail.config.ts +++ b/apps/web/src/lib/contrail.config.ts @@ -80,12 +80,14 @@ export const config: ContrailConfig = { // Powers the home-page "from people you follow" surface. network: { // Per-target caps so RSVPs (high-volume) can't squeeze events - // (low-volume) out of the cap. Bumped above the default 200 because - // most RSVPs in feed_items refer to past events, and we want enough - // breathing room to find recent ones after the JS-side filter. + // (low-volume) out of the cap. Kept deliberately modest: feed_items is + // pruned every cycle with a ROW_NUMBER()-over-the-table query, so large + // caps bloat the table and make the prune exceed D1's per-query CPU + // limit (which then thrashes the DB and fails concurrent reads). These + // values are enough to surface recent items after the JS-side filter. targets: [ - { collection: 'event', maxItems: 200 }, - { collection: 'rsvp', maxItems: 1000 } + { collection: 'event', maxItems: 100 }, + { collection: 'rsvp', maxItems: 250 } ] } } diff --git a/apps/web/src/lib/contrail.ts b/apps/web/src/lib/contrail.ts index 0e3c428..1145746 100644 --- a/apps/web/src/lib/contrail.ts +++ b/apps/web/src/lib/contrail.ts @@ -16,6 +16,32 @@ export const RSVP_HYDRATE_LIMIT = 20; export const RSVP_GOING = 'community.lexicon.calendar.rsvp#going'; export const RSVP_INTERESTED = 'community.lexicon.calendar.rsvp#interested'; +/** Transient D1 failures worth retrying (CPU-limit resets, lost connections). */ +function isTransientD1Error(e: unknown): boolean { + const msg = e instanceof Error ? e.message : String(e); + return /D1_ERROR|CPU time|was reset|Network connection lost|7429|storage caused object to be reset|internal error/i.test( + msg + ); +} + +/** + * Retry a contrail/D1 read on transient D1 errors (e.g. "D1 exceeded its CPU + * time limit and was reset" under load). Non-transient errors throw immediately. + */ +export async function withD1Retry(fn: () => Promise, attempts = 3): Promise { + let lastErr: unknown; + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (e) { + lastErr = e; + if (!isTransientD1Error(e) || i === attempts - 1) throw e; + await new Promise((r) => setTimeout(r, 120 * (i + 1))); + } + } + throw lastErr; +} + type ProfileOutput = RsvpAtmoGetProfile.$output; type EventListOutput = RsvpAtmoEventListRecords.$output; type EventListRecord = RsvpAtmoEventListRecords.Record; @@ -140,10 +166,7 @@ export function eventUrl(event: FlatEventRecord, actor?: string): string { return `/p/${who}/e/${event.rkey}`; } -export function getHostProfile( - did: string, - profiles?: AttendeeProfileEntry[] -): HostProfile | null { +export function getHostProfile(did: string, profiles?: AttendeeProfileEntry[]): HostProfile | null { const profile = profiles?.find((entry) => entry.did === did); if (!profile) return null; diff --git a/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/+page.server.ts b/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/+page.server.ts index ae0a669..cf522e6 100644 --- a/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/+page.server.ts +++ b/apps/web/src/routes/(app)/p/[actor]/e/[rkey]/+page.server.ts @@ -11,10 +11,58 @@ import { getServerClient, getViewerRsvpFromContrail, listEventAttendeesFromContrail, + withD1Retry, RSVP_HYDRATE_LIMIT } from '$lib/contrail'; +import type { Client } from '@atcute/client'; import { vodFromAtUri } from '$lib/vods'; +type EventRecord = Awaited>; + +/** + * Fetch the event record with resilience: retry transient D1 errors, cache + * successes briefly (Cloudflare Cache API), and fall back to the cached copy + * when D1 is momentarily unavailable — so a DB hiccup serves slightly-stale + * data instead of a misleading 404. + */ +async function loadEventRecordResilient( + client: Client, + did: string, + rkey: string +): Promise { + // `caches.default` is a Cloudflare extension not in the DOM `CacheStorage` + // type, and is absent in dev (vite/node) — guard + cast. + const cache = + typeof caches !== 'undefined' && 'default' in caches + ? (caches as unknown as { default: Cache }).default + : null; + const cacheKey = new Request(`https://event-cache.internal/${did}/${rkey}`); + + try { + const record = await withD1Retry(() => + getEventRecordFromContrail(client, { + did, + rkey, + hydrateRsvps: RSVP_HYDRATE_LIMIT, + profiles: true + }) + ); + if (record && cache) { + await cache.put( + cacheKey, + new Response(JSON.stringify(record), { headers: { 'cache-control': 'max-age=120' } }) + ); + } + return record; + } catch (e) { + if (cache) { + const cached = await cache.match(cacheKey); + if (cached) return (await cached.json()) as EventRecord; + } + throw e; + } +} + export async function load({ params, locals, url, platform }) { const client = getServerClient(platform!.env.DB); const { rkey } = params; @@ -33,16 +81,12 @@ export async function load({ params, locals, url, platform }) { throw error(503, 'Could not resolve this profile right now — please try again.'); } - // Fetch the event. Distinguish "genuinely not indexed" (404) from a transient - // index/D1 error (503), so a hiccup doesn't masquerade as "not found". - let eventRecord; + // Fetch the event (retry + cache fallback). Distinguish "genuinely not + // indexed" (404) from a transient index/D1 error (503), so a hiccup doesn't + // masquerade as "not found". + let eventRecord: EventRecord; try { - eventRecord = await getEventRecordFromContrail(client, { - did, - rkey, - hydrateRsvps: RSVP_HYDRATE_LIMIT, - profiles: true - }); + eventRecord = await loadEventRecordResilient(client, did, rkey); } catch { throw error(503, 'Temporarily unavailable — please try again.'); } diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index 017b450..569456c 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -24,8 +24,8 @@ "d1_databases": [ { "binding": "DB", - "database_name": "atmo-events-v4", - "database_id": "3ba9a931-aae7-425b-a68e-d18d9a8ab31b", + "database_name": "atmo-events-v5", + "database_id": "3f862040-84ee-4216-8ba7-ef3146058de4", "remote": true } ],