Retention follows per-user read and starred
reap_candidates still read entries.read/flagged, which nothing writes since read state moved to entry_state -- so starring no longer protected a file and the read-first ordering was dead. One file serves every subscriber, so anyone starring it keeps it, and it counts as read only once everyone subscribed has read it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
14
PROGRESS.md
14
PROGRESS.md
@@ -56,6 +56,20 @@ and until now nothing set them.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-09-11 — Retention caught up with per-user state
|
||||||
|
|
||||||
|
Moving read and starred into `entry_state` left `reap_candidates` reading `entries.read` and
|
||||||
|
`entries.flagged`, which nothing writes any more: **starring stopped protecting a file**, and the
|
||||||
|
"delete the ones already read first" ordering was dead. Harmless while the quota and age limits are
|
||||||
|
0 -- the reaper deletes nothing at all then -- but it would have bitten the moment one was set.
|
||||||
|
|
||||||
|
One file serves every subscriber, so both tests are now about all of them: **anyone** starring it
|
||||||
|
keeps it, and it only counts as read once **everyone** subscribed has read it. A file whose feed
|
||||||
|
nobody subscribes to has nobody left to keep it, so it sorts with the read ones. Tested with two
|
||||||
|
subscribers disagreeing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-09-11 — Steps B and C: what is yours, what is everyone's
|
## 2026-09-11 — Steps B and C: what is yours, what is everyone's
|
||||||
|
|
||||||
Read, starred and playback position moved out of `entries` into `entry_state (user_id, feed_id,
|
Read, starred and playback position moved out of `entries` into `entry_state (user_id, feed_id,
|
||||||
|
|||||||
32
src/db.rs
32
src/db.rs
@@ -466,23 +466,31 @@ pub struct Candidate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
/// Files on disk, flagged ones excluded, read before unread and oldest first within
|
/// Files on disk that may be deleted to get back under quota: starred by nobody,
|
||||||
/// each group.
|
/// with the ones everybody has finished going first, oldest first within each group.
|
||||||
///
|
///
|
||||||
/// The Python intended `read = 1 AND flagged = 0` but never achieved it (a missing
|
/// One file serves every subscriber, so both tests are about all of them: **anyone**
|
||||||
/// plistlib import and an `EntreiesData` typo meant the filter always threw). Requiring
|
/// starring it keeps it, and it only counts as read when **everyone** subscribed has
|
||||||
/// `read = 1` outright would be just as dead here, since nothing marks episodes read
|
/// read it. A file whose feed nobody subscribes to has no one left to keep it, so it
|
||||||
/// until a UI exists -- so `flagged` is the keep-forever marker, and `read` only decides
|
/// sorts with the read ones.
|
||||||
/// what goes first.
|
///
|
||||||
|
/// (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.)
|
||||||
pub fn reap_candidates(&self) -> Result<Vec<Candidate>> {
|
pub fn reap_candidates(&self) -> Result<Vec<Candidate>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT e.id, e.url, e.path, e.bytes_done,
|
"SELECT e.id, e.url, e.path, e.bytes_done, coalesce(e.downloaded_at, 0),
|
||||||
coalesce(e.downloaded_at, 0), coalesce(n.read, 0), coalesce(n.flagged, 0)
|
CASE WHEN coalesce(readers.n, 0) >= coalesce(subs.n, 0) THEN 1 ELSE 0 END
|
||||||
FROM enclosures e
|
FROM enclosures e
|
||||||
LEFT JOIN entries n ON n.feed_id = e.feed_id AND n.guid = e.guid
|
LEFT JOIN (SELECT feed_id, count(*) n FROM subscriptions GROUP BY feed_id) subs
|
||||||
WHERE e.path IS NOT NULL AND coalesce(n.flagged, 0) = 0
|
ON subs.feed_id = e.feed_id
|
||||||
ORDER BY coalesce(n.read, 0) DESC, coalesce(e.downloaded_at, 0) ASC, e.id ASC",
|
LEFT JOIN (SELECT feed_id, guid, count(*) n FROM entry_state
|
||||||
|
WHERE read = 1 GROUP BY feed_id, 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
|
||||||
|
WHERE s.feed_id = e.feed_id AND s.guid = e.guid AND s.flagged = 1)
|
||||||
|
ORDER BY 6 DESC, coalesce(e.downloaded_at, 0) ASC, e.id ASC",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |r| {
|
.query_map([], |r| {
|
||||||
|
|||||||
@@ -150,22 +150,39 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn query_never_offers_flagged_files_and_prefers_read_ones() {
|
fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() {
|
||||||
|
// One file serves both subscribers, so it takes both of them to release it.
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO entries (feed_id, guid, first_seen, read, flagged) VALUES
|
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0),(2,'sam',0,0);
|
||||||
('f', 'keep', 0, 1, 1),
|
INSERT INTO subscriptions (user_id, feed_id, created) VALUES (1,'f',0),(2,'f',0);
|
||||||
('f', 'unread', 0, 0, 0),
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||||
('f', 'read', 0, 1, 0);
|
('f', 'keep', 0),
|
||||||
|
('f', 'half', 0),
|
||||||
|
('f', 'unread', 0),
|
||||||
|
('f', 'read', 0);
|
||||||
|
-- Starred by one of the two, so it stays whatever the other thinks.
|
||||||
|
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
|
||||||
|
(1, 'f', 'keep', 1, 1),
|
||||||
|
(2, 'f', 'keep', 1, 0),
|
||||||
|
(1, 'f', 'half', 1, 0),
|
||||||
|
(1, 'f', 'read', 1, 0),
|
||||||
|
(2, 'f', 'read', 1, 0);
|
||||||
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
||||||
(1, 'f', 'keep', 'u1', '/tmp/keep', 10, 'done', 10),
|
(1, 'f', 'keep', 'u1', '/tmp/keep', 10, 'done', 10),
|
||||||
(2, 'f', 'unread', 'u2', '/tmp/unread', 10, 'done', 20),
|
(2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
|
||||||
(3, 'f', 'read', 'u3', '/tmp/read', 10, 'done', 30);",
|
(3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
|
||||||
|
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let got: Vec<i64> = db.reap_candidates().unwrap().iter().map(|c| c.id).collect();
|
let got: Vec<i64> = db.reap_candidates().unwrap().iter().map(|c| c.id).collect();
|
||||||
assert_eq!(got, vec![3, 2], "flagged excluded; read goes before unread");
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
vec![4, 2, 3],
|
||||||
|
"starred by anyone is never offered; read by everyone goes first, and one \
|
||||||
|
person still having it unread keeps it back with the unread ones"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user