refactor database queries and improve performanceUnverified
6691ef3 parent: 1702c76 modified
internal/atproto/stream_handler.go +3 -5 | @@ -63,11 +63,9 @@ func (h *StreamDBHandler) handleSubscription(ctx context.Context, event *Event) | ||
| 63 | 63 | if !ok { |
| 64 | 64 | return nil |
| 65 | 65 | } |
| 66 | - subs, _ := h.db.ListSubscriptions(ctx, event.DID, "", 100, 0) | |
| 67 | - for _, sub := range subs { | |
| 68 | - if sub.URI.Valid && sub.URI.String == event.URI { | |
| 69 | - return h.db.DeleteSubscription(ctx, event.DID, sub.FeedURL) | |
| 70 | - } | |
| 66 | + sub, err := h.db.GetSubscriptionByURI(ctx, event.DID, event.URI) | |
| 67 | + if err == nil && sub != nil { | |
| 68 | + return h.db.DeleteSubscription(ctx, event.DID, sub.FeedURL) | |
| 71 | 69 | } |
| 72 | 70 | _ = parsed |
| 73 | 71 | } |
| @@ -63,11 +63,9 @@ func (h *StreamDBHandler) handleSubscription(ctx context.Context, event *Event) | |||
| 63 | if !ok { | 63 | if !ok { |
| 64 | return nil | 64 | return nil |
| 65 | } | 65 | } |
| 66 | - subs, _ := h.db.ListSubscriptions(ctx, event.DID, "", 100, 0) | 66 | + sub, err := h.db.GetSubscriptionByURI(ctx, event.DID, event.URI) |
| 67 | - for _, sub := range subs { | 67 | + if err == nil && sub != nil { |
| 68 | - if sub.URI.Valid && sub.URI.String == event.URI { | 68 | + return h.db.DeleteSubscription(ctx, event.DID, sub.FeedURL) |
| 69 | - return h.db.DeleteSubscription(ctx, event.DID, sub.FeedURL) | ||
| 70 | - } | ||
| 71 | } | 69 | } |
| 72 | _ = parsed | 70 | _ = parsed |
| 73 | } | 71 | } |
modified
internal/atproto/sync.go +6 -12 | @@ -140,12 +140,9 @@ func (s *Sync) reconcileAnnotation(ctx context.Context, userDID, uri, cid string | ||
| 140 | 140 | return nil |
| 141 | 141 | } |
| 142 | 142 | |
| 143 | - var existing []*db.Annotation | |
| 144 | - existing, _ = s.db.ListAnnotations(ctx, rec.FeedURL, rec.ArticleURL, userDID, 100, 0) | |
| 145 | - for _, a := range existing { | |
| 146 | - if a.URI == uri { | |
| 147 | - return nil | |
| 148 | - } | |
| 143 | + exists, err := s.db.AnnotationExists(ctx, uri) | |
| 144 | + if err != nil || exists { | |
| 145 | + return err | |
| 149 | 146 | } |
| 150 | 147 | |
| 151 | 148 | t, _ := time.Parse(time.RFC3339, rec.CreatedAt) |
| @@ -177,12 +174,9 @@ func (s *Sync) reconcileMarginNote(ctx context.Context, userDID, uri, cid string | ||
| 177 | 174 | return nil |
| 178 | 175 | } |
| 179 | 176 | |
| 180 | - var existing []*db.Annotation | |
| 181 | - existing, _ = s.db.ListAnnotations(ctx, "", articleURL, userDID, 100, 0) | |
| 182 | - for _, a := range existing { | |
| 183 | - if a.URI == uri { | |
| 184 | - return nil | |
| 185 | - } | |
| 177 | + exists, err := s.db.AnnotationExists(ctx, uri) | |
| 178 | + if err != nil || exists { | |
| 179 | + return err | |
| 186 | 180 | } |
| 187 | 181 | |
| 188 | 182 | feedURL := "" |
| @@ -140,12 +140,9 @@ func (s *Sync) reconcileAnnotation(ctx context.Context, userDID, uri, cid string | |||
| 140 | return nil | 140 | return nil |
| 141 | } | 141 | } |
| 142 | 142 | ||
| 143 | - var existing []*db.Annotation | 143 | + exists, err := s.db.AnnotationExists(ctx, uri) |
| 144 | - existing, _ = s.db.ListAnnotations(ctx, rec.FeedURL, rec.ArticleURL, userDID, 100, 0) | 144 | + if err != nil || exists { |
| 145 | - for _, a := range existing { | 145 | + return err |
| 146 | - if a.URI == uri { | ||
| 147 | - return nil | ||
| 148 | - } | ||
| 149 | } | 146 | } |
| 150 | 147 | ||
| 151 | t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | 148 | t, _ := time.Parse(time.RFC3339, rec.CreatedAt) |
| @@ -177,12 +174,9 @@ func (s *Sync) reconcileMarginNote(ctx context.Context, userDID, uri, cid string | |||
| 177 | return nil | 174 | return nil |
| 178 | } | 175 | } |
| 179 | 176 | ||
| 180 | - var existing []*db.Annotation | 177 | + exists, err := s.db.AnnotationExists(ctx, uri) |
| 181 | - existing, _ = s.db.ListAnnotations(ctx, "", articleURL, userDID, 100, 0) | 178 | + if err != nil || exists { |
| 182 | - for _, a := range existing { | 179 | + return err |
| 183 | - if a.URI == uri { | ||
| 184 | - return nil | ||
| 185 | - } | ||
| 186 | } | 180 | } |
| 187 | 181 | ||
| 188 | feedURL := "" | 182 | feedURL := "" |
modified
internal/atproto/xrpc.go +28 -12 | @@ -422,6 +422,12 @@ func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) { | ||
| 422 | 422 | defer rows.Close() |
| 423 | 423 | |
| 424 | 424 | feedLists := make([]FeedListEntry, 0) |
| 425 | + type userRow struct { | |
| 426 | + did string | |
| 427 | + subCount int | |
| 428 | + } | |
| 429 | + var users []userRow | |
| 430 | + | |
| 425 | 431 | for rows.Next() { |
| 426 | 432 | var did, handle string |
| 427 | 433 | var subCount int |
| @@ -429,40 +435,50 @@ func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) { | ||
| 429 | 435 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 430 | 436 | return |
| 431 | 437 | } |
| 438 | + users = append(users, userRow{did: did, subCount: subCount}) | |
| 439 | + } | |
| 432 | 440 | |
| 441 | + subsByDID := make(map[string][]SubscriptionRecord) | |
| 442 | + if len(users) > 0 { | |
| 443 | + ph := make([]string, len(users)) | |
| 444 | + args := make([]any, len(users)) | |
| 445 | + for i, u := range users { | |
| 446 | + ph[i] = "?" | |
| 447 | + args[i] = u.did | |
| 448 | + } | |
| 433 | 449 | subRows, err := h.db.QueryContext(r.Context(), ` |
| 434 | - SELECT s.feed_url, COALESCE(s.title, f.title), s.category | |
| 450 | + SELECT s.user_did, s.feed_url, COALESCE(s.title, f.title), s.category | |
| 435 | 451 | FROM subscriptions s |
| 436 | 452 | JOIN feeds f ON s.feed_url = f.feed_url |
| 437 | - WHERE s.user_did = ? | |
| 438 | - ORDER BY s.added_at DESC | |
| 439 | - `, did) | |
| 453 | + WHERE s.user_did IN (`+strings.Join(ph, ",")+`) | |
| 454 | + ORDER BY s.user_did, s.added_at DESC | |
| 455 | + `, args...) | |
| 440 | 456 | if err != nil { |
| 441 | 457 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 442 | 458 | return |
| 443 | 459 | } |
| 444 | - | |
| 445 | - subs := make([]SubscriptionRecord, 0) | |
| 446 | 460 | for subRows.Next() { |
| 447 | - var feedURL, title string | |
| 461 | + var did, feedURL, title string | |
| 448 | 462 | var cat sql.NullString |
| 449 | - if err := subRows.Scan(&feedURL, &title, &cat); err != nil { | |
| 463 | + if err := subRows.Scan(&did, &feedURL, &title, &cat); err != nil { | |
| 450 | 464 | subRows.Close() |
| 451 | 465 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 452 | 466 | return |
| 453 | 467 | } |
| 454 | - subs = append(subs, SubscriptionRecord{ | |
| 468 | + subsByDID[did] = append(subsByDID[did], SubscriptionRecord{ | |
| 455 | 469 | FeedURL: feedURL, |
| 456 | 470 | Title: title, |
| 457 | 471 | Category: cat.String, |
| 458 | 472 | }) |
| 459 | 473 | } |
| 460 | 474 | subRows.Close() |
| 475 | + } | |
| 461 | 476 | |
| 477 | + for _, u := range users { | |
| 462 | 478 | feedLists = append(feedLists, FeedListEntry{ |
| 463 | - DID: did, | |
| 464 | - SubscriptionCount: subCount, | |
| 465 | - Subscriptions: subs, | |
| 479 | + DID: u.did, | |
| 480 | + SubscriptionCount: u.subCount, | |
| 481 | + Subscriptions: subsByDID[u.did], | |
| 466 | 482 | }) |
| 467 | 483 | } |
| 468 | 484 | |
| @@ -422,6 +422,12 @@ func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) { | |||
| 422 | defer rows.Close() | 422 | defer rows.Close() |
| 423 | 423 | ||
| 424 | feedLists := make([]FeedListEntry, 0) | 424 | feedLists := make([]FeedListEntry, 0) |
| 425 | + type userRow struct { | ||
| 426 | + did string | ||
| 427 | + subCount int | ||
| 428 | + } | ||
| 429 | + var users []userRow | ||
| 430 | + | ||
| 425 | for rows.Next() { | 431 | for rows.Next() { |
| 426 | var did, handle string | 432 | var did, handle string |
| 427 | var subCount int | 433 | var subCount int |
| @@ -429,40 +435,50 @@ func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) { | |||
| 429 | http.Error(w, err.Error(), http.StatusInternalServerError) | 435 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 430 | return | 436 | return |
| 431 | } | 437 | } |
| 438 | + users = append(users, userRow{did: did, subCount: subCount}) | ||
| 439 | + } | ||
| 432 | 440 | ||
| 441 | + subsByDID := make(map[string][]SubscriptionRecord) | ||
| 442 | + if len(users) > 0 { | ||
| 443 | + ph := make([]string, len(users)) | ||
| 444 | + args := make([]any, len(users)) | ||
| 445 | + for i, u := range users { | ||
| 446 | + ph[i] = "?" | ||
| 447 | + args[i] = u.did | ||
| 448 | + } | ||
| 433 | subRows, err := h.db.QueryContext(r.Context(), ` | 449 | subRows, err := h.db.QueryContext(r.Context(), ` |
| 434 | - SELECT s.feed_url, COALESCE(s.title, f.title), s.category | 450 | + SELECT s.user_did, s.feed_url, COALESCE(s.title, f.title), s.category |
| 435 | FROM subscriptions s | 451 | FROM subscriptions s |
| 436 | JOIN feeds f ON s.feed_url = f.feed_url | 452 | JOIN feeds f ON s.feed_url = f.feed_url |
| 437 | - WHERE s.user_did = ? | 453 | + WHERE s.user_did IN (`+strings.Join(ph, ",")+`) |
| 438 | - ORDER BY s.added_at DESC | 454 | + ORDER BY s.user_did, s.added_at DESC |
| 439 | - `, did) | 455 | + `, args...) |
| 440 | if err != nil { | 456 | if err != nil { |
| 441 | http.Error(w, err.Error(), http.StatusInternalServerError) | 457 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 442 | return | 458 | return |
| 443 | } | 459 | } |
| 444 | - | ||
| 445 | - subs := make([]SubscriptionRecord, 0) | ||
| 446 | for subRows.Next() { | 460 | for subRows.Next() { |
| 447 | - var feedURL, title string | 461 | + var did, feedURL, title string |
| 448 | var cat sql.NullString | 462 | var cat sql.NullString |
| 449 | - if err := subRows.Scan(&feedURL, &title, &cat); err != nil { | 463 | + if err := subRows.Scan(&did, &feedURL, &title, &cat); err != nil { |
| 450 | subRows.Close() | 464 | subRows.Close() |
| 451 | http.Error(w, err.Error(), http.StatusInternalServerError) | 465 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 452 | return | 466 | return |
| 453 | } | 467 | } |
| 454 | - subs = append(subs, SubscriptionRecord{ | 468 | + subsByDID[did] = append(subsByDID[did], SubscriptionRecord{ |
| 455 | FeedURL: feedURL, | 469 | FeedURL: feedURL, |
| 456 | Title: title, | 470 | Title: title, |
| 457 | Category: cat.String, | 471 | Category: cat.String, |
| 458 | }) | 472 | }) |
| 459 | } | 473 | } |
| 460 | subRows.Close() | 474 | subRows.Close() |
| 475 | + } | ||
| 461 | 476 | ||
| 477 | + for _, u := range users { | ||
| 462 | feedLists = append(feedLists, FeedListEntry{ | 478 | feedLists = append(feedLists, FeedListEntry{ |
| 463 | - DID: did, | 479 | + DID: u.did, |
| 464 | - SubscriptionCount: subCount, | 480 | + SubscriptionCount: u.subCount, |
| 465 | - Subscriptions: subs, | 481 | + Subscriptions: subsByDID[u.did], |
| 466 | }) | 482 | }) |
| 467 | } | 483 | } |
| 468 | 484 | ||
modified
internal/db/article.go +6 -4 | @@ -3,7 +3,6 @@ package db | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "database/sql" |
| 6 | - "fmt" | |
| 7 | 6 | ) |
| 8 | 7 | |
| 9 | 8 | type Article struct { |
| @@ -78,7 +77,8 @@ func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit, | ||
| 78 | 77 | args = append(args, feedURL) |
| 79 | 78 | } |
| 80 | 79 | |
| 81 | - query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset) | |
| 80 | + query += ` ORDER BY a.published DESC LIMIT ? OFFSET ?` | |
| 81 | + args = append(args, limit, offset) | |
| 82 | 82 | |
| 83 | 83 | rows, err := db.QueryContext(ctx, query, args...) |
| 84 | 84 | if err != nil { |
| @@ -117,7 +117,8 @@ func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, l | ||
| 117 | 117 | args = append(args, feedURL) |
| 118 | 118 | } |
| 119 | 119 | |
| 120 | - query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset) | |
| 120 | + query += ` ORDER BY a.published DESC LIMIT ? OFFSET ?` | |
| 121 | + args = append(args, limit, offset) | |
| 121 | 122 | |
| 122 | 123 | rows, err := db.QueryContext(ctx, query, args...) |
| 123 | 124 | if err != nil { |
| @@ -156,7 +157,8 @@ func (db *DB) ListReadArticles(ctx context.Context, userDID, feedURL string, lim | ||
| 156 | 157 | args = append(args, feedURL) |
| 157 | 158 | } |
| 158 | 159 | |
| 159 | - query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset) | |
| 160 | + query += ` ORDER BY a.published DESC LIMIT ? OFFSET ?` | |
| 161 | + args = append(args, limit, offset) | |
| 160 | 162 | |
| 161 | 163 | rows, err := db.QueryContext(ctx, query, args...) |
| 162 | 164 | if err != nil { |
| @@ -3,7 +3,6 @@ package db | |||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | "database/sql" | 5 | "database/sql" |
| 6 | - "fmt" | ||
| 7 | ) | 6 | ) |
| 8 | 7 | ||
| 9 | type Article struct { | 8 | type Article struct { |
| @@ -78,7 +77,8 @@ func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit, | |||
| 78 | args = append(args, feedURL) | 77 | args = append(args, feedURL) |
| 79 | } | 78 | } |
| 80 | 79 | ||
| 81 | - query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset) | 80 | + query += ` ORDER BY a.published DESC LIMIT ? OFFSET ?` |
| 81 | + args = append(args, limit, offset) | ||
| 82 | 82 | ||
| 83 | rows, err := db.QueryContext(ctx, query, args...) | 83 | rows, err := db.QueryContext(ctx, query, args...) |
| 84 | if err != nil { | 84 | if err != nil { |
| @@ -117,7 +117,8 @@ func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, l | |||
| 117 | args = append(args, feedURL) | 117 | args = append(args, feedURL) |
| 118 | } | 118 | } |
| 119 | 119 | ||
| 120 | - query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset) | 120 | + query += ` ORDER BY a.published DESC LIMIT ? OFFSET ?` |
| 121 | + args = append(args, limit, offset) | ||
| 121 | 122 | ||
| 122 | rows, err := db.QueryContext(ctx, query, args...) | 123 | rows, err := db.QueryContext(ctx, query, args...) |
| 123 | if err != nil { | 124 | if err != nil { |
| @@ -156,7 +157,8 @@ func (db *DB) ListReadArticles(ctx context.Context, userDID, feedURL string, lim | |||
| 156 | args = append(args, feedURL) | 157 | args = append(args, feedURL) |
| 157 | } | 158 | } |
| 158 | 159 | ||
| 159 | - query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset) | 160 | + query += ` ORDER BY a.published DESC LIMIT ? OFFSET ?` |
| 161 | + args = append(args, limit, offset) | ||
| 160 | 162 | ||
| 161 | rows, err := db.QueryContext(ctx, query, args...) | 163 | rows, err := db.QueryContext(ctx, query, args...) |
| 162 | if err != nil { | 164 | if err != nil { |
modified
internal/db/cluster.go +66 -30 | @@ -3,16 +3,21 @@ package db | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "database/sql" |
| 6 | - "fmt" | |
| 7 | 6 | ) |
| 8 | 7 | |
| 9 | 8 | func (db *DB) ComputeFeedSimilarity(ctx context.Context) error { |
| 10 | - _, err := db.ExecContext(ctx, `DELETE FROM feed_similarity`) | |
| 9 | + tx, err := db.BeginTx(ctx, nil) | |
| 11 | 10 | if err != nil { |
| 12 | 11 | return err |
| 13 | 12 | } |
| 13 | + defer tx.Rollback() | |
| 14 | 14 | |
| 15 | - rows, err := db.QueryContext(ctx, ` | |
| 15 | + _, err = tx.ExecContext(ctx, `DELETE FROM feed_similarity`) | |
| 16 | + if err != nil { | |
| 17 | + return err | |
| 18 | + } | |
| 19 | + | |
| 20 | + rows, err := tx.QueryContext(ctx, ` | |
| 16 | 21 | SELECT s1.feed_url, s2.feed_url, COUNT(*) AS overlap |
| 17 | 22 | FROM subscriptions s1 |
| 18 | 23 | JOIN subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url |
| @@ -48,7 +53,7 @@ func (db *DB) ComputeFeedSimilarity(ctx context.Context) error { | ||
| 48 | 53 | } |
| 49 | 54 | |
| 50 | 55 | if len(subCounts) > 0 { |
| 51 | - countRows, err := db.QueryContext(ctx, ` | |
| 56 | + countRows, err := tx.QueryContext(ctx, ` | |
| 52 | 57 | SELECT feed_url, COUNT(*) FROM subscriptions GROUP BY feed_url |
| 53 | 58 | `) |
| 54 | 59 | if err != nil { |
| @@ -66,31 +71,42 @@ func (db *DB) ComputeFeedSimilarity(ctx context.Context) error { | ||
| 66 | 71 | countRows.Close() |
| 67 | 72 | } |
| 68 | 73 | |
| 74 | + stmt, err := tx.PrepareContext(ctx, ` | |
| 75 | + INSERT INTO feed_similarity (feed_a, feed_b, jaccard, computed_at) | |
| 76 | + VALUES (?, ?, ?, CURRENT_TIMESTAMP) | |
| 77 | + `) | |
| 78 | + if err != nil { | |
| 79 | + return err | |
| 80 | + } | |
| 81 | + defer stmt.Close() | |
| 82 | + | |
| 69 | 83 | for _, p := range pairs { |
| 70 | 84 | total := subCounts[p.feedA] + subCounts[p.feedB] - p.overlap |
| 71 | 85 | if total == 0 { |
| 72 | 86 | continue |
| 73 | 87 | } |
| 74 | 88 | jaccard := float64(p.overlap) / float64(total) |
| 75 | - _, err := db.ExecContext(ctx, ` | |
| 76 | - INSERT INTO feed_similarity (feed_a, feed_b, jaccard, computed_at) | |
| 77 | - VALUES (?, ?, ?, CURRENT_TIMESTAMP) | |
| 78 | - `, p.feedA, p.feedB, jaccard) | |
| 79 | - if err != nil { | |
| 89 | + if _, err := stmt.ExecContext(ctx, p.feedA, p.feedB, jaccard); err != nil { | |
| 80 | 90 | return err |
| 81 | 91 | } |
| 82 | 92 | } |
| 83 | 93 | |
| 84 | - return nil | |
| 94 | + return tx.Commit() | |
| 85 | 95 | } |
| 86 | 96 | |
| 87 | 97 | func (db *DB) ComputeUserSimilarity(ctx context.Context) error { |
| 88 | - _, err := db.ExecContext(ctx, `DELETE FROM user_similarity`) | |
| 98 | + tx, err := db.BeginTx(ctx, nil) | |
| 89 | 99 | if err != nil { |
| 90 | 100 | return err |
| 91 | 101 | } |
| 102 | + defer tx.Rollback() | |
| 92 | 103 | |
| 93 | - rows, err := db.QueryContext(ctx, ` | |
| 104 | + _, err = tx.ExecContext(ctx, `DELETE FROM user_similarity`) | |
| 105 | + if err != nil { | |
| 106 | + return err | |
| 107 | + } | |
| 108 | + | |
| 109 | + rows, err := tx.QueryContext(ctx, ` | |
| 94 | 110 | SELECT s1.user_did, s2.user_did, COUNT(*) AS common |
| 95 | 111 | FROM subscriptions s1 |
| 96 | 112 | JOIN subscriptions s2 ON s1.user_did < s2.user_did AND s1.feed_url = s2.feed_url |
| @@ -126,7 +142,7 @@ func (db *DB) ComputeUserSimilarity(ctx context.Context) error { | ||
| 126 | 142 | } |
| 127 | 143 | |
| 128 | 144 | if len(subCounts) > 0 { |
| 129 | - countRows, err := db.QueryContext(ctx, ` | |
| 145 | + countRows, err := tx.QueryContext(ctx, ` | |
| 130 | 146 | SELECT user_did, COUNT(*) FROM subscriptions GROUP BY user_did |
| 131 | 147 | `) |
| 132 | 148 | if err != nil { |
| @@ -144,33 +160,44 @@ func (db *DB) ComputeUserSimilarity(ctx context.Context) error { | ||
| 144 | 160 | countRows.Close() |
| 145 | 161 | } |
| 146 | 162 | |
| 163 | + stmt, err := tx.PrepareContext(ctx, ` | |
| 164 | + INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, computed_at) | |
| 165 | + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) | |
| 166 | + `) | |
| 167 | + if err != nil { | |
| 168 | + return err | |
| 169 | + } | |
| 170 | + defer stmt.Close() | |
| 171 | + | |
| 147 | 172 | for _, p := range pairs { |
| 148 | 173 | total := subCounts[p.userA] + subCounts[p.userB] - p.common |
| 149 | 174 | if total == 0 { |
| 150 | 175 | continue |
| 151 | 176 | } |
| 152 | 177 | jaccard := float64(p.common) / float64(total) |
| 153 | - _, err := db.ExecContext(ctx, ` | |
| 154 | - INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, computed_at) | |
| 155 | - VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) | |
| 156 | - `, p.userA, p.userB, jaccard, p.common) | |
| 157 | - if err != nil { | |
| 178 | + if _, err := stmt.ExecContext(ctx, p.userA, p.userB, jaccard, p.common); err != nil { | |
| 158 | 179 | return err |
| 159 | 180 | } |
| 160 | 181 | } |
| 161 | 182 | |
| 162 | - return nil | |
| 183 | + return tx.Commit() | |
| 163 | 184 | } |
| 164 | 185 | |
| 165 | 186 | func (db *DB) ComputeFeedRecommendations(ctx context.Context, userDID string) error { |
| 166 | - _, err := db.ExecContext(ctx, ` | |
| 187 | + tx, err := db.BeginTx(ctx, nil) | |
| 188 | + if err != nil { | |
| 189 | + return err | |
| 190 | + } | |
| 191 | + defer tx.Rollback() | |
| 192 | + | |
| 193 | + _, err = tx.ExecContext(ctx, ` | |
| 167 | 194 | DELETE FROM user_feed_recommendations WHERE user_did = ? |
| 168 | 195 | `, userDID) |
| 169 | 196 | if err != nil { |
| 170 | 197 | return err |
| 171 | 198 | } |
| 172 | 199 | |
| 173 | - rows, err := db.QueryContext(ctx, ` | |
| 200 | + rows, err := tx.QueryContext(ctx, ` | |
| 174 | 201 | SELECT |
| 175 | 202 | CASE WHEN fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) THEN fs.feed_b ELSE fs.feed_a END AS recommended_feed, |
| 176 | 203 | SUM(fs.jaccard) AS score |
| @@ -186,21 +213,30 @@ func (db *DB) ComputeFeedRecommendations(ctx context.Context, userDID string) er | ||
| 186 | 213 | } |
| 187 | 214 | defer rows.Close() |
| 188 | 215 | |
| 216 | + stmt, err := tx.PrepareContext(ctx, ` | |
| 217 | + INSERT INTO user_feed_recommendations (user_did, feed_url, score, computed_at) | |
| 218 | + VALUES (?, ?, ?, CURRENT_TIMESTAMP) | |
| 219 | + `) | |
| 220 | + if err != nil { | |
| 221 | + return err | |
| 222 | + } | |
| 223 | + defer stmt.Close() | |
| 224 | + | |
| 189 | 225 | for rows.Next() { |
| 190 | 226 | var feedURL string |
| 191 | 227 | var score float64 |
| 192 | 228 | if err := rows.Scan(&feedURL, &score); err != nil { |
| 193 | 229 | return err |
| 194 | 230 | } |
| 195 | - _, err := db.ExecContext(ctx, ` | |
| 196 | - INSERT INTO user_feed_recommendations (user_did, feed_url, score, computed_at) | |
| 197 | - VALUES (?, ?, ?, CURRENT_TIMESTAMP) | |
| 198 | - `, userDID, feedURL, score) | |
| 199 | - if err != nil { | |
| 231 | + if _, err := stmt.ExecContext(ctx, userDID, feedURL, score); err != nil { | |
| 200 | 232 | return err |
| 201 | 233 | } |
| 202 | 234 | } |
| 203 | - return rows.Err() | |
| 235 | + if err := rows.Err(); err != nil { | |
| 236 | + return err | |
| 237 | + } | |
| 238 | + | |
| 239 | + return tx.Commit() | |
| 204 | 240 | } |
| 205 | 241 | |
| 206 | 242 | func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) { |
| @@ -241,7 +277,7 @@ func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit | ||
| 241 | 277 | } |
| 242 | 278 | |
| 243 | 279 | func (db *DB) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) { |
| 244 | - rows, err := db.QueryContext(ctx, fmt.Sprintf(` | |
| 280 | + rows, err := db.QueryContext(ctx, ` | |
| 245 | 281 | SELECT |
| 246 | 282 | CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END AS recommended_user, |
| 247 | 283 | us.jaccard, us.common_feeds, |
| @@ -251,8 +287,8 @@ func (db *DB) GetPeopleRecommendations(ctx context.Context, userDID string, limi | ||
| 251 | 287 | WHERE (us.user_a = ? OR us.user_b = ?) |
| 252 | 288 | AND u.handle IS NOT NULL AND u.handle != '' |
| 253 | 289 | ORDER BY us.jaccard DESC |
| 254 | - LIMIT %d | |
| 255 | - `, limit), userDID, userDID, userDID, userDID) | |
| 290 | + LIMIT ? | |
| 291 | + `, userDID, userDID, userDID, userDID, limit) | |
| 256 | 292 | if err != nil { |
| 257 | 293 | return nil, err |
| 258 | 294 | } |
| @@ -3,16 +3,21 @@ package db | |||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | "database/sql" | 5 | "database/sql" |
| 6 | - "fmt" | ||
| 7 | ) | 6 | ) |
| 8 | 7 | ||
| 9 | func (db *DB) ComputeFeedSimilarity(ctx context.Context) error { | 8 | func (db *DB) ComputeFeedSimilarity(ctx context.Context) error { |
| 10 | - _, err := db.ExecContext(ctx, `DELETE FROM feed_similarity`) | 9 | + tx, err := db.BeginTx(ctx, nil) |
| 11 | if err != nil { | 10 | if err != nil { |
| 12 | return err | 11 | return err |
| 13 | } | 12 | } |
| 13 | + defer tx.Rollback() | ||
| 14 | 14 | ||
| 15 | - rows, err := db.QueryContext(ctx, ` | 15 | + _, err = tx.ExecContext(ctx, `DELETE FROM feed_similarity`) |
| 16 | + if err != nil { | ||
| 17 | + return err | ||
| 18 | + } | ||
| 19 | + | ||
| 20 | + rows, err := tx.QueryContext(ctx, ` | ||
| 16 | SELECT s1.feed_url, s2.feed_url, COUNT(*) AS overlap | 21 | SELECT s1.feed_url, s2.feed_url, COUNT(*) AS overlap |
| 17 | FROM subscriptions s1 | 22 | FROM subscriptions s1 |
| 18 | JOIN subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url | 23 | JOIN subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url |
| @@ -48,7 +53,7 @@ func (db *DB) ComputeFeedSimilarity(ctx context.Context) error { | |||
| 48 | } | 53 | } |
| 49 | 54 | ||
| 50 | if len(subCounts) > 0 { | 55 | if len(subCounts) > 0 { |
| 51 | - countRows, err := db.QueryContext(ctx, ` | 56 | + countRows, err := tx.QueryContext(ctx, ` |
| 52 | SELECT feed_url, COUNT(*) FROM subscriptions GROUP BY feed_url | 57 | SELECT feed_url, COUNT(*) FROM subscriptions GROUP BY feed_url |
| 53 | `) | 58 | `) |
| 54 | if err != nil { | 59 | if err != nil { |
| @@ -66,31 +71,42 @@ func (db *DB) ComputeFeedSimilarity(ctx context.Context) error { | |||
| 66 | countRows.Close() | 71 | countRows.Close() |
| 67 | } | 72 | } |
| 68 | 73 | ||
| 74 | + stmt, err := tx.PrepareContext(ctx, ` | ||
| 75 | + INSERT INTO feed_similarity (feed_a, feed_b, jaccard, computed_at) | ||
| 76 | + VALUES (?, ?, ?, CURRENT_TIMESTAMP) | ||
| 77 | + `) | ||
| 78 | + if err != nil { | ||
| 79 | + return err | ||
| 80 | + } | ||
| 81 | + defer stmt.Close() | ||
| 82 | + | ||
| 69 | for _, p := range pairs { | 83 | for _, p := range pairs { |
| 70 | total := subCounts[p.feedA] + subCounts[p.feedB] - p.overlap | 84 | total := subCounts[p.feedA] + subCounts[p.feedB] - p.overlap |
| 71 | if total == 0 { | 85 | if total == 0 { |
| 72 | continue | 86 | continue |
| 73 | } | 87 | } |
| 74 | jaccard := float64(p.overlap) / float64(total) | 88 | jaccard := float64(p.overlap) / float64(total) |
| 75 | - _, err := db.ExecContext(ctx, ` | 89 | + if _, err := stmt.ExecContext(ctx, p.feedA, p.feedB, jaccard); err != nil { |
| 76 | - INSERT INTO feed_similarity (feed_a, feed_b, jaccard, computed_at) | ||
| 77 | - VALUES (?, ?, ?, CURRENT_TIMESTAMP) | ||
| 78 | - `, p.feedA, p.feedB, jaccard) | ||
| 79 | - if err != nil { | ||
| 80 | return err | 90 | return err |
| 81 | } | 91 | } |
| 82 | } | 92 | } |
| 83 | 93 | ||
| 84 | - return nil | 94 | + return tx.Commit() |
| 85 | } | 95 | } |
| 86 | 96 | ||
| 87 | func (db *DB) ComputeUserSimilarity(ctx context.Context) error { | 97 | func (db *DB) ComputeUserSimilarity(ctx context.Context) error { |
| 88 | - _, err := db.ExecContext(ctx, `DELETE FROM user_similarity`) | 98 | + tx, err := db.BeginTx(ctx, nil) |
| 89 | if err != nil { | 99 | if err != nil { |
| 90 | return err | 100 | return err |
| 91 | } | 101 | } |
| 102 | + defer tx.Rollback() | ||
| 92 | 103 | ||
| 93 | - rows, err := db.QueryContext(ctx, ` | 104 | + _, err = tx.ExecContext(ctx, `DELETE FROM user_similarity`) |
| 105 | + if err != nil { | ||
| 106 | + return err | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + rows, err := tx.QueryContext(ctx, ` | ||
| 94 | SELECT s1.user_did, s2.user_did, COUNT(*) AS common | 110 | SELECT s1.user_did, s2.user_did, COUNT(*) AS common |
| 95 | FROM subscriptions s1 | 111 | FROM subscriptions s1 |
| 96 | JOIN subscriptions s2 ON s1.user_did < s2.user_did AND s1.feed_url = s2.feed_url | 112 | JOIN subscriptions s2 ON s1.user_did < s2.user_did AND s1.feed_url = s2.feed_url |
| @@ -126,7 +142,7 @@ func (db *DB) ComputeUserSimilarity(ctx context.Context) error { | |||
| 126 | } | 142 | } |
| 127 | 143 | ||
| 128 | if len(subCounts) > 0 { | 144 | if len(subCounts) > 0 { |
| 129 | - countRows, err := db.QueryContext(ctx, ` | 145 | + countRows, err := tx.QueryContext(ctx, ` |
| 130 | SELECT user_did, COUNT(*) FROM subscriptions GROUP BY user_did | 146 | SELECT user_did, COUNT(*) FROM subscriptions GROUP BY user_did |
| 131 | `) | 147 | `) |
| 132 | if err != nil { | 148 | if err != nil { |
| @@ -144,33 +160,44 @@ func (db *DB) ComputeUserSimilarity(ctx context.Context) error { | |||
| 144 | countRows.Close() | 160 | countRows.Close() |
| 145 | } | 161 | } |
| 146 | 162 | ||
| 163 | + stmt, err := tx.PrepareContext(ctx, ` | ||
| 164 | + INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, computed_at) | ||
| 165 | + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) | ||
| 166 | + `) | ||
| 167 | + if err != nil { | ||
| 168 | + return err | ||
| 169 | + } | ||
| 170 | + defer stmt.Close() | ||
| 171 | + | ||
| 147 | for _, p := range pairs { | 172 | for _, p := range pairs { |
| 148 | total := subCounts[p.userA] + subCounts[p.userB] - p.common | 173 | total := subCounts[p.userA] + subCounts[p.userB] - p.common |
| 149 | if total == 0 { | 174 | if total == 0 { |
| 150 | continue | 175 | continue |
| 151 | } | 176 | } |
| 152 | jaccard := float64(p.common) / float64(total) | 177 | jaccard := float64(p.common) / float64(total) |
| 153 | - _, err := db.ExecContext(ctx, ` | 178 | + if _, err := stmt.ExecContext(ctx, p.userA, p.userB, jaccard, p.common); err != nil { |
| 154 | - INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, computed_at) | ||
| 155 | - VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) | ||
| 156 | - `, p.userA, p.userB, jaccard, p.common) | ||
| 157 | - if err != nil { | ||
| 158 | return err | 179 | return err |
| 159 | } | 180 | } |
| 160 | } | 181 | } |
| 161 | 182 | ||
| 162 | - return nil | 183 | + return tx.Commit() |
| 163 | } | 184 | } |
| 164 | 185 | ||
| 165 | func (db *DB) ComputeFeedRecommendations(ctx context.Context, userDID string) error { | 186 | func (db *DB) ComputeFeedRecommendations(ctx context.Context, userDID string) error { |
| 166 | - _, err := db.ExecContext(ctx, ` | 187 | + tx, err := db.BeginTx(ctx, nil) |
| 188 | + if err != nil { | ||
| 189 | + return err | ||
| 190 | + } | ||
| 191 | + defer tx.Rollback() | ||
| 192 | + | ||
| 193 | + _, err = tx.ExecContext(ctx, ` | ||
| 167 | DELETE FROM user_feed_recommendations WHERE user_did = ? | 194 | DELETE FROM user_feed_recommendations WHERE user_did = ? |
| 168 | `, userDID) | 195 | `, userDID) |
| 169 | if err != nil { | 196 | if err != nil { |
| 170 | return err | 197 | return err |
| 171 | } | 198 | } |
| 172 | 199 | ||
| 173 | - rows, err := db.QueryContext(ctx, ` | 200 | + rows, err := tx.QueryContext(ctx, ` |
| 174 | SELECT | 201 | SELECT |
| 175 | CASE WHEN fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) THEN fs.feed_b ELSE fs.feed_a END AS recommended_feed, | 202 | CASE WHEN fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) THEN fs.feed_b ELSE fs.feed_a END AS recommended_feed, |
| 176 | SUM(fs.jaccard) AS score | 203 | SUM(fs.jaccard) AS score |
| @@ -186,21 +213,30 @@ func (db *DB) ComputeFeedRecommendations(ctx context.Context, userDID string) er | |||
| 186 | } | 213 | } |
| 187 | defer rows.Close() | 214 | defer rows.Close() |
| 188 | 215 | ||
| 216 | + stmt, err := tx.PrepareContext(ctx, ` | ||
| 217 | + INSERT INTO user_feed_recommendations (user_did, feed_url, score, computed_at) | ||
| 218 | + VALUES (?, ?, ?, CURRENT_TIMESTAMP) | ||
| 219 | + `) | ||
| 220 | + if err != nil { | ||
| 221 | + return err | ||
| 222 | + } | ||
| 223 | + defer stmt.Close() | ||
| 224 | + | ||
| 189 | for rows.Next() { | 225 | for rows.Next() { |
| 190 | var feedURL string | 226 | var feedURL string |
| 191 | var score float64 | 227 | var score float64 |
| 192 | if err := rows.Scan(&feedURL, &score); err != nil { | 228 | if err := rows.Scan(&feedURL, &score); err != nil { |
| 193 | return err | 229 | return err |
| 194 | } | 230 | } |
| 195 | - _, err := db.ExecContext(ctx, ` | 231 | + if _, err := stmt.ExecContext(ctx, userDID, feedURL, score); err != nil { |
| 196 | - INSERT INTO user_feed_recommendations (user_did, feed_url, score, computed_at) | ||
| 197 | - VALUES (?, ?, ?, CURRENT_TIMESTAMP) | ||
| 198 | - `, userDID, feedURL, score) | ||
| 199 | - if err != nil { | ||
| 200 | return err | 232 | return err |
| 201 | } | 233 | } |
| 202 | } | 234 | } |
| 203 | - return rows.Err() | 235 | + if err := rows.Err(); err != nil { |
| 236 | + return err | ||
| 237 | + } | ||
| 238 | + | ||
| 239 | + return tx.Commit() | ||
| 204 | } | 240 | } |
| 205 | 241 | ||
| 206 | func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) { | 242 | func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) { |
| @@ -241,7 +277,7 @@ func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit | |||
| 241 | } | 277 | } |
| 242 | 278 | ||
| 243 | func (db *DB) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) { | 279 | func (db *DB) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) { |
| 244 | - rows, err := db.QueryContext(ctx, fmt.Sprintf(` | 280 | + rows, err := db.QueryContext(ctx, ` |
| 245 | SELECT | 281 | SELECT |
| 246 | CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END AS recommended_user, | 282 | CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END AS recommended_user, |
| 247 | us.jaccard, us.common_feeds, | 283 | us.jaccard, us.common_feeds, |
| @@ -251,8 +287,8 @@ func (db *DB) GetPeopleRecommendations(ctx context.Context, userDID string, limi | |||
| 251 | WHERE (us.user_a = ? OR us.user_b = ?) | 287 | WHERE (us.user_a = ? OR us.user_b = ?) |
| 252 | AND u.handle IS NOT NULL AND u.handle != '' | 288 | AND u.handle IS NOT NULL AND u.handle != '' |
| 253 | ORDER BY us.jaccard DESC | 289 | ORDER BY us.jaccard DESC |
| 254 | - LIMIT %d | 290 | + LIMIT ? |
| 255 | - `, limit), userDID, userDID, userDID, userDID) | 291 | + `, userDID, userDID, userDID, userDID, limit) |
| 256 | if err != nil { | 292 | if err != nil { |
| 257 | return nil, err | 293 | return nil, err |
| 258 | } | 294 | } |
modified
internal/db/db.go +10 -1 | @@ -31,12 +31,14 @@ type DB struct { | ||
| 31 | 31 | } |
| 32 | 32 | |
| 33 | 33 | func Open(path string) (*DB, error) { |
| 34 | - db, err := sql.Open("sqlite3", path) | |
| 34 | + db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000") | |
| 35 | 35 | if err != nil { |
| 36 | 36 | return nil, err |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | 39 | db.SetMaxOpenConns(1) |
| 40 | + db.SetMaxIdleConns(2) | |
| 41 | + db.SetConnMaxLifetime(30 * time.Minute) | |
| 40 | 42 | |
| 41 | 43 | if err := initSchema(db); err != nil { |
| 42 | 44 | db.Close() |
| @@ -189,12 +191,19 @@ var schema = []string{ | ||
| 189 | 191 | )`, |
| 190 | 192 | `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed ON subscriptions(feed_url)`, |
| 191 | 193 | `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_did)`, |
| 194 | + `CREATE INDEX IF NOT EXISTS idx_subscriptions_uri ON subscriptions(uri)`, | |
| 192 | 195 | `CREATE INDEX IF NOT EXISTS idx_articles_feed ON articles(feed_url)`, |
| 193 | 196 | `CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published DESC)`, |
| 197 | + `CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(url)`, | |
| 194 | 198 | `CREATE INDEX IF NOT EXISTS idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0`, |
| 195 | 199 | `CREATE INDEX IF NOT EXISTS idx_annotations_article ON annotations(article_url)`, |
| 200 | + `CREATE INDEX IF NOT EXISTS idx_annotations_author ON annotations(author_did)`, | |
| 201 | + `CREATE INDEX IF NOT EXISTS idx_annotations_created_at ON annotations(created_at DESC)`, | |
| 196 | 202 | `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`, |
| 197 | 203 | `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`, |
| 204 | + `CREATE INDEX IF NOT EXISTS idx_likes_created_at ON likes(created_at DESC)`, | |
| 198 | 205 | `CREATE INDEX IF NOT EXISTS idx_follows_user ON follows(user_did)`, |
| 199 | 206 | `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`, |
| 207 | + `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`, | |
| 208 | + `CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle)`, | |
| 200 | 209 | } |
| @@ -31,12 +31,14 @@ type DB struct { | |||
| 31 | } | 31 | } |
| 32 | 32 | ||
| 33 | func Open(path string) (*DB, error) { | 33 | func Open(path string) (*DB, error) { |
| 34 | - db, err := sql.Open("sqlite3", path) | 34 | + db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000") |
| 35 | if err != nil { | 35 | if err != nil { |
| 36 | return nil, err | 36 | return nil, err |
| 37 | } | 37 | } |
| 38 | 38 | ||
| 39 | db.SetMaxOpenConns(1) | 39 | db.SetMaxOpenConns(1) |
| 40 | + db.SetMaxIdleConns(2) | ||
| 41 | + db.SetConnMaxLifetime(30 * time.Minute) | ||
| 40 | 42 | ||
| 41 | if err := initSchema(db); err != nil { | 43 | if err := initSchema(db); err != nil { |
| 42 | db.Close() | 44 | db.Close() |
| @@ -189,12 +191,19 @@ var schema = []string{ | |||
| 189 | )`, | 191 | )`, |
| 190 | `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed ON subscriptions(feed_url)`, | 192 | `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed ON subscriptions(feed_url)`, |
| 191 | `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_did)`, | 193 | `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_did)`, |
| 194 | + `CREATE INDEX IF NOT EXISTS idx_subscriptions_uri ON subscriptions(uri)`, | ||
| 192 | `CREATE INDEX IF NOT EXISTS idx_articles_feed ON articles(feed_url)`, | 195 | `CREATE INDEX IF NOT EXISTS idx_articles_feed ON articles(feed_url)`, |
| 193 | `CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published DESC)`, | 196 | `CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published DESC)`, |
| 197 | + `CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(url)`, | ||
| 194 | `CREATE INDEX IF NOT EXISTS idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0`, | 198 | `CREATE INDEX IF NOT EXISTS idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0`, |
| 195 | `CREATE INDEX IF NOT EXISTS idx_annotations_article ON annotations(article_url)`, | 199 | `CREATE INDEX IF NOT EXISTS idx_annotations_article ON annotations(article_url)`, |
| 200 | + `CREATE INDEX IF NOT EXISTS idx_annotations_author ON annotations(author_did)`, | ||
| 201 | + `CREATE INDEX IF NOT EXISTS idx_annotations_created_at ON annotations(created_at DESC)`, | ||
| 196 | `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`, | 202 | `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`, |
| 197 | `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`, | 203 | `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`, |
| 204 | + `CREATE INDEX IF NOT EXISTS idx_likes_created_at ON likes(created_at DESC)`, | ||
| 198 | `CREATE INDEX IF NOT EXISTS idx_follows_user ON follows(user_did)`, | 205 | `CREATE INDEX IF NOT EXISTS idx_follows_user ON follows(user_did)`, |
| 199 | `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`, | 206 | `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`, |
| 207 | + `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`, | ||
| 208 | + `CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle)`, | ||
| 200 | } | 209 | } |
modified
internal/db/feed.go +58 -10 | @@ -3,7 +3,7 @@ package db | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "database/sql" |
| 6 | - "fmt" | |
| 6 | + "strings" | |
| 7 | 7 | "time" |
| 8 | 8 | ) |
| 9 | 9 | |
| @@ -203,8 +203,18 @@ func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error | ||
| 203 | 203 | return err |
| 204 | 204 | } |
| 205 | 205 | |
| 206 | - for _, u := range feedURLs { | |
| 207 | - if _, err := tx.ExecContext(ctx, `UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0) WHERE feed_url = ?`, u); err != nil { | |
| 206 | + if len(feedURLs) > 0 { | |
| 207 | + ph := make([]string, len(feedURLs)) | |
| 208 | + args := make([]any, len(feedURLs)) | |
| 209 | + for i, u := range feedURLs { | |
| 210 | + ph[i] = "?" | |
| 211 | + args[i] = u | |
| 212 | + } | |
| 213 | + _, err = tx.ExecContext(ctx, ` | |
| 214 | + UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0) | |
| 215 | + WHERE feed_url IN (`+strings.Join(ph, ",")+`) | |
| 216 | + `, args...) | |
| 217 | + if err != nil { | |
| 208 | 218 | return err |
| 209 | 219 | } |
| 210 | 220 | } |
| @@ -212,6 +222,21 @@ func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error | ||
| 212 | 222 | return tx.Commit() |
| 213 | 223 | } |
| 214 | 224 | |
| 225 | +func (db *DB) GetSubscriptionByURI(ctx context.Context, userDID, uri string) (*Subscription, error) { | |
| 226 | + s := &Subscription{} | |
| 227 | + err := db.QueryRowContext(ctx, ` | |
| 228 | + SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at, | |
| 229 | + s.uri, s.cid | |
| 230 | + FROM subscriptions s | |
| 231 | + LEFT JOIN feeds f ON s.feed_url = f.feed_url | |
| 232 | + WHERE s.user_did = ? AND s.uri = ? | |
| 233 | + `, userDID, uri).Scan(&s.ID, &s.UserDID, &s.FeedURL, &s.FeedTitle, &s.Category, &s.AddedAt, &s.URI, &s.CID) | |
| 234 | + if err != nil { | |
| 235 | + return nil, err | |
| 236 | + } | |
| 237 | + return s, nil | |
| 238 | +} | |
| 239 | + | |
| 215 | 240 | func (db *DB) GetSubscription(ctx context.Context, userDID, feedURL string) (*Subscription, error) { |
| 216 | 241 | s := &Subscription{} |
| 217 | 242 | err := db.QueryRowContext(ctx, ` |
| @@ -240,7 +265,8 @@ func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, l | ||
| 240 | 265 | args = append(args, category) |
| 241 | 266 | } |
| 242 | 267 | |
| 243 | - query += fmt.Sprintf(` ORDER BY s.added_at DESC LIMIT %d OFFSET %d`, limit, offset) | |
| 268 | + query += ` ORDER BY s.added_at DESC LIMIT ? OFFSET ?` | |
| 269 | + args = append(args, limit, offset) | |
| 244 | 270 | |
| 245 | 271 | rows, err := db.QueryContext(ctx, query, args...) |
| 246 | 272 | if err != nil { |
| @@ -267,6 +293,28 @@ func (db *DB) GetSubscriptionCount(ctx context.Context, userDID string) (int, er | ||
| 267 | 293 | return count, err |
| 268 | 294 | } |
| 269 | 295 | |
| 296 | +func (db *DB) GetCategories(ctx context.Context, userDID string) ([]string, error) { | |
| 297 | + rows, err := db.QueryContext(ctx, ` | |
| 298 | + SELECT DISTINCT category FROM subscriptions | |
| 299 | + WHERE user_did = ? AND category IS NOT NULL AND category != '' | |
| 300 | + ORDER BY category | |
| 301 | + `, userDID) | |
| 302 | + if err != nil { | |
| 303 | + return nil, err | |
| 304 | + } | |
| 305 | + defer rows.Close() | |
| 306 | + | |
| 307 | + var categories []string | |
| 308 | + for rows.Next() { | |
| 309 | + var cat string | |
| 310 | + if err := rows.Scan(&cat); err != nil { | |
| 311 | + return nil, err | |
| 312 | + } | |
| 313 | + categories = append(categories, cat) | |
| 314 | + } | |
| 315 | + return categories, rows.Err() | |
| 316 | +} | |
| 317 | + | |
| 270 | 318 | func (db *DB) UpdateFeedFavicon(ctx context.Context, feedURL, faviconURL string) error { |
| 271 | 319 | _, err := db.ExecContext(ctx, `UPDATE feeds SET favicon_url = ? WHERE feed_url = ?`, faviconURL, feedURL) |
| 272 | 320 | return err |
| @@ -301,14 +349,14 @@ func (db *DB) ListDeadFeeds(ctx context.Context, userDID string, threshold int) | ||
| 301 | 349 | } |
| 302 | 350 | |
| 303 | 351 | func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) { |
| 304 | - rows, err := db.QueryContext(ctx, fmt.Sprintf(` | |
| 352 | + rows, err := db.QueryContext(ctx, ` | |
| 305 | 353 | SELECT feed_url, title, site_url, description, feed_type, |
| 306 | 354 | last_fetched_at, last_error, subscriber_count, etag, last_modified, |
| 307 | 355 | fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url |
| 308 | 356 | FROM feeds |
| 309 | 357 | ORDER BY subscriber_count DESC |
| 310 | - LIMIT %d OFFSET %d | |
| 311 | - `, limit, offset)) | |
| 358 | + LIMIT ? OFFSET ? | |
| 359 | + `, limit, offset) | |
| 312 | 360 | if err != nil { |
| 313 | 361 | return nil, err |
| 314 | 362 | } |
| @@ -328,15 +376,15 @@ func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, err | ||
| 328 | 376 | } |
| 329 | 377 | |
| 330 | 378 | func (db *DB) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) { |
| 331 | - rows, err := db.QueryContext(ctx, fmt.Sprintf(` | |
| 379 | + rows, err := db.QueryContext(ctx, ` | |
| 332 | 380 | SELECT feed_url, title, site_url, description, feed_type, |
| 333 | 381 | last_fetched_at, last_error, subscriber_count, etag, last_modified, |
| 334 | 382 | fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url |
| 335 | 383 | FROM feeds |
| 336 | 384 | WHERE feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) |
| 337 | 385 | ORDER BY subscriber_count DESC |
| 338 | - LIMIT %d OFFSET %d | |
| 339 | - `, limit, offset), userDID) | |
| 386 | + LIMIT ? OFFSET ? | |
| 387 | + `, userDID, limit, offset) | |
| 340 | 388 | if err != nil { |
| 341 | 389 | return nil, err |
| 342 | 390 | } |
| @@ -3,7 +3,7 @@ package db | |||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | "database/sql" | 5 | "database/sql" |
| 6 | - "fmt" | 6 | + "strings" |
| 7 | "time" | 7 | "time" |
| 8 | ) | 8 | ) |
| 9 | 9 | ||
| @@ -203,8 +203,18 @@ func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error | |||
| 203 | return err | 203 | return err |
| 204 | } | 204 | } |
| 205 | 205 | ||
| 206 | - for _, u := range feedURLs { | 206 | + if len(feedURLs) > 0 { |
| 207 | - if _, err := tx.ExecContext(ctx, `UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0) WHERE feed_url = ?`, u); err != nil { | 207 | + ph := make([]string, len(feedURLs)) |
| 208 | + args := make([]any, len(feedURLs)) | ||
| 209 | + for i, u := range feedURLs { | ||
| 210 | + ph[i] = "?" | ||
| 211 | + args[i] = u | ||
| 212 | + } | ||
| 213 | + _, err = tx.ExecContext(ctx, ` | ||
| 214 | + UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0) | ||
| 215 | + WHERE feed_url IN (`+strings.Join(ph, ",")+`) | ||
| 216 | + `, args...) | ||
| 217 | + if err != nil { | ||
| 208 | return err | 218 | return err |
| 209 | } | 219 | } |
| 210 | } | 220 | } |
| @@ -212,6 +222,21 @@ func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error | |||
| 212 | return tx.Commit() | 222 | return tx.Commit() |
| 213 | } | 223 | } |
| 214 | 224 | ||
| 225 | +func (db *DB) GetSubscriptionByURI(ctx context.Context, userDID, uri string) (*Subscription, error) { | ||
| 226 | + s := &Subscription{} | ||
| 227 | + err := db.QueryRowContext(ctx, ` | ||
| 228 | + SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at, | ||
| 229 | + s.uri, s.cid | ||
| 230 | + FROM subscriptions s | ||
| 231 | + LEFT JOIN feeds f ON s.feed_url = f.feed_url | ||
| 232 | + WHERE s.user_did = ? AND s.uri = ? | ||
| 233 | + `, userDID, uri).Scan(&s.ID, &s.UserDID, &s.FeedURL, &s.FeedTitle, &s.Category, &s.AddedAt, &s.URI, &s.CID) | ||
| 234 | + if err != nil { | ||
| 235 | + return nil, err | ||
| 236 | + } | ||
| 237 | + return s, nil | ||
| 238 | +} | ||
| 239 | + | ||
| 215 | func (db *DB) GetSubscription(ctx context.Context, userDID, feedURL string) (*Subscription, error) { | 240 | func (db *DB) GetSubscription(ctx context.Context, userDID, feedURL string) (*Subscription, error) { |
| 216 | s := &Subscription{} | 241 | s := &Subscription{} |
| 217 | err := db.QueryRowContext(ctx, ` | 242 | err := db.QueryRowContext(ctx, ` |
| @@ -240,7 +265,8 @@ func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, l | |||
| 240 | args = append(args, category) | 265 | args = append(args, category) |
| 241 | } | 266 | } |
| 242 | 267 | ||
| 243 | - query += fmt.Sprintf(` ORDER BY s.added_at DESC LIMIT %d OFFSET %d`, limit, offset) | 268 | + query += ` ORDER BY s.added_at DESC LIMIT ? OFFSET ?` |
| 269 | + args = append(args, limit, offset) | ||
| 244 | 270 | ||
| 245 | rows, err := db.QueryContext(ctx, query, args...) | 271 | rows, err := db.QueryContext(ctx, query, args...) |
| 246 | if err != nil { | 272 | if err != nil { |
| @@ -267,6 +293,28 @@ func (db *DB) GetSubscriptionCount(ctx context.Context, userDID string) (int, er | |||
| 267 | return count, err | 293 | return count, err |
| 268 | } | 294 | } |
| 269 | 295 | ||
| 296 | +func (db *DB) GetCategories(ctx context.Context, userDID string) ([]string, error) { | ||
| 297 | + rows, err := db.QueryContext(ctx, ` | ||
| 298 | + SELECT DISTINCT category FROM subscriptions | ||
| 299 | + WHERE user_did = ? AND category IS NOT NULL AND category != '' | ||
| 300 | + ORDER BY category | ||
| 301 | + `, userDID) | ||
| 302 | + if err != nil { | ||
| 303 | + return nil, err | ||
| 304 | + } | ||
| 305 | + defer rows.Close() | ||
| 306 | + | ||
| 307 | + var categories []string | ||
| 308 | + for rows.Next() { | ||
| 309 | + var cat string | ||
| 310 | + if err := rows.Scan(&cat); err != nil { | ||
| 311 | + return nil, err | ||
| 312 | + } | ||
| 313 | + categories = append(categories, cat) | ||
| 314 | + } | ||
| 315 | + return categories, rows.Err() | ||
| 316 | +} | ||
| 317 | + | ||
| 270 | func (db *DB) UpdateFeedFavicon(ctx context.Context, feedURL, faviconURL string) error { | 318 | func (db *DB) UpdateFeedFavicon(ctx context.Context, feedURL, faviconURL string) error { |
| 271 | _, err := db.ExecContext(ctx, `UPDATE feeds SET favicon_url = ? WHERE feed_url = ?`, faviconURL, feedURL) | 319 | _, err := db.ExecContext(ctx, `UPDATE feeds SET favicon_url = ? WHERE feed_url = ?`, faviconURL, feedURL) |
| 272 | return err | 320 | return err |
| @@ -301,14 +349,14 @@ func (db *DB) ListDeadFeeds(ctx context.Context, userDID string, threshold int) | |||
| 301 | } | 349 | } |
| 302 | 350 | ||
| 303 | func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) { | 351 | func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) { |
| 304 | - rows, err := db.QueryContext(ctx, fmt.Sprintf(` | 352 | + rows, err := db.QueryContext(ctx, ` |
| 305 | SELECT feed_url, title, site_url, description, feed_type, | 353 | SELECT feed_url, title, site_url, description, feed_type, |
| 306 | last_fetched_at, last_error, subscriber_count, etag, last_modified, | 354 | last_fetched_at, last_error, subscriber_count, etag, last_modified, |
| 307 | fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url | 355 | fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url |
| 308 | FROM feeds | 356 | FROM feeds |
| 309 | ORDER BY subscriber_count DESC | 357 | ORDER BY subscriber_count DESC |
| 310 | - LIMIT %d OFFSET %d | 358 | + LIMIT ? OFFSET ? |
| 311 | - `, limit, offset)) | 359 | + `, limit, offset) |
| 312 | if err != nil { | 360 | if err != nil { |
| 313 | return nil, err | 361 | return nil, err |
| 314 | } | 362 | } |
| @@ -328,15 +376,15 @@ func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, err | |||
| 328 | } | 376 | } |
| 329 | 377 | ||
| 330 | func (db *DB) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) { | 378 | func (db *DB) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) { |
| 331 | - rows, err := db.QueryContext(ctx, fmt.Sprintf(` | 379 | + rows, err := db.QueryContext(ctx, ` |
| 332 | SELECT feed_url, title, site_url, description, feed_type, | 380 | SELECT feed_url, title, site_url, description, feed_type, |
| 333 | last_fetched_at, last_error, subscriber_count, etag, last_modified, | 381 | last_fetched_at, last_error, subscriber_count, etag, last_modified, |
| 334 | fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url | 382 | fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url |
| 335 | FROM feeds | 383 | FROM feeds |
| 336 | WHERE feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) | 384 | WHERE feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) |
| 337 | ORDER BY subscriber_count DESC | 385 | ORDER BY subscriber_count DESC |
| 338 | - LIMIT %d OFFSET %d | 386 | + LIMIT ? OFFSET ? |
| 339 | - `, limit, offset), userDID) | 387 | + `, userDID, limit, offset) |
| 340 | if err != nil { | 388 | if err != nil { |
| 341 | return nil, err | 389 | return nil, err |
| 342 | } | 390 | } |
modified
internal/db/social.go +25 -12 | @@ -3,7 +3,6 @@ package db | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "database/sql" |
| 6 | - "fmt" | |
| 7 | 6 | "strings" |
| 8 | 7 | ) |
| 9 | 8 | |
| @@ -60,6 +59,18 @@ func (db *DB) DeleteAnnotation(ctx context.Context, uri string) error { | ||
| 60 | 59 | return err |
| 61 | 60 | } |
| 62 | 61 | |
| 62 | +func (db *DB) AnnotationExists(ctx context.Context, uri string) (bool, error) { | |
| 63 | + var exists int | |
| 64 | + err := db.QueryRowContext(ctx, `SELECT 1 FROM annotations WHERE uri = ?`, uri).Scan(&exists) | |
| 65 | + if err == sql.ErrNoRows { | |
| 66 | + return false, nil | |
| 67 | + } | |
| 68 | + if err != nil { | |
| 69 | + return false, err | |
| 70 | + } | |
| 71 | + return true, nil | |
| 72 | +} | |
| 73 | + | |
| 63 | 74 | func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDID string, limit, offset int) ([]*Annotation, error) { |
| 64 | 75 | var conds []string |
| 65 | 76 | var args []any |
| @@ -83,7 +94,8 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI | ||
| 83 | 94 | if len(conds) > 0 { |
| 84 | 95 | query += ` WHERE ` + strings.Join(conds, " AND ") |
| 85 | 96 | } |
| 86 | - query += fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT %d OFFSET %d`, limit, offset) | |
| 97 | + query += ` ORDER BY a.created_at DESC LIMIT ? OFFSET ?` | |
| 98 | + args = append(args, limit, offset) | |
| 87 | 99 | |
| 88 | 100 | rows, err := db.QueryContext(ctx, query, args...) |
| 89 | 101 | if err != nil { |
| @@ -140,7 +152,8 @@ func (db *DB) ListLikes(ctx context.Context, authorDID, feedURL string, limit, o | ||
| 140 | 152 | if len(conds) > 0 { |
| 141 | 153 | query += ` WHERE ` + strings.Join(conds, " AND ") |
| 142 | 154 | } |
| 143 | - query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT %d OFFSET %d`, limit, offset) | |
| 155 | + query += ` ORDER BY created_at DESC LIMIT ? OFFSET ?` | |
| 156 | + args = append(args, limit, offset) | |
| 144 | 157 | |
| 145 | 158 | rows, err := db.QueryContext(ctx, query, args...) |
| 146 | 159 | if err != nil { |
| @@ -207,7 +220,7 @@ type TrendingItem struct { | ||
| 207 | 220 | } |
| 208 | 221 | |
| 209 | 222 | func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since string, limit, offset int) ([]*TrendingItem, error) { |
| 210 | - rows, err := db.QueryContext(ctx, fmt.Sprintf(` | |
| 223 | + rows, err := db.QueryContext(ctx, ` | |
| 211 | 224 | SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''), |
| 212 | 225 | COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''), |
| 213 | 226 | COALESCE(f.favicon_url, ''), |
| @@ -227,8 +240,8 @@ func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since st | ||
| 227 | 240 | ) |
| 228 | 241 | GROUP BY ar.id |
| 229 | 242 | ORDER BY like_count DESC, annotation_count DESC |
| 230 | - LIMIT %d OFFSET %d | |
| 231 | - `, limit, offset), since, since, userDID, userDID, userDID, userDID, userDID) | |
| 243 | + LIMIT ? OFFSET ? | |
| 244 | + `, since, since, userDID, userDID, userDID, userDID, userDID, limit, offset) | |
| 232 | 245 | if err != nil { |
| 233 | 246 | return nil, err |
| 234 | 247 | } |
| @@ -248,7 +261,7 @@ func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since st | ||
| 248 | 261 | } |
| 249 | 262 | |
| 250 | 263 | func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, offset int) ([]*TrendingItem, error) { |
| 251 | - rows, err := db.QueryContext(ctx, fmt.Sprintf(` | |
| 264 | + rows, err := db.QueryContext(ctx, ` | |
| 252 | 265 | SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''), |
| 253 | 266 | COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''), |
| 254 | 267 | COALESCE(f.favicon_url, ''), |
| @@ -261,8 +274,8 @@ func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, off | ||
| 261 | 274 | WHERE l.created_at >= ? |
| 262 | 275 | GROUP BY ar.id |
| 263 | 276 | ORDER BY like_count DESC, annotation_count DESC |
| 264 | - LIMIT %d OFFSET %d | |
| 265 | - `, limit, offset), since, since) | |
| 277 | + LIMIT ? OFFSET ? | |
| 278 | + `, since, since, limit, offset) | |
| 266 | 279 | if err != nil { |
| 267 | 280 | return nil, err |
| 268 | 281 | } |
| @@ -282,7 +295,7 @@ func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, off | ||
| 282 | 295 | } |
| 283 | 296 | |
| 284 | 297 | func (db *DB) ListLikedArticles(ctx context.Context, userDID string, limit, offset int) ([]*Article, error) { |
| 285 | - rows, err := db.QueryContext(ctx, fmt.Sprintf(` | |
| 298 | + rows, err := db.QueryContext(ctx, ` | |
| 286 | 299 | SELECT DISTINCT a.id, a.feed_url, a.guid, a.title, a.url, a.author, a.summary, a.content, |
| 287 | 300 | a.published, a.updated, a.fetched_at, |
| 288 | 301 | COALESCE(f.title, '') |
| @@ -291,8 +304,8 @@ func (db *DB) ListLikedArticles(ctx context.Context, userDID string, limit, offs | ||
| 291 | 304 | LEFT JOIN feeds f ON f.feed_url = a.feed_url |
| 292 | 305 | WHERE l.author_did = ? |
| 293 | 306 | ORDER BY l.created_at DESC |
| 294 | - LIMIT %d OFFSET %d | |
| 295 | - `, limit, offset), userDID) | |
| 307 | + LIMIT ? OFFSET ? | |
| 308 | + `, userDID, limit, offset) | |
| 296 | 309 | if err != nil { |
| 297 | 310 | return nil, err |
| 298 | 311 | } |
| @@ -3,7 +3,6 @@ package db | |||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | "database/sql" | 5 | "database/sql" |
| 6 | - "fmt" | ||
| 7 | "strings" | 6 | "strings" |
| 8 | ) | 7 | ) |
| 9 | 8 | ||
| @@ -60,6 +59,18 @@ func (db *DB) DeleteAnnotation(ctx context.Context, uri string) error { | |||
| 60 | return err | 59 | return err |
| 61 | } | 60 | } |
| 62 | 61 | ||
| 62 | +func (db *DB) AnnotationExists(ctx context.Context, uri string) (bool, error) { | ||
| 63 | + var exists int | ||
| 64 | + err := db.QueryRowContext(ctx, `SELECT 1 FROM annotations WHERE uri = ?`, uri).Scan(&exists) | ||
| 65 | + if err == sql.ErrNoRows { | ||
| 66 | + return false, nil | ||
| 67 | + } | ||
| 68 | + if err != nil { | ||
| 69 | + return false, err | ||
| 70 | + } | ||
| 71 | + return true, nil | ||
| 72 | +} | ||
| 73 | + | ||
| 63 | func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDID string, limit, offset int) ([]*Annotation, error) { | 74 | func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDID string, limit, offset int) ([]*Annotation, error) { |
| 64 | var conds []string | 75 | var conds []string |
| 65 | var args []any | 76 | var args []any |
| @@ -83,7 +94,8 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI | |||
| 83 | if len(conds) > 0 { | 94 | if len(conds) > 0 { |
| 84 | query += ` WHERE ` + strings.Join(conds, " AND ") | 95 | query += ` WHERE ` + strings.Join(conds, " AND ") |
| 85 | } | 96 | } |
| 86 | - query += fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT %d OFFSET %d`, limit, offset) | 97 | + query += ` ORDER BY a.created_at DESC LIMIT ? OFFSET ?` |
| 98 | + args = append(args, limit, offset) | ||
| 87 | 99 | ||
| 88 | rows, err := db.QueryContext(ctx, query, args...) | 100 | rows, err := db.QueryContext(ctx, query, args...) |
| 89 | if err != nil { | 101 | if err != nil { |
| @@ -140,7 +152,8 @@ func (db *DB) ListLikes(ctx context.Context, authorDID, feedURL string, limit, o | |||
| 140 | if len(conds) > 0 { | 152 | if len(conds) > 0 { |
| 141 | query += ` WHERE ` + strings.Join(conds, " AND ") | 153 | query += ` WHERE ` + strings.Join(conds, " AND ") |
| 142 | } | 154 | } |
| 143 | - query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT %d OFFSET %d`, limit, offset) | 155 | + query += ` ORDER BY created_at DESC LIMIT ? OFFSET ?` |
| 156 | + args = append(args, limit, offset) | ||
| 144 | 157 | ||
| 145 | rows, err := db.QueryContext(ctx, query, args...) | 158 | rows, err := db.QueryContext(ctx, query, args...) |
| 146 | if err != nil { | 159 | if err != nil { |
| @@ -207,7 +220,7 @@ type TrendingItem struct { | |||
| 207 | } | 220 | } |
| 208 | 221 | ||
| 209 | func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since string, limit, offset int) ([]*TrendingItem, error) { | 222 | func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since string, limit, offset int) ([]*TrendingItem, error) { |
| 210 | - rows, err := db.QueryContext(ctx, fmt.Sprintf(` | 223 | + rows, err := db.QueryContext(ctx, ` |
| 211 | SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''), | 224 | SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''), |
| 212 | COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''), | 225 | COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''), |
| 213 | COALESCE(f.favicon_url, ''), | 226 | COALESCE(f.favicon_url, ''), |
| @@ -227,8 +240,8 @@ func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since st | |||
| 227 | ) | 240 | ) |
| 228 | GROUP BY ar.id | 241 | GROUP BY ar.id |
| 229 | ORDER BY like_count DESC, annotation_count DESC | 242 | ORDER BY like_count DESC, annotation_count DESC |
| 230 | - LIMIT %d OFFSET %d | 243 | + LIMIT ? OFFSET ? |
| 231 | - `, limit, offset), since, since, userDID, userDID, userDID, userDID, userDID) | 244 | + `, since, since, userDID, userDID, userDID, userDID, userDID, limit, offset) |
| 232 | if err != nil { | 245 | if err != nil { |
| 233 | return nil, err | 246 | return nil, err |
| 234 | } | 247 | } |
| @@ -248,7 +261,7 @@ func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since st | |||
| 248 | } | 261 | } |
| 249 | 262 | ||
| 250 | func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, offset int) ([]*TrendingItem, error) { | 263 | func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, offset int) ([]*TrendingItem, error) { |
| 251 | - rows, err := db.QueryContext(ctx, fmt.Sprintf(` | 264 | + rows, err := db.QueryContext(ctx, ` |
| 252 | SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''), | 265 | SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''), |
| 253 | COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''), | 266 | COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''), |
| 254 | COALESCE(f.favicon_url, ''), | 267 | COALESCE(f.favicon_url, ''), |
| @@ -261,8 +274,8 @@ func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, off | |||
| 261 | WHERE l.created_at >= ? | 274 | WHERE l.created_at >= ? |
| 262 | GROUP BY ar.id | 275 | GROUP BY ar.id |
| 263 | ORDER BY like_count DESC, annotation_count DESC | 276 | ORDER BY like_count DESC, annotation_count DESC |
| 264 | - LIMIT %d OFFSET %d | 277 | + LIMIT ? OFFSET ? |
| 265 | - `, limit, offset), since, since) | 278 | + `, since, since, limit, offset) |
| 266 | if err != nil { | 279 | if err != nil { |
| 267 | return nil, err | 280 | return nil, err |
| 268 | } | 281 | } |
| @@ -282,7 +295,7 @@ func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, off | |||
| 282 | } | 295 | } |
| 283 | 296 | ||
| 284 | func (db *DB) ListLikedArticles(ctx context.Context, userDID string, limit, offset int) ([]*Article, error) { | 297 | func (db *DB) ListLikedArticles(ctx context.Context, userDID string, limit, offset int) ([]*Article, error) { |
| 285 | - rows, err := db.QueryContext(ctx, fmt.Sprintf(` | 298 | + rows, err := db.QueryContext(ctx, ` |
| 286 | SELECT DISTINCT a.id, a.feed_url, a.guid, a.title, a.url, a.author, a.summary, a.content, | 299 | SELECT DISTINCT a.id, a.feed_url, a.guid, a.title, a.url, a.author, a.summary, a.content, |
| 287 | a.published, a.updated, a.fetched_at, | 300 | a.published, a.updated, a.fetched_at, |
| 288 | COALESCE(f.title, '') | 301 | COALESCE(f.title, '') |
| @@ -291,8 +304,8 @@ func (db *DB) ListLikedArticles(ctx context.Context, userDID string, limit, offs | |||
| 291 | LEFT JOIN feeds f ON f.feed_url = a.feed_url | 304 | LEFT JOIN feeds f ON f.feed_url = a.feed_url |
| 292 | WHERE l.author_did = ? | 305 | WHERE l.author_did = ? |
| 293 | ORDER BY l.created_at DESC | 306 | ORDER BY l.created_at DESC |
| 294 | - LIMIT %d OFFSET %d | 307 | + LIMIT ? OFFSET ? |
| 295 | - `, limit, offset), userDID) | 308 | + `, userDID, limit, offset) |
| 296 | if err != nil { | 309 | if err != nil { |
| 297 | return nil, err | 310 | return nil, err |
| 298 | } | 311 | } |
modified
internal/feed/discover.go +6 -6 | @@ -21,20 +21,21 @@ var ( | ||
| 21 | 21 | relFeedRe = regexp.MustCompile(`rel="(alternate|feed)"`) |
| 22 | 22 | typeFeedRe = regexp.MustCompile(`type="([^"]*(?:rss|atom|feed|xml)[^"]*)"`) |
| 23 | 23 | relIconRe = regexp.MustCompile(`rel="[^"]*icon[^"]*"`) |
| 24 | + baseHrefRe = regexp.MustCompile(`<base[^>]+href="([^"]*)"`) | |
| 24 | 25 | faviconPaths = []string{"/favicon.ico", "/favicon.png", "/apple-touch-icon.png"} |
| 26 | + | |
| 27 | + discoverClient = &http.Client{Timeout: 15 * time.Second} | |
| 25 | 28 | ) |
| 26 | 29 | |
| 27 | 30 | func Discover(ctx context.Context, siteURL string) (*DiscoveryResult, error) { |
| 28 | - client := &http.Client{Timeout: 15 * time.Second} | |
| 29 | - | |
| 30 | 31 | result := &DiscoveryResult{} |
| 31 | 32 | |
| 32 | - favicon := discoverFavicon(ctx, client, siteURL) | |
| 33 | + favicon := discoverFavicon(ctx, discoverClient, siteURL) | |
| 33 | 34 | if favicon != "" { |
| 34 | 35 | result.Favicon = favicon |
| 35 | 36 | } |
| 36 | 37 | |
| 37 | - feeds := discoverFeedLinks(ctx, client, siteURL) | |
| 38 | + feeds := discoverFeedLinks(ctx, discoverClient, siteURL) | |
| 38 | 39 | result.FeedURLs = feeds |
| 39 | 40 | |
| 40 | 41 | return result, nil |
| @@ -165,8 +166,7 @@ func tryDefaultFavicons(ctx context.Context, client *http.Client, siteURL string | ||
| 165 | 166 | } |
| 166 | 167 | |
| 167 | 168 | func resolveBaseURL(siteURL, html string) string { |
| 168 | - baseRe := regexp.MustCompile(`<base[^>]+href="([^"]*)"`) | |
| 169 | - match := baseRe.FindStringSubmatch(html) | |
| 169 | + match := baseHrefRe.FindStringSubmatch(html) | |
| 170 | 170 | if len(match) >= 2 && match[1] != "" { |
| 171 | 171 | return resolveURL(siteURL, match[1]) |
| 172 | 172 | } |
| @@ -21,20 +21,21 @@ var ( | |||
| 21 | relFeedRe = regexp.MustCompile(`rel="(alternate|feed)"`) | 21 | relFeedRe = regexp.MustCompile(`rel="(alternate|feed)"`) |
| 22 | typeFeedRe = regexp.MustCompile(`type="([^"]*(?:rss|atom|feed|xml)[^"]*)"`) | 22 | typeFeedRe = regexp.MustCompile(`type="([^"]*(?:rss|atom|feed|xml)[^"]*)"`) |
| 23 | relIconRe = regexp.MustCompile(`rel="[^"]*icon[^"]*"`) | 23 | relIconRe = regexp.MustCompile(`rel="[^"]*icon[^"]*"`) |
| 24 | + baseHrefRe = regexp.MustCompile(`<base[^>]+href="([^"]*)"`) | ||
| 24 | faviconPaths = []string{"/favicon.ico", "/favicon.png", "/apple-touch-icon.png"} | 25 | faviconPaths = []string{"/favicon.ico", "/favicon.png", "/apple-touch-icon.png"} |
| 26 | + | ||
| 27 | + discoverClient = &http.Client{Timeout: 15 * time.Second} | ||
| 25 | ) | 28 | ) |
| 26 | 29 | ||
| 27 | func Discover(ctx context.Context, siteURL string) (*DiscoveryResult, error) { | 30 | func Discover(ctx context.Context, siteURL string) (*DiscoveryResult, error) { |
| 28 | - client := &http.Client{Timeout: 15 * time.Second} | ||
| 29 | - | ||
| 30 | result := &DiscoveryResult{} | 31 | result := &DiscoveryResult{} |
| 31 | 32 | ||
| 32 | - favicon := discoverFavicon(ctx, client, siteURL) | 33 | + favicon := discoverFavicon(ctx, discoverClient, siteURL) |
| 33 | if favicon != "" { | 34 | if favicon != "" { |
| 34 | result.Favicon = favicon | 35 | result.Favicon = favicon |
| 35 | } | 36 | } |
| 36 | 37 | ||
| 37 | - feeds := discoverFeedLinks(ctx, client, siteURL) | 38 | + feeds := discoverFeedLinks(ctx, discoverClient, siteURL) |
| 38 | result.FeedURLs = feeds | 39 | result.FeedURLs = feeds |
| 39 | 40 | ||
| 40 | return result, nil | 41 | return result, nil |
| @@ -165,8 +166,7 @@ func tryDefaultFavicons(ctx context.Context, client *http.Client, siteURL string | |||
| 165 | } | 166 | } |
| 166 | 167 | ||
| 167 | func resolveBaseURL(siteURL, html string) string { | 168 | func resolveBaseURL(siteURL, html string) string { |
| 168 | - baseRe := regexp.MustCompile(`<base[^>]+href="([^"]*)"`) | 169 | + match := baseHrefRe.FindStringSubmatch(html) |
| 169 | - match := baseRe.FindStringSubmatch(html) | ||
| 170 | if len(match) >= 2 && match[1] != "" { | 170 | if len(match) >= 2 && match[1] != "" { |
| 171 | return resolveURL(siteURL, match[1]) | 171 | return resolveURL(siteURL, match[1]) |
| 172 | } | 172 | } |
modified
internal/feed/fetcher.go +14 -2 | @@ -119,9 +119,21 @@ func (s *Scheduler) fetchAll(ctx context.Context) { | ||
| 119 | 119 | s.logger.Error("failed to get feeds", "error", err) |
| 120 | 120 | return |
| 121 | 121 | } |
| 122 | + | |
| 123 | + sem := make(chan struct{}, 10) | |
| 124 | + var wg sync.WaitGroup | |
| 122 | 125 | for _, f := range feeds { |
| 123 | - s.FetchFeed(ctx, f) | |
| 124 | - } | |
| 126 | + wg.Add(1) | |
| 127 | + sem <- struct{}{} | |
| 128 | + go func(feed *Feed) { | |
| 129 | + defer func() { | |
| 130 | + <-sem | |
| 131 | + wg.Done() | |
| 132 | + }() | |
| 133 | + s.FetchFeed(ctx, feed) | |
| 134 | + }(f) | |
| 135 | + } | |
| 136 | + wg.Wait() | |
| 125 | 137 | } |
| 126 | 138 | |
| 127 | 139 | func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) { |
| @@ -119,9 +119,21 @@ func (s *Scheduler) fetchAll(ctx context.Context) { | |||
| 119 | s.logger.Error("failed to get feeds", "error", err) | 119 | s.logger.Error("failed to get feeds", "error", err) |
| 120 | return | 120 | return |
| 121 | } | 121 | } |
| 122 | + | ||
| 123 | + sem := make(chan struct{}, 10) | ||
| 124 | + var wg sync.WaitGroup | ||
| 122 | for _, f := range feeds { | 125 | for _, f := range feeds { |
| 123 | - s.FetchFeed(ctx, f) | 126 | + wg.Add(1) |
| 124 | - } | 127 | + sem <- struct{}{} |
| 128 | + go func(feed *Feed) { | ||
| 129 | + defer func() { | ||
| 130 | + <-sem | ||
| 131 | + wg.Done() | ||
| 132 | + }() | ||
| 133 | + s.FetchFeed(ctx, feed) | ||
| 134 | + }(f) | ||
| 135 | + } | ||
| 136 | + wg.Wait() | ||
| 125 | } | 137 | } |
| 126 | 138 | ||
| 127 | func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) { | 139 | func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) { |
modified
internal/feed/parser.go +1 -1 | @@ -99,7 +99,7 @@ type jsonFeed struct { | ||
| 99 | 99 | } |
| 100 | 100 | |
| 101 | 101 | func Parse(r io.Reader, feedURL string) (*ParseResult, error) { |
| 102 | - data, err := io.ReadAll(r) | |
| 102 | + data, err := io.ReadAll(io.LimitReader(r, 10*1024*1024)) | |
| 103 | 103 | if err != nil { |
| 104 | 104 | return nil, fmt.Errorf("reading feed data: %w", err) |
| 105 | 105 | } |
| @@ -99,7 +99,7 @@ type jsonFeed struct { | |||
| 99 | } | 99 | } |
| 100 | 100 | ||
| 101 | func Parse(r io.Reader, feedURL string) (*ParseResult, error) { | 101 | func Parse(r io.Reader, feedURL string) (*ParseResult, error) { |
| 102 | - data, err := io.ReadAll(r) | 102 | + data, err := io.ReadAll(io.LimitReader(r, 10*1024*1024)) |
| 103 | if err != nil { | 103 | if err != nil { |
| 104 | return nil, fmt.Errorf("reading feed data: %w", err) | 104 | return nil, fmt.Errorf("reading feed data: %w", err) |
| 105 | } | 105 | } |
modified
internal/server/feeds_handler.go +1 -8 | @@ -29,14 +29,7 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) { | ||
| 29 | 29 | peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5) |
| 30 | 30 | deadFeeds, _ := s.db.ListDeadFeeds(r.Context(), user.DID, 7) |
| 31 | 31 | |
| 32 | - seen := make(map[string]bool) | |
| 33 | - var categories []string | |
| 34 | - for _, sub := range allSubs { | |
| 35 | - if sub.Category.Valid && sub.Category.String != "" && !seen[sub.Category.String] { | |
| 36 | - seen[sub.Category.String] = true | |
| 37 | - categories = append(categories, sub.Category.String) | |
| 38 | - } | |
| 39 | - } | |
| 32 | + categories, _ := s.db.GetCategories(r.Context(), user.DID) | |
| 40 | 33 | |
| 41 | 34 | s.render(w, r, "feeds.html", map[string]any{ |
| 42 | 35 | "User": user, |
| @@ -29,14 +29,7 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) { | |||
| 29 | peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5) | 29 | peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5) |
| 30 | deadFeeds, _ := s.db.ListDeadFeeds(r.Context(), user.DID, 7) | 30 | deadFeeds, _ := s.db.ListDeadFeeds(r.Context(), user.DID, 7) |
| 31 | 31 | ||
| 32 | - seen := make(map[string]bool) | 32 | + categories, _ := s.db.GetCategories(r.Context(), user.DID) |
| 33 | - var categories []string | ||
| 34 | - for _, sub := range allSubs { | ||
| 35 | - if sub.Category.Valid && sub.Category.String != "" && !seen[sub.Category.String] { | ||
| 36 | - seen[sub.Category.String] = true | ||
| 37 | - categories = append(categories, sub.Category.String) | ||
| 38 | - } | ||
| 39 | - } | ||
| 40 | 33 | ||
| 41 | s.render(w, r, "feeds.html", map[string]any{ | 34 | s.render(w, r, "feeds.html", map[string]any{ |
| 42 | "User": user, | 35 | "User": user, |