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:
50
src/db.rs
50
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<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();
|
||||
|
||||
Reference in New Issue
Block a user