rss email digests over ssh because you're a cool kid herald.dunkirk.sh
go rss rss-reader ssh charm

feat: prepared statements, cache control, and faster sorting

dunkirk.sh 834d1eba df84190d

verified
Changed files
+169 -48
store
web
+2 -9
store/configs.go
··· 102 102 103 103 func (db *DB) GetConfig(ctx context.Context, userID int64, filename string) (*Config, error) { 104 104 var cfg Config 105 - err := db.QueryRowContext(ctx, 106 - `SELECT id, user_id, filename, email, cron_expr, digest, inline_content, raw_text, last_run, next_run, created_at 107 - FROM configs WHERE user_id = ? AND filename = ?`, 108 - userID, filename, 109 - ).Scan(&cfg.ID, &cfg.UserID, &cfg.Filename, &cfg.Email, &cfg.CronExpr, &cfg.Digest, &cfg.InlineContent, &cfg.RawText, &cfg.LastRun, &cfg.NextRun, &cfg.CreatedAt) 105 + err := db.stmts.getConfig.QueryRowContext(ctx, userID, filename).Scan(&cfg.ID, &cfg.UserID, &cfg.Filename, &cfg.Email, &cfg.CronExpr, &cfg.Digest, &cfg.InlineContent, &cfg.RawText, &cfg.LastRun, &cfg.NextRun, &cfg.CreatedAt) 110 106 if err != nil { 111 107 return nil, err 112 108 } ··· 168 164 } 169 165 170 166 func (db *DB) UpdateLastRun(ctx context.Context, configID int64, lastRun, nextRun time.Time) error { 171 - _, err := db.ExecContext(ctx, 172 - `UPDATE configs SET last_run = ?, next_run = ? WHERE id = ?`, 173 - lastRun, nextRun, configID, 174 - ) 167 + _, err := db.stmts.updateConfigRun.ExecContext(ctx, lastRun, nextRun, configID) 175 168 if err != nil { 176 169 return fmt.Errorf("update last run: %w", err) 177 170 }
+78 -1
store/db.go
··· 10 10 11 11 type DB struct { 12 12 *sql.DB 13 + stmts *preparedStmts 14 + } 15 + 16 + type preparedStmts struct { 17 + markItemSeen *sql.Stmt 18 + isItemSeen *sql.Stmt 19 + getSeenItems *sql.Stmt 20 + getConfig *sql.Stmt 21 + updateConfigRun *sql.Stmt 22 + updateFeedMeta *sql.Stmt 23 + cleanupSeenItems *sql.Stmt 13 24 } 14 25 15 26 func Open(path string) (*DB, error) { ··· 26 37 return nil, fmt.Errorf("ping database: %w", err) 27 38 } 28 39 29 - store := &DB{db} 40 + store := &DB{DB: db} 30 41 if err := store.migrate(); err != nil { 31 42 return nil, fmt.Errorf("migrate database: %w", err) 43 + } 44 + 45 + if err := store.prepareStatements(); err != nil { 46 + return nil, fmt.Errorf("prepare statements: %w", err) 32 47 } 33 48 34 49 return store, nil ··· 107 122 } 108 123 109 124 func (db *DB) Close() error { 125 + if db.stmts != nil { 126 + db.stmts.markItemSeen.Close() 127 + db.stmts.isItemSeen.Close() 128 + db.stmts.getSeenItems.Close() 129 + db.stmts.getConfig.Close() 130 + db.stmts.updateConfigRun.Close() 131 + db.stmts.updateFeedMeta.Close() 132 + db.stmts.cleanupSeenItems.Close() 133 + } 110 134 return db.DB.Close() 135 + } 136 + 137 + func (db *DB) prepareStatements() error { 138 + db.stmts = &preparedStmts{} 139 + 140 + var err error 141 + 142 + db.stmts.markItemSeen, err = db.Prepare( 143 + `INSERT INTO seen_items (feed_id, guid, title, link) VALUES (?, ?, ?, ?) 144 + ON CONFLICT(feed_id, guid) DO UPDATE SET title = excluded.title, link = excluded.link`) 145 + if err != nil { 146 + return fmt.Errorf("prepare markItemSeen: %w", err) 147 + } 148 + 149 + db.stmts.isItemSeen, err = db.Prepare( 150 + `SELECT id FROM seen_items WHERE feed_id = ? AND guid = ?`) 151 + if err != nil { 152 + return fmt.Errorf("prepare isItemSeen: %w", err) 153 + } 154 + 155 + db.stmts.getSeenItems, err = db.Prepare( 156 + `SELECT id, feed_id, guid, title, link, seen_at 157 + FROM seen_items WHERE feed_id = ? ORDER BY seen_at DESC LIMIT ?`) 158 + if err != nil { 159 + return fmt.Errorf("prepare getSeenItems: %w", err) 160 + } 161 + 162 + db.stmts.getConfig, err = db.Prepare( 163 + `SELECT id, user_id, filename, email, cron_expr, digest, inline_content, raw_text, last_run, next_run, created_at 164 + FROM configs WHERE user_id = ? AND filename = ?`) 165 + if err != nil { 166 + return fmt.Errorf("prepare getConfig: %w", err) 167 + } 168 + 169 + db.stmts.updateConfigRun, err = db.Prepare( 170 + `UPDATE configs SET last_run = ?, next_run = ? WHERE id = ?`) 171 + if err != nil { 172 + return fmt.Errorf("prepare updateConfigRun: %w", err) 173 + } 174 + 175 + db.stmts.updateFeedMeta, err = db.Prepare( 176 + `UPDATE feeds SET last_fetched = ?, etag = ?, last_modified = ? WHERE id = ?`) 177 + if err != nil { 178 + return fmt.Errorf("prepare updateFeedMeta: %w", err) 179 + } 180 + 181 + db.stmts.cleanupSeenItems, err = db.Prepare( 182 + `DELETE FROM seen_items WHERE seen_at < ?`) 183 + if err != nil { 184 + return fmt.Errorf("prepare cleanupSeenItems: %w", err) 185 + } 186 + 187 + return nil 111 188 } 112 189 113 190 func (db *DB) Migrate(ctx context.Context) error {
+1 -4
store/feeds.go
··· 142 142 lmVal = sql.NullString{String: lastModified, Valid: true} 143 143 } 144 144 145 - _, err := db.ExecContext(ctx, 146 - `UPDATE feeds SET last_fetched = ?, etag = ?, last_modified = ? WHERE id = ?`, 147 - time.Now(), etagVal, lmVal, feedID, 148 - ) 145 + _, err := db.stmts.updateFeedMeta.ExecContext(ctx, time.Now(), etagVal, lmVal, feedID) 149 146 if err != nil { 150 147 return fmt.Errorf("update feed fetched: %w", err) 151 148 }
+4 -18
store/items.go
··· 26 26 linkVal = sql.NullString{String: link, Valid: true} 27 27 } 28 28 29 - _, err := db.ExecContext(ctx, 30 - `INSERT INTO seen_items (feed_id, guid, title, link) VALUES (?, ?, ?, ?) 31 - ON CONFLICT(feed_id, guid) DO UPDATE SET title = excluded.title, link = excluded.link`, 32 - feedID, guid, titleVal, linkVal, 33 - ) 29 + _, err := db.stmts.markItemSeen.ExecContext(ctx, feedID, guid, titleVal, linkVal) 34 30 if err != nil { 35 31 return fmt.Errorf("mark item seen: %w", err) 36 32 } ··· 39 35 40 36 func (db *DB) IsItemSeen(ctx context.Context, feedID int64, guid string) (bool, error) { 41 37 var id int64 42 - err := db.QueryRowContext(ctx, 43 - `SELECT id FROM seen_items WHERE feed_id = ? AND guid = ?`, 44 - feedID, guid, 45 - ).Scan(&id) 38 + err := db.stmts.isItemSeen.QueryRowContext(ctx, feedID, guid).Scan(&id) 46 39 if err != nil { 47 40 if errors.Is(err, sql.ErrNoRows) { 48 41 return false, nil ··· 73 66 } 74 67 75 68 func (db *DB) GetSeenItems(ctx context.Context, feedID int64, limit int) ([]*SeenItem, error) { 76 - rows, err := db.QueryContext(ctx, 77 - `SELECT id, feed_id, guid, title, link, seen_at 78 - FROM seen_items WHERE feed_id = ? ORDER BY seen_at DESC LIMIT ?`, 79 - feedID, limit, 80 - ) 69 + rows, err := db.stmts.getSeenItems.QueryContext(ctx, feedID, limit) 81 70 if err != nil { 82 71 return nil, fmt.Errorf("query seen items: %w", err) 83 72 } ··· 138 127 // CleanupOldSeenItems deletes seen items older than the specified duration 139 128 func (db *DB) CleanupOldSeenItems(ctx context.Context, olderThan time.Duration) (int64, error) { 140 129 cutoff := time.Now().Add(-olderThan) 141 - result, err := db.ExecContext(ctx, 142 - `DELETE FROM seen_items WHERE seen_at < ?`, 143 - cutoff, 144 - ) 130 + result, err := db.stmts.cleanupSeenItems.ExecContext(ctx, cutoff) 145 131 if err != nil { 146 132 return 0, fmt.Errorf("cleanup old seen items: %w", err) 147 133 }
+84 -16
web/handlers.go
··· 170 170 PubDate string `xml:"pubDate"` 171 171 } 172 172 173 + type rssItemWithTime struct { 174 + rssItem 175 + parsedTime time.Time 176 + } 177 + 173 178 type rssChannel struct { 174 179 Title string `xml:"title"` 175 180 Link string `xml:"link"` ··· 208 213 return 209 214 } 210 215 211 - var items []rssItem 216 + var items []rssItemWithTime 212 217 feeds, err := s.store.GetFeedsByConfig(ctx, cfg.ID) 213 218 if err != nil { 214 219 s.logger.Error("get feeds", "err", err) ··· 222 227 continue 223 228 } 224 229 for _, item := range seenItems { 225 - rItem := rssItem{ 226 - GUID: item.GUID, 227 - PubDate: item.SeenAt.Format(time.RFC1123Z), 230 + rItem := rssItemWithTime{ 231 + rssItem: rssItem{ 232 + GUID: item.GUID, 233 + PubDate: item.SeenAt.Format(time.RFC1123Z), 234 + }, 235 + parsedTime: item.SeenAt, 228 236 } 229 237 if item.Title.Valid { 230 238 rItem.Title = item.Title.String ··· 237 245 } 238 246 239 247 sort.Slice(items, func(i, j int) bool { 240 - ti, _ := time.Parse(time.RFC1123Z, items[i].PubDate) 241 - tj, _ := time.Parse(time.RFC1123Z, items[j].PubDate) 242 - return ti.After(tj) 248 + return items[i].parsedTime.After(items[j].parsedTime) 243 249 }) 244 250 245 251 if len(items) > 100 { 246 252 items = items[:100] 247 253 } 248 254 255 + // Convert to rssItem for XML encoding 256 + rssItems := make([]rssItem, len(items)) 257 + for i, item := range items { 258 + rssItems[i] = item.rssItem 259 + } 260 + 249 261 feed := rssFeed{ 250 262 Version: "2.0", 251 263 Channel: rssChannel{ 252 264 Title: "Herald - " + configFilename, 253 265 Link: s.origin + "/" + fingerprint + "/" + configFilename, 254 266 Description: "Feed for " + configFilename, 255 - Items: items, 267 + Items: rssItems, 256 268 }, 257 269 } 258 270 271 + // Add caching headers 259 272 w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") 273 + w.Header().Set("Cache-Control", "public, max-age=300") 274 + if cfg.LastRun.Valid { 275 + etag := fmt.Sprintf(`"%s-%d"`, fingerprint[:8], cfg.LastRun.Time.Unix()) 276 + w.Header().Set("ETag", etag) 277 + w.Header().Set("Last-Modified", cfg.LastRun.Time.UTC().Format(http.TimeFormat)) 278 + 279 + // Check If-None-Match 280 + if match := r.Header.Get("If-None-Match"); match == etag { 281 + w.WriteHeader(http.StatusNotModified) 282 + return 283 + } 284 + 285 + // Check If-Modified-Since 286 + if modSince := r.Header.Get("If-Modified-Since"); modSince != "" { 287 + if t, err := http.ParseTime(modSince); err == nil && !cfg.LastRun.Time.After(t) { 288 + w.WriteHeader(http.StatusNotModified) 289 + return 290 + } 291 + } 292 + } 293 + 260 294 w.Write([]byte(xml.Header)) 261 295 enc := xml.NewEncoder(w) 262 296 enc.Indent("", " ") ··· 278 312 DatePublished string `json:"date_published"` 279 313 } 280 314 315 + type jsonFeedItemWithTime struct { 316 + jsonFeedItem 317 + parsedTime time.Time 318 + } 319 + 281 320 func (s *Server) handleFeedJSON(w http.ResponseWriter, r *http.Request, fingerprint, configFilename string) { 282 321 ctx := r.Context() 283 322 ··· 303 342 return 304 343 } 305 344 306 - var items []jsonFeedItem 345 + var items []jsonFeedItemWithTime 307 346 feeds, err := s.store.GetFeedsByConfig(ctx, cfg.ID) 308 347 if err != nil { 309 348 s.logger.Error("get feeds", "err", err) ··· 317 356 continue 318 357 } 319 358 for _, item := range seenItems { 320 - jItem := jsonFeedItem{ 321 - ID: item.GUID, 322 - DatePublished: item.SeenAt.Format(time.RFC3339), 359 + jItem := jsonFeedItemWithTime{ 360 + jsonFeedItem: jsonFeedItem{ 361 + ID: item.GUID, 362 + DatePublished: item.SeenAt.Format(time.RFC3339), 363 + }, 364 + parsedTime: item.SeenAt, 323 365 } 324 366 if item.Title.Valid { 325 367 jItem.Title = item.Title.String ··· 332 374 } 333 375 334 376 sort.Slice(items, func(i, j int) bool { 335 - ti, _ := time.Parse(time.RFC3339, items[i].DatePublished) 336 - tj, _ := time.Parse(time.RFC3339, items[j].DatePublished) 337 - return ti.After(tj) 377 + return items[i].parsedTime.After(items[j].parsedTime) 338 378 }) 339 379 340 380 if len(items) > 100 { 341 381 items = items[:100] 342 382 } 343 383 384 + // Convert to jsonFeedItem for JSON encoding 385 + jsonItems := make([]jsonFeedItem, len(items)) 386 + for i, item := range items { 387 + jsonItems[i] = item.jsonFeedItem 388 + } 389 + 344 390 feed := jsonFeed{ 345 391 Version: "https://jsonfeed.org/version/1.1", 346 392 Title: "Herald - " + configFilename, 347 393 HomePageURL: s.origin + "/" + fingerprint + "/" + configFilename, 348 394 FeedURL: s.origin + "/" + fingerprint + "/" + configFilename + ".json", 349 - Items: items, 395 + Items: jsonItems, 350 396 } 351 397 398 + // Add caching headers 352 399 w.Header().Set("Content-Type", "application/feed+json; charset=utf-8") 400 + w.Header().Set("Cache-Control", "public, max-age=300") 401 + if cfg.LastRun.Valid { 402 + etag := fmt.Sprintf(`"%s-%d"`, fingerprint[:8], cfg.LastRun.Time.Unix()) 403 + w.Header().Set("ETag", etag) 404 + w.Header().Set("Last-Modified", cfg.LastRun.Time.UTC().Format(http.TimeFormat)) 405 + 406 + // Check If-None-Match 407 + if match := r.Header.Get("If-None-Match"); match == etag { 408 + w.WriteHeader(http.StatusNotModified) 409 + return 410 + } 411 + 412 + // Check If-Modified-Since 413 + if modSince := r.Header.Get("If-Modified-Since"); modSince != "" { 414 + if t, err := http.ParseTime(modSince); err == nil && !cfg.LastRun.Time.After(t) { 415 + w.WriteHeader(http.StatusNotModified) 416 + return 417 + } 418 + } 419 + } 420 + 353 421 enc := json.NewEncoder(w) 354 422 enc.SetIndent("", " ") 355 423 enc.Encode(feed)