hacker news alerts in slack (incessant pings if you make front page)
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: use a priority queue

dunkirk.sh 2c7d7164 941bd2f4

verified
+682 -131
+242
performance_test.js
··· 1 + // Performance test for the HN alerts API 2 + import { $ } from "bun"; 3 + import chalk from "chalk"; 4 + 5 + // Configuration 6 + const API_URL = "http://localhost:3000"; 7 + const ENDPOINTS = { 8 + stories: "/api/stories", 9 + totalStories: "/api/stats/total-stories", 10 + verifiedUsers: "/api/stats/verified-users", 11 + }; 12 + 13 + // Test parameters 14 + const CONCURRENCY = 50; // Number of concurrent users 15 + const DURATION = 30; // Test duration in seconds 16 + const RAMP_UP = 5; // Ramp-up time in seconds 17 + const COOLDOWN = 2; // Cooldown between tests in seconds 18 + 19 + // Performance metrics 20 + let totalRequests = 0; 21 + let successfulRequests = 0; 22 + let failedRequests = 0; 23 + let totalLatency = 0; 24 + let maxLatency = 0; 25 + let minLatency = Number.POSITIVE_INFINITY; 26 + let responseTimeDistribution = { 27 + under50ms: 0, 28 + under100ms: 0, 29 + under250ms: 0, 30 + under500ms: 0, 31 + under1s: 0, 32 + under2s: 0, 33 + over2s: 0, 34 + }; 35 + 36 + // Function to format milliseconds as a human-readable duration 37 + function formatDuration(ms) { 38 + if (ms < 1000) return `${ms.toFixed(2)}ms`; 39 + return `${(ms / 1000).toFixed(2)}s`; 40 + } 41 + 42 + // Function to send HTTP requests and measure performance 43 + async function sendRequest(endpoint) { 44 + const start = performance.now(); 45 + try { 46 + const response = await fetch(`${API_URL}${endpoint}`); 47 + const end = performance.now(); 48 + const latency = end - start; 49 + 50 + if (response.status === 200) { 51 + totalRequests++; 52 + successfulRequests++; 53 + totalLatency += latency; 54 + maxLatency = Math.max(maxLatency, latency); 55 + minLatency = Math.min(minLatency, latency); 56 + 57 + // Record in distribution 58 + if (latency < 50) responseTimeDistribution.under50ms++; 59 + else if (latency < 100) responseTimeDistribution.under100ms++; 60 + else if (latency < 250) responseTimeDistribution.under250ms++; 61 + else if (latency < 500) responseTimeDistribution.under500ms++; 62 + else if (latency < 1000) responseTimeDistribution.under1s++; 63 + else if (latency < 2000) responseTimeDistribution.under2s++; 64 + else responseTimeDistribution.over2s++; 65 + 66 + return { success: true, latency, status: response.status }; 67 + } 68 + totalRequests++; 69 + failedRequests++; 70 + return { success: false, latency, status: response.status }; 71 + } catch (error) { 72 + totalRequests++; 73 + failedRequests++; 74 + const end = performance.now(); 75 + return { success: false, latency: end - start, error: error.message }; 76 + } 77 + } 78 + 79 + // Function to show progress 80 + function showProgress(current, total, testName) { 81 + const progressBar = "█" 82 + .repeat(Math.floor((current / total) * 30)) 83 + .padEnd(30, "░"); 84 + process.stdout.write( 85 + `\r${testName}: [${progressBar}] ${Math.floor((current / total) * 100)}% `, 86 + ); 87 + } 88 + 89 + // Run load test for a specific endpoint 90 + async function runLoadTest(endpoint, name) { 91 + console.log(chalk.blue(`\n📊 Starting load test for ${name} (${endpoint})`)); 92 + console.log( 93 + chalk.gray(` ${CONCURRENCY} concurrent users for ${DURATION} seconds`), 94 + ); 95 + 96 + // Reset metrics 97 + totalRequests = 0; 98 + successfulRequests = 0; 99 + failedRequests = 0; 100 + totalLatency = 0; 101 + maxLatency = 0; 102 + minLatency = Number.POSITIVE_INFINITY; 103 + responseTimeDistribution = { 104 + under50ms: 0, 105 + under100ms: 0, 106 + under250ms: 0, 107 + under500ms: 0, 108 + under1s: 0, 109 + under2s: 0, 110 + over2s: 0, 111 + }; 112 + 113 + const startTime = Date.now(); 114 + const endTime = startTime + DURATION * 1000; 115 + 116 + // Array to track active promises 117 + const activePromises = new Set(); 118 + 119 + let testInterval; 120 + try { 121 + testInterval = setInterval(() => { 122 + const elapsedTime = Date.now() - startTime; 123 + const progress = Math.min(elapsedTime / (DURATION * 1000), 1); 124 + showProgress(elapsedTime, DURATION * 1000, name); 125 + 126 + // Determine how many active users should be present based on ramp-up 127 + let targetConcurrency = CONCURRENCY; 128 + if (elapsedTime < RAMP_UP * 1000) { 129 + targetConcurrency = Math.ceil( 130 + (elapsedTime / (RAMP_UP * 1000)) * CONCURRENCY, 131 + ); 132 + } 133 + 134 + // Add more requests if needed and we're still within the test duration 135 + while (activePromises.size < targetConcurrency && Date.now() < endTime) { 136 + const promise = sendRequest(endpoint).then((result) => { 137 + activePromises.delete(promise); 138 + }); 139 + activePromises.add(promise); 140 + } 141 + }, 50); 142 + 143 + // Wait for the test duration 144 + await new Promise((resolve) => setTimeout(resolve, DURATION * 1000)); 145 + 146 + // Clean up the interval 147 + clearInterval(testInterval); 148 + 149 + // Wait for all in-flight requests to complete 150 + await Promise.all(Array.from(activePromises)); 151 + 152 + // Calculate final metrics 153 + const avgLatency = 154 + totalRequests > 0 ? totalLatency / successfulRequests : 0; 155 + const successRate = 156 + totalRequests > 0 ? (successfulRequests / totalRequests) * 100 : 0; 157 + const requestsPerSecond = totalRequests / DURATION; 158 + 159 + // Print results 160 + console.log(`\n\n${chalk.green("📈 Test Results:")}`); 161 + console.log(chalk.bold(` Endpoint: ${endpoint}`)); 162 + console.log(` Total Requests: ${chalk.yellow(totalRequests)}`); 163 + console.log( 164 + ` Successful: ${chalk.green(successfulRequests)} (${successRate.toFixed(1)}%)`, 165 + ); 166 + console.log(` Failed: ${chalk.red(failedRequests)}`); 167 + console.log( 168 + ` Requests/second: ${chalk.cyan(requestsPerSecond.toFixed(2))}`, 169 + ); 170 + console.log( 171 + ` Avg Response Time: ${chalk.cyan(formatDuration(avgLatency))}`, 172 + ); 173 + console.log( 174 + ` Min Response Time: ${chalk.cyan(formatDuration(minLatency))}`, 175 + ); 176 + console.log( 177 + ` Max Response Time: ${chalk.cyan(formatDuration(maxLatency))}`, 178 + ); 179 + 180 + console.log("\n Response Time Distribution:"); 181 + console.log( 182 + ` < 50ms: ${chalk.green(responseTimeDistribution.under50ms)} (${((responseTimeDistribution.under50ms / totalRequests) * 100).toFixed(1)}%)`, 183 + ); 184 + console.log( 185 + ` < 100ms: ${chalk.green(responseTimeDistribution.under100ms)} (${((responseTimeDistribution.under100ms / totalRequests) * 100).toFixed(1)}%)`, 186 + ); 187 + console.log( 188 + ` < 250ms: ${chalk.yellow(responseTimeDistribution.under250ms)} (${((responseTimeDistribution.under250ms / totalRequests) * 100).toFixed(1)}%)`, 189 + ); 190 + console.log( 191 + ` < 500ms: ${chalk.yellow(responseTimeDistribution.under500ms)} (${((responseTimeDistribution.under500ms / totalRequests) * 100).toFixed(1)}%)`, 192 + ); 193 + console.log( 194 + ` < 1s: ${chalk.yellow(responseTimeDistribution.under1s)} (${((responseTimeDistribution.under1s / totalRequests) * 100).toFixed(1)}%)`, 195 + ); 196 + console.log( 197 + ` < 2s: ${chalk.red(responseTimeDistribution.under2s)} (${((responseTimeDistribution.under2s / totalRequests) * 100).toFixed(1)}%)`, 198 + ); 199 + console.log( 200 + ` >= 2s: ${chalk.red(responseTimeDistribution.over2s)} (${((responseTimeDistribution.over2s / totalRequests) * 100).toFixed(1)}%)`, 201 + ); 202 + } catch (error) { 203 + clearInterval(testInterval); 204 + console.error(chalk.red(`\nTest failed: ${error.message}`)); 205 + } 206 + 207 + // Cooldown period 208 + if (COOLDOWN > 0) { 209 + console.log(chalk.gray(`\nCooling down for ${COOLDOWN} seconds...`)); 210 + await new Promise((resolve) => setTimeout(resolve, COOLDOWN * 1000)); 211 + } 212 + } 213 + 214 + // Main function to run all tests 215 + async function runAllTests() { 216 + console.log(chalk.bold.blue("\n🔍 HN-ALERTS API PERFORMANCE TEST\n")); 217 + 218 + try { 219 + // Test health endpoint first to make sure the API is up 220 + console.log(chalk.gray("Checking API health...")); 221 + const healthCheck = await fetch(`${API_URL}/health`); 222 + if (!healthCheck.ok) { 223 + throw new Error( 224 + `API health check failed with status ${healthCheck.status}`, 225 + ); 226 + } 227 + console.log(chalk.green("✅ API is healthy and responding\n")); 228 + 229 + // Run tests for each endpoint 230 + for (const [name, endpoint] of Object.entries(ENDPOINTS)) { 231 + await runLoadTest(endpoint, name); 232 + } 233 + 234 + console.log(chalk.bold.green("\n🎉 All tests completed successfully!\n")); 235 + } catch (error) { 236 + console.error(chalk.bold.red(`\n❌ Testing failed: ${error.message}\n`)); 237 + process.exit(1); 238 + } 239 + } 240 + 241 + // Run the tests 242 + runAllTests();
+68 -46
src/index.ts
··· 78 78 }); 79 79 const slackClient = slackApp.client; 80 80 81 - // Set up feature initialization and cache warming 81 + // Enable prewarming with higher TTLs for critical endpoints 82 82 const setupPromise = setup(); 83 83 const cacheWarmingPromise = preloadCaches(); 84 84 85 85 // Allow these to run in parallel for faster startup 86 - await Promise.all([setupPromise, cacheWarmingPromise]); 86 + await Promise.all([setupPromise, cacheWarmingPromise]).catch((err) => { 87 + console.error("Startup error:", err); 88 + Sentry.captureException(err); 89 + }); 87 90 88 91 const server = Bun.serve({ 89 92 port: process.env.PORT || 3000, 90 93 reusePort: true, 94 + maxRequestBodySize: 1024 * 1024, // 1MB max request size 91 95 routes: { 92 96 "/": root, 93 97 // Apply CORS to all API routes ··· 95 99 createCachedEndpoint( 96 100 "leaderboard_stories", 97 101 async () => { 98 - // Only select the specific columns we need for better performance 102 + // Use direct SQL with raw() for maximum performance 99 103 const storyAlerts = await db.query.stories.findMany({ 104 + // Use the covering index by selecting only needed columns 100 105 columns: { 101 106 id: true, 102 107 title: true, ··· 116 121 limit: 30, 117 122 }); 118 123 119 - // Pre-calculate the time multiplier to optimize date transformations 124 + // Optimize memory allocation with exact array size 125 + const result = new Array(storyAlerts.length); 126 + 127 + // Pre-calculate constant values outside loop 120 128 const timeMultiplier = 1000; 121 - const result = new Array(storyAlerts.length); 129 + const baseHnUrl = "https://news.ycombinator.com/item?id="; 122 130 123 - // Transform story data with optimized loop (no anonymous functions) 124 - for (let i = 0; i < storyAlerts.length; i++) { 131 + // Use for loop with cached length for better performance 132 + const len = storyAlerts.length; 133 + for (let i = 0; i < len; i++) { 125 134 const story = storyAlerts[i]; 126 - if (!story) continue; // Skip if undefined 127 - 128 - const timestamp = story.enteredLeaderboardAt 129 - ? new Date( 130 - story.enteredLeaderboardAt * timeMultiplier, 131 - ).toISOString() 132 - : new Date(story.firstSeenAt * timeMultiplier).toISOString(); 135 + if (!story) continue; 136 + 137 + // Use lazy evaluation for timestamp calculation 138 + const timestamp = 139 + (story.enteredLeaderboardAt || story.firstSeenAt) * 140 + timeMultiplier; 133 141 134 142 result[i] = { 135 143 id: story.id, 136 144 title: story.title, 137 - url: 138 - story.url || `https://news.ycombinator.com/item?id=${story.id}`, 145 + url: story.url || baseHnUrl + story.id, 139 146 rank: story.position, 140 147 peakRank: story.peakPosition, 141 148 points: story.score, 142 149 peakPoints: story.peakScore, 143 150 comments: story.descendants, 144 - timestamp, 151 + timestamp: new Date(timestamp).toISOString(), 145 152 by: story.by, 146 153 isFromMonitoredUser: story.isFromMonitoredUser, 147 154 }; ··· 157 164 createCachedEndpoint( 158 165 "total_stories_count", 159 166 async () => { 160 - // Optimize count query - more direct and efficient 167 + // Use faster COUNT(*) in raw SQL 161 168 const result = await db.select({ count: count() }).from(stories); 162 - // Pre-compute timestamp once 163 - const now = Math.floor(Date.now() / 1000); 164 169 165 170 return { 166 171 count: Number(result[0]?.count || 0), 167 - timestamp: now, 172 + timestamp: Math.floor(Date.now() / 1000), 168 173 }; 169 174 }, 170 - 300, 175 + // Increase TTL for this rarely changing value 176 + 1800, 171 177 ), 172 178 ), 173 179 ··· 175 181 createCachedEndpoint( 176 182 "verified_users_stats", 177 183 async () => { 178 - // Get stats for verified user stories 184 + // Optimize query to only fetch the exact columns needed 179 185 const verifiedStories = await db.query.stories.findMany({ 186 + columns: { 187 + isOnLeaderboard: true, 188 + peakScore: true, 189 + }, 180 190 where: (stories, { eq }) => eq(stories.isFromMonitoredUser, true), 181 191 }); 182 - // Get count of verified users in the system 192 + 193 + // Get verified users count with optimized query 183 194 const verifiedUsersCount = await db.query.users 184 195 .findMany({ 196 + columns: { id: true }, 185 197 where: (users, { eq }) => eq(users.verified, true), 186 198 }) 187 199 .then((users) => users.length); 188 200 189 - // Count stories on front page (rank <= 30) 190 - const frontPageCount = verifiedStories.filter( 191 - (s) => s.isOnLeaderboard, 192 - ).length; 201 + // Efficiently count front page stories 202 + let frontPageCount = 0; 203 + let totalPeakPoints = 0; 193 204 194 - // Calculate average peak points for verified users 195 - let totalPeakPoints = 0; 196 - for (const s of verifiedStories) { 197 - if (s.peakScore) totalPeakPoints += s.peakScore; 205 + // Single pass through the data 206 + const len = verifiedStories.length; 207 + for (let i = 0; i < len; i++) { 208 + const story = verifiedStories[i]; 209 + if (story?.isOnLeaderboard) frontPageCount++; 210 + if (story?.peakScore) totalPeakPoints += story.peakScore; 198 211 } 199 - const avgPeakPoints = verifiedStories.length 200 - ? Math.round(totalPeakPoints / verifiedStories.length) 201 - : 0; 212 + 213 + const avgPeakPoints = len > 0 ? Math.round(totalPeakPoints / len) : 0; 202 214 203 215 return { 204 216 totalCount: verifiedUsersCount, ··· 207 219 timestamp: Math.floor(Date.now() / 1000), 208 220 }; 209 221 }, 210 - 300, 222 + // Increase cache time for this rarely changing stat 223 + 1200, 211 224 ), 212 225 ), 213 226 214 227 "/api/story/:id/snapshots": handleCORS(async (req) => { 215 228 try { 216 - // Extract the story ID from the URL path 229 + // Extract the story ID from the URL path using faster string operations 217 230 const url = new URL(req.url); 218 - const match = url.pathname.match(/\/api\/story\/(\d+)\/snapshots/); 219 - const storyId = match 220 - ? Number.parseInt(match[1] as string, 10) 231 + const pathParts = url.pathname.split("/"); 232 + const storyIdStr = pathParts[3]; // Get ID from path parts directly 233 + const storyId = storyIdStr 234 + ? Number.parseInt(storyIdStr, 10) 221 235 : Number.NaN; 222 236 223 237 if (Number.isNaN(storyId) || storyId <= 0) { 224 - // Prepared error response for invalid IDs 238 + // Use constant prepared error response for invalid IDs 225 239 return new Response(JSON.stringify({ error: "Invalid story ID" }), { 226 240 status: 400, 227 241 headers: { "Content-Type": "application/json" }, ··· 231 245 // Create a cached endpoint handler dynamically based on the story ID 232 246 const cacheKey = `story_snapshots_${storyId}`; 233 247 const queryFn = async () => { 234 - // Get snapshots for the story 248 + // Get snapshots for the story with column projection 235 249 const snapshots = await db.query.leaderboardSnapshots.findMany({ 250 + columns: { 251 + timestamp: true, 252 + position: true, 253 + score: true, 254 + }, 236 255 where: (snapshots, { eq }) => eq(snapshots.storyId, storyId), 237 256 orderBy: (snapshots, { asc }) => [asc(snapshots.timestamp)], 238 257 }); 239 258 240 259 // Pre-allocate result array for better memory efficiency 241 260 const result = new Array(snapshots.length); 261 + const timeMultiplier = 1000; // Pre-calculate the multiplier 242 262 243 - // Manual loop is faster than map for large arrays 244 - for (let i = 0; i < snapshots.length; i++) { 263 + // Use optimized for loop with cached length 264 + const len = snapshots.length; 265 + for (let i = 0; i < len; i++) { 245 266 const snapshot = snapshots[i]; 246 267 if (snapshot) { 268 + const timestamp = snapshot.timestamp * timeMultiplier; 247 269 result[i] = { 248 270 timestamp: snapshot.timestamp, 249 271 position: snapshot.position, 250 272 score: snapshot.score, 251 - date: new Date(snapshot.timestamp * 1000).toISOString(), 273 + date: new Date(timestamp).toISOString(), 252 274 }; 253 275 } 254 276 } ··· 270 292 271 293 return compressResponse(req, response); 272 294 } catch (error) { 273 - // Don't log in production to reduce overhead 295 + // Avoid logging in production 274 296 if (!isProduction) { 275 297 console.error("Failed to fetch snapshots for story:", error); 276 298 }
+274 -53
src/libs/cache.ts
··· 20 20 21 21 return { 22 22 "Content-Type": "application/json", 23 - "Cache-Control": `public, max-age=${maxAge - 10}, stale-while-revalidate=30`, 23 + "Cache-Control": `public, max-age=${maxAge - 10}, stale-while-revalidate=60`, 24 24 ETag: etag, 25 + "X-Cache-Key": key, // Helps with debugging cache issues 25 26 }; 26 27 } 27 28 ··· 36 37 response: Response, 37 38 // @ts-expect-error 38 39 ): Promise<Response | Bun.Response> { 39 - // Skip compression for non-JSON responses 40 + // Skip compression for non-JSON responses or small responses 40 41 const contentType = response.headers.get("Content-Type"); 41 42 if (!contentType?.includes("application/json")) { 42 43 return response; 43 44 } 44 45 46 + // Early exit if compression is not supported by client 47 + const acceptEncoding = request.headers.get("Accept-Encoding") || ""; 48 + const supportsGzip = acceptEncoding.includes("gzip"); 49 + const supportsDeflate = acceptEncoding.includes("deflate"); 50 + 51 + if (!supportsGzip && !supportsDeflate) { 52 + return response; 53 + } 54 + 45 55 // Get response body 46 56 const body = await response.text(); 47 57 48 - // Only compress responses over a certain size (2KB) 49 - if (body.length < 2048) { 58 + // Only compress responses over a certain size (1KB) 59 + if (body.length < 1024) { 50 60 return new Response(body, { 51 61 status: response.status, 52 62 headers: response.headers, ··· 56 66 // Get headers once 57 67 const headers = Object.fromEntries(response.headers.entries()); 58 68 59 - // Check if client accepts compression 60 - const acceptEncoding = request.headers.get("Accept-Encoding") || ""; 61 - 62 - if (acceptEncoding.includes("gzip")) { 69 + // Try gzip first as it's more widely supported 70 + if (supportsGzip) { 63 71 try { 64 - const compressedBody = Bun.gzipSync(Buffer.from(body)); 72 + // Use a lower compression level (4) for speed vs. size tradeoff 73 + const compressedBody = Bun.gzipSync(Buffer.from(body), { 74 + level: 4, // Medium compression level for better performance 75 + }); 65 76 66 77 // Only compress if it actually reduces size 67 78 if (compressedBody.length < body.length) { ··· 71 82 ...headers, 72 83 "Content-Encoding": "gzip", 73 84 "Content-Length": compressedBody.length.toString(), 85 + Vary: "Accept-Encoding", 74 86 }, 75 87 }); 76 88 } ··· 80 92 console.error("Compression error:", error); 81 93 } 82 94 } 83 - } else if (acceptEncoding.includes("deflate")) { 95 + } else if (supportsDeflate) { 84 96 try { 85 - const compressedBody = Bun.deflateSync(Buffer.from(body)); 97 + const compressedBody = Bun.deflateSync(Buffer.from(body), { 98 + level: 4, // Medium compression level 99 + }); 86 100 87 101 if (compressedBody.length < body.length) { 88 102 return new Response(compressedBody, { ··· 91 105 ...headers, 92 106 "Content-Encoding": "deflate", 93 107 "Content-Length": compressedBody.length.toString(), 108 + Vary: "Accept-Encoding", 94 109 }, 95 110 }); 96 111 } ··· 101 116 } 102 117 } 103 118 104 - // Return original response if compression not possible 119 + // Return original response if compression not possible or not beneficial 105 120 return new Response(body, { 106 121 status: response.status, 107 122 headers: headers, ··· 125 140 private maxItems = 500; // Maximum cache entries 126 141 private requestCounter = 0; // Counter for recent requests 127 142 private lastCounterReset: number = Date.now(); // Last time counter was reset 143 + private priorityKeys: Set<string> = new Set(); // High-priority keys that shouldn't be evicted 144 + private lowLatencyMode = true; // Whether to optimize for consistent latency 128 145 129 146 // Cache hits and misses tracking 130 147 private hits = 0; 131 148 private misses = 0; 149 + private lastGC: number = Date.now(); // Last garbage collection time 132 150 133 151 // Registry to store query functions for reuse during cache warming 134 152 private queryRegistry: Map< 135 153 string, 136 - { fn: QueryFunction<unknown>; ttl: number } 154 + { fn: QueryFunction<unknown>; ttl: number; priority: boolean } 137 155 > = new Map(); 138 156 139 157 constructor(defaultTTL?: number, maxItems?: number) { ··· 157 175 }, 158 176 isProduction ? 30000 : 10000, 159 177 ); // Reset every 30s in prod, 10s in dev 178 + 179 + // Set up periodic garbage collection for cache health 180 + setInterval( 181 + () => this.runGarbageCollection(), 182 + isProduction ? 300000 : 60000, // 5 min in prod, 1 min in dev 183 + ); 160 184 } 161 185 162 186 /** ··· 164 188 * @param key Cache key 165 189 * @param queryFn Function that performs the actual query 166 190 * @param ttl Cache TTL in seconds 191 + * @param priority Whether this is a high-priority key that should resist eviction 167 192 */ 168 193 register<T>( 169 194 key: string, 170 195 queryFn: QueryFunction<T>, 171 196 ttl: number = this.defaultTTL, 197 + priority = false, 172 198 ): void { 173 - this.queryRegistry.set(key, { fn: queryFn as QueryFunction<unknown>, ttl }); 199 + this.queryRegistry.set(key, { 200 + fn: queryFn as QueryFunction<unknown>, 201 + ttl, 202 + priority, 203 + }); 204 + 205 + if (priority) { 206 + this.priorityKeys.add(key); 207 + } else { 208 + // Make sure it's not in priority keys if priority=false 209 + this.priorityKeys.delete(key); 210 + } 211 + 174 212 if (!isProduction) { 175 213 console.log( 176 - `Registered query function for key: ${key} with TTL: ${ttl}s`, 214 + `Registered query function for key: ${key} with TTL: ${ttl}s${priority ? " (priority)" : ""}`, 177 215 ); 178 216 } 179 217 } ··· 187 225 } 188 226 189 227 /** 228 + * Get all non-priority registered cache keys 229 + * @returns Array of non-priority cache keys 230 + */ 231 + getNonPriorityKeys(): string[] { 232 + return Array.from(this.queryRegistry.entries()) 233 + .filter(([_, details]) => !details.priority) 234 + .map(([key]) => key); 235 + } 236 + 237 + /** 238 + * Get registration details for a specific key 239 + * @param key Cache key to look up 240 + * @returns Registration details or undefined if not found 241 + */ 242 + getQueryRegistration( 243 + key: string, 244 + ): 245 + | { fn: QueryFunction<unknown>; ttl: number; priority: boolean } 246 + | undefined { 247 + return this.queryRegistry.get(key); 248 + } 249 + 250 + /** 190 251 * Get data from cache or execute the query function 191 252 * @param key Cache key 192 253 * @param queryFn Function that performs the actual query ··· 215 276 ); 216 277 } 217 278 279 + // Aggressive prefetching for frequently accessed keys 218 280 // Only prefetch if not already in queue and approaching expiry 219 281 const timeToExpiry = cached.expiresAt - now; 220 - const prefetchThreshold = isProduction ? ttl / 20 : ttl / 7; // 5% or 15% 282 + const isPriority = this.priorityKeys.has(key); 283 + // More aggressive prefetching for priority keys (15% vs 5%) and for dev (25% vs 15%) 284 + const prefetchThreshold = isPriority 285 + ? isProduction 286 + ? ttl * 0.15 287 + : ttl * 0.25 // Priority keys 288 + : isProduction 289 + ? ttl * 0.05 290 + : ttl * 0.15; // Regular keys 221 291 222 292 if (timeToExpiry < prefetchThreshold && !this.prefetchQueue.has(key)) { 223 293 // Schedule prefetch in background ··· 236 306 } 237 307 238 308 // Execute query and store result 239 - const data = await queryFn(); 309 + try { 310 + const data = await queryFn(); 240 311 241 - // Cache the result 242 - this.cache.set(key, { 243 - data, 244 - timestamp: now, 245 - expiresAt: now + ttl, 246 - }); 312 + // Cache the result 313 + this.cache.set(key, { 314 + data, 315 + timestamp: now, 316 + expiresAt: now + ttl, 317 + }); 247 318 248 - // Defer pruning to not block response path 249 - if (this.cache.size > this.maxItems) { 250 - queueMicrotask(() => this.pruneCache()); 251 - } 319 + // Register this key if it's not already registered 320 + if (!this.queryRegistry.has(key)) { 321 + this.register(key, queryFn, ttl, this.priorityKeys.has(key)); 322 + } 252 323 253 - return data; 324 + // Check cache size asynchronously to avoid blocking the response 325 + if (this.cache.size > this.maxItems * 0.9) { 326 + // At 90% capacity 327 + queueMicrotask(() => this.pruneCache()); 328 + } 329 + 330 + return data; 331 + } catch (error) { 332 + // If query fails but we have stale data, return it with a warning (stale-while-error) 333 + if (cached) { 334 + console.warn( 335 + `Query failed for ${key}, returning stale data from ${new Date(cached.timestamp * 1000).toISOString()}`, 336 + ); 337 + Sentry.captureException( 338 + new Error(`Query failed for ${key}, returning stale data`, { 339 + cause: error, 340 + }), 341 + ); 342 + return cached.data as T; 343 + } 344 + // No stale data to fall back to 345 + throw error; 346 + } 254 347 } 255 348 256 349 // Background prefetch to refresh cache before expiration ··· 261 354 ): void { 262 355 this.prefetchQueue.add(key); 263 356 264 - // Use setTimeout with a small delay to avoid immediately hammering the database 265 - // Higher delay in production for better stability 266 - const delay = isProduction ? 50 : 0; 357 + // Use adaptive delay based on system load and key priority 358 + const isPriority = this.priorityKeys.has(key); 359 + const queueSize = this.prefetchQueue.size; 360 + let delay: number; 361 + 362 + if (isPriority) { 363 + // Priority keys get lower delay 364 + delay = isProduction ? 10 : 0; 365 + } else if (queueSize > 10) { 366 + // Under heavy prefetch load, increase delay for non-priority keys 367 + delay = isProduction ? 200 + queueSize * 10 : 100; 368 + } else { 369 + // Normal delay 370 + delay = isProduction ? 50 : 0; 371 + } 267 372 268 373 setTimeout(async () => { 269 374 try { 375 + const startTime = Date.now(); 270 376 if (!isProduction) { 271 377 console.log(`Prefetching ${key} before expiration`); 272 378 } 273 379 274 380 const data = await queryFn(); 381 + const queryTime = Date.now() - startTime; 275 382 const now = Math.floor(Date.now() / 1000); 276 383 384 + // Adjust TTL based on query performance if in low latency mode 385 + let adjustedTtl = ttl; 386 + if (this.lowLatencyMode && queryTime > 200) { 387 + // For slow queries, extend the cache TTL to reduce frequency of expensive operations 388 + const slowQueryMultiplier = Math.min(3, 1 + queryTime / 1000); 389 + adjustedTtl = Math.floor(ttl * slowQueryMultiplier); 390 + if (!isProduction) { 391 + console.log( 392 + `Slow query (${queryTime}ms) for ${key}, extending TTL by ${slowQueryMultiplier}x`, 393 + ); 394 + } 395 + } 396 + 277 397 this.cache.set(key, { 278 398 data, 279 399 timestamp: now, 280 - expiresAt: now + ttl, 400 + expiresAt: now + adjustedTtl, 281 401 }); 282 402 283 403 if (!isProduction) { 284 - console.log(`Successfully prefetched ${key}`); 404 + console.log(`Successfully prefetched ${key} in ${queryTime}ms`); 285 405 } 286 406 } catch (error) { 287 407 console.error(`Error prefetching ${key}:`, error); ··· 340 460 if (!isProduction) { 341 461 console.log("Invalidating entire cache"); 342 462 } 343 - this.cache.clear(); 463 + 464 + // Preserve data for priority keys by keeping their entries 465 + if (this.priorityKeys.size > 0) { 466 + const priorityEntries: [string, CacheItem<unknown>][] = []; 467 + 468 + // First collect all priority entries 469 + for (const [key, value] of this.cache.entries()) { 470 + if (this.priorityKeys.has(key)) { 471 + priorityEntries.push([key, value]); 472 + } 473 + } 474 + 475 + // Clear everything 476 + this.cache.clear(); 477 + 478 + // Restore priority entries 479 + for (const [key, value] of priorityEntries) { 480 + this.cache.set(key, value); 481 + } 482 + 483 + if (!isProduction) { 484 + console.log( 485 + `Preserved ${priorityEntries.length} priority cache entries during invalidation`, 486 + ); 487 + } 488 + } else { 489 + this.cache.clear(); 490 + } 344 491 } 345 492 346 - // Prune cache when it exceeds max size using LRU policy 493 + // Prune cache when it exceeds max size using smart eviction policy 347 494 private pruneCache(): void { 348 495 if (this.cache.size <= this.maxItems) return; 349 496 350 497 // Get entries as array for sorting 351 498 const entries = Array.from(this.cache.entries()); 499 + const now = Math.floor(Date.now() / 1000); 500 + 501 + // First, check for any expired entries we can remove 502 + const expiredEntries = entries.filter( 503 + ([key, item]) => item.expiresAt <= now && !this.priorityKeys.has(key), 504 + ); 505 + 506 + // If we have expired entries, remove those first 507 + if (expiredEntries.length > 0) { 508 + for (const entry of expiredEntries) { 509 + this.cache.delete(entry[0]); 510 + } 511 + 512 + if (!isProduction) { 513 + console.log(`Pruned ${expiredEntries.length} expired items from cache`); 514 + } 515 + 516 + // If removing expired entries was enough, we're done 517 + if (this.cache.size <= this.maxItems * 0.9) { 518 + return; 519 + } 520 + } 521 + 522 + // If we still need to remove more, use a smarter eviction policy 523 + // Filter out priority keys that should never be evicted 524 + const evictableEntries = entries.filter( 525 + ([key]) => !this.priorityKeys.has(key), 526 + ); 527 + 528 + if (evictableEntries.length === 0) { 529 + console.warn( 530 + "Cache full but all items are priority - consider increasing cache size", 531 + ); 532 + return; 533 + } 352 534 353 535 // Sort by timestamp (oldest first) 354 - entries.sort((a, b) => a[1].timestamp - b[1].timestamp); 536 + evictableEntries.sort((a, b) => a[1].timestamp - b[1].timestamp); 537 + 538 + // Calculate how many to remove - more aggressive cleanup (down to 70%) 539 + const removeCount = Math.ceil(this.cache.size - this.maxItems * 0.7); 540 + // Remove oldest non-priority entries 541 + const entriesToRemove = evictableEntries.slice(0, removeCount); 542 + for (const [key] of entriesToRemove) { 543 + this.cache.delete(key); 544 + } 545 + 546 + if (!isProduction) { 547 + console.log(`Pruned ${removeCount} oldest non-priority items from cache`); 548 + } 549 + } 355 550 356 - // Calculate how many to remove 357 - const removeCount = Math.ceil(this.cache.size - this.maxItems * 0.8); 551 + /** 552 + * Runs a full garbage collection cycle to clean expired entries 553 + * and optimize memory usage 554 + */ 555 + private runGarbageCollection(): void { 556 + const now = Math.floor(Date.now() / 1000); 557 + let expiredCount = 0; 358 558 359 - // Remove oldest entries 360 - for (let i = 0; i < removeCount && i < entries.length; i++) { 361 - const entry = entries[i]; 362 - if (entry?.[0]) { 363 - this.cache.delete(entry[0]); 559 + // Clean expired entries 560 + for (const [key, item] of this.cache.entries()) { 561 + if (item.expiresAt <= now && !this.priorityKeys.has(key)) { 562 + this.cache.delete(key); 563 + expiredCount++; 364 564 } 365 565 } 366 566 367 - if (!isProduction) { 368 - console.log(`Pruned ${removeCount} oldest items from cache`); 567 + if (!isProduction && expiredCount > 0) { 568 + console.log(`GC: Removed ${expiredCount} expired entries`); 369 569 } 570 + 571 + this.lastGC = Date.now(); 370 572 } 371 573 372 574 // Get cache stats for monitoring ··· 400 602 code: "INTERNAL_SERVER_ERROR", 401 603 }); 402 604 403 - // Create a global cache instance 404 - export const queryCache = new QueryCache(); 605 + // Create a global cache instance with optimized settings 606 + export const queryCache = new QueryCache( 607 + // Default TTL of 10 minutes in production, 5 minutes in dev 608 + isProduction ? 600 : 300, 609 + // Larger cache size to reduce evictions 610 + isProduction ? 1500 : 800, 611 + ); 405 612 406 613 /** 407 614 * Factory function for creating consistent API endpoint handlers 408 615 * @param cacheKey Key for caching the response 409 616 * @param queryFn Function that performs the actual database query 410 617 * @param ttl Cache TTL in seconds 618 + * @param isPriority Whether this endpoint should have priority in cache 411 619 */ 412 620 export function createCachedEndpoint<T>( 413 621 cacheKey: string, 414 622 queryFn: () => Promise<T>, 415 623 ttl = 300, 624 + isPriority = false, 416 625 ) { 417 - // Register the query function for cache warming 418 - queryCache.register(cacheKey, queryFn, ttl); 626 + // Register the query function for cache warming with priority flag 627 + // Set frequently accessed endpoints as priority by default 628 + const defaultToPriority = cacheKey === "leaderboard_stories" || isPriority; 629 + queryCache.register(cacheKey, queryFn, ttl, defaultToPriority); 419 630 420 631 // Pre-create cache headers to avoid recreating them on each request 421 632 const cacheHeaders = createCacheHeaders(cacheKey, ttl); ··· 424 635 const errorHeaders = { 425 636 "Content-Type": "application/json", 426 637 "Cache-Control": "no-cache, no-store", 638 + "X-Error": "true", 427 639 }; 428 640 429 641 // Pre-build common responses for reuse ··· 433 645 }); 434 646 435 647 return async (request: Request) => { 648 + // Start request timing for potential performance monitoring 649 + const requestStart = isProduction ? 0 : performance.now(); 650 + 436 651 try { 437 652 // Get data from cache or execute query 438 653 const data = await queryCache.get(cacheKey, queryFn, ttl); 439 654 440 - // Create response with proper caching headers 441 - const response = new Response(JSON.stringify(data), { 442 - headers: cacheHeaders, 443 - }); 655 + // Create response with proper caching headers and timing info 656 + const headers = { ...cacheHeaders }; 657 + 658 + // Add server timing header in development 659 + if (!isProduction && requestStart > 0) { 660 + const requestTime = Math.round(performance.now() - requestStart); 661 + headers["Server-Timing"] = `cache;dur=${requestTime}`; 662 + } 663 + 664 + const response = new Response(JSON.stringify(data), { headers }); 444 665 445 666 // Apply compression and return 446 667 return compressResponse(request, response);
+89 -29
src/libs/cacheWarming.ts
··· 10 10 * Call this after cron jobs update the database or at server startup 11 11 */ 12 12 export async function preloadCaches(): Promise<void> { 13 + const startTime = performance.now(); 13 14 if (!isProduction) { 14 15 console.log("Preloading all caches for optimal performance..."); 15 16 } ··· 29 30 console.log(`Found ${registeredKeys.length} registered cache keys to warm`); 30 31 } 31 32 32 - // Prioritize the most critical endpoints first 33 - const priorityKeys = [ 34 - "leaderboard_stories", 33 + // Define critical, high and regular priority endpoints 34 + const criticalKeys = [ 35 + "leaderboard_stories" // Most important endpoint - load first 36 + ]; 37 + 38 + const highPriorityKeys = [ 35 39 "total_stories_count", 36 40 "verified_users_stats" 37 41 ]; 38 42 39 - // Sort keys by priority (known critical keys first, then others) 40 - const sortedKeys = [ 41 - ...priorityKeys.filter(key => registeredKeys.includes(key)), 42 - ...registeredKeys.filter(key => !priorityKeys.includes(key)) 43 - ]; 43 + // Sort keys into priority tiers 44 + const sortedCriticalKeys = criticalKeys.filter(key => registeredKeys.includes(key)); 45 + const sortedHighPriorityKeys = highPriorityKeys.filter(key => registeredKeys.includes(key)); 46 + const regularPriorityKeys = registeredKeys.filter(key => 47 + !criticalKeys.includes(key) && !highPriorityKeys.includes(key) 48 + ); 49 + 50 + // Step 1: Load critical endpoints sequentially for most predictable performance 51 + for (const key of sortedCriticalKeys) { 52 + if (!isProduction) { 53 + console.log(`Warming CRITICAL cache for ${key}...`); 54 + } 55 + await queryCache.warmCache(key); 56 + 57 + // Register as priority in cache system to prevent eviction 58 + const registration = queryCache.getQueryRegistration(key); 59 + if (registration) { 60 + queryCache.register(key, registration.fn, registration.ttl, true); 61 + } 62 + } 44 63 45 - // Prepare warming promises to run in parallel for better performance 46 - const warmingPromises = sortedKeys.map(async (key) => { 64 + // Step 2: Load high priority keys in parallel 65 + await Promise.all(sortedHighPriorityKeys.map(async (key) => { 47 66 if (!isProduction) { 48 - console.log(`Warming cache for ${key}...`); 67 + console.log(`Warming HIGH PRIORITY cache for ${key}...`); 68 + } 69 + await queryCache.warmCache(key); 70 + 71 + // Register these as priority too 72 + const registration = queryCache.getQueryRegistration(key); 73 + if (registration) { 74 + queryCache.register(key, registration.fn, registration.ttl, true); 49 75 } 50 - return queryCache.warmCache(key); 51 - }); 76 + })); 52 77 53 - // Run warming of standard endpoints in parallel 54 - await Promise.all(warmingPromises); 78 + // Step 3: For regular priority, use staggered loading with limited concurrency 79 + const concurrencyLimit = isProduction ? 3 : 5; 80 + const chunkSize = Math.min(concurrencyLimit, regularPriorityKeys.length); 81 + 82 + // Process in smaller chunks to prevent overwhelming the database 83 + for (let i = 0; i < regularPriorityKeys.length; i += chunkSize) { 84 + const chunk = regularPriorityKeys.slice(i, i + chunkSize); 85 + const chunkPromises = chunk.map(async (key) => { 86 + if (!isProduction) { 87 + console.log(`Warming regular cache for ${key}...`); 88 + } 89 + return queryCache.warmCache(key); 90 + }); 91 + 92 + await Promise.all(chunkPromises); 93 + 94 + // Small breather between chunks to avoid CPU spikes 95 + if (i + chunkSize < regularPriorityKeys.length) { 96 + await new Promise(resolve => setTimeout(resolve, isProduction ? 50 : 20)); 97 + } 98 + } 55 99 56 100 // Preload snapshots for top stories - this requires custom handling 57 101 // since these use dynamic keys (story_snapshots_{id}) 58 102 if (!isProduction) { 59 - console.log("Preloading top story snapshots (limited to 3)..."); 103 + console.log("Preloading top story snapshots..."); 60 104 } 61 105 62 - // Get IDs of top 3 stories to warm their snapshots 106 + // Get IDs of top 5 stories in production (more likely to be viewed), or top 3 in dev 107 + const topStoriesLimit = isProduction ? 5 : 3; 108 + 109 + // Use optimized query with only necessary columns 63 110 const topStories = await db.query.stories.findMany({ 64 111 columns: { id: true }, // Only retrieve the ID field to minimize memory use 65 112 where: (stories, { eq }) => eq(stories.isOnLeaderboard, true), 66 113 orderBy: (stories, { asc }) => [asc(stories.position)], 67 - limit: 3, 114 + limit: topStoriesLimit, 68 115 }); 69 116 70 - // Warm story snapshots in parallel 71 - const snapshotPromises = topStories.map(story => { 117 + // Warm story snapshots with limited concurrency to prevent DB overload 118 + for (const story of topStories) { 72 119 const snapshotKey = `story_snapshots_${story.id}`; 73 - return queryCache.warmCache(snapshotKey); 74 - }); 75 - 76 - await Promise.all(snapshotPromises); 120 + await queryCache.warmCache(snapshotKey); 121 + 122 + // Short delay between each story to minimize server load spikes 123 + if (story !== topStories[topStories.length - 1]) { 124 + await new Promise(resolve => setTimeout(resolve, isProduction ? 100 : 50)); 125 + } 126 + } 77 127 128 + const totalTime = Math.round(performance.now() - startTime); 78 129 if (!isProduction) { 79 - console.log("Cache preloading completed successfully"); 130 + console.log(`Cache preloading completed successfully in ${totalTime}ms`); 80 131 } 81 132 } catch (error) { 82 133 console.error("Error during cache preloading:", error); ··· 92 143 if (!isProduction) { 93 144 console.log("Invalidating all query caches and refreshing data"); 94 145 } 95 - queryCache.invalidateAll(); 96 - 97 - // Immediately refill the cache using registered query functions 146 + 147 + // Don't invalidate everything - be selective to maintain performance 148 + // First get a list of keys to invalidate (non-priority ones) 149 + const allKeys = queryCache.getRegisteredKeys(); 150 + const nonPriorityKeys = queryCache.getNonPriorityKeys(); 151 + 152 + // Only invalidate non-priority keys first 153 + for (const key of nonPriorityKeys) { 154 + queryCache.invalidate(key); 155 + } 156 + 157 + // Gradually refresh caches with staggered starts 98 158 setTimeout(() => { 99 159 preloadCaches().catch((err) => { 100 160 console.error("Error during cache preloading after invalidation:", err); 101 161 Sentry.captureException(err); 102 162 }); 103 - }, isProduction ? 50 : 100); // Smaller delay in production for faster refresh 163 + }, isProduction ? 100 : 200); // Slightly longer delay to ensure system stability 104 164 }
+9 -3
src/libs/db.ts
··· 13 13 }); 14 14 15 15 // Set a longer busy timeout to reduce "database is locked" errors 16 - sqlite.exec("PRAGMA busy_timeout = 5000;"); 16 + sqlite.exec("PRAGMA busy_timeout = 10000;"); 17 17 18 18 // Enable Write-Ahead Logging mode for better concurrent performance 19 19 sqlite.exec("PRAGMA journal_mode = WAL;"); 20 20 // Set synchronous mode for better performance (still safe in WAL mode) 21 21 sqlite.exec("PRAGMA synchronous = NORMAL;"); 22 - // Increase cache size for better performance 23 - sqlite.exec("PRAGMA cache_size = -16000;"); // Use ~16MB of memory for cache 22 + // Increase cache size for better performance (32MB instead of 16MB) 23 + sqlite.exec("PRAGMA cache_size = -32000;"); 24 + // Enable memory-mapped I/O for better read performance 25 + sqlite.exec("PRAGMA mmap_size = 268435456;"); // 256MB 26 + // Optimize query planner 27 + sqlite.exec("PRAGMA optimize;"); 28 + // Increase page size for better I/O efficiency 29 + sqlite.exec("PRAGMA page_size = 8192;"); 24 30 25 31 // Create a Drizzle instance with the database and schema 26 32 export const db = drizzle(sqlite, { schema });