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

@@ -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

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 {