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

@@ -782,7 +782,9 @@ impl Db {
/// 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
/// 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
/// 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
ON subs.feed_id = e.feed_id
-- 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
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
WHERE e.path IS NOT NULL
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)
ORDER BY 6 DESC, coalesce(e.downloaded_at, 0) ASC, e.id ASC",
vec![],
@@ -941,9 +945,10 @@ impl Db {
SELECT 1 FROM enclosures e
WHERE e.feed_id = entries.feed_id AND e.guid = entries.guid
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 (
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
AND s.flagged)",
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]
async fn prune_keeps_entries_that_still_have_a_file() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"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 entries (feed_id, guid, first_seen) VALUES
('f', 'has-file', 100),