Count only subscribers' read marks and pins when freeing disk space (#156)

The reaper counted read marks and pins in entry_state from anyone and compared them with the feed's
subscribers. Unsubscribing leaves your rows behind, and the flags endpoint writes them for feeds you
do not subscribe to as well. So an episode read by someone who had left and by one subscriber of
two counted as heard by everyone, and went first under the quota though the other had not played
it; and a pin of a person who had left kept a file, and its item, for good, which can leave the
quota impossible to meet.

Both queries, the reaper's candidates and prune_entries, now count only the marks of people who
subscribe to the item's feed. The old prune test's pin had no subscriber behind it; it has one now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 01:45:14 +00:00
parent ec55682c13
commit 60cbb7aff6
3 changed files with 35 additions and 4 deletions

View File

@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Freeing disk space counts only the people who subscribe to a feed: an episode is no longer taken as heard by everyone because someone who has since unsubscribed heard it, and their pins no longer keep files for good.
- An episode whose file turns out to be a torrent shows as downloaded once it is, instead of being fetched again on every check of its feed, and no longer holds up the checking of other feeds while it downloads and seeds. - An episode whose file turns out to be a torrent shows as downloaded once it is, instead of being fetched again on every check of its feed, and no longer holds up the checking of other feeds while it downloads and seeds.
- Downloading the latest 65 to 100 items of a feed queues them at once, instead of hanging until most had downloaded and holding up adding feeds and Refresh meanwhile. - Downloading the latest 65 to 100 items of a feed queues them at once, instead of hanging until most had downloaded and holding up adding feeds and Refresh meanwhile.
- Changes to the feed list made at the same moment, such as two people adding feeds, or a feed added while the server settings change, no longer undo one another. - Changes to the feed list made at the same moment, such as two people adding feeds, or a feed added while the server settings change, no longer undo one another.

View File

@@ -782,7 +782,9 @@ impl Db {
/// One file serves every subscriber, so both tests are about all of them: **anyone** /// One file serves every subscriber, so both tests are about all of them: **anyone**
/// starring it keeps it, and it only counts as read when **everyone** subscribed has /// starring it keeps it, and it only counts as read when **everyone** subscribed has
/// read it. A file whose feed nobody subscribes to has no one left to keep it, so it /// read it. A file whose feed nobody subscribes to has no one left to keep it, so it
/// sorts with the read ones. /// sorts with the read ones. Only the marks of people who subscribe count: unsubscribing
/// leaves yours behind, and counted, a reader who had left made an episode someone had not
/// played look finished, and a pin of theirs kept a file for good (#156).
/// ///
/// (The Python intended `read = 1 AND flagged = 0` but never achieved it -- a missing /// (The Python intended `read = 1 AND flagged = 0` but never achieved it -- a missing
/// plistlib import and an `EntreiesData` typo meant the filter always threw.) /// plistlib import and an `EntreiesData` typo meant the filter always threw.)
@@ -796,13 +798,15 @@ impl Db {
LEFT JOIN (SELECT feed_id, count(*) AS n FROM subscriptions GROUP BY feed_id) subs LEFT JOIN (SELECT feed_id, count(*) AS n FROM subscriptions GROUP BY feed_id) subs
ON subs.feed_id = e.feed_id ON subs.feed_id = e.feed_id
-- Someone an item is hidden from is as done with it as someone who read it. -- Someone an item is hidden from is as done with it as someone who read it.
LEFT JOIN (SELECT feed_id, guid, count(*) AS n LEFT JOIN (SELECT done.feed_id, done.guid, count(*) AS n
FROM (SELECT user_id, feed_id, guid FROM entry_state WHERE read FROM (SELECT user_id, feed_id, guid FROM entry_state WHERE read
UNION SELECT user_id, feed_id, guid FROM hidden) done UNION SELECT user_id, feed_id, guid FROM hidden) done
GROUP BY feed_id, guid) readers JOIN subscriptions u ON u.user_id = done.user_id AND u.feed_id = done.feed_id
GROUP BY done.feed_id, done.guid) readers
ON readers.feed_id = e.feed_id AND readers.guid = e.guid ON readers.feed_id = e.feed_id AND readers.guid = e.guid
WHERE e.path IS NOT NULL WHERE e.path IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM entry_state s AND NOT EXISTS (SELECT 1 FROM entry_state s
JOIN subscriptions u ON u.user_id = s.user_id AND u.feed_id = s.feed_id
WHERE s.feed_id = e.feed_id AND s.guid = e.guid AND s.flagged) WHERE s.feed_id = e.feed_id AND s.guid = e.guid AND s.flagged)
ORDER BY 6 DESC, coalesce(e.downloaded_at, 0) ASC, e.id ASC", ORDER BY 6 DESC, coalesce(e.downloaded_at, 0) ASC, e.id ASC",
vec![], vec![],
@@ -941,9 +945,10 @@ impl Db {
SELECT 1 FROM enclosures e SELECT 1 FROM enclosures e
WHERE e.feed_id = entries.feed_id AND e.guid = entries.guid WHERE e.feed_id = entries.feed_id AND e.guid = entries.guid
AND e.path IS NOT NULL) AND e.path IS NOT NULL)
-- Starred by anyone keeps it, the same rule the reaper follows. -- Starred by anyone subscribed keeps it, the same rule the reaper follows.
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM entry_state s SELECT 1 FROM entry_state s
JOIN subscriptions u ON u.user_id = s.user_id AND u.feed_id = s.feed_id
WHERE s.feed_id = entries.feed_id AND s.guid = entries.guid WHERE s.feed_id = entries.feed_id AND s.guid = entries.guid
AND s.flagged)", AND s.flagged)",
vec![older_than.into()], vec![older_than.into()],

View File

@@ -189,11 +189,36 @@ mod tests {
); );
} }
#[tokio::test]
async fn the_marks_of_someone_who_left_count_for_nothing() {
// ray and sam subscribe; left read one episode and pinned the others before unsubscribing.
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false),(3,'left',false);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'heard', 0), ('f', 'pinned', 0), ('f', 'old', 0);
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
(3, 'f', 'heard', true, false),
(1, 'f', 'heard', true, false),
(3, 'f', 'pinned', false, true),
(3, 'f', 'old', false, true);
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
(1, 'f', 'heard', 'u1', '/tmp/heard', 10, 'done', 10),
(2, 'f', 'pinned', 'u2', '/tmp/pinned', 10, 'done', 20);",
).await
.unwrap();
let got: Vec<(i64, bool)> = db.reap_candidates().await.unwrap().iter().map(|c| (c.id, c.read)).collect();
assert_eq!(got, vec![(1, false), (2, false)], "sam has not heard it, and nobody here pinned it (#156)");
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "the old item goes, a leaver's pin or not");
}
#[tokio::test] #[tokio::test]
async fn prune_keeps_entries_that_still_have_a_file() { async fn prune_keeps_entries_that_still_have_a_file() {
let db = Db::memory().await.unwrap(); let db = Db::memory().await.unwrap();
db.exec_for_test( db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true); "INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f');
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',true); INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',true);
INSERT INTO entries (feed_id, guid, first_seen) VALUES INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'has-file', 100), ('f', 'has-file', 100),