From c56c8439b5fef1962f9c17e95179091cbe2b9123 Mon Sep 17 00:00:00 2001 From: TeapoyY Date: Mon, 6 Apr 2026 02:24:30 +0800 Subject: [PATCH] feat: add Redis caching for bounty listings (issue #128) - Added ioredis dependency for Redis support - Created services/redis.ts with getCached, setCache, and deleteCachePattern helpers - Added Redis caching to GET /api/bounties listing endpoint with 30s TTL - Added cache invalidation on bounty update and complete actions - Cache key is deterministic based on query params for consistent hits --- packages/api/package.json | 1 + packages/api/src/routes/bounties.ts | 51 ++++++++++++- packages/api/src/services/redis.ts | 108 ++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/services/redis.ts diff --git a/packages/api/package.json b/packages/api/package.json index 046cdda..5fa64a8 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -30,6 +30,7 @@ "dotenv": "^17.3.1", "drizzle-orm": "^0.45.1", "hono": "^4.11.9", + "ioredis": "^5.6.1", "postgres": "^3.4.8", "uuid": "^13.0.0", "zod": "^4.3.6" diff --git a/packages/api/src/routes/bounties.ts b/packages/api/src/routes/bounties.ts index 316bda0..52a400b 100644 --- a/packages/api/src/routes/bounties.ts +++ b/packages/api/src/routes/bounties.ts @@ -6,6 +6,30 @@ import { bounties, users, applications } from '../db/schema'; import { eq, and, gte, lte, sql, desc, or, lt, count } from 'drizzle-orm'; import { alias } from 'drizzle-orm/pg-core'; import { z } from 'zod'; +import { getCached, setCache, deleteCachePattern } from '../services/redis'; + +// Cache TTLs in seconds +const BOUNTY_LIST_CACHE_TTL = 30; // 30 seconds for paginated listing +const BOUNTY_LIST_USER_TTL = 15 * 60; // 15 minutes for user-specific filtered listing + +// Build a deterministic cache key from query params +function getBountyListCacheKey(query: Record, userId?: string): string { + const params = new URLSearchParams(); + // Normalize params deterministically + const sortedKeys = Object.keys(query).sort(); + for (const key of sortedKeys) { + const val = query[key]; + if (val !== undefined && val !== '') { + if (Array.isArray(val)) { + for (const v of val) params.append(key, v); + } else { + params.append(key, val); + } + } + } + const qstr = params.toString(); + return userId ? `bounty:list:${userId}:${qstr}` : `bounty:list:anon:${qstr}`; +} const applySchema = z.object({ cover_letter: z.string().trim().min(1, 'cover_letter is required and must be a non-empty string'), @@ -44,6 +68,15 @@ bountiesRouter.get('/', async (c) => { return c.json({ error: `Invalid status. Allowed values are: ${allowedStatuses.join(', ')}` }, 400); } + // Check Redis cache (only for first page, no cursor) + if (!cursor) { + const cacheKey = getBountyListCacheKey(query); + const cached = await getCached<{ data: any[]; meta: any }>(cacheKey); + if (cached) { + return c.json(cached); + } + } + let whereClause = undefined; const filters = []; @@ -120,14 +153,22 @@ bountiesRouter.get('/', async (c) => { })).toString('base64'); } - return c.json({ + const response = { data, meta: { next_cursor: nextCursor, has_more: hasMore, count: data.length, }, - }); + }; + + // Cache the response (only first page, no cursor) + if (!cursor) { + const cacheKey = getBountyListCacheKey(query); + await setCache(cacheKey, response, BOUNTY_LIST_CACHE_TTL); + } + + return c.json(response); }); /** @@ -333,6 +374,9 @@ bountiesRouter.patch('/:id', ensureBountyCreator('id'), async (c) => { }) .where(eq(bounties.id, id)); + // Invalidate bounty listing cache + await deleteCachePattern('bounty:list:*'); + return c.json({ success: true, message: 'Bounty updated' }); }); @@ -350,6 +394,9 @@ bountiesRouter.post('/:id/complete', ensureBountyAssignee('id'), async (c) => { }) .where(eq(bounties.id, id)); + // Invalidate bounty listing cache + await deleteCachePattern('bounty:list:*'); + return c.json({ success: true, message: 'Bounty submitted for review' }); }); diff --git a/packages/api/src/services/redis.ts b/packages/api/src/services/redis.ts new file mode 100644 index 0000000..84682b9 --- /dev/null +++ b/packages/api/src/services/redis.ts @@ -0,0 +1,108 @@ +import Redis from 'ioredis'; + +let redis: Redis | null = null; + +/** + * Get or create a Redis client instance. + * Returns null if REDIS_URL is not configured. + */ +export function getRedisClient(): Redis | null { + if (!process.env.REDIS_URL) { + return null; + } + + if (!redis) { + redis = new Redis(process.env.REDIS_URL, { + maxRetriesPerRequest: 3, + retryStrategy: (times) => { + if (times > 3) return null; + return Math.min(times * 200, 2000); + }, + lazyConnect: true, + }); + + redis.on('error', (err) => { + console.error('[Redis] Connection error:', err.message); + }); + + redis.on('connect', () => { + console.log('[Redis] Connected successfully'); + }); + } + + return redis; +} + +/** + * Get a cached value from Redis and parse it as JSON. + * Returns null if Redis is not available or key does not exist. + */ +export async function getCached(key: string): Promise { + const client = getRedisClient(); + if (!client) return null; + + try { + const cached = await client.get(key); + if (cached) { + return JSON.parse(cached) as T; + } + return null; + } catch (err) { + console.error(`[Redis] Get error for key ${key}:`, err); + return null; + } +} + +/** + * Set a cached value in Redis with a TTL (in seconds). + */ +export async function setCache(key: string, value: unknown, ttlSeconds: number): Promise { + const client = getRedisClient(); + if (!client) return; + + try { + await client.setex(key, ttlSeconds, JSON.stringify(value)); + } catch (err) { + console.error(`[Redis] Set error for key ${key}:`, err); + } +} + +/** + * Delete a cached key from Redis. + */ +export async function deleteCache(key: string): Promise { + const client = getRedisClient(); + if (!client) return; + + try { + await client.del(key); + } catch (err) { + console.error(`[Redis] Delete error for key ${key}:`, err); + } +} + +/** + * Delete all cached keys matching a pattern (e.g., "bounty:list:*"). + * Uses SCAN to avoid blocking Redis. + */ +export async function deleteCachePattern(pattern: string): Promise { + const client = getRedisClient(); + if (!client) return; + + try { + let cursor = '0'; + const keysToDelete: string[] = []; + do { + const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + cursor = nextCursor; + keysToDelete.push(...keys); + } while (cursor !== '0'); + + if (keysToDelete.length > 0) { + await client.del(...keysToDelete); + console.log(`[Redis] Deleted ${keysToDelete.length} keys matching pattern: ${pattern}`); + } + } catch (err) { + console.error(`[Redis] Pattern delete error for ${pattern}:`, err); + } +}