perf: reduce Neon compute usage with cron + caching optimizations
- 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.
This commit is contained in:
parent
8f4fd77d79
commit
fd4520dbf6
6 changed files with 363 additions and 9 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
if (!process.env.KV_REST_API_URL) return null;
|
||||
return await kv.get<T>(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to set cache, silently fails if KV not configured
|
||||
*/
|
||||
async function setCache<T>(key: string, value: T, ttl: number): Promise<void> {
|
||||
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<ApiResponse>(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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
276
docs/NEON_COMPUTE_OPTIMIZATION.md
Normal file
276
docs/NEON_COMPUTE_OPTIMIZATION.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue