this repo has no description
3
fork

Configure Feed

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

feat: add a high load mode

+236 -44
+42 -19
src/index.ts
··· 4 4 import { db } from "./libs/db"; 5 5 import { version, name } from "../package.json"; 6 6 import { preloadCaches, invalidateAndRefreshCaches } from "./libs/cacheWarming"; 7 - import { queryCache, createCacheHeaders, compressResponse, createCachedEndpoint } from "./libs/cache"; 7 + import { QueryCache, queryCache, createCacheHeaders, compressResponse, createCachedEndpoint } from "./libs/cache"; 8 8 import root from "../public/index.html"; 9 - import { count } from "drizzle-orm"; 9 + import { count, sql } from "drizzle-orm"; 10 10 import { stories } from "./libs/schema"; 11 11 12 12 const environment = process.env.NODE_ENV; ··· 71 71 routes: { 72 72 "/": root, 73 73 "/api/stories": createCachedEndpoint("leaderboard_stories", async () => { 74 + // Only select the specific columns we need for better performance 74 75 const storyAlerts = await db.query.stories.findMany({ 76 + columns: { 77 + id: true, 78 + title: true, 79 + url: true, 80 + position: true, 81 + peakPosition: true, 82 + score: true, 83 + peakScore: true, 84 + descendants: true, 85 + enteredLeaderboardAt: true, 86 + firstSeenAt: true, 87 + by: true, 88 + isFromMonitoredUser: true, 89 + }, 75 90 where: (stories, { eq }) => eq(stories.isOnLeaderboard, true), 76 91 orderBy: (stories, { asc }) => [asc(stories.position)], 77 - limit: 100, 92 + limit: 30, // Reduced from 100 to 30 for better performance 78 93 }); 79 94 95 + // Pre-calculate the time multiplier to optimize date transformations 96 + const timeMultiplier = 1000; 97 + 80 98 // Transform story data to match the format expected by the frontend 81 - return storyAlerts.map((story) => ({ 82 - id: story.id, 83 - title: story.title, 84 - url: 85 - story.url || `https://news.ycombinator.com/item?id=${story.id}`, 86 - rank: story.position, 87 - peakRank: story.peakPosition, 88 - points: story.score, 89 - peakPoints: story.peakScore, 90 - comments: story.descendants, 91 - timestamp: story.enteredLeaderboardAt 92 - ? new Date(story.enteredLeaderboardAt * 1000).toISOString() 93 - : new Date(story.firstSeenAt * 1000).toISOString(), 94 - by: story.by, 95 - isFromMonitoredUser: story.isFromMonitoredUser, 96 - })); 99 + return storyAlerts.map((story) => { 100 + // Calculate timestamp only once per story 101 + const timestamp = story.enteredLeaderboardAt 102 + ? new Date(story.enteredLeaderboardAt * timeMultiplier).toISOString() 103 + : new Date(story.firstSeenAt * timeMultiplier).toISOString(); 104 + 105 + return { 106 + id: story.id, 107 + title: story.title, 108 + url: 109 + story.url || `https://news.ycombinator.com/item?id=${story.id}`, 110 + rank: story.position, 111 + peakRank: story.peakPosition, 112 + points: story.score, 113 + peakPoints: story.peakScore, 114 + comments: story.descendants, 115 + timestamp, 116 + by: story.by, 117 + isFromMonitoredUser: story.isFromMonitoredUser, 118 + }; 119 + }); 97 120 }, 300), 98 121 "/api/stats/total-stories": createCachedEndpoint("total_stories_count", async () => { 99 122 const result = await db.select({ count: count() }).from(stories);
+107 -11
src/libs/cache.ts
··· 85 85 private cache: Map<string, CacheItem<unknown>> = new Map(); 86 86 private defaultTTL: number = 60 * 5; // 5 minutes in seconds 87 87 private prefetchQueue: Set<string> = new Set(); 88 + private maxItems = 500; // Maximum cache entries 89 + private highLoadMode = false; // Track high load mode 90 + private highLoadThreshold = 200; // Request threshold for high load 91 + private requestCounter = 0; // Counter for recent requests 92 + private lastCounterReset: number = Date.now(); // Last time counter was reset 88 93 89 - constructor(defaultTTL?: number) { 94 + constructor(defaultTTL?: number, maxItems?: number) { 90 95 if (defaultTTL) { 91 96 this.defaultTTL = defaultTTL; 92 97 } 93 - console.log(`Initialized query cache with ${this.defaultTTL}s TTL`); 98 + if (maxItems) { 99 + this.maxItems = maxItems; 100 + } 101 + console.log( 102 + `Initialized query cache with ${this.defaultTTL}s TTL and max ${this.maxItems} items`, 103 + ); 104 + 105 + // Set up periodic counter reset for load detection 106 + setInterval(() => { 107 + this.highLoadMode = this.requestCounter > this.highLoadThreshold; 108 + this.requestCounter = 0; 109 + this.lastCounterReset = Date.now(); 110 + }, 10000); // Reset every 10 seconds 94 111 } 95 112 96 113 async get<T>( ··· 98 115 queryFn: () => Promise<T>, 99 116 ttl: number = this.defaultTTL, 100 117 ): Promise<T> { 118 + // Track request load 119 + this.requestCounter++; 120 + 101 121 const now = Math.floor(Date.now() / 1000); 102 122 const cached = this.cache.get(key); 103 123 104 124 // Return cached value if it exists and is not expired 105 125 if (cached && cached.expiresAt > now) { 106 - console.log( 107 - `Cache hit for ${key} (expires in ${cached.expiresAt - now}s)`, 108 - ); 126 + // Reduce logging in high load scenarios 127 + if (!this.highLoadMode) { 128 + console.log( 129 + `Cache hit for ${key} (expires in ${cached.expiresAt - now}s)`, 130 + ); 131 + } 109 132 110 133 // Prefetch if approaching expiration (last 10% of TTL) 111 - if (cached.expiresAt - now < ttl * 0.1 && !this.prefetchQueue.has(key)) { 134 + // Don't prefetch during high load to reduce DB pressure 135 + if ( 136 + !this.highLoadMode && 137 + cached.expiresAt - now < ttl * 0.1 && 138 + !this.prefetchQueue.has(key) 139 + ) { 112 140 this.prefetch(key, queryFn, ttl); 113 141 } 114 142 ··· 116 144 } 117 145 118 146 // Execute the query 119 - console.log(`Cache miss for ${key}, fetching from database...`); 147 + if (!this.highLoadMode) { 148 + console.log(`Cache miss for ${key}, fetching from database...`); 149 + } 120 150 const data = await queryFn(); 121 151 122 152 // Cache the result ··· 125 155 timestamp: now, 126 156 expiresAt: now + ttl, 127 157 }); 158 + 159 + // Prune cache if it exceeds max size 160 + this.pruneCache(); 128 161 129 162 return data; 130 163 } ··· 171 204 this.cache.clear(); 172 205 } 173 206 207 + // Prune cache when it exceeds max size using LRU policy 208 + private pruneCache(): void { 209 + if (this.cache.size <= this.maxItems) return; 210 + 211 + // Get all entries sorted by timestamp (oldest first) 212 + const entries = Array.from(this.cache.entries()).sort( 213 + (a, b) => a[1].timestamp - b[1].timestamp, 214 + ); 215 + 216 + // Calculate how many to remove 217 + const removeCount = Math.ceil(this.cache.size - this.maxItems); 218 + 219 + // Remove oldest entries 220 + for (let i = 0; i < removeCount && i < entries.length; i++) { 221 + const entry = entries[i]; 222 + if (entry) { 223 + this.cache.delete(entry[0]); 224 + } 225 + } 226 + 227 + if (!this.highLoadMode) { 228 + console.log(`Pruned ${removeCount} oldest items from cache`); 229 + } 230 + } 231 + 174 232 // Get cache stats for monitoring 175 - getStats(): { size: number; keys: string[] } { 233 + getStats(): { 234 + size: number; 235 + keys: string[]; 236 + highLoad: boolean; 237 + requestRate: number; 238 + } { 239 + const elapsedSeconds = (Date.now() - this.lastCounterReset) / 1000; 240 + const requestRate = 241 + elapsedSeconds > 0 ? this.requestCounter / elapsedSeconds : 0; 242 + 176 243 return { 177 244 size: this.cache.size, 178 245 keys: Array.from(this.cache.keys()), 246 + highLoad: this.highLoadMode, 247 + requestRate: Math.round(requestRate * 100) / 100, 179 248 }; 180 249 } 181 250 } 182 251 183 252 /** 184 253 * Factory function for creating consistent API endpoint handlers 254 + * Creates consistent API endpoint handlers 185 255 * @param cacheKey Key for caching the response 186 256 * @param queryFn Function that performs the actual database query 187 257 * @param ttl Cache TTL in seconds 188 258 */ 189 259 export function createCachedEndpoint<T>( 190 260 cacheKey: string, 191 - queryFn: () => Promise<T>, 261 + queryFn: (() => Promise<T>) & { highLoad?: () => Promise<T> }, 192 262 ttl = 300, 193 263 ) { 194 264 return async (request: Request) => { 195 265 try { 266 + // Check for high load indicators in headers 267 + const isHighLoad = 268 + queryCache.getStats().highLoad || 269 + request.headers.get("x-high-load") === "true"; 270 + 271 + // Use a different cache key under high load if needed 272 + const effectiveCacheKey = isHighLoad ? `${cacheKey}_lite` : cacheKey; 273 + 274 + // Execute optimized query function during high load, or regular one otherwise 275 + const effectiveQueryFn = 276 + isHighLoad && queryFn.highLoad !== undefined 277 + ? queryFn.highLoad 278 + : queryFn; 279 + 196 280 // Get data from cache or execute query 197 - const data = await queryCache.get(cacheKey, queryFn, ttl); 281 + const data = await queryCache.get( 282 + effectiveCacheKey, 283 + effectiveQueryFn, 284 + ttl, 285 + ); 198 286 199 287 // Create response with proper caching headers 200 288 const response = new Response(JSON.stringify(data), { 201 - headers: createCacheHeaders(cacheKey, ttl), 289 + headers: { 290 + ...createCacheHeaders(effectiveCacheKey, ttl), 291 + "X-High-Load": isHighLoad ? "true" : "false", 292 + }, 202 293 }); 203 294 204 295 // Apply compression and return ··· 206 297 } catch (error) { 207 298 // Log the error with context 208 299 console.error(`Error in endpoint ${cacheKey}:`, error); 300 + 301 + // Capture with Sentry if available 302 + if (typeof Sentry !== "undefined" && Sentry.captureException) { 303 + Sentry.captureException(error); 304 + } 209 305 210 306 // Return consistent error response 211 307 return new Response(
+74 -14
src/libs/cacheWarming.ts
··· 17 17 // 1. Leaderboard stories (most frequently accessed) 18 18 console.log("Preloading leaderboard stories cache..."); 19 19 await queryCache.get('leaderboard_stories', async () => { 20 + // Only select the specific columns we need for better performance 20 21 const storyAlerts = await db.query.stories.findMany({ 22 + columns: { 23 + id: true, 24 + title: true, 25 + url: true, 26 + position: true, 27 + peakPosition: true, 28 + score: true, 29 + peakScore: true, 30 + descendants: true, 31 + enteredLeaderboardAt: true, 32 + firstSeenAt: true, 33 + by: true, 34 + isFromMonitoredUser: true, 35 + }, 21 36 where: (stories, { eq }) => eq(stories.isOnLeaderboard, true), 22 37 orderBy: (stories, { asc }) => [asc(stories.position)], 23 - limit: 100, 38 + limit: 30, // Reduced from 100 to 30 for better performance 24 39 }); 25 40 41 + // Pre-calculate the time multiplier to optimize date transformations 42 + const timeMultiplier = 1000; 43 + 26 44 // Transform for frontend 45 + return storyAlerts.map((story) => { 46 + // Calculate timestamp only once per story 47 + const timestamp = story.enteredLeaderboardAt 48 + ? new Date(story.enteredLeaderboardAt * timeMultiplier).toISOString() 49 + : new Date(story.firstSeenAt * timeMultiplier).toISOString(); 50 + 51 + return { 52 + id: story.id, 53 + title: story.title, 54 + url: story.url || `https://news.ycombinator.com/item?id=${story.id}`, 55 + rank: story.position, 56 + peakRank: story.peakPosition, 57 + points: story.score, 58 + peakPoints: story.peakScore, 59 + comments: story.descendants, 60 + timestamp, 61 + by: story.by, 62 + isFromMonitoredUser: story.isFromMonitoredUser, 63 + }; 64 + }); 65 + }); 66 + 67 + // 1.1 Leaderboard stories lite version for high load scenarios 68 + console.log("Preloading leaderboard stories lite cache..."); 69 + await queryCache.get('leaderboard_stories_lite', async () => { 70 + // Even more optimized for high load - fewer fields, fewer records 71 + const storyAlerts = await db.query.stories.findMany({ 72 + columns: { 73 + id: true, 74 + title: true, 75 + url: true, 76 + position: true, 77 + score: true, 78 + descendants: true, 79 + by: true, 80 + isFromMonitoredUser: true, 81 + }, 82 + where: (stories, { eq }) => eq(stories.isOnLeaderboard, true), 83 + orderBy: (stories, { asc }) => [asc(stories.position)], 84 + limit: 20, // Even fewer for extreme load scenarios 85 + }); 86 + 87 + const timeMultiplier = 1000; 88 + 27 89 return storyAlerts.map((story) => ({ 28 90 id: story.id, 29 91 title: story.title, 30 92 url: story.url || `https://news.ycombinator.com/item?id=${story.id}`, 31 93 rank: story.position, 32 - peakRank: story.peakPosition, 33 94 points: story.score, 34 - peakPoints: story.peakScore, 35 95 comments: story.descendants, 36 - timestamp: story.enteredLeaderboardAt 37 - ? new Date(story.enteredLeaderboardAt * 1000).toISOString() 38 - : new Date(story.firstSeenAt * 1000).toISOString(), 39 96 by: story.by, 40 97 isFromMonitoredUser: story.isFromMonitoredUser, 41 98 })); ··· 84 141 }; 85 142 }); 86 143 87 - // 4. Optional: Warm up top 5 story snapshots (preload most accessed story graphs) 144 + // 4. Optional: Warm up top 3 story snapshots (preload most accessed story graphs) 88 145 // This is done with lower priority as it's less critical 89 - console.log("Preloading top story snapshots (limited to 5)..."); 146 + console.log("Preloading top story snapshots (limited to 3)..."); 90 147 91 - // Get IDs of top 5 stories to warm their snapshots 148 + // Get IDs of top 3 stories to warm their snapshots 92 149 const topStories = await db.query.stories.findMany({ 150 + columns: { id: true }, // Only retrieve the ID field to minimize memory use 93 151 where: (stories, { eq }) => eq(stories.isOnLeaderboard, true), 94 152 orderBy: (stories, { asc }) => [asc(stories.position)], 95 - limit: 5, // Reduced from 20 to 5 to minimize initial load 153 + limit: 3, // Further reduced from 5 to 3 to minimize initial load 96 154 }); 97 155 98 156 // Preload snapshots for these stories sequentially ··· 130 188 queryCache.invalidateAll(); 131 189 132 190 // Immediately refill the cache 133 - preloadCaches().catch(err => { 134 - console.error("Error during cache preloading after invalidation:", err); 135 - Sentry.captureException(err); 136 - }); 191 + setTimeout(() => { 192 + preloadCaches().catch(err => { 193 + console.error("Error during cache preloading after invalidation:", err); 194 + Sentry.captureException(err); 195 + }); 196 + }, 100); // Small delay to let any pending requests complete 137 197 }
+13
src/libs/schema.ts
··· 80 80 table.isOnLeaderboard, 81 81 table.position, 82 82 ), 83 + // Add covering index for the leaderboard API query 84 + leaderboardCoveringIdx: index("idx_stories_leaderboard_covering").on( 85 + table.isOnLeaderboard, 86 + table.position, 87 + table.title, 88 + table.url, 89 + table.score, 90 + table.peakScore, 91 + table.peakPosition, 92 + table.descendants, 93 + table.by, 94 + table.isFromMonitoredUser, 95 + ), 83 96 // Add index on by field for user-specific queries 84 97 byIdx: index("idx_stories_by").on(table.by), 85 98 // Add index on expiration for cleanup queries