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 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 19:40:18 +00:00
parent db87bc0f42
commit 0cc956b7c6
3 changed files with 55 additions and 0 deletions

View File

@@ -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<Vec<String>> {
let gone: Vec<String> = 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::<Result<_>>()?;
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<usize> {
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();

View File

@@ -57,6 +57,10 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
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 {