Make feed fetch scheduler configurable and improve update logicUnverified
238f0f3 parent: 222591d modified
.env.example +3 -2 | @@ -2,8 +2,9 @@ GLEAN_ADDR=:8080 | ||
| 2 | 2 | GLEAN_DB=glean.db |
| 3 | 3 | GLEAN_JETSTREAM=wss://jetstream.glean.at |
| 4 | 4 | GLEAN_PLC_URL=https://didplc.glean.at |
| 5 | -GLEAN_SYNC_INTERVAL=1h | |
| 6 | -GLEAN_CLUSTER_INTERVAL=10m | |
| 5 | +GLEAN_SYNC_INTERVAL=30m | |
| 6 | +GLEAN_CLUSTER_INTERVAL=1h | |
| 7 | +GLEAN_FETCH_INTERVAL=5m | |
| 7 | 8 | GLEAN_COLLECTION_DIR_URL=https://lightrail.microcosm.blue/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription |
| 8 | 9 | # Leave empty for localhost OAuth (development) |
| 9 | 10 | # GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata |
| @@ -2,8 +2,9 @@ GLEAN_ADDR=:8080 | |||
| 2 | GLEAN_DB=glean.db | 2 | GLEAN_DB=glean.db |
| 3 | GLEAN_JETSTREAM=wss://jetstream.glean.at | 3 | GLEAN_JETSTREAM=wss://jetstream.glean.at |
| 4 | GLEAN_PLC_URL=https://didplc.glean.at | 4 | GLEAN_PLC_URL=https://didplc.glean.at |
| 5 | -GLEAN_SYNC_INTERVAL=1h | 5 | +GLEAN_SYNC_INTERVAL=30m |
| 6 | -GLEAN_CLUSTER_INTERVAL=10m | 6 | +GLEAN_CLUSTER_INTERVAL=1h |
| 7 | +GLEAN_FETCH_INTERVAL=5m | ||
| 7 | GLEAN_COLLECTION_DIR_URL=https://lightrail.microcosm.blue/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription | 8 | GLEAN_COLLECTION_DIR_URL=https://lightrail.microcosm.blue/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription |
| 8 | # Leave empty for localhost OAuth (development) | 9 | # Leave empty for localhost OAuth (development) |
| 9 | # GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata | 10 | # GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata |
modified
docs/specs.md +2 -2 | @@ -328,9 +328,9 @@ A background scheduler polls subscribed feeds on a fixed 5-minute tick. Feeds ar | ||
| 328 | 328 | |
| 329 | 329 | ### 4.2 Fetch Schedule |
| 330 | 330 | |
| 331 | -The scheduler uses a single fixed interval with in-flight deduplication: | |
| 331 | +The scheduler uses a configurable tick interval with in-flight deduplication: | |
| 332 | 332 | |
| 333 | -- **Tick interval**: The scheduler checks for stale feeds every 5 minutes | |
| 333 | +- **Tick interval**: The scheduler checks for stale feeds every `GLEAN_FETCH_INTERVAL` (default 5 minutes) | |
| 334 | 334 | - **Staleness threshold**: Feeds not fetched in the last 30 minutes are eligible |
| 335 | 335 | - **Subscriber filter**: Only feeds with `subscriber_count > 0` are fetched |
| 336 | 336 | - **In-flight dedup**: If a feed is already being fetched (e.g., manual refresh and background scheduler overlap), the second caller waits for the first to complete rather than fetching again |
| @@ -328,9 +328,9 @@ A background scheduler polls subscribed feeds on a fixed 5-minute tick. Feeds ar | |||
| 328 | 328 | ||
| 329 | ### 4.2 Fetch Schedule | 329 | ### 4.2 Fetch Schedule |
| 330 | 330 | ||
| 331 | -The scheduler uses a single fixed interval with in-flight deduplication: | 331 | +The scheduler uses a configurable tick interval with in-flight deduplication: |
| 332 | 332 | ||
| 333 | -- **Tick interval**: The scheduler checks for stale feeds every 5 minutes | 333 | +- **Tick interval**: The scheduler checks for stale feeds every `GLEAN_FETCH_INTERVAL` (default 5 minutes) |
| 334 | - **Staleness threshold**: Feeds not fetched in the last 30 minutes are eligible | 334 | - **Staleness threshold**: Feeds not fetched in the last 30 minutes are eligible |
| 335 | - **Subscriber filter**: Only feeds with `subscriber_count > 0` are fetched | 335 | - **Subscriber filter**: Only feeds with `subscriber_count > 0` are fetched |
| 336 | - **In-flight dedup**: If a feed is already being fetched (e.g., manual refresh and background scheduler overlap), the second caller waits for the first to complete rather than fetching again | 336 | - **In-flight dedup**: If a feed is already being fetched (e.g., manual refresh and background scheduler overlap), the second caller waits for the first to complete rather than fetching again |
modified
internal/atproto/jetstream.go +1 -1 | @@ -130,7 +130,7 @@ func (jc *JetstreamConsumer) Start(ctx context.Context) error { | ||
| 130 | 130 | return ctx.Err() |
| 131 | 131 | } |
| 132 | 132 | if err != nil { |
| 133 | - jc.logger.Error("jetstream connection error", "error", err) | |
| 133 | + jc.logger.Warn("jetstream connection lost", "error", err) | |
| 134 | 134 | metrics.JetstreamReconnects.Inc() |
| 135 | 135 | } |
| 136 | 136 | |
| @@ -130,7 +130,7 @@ func (jc *JetstreamConsumer) Start(ctx context.Context) error { | |||
| 130 | return ctx.Err() | 130 | return ctx.Err() |
| 131 | } | 131 | } |
| 132 | if err != nil { | 132 | if err != nil { |
| 133 | - jc.logger.Error("jetstream connection error", "error", err) | 133 | + jc.logger.Warn("jetstream connection lost", "error", err) |
| 134 | metrics.JetstreamReconnects.Inc() | 134 | metrics.JetstreamReconnects.Inc() |
| 135 | } | 135 | } |
| 136 | 136 | ||
modified
internal/cluster/social.go +8 -6 | @@ -17,12 +17,14 @@ func (e *Engine) ComputeFollowDistances(ctx context.Context) error { | ||
| 17 | 17 | |
| 18 | 18 | _, err = tx.ExecContext(ctx, ` |
| 19 | 19 | INSERT INTO follow_distances (user_a, user_b, distance) |
| 20 | - SELECT user_did, target_did, 1 FROM follows WHERE user_did != target_did | |
| 21 | - UNION ALL | |
| 22 | - SELECT f1.user_did, f2.target_did, 2 | |
| 23 | - FROM follows f1 | |
| 24 | - JOIN follows f2 ON f1.target_did = f2.user_did | |
| 25 | - WHERE f1.user_did != f2.target_did | |
| 20 | + SELECT user_a, user_b, MIN(distance) FROM ( | |
| 21 | + SELECT user_did AS user_a, target_did AS user_b, 1 AS distance FROM follows WHERE user_did != target_did | |
| 22 | + UNION ALL | |
| 23 | + SELECT f1.user_did, f2.target_did, 2 | |
| 24 | + FROM follows f1 | |
| 25 | + JOIN follows f2 ON f1.target_did = f2.user_did | |
| 26 | + WHERE f1.user_did != f2.target_did | |
| 27 | + ) GROUP BY user_a, user_b | |
| 26 | 28 | `) |
| 27 | 29 | if err != nil { |
| 28 | 30 | return err |
| @@ -17,12 +17,14 @@ func (e *Engine) ComputeFollowDistances(ctx context.Context) error { | |||
| 17 | 17 | ||
| 18 | _, err = tx.ExecContext(ctx, ` | 18 | _, err = tx.ExecContext(ctx, ` |
| 19 | INSERT INTO follow_distances (user_a, user_b, distance) | 19 | INSERT INTO follow_distances (user_a, user_b, distance) |
| 20 | - SELECT user_did, target_did, 1 FROM follows WHERE user_did != target_did | 20 | + SELECT user_a, user_b, MIN(distance) FROM ( |
| 21 | - UNION ALL | 21 | + SELECT user_did AS user_a, target_did AS user_b, 1 AS distance FROM follows WHERE user_did != target_did |
| 22 | - SELECT f1.user_did, f2.target_did, 2 | 22 | + UNION ALL |
| 23 | - FROM follows f1 | 23 | + SELECT f1.user_did, f2.target_did, 2 |
| 24 | - JOIN follows f2 ON f1.target_did = f2.user_did | 24 | + FROM follows f1 |
| 25 | - WHERE f1.user_did != f2.target_did | 25 | + JOIN follows f2 ON f1.target_did = f2.user_did |
| 26 | + WHERE f1.user_did != f2.target_did | ||
| 27 | + ) GROUP BY user_a, user_b | ||
| 26 | `) | 28 | `) |
| 27 | if err != nil { | 29 | if err != nil { |
| 28 | return err | 30 | return err |
modified
internal/db/article.go +0 -25 | @@ -37,36 +37,11 @@ type ReadState struct { | ||
| 37 | 37 | ReadAt sql.NullTime |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | -func (db *DB) UpsertArticle(ctx context.Context, article *Article) (int64, error) { | |
| 41 | - var id int64 | |
| 42 | - err := db.QueryRowContext(ctx, ` | |
| 43 | - INSERT INTO articles (feed_url, guid, title, url, author, summary, content, published, updated) | |
| 44 | - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 45 | - ON CONFLICT(feed_url, guid) DO NOTHING | |
| 46 | - RETURNING id | |
| 47 | - `, article.FeedURL, article.GUID, article.Title, article.URL, article.Author, | |
| 48 | - article.Summary, article.Content, article.Published, article.Updated).Scan(&id) | |
| 49 | - if err == sql.ErrNoRows { | |
| 50 | - err = db.QueryRowContext(ctx, ` | |
| 51 | - SELECT id FROM articles WHERE feed_url = ? AND guid = ? | |
| 52 | - `, article.FeedURL, article.GUID).Scan(&id) | |
| 53 | - } | |
| 54 | - return id, err | |
| 55 | -} | |
| 56 | - | |
| 57 | 40 | func (db *DB) UpsertArticlesBatch(ctx context.Context, articles []feed.Article) error { |
| 58 | 41 | if len(articles) == 0 { |
| 59 | 42 | return nil |
| 60 | 43 | } |
| 61 | 44 | |
| 62 | - err := upsertArticlesBatch(ctx, db, articles) | |
| 63 | - if err != nil { | |
| 64 | - err = upsertArticlesBatch(ctx, db, articles) | |
| 65 | - } | |
| 66 | - return err | |
| 67 | -} | |
| 68 | - | |
| 69 | -func upsertArticlesBatch(ctx context.Context, db *DB, articles []feed.Article) error { | |
| 70 | 45 | tx, err := db.BeginTx(ctx, nil) |
| 71 | 46 | if err != nil { |
| 72 | 47 | return err |
| @@ -37,36 +37,11 @@ type ReadState struct { | |||
| 37 | ReadAt sql.NullTime | 37 | ReadAt sql.NullTime |
| 38 | } | 38 | } |
| 39 | 39 | ||
| 40 | -func (db *DB) UpsertArticle(ctx context.Context, article *Article) (int64, error) { | ||
| 41 | - var id int64 | ||
| 42 | - err := db.QueryRowContext(ctx, ` | ||
| 43 | - INSERT INTO articles (feed_url, guid, title, url, author, summary, content, published, updated) | ||
| 44 | - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) | ||
| 45 | - ON CONFLICT(feed_url, guid) DO NOTHING | ||
| 46 | - RETURNING id | ||
| 47 | - `, article.FeedURL, article.GUID, article.Title, article.URL, article.Author, | ||
| 48 | - article.Summary, article.Content, article.Published, article.Updated).Scan(&id) | ||
| 49 | - if err == sql.ErrNoRows { | ||
| 50 | - err = db.QueryRowContext(ctx, ` | ||
| 51 | - SELECT id FROM articles WHERE feed_url = ? AND guid = ? | ||
| 52 | - `, article.FeedURL, article.GUID).Scan(&id) | ||
| 53 | - } | ||
| 54 | - return id, err | ||
| 55 | -} | ||
| 56 | - | ||
| 57 | func (db *DB) UpsertArticlesBatch(ctx context.Context, articles []feed.Article) error { | 40 | func (db *DB) UpsertArticlesBatch(ctx context.Context, articles []feed.Article) error { |
| 58 | if len(articles) == 0 { | 41 | if len(articles) == 0 { |
| 59 | return nil | 42 | return nil |
| 60 | } | 43 | } |
| 61 | 44 | ||
| 62 | - err := upsertArticlesBatch(ctx, db, articles) | ||
| 63 | - if err != nil { | ||
| 64 | - err = upsertArticlesBatch(ctx, db, articles) | ||
| 65 | - } | ||
| 66 | - return err | ||
| 67 | -} | ||
| 68 | - | ||
| 69 | -func upsertArticlesBatch(ctx context.Context, db *DB, articles []feed.Article) error { | ||
| 70 | tx, err := db.BeginTx(ctx, nil) | 45 | tx, err := db.BeginTx(ctx, nil) |
| 71 | if err != nil { | 46 | if err != nil { |
| 72 | return err | 47 | return err |
modified
internal/db/store.go +16 -31 | @@ -2,7 +2,6 @@ package db | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | - "database/sql" | |
| 6 | 5 | "time" |
| 7 | 6 | |
| 8 | 7 | "pkg.rbrt.fr/glean/internal/feed" |
| @@ -37,37 +36,23 @@ func (a *FeedStoreAdapter) GetFeedsToFetch(ctx context.Context, olderThan time.D | ||
| 37 | 36 | return feeds, nil |
| 38 | 37 | } |
| 39 | 38 | |
| 40 | -func (a *FeedStoreAdapter) UpsertArticle(ctx context.Context, article *feed.Article) (int64, error) { | |
| 41 | - dbArticle := &Article{ | |
| 42 | - FeedURL: article.FeedURL, | |
| 43 | - GUID: article.GUID, | |
| 44 | - Title: article.Title, | |
| 45 | - Summary: sql.NullString{String: article.Summary, Valid: article.Summary != ""}, | |
| 46 | - Content: sql.NullString{String: article.Content, Valid: article.Content != ""}, | |
| 47 | - Author: sql.NullString{String: article.Author, Valid: article.Author != ""}, | |
| 48 | - URL: sql.NullString{String: article.URL, Valid: article.URL != ""}, | |
| 49 | - } | |
| 50 | - if !article.Published.IsZero() { | |
| 51 | - dbArticle.Published = sql.NullTime{Time: article.Published, Valid: true} | |
| 52 | - } | |
| 53 | - if !article.Updated.IsZero() { | |
| 54 | - dbArticle.Updated = sql.NullTime{Time: article.Updated, Valid: true} | |
| 55 | - } | |
| 56 | - return a.db.UpsertArticle(ctx, dbArticle) | |
| 57 | -} | |
| 58 | - | |
| 59 | -func (a *FeedStoreAdapter) UpsertArticlesBatch(ctx context.Context, articles []feed.Article) error { | |
| 60 | - return a.db.UpsertArticlesBatch(ctx, articles) | |
| 61 | -} | |
| 62 | - | |
| 63 | -func (a *FeedStoreAdapter) MarkFeedFetched(ctx context.Context, feedURL, etag, lastModified string) error { | |
| 64 | - return a.db.MarkFeedFetched(ctx, feedURL, etag, lastModified) | |
| 65 | -} | |
| 66 | - | |
| 67 | -func (a *FeedStoreAdapter) MarkFeedFetchError(ctx context.Context, feedURL, lastError string) error { | |
| 39 | +func (a *FeedStoreAdapter) RecordFetchError(ctx context.Context, feedURL, lastError string) error { | |
| 68 | 40 | return a.db.MarkFeedFetchError(ctx, feedURL, lastError) |
| 69 | 41 | } |
| 70 | 42 | |
| 71 | -func (a *FeedStoreAdapter) UpdateFeedFavicon(ctx context.Context, feedURL, faviconURL string) error { | |
| 72 | - return a.db.UpdateFeedFavicon(ctx, feedURL, faviconURL) | |
| 43 | +func (a *FeedStoreAdapter) StoreFetchResult(ctx context.Context, feedURL, etag, lastModified string, articles []feed.Article, faviconURL string) error { | |
| 44 | + if err := a.db.MarkFeedFetched(ctx, feedURL, etag, lastModified); err != nil { | |
| 45 | + return err | |
| 46 | + } | |
| 47 | + if len(articles) > 0 { | |
| 48 | + if err := a.db.UpsertArticlesBatch(ctx, articles); err != nil { | |
| 49 | + return err | |
| 50 | + } | |
| 51 | + } | |
| 52 | + if faviconURL != "" { | |
| 53 | + if err := a.db.UpdateFeedFavicon(ctx, feedURL, faviconURL); err != nil { | |
| 54 | + return err | |
| 55 | + } | |
| 56 | + } | |
| 57 | + return nil | |
| 73 | 58 | } |
| @@ -2,7 +2,6 @@ package db | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | - "database/sql" | ||
| 6 | "time" | 5 | "time" |
| 7 | 6 | ||
| 8 | "pkg.rbrt.fr/glean/internal/feed" | 7 | "pkg.rbrt.fr/glean/internal/feed" |
| @@ -37,37 +36,23 @@ func (a *FeedStoreAdapter) GetFeedsToFetch(ctx context.Context, olderThan time.D | |||
| 37 | return feeds, nil | 36 | return feeds, nil |
| 38 | } | 37 | } |
| 39 | 38 | ||
| 40 | -func (a *FeedStoreAdapter) UpsertArticle(ctx context.Context, article *feed.Article) (int64, error) { | 39 | +func (a *FeedStoreAdapter) RecordFetchError(ctx context.Context, feedURL, lastError string) error { |
| 41 | - dbArticle := &Article{ | ||
| 42 | - FeedURL: article.FeedURL, | ||
| 43 | - GUID: article.GUID, | ||
| 44 | - Title: article.Title, | ||
| 45 | - Summary: sql.NullString{String: article.Summary, Valid: article.Summary != ""}, | ||
| 46 | - Content: sql.NullString{String: article.Content, Valid: article.Content != ""}, | ||
| 47 | - Author: sql.NullString{String: article.Author, Valid: article.Author != ""}, | ||
| 48 | - URL: sql.NullString{String: article.URL, Valid: article.URL != ""}, | ||
| 49 | - } | ||
| 50 | - if !article.Published.IsZero() { | ||
| 51 | - dbArticle.Published = sql.NullTime{Time: article.Published, Valid: true} | ||
| 52 | - } | ||
| 53 | - if !article.Updated.IsZero() { | ||
| 54 | - dbArticle.Updated = sql.NullTime{Time: article.Updated, Valid: true} | ||
| 55 | - } | ||
| 56 | - return a.db.UpsertArticle(ctx, dbArticle) | ||
| 57 | -} | ||
| 58 | - | ||
| 59 | -func (a *FeedStoreAdapter) UpsertArticlesBatch(ctx context.Context, articles []feed.Article) error { | ||
| 60 | - return a.db.UpsertArticlesBatch(ctx, articles) | ||
| 61 | -} | ||
| 62 | - | ||
| 63 | -func (a *FeedStoreAdapter) MarkFeedFetched(ctx context.Context, feedURL, etag, lastModified string) error { | ||
| 64 | - return a.db.MarkFeedFetched(ctx, feedURL, etag, lastModified) | ||
| 65 | -} | ||
| 66 | - | ||
| 67 | -func (a *FeedStoreAdapter) MarkFeedFetchError(ctx context.Context, feedURL, lastError string) error { | ||
| 68 | return a.db.MarkFeedFetchError(ctx, feedURL, lastError) | 40 | return a.db.MarkFeedFetchError(ctx, feedURL, lastError) |
| 69 | } | 41 | } |
| 70 | 42 | ||
| 71 | -func (a *FeedStoreAdapter) UpdateFeedFavicon(ctx context.Context, feedURL, faviconURL string) error { | 43 | +func (a *FeedStoreAdapter) StoreFetchResult(ctx context.Context, feedURL, etag, lastModified string, articles []feed.Article, faviconURL string) error { |
| 72 | - return a.db.UpdateFeedFavicon(ctx, feedURL, faviconURL) | 44 | + if err := a.db.MarkFeedFetched(ctx, feedURL, etag, lastModified); err != nil { |
| 45 | + return err | ||
| 46 | + } | ||
| 47 | + if len(articles) > 0 { | ||
| 48 | + if err := a.db.UpsertArticlesBatch(ctx, articles); err != nil { | ||
| 49 | + return err | ||
| 50 | + } | ||
| 51 | + } | ||
| 52 | + if faviconURL != "" { | ||
| 53 | + if err := a.db.UpdateFeedFavicon(ctx, feedURL, faviconURL); err != nil { | ||
| 54 | + return err | ||
| 55 | + } | ||
| 56 | + } | ||
| 57 | + return nil | ||
| 73 | } | 58 | } |
modified
internal/feed/discover.go +3 -6 | @@ -63,10 +63,7 @@ func cleanFavicon(s string) string { | ||
| 63 | 63 | return strings.TrimRight(s, "/") |
| 64 | 64 | } |
| 65 | 65 | |
| 66 | -func ResolveFavicon(ctx context.Context, feedURL, siteURL, parsedFavicon string) string { | |
| 67 | - if parsedFavicon != "" { | |
| 68 | - return cleanFavicon(parsedFavicon) | |
| 69 | - } | |
| 66 | +func ResolveFavicon(ctx context.Context, feedURL, siteURL string) string { | |
| 70 | 67 | target := siteURL |
| 71 | 68 | if target == "" { |
| 72 | 69 | target = feedURL |
| @@ -117,7 +114,7 @@ func findFavicon(ctx context.Context, base *url.URL, links []string) string { | ||
| 117 | 114 | origin.Fragment = "" |
| 118 | 115 | |
| 119 | 116 | type result struct { |
| 120 | - url string | |
| 117 | + url string | |
| 121 | 118 | found bool |
| 122 | 119 | } |
| 123 | 120 | found := make(chan result, 1) |
| @@ -201,4 +198,4 @@ func fetchHTML(ctx context.Context, siteURL string) (*url.URL, string) { | ||
| 201 | 198 | |
| 202 | 199 | base := resp.Request.URL |
| 203 | 200 | return base, string(body) |
| 204 | -} | |
| \ No newline at end of file | ||
| 201 | +} | |
| @@ -63,10 +63,7 @@ func cleanFavicon(s string) string { | |||
| 63 | return strings.TrimRight(s, "/") | 63 | return strings.TrimRight(s, "/") |
| 64 | } | 64 | } |
| 65 | 65 | ||
| 66 | -func ResolveFavicon(ctx context.Context, feedURL, siteURL, parsedFavicon string) string { | 66 | +func ResolveFavicon(ctx context.Context, feedURL, siteURL string) string { |
| 67 | - if parsedFavicon != "" { | ||
| 68 | - return cleanFavicon(parsedFavicon) | ||
| 69 | - } | ||
| 70 | target := siteURL | 67 | target := siteURL |
| 71 | if target == "" { | 68 | if target == "" { |
| 72 | target = feedURL | 69 | target = feedURL |
| @@ -117,7 +114,7 @@ func findFavicon(ctx context.Context, base *url.URL, links []string) string { | |||
| 117 | origin.Fragment = "" | 114 | origin.Fragment = "" |
| 118 | 115 | ||
| 119 | type result struct { | 116 | type result struct { |
| 120 | - url string | 117 | + url string |
| 121 | found bool | 118 | found bool |
| 122 | } | 119 | } |
| 123 | found := make(chan result, 1) | 120 | found := make(chan result, 1) |
| @@ -201,4 +198,4 @@ func fetchHTML(ctx context.Context, siteURL string) (*url.URL, string) { | |||
| 201 | 198 | ||
| 202 | base := resp.Request.URL | 199 | base := resp.Request.URL |
| 203 | return base, string(body) | 200 | return base, string(body) |
| 204 | -} | ||
| \ No newline at end of file | \ No newline at end of file | ||
| 201 | +} | ||
modified
internal/feed/fetcher.go +34 -48 | @@ -69,11 +69,8 @@ func (f *Fetcher) Fetch(ctx context.Context, feedURL, etag, lastModified string) | ||
| 69 | 69 | |
| 70 | 70 | type FeedStore interface { |
| 71 | 71 | GetFeedsToFetch(ctx context.Context, olderThan time.Duration, limit int) ([]*Feed, error) |
| 72 | - UpsertArticle(ctx context.Context, article *Article) (int64, error) | |
| 73 | - UpsertArticlesBatch(ctx context.Context, articles []Article) error | |
| 74 | - MarkFeedFetched(ctx context.Context, feedURL, etag, lastModified string) error | |
| 75 | - MarkFeedFetchError(ctx context.Context, feedURL, lastError string) error | |
| 76 | - UpdateFeedFavicon(ctx context.Context, feedURL, faviconURL string) error | |
| 72 | + StoreFetchResult(ctx context.Context, feedURL, etag, lastModified string, articles []Article, faviconURL string) error | |
| 73 | + RecordFetchError(ctx context.Context, feedURL, lastError string) error | |
| 77 | 74 | } |
| 78 | 75 | |
| 79 | 76 | type fetchCall struct { |
| @@ -81,29 +78,29 @@ type fetchCall struct { | ||
| 81 | 78 | } |
| 82 | 79 | |
| 83 | 80 | type Scheduler struct { |
| 84 | - fetcher *Fetcher | |
| 85 | - store FeedStore | |
| 86 | - logger *slog.Logger | |
| 87 | - interval time.Duration | |
| 88 | - inFlight sync.Map | |
| 81 | + fetcher *Fetcher | |
| 82 | + store FeedStore | |
| 83 | + logger *slog.Logger | |
| 84 | + tickInterval time.Duration | |
| 85 | + staleInterval time.Duration | |
| 86 | + inFlight sync.Map | |
| 89 | 87 | } |
| 90 | 88 | |
| 91 | -func NewScheduler(store FeedStore, logger *slog.Logger) *Scheduler { | |
| 89 | +func NewScheduler(store FeedStore, logger *slog.Logger, tickInterval, staleInterval time.Duration) *Scheduler { | |
| 92 | 90 | return &Scheduler{ |
| 93 | - fetcher: NewFetcher(), | |
| 94 | - store: store, | |
| 95 | - logger: logger, | |
| 96 | - interval: 30 * time.Minute, | |
| 97 | - inFlight: sync.Map{}, | |
| 91 | + fetcher: NewFetcher(), | |
| 92 | + store: store, | |
| 93 | + logger: logger, | |
| 94 | + tickInterval: tickInterval, | |
| 95 | + staleInterval: staleInterval, | |
| 96 | + inFlight: sync.Map{}, | |
| 98 | 97 | } |
| 99 | 98 | } |
| 100 | 99 | |
| 101 | 100 | func (s *Scheduler) Run(ctx context.Context) error { |
| 102 | - ticker := time.NewTicker(5 * time.Minute) | |
| 101 | + ticker := time.NewTicker(s.tickInterval) | |
| 103 | 102 | defer ticker.Stop() |
| 104 | 103 | |
| 105 | - s.fetchAll(ctx) | |
| 106 | - | |
| 107 | 104 | for { |
| 108 | 105 | select { |
| 109 | 106 | case <-ctx.Done(): |
| @@ -115,7 +112,7 @@ func (s *Scheduler) Run(ctx context.Context) error { | ||
| 115 | 112 | } |
| 116 | 113 | |
| 117 | 114 | func (s *Scheduler) fetchAll(ctx context.Context) { |
| 118 | - feeds, err := s.store.GetFeedsToFetch(ctx, s.interval, 200) | |
| 115 | + feeds, err := s.store.GetFeedsToFetch(ctx, s.staleInterval, 500) | |
| 119 | 116 | if err != nil { |
| 120 | 117 | s.logger.Error("failed to get feeds", "error", err) |
| 121 | 118 | return |
| @@ -151,45 +148,34 @@ func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) { | ||
| 151 | 148 | start := time.Now() |
| 152 | 149 | result, newEtag, newLastModified, err := s.fetcher.Fetch(ctx, feed.URL, feed.ETag, feed.LastModified) |
| 153 | 150 | metrics.FeedsFetchedDuration.Observe(time.Since(start).Seconds()) |
| 151 | + metrics.FeedsFetched.Inc() | |
| 152 | + metrics.FeedsFetchedLast.Set(float64(time.Now().Unix())) | |
| 154 | 153 | if err != nil { |
| 155 | - metrics.FeedsFetched.WithLabelValues("error").Inc() | |
| 156 | 154 | s.logger.Error("failed to fetch feed", "error", err, "feed", feed.URL) |
| 157 | - if updErr := s.store.MarkFeedFetchError(ctx, feed.URL, err.Error()); updErr != nil { | |
| 158 | - s.logger.Error("failed to update feed fetch error", "error", updErr, "feed", feed.URL) | |
| 159 | - } | |
| 155 | + s.store.RecordFetchError(ctx, feed.URL, err.Error()) | |
| 160 | 156 | return |
| 161 | 157 | } |
| 162 | 158 | |
| 163 | 159 | if result == nil { |
| 164 | - metrics.FeedsFetched.WithLabelValues("not_modified").Inc() | |
| 165 | - if updErr := s.store.MarkFeedFetched(ctx, feed.URL, feed.ETag, feed.LastModified); updErr != nil { | |
| 166 | - s.logger.Error("failed to update feed fetch result", "error", updErr, "feed", feed.URL) | |
| 160 | + s.logger.Info("fetched articles", "feed", feed.URL, "count", 0) | |
| 161 | + if err := s.store.StoreFetchResult(ctx, feed.URL, newEtag, newLastModified, nil, ""); err != nil { | |
| 162 | + s.logger.Error("failed to store feed fetch result", "error", err, "feed", feed.URL) | |
| 167 | 163 | } |
| 168 | 164 | return |
| 169 | 165 | } |
| 170 | 166 | |
| 171 | - metrics.FeedsFetched.WithLabelValues("success").Inc() | |
| 172 | - | |
| 173 | - for _, article := range result.Articles { | |
| 174 | - article.FeedURL = feed.URL | |
| 175 | - } | |
| 176 | - if err := s.store.UpsertArticlesBatch(ctx, result.Articles); err != nil { | |
| 177 | - s.logger.Error("failed to upsert articles", "error", err, "feed", feed.URL) | |
| 178 | - } else { | |
| 179 | - metrics.ArticlesUpserted.Add(float64(len(result.Articles))) | |
| 167 | + faviconURL := result.Feed.FaviconURL | |
| 168 | + if faviconURL == "" && feed.FaviconURL == "" { | |
| 169 | + faviconURL = ResolveFavicon(context.Background(), feed.URL, feed.SiteURL) | |
| 180 | 170 | } |
| 181 | 171 | |
| 182 | - if err := s.store.MarkFeedFetched(ctx, feed.URL, newEtag, newLastModified); err != nil { | |
| 183 | - s.logger.Error("failed to update feed fetch result", "error", err, "feed", feed.URL) | |
| 184 | - } | |
| 185 | - | |
| 186 | - if result != nil && result.Feed.FaviconURL != "" { | |
| 187 | - _ = s.store.UpdateFeedFavicon(ctx, feed.URL, result.Feed.FaviconURL) | |
| 188 | - } else if feed.FaviconURL == "" { | |
| 189 | - go func() { | |
| 190 | - if f := ResolveFavicon(context.Background(), feed.URL, feed.SiteURL, ""); f != "" { | |
| 191 | - _ = s.store.UpdateFeedFavicon(context.Background(), feed.URL, f) | |
| 192 | - } | |
| 193 | - }() | |
| 172 | + if err := s.store.StoreFetchResult(ctx, feed.URL, newEtag, newLastModified, result.Articles, faviconURL); err != nil { | |
| 173 | + s.logger.Error("failed to store feed fetch result", "error", err, "feed", feed.URL) | |
| 174 | + } else { | |
| 175 | + articleCount := len(result.Articles) | |
| 176 | + s.logger.Info("fetched articles", "feed", feed.URL, "count", articleCount) | |
| 177 | + if articleCount > 0 { | |
| 178 | + metrics.ArticlesUpserted.Add(float64(articleCount)) | |
| 179 | + } | |
| 194 | 180 | } |
| 195 | 181 | } |
| @@ -69,11 +69,8 @@ func (f *Fetcher) Fetch(ctx context.Context, feedURL, etag, lastModified string) | |||
| 69 | 69 | ||
| 70 | type FeedStore interface { | 70 | type FeedStore interface { |
| 71 | GetFeedsToFetch(ctx context.Context, olderThan time.Duration, limit int) ([]*Feed, error) | 71 | GetFeedsToFetch(ctx context.Context, olderThan time.Duration, limit int) ([]*Feed, error) |
| 72 | - UpsertArticle(ctx context.Context, article *Article) (int64, error) | 72 | + StoreFetchResult(ctx context.Context, feedURL, etag, lastModified string, articles []Article, faviconURL string) error |
| 73 | - UpsertArticlesBatch(ctx context.Context, articles []Article) error | 73 | + RecordFetchError(ctx context.Context, feedURL, lastError string) error |
| 74 | - MarkFeedFetched(ctx context.Context, feedURL, etag, lastModified string) error | ||
| 75 | - MarkFeedFetchError(ctx context.Context, feedURL, lastError string) error | ||
| 76 | - UpdateFeedFavicon(ctx context.Context, feedURL, faviconURL string) error | ||
| 77 | } | 74 | } |
| 78 | 75 | ||
| 79 | type fetchCall struct { | 76 | type fetchCall struct { |
| @@ -81,29 +78,29 @@ type fetchCall struct { | |||
| 81 | } | 78 | } |
| 82 | 79 | ||
| 83 | type Scheduler struct { | 80 | type Scheduler struct { |
| 84 | - fetcher *Fetcher | 81 | + fetcher *Fetcher |
| 85 | - store FeedStore | 82 | + store FeedStore |
| 86 | - logger *slog.Logger | 83 | + logger *slog.Logger |
| 87 | - interval time.Duration | 84 | + tickInterval time.Duration |
| 88 | - inFlight sync.Map | 85 | + staleInterval time.Duration |
| 86 | + inFlight sync.Map | ||
| 89 | } | 87 | } |
| 90 | 88 | ||
| 91 | -func NewScheduler(store FeedStore, logger *slog.Logger) *Scheduler { | 89 | +func NewScheduler(store FeedStore, logger *slog.Logger, tickInterval, staleInterval time.Duration) *Scheduler { |
| 92 | return &Scheduler{ | 90 | return &Scheduler{ |
| 93 | - fetcher: NewFetcher(), | 91 | + fetcher: NewFetcher(), |
| 94 | - store: store, | 92 | + store: store, |
| 95 | - logger: logger, | 93 | + logger: logger, |
| 96 | - interval: 30 * time.Minute, | 94 | + tickInterval: tickInterval, |
| 97 | - inFlight: sync.Map{}, | 95 | + staleInterval: staleInterval, |
| 96 | + inFlight: sync.Map{}, | ||
| 98 | } | 97 | } |
| 99 | } | 98 | } |
| 100 | 99 | ||
| 101 | func (s *Scheduler) Run(ctx context.Context) error { | 100 | func (s *Scheduler) Run(ctx context.Context) error { |
| 102 | - ticker := time.NewTicker(5 * time.Minute) | 101 | + ticker := time.NewTicker(s.tickInterval) |
| 103 | defer ticker.Stop() | 102 | defer ticker.Stop() |
| 104 | 103 | ||
| 105 | - s.fetchAll(ctx) | ||
| 106 | - | ||
| 107 | for { | 104 | for { |
| 108 | select { | 105 | select { |
| 109 | case <-ctx.Done(): | 106 | case <-ctx.Done(): |
| @@ -115,7 +112,7 @@ func (s *Scheduler) Run(ctx context.Context) error { | |||
| 115 | } | 112 | } |
| 116 | 113 | ||
| 117 | func (s *Scheduler) fetchAll(ctx context.Context) { | 114 | func (s *Scheduler) fetchAll(ctx context.Context) { |
| 118 | - feeds, err := s.store.GetFeedsToFetch(ctx, s.interval, 200) | 115 | + feeds, err := s.store.GetFeedsToFetch(ctx, s.staleInterval, 500) |
| 119 | if err != nil { | 116 | if err != nil { |
| 120 | s.logger.Error("failed to get feeds", "error", err) | 117 | s.logger.Error("failed to get feeds", "error", err) |
| 121 | return | 118 | return |
| @@ -151,45 +148,34 @@ func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) { | |||
| 151 | start := time.Now() | 148 | start := time.Now() |
| 152 | result, newEtag, newLastModified, err := s.fetcher.Fetch(ctx, feed.URL, feed.ETag, feed.LastModified) | 149 | result, newEtag, newLastModified, err := s.fetcher.Fetch(ctx, feed.URL, feed.ETag, feed.LastModified) |
| 153 | metrics.FeedsFetchedDuration.Observe(time.Since(start).Seconds()) | 150 | metrics.FeedsFetchedDuration.Observe(time.Since(start).Seconds()) |
| 151 | + metrics.FeedsFetched.Inc() | ||
| 152 | + metrics.FeedsFetchedLast.Set(float64(time.Now().Unix())) | ||
| 154 | if err != nil { | 153 | if err != nil { |
| 155 | - metrics.FeedsFetched.WithLabelValues("error").Inc() | ||
| 156 | s.logger.Error("failed to fetch feed", "error", err, "feed", feed.URL) | 154 | s.logger.Error("failed to fetch feed", "error", err, "feed", feed.URL) |
| 157 | - if updErr := s.store.MarkFeedFetchError(ctx, feed.URL, err.Error()); updErr != nil { | 155 | + s.store.RecordFetchError(ctx, feed.URL, err.Error()) |
| 158 | - s.logger.Error("failed to update feed fetch error", "error", updErr, "feed", feed.URL) | ||
| 159 | - } | ||
| 160 | return | 156 | return |
| 161 | } | 157 | } |
| 162 | 158 | ||
| 163 | if result == nil { | 159 | if result == nil { |
| 164 | - metrics.FeedsFetched.WithLabelValues("not_modified").Inc() | 160 | + s.logger.Info("fetched articles", "feed", feed.URL, "count", 0) |
| 165 | - if updErr := s.store.MarkFeedFetched(ctx, feed.URL, feed.ETag, feed.LastModified); updErr != nil { | 161 | + if err := s.store.StoreFetchResult(ctx, feed.URL, newEtag, newLastModified, nil, ""); err != nil { |
| 166 | - s.logger.Error("failed to update feed fetch result", "error", updErr, "feed", feed.URL) | 162 | + s.logger.Error("failed to store feed fetch result", "error", err, "feed", feed.URL) |
| 167 | } | 163 | } |
| 168 | return | 164 | return |
| 169 | } | 165 | } |
| 170 | 166 | ||
| 171 | - metrics.FeedsFetched.WithLabelValues("success").Inc() | 167 | + faviconURL := result.Feed.FaviconURL |
| 172 | - | 168 | + if faviconURL == "" && feed.FaviconURL == "" { |
| 173 | - for _, article := range result.Articles { | 169 | + faviconURL = ResolveFavicon(context.Background(), feed.URL, feed.SiteURL) |
| 174 | - article.FeedURL = feed.URL | ||
| 175 | - } | ||
| 176 | - if err := s.store.UpsertArticlesBatch(ctx, result.Articles); err != nil { | ||
| 177 | - s.logger.Error("failed to upsert articles", "error", err, "feed", feed.URL) | ||
| 178 | - } else { | ||
| 179 | - metrics.ArticlesUpserted.Add(float64(len(result.Articles))) | ||
| 180 | } | 170 | } |
| 181 | 171 | ||
| 182 | - if err := s.store.MarkFeedFetched(ctx, feed.URL, newEtag, newLastModified); err != nil { | 172 | + if err := s.store.StoreFetchResult(ctx, feed.URL, newEtag, newLastModified, result.Articles, faviconURL); err != nil { |
| 183 | - s.logger.Error("failed to update feed fetch result", "error", err, "feed", feed.URL) | 173 | + s.logger.Error("failed to store feed fetch result", "error", err, "feed", feed.URL) |
| 184 | - } | 174 | + } else { |
| 185 | - | 175 | + articleCount := len(result.Articles) |
| 186 | - if result != nil && result.Feed.FaviconURL != "" { | 176 | + s.logger.Info("fetched articles", "feed", feed.URL, "count", articleCount) |
| 187 | - _ = s.store.UpdateFeedFavicon(ctx, feed.URL, result.Feed.FaviconURL) | 177 | + if articleCount > 0 { |
| 188 | - } else if feed.FaviconURL == "" { | 178 | + metrics.ArticlesUpserted.Add(float64(articleCount)) |
| 189 | - go func() { | 179 | + } |
| 190 | - if f := ResolveFavicon(context.Background(), feed.URL, feed.SiteURL, ""); f != "" { | ||
| 191 | - _ = s.store.UpdateFeedFavicon(context.Background(), feed.URL, f) | ||
| 192 | - } | ||
| 193 | - }() | ||
| 194 | } | 180 | } |
| 195 | } | 181 | } |
modified
internal/feed/opml.go +2 -10 | @@ -4,6 +4,7 @@ import ( | ||
| 4 | 4 | "bytes" |
| 5 | 5 | "encoding/xml" |
| 6 | 6 | "io" |
| 7 | + "slices" | |
| 7 | 8 | "strings" |
| 8 | 9 | ) |
| 9 | 10 | |
| @@ -115,7 +116,7 @@ func GenerateOPML(feeds []FeedURL, title string) ([]byte, error) { | ||
| 115 | 116 | } |
| 116 | 117 | if f.Category != "" { |
| 117 | 118 | categoryMap[f.Category] = append(categoryMap[f.Category], outline) |
| 118 | - if !contains(categories, f.Category) { | |
| 119 | + if !slices.Contains(categories, f.Category) { | |
| 119 | 120 | categories = append(categories, f.Category) |
| 120 | 121 | } |
| 121 | 122 | } else { |
| @@ -143,12 +144,3 @@ func GenerateOPML(feeds []FeedURL, title string) ([]byte, error) { | ||
| 143 | 144 | |
| 144 | 145 | return buf.Bytes(), nil |
| 145 | 146 | } |
| 146 | - | |
| 147 | -func contains(slice []string, s string) bool { | |
| 148 | - for _, v := range slice { | |
| 149 | - if v == s { | |
| 150 | - return true | |
| 151 | - } | |
| 152 | - } | |
| 153 | - return false | |
| 154 | -} | |
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | "bytes" | 4 | "bytes" |
| 5 | "encoding/xml" | 5 | "encoding/xml" |
| 6 | "io" | 6 | "io" |
| 7 | + "slices" | ||
| 7 | "strings" | 8 | "strings" |
| 8 | ) | 9 | ) |
| 9 | 10 | ||
| @@ -115,7 +116,7 @@ func GenerateOPML(feeds []FeedURL, title string) ([]byte, error) { | |||
| 115 | } | 116 | } |
| 116 | if f.Category != "" { | 117 | if f.Category != "" { |
| 117 | categoryMap[f.Category] = append(categoryMap[f.Category], outline) | 118 | categoryMap[f.Category] = append(categoryMap[f.Category], outline) |
| 118 | - if !contains(categories, f.Category) { | 119 | + if !slices.Contains(categories, f.Category) { |
| 119 | categories = append(categories, f.Category) | 120 | categories = append(categories, f.Category) |
| 120 | } | 121 | } |
| 121 | } else { | 122 | } else { |
| @@ -143,12 +144,3 @@ func GenerateOPML(feeds []FeedURL, title string) ([]byte, error) { | |||
| 143 | 144 | ||
| 144 | return buf.Bytes(), nil | 145 | return buf.Bytes(), nil |
| 145 | } | 146 | } |
| 146 | - | ||
| 147 | -func contains(slice []string, s string) bool { | ||
| 148 | - for _, v := range slice { | ||
| 149 | - if v == s { | ||
| 150 | - return true | ||
| 151 | - } | ||
| 152 | - } | ||
| 153 | - return false | ||
| 154 | -} | ||
modified
internal/metrics/metrics.go +7 -2 | @@ -6,10 +6,15 @@ import ( | ||
| 6 | 6 | ) |
| 7 | 7 | |
| 8 | 8 | var ( |
| 9 | - FeedsFetched = promauto.NewCounterVec(prometheus.CounterOpts{ | |
| 9 | + FeedsFetched = promauto.NewCounter(prometheus.CounterOpts{ | |
| 10 | 10 | Name: "glean_feeds_fetched_total", |
| 11 | 11 | Help: "Total number of feed fetch attempts", |
| 12 | - }, []string{"status"}) | |
| 12 | + }) | |
| 13 | + | |
| 14 | + FeedsFetchedLast = promauto.NewGauge(prometheus.GaugeOpts{ | |
| 15 | + Name: "glean_feeds_fetched_last_timestamp_seconds", | |
| 16 | + Help: "Unix timestamp of last feed fetch", | |
| 17 | + }) | |
| 13 | 18 | |
| 14 | 19 | FeedsFetchedDuration = promauto.NewHistogram(prometheus.HistogramOpts{ |
| 15 | 20 | Name: "glean_feed_fetch_duration_seconds", |
| @@ -6,10 +6,15 @@ import ( | |||
| 6 | ) | 6 | ) |
| 7 | 7 | ||
| 8 | var ( | 8 | var ( |
| 9 | - FeedsFetched = promauto.NewCounterVec(prometheus.CounterOpts{ | 9 | + FeedsFetched = promauto.NewCounter(prometheus.CounterOpts{ |
| 10 | Name: "glean_feeds_fetched_total", | 10 | Name: "glean_feeds_fetched_total", |
| 11 | Help: "Total number of feed fetch attempts", | 11 | Help: "Total number of feed fetch attempts", |
| 12 | - }, []string{"status"}) | 12 | + }) |
| 13 | + | ||
| 14 | + FeedsFetchedLast = promauto.NewGauge(prometheus.GaugeOpts{ | ||
| 15 | + Name: "glean_feeds_fetched_last_timestamp_seconds", | ||
| 16 | + Help: "Unix timestamp of last feed fetch", | ||
| 17 | + }) | ||
| 13 | 18 | ||
| 14 | FeedsFetchedDuration = promauto.NewHistogram(prometheus.HistogramOpts{ | 19 | FeedsFetchedDuration = promauto.NewHistogram(prometheus.HistogramOpts{ |
| 15 | Name: "glean_feed_fetch_duration_seconds", | 20 | Name: "glean_feed_fetch_duration_seconds", |
modified
internal/server/feeds_handler.go +24 -22 | @@ -72,30 +72,32 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | ||
| 72 | 72 | return |
| 73 | 73 | } |
| 74 | 74 | |
| 75 | + var feedTitle string | |
| 76 | + var faviconURL string | |
| 75 | 77 | if result != nil { |
| 76 | - f := &db.Feed{ | |
| 77 | - FeedURL: feedURL, | |
| 78 | - Title: nullString(result.Feed.Title), | |
| 79 | - SiteURL: nullString(result.Feed.SiteURL), | |
| 80 | - Description: nullString(result.Feed.Description), | |
| 81 | - FeedType: nullString(result.Feed.Type), | |
| 82 | - } | |
| 83 | - if err := s.db.UpsertFeed(r.Context(), f); err != nil { | |
| 84 | - s.logger.Error("failed to upsert feed", "error", err) | |
| 85 | - http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 86 | - return | |
| 78 | + feedTitle = result.Feed.Title | |
| 79 | + faviconURL = result.Feed.FaviconURL | |
| 80 | + if faviconURL == "" { | |
| 81 | + go func() { | |
| 82 | + if f := feed.ResolveFavicon(context.Background(), feedURL, result.Feed.SiteURL); f != "" { | |
| 83 | + _ = s.db.UpdateFeedFavicon(context.Background(), feedURL, f) | |
| 84 | + } | |
| 85 | + }() | |
| 87 | 86 | } |
| 88 | - | |
| 89 | - go func() { | |
| 90 | - if f := feed.ResolveFavicon(context.Background(), feedURL, result.Feed.SiteURL, result.Feed.FaviconURL); f != "" { | |
| 91 | - _ = s.db.UpdateFeedFavicon(context.Background(), feedURL, f) | |
| 92 | - } | |
| 93 | - }() | |
| 94 | 87 | } |
| 95 | 88 | |
| 96 | - var feedTitle string | |
| 97 | - if result != nil { | |
| 98 | - feedTitle = result.Feed.Title | |
| 89 | + f := &db.Feed{ | |
| 90 | + FeedURL: feedURL, | |
| 91 | + Title: nullString(feedTitle), | |
| 92 | + SiteURL: nullString(result.Feed.SiteURL), | |
| 93 | + Description: nullString(result.Feed.Description), | |
| 94 | + FeedType: nullString(result.Feed.Type), | |
| 95 | + FaviconURL: nullString(faviconURL), | |
| 96 | + } | |
| 97 | + if err := s.db.UpsertFeed(r.Context(), f); err != nil { | |
| 98 | + s.logger.Error("failed to upsert feed", "error", err) | |
| 99 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 100 | + return | |
| 99 | 101 | } |
| 100 | 102 | |
| 101 | 103 | var subURI, subCID string |
| @@ -235,8 +237,8 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) { | ||
| 235 | 237 | } |
| 236 | 238 | |
| 237 | 239 | go func(feedURL, siteURL string) { |
| 238 | - if f := feed.ResolveFavicon(context.Background(), feedURL, siteURL, ""); f != "" { | |
| 239 | - _ = s.db.UpdateFeedFavicon(context.Background(), feedURL, f) | |
| 240 | + if fav := feed.ResolveFavicon(context.Background(), feedURL, siteURL); fav != "" { | |
| 241 | + _ = s.db.UpdateFeedFavicon(context.Background(), feedURL, fav) | |
| 240 | 242 | } |
| 241 | 243 | }(fu.URL, fu.SiteURL) |
| 242 | 244 | |
| @@ -72,30 +72,32 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | |||
| 72 | return | 72 | return |
| 73 | } | 73 | } |
| 74 | 74 | ||
| 75 | + var feedTitle string | ||
| 76 | + var faviconURL string | ||
| 75 | if result != nil { | 77 | if result != nil { |
| 76 | - f := &db.Feed{ | 78 | + feedTitle = result.Feed.Title |
| 77 | - FeedURL: feedURL, | 79 | + faviconURL = result.Feed.FaviconURL |
| 78 | - Title: nullString(result.Feed.Title), | 80 | + if faviconURL == "" { |
| 79 | - SiteURL: nullString(result.Feed.SiteURL), | 81 | + go func() { |
| 80 | - Description: nullString(result.Feed.Description), | 82 | + if f := feed.ResolveFavicon(context.Background(), feedURL, result.Feed.SiteURL); f != "" { |
| 81 | - FeedType: nullString(result.Feed.Type), | 83 | + _ = s.db.UpdateFeedFavicon(context.Background(), feedURL, f) |
| 82 | - } | 84 | + } |
| 83 | - if err := s.db.UpsertFeed(r.Context(), f); err != nil { | 85 | + }() |
| 84 | - s.logger.Error("failed to upsert feed", "error", err) | ||
| 85 | - http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 86 | - return | ||
| 87 | } | 86 | } |
| 88 | - | ||
| 89 | - go func() { | ||
| 90 | - if f := feed.ResolveFavicon(context.Background(), feedURL, result.Feed.SiteURL, result.Feed.FaviconURL); f != "" { | ||
| 91 | - _ = s.db.UpdateFeedFavicon(context.Background(), feedURL, f) | ||
| 92 | - } | ||
| 93 | - }() | ||
| 94 | } | 87 | } |
| 95 | 88 | ||
| 96 | - var feedTitle string | 89 | + f := &db.Feed{ |
| 97 | - if result != nil { | 90 | + FeedURL: feedURL, |
| 98 | - feedTitle = result.Feed.Title | 91 | + Title: nullString(feedTitle), |
| 92 | + SiteURL: nullString(result.Feed.SiteURL), | ||
| 93 | + Description: nullString(result.Feed.Description), | ||
| 94 | + FeedType: nullString(result.Feed.Type), | ||
| 95 | + FaviconURL: nullString(faviconURL), | ||
| 96 | + } | ||
| 97 | + if err := s.db.UpsertFeed(r.Context(), f); err != nil { | ||
| 98 | + s.logger.Error("failed to upsert feed", "error", err) | ||
| 99 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 100 | + return | ||
| 99 | } | 101 | } |
| 100 | 102 | ||
| 101 | var subURI, subCID string | 103 | var subURI, subCID string |
| @@ -235,8 +237,8 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) { | |||
| 235 | } | 237 | } |
| 236 | 238 | ||
| 237 | go func(feedURL, siteURL string) { | 239 | go func(feedURL, siteURL string) { |
| 238 | - if f := feed.ResolveFavicon(context.Background(), feedURL, siteURL, ""); f != "" { | 240 | + if fav := feed.ResolveFavicon(context.Background(), feedURL, siteURL); fav != "" { |
| 239 | - _ = s.db.UpdateFeedFavicon(context.Background(), feedURL, f) | 241 | + _ = s.db.UpdateFeedFavicon(context.Background(), feedURL, fav) |
| 240 | } | 242 | } |
| 241 | }(fu.URL, fu.SiteURL) | 243 | }(fu.URL, fu.SiteURL) |
| 242 | 244 | ||
modified
main.go +2 -1 | @@ -25,6 +25,7 @@ func main() { | ||
| 25 | 25 | jetstreamURL := flag.String("jetstream", envOr("GLEAN_JETSTREAM", "wss://jetstream.glean.at"), "Jetstream URL") |
| 26 | 26 | syncInterval := flag.Duration("sync-interval", envDuration("GLEAN_SYNC_INTERVAL", 1*time.Hour), "PDS sync interval") |
| 27 | 27 | clusterInterval := flag.Duration("cluster-interval", envDuration("GLEAN_CLUSTER_INTERVAL", 10*time.Minute), "cluster recomputation interval") |
| 28 | + fetchInterval := flag.Duration("fetch-interval", envDuration("GLEAN_FETCH_INTERVAL", 5*time.Minute), "feed fetch tick interval") | |
| 28 | 29 | collectionDirURL := flag.String("collection-dir", envOr("GLEAN_COLLECTION_DIR_URL", ""), "collection directory URL for startup backfill") |
| 29 | 30 | backfillConcurrency := flag.Int("backfill-concurrency", envInt("GLEAN_BACKFILL_CONCURRENCY", 5), "max concurrent backfill workers") |
| 30 | 31 | flag.Parse() |
| @@ -44,7 +45,7 @@ func main() { | ||
| 44 | 45 | callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "") |
| 45 | 46 | |
| 46 | 47 | storeAdapter := db.NewFeedStoreAdapter(database) |
| 47 | - scheduler := feed.NewScheduler(storeAdapter, logger) | |
| 48 | + scheduler := feed.NewScheduler(storeAdapter, logger, *fetchInterval, 30*time.Minute) | |
| 48 | 49 | |
| 49 | 50 | engine := cluster.NewEngine(database.DB, logger) |
| 50 | 51 | |
| @@ -25,6 +25,7 @@ func main() { | |||
| 25 | jetstreamURL := flag.String("jetstream", envOr("GLEAN_JETSTREAM", "wss://jetstream.glean.at"), "Jetstream URL") | 25 | jetstreamURL := flag.String("jetstream", envOr("GLEAN_JETSTREAM", "wss://jetstream.glean.at"), "Jetstream URL") |
| 26 | syncInterval := flag.Duration("sync-interval", envDuration("GLEAN_SYNC_INTERVAL", 1*time.Hour), "PDS sync interval") | 26 | syncInterval := flag.Duration("sync-interval", envDuration("GLEAN_SYNC_INTERVAL", 1*time.Hour), "PDS sync interval") |
| 27 | clusterInterval := flag.Duration("cluster-interval", envDuration("GLEAN_CLUSTER_INTERVAL", 10*time.Minute), "cluster recomputation interval") | 27 | clusterInterval := flag.Duration("cluster-interval", envDuration("GLEAN_CLUSTER_INTERVAL", 10*time.Minute), "cluster recomputation interval") |
| 28 | + fetchInterval := flag.Duration("fetch-interval", envDuration("GLEAN_FETCH_INTERVAL", 5*time.Minute), "feed fetch tick interval") | ||
| 28 | collectionDirURL := flag.String("collection-dir", envOr("GLEAN_COLLECTION_DIR_URL", ""), "collection directory URL for startup backfill") | 29 | collectionDirURL := flag.String("collection-dir", envOr("GLEAN_COLLECTION_DIR_URL", ""), "collection directory URL for startup backfill") |
| 29 | backfillConcurrency := flag.Int("backfill-concurrency", envInt("GLEAN_BACKFILL_CONCURRENCY", 5), "max concurrent backfill workers") | 30 | backfillConcurrency := flag.Int("backfill-concurrency", envInt("GLEAN_BACKFILL_CONCURRENCY", 5), "max concurrent backfill workers") |
| 30 | flag.Parse() | 31 | flag.Parse() |
| @@ -44,7 +45,7 @@ func main() { | |||
| 44 | callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "") | 45 | callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "") |
| 45 | 46 | ||
| 46 | storeAdapter := db.NewFeedStoreAdapter(database) | 47 | storeAdapter := db.NewFeedStoreAdapter(database) |
| 47 | - scheduler := feed.NewScheduler(storeAdapter, logger) | 48 | + scheduler := feed.NewScheduler(storeAdapter, logger, *fetchInterval, 30*time.Minute) |
| 48 | 49 | ||
| 49 | engine := cluster.NewEngine(database.DB, logger) | 50 | engine := cluster.NewEngine(database.DB, logger) |
| 50 | 51 | ||
modified
readme.md +1 -0 | @@ -44,6 +44,7 @@ Then open `http://localhost:8080`. | ||
| 44 | 44 | | `GLEAN_JETSTREAM` | `wss://jetstream.glean.at` | Jetstream WebSocket URL | |
| 45 | 45 | | `GLEAN_SYNC_INTERVAL` | `1h` | PDS sync interval (Go duration: `30m`, `2h30m`, etc.) | |
| 46 | 46 | | `GLEAN_CLUSTER_INTERVAL` | `10m` | Cluster recomputation interval (Go duration) | |
| 47 | +| `GLEAN_FETCH_INTERVAL` | `5m` | Feed fetch scheduler tick interval (Go duration) | | |
| 47 | 48 | | `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill | |
| 48 | 49 | | `GLEAN_PLC_URL` | `https://didplc.glean.at` | PLC directory URL for DID resolution | |
| 49 | 50 | | `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) | |
| @@ -44,6 +44,7 @@ Then open `http://localhost:8080`. | |||
| 44 | | `GLEAN_JETSTREAM` | `wss://jetstream.glean.at` | Jetstream WebSocket URL | | 44 | | `GLEAN_JETSTREAM` | `wss://jetstream.glean.at` | Jetstream WebSocket URL | |
| 45 | | `GLEAN_SYNC_INTERVAL` | `1h` | PDS sync interval (Go duration: `30m`, `2h30m`, etc.) | | 45 | | `GLEAN_SYNC_INTERVAL` | `1h` | PDS sync interval (Go duration: `30m`, `2h30m`, etc.) | |
| 46 | | `GLEAN_CLUSTER_INTERVAL` | `10m` | Cluster recomputation interval (Go duration) | | 46 | | `GLEAN_CLUSTER_INTERVAL` | `10m` | Cluster recomputation interval (Go duration) | |
| 47 | +| `GLEAN_FETCH_INTERVAL` | `5m` | Feed fetch scheduler tick interval (Go duration) | | ||
| 47 | | `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill | | 48 | | `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill | |
| 48 | | `GLEAN_PLC_URL` | `https://didplc.glean.at` | PLC directory URL for DID resolution | | 49 | | `GLEAN_PLC_URL` | `https://didplc.glean.at` | PLC directory URL for DID resolution | |
| 49 | | `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) | | 50 | | `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) | |