From 0cc956b7c60363eff5a5e5a008574eb825a83f3b Mon Sep 17 00:00:00 2001 From: rays Date: Tue, 29 Sep 2026 19:40:18 +0000 Subject: [PATCH] Forget a failing feed once nobody subscribes to it (#100) Unsubscribing leaves a feed's row and history, which suits one that worked. One that never did stayed with its error for good and was never scanned again: cnn-com, added as a bare 'cnn.com' (#101), sat there failing with no subscriber. The reaper, before each scan, now deletes a feed that is failing, has no subscriber, is not in the catalogue and has no file on disk, with its items, file rows, read state and block list. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/db.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++++++ src/retention.rs | 4 ++++ 3 files changed, 55 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8f61d1..3cf8ee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- A feed that was failing when its last subscriber left is forgotten, rather than kept with its error for good. One that worked, or has files on disk, is kept as before. - A feed that keeps failing is checked less and less often, waiting as long as it has been failing, up to once a day; it goes back to its schedule as soon as it works. Refreshing it still checks it at once. - A scan's trace shows the time a feed spends on its artwork and in the database after the fetch. - The JSON log carries each line's `trace_id` and `span_id`, logs each scan event once instead of diff --git a/src/db.rs b/src/db.rs index 45434e2..b934151 100644 --- a/src/db.rs +++ b/src/db.rs @@ -739,6 +739,33 @@ impl Db { /// Old entries that never had a file, or no longer have one. Enclosure rows stay -- /// they are the dedupe history. + /// Forgets feeds nobody subscribes to that were failing when the last subscriber left: not in + /// the catalogue, no file on disk. Unsubscribing leaves a feed's row and history behind, which + /// is right for one that worked; one that never did, like cnn-com added without its https://, + /// sat in the database with its error for good. Returns the ids forgotten. + pub async fn prune_abandoned_failures(&self) -> Result> { + let gone: Vec = self + .rows( + "DELETE FROM feeds + WHERE last_error IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.feed_id = feeds.id) + AND NOT EXISTS (SELECT 1 FROM catalogue c WHERE c.id = feeds.id) + AND NOT EXISTS (SELECT 1 FROM enclosures x WHERE x.feed_id = feeds.id AND x.path IS NOT NULL) + RETURNING id", + vec![], + ) + .await? + .iter() + .map(|r| Ok(r.try_get("", "id")?)) + .collect::>()?; + for id in &gone { + for table in ["enclosures", "entry_state", "hidden", "blocklists", "entries"] { + self.exec(&format!("DELETE FROM {table} WHERE feed_id = $1"), vec![id.clone().into()]).await?; + } + } + Ok(gone) + } + pub async fn prune_entries(&self, older_than: i64) -> Result { let n = self .exec( @@ -1949,6 +1976,29 @@ mod tests { assert_eq!((list["f"].unread, list["g"].unread), (1, 1)); // a read, c hidden; sam's read is sam's } + #[tokio::test] + async fn a_failing_feed_nobody_subscribes_to_is_forgotten() { + let db = Db::memory().await.unwrap(); + db.exec_for_test( + "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true); + INSERT INTO feeds (id, url, last_error) VALUES + ('gone','cnn.com','relative URL'),('wanted','u','HTTP 503'), + ('kept','v','HTTP 404'),('fine','w',null),('listed','x','HTTP 500'); + INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'wanted'); + INSERT INTO catalogue (id, spec) VALUES ('listed','{}'); + INSERT INTO entries (feed_id, guid, first_seen) VALUES ('gone','a',0),('kept','b',0); + INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES + (1,'gone','a','u1',null,'pending'),(2,'kept','b','u2','/tmp/b','done');", + ).await + .unwrap(); + // Subscribed, holding a file, working, or in the catalogue: all stay. + assert_eq!(db.prune_abandoned_failures().await.unwrap(), vec!["gone".to_string()]); + assert_eq!(db.feed_summary("gone").await.unwrap().entries, 0); + assert!(db.enclosure(1).await.unwrap().is_none()); + assert_eq!(db.feed_summary("kept").await.unwrap().entries, 1); + assert!(db.prune_abandoned_failures().await.unwrap().is_empty()); + } + #[tokio::test] async fn a_limit_takes_the_newest_episodes_not_the_back_catalogue() { let db = Db::memory().await.unwrap(); diff --git a/src/retention.rs b/src/retention.rs index 009f7cd..b1c357d 100644 --- a/src/retention.rs +++ b/src/retention.rs @@ -57,6 +57,10 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result { report.reconciled += 1; } + for id in db.prune_abandoned_failures().await? { + tracing::info!(feed = id, "forgot a failing feed nobody subscribes to"); + } + let candidates = db.reap_candidates().await?; if cfg.general.max_age_days > 0 {