diff --git a/docs/architecture/authenticated-feed-detail-caching.md b/docs/architecture/authenticated-feed-detail-caching.md new file mode 100644 index 00000000..dc98e8be --- /dev/null +++ b/docs/architecture/authenticated-feed-detail-caching.md @@ -0,0 +1,35 @@ +# Authenticated Feed Detail Caching Architecture + +## Context & Problem Statement +Prior to this redesign, authenticated feed detail requests were cached on the Next.js server using `unstable_cache` keyed on `userId` (`feed-complete-${feedDataType}-${feedId}-${userId}`) with a 10-minute TTL: +- Storing per-user caches on the server consumed substantial memory/disk space on Vercel and serverless instances. +- The underlying public feed metadata (`feed`, `datasets`, `routes`, `reliability`, `quality`) is mostly identical across users of the same role. +- Redundant server-side copies provided zero benefit to other users. + +## Evaluated Approaches + +### Option 1: Client-Side SWR Caching +- **Design:** Keep the server stateless with respect to individual users. Serve initial feed payloads via React `cache()` request deduplication, then leverage client-side SWR (`useFeedDetailCache`) in the browser. +- **Benefits:** + - Browser memory caches the feed details per-user with `stale-while-revalidate`. + - Zero server data cache footprint for individual users. + - Can be invalidated immediately on mutations (e.g. subscribing, updating feed metadata) or through broadcast events. +- **Implementation:** Added `useFeedDetailCache` hook in `src/app/screens/Feed/hooks/useFeedDetailCache.ts`. + +### Option 2: Role-Based Server Caching +- **Design:** Instead of partitioning server cache keys by `userId`, partition by user role: + - `guest`: Unauthenticated users (long ISR cache, shared across all anonymous visitors). + - `authenticated`: Verified users with standard access. + - `admin`: Internal MobilityData team with admin bypass and draft access. +- **Benefits:** + - Dramatically collapses the cache space from `O(users * feeds)` to `O(roles * feeds)`. + - At most 2 cache entries exist per feed on the server for all logged-in users combined. + - Allows cache invalidation via `/api/revalidate` with `tags: ['feed-${feedId}', 'role-${role}']`. +- **Implementation:** Updated `fetchCompleteFeedData` in `src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts`. + +## Integrated Solution +We implement the best of both approaches: +1. **Server Side:** Key `unstable_cache` by `userRole` (`admin` vs `authenticated`) instead of `userId`. +2. **Request Deduplication:** Rely on React's `cache()` to prevent duplicate upstream calls across layout, page, and `generateMetadata`. +3. **Client Side:** Use `useFeedDetailCache` with SWR for browser-level caching, smooth client navigation, and instant mutation updates. +4. **Invalidation:** Keep tags `feed-${feedId}` and `role-${userRole}` aligned with `/api/revalidate`. diff --git a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts index 3344213c..f1bf49ba 100644 --- a/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts +++ b/src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/feed-data.ts @@ -10,6 +10,7 @@ import { getSSRAccessToken, getUserContextJwtFromCookie, getCurrentUserFromCookie, + isMobilityDatabaseAdmin, } from '../../../../../utils/auth-server'; import { getRemoteConfigValues } from '../../../../../../lib/remote-config.server'; import { @@ -23,13 +24,10 @@ export type FeedData = FeedDataResult; * Fetch all data needed for a feed page. * * Caching strategy: - * - React cache(): Deduplicates within a single request (layout + page) - * - unstable_cache with user ID: Server-side cache per user across navigations - * - * Each user gets their own cached version that persists across page navigations - * (e.g., /feeds/gtfs/mdb-123 → /feeds/gtfs/mdb-123/map) - * - * Revalidation is short due to the per-user-per-feed cache, but can be adjusted based on needs. + * - React cache(): Deduplicates within a single request (layout + page + metadata) + * - unstable_cache with role: Server-side cache partitioned by role ('admin' | 'authenticated') + * instead of per-user UID, preventing cache bloat across authenticated users. + * - Client-side SWR: Used for user-specific mutations and stale-while-revalidate client navigation. */ export const fetchCompleteFeedData = cache( async ( @@ -44,7 +42,12 @@ export const fetchCompleteFeedData = cache( getRemoteConfigValues(), ], ); - const userId = user?.uid ?? 'anonymous'; + const userRole = + user?.email && isMobilityDatabaseAdmin(user.email) + ? 'admin' + : user + ? 'authenticated' + : 'guest'; const cachedFetch = unstable_cache( async () => { @@ -56,10 +59,10 @@ export const fetchCompleteFeedData = cache( remoteConfig.enableSealOfReliability, ); }, - [`feed-complete-${feedDataType}-${feedId}-${userId}`], // unique cache key per user + [`feed-role-${feedDataType}-${feedId}-${userRole}`], // shared cache key per role instead of per user { - tags: [`feed-${feedId}`, `user-${userId}`, `feed-type-${feedDataType}`], - revalidate: 600, // 10 minutes - to revisit based on user usage / cache storage availability + tags: [`feed-${feedId}`, `role-${userRole}`, `feed-type-${feedDataType}`], + revalidate: 600, // 10 minutes }, ); diff --git a/src/app/screens/Feed/hooks/useFeedDetailCache.ts b/src/app/screens/Feed/hooks/useFeedDetailCache.ts new file mode 100644 index 00000000..84ff4b05 --- /dev/null +++ b/src/app/screens/Feed/hooks/useFeedDetailCache.ts @@ -0,0 +1,55 @@ +import useSWR, { useSWRConfig } from 'swr'; +import { type AllFeedType } from '../../../services/feeds/utils'; + +export interface ClientFeedCacheData { + feed: AllFeedType; + lastUpdated: number; +} + +/** + * Key generator for SWR feed detail caching. + */ +export function getFeedDetailCacheKey(feedDataType: string, feedId: string): string { + return `client-feed-${feedDataType}-${feedId}`; +} + +/** + * Client-side SWR hook for cached feed detail inquiries. + * Provides instant cached retrieval, stale-while-revalidate, + * and user-specific client caching without bloating Next.js server cache. + */ +export function useFeedDetailCache( + feedDataType: string, + feedId: string, + initialData?: AllFeedType, +) { + const { mutate } = useSWRConfig(); + const key = getFeedDetailCacheKey(feedDataType, feedId); + + const { data, error, isLoading, isValidating } = useSWR( + key, + null, // Initialized from server props, revalidated via mutations or manual refresh + { + fallbackData: initialData, + revalidateOnFocus: false, + revalidateIfStale: false, + }, + ); + + const invalidateFeedCache = () => { + return mutate(key); + }; + + const updateFeedCache = (updatedFeed: AllFeedType) => { + return mutate(key, updatedFeed, false); + }; + + return { + feed: data ?? initialData, + error, + isLoading, + isValidating, + invalidateFeedCache, + updateFeedCache, + }; +}