Step 5: quota and age retention

Oldest-first reaper with the original's 50 MB headroom pad, plus a
reconcile pass for files deleted by hand and pruning of stale entries.

The Python meant to reap only read, unflagged episodes but a missing
import and a typo made that filter throw on every candidate. Requiring
read=1 would be equally dead headless, so flagged is the keep-forever
marker and read only decides ordering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
This commit is contained in:
2026-09-09 20:41:46 +00:00
parent a8e1f474a6
commit 21d32104fe
4 changed files with 345 additions and 3 deletions

View File

@@ -11,8 +11,7 @@ The full design and step list live in the plan file at
- [x] **2. `config.rs` + `db.rs`** — TOML config structs + SQLite schema.
- [x] **3. `feed.rs`** — conditional GET, RSS-then-Atom parse, persist entries.
- [x] **4. `download.rs`** — downloads, filters, dedupe.
- [ ] **5. `retention.rs`** — oldest-first quota + age reaper, `ipx reap [--dry-run]`.
*Done when:* smoke 6 passes.
- [x] **5. `retention.rs`** — oldest-first quota + age reaper.
- [ ] **6. `ipc.rs` + daemon** — broadcast event bus, UDS JSON-lines server, TTL scheduler,
CLI-proxies-to-daemon. *Done when:* smoke 3 passes.
- [ ] **7. `torrent.rs`** — librqbit, seed to ratio/time, stall abort. *Done when:* smoke 5 passes.
@@ -31,6 +30,35 @@ The full design and step list live in the plan file at
---
## 2026-09-09 — Step 5: retention.rs
`src/retention.rs`: reconcile pass (rows claiming a file that is gone become `reaped`, fixing the
step-4 wart), age sweep, quota sweep keeping the original's 50 MB headroom pad, and entry pruning.
`ipx reap [--dry-run]`; a sweep also runs before every `fetch`, as the Python did per download.
`pick()` and `aged()` are pure so the ordering rules are testable without touching a disk.
**Judgement call worth Ray's eye.** The Python meant to reap only `read = 1 AND flagged = 0` but
never managed it — a missing `plistlib` import and an `EntreiesData` typo made that filter throw on
every candidate, so with a `.ipxd` present nothing was ever deleted. Requiring `read = 1` here would
be equally dead, because nothing marks episodes read until a UI exists. So: **`flagged` is the
keep-forever marker, and `read` only decides what goes first** (`ORDER BY read DESC, downloaded_at
ASC`). Quota therefore actually reclaims space headless. Say the word if you would rather unread
episodes were never touched.
Second call: `max_age_days` deletes *files* older than the cutoff, not just fileless entries as the
plan's wording had it — "keep 30 days of episodes" is what the setting reads like on a NAS.
Verified: `cargo test` 21/21, including the two tests encoding the exact bug the Python had —
flagged files are never offered, and read sort ahead of unread. Smoke 6 with three 30 MB episodes
against a 0.1 GB quota (52.4 MB ceiling after the pad): dry run listed ep1+ep2 and deleted nothing
(3 files still on disk), the real run deleted exactly those two oldest, left ep3, flipped both rows
to `reaped` with `path = NULL`. A full re-parse with the conditional-GET headers cleared then
re-downloaded nothing.
Next: step 6 — `ipc.rs` + daemon.
---
## 2026-09-09 — Step 4: download.rs
`src/download.rs`: streaming download to `<download_dir>/.ipx-incomplete/` (same filesystem as the