From fd4520dbf60c1d18b43a9dc233bdc642ffb84a4c Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Wed, 31 Dec 2025 12:14:00 +1000 Subject: [PATCH] perf: reduce Neon compute usage with cron + caching optimizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reduce cron frequency: changes 2min→4hr, keyword 15min→6hr, metrics hourly→daily - Add Prisma directUrl for connection pooling support - Add Vercel KV caching to /api/tools endpoint (graceful degradation if not configured) - Add X-Cache header to indicate cache hit/miss - Add NEON_COMPUTE_OPTIMIZATION.md with full strategy guide These changes should reduce Neon CU usage from 100+ to ~20-30 CU-hrs/month. --- apps/web/package.json | 1 + apps/web/src/app/api/tools/route.ts | 53 ++++++ docs/NEON_COMPUTE_OPTIMIZATION.md | 276 ++++++++++++++++++++++++++++ packages/db/prisma/schema.prisma | 5 +- pnpm-lock.yaml | 31 +++- vercel.json | 6 +- 6 files changed, 363 insertions(+), 9 deletions(-) create mode 100644 docs/NEON_COMPUTE_OPTIMIZATION.md diff --git a/apps/web/package.json b/apps/web/package.json index a90c940..001685d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "@vercel/analytics": "^1.6.1", "@vercel/blob": "^2.0.0", + "@vercel/kv": "^3.0.0", "ai": "6.0.3", "bm25": "^0.1.1", "d3": "^7.9.0", diff --git a/apps/web/src/app/api/tools/route.ts b/apps/web/src/app/api/tools/route.ts index bebe0a2..66812d5 100644 --- a/apps/web/src/app/api/tools/route.ts +++ b/apps/web/src/app/api/tools/route.ts @@ -1,4 +1,5 @@ import { type Prisma, prisma } from '@tpmjs/db'; +import { kv } from '@vercel/kv'; import { type NextRequest, NextResponse } from 'next/server'; import { checkRateLimit } from '~/lib/rate-limit'; @@ -6,6 +7,34 @@ export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export const maxDuration = 60; +// Cache configuration +const CACHE_TTL = 300; // 5 minutes +const CACHE_PREFIX = 'tools:'; + +/** + * Try to get cached response, returns null if KV not configured or cache miss + */ +async function getCached(key: string): Promise { + try { + if (!process.env.KV_REST_API_URL) return null; + return await kv.get(key); + } catch { + return null; + } +} + +/** + * Try to set cache, silently fails if KV not configured + */ +async function setCache(key: string, value: T, ttl: number): Promise { + try { + if (!process.env.KV_REST_API_URL) return; + await kv.set(key, value, { ex: ttl }); + } catch { + // Silently ignore cache errors + } +} + // Constants const DEFAULT_LIMIT = 20; const MAX_LIMIT = 1000; @@ -255,6 +284,24 @@ export async function GET(request: NextRequest) { const limitParam = searchParams.get('limit'); const offsetParam = searchParams.get('offset'); + // Build cache key from query params + const cacheKey = `${CACHE_PREFIX}${searchParams.toString() || 'default'}`; + + // Try cache first (only for simple queries without search) + if (!query) { + const cached = await getCached(cacheKey); + if (cached) { + return NextResponse.json(cached, { + status: 200, + headers: { + 'X-Request-ID': requestId, + 'X-Cache': 'HIT', + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', + }, + }); + } + } + // Validate pagination parameters let limit: number; let offset: number; @@ -344,11 +391,17 @@ export async function GET(request: NextRequest) { }, }; + // Cache response for non-search queries + if (!query) { + await setCache(cacheKey, response, CACHE_TTL); + } + return NextResponse.json(response, { status: 200, headers: { 'X-Request-ID': requestId, 'X-Processing-Time': `${processingTime}ms`, + 'X-Cache': 'MISS', 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', }, }); diff --git a/docs/NEON_COMPUTE_OPTIMIZATION.md b/docs/NEON_COMPUTE_OPTIMIZATION.md new file mode 100644 index 0000000..832a3b2 --- /dev/null +++ b/docs/NEON_COMPUTE_OPTIMIZATION.md @@ -0,0 +1,276 @@ +# Neon Compute Usage Optimization Strategy + +Current usage: **100+ CU-hrs/month** (over free tier limit) + +## Root Causes + +### 1. Cron Jobs Hitting Database Too Frequently + +Current schedule: +- `/api/sync/changes` - Every **2 minutes** (720 runs/day) +- `/api/sync/keyword` - Every **15 minutes** (96 runs/day) +- `/api/sync/metrics` - Every **hour** (24 runs/day) + +Each run wakes up the Neon compute instance if it's scaled to zero, incurring cold start costs. + +### 2. No Connection Pooling + +Prisma creates new connections for each serverless function invocation. Neon recommends using their connection pooler with `?pgbouncer=true`. + +### 3. API Endpoints Without Caching + +Every `/api/tools` request hits the database directly. No Redis/memory caching layer. + +### 4. Potentially Expensive Queries + +- `prisma.tool.findMany()` with multiple includes and order by clauses +- No query result caching + +--- + +## Optimization Strategy + +### Phase 1: Immediate Fixes (High Impact) + +#### 1.1 Reduce Cron Frequency + +```json +// vercel.json - proposed changes +{ + "crons": [ + { + "path": "/api/sync/changes", + "schedule": "0 */4 * * *" // Every 4 hours instead of 2 minutes + }, + { + "path": "/api/sync/keyword", + "schedule": "0 */6 * * *" // Every 6 hours instead of 15 minutes + }, + { + "path": "/api/sync/metrics", + "schedule": "0 0 * * *" // Once daily instead of hourly + } + ] +} +``` + +**Impact**: Reduces cron-triggered database wakeups from ~840/day to ~10/day + +#### 1.2 Enable Neon Connection Pooling + +Update `DATABASE_URL` in Vercel environment: + +``` +# Current (direct connection) +postgresql://user:pass@ep-xxx.us-east-1.aws.neon.tech/db + +# Optimized (pooled connection) +postgresql://user:pass@ep-xxx-pooler.us-east-1.aws.neon.tech/db?pgbouncer=true +``` + +Add to Prisma schema: +```prisma +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") // For migrations only +} +``` + +**Impact**: Reduces connection overhead by 50-80% + +#### 1.3 Reduce Auto-Suspend Timeout + +In Neon console, set compute auto-suspend to **1 minute** (minimum) instead of 5 minutes. + +**Impact**: Less idle compute time billed + +### Phase 2: Caching Layer (Medium Impact) + +#### 2.1 Add Vercel KV (Redis) for API Caching + +```typescript +// apps/web/src/app/api/tools/route.ts +import { kv } from '@vercel/kv'; + +const CACHE_TTL = 300; // 5 minutes +const CACHE_KEY = 'tools:list'; + +export async function GET(request: NextRequest) { + // Try cache first + const cached = await kv.get(CACHE_KEY); + if (cached) { + return NextResponse.json(cached); + } + + // Fetch from DB + const tools = await prisma.tool.findMany({ ... }); + + // Cache result + await kv.set(CACHE_KEY, tools, { ex: CACHE_TTL }); + + return NextResponse.json(tools); +} +``` + +**Impact**: 90%+ cache hit rate for browse/search operations + +#### 2.2 Implement Stale-While-Revalidate Pattern + +```typescript +// Return stale data immediately, refresh in background +const cached = await kv.get(CACHE_KEY); +if (cached) { + // Async refresh if stale + if (cached.timestamp < Date.now() - CACHE_TTL * 1000) { + refreshCacheInBackground(); + } + return NextResponse.json(cached.data); +} +``` + +### Phase 3: Query Optimization (Low-Medium Impact) + +#### 3.1 Use Select Instead of Include + +```typescript +// Before: Fetches all package fields +const tools = await prisma.tool.findMany({ + include: { package: true } +}); + +// After: Only fetch needed fields +const tools = await prisma.tool.findMany({ + select: { + id: true, + name: true, + description: true, + package: { + select: { + npmPackageName: true, + npmVersion: true, + category: true, + } + } + } +}); +``` + +#### 3.2 Add Database Indexes + +Review slow queries in Neon console and add indexes: + +```prisma +model Tool { + // Composite index for common query pattern + @@index([qualityScore(sort: Desc), createdAt(sort: Desc)]) +} +``` + +#### 3.3 Paginate Large Result Sets + +Current: `limit: 1000` in tool-search page +Proposed: `limit: 50` with infinite scroll + +### Phase 4: Architecture Changes (High Impact, More Work) + +#### 4.1 Static Generation for Tool Pages + +Convert tool detail pages from `dynamic = 'force-dynamic'` to ISR: + +```typescript +// apps/web/src/app/tool/[...slug]/page.tsx +export const revalidate = 3600; // Revalidate every hour +export const dynamicParams = true; + +export async function generateStaticParams() { + const tools = await prisma.tool.findMany({ + select: { name: true, package: { select: { npmPackageName: true } } }, + take: 100, // Pre-generate top 100 tools + }); + return tools.map(t => ({ slug: [t.package.npmPackageName, t.name] })); +} +``` + +**Impact**: Zero database hits for cached pages + +#### 4.2 Move Sync Jobs to GitHub Actions + +Instead of Vercel Cron (which triggers serverless functions that connect to Neon), use GitHub Actions with a direct database connection: + +```yaml +# .github/workflows/sync.yml +name: NPM Sync +on: + schedule: + - cron: '0 */6 * * *' # Every 6 hours + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: pnpm install + - run: pnpm --filter=@tpmjs/db db:generate + - run: node scripts/sync-npm.js + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} +``` + +**Impact**: Sync jobs don't wake Neon compute (uses direct connection from GitHub runner) + +#### 4.3 Consider Neon Branching for Dev/Preview + +Use Neon branching so preview deployments don't hit production database: + +``` +Production: main branch (ep-xxx-pooler...) +Preview: dev branch (ep-yyy-pooler...) +``` + +--- + +## Implementation Priority + +| Priority | Task | Impact | Effort | +|----------|------|--------|--------| +| 🔴 P0 | Reduce cron frequency | High | 5 min | +| 🔴 P0 | Enable connection pooling | High | 10 min | +| 🟡 P1 | Add Vercel KV caching | High | 2 hrs | +| 🟡 P1 | Reduce auto-suspend timeout | Medium | 5 min | +| 🟢 P2 | Optimize queries with select | Medium | 1 hr | +| 🟢 P2 | Move to ISR for tool pages | High | 2 hrs | +| 🔵 P3 | Move sync to GitHub Actions | High | 3 hrs | + +--- + +## Monitoring + +After implementing changes, monitor in Neon console: +- **Compute hours**: Should drop 80%+ after P0 changes +- **Connections**: Should be more stable with pooling +- **Query performance**: Check slow query log + +--- + +## Cost Projections + +| Scenario | CU-hrs/month | Cost | +|----------|--------------|------| +| Current | 100+ | Over free tier | +| After P0 | ~20-30 | Free tier | +| After P1 | ~10-15 | Free tier | +| After all | ~5-10 | Free tier | + +Free tier limit: **100 CU-hrs/month** + +--- + +## Quick Start + +1. **Right now**: Update `vercel.json` cron schedules +2. **Today**: Add `-pooler` to DATABASE_URL in Vercel +3. **This week**: Add Vercel KV caching +4. **Next week**: Convert to ISR + +This should get you well under the 100 CU-hr limit. diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 9882ea1..e9a9541 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -6,8 +6,9 @@ generator client { } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") // Direct connection for migrations (bypasses pooler) } /// Package table - stores NPM package metadata (package-level) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 791c130..7b4e13e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,10 +134,10 @@ importers: version: 10.4.22(postcss@8.5.6) eslint: specifier: ^9.39.1 - version: 9.39.1(jiti@1.21.7) + version: 9.39.1(jiti@2.6.1) eslint-config-next: specifier: ^16.0.4 - version: 16.0.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + version: 16.0.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) postcss: specifier: ^8.5.1 version: 8.5.6 @@ -253,6 +253,9 @@ importers: '@vercel/blob': specifier: ^2.0.0 version: 2.0.0 + '@vercel/kv': + specifier: ^3.0.0 + version: 3.0.0 ai: specifier: 6.0.3 version: 6.0.3(zod@4.1.13) @@ -325,10 +328,10 @@ importers: version: 17.2.3 eslint: specifier: ^9.39.1 - version: 9.39.1(jiti@2.6.1) + version: 9.39.1(jiti@1.21.7) eslint-config-next: specifier: ^16.0.4 - version: 16.0.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + version: 16.0.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) postcss: specifier: ^8.5.1 version: 8.5.6 @@ -2860,6 +2863,9 @@ packages: cpu: [x64] os: [win32] + '@upstash/redis@1.36.0': + resolution: {integrity: sha512-9zN2UV9QJGPnXfWU3yZBLVQaqqENDh7g+Y4J2vJuSxBCi9FQ0aUOtaXlzuFhnsiZvCqM+eS27ic+tgmkWUsfOg==} + '@vercel/analytics@1.6.1': resolution: {integrity: sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==} peerDependencies: @@ -2890,6 +2896,10 @@ packages: resolution: {integrity: sha512-oAj7Pdy83YKSwIaMFoM7zFeLYWRc+qUpW3PiDSblxQMnGFb43qs4bmfq7dr/+JIfwhs6PTwe1o2YBwKhyjWxXw==} engines: {node: '>=20.0.0'} + '@vercel/kv@3.0.0': + resolution: {integrity: sha512-pKT8fRnfyYk2MgvyB6fn6ipJPCdfZwiKDdw7vB+HL50rjboEBHDVBEcnwfkEpVSp2AjNtoaOUH7zG+bVC/rvSg==} + engines: {node: '>=14.6'} + '@vercel/oidc@3.0.5': resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} engines: {node: '>= 20'} @@ -6191,6 +6201,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -8491,6 +8504,10 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@upstash/redis@1.36.0': + dependencies: + uncrypto: 0.1.3 + '@vercel/analytics@1.6.1(next@16.0.8(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)': optionalDependencies: next: 16.0.8(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -8504,6 +8521,10 @@ snapshots: throttleit: 2.1.0 undici: 5.29.0 + '@vercel/kv@3.0.0': + dependencies: + '@upstash/redis': 1.36.0 + '@vercel/oidc@3.0.5': {} '@vitest/expect@2.0.5': @@ -12875,6 +12896,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + uncrypto@0.1.3: {} + undici-types@6.21.0: {} undici@5.29.0: diff --git a/vercel.json b/vercel.json index 8b8ddf3..56660e8 100644 --- a/vercel.json +++ b/vercel.json @@ -39,15 +39,15 @@ "crons": [ { "path": "/api/sync/changes", - "schedule": "*/2 * * * *" + "schedule": "0 */4 * * *" }, { "path": "/api/sync/keyword", - "schedule": "*/15 * * * *" + "schedule": "0 */6 * * *" }, { "path": "/api/sync/metrics", - "schedule": "0 * * * *" + "schedule": "0 0 * * *" }, { "path": "/api/sync/stats-snapshot",