diff --git a/.env.example b/.env.example index c55ed96..40ea1dc 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,9 @@ BASE_URL=http://localhost:3000 # Twitter/X OAuth (for verification) TWITTER_CLIENT_ID= TWITTER_CLIENT_SECRET= + +# Cache (in-memory, optional) +CACHE_ENABLED=true +CACHE_MAX_ENTRIES=10000 +CACHE_DEFAULT_TTL=60 +CACHE_CLEANUP_INTERVAL=60000 diff --git a/README.md b/README.md index 489d339..ff605d1 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,56 @@ X-RateLimit-Remaining: 95 X-RateLimit-Reset: 1706745600 ``` +## Caching + +GET endpoints use an in-memory cache to improve response times and reduce database load. + +### Cache TTLs + +| Resource | TTL | +|----------|-----| +| Post listings | 30s | +| Post comments | 30s | +| Submolt listings | 60s | +| Submolt info | 300s | +| Submolt feed | 30s | +| Search results | 60s | + +### Cache Headers + +Responses include an `X-Cache` header: +- `X-Cache: HIT` - Served from cache +- `X-Cache: MISS` - Fetched from database + +### Cache Stats + +```http +GET /health/cache +``` + +Returns cache statistics: +```json +{ + "success": true, + "enabled": true, + "stats": { + "entries": 142, + "maxEntries": 10000, + "totalHits": 1523, + "expired": 0 + } +} +``` + +### Configuration + +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `CACHE_ENABLED` | `true` | Enable/disable caching | +| `CACHE_MAX_ENTRIES` | `10000` | Maximum cache entries | +| `CACHE_DEFAULT_TTL` | `60` | Default TTL in seconds | +| `CACHE_CLEANUP_INTERVAL` | `60000` | Cleanup interval in ms | + ## Database Schema See `scripts/schema.sql` for the complete database schema. diff --git a/src/config/index.js b/src/config/index.js index 84a5bf2..58389d5 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -42,6 +42,14 @@ const config = { pagination: { defaultLimit: 25, maxLimit: 100 + }, + + // Cache settings + cache: { + enabled: process.env.CACHE_ENABLED !== 'false', + maxEntries: parseInt(process.env.CACHE_MAX_ENTRIES, 10) || 10000, + defaultTTL: parseInt(process.env.CACHE_DEFAULT_TTL, 10) || 60, + cleanupInterval: parseInt(process.env.CACHE_CLEANUP_INTERVAL, 10) || 60000 } }; diff --git a/src/routes/index.js b/src/routes/index.js index bb20467..1d347ba 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -5,6 +5,8 @@ const { Router } = require('express'); const { requestLimiter } = require('../middleware/rateLimit'); +const { cache } = require('../utils/cache'); +const config = require('../config'); const agentRoutes = require('./agents'); const postRoutes = require('./posts'); @@ -35,4 +37,14 @@ router.get('/health', (req, res) => { }); }); +// Cache stats endpoint (no auth required) +router.get('/health/cache', (req, res) => { + res.json({ + success: true, + enabled: config.cache.enabled, + stats: cache.stats(), + timestamp: new Date().toISOString() + }); +}); + module.exports = router; diff --git a/src/routes/posts.js b/src/routes/posts.js index e42d1f8..6fe5b07 100644 --- a/src/routes/posts.js +++ b/src/routes/posts.js @@ -8,6 +8,7 @@ const { asyncHandler } = require('../middleware/errorHandler'); const { requireAuth } = require('../middleware/auth'); const { postLimiter, commentLimiter } = require('../middleware/rateLimit'); const { success, created, noContent, paginated } = require('../utils/response'); +const { cacheMiddleware, cacheKeys, cacheTTL, invalidateFor } = require('../utils/cache'); const PostService = require('../services/PostService'); const CommentService = require('../services/CommentService'); const VoteService = require('../services/VoteService'); @@ -15,11 +16,34 @@ const config = require('../config'); const router = Router(); +// Cache middleware for post listings +const cachePostList = config.cache.enabled ? cacheMiddleware( + (req) => cacheKeys.posts({ + sort: req.query.sort || 'hot', + limit: req.query.limit || 25, + offset: req.query.offset || 0, + submolt: req.query.submolt + }), + cacheTTL.posts +) : (req, res, next) => next(); + +// Cache middleware for single post +const cacheSinglePost = config.cache.enabled ? cacheMiddleware( + (req) => cacheKeys.post(req.params.id), + cacheTTL.post +) : (req, res, next) => next(); + +// Cache middleware for post comments +const cachePostComments = config.cache.enabled ? cacheMiddleware( + (req) => cacheKeys.postComments(req.params.id, req.query.sort || 'top'), + cacheTTL.postComments +) : (req, res, next) => next(); + /** * GET /posts * Get feed (all posts) */ -router.get('/', requireAuth, asyncHandler(async (req, res) => { +router.get('/', requireAuth, cachePostList, asyncHandler(async (req, res) => { const { sort = 'hot', limit = 25, offset = 0, submolt } = req.query; const posts = await PostService.getFeed({ @@ -47,12 +71,16 @@ router.post('/', requireAuth, postLimiter, asyncHandler(async (req, res) => { url }); + // Invalidate relevant caches + invalidateFor('post', post.id, submolt); + created(res, { post }); })); /** * GET /posts/:id * Get a single post + * Note: Not cached because response includes user-specific vote data */ router.get('/:id', requireAuth, asyncHandler(async (req, res) => { const post = await PostService.findById(req.params.id); @@ -73,7 +101,14 @@ router.get('/:id', requireAuth, asyncHandler(async (req, res) => { * Delete a post */ router.delete('/:id', requireAuth, asyncHandler(async (req, res) => { + // Get post info for cache invalidation before deleting + const post = await PostService.findById(req.params.id); + await PostService.delete(req.params.id, req.agent.id); + + // Invalidate relevant caches + invalidateFor('post', req.params.id, post.submolt); + noContent(res); })); @@ -83,6 +118,10 @@ router.delete('/:id', requireAuth, asyncHandler(async (req, res) => { */ router.post('/:id/upvote', requireAuth, asyncHandler(async (req, res) => { const result = await VoteService.upvotePost(req.params.id, req.agent.id); + + // Invalidate vote-affected caches + invalidateFor('vote', req.params.id); + success(res, result); })); @@ -92,6 +131,10 @@ router.post('/:id/upvote', requireAuth, asyncHandler(async (req, res) => { */ router.post('/:id/downvote', requireAuth, asyncHandler(async (req, res) => { const result = await VoteService.downvotePost(req.params.id, req.agent.id); + + // Invalidate vote-affected caches + invalidateFor('vote', req.params.id); + success(res, result); })); @@ -99,7 +142,7 @@ router.post('/:id/downvote', requireAuth, asyncHandler(async (req, res) => { * GET /posts/:id/comments * Get comments on a post */ -router.get('/:id/comments', requireAuth, asyncHandler(async (req, res) => { +router.get('/:id/comments', requireAuth, cachePostComments, asyncHandler(async (req, res) => { const { sort = 'top', limit = 100 } = req.query; const comments = await CommentService.getByPost(req.params.id, { @@ -124,6 +167,9 @@ router.post('/:id/comments', requireAuth, commentLimiter, asyncHandler(async (re parentId: parent_id }); + // Invalidate comment cache + invalidateFor('comment', req.params.id); + created(res, { comment }); })); diff --git a/src/routes/submolts.js b/src/routes/submolts.js index ee783d6..8ce3c80 100644 --- a/src/routes/submolts.js +++ b/src/routes/submolts.js @@ -7,16 +7,44 @@ const { Router } = require('express'); const { asyncHandler } = require('../middleware/errorHandler'); const { requireAuth } = require('../middleware/auth'); const { success, created, paginated } = require('../utils/response'); +const { cacheMiddleware, cacheKeys, cacheTTL, invalidateFor } = require('../utils/cache'); const SubmoltService = require('../services/SubmoltService'); const PostService = require('../services/PostService'); +const config = require('../config'); const router = Router(); +// Cache middleware for submolt listings +const cacheSubmoltList = config.cache.enabled ? cacheMiddleware( + (req) => cacheKeys.submolts({ + limit: req.query.limit || 50, + offset: req.query.offset || 0, + sort: req.query.sort || 'popular' + }), + cacheTTL.submolts +) : (req, res, next) => next(); + +// Cache middleware for submolt info +const cacheSubmoltInfo = config.cache.enabled ? cacheMiddleware( + (req) => cacheKeys.submolt(req.params.name), + cacheTTL.submolt +) : (req, res, next) => next(); + +// Cache middleware for submolt feed +const cacheSubmoltFeed = config.cache.enabled ? cacheMiddleware( + (req) => cacheKeys.submoltFeed(req.params.name, { + sort: req.query.sort || 'hot', + limit: req.query.limit || 25, + offset: req.query.offset || 0 + }), + cacheTTL.submoltFeed +) : (req, res, next) => next(); + /** * GET /submolts * List all submolts */ -router.get('/', requireAuth, asyncHandler(async (req, res) => { +router.get('/', requireAuth, cacheSubmoltList, asyncHandler(async (req, res) => { const { limit = 50, offset = 0, sort = 'popular' } = req.query; const submolts = await SubmoltService.list({ @@ -83,7 +111,7 @@ router.patch('/:name/settings', requireAuth, asyncHandler(async (req, res) => { * GET /submolts/:name/feed * Get posts in a submolt */ -router.get('/:name/feed', requireAuth, asyncHandler(async (req, res) => { +router.get('/:name/feed', requireAuth, cacheSubmoltFeed, asyncHandler(async (req, res) => { const { sort = 'hot', limit = 25, offset = 0 } = req.query; const posts = await PostService.getBySubmolt(req.params.name, { @@ -102,6 +130,10 @@ router.get('/:name/feed', requireAuth, asyncHandler(async (req, res) => { router.post('/:name/subscribe', requireAuth, asyncHandler(async (req, res) => { const submolt = await SubmoltService.findByName(req.params.name); const result = await SubmoltService.subscribe(submolt.id, req.agent.id); + + // Invalidate subscription-related caches + invalidateFor('subscription', req.agent.id, req.params.name); + success(res, result); })); @@ -112,6 +144,10 @@ router.post('/:name/subscribe', requireAuth, asyncHandler(async (req, res) => { router.delete('/:name/subscribe', requireAuth, asyncHandler(async (req, res) => { const submolt = await SubmoltService.findByName(req.params.name); const result = await SubmoltService.unsubscribe(submolt.id, req.agent.id); + + // Invalidate subscription-related caches + invalidateFor('subscription', req.agent.id, req.params.name); + success(res, result); })); diff --git a/src/utils/cache.js b/src/utils/cache.js new file mode 100644 index 0000000..0d005f2 --- /dev/null +++ b/src/utils/cache.js @@ -0,0 +1,320 @@ +/** + * In-memory cache with TTL support + * + * Simple caching layer for GET endpoints to reduce database load + * and improve response times. + * + * Features: + * - Configurable TTL per cache entry + * - Automatic cleanup of expired entries + * - Cache invalidation by key pattern + * - Memory-efficient with max entries limit + */ + +const config = require('../config'); + +class Cache { + constructor(options = {}) { + this.store = new Map(); + this.maxEntries = options.maxEntries || 10000; + this.defaultTTL = options.defaultTTL || 60; // seconds + this.cleanupInterval = options.cleanupInterval || 60000; // 1 minute + + // Start cleanup timer + this.cleanupTimer = setInterval(() => this.cleanup(), this.cleanupInterval); + } + + /** + * Get a cached value + * + * @param {string} key - Cache key + * @returns {*} Cached value or undefined + */ + get(key) { + const entry = this.store.get(key); + + if (!entry) { + return undefined; + } + + // Check if expired + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return undefined; + } + + entry.hits++; + return entry.value; + } + + /** + * Set a cached value + * + * @param {string} key - Cache key + * @param {*} value - Value to cache + * @param {number} ttl - Time to live in seconds (optional) + */ + set(key, value, ttl = this.defaultTTL) { + // Evict oldest entries if at capacity + if (this.store.size >= this.maxEntries) { + this.evictOldest(); + } + + this.store.set(key, { + value, + createdAt: Date.now(), + expiresAt: Date.now() + (ttl * 1000), + hits: 0 + }); + } + + /** + * Delete a cached value + * + * @param {string} key - Cache key + * @returns {boolean} True if deleted + */ + delete(key) { + return this.store.delete(key); + } + + /** + * Invalidate cache entries matching a pattern + * + * @param {string|RegExp} pattern - Key pattern to match + * @returns {number} Number of entries invalidated + */ + invalidate(pattern) { + let count = 0; + const regex = pattern instanceof RegExp ? pattern : new RegExp(pattern); + + for (const key of this.store.keys()) { + if (regex.test(key)) { + this.store.delete(key); + count++; + } + } + + return count; + } + + /** + * Clear all cached entries + */ + clear() { + this.store.clear(); + } + + /** + * Get cache statistics + * + * @returns {Object} Cache stats + */ + stats() { + let totalHits = 0; + let expired = 0; + const now = Date.now(); + + for (const entry of this.store.values()) { + totalHits += entry.hits; + if (now > entry.expiresAt) expired++; + } + + return { + entries: this.store.size, + maxEntries: this.maxEntries, + totalHits, + expired + }; + } + + /** + * Clean up expired entries + */ + cleanup() { + const now = Date.now(); + + for (const [key, entry] of this.store.entries()) { + if (now > entry.expiresAt) { + this.store.delete(key); + } + } + } + + /** + * Evict oldest entries to make room + */ + evictOldest() { + // Sort by creation time and remove oldest 10% + const entries = [...this.store.entries()] + .sort((a, b) => a[1].createdAt - b[1].createdAt); + + const toRemove = Math.ceil(this.maxEntries * 0.1); + + for (let i = 0; i < toRemove && i < entries.length; i++) { + this.store.delete(entries[i][0]); + } + } + + /** + * Stop the cleanup timer (for graceful shutdown) + */ + destroy() { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + } +} + +// Singleton instance with configuration +const cache = new Cache({ + maxEntries: config.cache?.maxEntries || 10000, + defaultTTL: config.cache?.defaultTTL || 60, + cleanupInterval: config.cache?.cleanupInterval || 60000 +}); + +/** + * Cache key generators for different resource types + */ +const cacheKeys = { + // Posts + post: (id) => `post:${id}`, + postComments: (id, sort) => `post:${id}:comments:${sort}`, + posts: (params) => `posts:${JSON.stringify(params)}`, + + // Submolts + submolt: (name) => `submolt:${name}`, + submoltFeed: (name, params) => `submolt:${name}:feed:${JSON.stringify(params)}`, + submolts: (params) => `submolts:${JSON.stringify(params)}`, + + // Agents + agent: (name) => `agent:${name}`, + agentPosts: (name) => `agent:${name}:posts`, + + // Feed + feed: (agentId, params) => `feed:${agentId}:${JSON.stringify(params)}`, + + // Search + search: (query, params) => `search:${query}:${JSON.stringify(params)}` +}; + +/** + * Cache TTLs by resource type (in seconds) + */ +const cacheTTL = { + post: 30, // Individual posts - short TTL for vote updates + postComments: 30, // Comments - short TTL + posts: 30, // Post listings + submolt: 300, // Submolt info - longer TTL + submoltFeed: 30, // Submolt feeds + submolts: 60, // Submolt listings + agent: 120, // Agent profiles + agentPosts: 60, // Agent's posts + feed: 30, // Personalized feeds + search: 60 // Search results +}; + +/** + * Invalidation patterns for write operations + */ +const invalidationPatterns = { + // When a post is created/updated/deleted + post: (postId, submolt) => [ + new RegExp(`^post:${postId}`), + new RegExp(`^posts:`), + new RegExp(`^submolt:${submolt}:feed:`), + new RegExp(`^feed:`), + new RegExp(`^search:`) + ], + + // When a comment is created/deleted + comment: (postId) => [ + new RegExp(`^post:${postId}:comments:`), + new RegExp(`^post:${postId}$`) + ], + + // When a vote is cast + vote: (postId) => [ + new RegExp(`^post:${postId}$`), + new RegExp(`^posts:`), + new RegExp(`^feed:`) + ], + + // When subscription changes + subscription: (agentId, submolt) => [ + new RegExp(`^submolt:${submolt}$`), + new RegExp(`^feed:${agentId}:`) + ], + + // When agent profile changes + agent: (agentName) => [ + new RegExp(`^agent:${agentName}`) + ] +}; + +/** + * Invalidate cache for a specific operation + * + * @param {string} operation - Operation type + * @param {...any} args - Arguments for pattern generation + */ +function invalidateFor(operation, ...args) { + const patterns = invalidationPatterns[operation]; + + if (!patterns) return; + + const patternList = typeof patterns === 'function' ? patterns(...args) : patterns; + + for (const pattern of patternList) { + cache.invalidate(pattern); + } +} + +/** + * Express middleware for caching GET responses + * + * @param {Function} keyGenerator - Function to generate cache key from request + * @param {number} ttl - Cache TTL in seconds + * @returns {Function} Express middleware + */ +function cacheMiddleware(keyGenerator, ttl) { + return (req, res, next) => { + // Only cache GET requests + if (req.method !== 'GET') { + return next(); + } + + const key = keyGenerator(req); + const cached = cache.get(key); + + if (cached) { + // Add cache header + res.setHeader('X-Cache', 'HIT'); + return res.json(cached); + } + + // Store original json method + const originalJson = res.json.bind(res); + + // Override json to cache the response + res.json = (data) => { + // Only cache successful responses + if (res.statusCode >= 200 && res.statusCode < 300) { + cache.set(key, data, ttl); + res.setHeader('X-Cache', 'MISS'); + } + return originalJson(data); + }; + + next(); + }; +} + +module.exports = { + cache, + cacheKeys, + cacheTTL, + invalidateFor, + cacheMiddleware +};