feather_reader/store.rs
1//! SQLite persistence layer (via `sqlx`, runtime queries).
2//!
3//! FeatherReader keeps the source of truth for *what a user follows* and *their
4//! read-position* in the user's own atproto PDS (as `community.lexicon.rss.*`
5//! records). This module is the **local per-DID cache + debounce
6//! buffer**: a single SQLite file that holds
7//!
8//! * `feeds` + `entries` — a shared cache of feed metadata and articles, keyed by
9//! feed URL / feed-native GUID and **shared across every DID** that follows the
10//! same feed (many users on one instance don't multiply fetch load), and
11//! * `entry_state` + `read_cursor` — per-DID read/star state and the per-feed
12//! read cursor that the (v1.1) batched flusher syncs up to the PDS.
13//!
14//! All queries here are **runtime** queries (`sqlx::query` / `sqlx::query_as`),
15//! not the compile-time `query!` macros — so the crate builds with no
16//! `DATABASE_URL` and no offline metadata. Schema creation is idempotent
17//! (`CREATE TABLE IF NOT EXISTS`) and runs inside [`init`].
18//!
19//! Errors propagate as [`anyhow::Result`]; nothing in the non-test paths panics.
20
21use anyhow::{Context, Result};
22use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
23use sqlx::{ConnectOptions, FromRow, Row};
24use std::str::FromStr;
25
26use crate::config::Config;
27
28/// Typed failure modes for [`redeem_code`]. Distinct variants so the web layer
29/// can map each to the right user-facing message / HTTP status without string
30/// matching. Everything else (a real SQLite error) still propagates as
31/// [`anyhow::Error`] out of the `Result`.
32#[derive(Debug, thiserror::Error, PartialEq, Eq)]
33pub enum RedeemError {
34 /// No invite code with that value exists.
35 #[error("invite code not found")]
36 NotFound,
37 /// The code exists but is past its `expires_at` (or already flipped to
38 /// `expired`).
39 #[error("invite code expired")]
40 Expired,
41 /// The code has already been redeemed (or is otherwise not `active`).
42 #[error("invite code already redeemed")]
43 AlreadyRedeemed,
44 /// The closed-beta seat cap ([`Config`]'s `FEATHERREADER_BETA_CAP`) is full.
45 #[error("beta is at capacity")]
46 CapacityFull,
47}
48
49/// The SQLite connection pool type the rest of the crate refers to as
50/// [`Pool`]. A thin alias over `SqlitePool` so [`crate::AppState`] and the web
51/// layer name one stable type; if the backend ever changes, this is the single
52/// place to swap it.
53pub type Pool = SqlitePool;
54
55/// A cached syndication feed, shared across all DIDs that subscribe to its URL.
56///
57/// This mirrors the PDS-side `community.lexicon.rss.subscription.url`; the row is
58/// created/updated by the poller, never owned by a single user.
59#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
60pub struct Feed {
61 pub id: i64,
62 pub url: String,
63 pub title: Option<String>,
64 pub site_url: Option<String>,
65 /// HTTP `ETag` from the last successful fetch, for conditional GET.
66 pub etag: Option<String>,
67 /// HTTP `Last-Modified` from the last successful fetch, for conditional GET.
68 pub last_modified: Option<String>,
69 /// When we last polled this feed (RFC3339), or `None` if never.
70 pub last_polled: Option<String>,
71 /// When this feed is next due to be polled (RFC3339), or `None`.
72 pub next_poll: Option<String>,
73 /// Count of consecutive poll FAILURES since the last success/304. Drives the
74 /// exponential poll backoff (reset to 0 on any success or 304).
75 #[sqlx(default)]
76 pub consecutive_errors: i64,
77}
78
79/// A cached article/item belonging to a [`Feed`]. Shared cache (not per-DID).
80#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
81pub struct Entry {
82 pub id: i64,
83 pub feed_id: i64,
84 /// Feed-native GUID/id, unique within a feed (used for dedup on re-fetch).
85 pub guid: String,
86 pub url: Option<String>,
87 pub title: Option<String>,
88 pub author: Option<String>,
89 /// Publication time as reported by the feed (RFC3339), or `None`.
90 pub published: Option<String>,
91 /// Article body HTML, **already sanitized** (ammonia) before it reaches here.
92 pub content_html: Option<String>,
93 /// When FeatherReader first fetched/stored this entry (RFC3339).
94 pub fetched_at: String,
95}
96
97/// One row of a LIST view — deliberately **without** `content_html`.
98///
99/// The list queries used to be `SELECT e.*` into [`Entry`], which carries the
100/// sanitized article body. The body is essentially the whole of a cached entry
101/// (measured: 11.9 KB/entry), and no list surface has ever rendered it — the
102/// reader's `EntryRow` reads id, title, feed title, date, read, starred and
103/// link, and nothing else. So every article on every page load was read off
104/// disk, allocated, and dropped unexamined. On a 512 MB box with 250 concurrent
105/// requests permitted, one reader with a large backlog could ask for hundreds of
106/// megabytes in a single handler, and the resulting OOM/restart looked like a
107/// healthy machine that simply fell over.
108///
109/// `read` / `starred` come from the same `LEFT JOIN` that filters the view, so a
110/// caller does not have to fetch the whole unread or starred set a second time
111/// just to decorate the rows it is showing.
112///
113/// [`Entry`] is still the right type for the single-entry reader, which is the
114/// one surface that genuinely needs the body.
115#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
116pub struct EntryListRow {
117 pub id: i64,
118 pub feed_id: i64,
119 /// Feed-native GUID — used to match a cached entry against a PDS saved record.
120 pub guid: String,
121 pub url: Option<String>,
122 pub title: Option<String>,
123 pub published: Option<String>,
124 /// This DID's read bit. `false` when there is no `entry_state` row at all.
125 pub read: bool,
126 /// This DID's star bit. `false` when there is no `entry_state` row at all.
127 pub starred: bool,
128}
129
130/// Which list [`list_entries`] (and its siblings) is producing.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum ListView {
133 /// No `entry_state` row for this DID, or one with `read = 0`.
134 Unread,
135 /// An `entry_state` row with `starred = 1`.
136 Starred,
137 /// Every subscribed entry, read or not.
138 All,
139}
140
141impl ListView {
142 /// The `WHERE` fragment that selects this view, given `s` as the per-DID
143 /// `entry_state` LEFT JOIN alias.
144 fn predicate(self) -> &'static str {
145 match self {
146 // An entry with no state row is unread — hence LEFT JOIN + COALESCE
147 // rather than a join that would drop never-touched entries.
148 ListView::Unread => "COALESCE(s.read, 0) = 0",
149 ListView::Starred => "COALESCE(s.starred, 0) = 1",
150 ListView::All => "1 = 1",
151 }
152 }
153}
154
155/// Per-`(did, entry)` read/star state — the fast in-session working copy that the
156/// batched flusher later syncs to the PDS as a per-feed read cursor.
157#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
158pub struct EntryState {
159 pub did: String,
160 pub entry_id: i64,
161 pub read: bool,
162 pub starred: bool,
163 pub updated_at: String,
164}
165
166/// Per-`(did, feed_url)` read cursor — the local mirror of the PDS
167/// `community.lexicon.rss.readState` record plus flush bookkeeping.
168///
169/// `read_ids` / `unread_ids` are stored as JSON arrays of entry ids (the two
170/// bounded exception sets around the `read_through` high-water-mark); `dirty`
171/// marks that local `entry_state` has changed since the last PDS flush.
172#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
173pub struct ReadCursor {
174 pub did: String,
175 pub feed_url: String,
176 /// High-water-mark (RFC3339): every entry seen/published `<=` this is read.
177 pub read_through: Option<String>,
178 /// JSON array of entry ids newer than `read_through` that are also read.
179 pub read_ids: String,
180 /// JSON array of entry ids older than `read_through` explicitly kept unread.
181 pub unread_ids: String,
182 /// Set when `entry_state` changed since the last flush (debounce trigger).
183 pub dirty: bool,
184 /// Whether this cursor's `readState` record has been CREATED in the PDS yet.
185 /// The first flush of a feed must emit an `applyWrites#create` (an `#update`
186 /// errors on a record that does not pre-exist, and applyWrites is atomic
187 /// per-repo, so one not-yet-created cursor would drop the whole DID batch).
188 /// Flipped to `true` on the flush that creates it.
189 #[sqlx(default)]
190 pub pds_created: bool,
191 pub updated_at: String,
192}
193
194/// The `network_stat` key the relay adoption probe writes under.
195///
196/// Lives here, beside [`NetworkStat`], because **both** the writer (the
197/// scheduler's probe, compiled into the binary) and the reader (`web::about`,
198/// compiled into the library) name it — a literal in either place would be two
199/// strings free to drift apart.
200pub const ADOPTION_STAT_KEY: &str = "adoption.subscription";
201
202/// One relay's observation of how many repos hold a collection
203/// (`design/NETWORK-SPEC.md` §4.3). A projection: droppable, rebuildable from
204/// the network, and never read by anything on the reading path.
205#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
206pub struct NetworkStat {
207 /// The metric key, e.g. [`ADOPTION_STAT_KEY`].
208 pub key: String,
209 /// The relay base URL the number came from.
210 pub source: String,
211 /// The observed count.
212 pub value: i64,
213 /// Set when the probe hit its page cap: the value is a floor, not a count.
214 pub truncated: bool,
215 /// When the observation was taken (RFC3339, UTC).
216 pub observed_at: String,
217}
218
219/// New-feed payload for [`upsert_feed`] (id is assigned by SQLite).
220#[derive(Debug, Clone, Default)]
221pub struct NewFeed {
222 pub url: String,
223 pub title: Option<String>,
224 pub site_url: Option<String>,
225 pub etag: Option<String>,
226 pub last_modified: Option<String>,
227 pub last_polled: Option<String>,
228 pub next_poll: Option<String>,
229}
230
231/// New-entry payload for [`insert_entries`] (id is assigned by SQLite,
232/// `fetched_at` defaults to "now" when not supplied).
233#[derive(Debug, Clone, Default)]
234pub struct NewEntry {
235 pub guid: String,
236 pub url: Option<String>,
237 pub title: Option<String>,
238 pub author: Option<String>,
239 pub published: Option<String>,
240 /// Already-sanitized HTML.
241 pub content_html: Option<String>,
242 /// Optional explicit fetch time (RFC3339); defaults to now if `None`.
243 pub fetched_at: Option<String>,
244}
245
246/// The SQLite schema. Idempotent — safe to run on every startup.
247///
248/// `feeds`/`entries` are the shared cache; `entry_state`/`read_cursor` are
249/// per-DID. Indices cover the scheduler's due-feed query, the read/unread list
250/// query, and the flusher's dirty-cursor scan.
251const SCHEMA: &str = r#"
252PRAGMA foreign_keys = ON;
253
254CREATE TABLE IF NOT EXISTS feeds (
255 id INTEGER PRIMARY KEY AUTOINCREMENT,
256 url TEXT NOT NULL UNIQUE,
257 title TEXT,
258 site_url TEXT,
259 etag TEXT,
260 last_modified TEXT,
261 last_polled TEXT,
262 next_poll TEXT,
263 consecutive_errors INTEGER NOT NULL DEFAULT 0,
264 last_error_kind TEXT,
265 last_error TEXT,
266 -- What the poller does with this row; see `feed::FeedKind`. Written by the
267 -- Rust side at insert so SQL never re-derives it from the URL.
268 kind TEXT NOT NULL DEFAULT 'rss'
269);
270CREATE INDEX IF NOT EXISTS idx_feeds_next_poll ON feeds (next_poll);
271CREATE INDEX IF NOT EXISTS idx_feeds_kind ON feeds (kind);
272
273CREATE TABLE IF NOT EXISTS entries (
274 id INTEGER PRIMARY KEY AUTOINCREMENT,
275 feed_id INTEGER NOT NULL REFERENCES feeds (id) ON DELETE CASCADE,
276 guid TEXT NOT NULL,
277 url TEXT,
278 title TEXT,
279 author TEXT,
280 published TEXT,
281 content_html TEXT,
282 fetched_at TEXT NOT NULL,
283 UNIQUE (feed_id, guid)
284);
285CREATE INDEX IF NOT EXISTS idx_entries_feed_published ON entries (feed_id, published);
286
287CREATE TABLE IF NOT EXISTS entry_state (
288 did TEXT NOT NULL,
289 entry_id INTEGER NOT NULL REFERENCES entries (id) ON DELETE CASCADE,
290 read INTEGER NOT NULL DEFAULT 0,
291 starred INTEGER NOT NULL DEFAULT 0,
292 updated_at TEXT NOT NULL,
293 PRIMARY KEY (did, entry_id)
294);
295CREATE INDEX IF NOT EXISTS idx_entry_state_did_read ON entry_state (did, read);
296-- The FK child key. `entry_id` is the TRAILING column of the primary key, so
297-- without this index it is not the leading column of anything and SQLite must
298-- FULL SCAN entry_state for EVERY row deleted from `entries` to service
299-- ON DELETE CASCADE.
300--
301-- That is not theoretical. Measured on 600k entry_state rows: 500 deletes took
302-- 10.3s and 2,000 took 38.3s, against a busy_timeout of 5s — so any retention
303-- sweep removing more than roughly 260 entries made every concurrent writer
304-- (star, mark-read, OAuth session write) fail with SQLITE_BUSY. With this index
305-- the same 32,850-row delete goes from ~10 minutes to 0.7s.
306--
307-- It also fixes the per-feed trim, whose starred-sparing subquery scans
308-- entry_state on every poll of every feed and scales with TOTAL rows across all
309-- users rather than with the feed being trimmed (2ms -> 21ms at 1M rows).
310CREATE INDEX IF NOT EXISTS idx_entry_state_entry_id ON entry_state (entry_id);
311
312-- Per-DID subscription projection. The shared `feeds`/`entries` cache is
313-- deduped by URL and NOT owned by any single DID; `sub_ref` records which
314-- feeds a given DID actually subscribes to (mirrored from the caller's PDS
315-- subscription set on every resolve/sync). Every entry/feed READ and every
316-- read/star MUTATION is scoped through this table so one user can never read
317-- or mutate another user's cached articles. Rows are refreshed by
318-- `replace_sub_refs`.
319CREATE TABLE IF NOT EXISTS sub_ref (
320 did TEXT NOT NULL,
321 feed_id INTEGER NOT NULL REFERENCES feeds (id) ON DELETE CASCADE,
322 PRIMARY KEY (did, feed_id)
323);
324CREATE INDEX IF NOT EXISTS idx_sub_ref_feed ON sub_ref (feed_id);
325
326CREATE TABLE IF NOT EXISTS read_cursor (
327 did TEXT NOT NULL,
328 feed_url TEXT NOT NULL,
329 read_through TEXT,
330 read_ids TEXT NOT NULL DEFAULT '[]',
331 unread_ids TEXT NOT NULL DEFAULT '[]',
332 dirty INTEGER NOT NULL DEFAULT 0,
333 pds_created INTEGER NOT NULL DEFAULT 0,
334 updated_at TEXT NOT NULL,
335 PRIMARY KEY (did, feed_url)
336);
337CREATE INDEX IF NOT EXISTS idx_read_cursor_dirty ON read_cursor (did, dirty);
338-- The (did, feed_url) PRIMARY KEY can't serve a feed_url-only lookup (did is the
339-- leading column). The retention path's orphan-cursor cleanup filters cursors by
340-- feed_url alone, so give it an index.
341CREATE INDEX IF NOT EXISTS idx_read_cursor_feed_url ON read_cursor (feed_url);
342
343CREATE TABLE IF NOT EXISTS beta_access (
344 did TEXT PRIMARY KEY,
345 handle TEXT,
346 granted_by TEXT NOT NULL,
347 granted_at INTEGER NOT NULL,
348 invite_code_used TEXT
349);
350
351CREATE TABLE IF NOT EXISTS invite_codes (
352 code TEXT PRIMARY KEY,
353 creator_did TEXT NOT NULL,
354 status TEXT NOT NULL,
355 invitee_did TEXT,
356 -- The follower DID a bot-minted claim was minted FOR (recorded at mint time,
357 -- distinct from `invitee_did` which is stamped at redeem). This is the
358 -- server-side idempotency key: a second `POST /bot/claims` for a DID that
359 -- already holds an outstanding active code returns the SAME code instead of
360 -- minting a duplicate, so a bot-host state loss cannot re-mint per follower.
361 intended_did TEXT,
362 created_at INTEGER NOT NULL,
363 expires_at INTEGER NOT NULL,
364 redeemed_at INTEGER
365);
366CREATE INDEX IF NOT EXISTS idx_invite_codes_status ON invite_codes (status, expires_at);
367-- NOTE: the `intended_did` indexes are created in `apply_migrations`, AFTER the
368-- `intended_did` column is ensured. They MUST NOT live in this base SCHEMA batch:
369-- on an existing pre-0.2.2 volume the `CREATE TABLE IF NOT EXISTS invite_codes`
370-- above is a no-op (the table already exists without `intended_did`), so a
371-- `CREATE INDEX ... (intended_did, ...)` here would fail with "no such column"
372-- and crash-loop the boot before migrations ever run.
373
374-- Network-observation counters (v0.2.8, design/NETWORK-SPEC.md §4.3). One row
375-- per (metric, relay): the adoption probe records how many repos a given relay
376-- has INDEXED as holding a collection. We store the COUNT, never the DID list —
377-- persisting the DIDs would build a durable register of "accounts that use an
378-- RSS reader" on our disk for a feature whose only output is an integer. This
379-- table is a PROJECTION, not a source of truth: `DROP TABLE` it and the next
380-- probe rebuilds it, and nothing in the reader path reads it. Bounded forever at
381-- (metrics × relays) rows, so it never interacts with the DB-size watermark.
382CREATE TABLE IF NOT EXISTS network_stat (
383 key TEXT NOT NULL, -- e.g. 'adoption.subscription'
384 source TEXT NOT NULL, -- the relay host the number came from
385 value INTEGER NOT NULL,
386 truncated INTEGER NOT NULL DEFAULT 0,
387 observed_at TEXT NOT NULL,
388 PRIMARY KEY (key, source)
389);
390-- Repo-operation timings, for comparing the two backends across a CUTOVER.
391--
392-- Persisted rather than held in memory because flipping the backend requires a
393-- restart, and an in-memory table would lose the outgoing backend's numbers at
394-- exactly the moment they became worth comparing against. These rows are the
395-- only reason a "side by side" table can show two backends at once.
396--
397-- `repo_timing` is a bounded window of recent samples (pruned per backend+op);
398-- `repo_timing_total` carries the all-time counts, which must survive that
399-- pruning or a long-running backend would appear to have served fewer calls
400-- than a fresh one.
401CREATE TABLE IF NOT EXISTS repo_timing (
402 id INTEGER PRIMARY KEY AUTOINCREMENT,
403 backend TEXT NOT NULL,
404 op TEXT NOT NULL,
405 micros INTEGER NOT NULL,
406 ok INTEGER NOT NULL,
407 at INTEGER NOT NULL
408);
409
410CREATE INDEX IF NOT EXISTS idx_repo_timing_key ON repo_timing(backend, op, id);
411
412CREATE TABLE IF NOT EXISTS repo_timing_total (
413 backend TEXT NOT NULL,
414 op TEXT NOT NULL,
415 ok_count INTEGER NOT NULL DEFAULT 0,
416 err_count INTEGER NOT NULL DEFAULT 0,
417 PRIMARY KEY (backend, op)
418);
419
420"#;
421
422/// RFC3339 timestamp for "now" (UTC, seconds precision), used as the default for
423/// `*_at` columns. Uses `chrono` to match the shape written by [`crate::feed`]
424/// and [`crate::web`] (one timestamp format across the whole crate).
425fn now_rfc3339() -> String {
426 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
427}
428
429/// Open the per-DID SQLite cache described by [`Config`] (its `db_path`), run
430/// schema creation, and return the pool.
431///
432/// This is the entrypoint `main` calls: it derives the sqlx SQLite URL from the
433/// configured filesystem path and delegates to [`init_url`]. Kept separate from
434/// [`init_url`] so tests can open an in-memory database directly.
435pub async fn init(config: &Config) -> Result<Pool> {
436 // sqlx wants a `sqlite://<path>` URL; build it from the configured path.
437 let db_url = format!("sqlite://{}", config.db_path.display());
438 init_url(&db_url).await
439}
440
441/// Open (creating if needed) the SQLite database at `db_url`, run schema
442/// creation, and return a connection pool.
443///
444/// `db_url` is a sqlx SQLite URL, e.g. `sqlite://featherreader.db` or
445/// `sqlite::memory:` for an ephemeral in-memory database. The file is created
446/// if it does not exist; WAL journaling is enabled for on-disk databases and
447/// foreign keys are enforced on every connection.
448/// Ceiling the WAL is truncated back to at each checkpoint.
449///
450/// The WAL lives on the same volume as the database and counts against the same
451/// 1 GB, but nothing bounded it: SQLite grows the WAL to fit the largest
452/// transaction it has ever seen and never shrinks it again without this limit.
453const WAL_SIZE_LIMIT_BYTES: i64 = 64 * 1024 * 1024;
454
455pub async fn init_url(db_url: &str) -> Result<Pool> {
456 // An in-memory DB must run on a SINGLE connection: each `:memory:` connection
457 // is a *separate* database, and a multi-connection in-memory pool can also
458 // deadlock a writer against an idle pooled connection's shared-cache table
459 // read-lock (SQLITE_LOCKED, code 262 — which `busy_timeout` does NOT retry;
460 // seen as a Linux-only flaky failure in redeem_code's UPDATE). On-disk uses
461 // WAL + a 5-connection pool as normal.
462 let is_memory = db_url.contains(":memory:");
463 let mut opts = SqliteConnectOptions::from_str(db_url)
464 .with_context(|| format!("invalid sqlite url: {db_url}"))?
465 .create_if_missing(true)
466 .foreign_keys(true);
467 // WAL is a no-op / unsupported for :memory:, so only request it on-disk.
468 if !is_memory {
469 opts = opts.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
470 // **Incremental auto-vacuum, set at CREATION.**
471 //
472 // `auto_vacuum` was read by `reclaim` and never set anywhere, so every
473 // database ran in SQLite's default NONE mode and `reclaim` always took
474 // its full-`VACUUM` branch — daily, and again after every prune. A full
475 // VACUUM needs free disk roughly equal to the live database because it
476 // writes a whole new file, which is exactly what is scarce under the
477 // disk pressure that triggers a sweep; on a ~700 MiB database on a 1 GB
478 // volume it cannot complete at all.
479 //
480 // This pragma only takes effect on a database with no tables yet, so it
481 // fixes NEW instances permanently and does nothing to existing ones —
482 // deliberately. Changing it on a populated database requires running the
483 // very full VACUUM that is unsafe here, so that is a separate,
484 // operator-invoked step: see [`migrate_to_incremental_vacuum`].
485 opts = opts.auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Incremental);
486 // Truncate the WAL back down at checkpoints. Without a limit, a WAL
487 // grown once by a single large transaction stays that size for the life
488 // of the file — permanently occupying volume the watermark is trying to
489 // protect. The batched retention deletes keep transactions small now, so
490 // in practice the WAL should rarely approach this; the limit is what
491 // makes that a guarantee rather than a hope.
492 opts = opts.pragma("journal_size_limit", WAL_SIZE_LIMIT_BYTES.to_string());
493 }
494 // Under a concurrent write burst (the poller's insert_entries tx racing the
495 // web layer's mark_read / redeem_code tx) SQLite would otherwise return
496 // SQLITE_BUSY the instant a writer holds the lock. `busy_timeout` makes a
497 // blocked connection WAIT (retry) for up to this long before erroring, so
498 // short lock contention resolves transparently instead of surfacing a
499 // spurious failure. Mirrors the OAuth sidecar's `stores.ts`
500 // (`PRAGMA busy_timeout = 5000`). 5 s is comfortably above any single
501 // FeatherReader transaction.
502 opts = opts.busy_timeout(std::time::Duration::from_millis(5000));
503 // Quiet sqlx's per-statement query logging.
504 opts = opts.log_statements(tracing::log::LevelFilter::Debug);
505
506 let pool = SqlitePoolOptions::new()
507 // Keep at least one connection alive so an in-memory DB isn't dropped
508 // (each `:memory:` connection is a *separate* database otherwise).
509 .min_connections(1)
510 .max_connections(if is_memory { 1 } else { 5 })
511 .connect_with(opts)
512 .await
513 .with_context(|| format!("failed to open sqlite pool: {db_url}"))?;
514
515 init_schema(&pool).await?;
516 Ok(pool)
517}
518
519/// Run the idempotent schema creation. Split out so callers/tests can (re)apply
520/// it against an already-open pool.
521pub async fn init_schema(pool: &SqlitePool) -> Result<()> {
522 // `execute` runs the multi-statement batch (sqlite allows this).
523 sqlx::query(SCHEMA)
524 .execute(pool)
525 .await
526 .context("failed to create schema")?;
527 apply_migrations(pool).await?;
528 // The Rust OAuth client's tables live in the same database. Created
529 // UNCONDITIONALLY, not only when that backend is selected: the tables are
530 // empty and harmless under the sidecar, whereas creating them lazily would
531 // make the first request after a cutover flip fail with "no such table" --
532 // at the one moment nobody wants to discover a migration was missed.
533 crate::oauth::store::init_schema(pool)
534 .await
535 .context("failed to create the OAuth schema")?;
536 Ok(())
537}
538
539/// Apply additive, idempotent migrations to bring an EXISTING database up to the
540/// current [`SCHEMA`]. `CREATE TABLE IF NOT EXISTS` never alters a table that
541/// already exists, so a column added to a shipped table must be back-filled here
542/// (SQLite has no `ADD COLUMN IF NOT EXISTS`, so we probe `table_info` first).
543async fn apply_migrations(pool: &SqlitePool) -> Result<()> {
544 // feeds.consecutive_errors — drives the exponential poll backoff. Older DBs
545 // predate the column; add it (defaulting to 0) if it is missing.
546 ensure_column(
547 pool,
548 "PRAGMA table_info(feeds)",
549 "consecutive_errors",
550 "ALTER TABLE feeds ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0",
551 )
552 .await?;
553 // feeds.last_error_kind / feeds.last_error — WHY a feed is failing, not just
554 // how often. `consecutive_errors` recorded a count and nothing else, which is
555 // how a systematic defect across sixty feeds stayed indistinguishable from
556 // sixty dead blogs until #159: every one of them was our own 304 handling,
557 // and the table could not say so. Nullable, and NULL once a poll succeeds.
558 ensure_column(
559 pool,
560 "PRAGMA table_info(feeds)",
561 "last_error_kind",
562 "ALTER TABLE feeds ADD COLUMN last_error_kind TEXT",
563 )
564 .await?;
565 ensure_column(
566 pool,
567 "PRAGMA table_info(feeds)",
568 "last_error",
569 "ALTER TABLE feeds ADD COLUMN last_error TEXT",
570 )
571 .await?;
572
573 // feeds.kind — what the poller does with a row. Older DBs predate it and
574 // get `'rss'` from the DEFAULT, which is wrong for the at:// rows, so it is
575 // back-filled below.
576 ensure_column(
577 pool,
578 "PRAGMA table_info(feeds)",
579 "kind",
580 "ALTER TABLE feeds ADD COLUMN kind TEXT NOT NULL DEFAULT 'rss'",
581 )
582 .await?;
583
584 // **Re-derived in Rust, every row, every start — not translated once.**
585 //
586 // `kind` is a pure function of `url`, so it is a cache, and a cache that is
587 // only ever written forward goes stale the moment the function changes.
588 // The first version of this was a one-directional SQL `UPDATE` carrying its
589 // own copy of the rule as a string predicate: it agreed with
590 // `FeedKind::of` on the day it was written, translated `rss` to
591 // `publication` and never the reverse, and had no way to notice either
592 // fact. Asking the Rust classifier about every row instead means the column
593 // cannot disagree with the one function that defines it, and a future kind
594 // — or a corrected rule — needs no migration of its own.
595 //
596 // Cheap by shape, not by assumption: it writes only rows that are actually
597 // wrong, so the steady state is a single scan of a table that holds one row
598 // per subscribed feed.
599 let rows = sqlx::query("SELECT id, url, kind FROM feeds")
600 .fetch_all(pool)
601 .await
602 .context("reading feeds to re-derive kind")?;
603 let mut tx = pool.begin().await.context("begin kind re-derivation")?;
604 let (mut to_pollable, mut to_unpollable, mut unreadable) = (0u64, 0u64, 0u64);
605 for row in rows {
606 // **A row we cannot read is skipped, not fatal.** This runs on the boot
607 // path, so anything that returns `Err` here is the difference between a
608 // wedged poller and a site that will not start. A `url` or `kind` that
609 // is not decodable as text takes no opinion from us and keeps whatever
610 // it has; every reader downstream already treats an unknown kind as
611 // unpollable. Nothing sqlx writes produces such a row — it binds `&str`
612 // as TEXT everywhere — so reaching this means the file was edited by
613 // hand, which is exactly when refusing to boot is the least helpful
614 // thing to do.
615 let (Ok(id), Ok(url), Ok(kind)) = (
616 row.try_get::<i64, _>("id"),
617 row.try_get::<String, _>("url"),
618 row.try_get::<String, _>("kind"),
619 ) else {
620 unreadable += 1;
621 continue;
622 };
623 let want = crate::feed::FeedKind::of(&url);
624 if kind == want.as_str() {
625 continue;
626 }
627 sqlx::query("UPDATE feeds SET kind = ?1 WHERE id = ?2")
628 .bind(want.as_str())
629 .bind(id)
630 .execute(&mut *tx)
631 .await
632 .with_context(|| format!("re-deriving kind for feed {id}"))?;
633 if crate::feed::FeedKind::POLLABLE.contains(&want) {
634 to_pollable += 1;
635 } else {
636 // **Declaring a row unpollable orphans its poll state, so clear
637 // it.** A backoff horizon and an error count belong to a feed the
638 // scheduler selects; on a row it will never select again they are
639 // dead, and not inert. They are hidden from `/stats` and the cause
640 // histogram, which filter on kind, so they rot unseen — and if a
641 // later rule change makes the row pollable again it resumes at
642 // `backoff_for(n)` on an `n` earned under a classification that no
643 // longer applies, which for seven prior errors is a first retry ten
644 // hours out instead of five minutes.
645 //
646 // Narrower than the step below, deliberately: that one clears only
647 // rows we never polled, on the grounds that a real feed's history
648 // still means something. This clears rows whose history can no
649 // longer mean anything, because nothing will add to it or act on
650 // it.
651 sqlx::query(
652 "UPDATE feeds SET consecutive_errors = 0, last_error_kind = NULL, \
653 last_error = NULL, next_poll = NULL WHERE id = ?1",
654 )
655 .bind(id)
656 .execute(&mut *tx)
657 .await
658 .with_context(|| format!("clearing orphaned poll state for feed {id}"))?;
659 to_unpollable += 1;
660 }
661 }
662 tx.commit().await.context("commit kind re-derivation")?;
663 // Quiet in the steady state, which is every boot where nothing changed.
664 // Split by direction because the two mean opposite things to an operator:
665 // one puts feeds back in the poller's queue, the other takes them out of
666 // every figure `/stats` reports.
667 if to_pollable > 0 || to_unpollable > 0 {
668 tracing::info!(
669 to_pollable,
670 to_unpollable,
671 "feeds.kind re-derived from the URL"
672 );
673 }
674 if unreadable > 0 {
675 tracing::warn!(
676 unreadable,
677 "feeds rows are not readable as text; their kind was left alone"
678 );
679 }
680
681 // **Clear failure counts on rows we never actually polled.**
682 //
683 // `due_feeds` excludes them by kind (see `feed::FeedKind`) — but rows
684 // subscribed before the scheme check already carry the errors OUR refusal
685 // produced. Left alone they would count as failing forever, since no poll
686 // that could clear them will ever be scheduled.
687 //
688 // A real feed's history is untouched: it still means something. The
689 // recorded reason goes with the count: a row with no errors must carry no
690 // reason, which is what `reset_feed_errors` promises and a test asserts.
691 //
692 // **Idempotent by predicate.** `last_polled` is set only by a successful
693 // poll — `bump_feed_errors` never touches it — so `last_polled IS NULL`
694 // selects exactly the rows whose every error came from our own refusal.
695 // A row a wired standard.site reader has fetched once keeps its later
696 // failures across restarts; a row that only ever failed under the refusal
697 // is cleared at every boot, including after a rollback to a build that
698 // polled it. A version stamp was the first design and left that rollback
699 // case a permanent hole (re-accumulated errors hidden by the filters,
700 // never cleared). Trade-off accepted: a publication that has never once
701 // succeeded restarts its backoff at the floor on every boot.
702 sqlx::query(sqlx::AssertSqlSafe(format!(
703 "UPDATE feeds SET consecutive_errors = 0, last_error_kind = NULL, last_error = NULL \
704 WHERE kind NOT IN ({POLLABLE_KINDS_SQL}) AND last_polled IS NULL \
705 AND consecutive_errors > 0"
706 )))
707 .execute(pool)
708 .await
709 .context("clearing error counts on unpollable at:// feeds")?;
710 // read_cursor.pds_created — tracks whether a feed's readState record has been
711 // created in the PDS, so the first flush emits a `create` (not a bare
712 // `update`, which errors on a not-yet-existing record). Older DBs predate it.
713 ensure_column(
714 pool,
715 "PRAGMA table_info(read_cursor)",
716 "pds_created",
717 "ALTER TABLE read_cursor ADD COLUMN pds_created INTEGER NOT NULL DEFAULT 0",
718 )
719 .await?;
720 // invite_codes.intended_did — the follower DID a bot claim was minted for, the
721 // server-side idempotency key for `POST /bot/claims`. Older DBs (before the
722 // follow→invite bot) predate it; it is nullable (browser/admin-minted codes
723 // leave it NULL).
724 ensure_column(
725 pool,
726 "PRAGMA table_info(invite_codes)",
727 "intended_did",
728 "ALTER TABLE invite_codes ADD COLUMN intended_did TEXT",
729 )
730 .await?;
731 // Indexes on `intended_did` are created HERE (not in the base SCHEMA batch)
732 // because they reference a column that only exists after the migration above.
733 // On an existing pre-0.2.2 DB the `invite_codes` CREATE TABLE is a no-op, so
734 // an index on `intended_did` in SCHEMA would fail before this migration ran
735 // (that was blocker B1). All are `IF NOT EXISTS`, so re-running is a no-op.
736 //
737 // Look up an outstanding active claim by the DID it was minted for (bot dedupe).
738 sqlx::query(
739 "CREATE INDEX IF NOT EXISTS idx_invite_codes_intended \
740 ON invite_codes (intended_did, status)",
741 )
742 .execute(pool)
743 .await
744 .context("creating idx_invite_codes_intended")?;
745 // Enforce at MOST one outstanding active claim per intended DID. This makes
746 // the bot's dedupe check-then-mint race-safe: two concurrent `POST /bot/claims`
747 // for the same follower can no longer both insert an active code (the second
748 // INSERT hits this unique constraint). Partial so it only constrains active
749 // bot-minted rows — redeemed/expired rows and NULL-intended (admin/browser)
750 // codes are unconstrained. (Blocker/should-fix S4.)
751 sqlx::query(
752 "CREATE UNIQUE INDEX IF NOT EXISTS idx_invite_codes_intended_active \
753 ON invite_codes (intended_did) \
754 WHERE intended_did IS NOT NULL AND status = 'active'",
755 )
756 .execute(pool)
757 .await
758 .context("creating idx_invite_codes_intended_active")?;
759 Ok(())
760}
761
762/// Add a column via `alter_sql` iff `info_sql` (a `PRAGMA table_info(<table>)`)
763/// does not already report `column`. All three SQL args are hard-coded internal
764/// literals (never user input), so they are safe `&'static str`s — the table name
765/// can't be a bind parameter in `PRAGMA`, which is why they're passed whole.
766async fn ensure_column(
767 pool: &SqlitePool,
768 info_sql: &'static str,
769 column: &str,
770 alter_sql: &'static str,
771) -> Result<()> {
772 let rows = sqlx::query(info_sql)
773 .fetch_all(pool)
774 .await
775 .with_context(|| format!("{info_sql} failed"))?;
776 let present = rows.iter().any(|r| r.get::<String, _>("name") == column);
777 if !present {
778 sqlx::query(alter_sql)
779 .execute(pool)
780 .await
781 .with_context(|| format!("adding column {column} via {alter_sql}"))?;
782 }
783 Ok(())
784}
785
786/// Insert a feed by URL, or update its metadata if the URL already exists.
787/// Returns the feed's row id (existing or newly assigned).
788///
789/// EVERY updatable column is COALESCE'd, so `None` means "leave alone" for all
790/// of them and a partial upsert cannot clobber a field it never mentioned.
791///
792/// `etag`/`last_modified` were the exception until now, and the exception was
793/// silently disabling conditional GET for the entire instance. `set_next_poll`
794/// in the scheduler supplies only `url` + `next_poll` after every single poll,
795/// which wrote both validators back to NULL — so `304 Not Modified` was
796/// unreachable and every feed was re-downloaded, re-parsed, re-sanitised and
797/// re-inserted in full, hourly, forever. `feed::touch_polled` had discovered the
798/// same trap earlier and worked around it in its own caller by re-reading the
799/// row first; that local fix is what let the next caller walk into it.
800///
801/// A stale validator is not a hazard: if the origin no longer issues one it
802/// ignores our `If-None-Match` and returns `200`, and if it still matches then
803/// `304` was the correct answer anyway.
804pub async fn upsert_feed(pool: &SqlitePool, feed: &NewFeed) -> Result<i64> {
805 let row = sqlx::query(
806 r#"
807 INSERT INTO feeds (url, title, site_url, etag, last_modified, last_polled, next_poll, kind)
808 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
809 ON CONFLICT (url) DO UPDATE SET
810 title = COALESCE(excluded.title, feeds.title),
811 site_url = COALESCE(excluded.site_url, feeds.site_url),
812 etag = COALESCE(excluded.etag, feeds.etag),
813 last_modified = COALESCE(excluded.last_modified, feeds.last_modified),
814 last_polled = COALESCE(excluded.last_polled, feeds.last_polled),
815 next_poll = COALESCE(excluded.next_poll, feeds.next_poll),
816 -- Not COALESCE: `kind` is derived from the URL, and `excluded`
817 -- always carries the current answer. Preserving the stored value
818 -- would make a row's classification a function of when it was
819 -- first subscribed rather than of what it is.
820 kind = excluded.kind
821 RETURNING id
822 "#,
823 )
824 .bind(&feed.url)
825 .bind(&feed.title)
826 .bind(&feed.site_url)
827 .bind(&feed.etag)
828 .bind(&feed.last_modified)
829 .bind(&feed.last_polled)
830 .bind(&feed.next_poll)
831 // Decided once, in Rust, and never re-derived from the URL by SQL.
832 .bind(crate::feed::FeedKind::of(&feed.url).as_str())
833 .fetch_one(pool)
834 .await
835 .with_context(|| format!("upsert_feed failed for {}", feed.url))?;
836
837 Ok(row.get::<i64, _>("id"))
838}
839
840/// Fetch a feed by its URL, if present.
841pub async fn get_feed_by_url(pool: &SqlitePool, url: &str) -> Result<Option<Feed>> {
842 let feed = sqlx::query_as::<_, Feed>("SELECT * FROM feeds WHERE url = ?1")
843 .bind(url)
844 .fetch_optional(pool)
845 .await
846 .with_context(|| format!("get_feed_by_url failed for {url}"))?;
847 Ok(feed)
848}
849
850/// The `kind` values the scheduler may select, as a SQL list.
851///
852/// Pinned against [`crate::feed::FeedKind::POLLABLE`] by
853/// `the_sql_kind_list_matches_the_rust_one` — a literal here and a slice there
854/// is exactly the drift the column was introduced to end, so the two are
855/// asserted equal rather than trusted. Wiring the standard.site reader means
856/// changing both, and that test is what makes forgetting one a failure.
857pub(crate) const POLLABLE_KINDS_SQL: &str = "'rss'";
858
859/// The `kind` values the retention **window** applies to, as a SQL list.
860///
861/// Pinned against [`crate::feed::FeedKind::AGED`] by
862/// `the_sql_aged_kind_list_matches_the_rust_one`, for the same reason
863/// [`POLLABLE_KINDS_SQL`] is pinned against `POLLABLE`.
864///
865/// Why a publication is not in it: see `FeedKind::AGED`. Measured — a 14-day
866/// window stored zero rows from every real publication tried, because their
867/// newest documents were 109 to 241 days old.
868pub(crate) const AGED_KINDS_SQL: &str = "'rss'";
869
870/// How many rows the poller will never select — the capacity consumed by feeds
871/// that cannot be fetched.
872///
873/// Rendered on `/admin/metrics` because the global ceiling counts these rows
874/// (see [`count_feeds`]) while `/stats` does not, so without this the cap could
875/// be reached with every public number saying otherwise.
876pub async fn unpollable_feeds(pool: &SqlitePool) -> Result<i64> {
877 sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
878 "SELECT COUNT(*) FROM feeds WHERE kind NOT IN ({POLLABLE_KINDS_SQL})"
879 )))
880 .fetch_one(pool)
881 .await
882 .context("counting unpollable feeds")
883}
884
885/// How many rows the poller will never select. Test-only: the assertion the
886/// at:// tests make, spelled once, against the predicate the code uses.
887#[cfg(test)]
888pub(crate) async fn count_unpollable_feeds(pool: &SqlitePool) -> Result<i64> {
889 sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
890 "SELECT COUNT(*) FROM feeds WHERE kind NOT IN ({POLLABLE_KINDS_SQL})"
891 )))
892 .fetch_one(pool)
893 .await
894 .context("counting unpollable feeds")
895}
896
897/// The scheduler's hot query: feeds whose `next_poll` is due (`<= as_of`, or
898/// never polled), oldest-due first. `as_of` is an RFC3339 timestamp.
899pub async fn due_feeds(pool: &SqlitePool, as_of: &str, limit: i64) -> Result<Vec<Feed>> {
900 let sql = format!(
901 r#"
902 SELECT * FROM feeds
903 WHERE (next_poll IS NULL OR next_poll <= ?1)
904 -- `at://` is not pollable, so it is not due: skipped, not failed.
905 -- The why lives on `feed::FeedKind::POLLABLE`.
906 AND kind IN ({POLLABLE_KINDS_SQL})
907 ORDER BY next_poll IS NOT NULL, next_poll ASC
908 LIMIT ?2
909 "#
910 );
911 let feeds = sqlx::query_as::<_, Feed>(sqlx::AssertSqlSafe(sql))
912 .bind(as_of)
913 .bind(limit)
914 .fetch_all(pool)
915 .await
916 .context("due_feeds failed")?;
917 Ok(feeds)
918}
919
920/// One failing feed, named, for the ADMIN view only.
921///
922/// The public `/stats` histogram is counts by cause and nothing else, by that
923/// page's own stated promise. This is the other half: the coarse bucket
924/// `fetch` covers DNS failure, timeout, SSRF refusal and — as #159 proved —
925/// this reader's own bugs, so a count alone cannot separate "the publishers are
926/// gone" from "we are broken". The detail can, and it lives behind the
927/// `ALLOWED_DIDS` gate where per-feed data is already permitted.
928#[derive(Debug, Clone, PartialEq, Eq)]
929pub struct FailingFeed {
930 pub url: String,
931 pub consecutive_errors: i64,
932 /// `None` for a row that predates the column — see the `unknown` bucket.
933 pub kind: Option<String>,
934 pub detail: Option<String>,
935}
936
937/// Every currently-failing feed with its recorded cause, worst first.
938///
939/// **Admin-gated callers only.** Bounded because this renders into one response
940/// and a large instance should not be able to make that response unbounded.
941pub async fn failing_feeds(pool: &SqlitePool, limit: i64) -> Result<Vec<FailingFeed>> {
942 // The same exclusion as `poll_health`: a row the poller never selects
943 // can never have its errors cleared, so listing it here would pin it to
944 // the top of the operator's page for good.
945 let sql = format!(
946 r#"
947 SELECT url, consecutive_errors, last_error_kind, last_error
948 FROM feeds
949 WHERE consecutive_errors > 0 AND kind IN ({POLLABLE_KINDS_SQL})
950 ORDER BY consecutive_errors DESC, url ASC
951 LIMIT ?1
952 "#
953 );
954 let rows: Vec<(String, i64, Option<String>, Option<String>)> =
955 sqlx::query_as(sqlx::AssertSqlSafe(sql))
956 .bind(limit)
957 .fetch_all(pool)
958 .await
959 .context("listing failing feeds")?;
960 Ok(rows
961 .into_iter()
962 .map(|(url, consecutive_errors, kind, detail)| FailingFeed {
963 url,
964 consecutive_errors,
965 kind,
966 detail,
967 })
968 .collect())
969}
970
971/// Cap on the stored `last_error` detail. Remote text on an unattended path.
972const MAX_ERROR_DETAIL_CHARS: usize = 300;
973
974/// Record a poll FAILURE for a feed: bump its `consecutive_errors` by one and
975/// return the NEW count. The count drives the exponential poll backoff, so a
976/// persistently-failing feed spaces its retries out toward the ceiling instead of
977/// hammering the 5-minute floor forever. Reset to 0 by [`reset_feed_errors`] on
978/// any success/304.
979pub async fn bump_feed_errors(
980 pool: &SqlitePool,
981 url: &str,
982 kind: crate::feed::FailureKind,
983 detail: &str,
984) -> Result<i64> {
985 let row = sqlx::query(
986 "UPDATE feeds SET consecutive_errors = consecutive_errors + 1, \
987 last_error_kind = ?2, last_error = ?3 \
988 WHERE url = ?1 RETURNING consecutive_errors",
989 )
990 .bind(url)
991 .bind(kind.as_str())
992 // **Truncated.** This is a remote server's error text on an unattended path;
993 // an upstream that returns a megabyte of prose should cost a bounded row,
994 // not an unbounded one.
995 .bind(
996 detail
997 .chars()
998 .take(MAX_ERROR_DETAIL_CHARS)
999 .collect::<String>(),
1000 )
1001 .fetch_optional(pool)
1002 .await
1003 .with_context(|| format!("bump_feed_errors failed for {url}"))?;
1004 // If the feed row somehow vanished, treat it as the first error.
1005 Ok(row
1006 .map(|r| r.get::<i64, _>("consecutive_errors"))
1007 .unwrap_or(1))
1008}
1009
1010/// Schedule a feed's next poll `delay` from now.
1011///
1012/// Lived as a private fn in the scheduler until `web::add_subscription`
1013/// needed it too: a poll taken off the scheduler settled the error columns but
1014/// never rescheduled, so a re-subscribed working feed stayed parked on its stale
1015/// backoff horizon for up to 24h. One implementation, two callers.
1016///
1017/// `upsert_feed` COALESCEs unset fields, so supplying only url + next_poll bumps
1018/// the schedule without clobbering title/validators/last_polled.
1019pub async fn set_next_poll(pool: &SqlitePool, url: &str, delay: std::time::Duration) -> Result<()> {
1020 let next = chrono::Utc::now()
1021 + chrono::Duration::from_std(delay).unwrap_or_else(|_| chrono::Duration::hours(1));
1022 let next_poll = next.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
1023 let nf = NewFeed {
1024 url: url.to_string(),
1025 next_poll: Some(next_poll),
1026 ..Default::default()
1027 };
1028 upsert_feed(pool, &nf).await.map(|_| ())
1029}
1030
1031/// Reset a feed's `consecutive_errors` to 0 after a successful poll (or a 304).
1032/// A no-op UPDATE if the row is missing.
1033pub async fn reset_feed_errors(pool: &SqlitePool, url: &str) -> Result<()> {
1034 // **Clears the reason too.** A stale `last_error` on a feed that is now
1035 // succeeding is worse than none: it is the aggregate below reporting a cause
1036 // that stopped applying, which is the failure this column exists to end.
1037 sqlx::query(
1038 "UPDATE feeds SET consecutive_errors = 0, last_error_kind = NULL, last_error = NULL \
1039 WHERE url = ?1",
1040 )
1041 .bind(url)
1042 .execute(pool)
1043 .await
1044 .with_context(|| format!("reset_feed_errors failed for {url}"))?;
1045 Ok(())
1046}
1047
1048/// The feeds a `did` currently subscribes to, per its `sub_ref` projection.
1049/// Used by the PDS-unreachable fallback in `resolve_subscriptions` to render
1050/// the sidebar from the caller's OWN last-known subscriptions (fail closed)
1051/// rather than every cached feed.
1052pub async fn feeds_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Feed>> {
1053 let feeds = sqlx::query_as::<_, Feed>(
1054 r#"
1055 SELECT f.* FROM feeds f
1056 JOIN sub_ref sr ON sr.feed_id = f.id AND sr.did = ?1
1057 ORDER BY f.title IS NULL, f.title, f.url
1058 "#,
1059 )
1060 .bind(did)
1061 .fetch_all(pool)
1062 .await
1063 .with_context(|| format!("feeds_for_did failed for {did}"))?;
1064 Ok(feeds)
1065}
1066
1067/// The feed ids a `did` currently subscribes to (its `sub_ref` rows).
1068///
1069/// **Not bounded by `max_subs_per_did`.** This comment used to claim it was, and
1070/// callers leaned on that: the cap is enforced on the ADD and OPML paths only,
1071/// never on read, and `sub_ref` is rebuilt from whatever the PDS returns — which
1072/// any client can write to, bounded only by the list-pages ceiling at 20,000
1073/// records. A claim in a comment is not a bound.
1074///
1075/// Callers must therefore not assume a small result. The one that cared — the
1076/// list views' scope filter — no longer does: it passes the whole set as a
1077/// single `json_each` bind rather than one SQL placeholder per feed.
1078pub async fn subscribed_feed_ids(pool: &SqlitePool, did: &str) -> Result<Vec<i64>> {
1079 let ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
1080 .bind(did)
1081 .fetch_all(pool)
1082 .await
1083 .with_context(|| format!("subscribed_feed_ids failed for {did}"))?;
1084 Ok(ids)
1085}
1086
1087/// The number of feeds a `did` currently subscribes to (its `sub_ref` rows).
1088/// Backs the per-DID subscription cap enforced at the add/import paths.
1089pub async fn count_subscriptions_for_did(pool: &SqlitePool, did: &str) -> Result<i64> {
1090 let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
1091 .bind(did)
1092 .fetch_one(pool)
1093 .await
1094 .with_context(|| format!("count_subscriptions_for_did failed for {did}"))?;
1095 Ok(n)
1096}
1097
1098/// The number of distinct feeds in the shared cache. Backs the global feeds
1099/// ceiling checked before a brand-new feed is inserted.
1100pub async fn count_feeds(pool: &SqlitePool) -> Result<i64> {
1101 let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds")
1102 .fetch_one(pool)
1103 .await
1104 .context("count_feeds failed")?;
1105 Ok(n)
1106}
1107
1108/// The **used** size of the SQLite database, in bytes, computed as
1109/// `(page_count - freelist_count) * page_size`. Backs the DB-size watermark that
1110/// disables new polling.
1111///
1112/// Subtracting the freelist is what keeps the watermark from latching the poller
1113/// off: `page_count` counts pages the file has *allocated*, including ones freed
1114/// by a `DELETE` but not yet returned to the OS (SQLite keeps them on a freelist
1115/// for reuse and never shrinks the file without a VACUUM). Counting only the
1116/// live pages means a retention prune (which frees pages, see [`reclaim`]) is
1117/// actually reflected here, so the watermark can drop back below its threshold
1118/// and polling resumes. Cheap (three `PRAGMA` reads); works for file + `:memory:`.
1119///
1120/// **The WAL counts too.** This is the number the DB-size watermark compares
1121/// against a VOLUME size, and in WAL mode the `-wal` sidecar sits on that same
1122/// volume — so leaving it out understated exactly the quantity the watermark
1123/// exists to bound. It is added back below, best-effort: a WAL that cannot be
1124/// stat'd contributes zero rather than failing the check, since a watermark that
1125/// errors is worse than one that is slightly optimistic.
1126pub async fn db_size_bytes(pool: &SqlitePool) -> Result<i64> {
1127 let page_count: i64 = sqlx::query_scalar("PRAGMA page_count")
1128 .fetch_one(pool)
1129 .await
1130 .context("PRAGMA page_count failed")?;
1131 let freelist_count: i64 = sqlx::query_scalar("PRAGMA freelist_count")
1132 .fetch_one(pool)
1133 .await
1134 .context("PRAGMA freelist_count failed")?;
1135 let page_size: i64 = sqlx::query_scalar("PRAGMA page_size")
1136 .fetch_one(pool)
1137 .await
1138 .context("PRAGMA page_size failed")?;
1139 let used_pages = page_count.saturating_sub(freelist_count).max(0);
1140 Ok(used_pages
1141 .saturating_mul(page_size)
1142 .saturating_add(wal_bytes(pool).await))
1143}
1144
1145/// Bytes the write-ahead log currently occupies on the database's volume, or 0
1146/// when there is no WAL (`:memory:`, non-WAL journal modes) or it cannot be
1147/// stat'd. Best-effort by design — see [`db_size_bytes`].
1148async fn wal_bytes(pool: &SqlitePool) -> i64 {
1149 let Some(path) = main_db_path(pool).await else {
1150 return 0;
1151 };
1152 std::fs::metadata(format!("{path}-wal"))
1153 .map(|m| i64::try_from(m.len()).unwrap_or(i64::MAX))
1154 .unwrap_or(0)
1155}
1156
1157/// The main database's file path, or `None` for `:memory:`.
1158async fn main_db_path(pool: &SqlitePool) -> Option<String> {
1159 sqlx::query_scalar("SELECT file FROM pragma_database_list WHERE name = 'main' AND file <> ''")
1160 .fetch_optional(pool)
1161 .await
1162 .ok()
1163 .flatten()
1164}
1165
1166/// Freelist pages returned to the OS per `incremental_vacuum` step. At a 4 KiB
1167/// page that is ~8 MiB per batch — a short lock hold, and few enough steps that
1168/// a large reclaim is tens of statements rather than thousands.
1169const RECLAIM_BATCH_PAGES: i64 = 2_000;
1170
1171/// Backstop on the reclaim loop. `freelist_count == 0` and the no-progress check
1172/// are the real terminators; at [`RECLAIM_BATCH_PAGES`] this is 2M pages (~8 GiB),
1173/// far past anything a 1 GB volume holds.
1174const RECLAIM_MAX_BATCHES: usize = 1_000;
1175
1176/// Reclaim freed pages so the database file (and its used-page accounting) can
1177/// actually shrink after a retention/prune sweep DELETEs rows.
1178///
1179/// Without this, a `DELETE` moves pages onto the freelist but never shrinks the
1180/// file — so once the DB-size watermark trips and retention deletes rows,
1181/// `page_count` stays put and [`db_size_bytes`] (well, its raw `page_count`
1182/// form) would never fall back below the watermark, latching the poller off
1183/// forever. Call this AFTER a prune. It uses incremental vacuum when the database
1184/// is in `auto_vacuum = INCREMENTAL` mode (cheap, no full rewrite), and otherwise
1185/// falls back to a full `VACUUM`.
1186pub async fn reclaim(pool: &SqlitePool) -> Result<()> {
1187 match auto_vacuum_mode(pool).await? {
1188 AutoVacuum::Incremental => {
1189 // **Bounded, like the deletes that precede it.**
1190 //
1191 // With no page argument this reclaims the ENTIRE freelist in one
1192 // transaction — handing straight back the write-lock hold that
1193 // batching the retention deletes had just won, immediately after the
1194 // sweep that created the freelist in the first place. Same shape as
1195 // `delete_in_batches`: a bounded unit of work, then an explicit
1196 // hand-off so a waiting writer actually gets in.
1197 // **Both early exits are LOUD.** Failing to reclaim is the failure
1198 // this function exists to prevent: `db_size_bytes` stays high,
1199 // `poll_due_once` keeps polling paused, and `/stats` says "paused"
1200 // with nothing anywhere saying reclaim gave up. Exiting silently
1201 // makes that indistinguishable from a sweep that had nothing to do.
1202 let mut drained = true;
1203 for batch in 0..RECLAIM_MAX_BATCHES {
1204 let before: i64 = sqlx::query_scalar("PRAGMA freelist_count")
1205 .fetch_one(pool)
1206 .await
1207 .context("PRAGMA freelist_count failed")?;
1208 if before == 0 {
1209 break;
1210 }
1211 // A PRAGMA argument cannot be a bind parameter, and this one is
1212 // a `const i64` declared in this file — nothing external reaches
1213 // it.
1214 sqlx::query(sqlx::AssertSqlSafe(format!(
1215 "PRAGMA incremental_vacuum({RECLAIM_BATCH_PAGES})"
1216 )))
1217 .execute(pool)
1218 .await
1219 .context("PRAGMA incremental_vacuum failed")?;
1220 let after: i64 = sqlx::query_scalar("PRAGMA freelist_count")
1221 .fetch_one(pool)
1222 .await
1223 .context("PRAGMA freelist_count failed")?;
1224 // No progress: either nothing more can be freed, or a
1225 // concurrent retention delete pushed `after` back up. Both leave
1226 // pages allocated, which is what an operator needs to know.
1227 //
1228 // This comment previously also claimed "a long-lived WAL read
1229 // snapshot pins freelist pages". MEASURED AND FALSE: with a
1230 // reader holding a snapshot taken BEFORE the delete, the
1231 // freelist still drained 2000 → 0 and `page_count` halved. A
1232 // reader blocks the CHECKPOINT, not the incremental vacuum — so
1233 // that case exits this loop through the SUCCESS path and is
1234 // reported below, not here.
1235 if after >= before {
1236 tracing::warn!(
1237 freelist_pages = after,
1238 batches_run = batch + 1,
1239 "reclaim stopped making progress with pages still on the \
1240 freelist; the file will not shrink and the DB-size watermark \
1241 may stay engaged until the next sweep"
1242 );
1243 drained = false;
1244 break;
1245 }
1246 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1247 // `after > 0` matters: the final batch can drain the freelist
1248 // completely, in which case the loop reaches here having
1249 // SUCCEEDED and would otherwise log "with pages still on the
1250 // freelist" for an empty one — and suppress the success line.
1251 // This is the same guard `delete_in_batches` carries, and the
1252 // same defect it already had; reproduced here verbatim by
1253 // copying the loop's shape without its condition.
1254 if batch + 1 == RECLAIM_MAX_BATCHES && after > 0 {
1255 tracing::warn!(
1256 batches_run = batch + 1,
1257 freelist_pages = after,
1258 "reclaim hit its batch backstop with pages still on the \
1259 freelist; the rest waits for the next sweep"
1260 );
1261 drained = false;
1262 }
1263 }
1264 if drained {
1265 tracing::debug!("reclaim: freelist drained");
1266 }
1267 }
1268 // SQLite already returns freed pages at every commit in this mode.
1269 // Nothing to do, and a VACUUM would be pure cost.
1270 AutoVacuum::Full => {}
1271 // **Deliberately a no-op, where this used to run a full VACUUM.**
1272 //
1273 // Nothing ever set `auto_vacuum`, so NONE was the mode every database
1274 // actually ran in — which made the full-VACUUM branch the one that
1275 // always executed, daily and after every prune. A full VACUUM writes a
1276 // complete second copy of the database, so it needs free disk roughly
1277 // equal to the live file; that is precisely what is missing under the
1278 // disk pressure that triggers a retention sweep. `poll_due_once` already
1279 // carries a comment explaining this danger and removed VACUUM from the
1280 // poll path — while leaving it in the retention path that runs under the
1281 // same pressure.
1282 //
1283 // Skipping it does NOT latch the DB-size watermark, which is the failure
1284 // this branch was written to prevent: `db_size_bytes` subtracts the
1285 // freelist, so a DELETE lowers the measured size with no VACUUM at all.
1286 // What is lost is the FILE shrinking, and the fix for that is to get the
1287 // database into INCREMENTAL mode — see `migrate_to_incremental_vacuum`,
1288 // which is operator-invoked precisely because it needs the one operation
1289 // that is unsafe to attempt automatically.
1290 AutoVacuum::None => {
1291 tracing::warn!(
1292 "auto_vacuum=NONE: skipping reclaim. Freed pages stay allocated and \
1293 the file will not shrink. Run `featherreader --migrate-auto-vacuum` \
1294 once, while the volume has headroom, to move this database to \
1295 INCREMENTAL mode."
1296 );
1297 }
1298 }
1299
1300 // Truncate the WAL as well. It lives on the same volume and is counted by
1301 // `db_size_bytes`, so reclaiming database pages while leaving a WAL grown by
1302 // the sweep that just ran would give back part of the space and hold the
1303 // rest. Worth doing even in the NONE branch above, where it is the only
1304 // space this function can return at all.
1305 //
1306 // **A blocked checkpoint is the real way the file stays big, so it warns.**
1307 //
1308 // Measured: with a reader holding an open snapshot, `incremental_vacuum`
1309 // still drains the freelist and `page_count` halves — but the main file
1310 // stayed at 16.4 MB until the reader released and the checkpoint could
1311 // truncate it to 8.2 MB. So a reader does not stop the reclaim; it stops the
1312 // SHRINK. That is the operator-visible outcome (`db_size_bytes` counts the
1313 // WAL, and the watermark is compared against a volume), and it used to be
1314 // reported at `debug!` — below any realistic filter — while the loop above
1315 // warned loudly about a mechanism that does not actually occur.
1316 //
1317 // Not an error: the next sweep checkpoints again once the reader is gone.
1318 match checkpoint_wal(pool).await {
1319 Ok(true) => {}
1320 Ok(false) => tracing::warn!(
1321 "the WAL could not be truncated after reclaim (busy: a concurrent reader \
1322 OR writer held it); the freed pages are gone but the file has not \
1323 shrunk yet, and the DB-size watermark may stay engaged until the next \
1324 sweep"
1325 ),
1326 Err(err) => tracing::warn!(%err, "wal checkpoint after reclaim failed"),
1327 }
1328 Ok(())
1329}
1330
1331/// Run a truncating WAL checkpoint. `Ok(false)` means SQLite declined because a
1332/// reader held the WAL.
1333///
1334/// **The busy case is a ROW, not an error.** `PRAGMA wal_checkpoint` returns
1335/// `(busy, log_frames, checkpointed_frames)` and sets `busy = 1` when it could
1336/// not run — measured: `(1, 3, 3)` with one open read transaction versus
1337/// `(0, 0, 0)` without. So `if let Err(..)` never fires on the case it was
1338/// written for, and a caller that depends on the WAL actually being truncated
1339/// (the migration's size report does) would silently get the untruncated one.
1340async fn checkpoint_wal<'e, E>(conn: E) -> Result<bool>
1341where
1342 E: sqlx::Executor<'e, Database = sqlx::Sqlite>,
1343{
1344 let row: (i64, i64, i64) = sqlx::query_as("PRAGMA wal_checkpoint(TRUNCATE)")
1345 .fetch_one(conn)
1346 .await
1347 .context("PRAGMA wal_checkpoint(TRUNCATE) failed")?;
1348 Ok(row.0 == 0)
1349}
1350
1351/// A database's `auto_vacuum` mode.
1352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1353pub enum AutoVacuum {
1354 /// 0 — freed pages stay on the freelist; only a full `VACUUM` returns them.
1355 None,
1356 /// 1 — SQLite returns freed pages at every commit.
1357 Full,
1358 /// 2 — freed pages are returned on demand by `PRAGMA incremental_vacuum`.
1359 Incremental,
1360}
1361
1362/// Read the database's `auto_vacuum` mode.
1363pub async fn auto_vacuum_mode(pool: &SqlitePool) -> Result<AutoVacuum> {
1364 let mode: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
1365 .fetch_one(pool)
1366 .await
1367 .context("PRAGMA auto_vacuum failed")?;
1368 Ok(match mode {
1369 1 => AutoVacuum::Full,
1370 2 => AutoVacuum::Incremental,
1371 _ => AutoVacuum::None,
1372 })
1373}
1374
1375/// What [`migrate_to_incremental_vacuum`] did.
1376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1377pub enum VacuumMigration {
1378 /// Already in a mode that reclaims; nothing was run.
1379 NotNeeded(AutoVacuum),
1380 /// Refused: not enough free space on the volume to hold the rebuilt file.
1381 ///
1382 /// `file_bytes` is the on-disk size, reported alongside the live-page figure
1383 /// the requirement is computed from, because on exactly this population
1384 /// (`NONE` mode, large freelist) the two differ a lot and only one of them
1385 /// matches what `ls -l` says.
1386 RefusedNoHeadroom {
1387 needed: u64,
1388 available: u64,
1389 file_bytes: Option<u64>,
1390 },
1391 /// Ran the pragma + full VACUUM; the database is now INCREMENTAL.
1392 Migrated {
1393 bytes_before: i64,
1394 bytes_after: i64,
1395 file_before: Option<u64>,
1396 file_after: Option<u64>,
1397 },
1398}
1399
1400/// Move a populated database from `auto_vacuum = NONE` to `INCREMENTAL`.
1401///
1402/// **Why this cannot happen at boot.** SQLite ignores `PRAGMA auto_vacuum` on a
1403/// database that already has tables unless it is followed by a full `VACUUM`,
1404/// which rebuilds the file. So the migration off the dangerous mode requires the
1405/// exact operation that is dangerous — a genuine chicken-and-egg, and the reason
1406/// this is an explicit operator step run when the volume has headroom rather
1407/// than something attempted lazily on a machine that is already under pressure.
1408///
1409/// Doing it automatically would also reintroduce the failure shape T2.1 just
1410/// removed: a boot-time VACUUM that cannot complete on a full volume, on a
1411/// supervisor that restarts the machine whenever a child exits, is a crash loop.
1412///
1413/// `available_bytes` is the caller's measurement of free space on the database's
1414/// volume (`None` where the platform cannot report it). The check is a refusal,
1415/// not a warning: starting a VACUUM that cannot finish wastes I/O on a box that
1416/// has none to spare. `VACUUM` itself is atomic — an interrupted one leaves the
1417/// original database intact — so the risk being managed here is wasted work and
1418/// a long write-lock hold, not corruption.
1419pub async fn migrate_to_incremental_vacuum(
1420 pool: &SqlitePool,
1421 available_bytes: Option<u64>,
1422) -> Result<VacuumMigration> {
1423 let mode = auto_vacuum_mode(pool).await?;
1424 if mode != AutoVacuum::None {
1425 return Ok(VacuumMigration::NotNeeded(mode));
1426 }
1427
1428 // **The on-disk file, not the live-page count.** `db_size_bytes` subtracts
1429 // the freelist, and the population this migration exists for is precisely
1430 // `auto_vacuum = NONE` with a large freelist — so the live size can be far
1431 // smaller than the file, and an operator comparing the refusal message to
1432 // `ls -l` would not trust either number. The rebuild is sized by the LIVE
1433 // pages (that is what gets copied), but the report shows both.
1434 let bytes_before = db_size_bytes(pool).await?;
1435 let file_before = main_db_file_bytes(pool).await;
1436 // Resolved BEFORE a connection is acquired below. Asking the pool for
1437 // anything while holding one of its connections deadlocks a saturated pool —
1438 // and a single-connection pool is always saturated. The first version of the
1439 // temp-directory block did exactly that, and because `main_db_path` swallows
1440 // errors into `None` it did not even fail loudly: it stalled for the full
1441 // acquire timeout and then silently skipped setting the directory, which is
1442 // the one thing it exists to do.
1443 let temp_dir = main_db_path(pool).await.and_then(|p| {
1444 std::path::Path::new(&p)
1445 .parent()
1446 .map(std::path::Path::to_path_buf)
1447 });
1448 let needed = (bytes_before.max(0) as u64).saturating_mul(2);
1449 if let Some(available) = available_bytes {
1450 if available < needed {
1451 return Ok(VacuumMigration::RefusedNoHeadroom {
1452 needed,
1453 available,
1454 file_bytes: file_before,
1455 });
1456 }
1457 }
1458
1459 // **One connection for both statements.**
1460 //
1461 // `PRAGMA auto_vacuum` on a populated database is connection-scoped INTENT
1462 // that only takes effect when the SAME connection runs the VACUUM. Issued
1463 // against the pool they can land on different connections, and the rebuild
1464 // then happens in NONE mode — caught by the `ensure!` below, so loud rather
1465 // than silent, but the operator has paid a whole-file rewrite for nothing on
1466 // a box chosen for being short of disk.
1467 let mut conn = pool
1468 .acquire()
1469 .await
1470 .context("acquiring a connection for the auto_vacuum migration")?;
1471
1472 // **Put the temp copy on the DATABASE's volume.**
1473 //
1474 // A VACUUM rebuilds through a temporary database, and the headroom check
1475 // above measures the data volume. `temp_store = FILE` alone only chooses
1476 // file-over-memory; it does NOT choose which filesystem, so the temp copy
1477 // resolved via `SQLITE_TMPDIR`/`TMPDIR`/`/var/tmp`/`/tmp` — the container
1478 // rootfs. The check could pass on `/data` and the VACUUM still hit
1479 // `SQLITE_FULL`, or fill the rootfs out from under Caddy.
1480 //
1481 // `temp_store_directory` is the pragma that actually decides — measured:
1482 // setting it alone moves the file, setting `temp_store = FILE` alone does
1483 // not. It is deprecated but fully functional in the bundled SQLite (3.51.3,
1484 // built without `SQLITE_OMIT_DEPRECATED`), and there is no non-deprecated
1485 // equivalent reachable from a connection.
1486 //
1487 // `temp_store = FILE` is kept as belt-and-braces rather than because it is
1488 // needed: this build's compile-time default is already FILE, but a build
1489 // defaulting to MEMORY would silently ignore the directory entirely.
1490 //
1491 // Note it sets the PROCESS-GLOBAL `sqlite3_temp_directory`, not connection
1492 // state — visible on other connections and other pools. Harmless because
1493 // this function is only reachable from the one-shot `--migrate-auto-vacuum`
1494 // CLI path, which does nothing else.
1495 sqlx::query("PRAGMA temp_store = FILE")
1496 .execute(&mut *conn)
1497 .await
1498 .context("PRAGMA temp_store = FILE failed")?;
1499 if let Some(dir) = temp_dir.clone() {
1500 // The path comes from SQLite's own `database_list`, not from a caller.
1501 let quoted = dir.display().to_string().replace('\'', "''");
1502 if let Err(err) = sqlx::query(sqlx::AssertSqlSafe(format!(
1503 "PRAGMA temp_store_directory = '{quoted}'"
1504 )))
1505 .execute(&mut *conn)
1506 .await
1507 {
1508 // Not fatal: the VACUUM can still succeed if the default temp
1509 // location happens to have room. But the headroom check is then
1510 // measuring the wrong filesystem, so say so.
1511 tracing::warn!(
1512 %err, dir = %dir.display(),
1513 "could not point SQLite's temp storage at the database volume; the \
1514 headroom check may not cover where the VACUUM actually writes"
1515 );
1516 }
1517 }
1518
1519 // Order matters: the pragma records the INTENT, and the VACUUM is what
1520 // actually rewrites the file in the new mode. Reversed, the VACUUM would
1521 // rebuild in NONE mode and the pragma would then be ignored again.
1522 sqlx::query("PRAGMA auto_vacuum = INCREMENTAL")
1523 .execute(&mut *conn)
1524 .await
1525 .context("PRAGMA auto_vacuum = INCREMENTAL failed")?;
1526 sqlx::query("VACUUM")
1527 .execute(&mut *conn)
1528 .await
1529 .context("VACUUM failed during the auto_vacuum migration")?;
1530
1531 // Fold the WAL back in BEFORE measuring. A VACUUM in WAL mode writes the
1532 // entire rebuilt database through the WAL, which keeps that high-water size
1533 // until a truncating checkpoint — and `db_size_bytes` now counts the WAL. So
1534 // the one number this command reports read as "the migration doubled my
1535 // database", which is the opposite of what it did.
1536 match checkpoint_wal(&mut *conn).await {
1537 Ok(true) => {}
1538 // Reported, because the size this function returns is computed straight
1539 // after and would otherwise read as "the migration doubled my database"
1540 // with nothing saying why.
1541 Ok(false) => tracing::warn!(
1542 "the WAL could not be truncated (a concurrent reader OR writer held it), \
1543 so the reported size below includes it"
1544 ),
1545 Err(err) => tracing::warn!(%err, "post-migration wal checkpoint failed"),
1546 }
1547
1548 // Verified on the HELD connection, then released before anything that goes
1549 // back to the pool. The test pool is single-connection, and so is a
1550 // production pool that happens to be saturated — reaching for a second one
1551 // while still holding the first is a deadlock waiting for a busy moment.
1552 let after_raw: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
1553 .fetch_one(&mut *conn)
1554 .await
1555 .context("PRAGMA auto_vacuum failed after the migration")?;
1556 drop(conn);
1557 let after = match after_raw {
1558 1 => AutoVacuum::Full,
1559 2 => AutoVacuum::Incremental,
1560 _ => AutoVacuum::None,
1561 };
1562 anyhow::ensure!(
1563 after == AutoVacuum::Incremental,
1564 "the auto_vacuum migration ran but the database is still in {after:?} mode"
1565 );
1566 Ok(VacuumMigration::Migrated {
1567 bytes_before,
1568 bytes_after: db_size_bytes(pool).await?,
1569 file_before,
1570 file_after: main_db_file_bytes(pool).await,
1571 })
1572}
1573
1574/// Size of the main database FILE on disk, or `None` for `:memory:` / an
1575/// unstattable path. Distinct from [`db_size_bytes`], which reports live pages.
1576async fn main_db_file_bytes(pool: &SqlitePool) -> Option<u64> {
1577 let path = main_db_path(pool).await?;
1578 std::fs::metadata(path).ok().map(|m| m.len())
1579}
1580
1581/// Insert a batch of entries for `feed_id`, deduping on `(feed_id, guid)`, then
1582/// trim the feed to at most [`crate::config`]-configured `max_entries_per_feed`
1583/// rows (newest by published date) so one firehose feed can't fill the disk.
1584///
1585/// On a GUID collision the existing entry is updated in place (title/url/body
1586/// may have changed on re-fetch) rather than duplicated. Runs in one
1587/// transaction. Returns the number of rows processed.
1588///
1589/// `max_entries_per_feed <= 0` disables the per-feed trim.
1590pub async fn insert_entries(
1591 pool: &SqlitePool,
1592 feed_id: i64,
1593 entries: &[NewEntry],
1594 max_entries_per_feed: i64,
1595) -> Result<u64> {
1596 let mut tx = pool.begin().await.context("begin insert_entries tx")?;
1597 let mut count: u64 = 0;
1598 for e in entries {
1599 let fetched_at = e.fetched_at.clone().unwrap_or_else(now_rfc3339);
1600 let res = sqlx::query(
1601 r#"
1602 INSERT INTO entries
1603 (feed_id, guid, url, title, author, published, content_html, fetched_at)
1604 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1605 ON CONFLICT (feed_id, guid) DO UPDATE SET
1606 url = excluded.url,
1607 title = excluded.title,
1608 author = excluded.author,
1609 published = excluded.published,
1610 content_html = excluded.content_html
1611 "#,
1612 )
1613 .bind(feed_id)
1614 .bind(&e.guid)
1615 .bind(&e.url)
1616 .bind(&e.title)
1617 .bind(&e.author)
1618 .bind(&e.published)
1619 .bind(&e.content_html)
1620 .bind(&fetched_at)
1621 .execute(&mut *tx)
1622 .await
1623 .with_context(|| format!("insert entry {} failed", e.guid))?;
1624 count += res.rows_affected();
1625 }
1626
1627 // Entries-per-feed cap: keep only the newest `max_entries_per_feed` rows for
1628 // this feed, deleting the overflow in the same transaction. "Newest" is
1629 // COALESCE(published, fetched_at) so an UNDATED entry (NULL published) sorts
1630 // by when we fetched it (NOT NULL) rather than always sorting LAST and being
1631 // evicted first — otherwise a feed of undated items would trim its freshest
1632 // rows. This bounds a single firehose/misbehaving feed's storage footprint
1633 // independent of the global retention sweep. `<= 0` disables it.
1634 //
1635 // The bound is `2 * max_entries_per_feed`, not `max_entries_per_feed`: the
1636 // newest N by date, plus up to N starred. See the sparing subquery below.
1637 if max_entries_per_feed > 0 {
1638 sqlx::query(
1639 r#"
1640 DELETE FROM entries
1641 WHERE feed_id = ?1
1642 AND id NOT IN (
1643 SELECT id FROM entries
1644 WHERE feed_id = ?1
1645 ORDER BY COALESCE(published, fetched_at) DESC, id DESC
1646 LIMIT ?2
1647 )
1648 -- Starred entries survive the per-feed trim, exactly as they
1649 -- survive the retention sweep. This predicate was added to the
1650 -- sweep and NOT here, which left the documented guarantee
1651 -- ("starred entries are never evicted") false — and made this
1652 -- path, which runs on every poll of every feed rather than daily,
1653 -- the main producer of the very "starred but not cached" case the
1654 -- saved-record rendering exists to paper over.
1655 --
1656 -- The sparing is BOUNDED and SCOPED, and both matter:
1657 --
1658 -- Bounded, because the first version spared every starred row
1659 -- without limit, which did not weaken the cap so much as remove
1660 -- it — measured at cap=5 with 50 starred rows, 55 survived, 11x
1661 -- the cap. That is the same unbounded-sparing mistake the
1662 -- retention hard ceiling was added to fix, reintroduced in the
1663 -- other sweep. Worst case is now cap + cap.
1664 --
1665 -- Scoped, because `SELECT entry_id FROM entry_state WHERE
1666 -- starred = 1` reads EVERY starred row on the instance, for every
1667 -- poll of every feed — cost scaling with total users rather than
1668 -- with the feed being trimmed.
1669 AND id NOT IN (
1670 SELECT e2.id FROM entries e2
1671 WHERE e2.feed_id = ?1
1672 AND EXISTS (
1673 SELECT 1 FROM entry_state s
1674 WHERE s.entry_id = e2.id AND s.starred = 1
1675 )
1676 ORDER BY COALESCE(e2.published, e2.fetched_at) DESC, e2.id DESC
1677 LIMIT ?2
1678 )
1679 "#,
1680 )
1681 .bind(feed_id)
1682 .bind(max_entries_per_feed)
1683 .execute(&mut *tx)
1684 .await
1685 .with_context(|| format!("trimming feed {feed_id} to {max_entries_per_feed} entries"))?;
1686 }
1687
1688 // Per-feed trim above may have DELETEd entries; their ids can linger in the
1689 // read_cursor exception sets (read_ids/unread_ids have no FK to entries), so
1690 // scrub the orphaned ids out of THIS feed's cursors in the same transaction.
1691 // Bounds id-set growth and keeps the flushed PDS record from referencing
1692 // entries that no longer exist. Scoped to the one feed for cheapness.
1693 if max_entries_per_feed > 0 {
1694 prune_orphan_cursor_ids_tx(&mut tx, Some(feed_id)).await?;
1695 }
1696
1697 tx.commit().await.context("commit insert_entries tx")?;
1698 Ok(count)
1699}
1700
1701/// Make a feed due for polling on the next tick.
1702///
1703/// Used when a saved article is missing from the cache: if the reader still
1704/// subscribes to the feed, the poller may be able to bring the article back on
1705/// its own. Clearing `next_poll` is the whole mechanism — `due_feeds` treats
1706/// NULL as due — so this adds no synthetic rows and no special-case fetch path.
1707///
1708/// **Rate-limited by `not_polled_since`**, and that is not a nicety.
1709///
1710/// `due_feeds` treats a NULL `next_poll` as due immediately, so clearing it
1711/// unconditionally from a page handler meant every reload of the starred view
1712/// made those feeds due again — bypassing the poll interval entirely. That is
1713/// outbound amplification against third-party feed origins, and it lets one
1714/// reader's feeds monopolise a poll budget that is shared and already the
1715/// binding constraint on how many readers an instance can serve.
1716///
1717/// A feed polled within the window is left alone: if the article was not in the
1718/// feed a minute ago, another fetch now will not find it either. The nudge is
1719/// therefore worth at most one extra poll per feed per interval, which is the
1720/// cadence the poller already targets.
1721///
1722/// A no-op if the URL is not a known feed.
1723pub async fn mark_feed_due(
1724 pool: &SqlitePool,
1725 feed_url: &str,
1726 not_polled_since: &str,
1727) -> Result<()> {
1728 sqlx::query(
1729 "UPDATE feeds SET next_poll = NULL \
1730 WHERE url = ?1 AND (last_polled IS NULL OR last_polled < ?2)",
1731 )
1732 .bind(feed_url)
1733 .bind(not_polled_since)
1734 .execute(pool)
1735 .await
1736 .context("marking a feed due")?;
1737 Ok(())
1738}
1739
1740/// Delete entries whose age exceeds the retention window — the shared cache's
1741/// **rolling window** — except those a reader has starred or not yet read. "Age" is `COALESCE(published, fetched_at)` so an UNDATED
1742/// entry falls back to when it was fetched (never NULL) rather than being treated
1743/// as infinitely old. `entry_state` cascades via its `ON DELETE CASCADE` FK.
1744///
1745/// After the delete, orphaned entry ids are scrubbed out of every affected feed's
1746/// `read_cursor` exception sets (which have no FK to `entries`) so the id-sets do
1747/// not grow without bound and the flushed PDS record never references a vanished
1748/// entry. The caller (the retention sweep) should follow a non-zero return with
1749/// [`reclaim`] so freed pages return to the OS.
1750///
1751/// The two knobs are **independent**. `days == 0` disables the rolling window and
1752/// nothing else; `hard_days == 0` disables the ceiling and nothing else. Only
1753/// when both are off is this a no-op. Returns the number of entry rows deleted.
1754pub async fn prune_old_entries(
1755 pool: &SqlitePool,
1756 days: i64,
1757 hard_days: i64,
1758 publication_days: i64,
1759) -> Result<u64> {
1760 let now = chrono::Utc::now();
1761 // **A window too large to be a date disables that pass; it must not panic.**
1762 //
1763 // `chrono::Duration::days` and `DateTime - TimeDelta` both panic out of
1764 // range, and every knob here parses from a `u32` with no upper bound — so
1765 // `FEATHERREADER_RETENTION_DAYS=1000000000` (a plausible unit slip: seconds or
1766 // milliseconds typed into a days field) panicked this function. Measured:
1767 // anything past roughly 96 million days overflows, and `u32::MAX` does.
1768 //
1769 // The consequence was not a crash an operator would notice. This runs in a
1770 // spawned task, so tokio catches the panic and the retention sweeper simply
1771 // stops for the life of the process — silently, permanently, and taking the
1772 // release valve for `db_size_watermark_bytes` with it, which is the one thing
1773 // that stops polling for every reader.
1774 //
1775 // Disabled-not-panicking is also the answer `standard_site::ingest_floor`
1776 // already gives for the same input, and the two are supposed to mirror each
1777 // other — `Config::retention_for` exists to keep them agreeing. An
1778 // unrepresentable window meant "store everything" there and "panic" here.
1779 let at = |d: i64, knob: &str| -> Option<String> {
1780 let cutoff = chrono::Duration::try_days(d).and_then(|w| now.checked_sub_signed(w));
1781 if cutoff.is_none() {
1782 tracing::warn!(
1783 days = d,
1784 knob,
1785 "retention window is too large to express as a date; treating it as \
1786 disabled for this sweep rather than failing the sweeper"
1787 );
1788 }
1789 cutoff.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
1790 };
1791
1792 let cutoff = (days > 0).then(|| at(days, "retention_days")).flatten();
1793 // **The third window, for the kinds age does not bound.** See
1794 // [`AGED_KINDS_SQL`] and `FeedKind::AGED`: a publication's entries are
1795 // bounded by COUNT (the per-feed trim), because a 14-day window stored zero
1796 // rows from every real publication measured. This is the backstop that keeps
1797 // "not aged out" from meaning "immortal" — the per-feed trim only runs when a
1798 // poll stores something, so rows belonging to a feed nobody polls any more
1799 // have nothing else to reap them.
1800 let publication_cutoff = (publication_days > 0)
1801 .then(|| at(publication_days, "publication_retention_days"))
1802 .flatten();
1803 // The ceiling only means anything if it is STRICTLY OLDER than the window.
1804 // At `0 < hard_days <= days` the two cutoffs coincide, and since the hard
1805 // delete spares nothing, it would delete exactly the rows the soft delete
1806 // exists to spare — turning the whole starred/unread exception into a no-op.
1807 // With no window at all (`days <= 0`) there is nothing to be inside of, so a
1808 // positive ceiling stands on its own.
1809 //
1810 // This used to be `hard_days.max(days)`, which clamps the wrong way: it made
1811 // `0` — the value an operator reaches for to turn a ceiling OFF, and the
1812 // documented "disabled" value for `RETENTION_DAYS` one line above it in the
1813 // same table — the single most destructive setting available, silently
1814 // purging starred and unread entries at the soft window. Measured: with
1815 // `days=14`, `hard=0` deleted a 30-day starred entry and a 30-day unread one.
1816 //
1817 // `<= 0` now means disabled, consistently with `days`. A contradictory
1818 // positive value is refused rather than reinterpreted downward.
1819 //
1820 // The ceiling is deliberately NOT gated on the window being enabled. It used
1821 // to be — this function returned on `days <= 0` before the ceiling was even
1822 // computed — which made `RETENTION_DAYS=0` mean "no window AND no ceiling":
1823 // the one configuration with no bound on the shared cache whatsoever. That
1824 // became load-bearing when the per-feed trim started sparing starred entries.
1825 // Before, the trim was a backstop for them; now nothing was. "I don't want a
1826 // rolling window" and "I don't want any ceiling at all" are different
1827 // statements, and are now configured separately.
1828 let hard_cutoff = if hard_days > 0 && (days <= 0 || hard_days > days) {
1829 at(hard_days, "retention_hard_days")
1830 } else {
1831 if hard_days > 0 {
1832 tracing::warn!(
1833 hard_days,
1834 days,
1835 "retention hard ceiling is not older than the retention window; \
1836 ignoring it — set it above the window or to 0 to disable"
1837 );
1838 }
1839 None
1840 };
1841
1842 if cutoff.is_none() && hard_cutoff.is_none() && publication_cutoff.is_none() {
1843 return Ok(0);
1844 }
1845
1846 // **The hard ceiling — the bound that sparing would otherwise remove.**
1847 //
1848 // Sparing `read = 0` is not a small exception: "mark unread" is a one-click
1849 // UI control, and `entries` is SHARED across every reader on the instance.
1850 // Without a ceiling, one person can pin unbounded rows, and the pins are
1851 // permanent.
1852 //
1853 // That matters beyond disk. `poll_due_once` stops ALL polling once the
1854 // database crosses `db_size_watermark_bytes`, and the retention DELETE is
1855 // the documented release valve. Pinned rows can hold the valve shut
1856 // forever, so the failure mode is: one reader pins enough content, the DB
1857 // latches above the watermark, and polling stops for EVERY reader with no
1858 // self-healing path. The window used to be an unconditional bound; sparing
1859 // removed it, and this restores it.
1860 //
1861 // Starred entries go too at this age, and that is now safe: a saved record
1862 // whose entry is gone renders from the PDS record as a link card, so the
1863 // reader keeps the article's identity even when the cache does not keep its
1864 // text.
1865 let hard_deleted = match &hard_cutoff {
1866 Some(cutoff) => {
1867 delete_in_batches(
1868 pool,
1869 // Scoped to the kinds the window applies to. A publication's
1870 // entries answer to `publication_cutoff` below instead, which is
1871 // generous where this is tight — an archive read is not a cache
1872 // of the last few days.
1873 &format!(
1874 "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1 \
1875 AND feed_id IN (SELECT id FROM feeds WHERE kind IN ({AGED_KINDS_SQL}))"
1876 ),
1877 cutoff,
1878 "hard ceiling",
1879 )
1880 .await?
1881 }
1882 None => 0,
1883 };
1884 // **Entries a reader has DELIBERATELY marked are kept, whatever their age.**
1885 //
1886 // Precisely: an entry is spared when some DID has an `entry_state` row for
1887 // it with `starred = 1` or `read = 0`. An entry nobody has ever touched has
1888 // no `entry_state` row at all and is NOT spared, even though every read path
1889 // treats "no row" as unread.
1890 //
1891 // That asymmetry is deliberate and load-bearing. Sparing every never-touched
1892 // entry would spare essentially the whole table — almost no entry is ever
1893 // interacted with — which would make the window a no-op and leave the hard
1894 // ceiling as the only bound. The window is for evicting cache nobody claimed;
1895 // the exception is for the things a reader acted on.
1896 //
1897 // This comment used to read "starred and unread entries are kept", which is
1898 // the reading that would motivate exactly that change.
1899 //
1900 // The window is a cache eviction policy, not a data-retention policy. The
1901 // PDS is the source of truth for what a reader CHOSE — subscriptions,
1902 // folders, stars, read-state — but the entry CONTENT was never there. It
1903 // exists here and at the origin feed, and a feed typically serves only its
1904 // last few dozen items, so a pruned article is usually unrecoverable.
1905 //
1906 // Deleting indiscriminately therefore lost two things a reader would notice:
1907 // a starred article vanished from the starred view entirely (the view joins
1908 // `entries`, and `entry_state` cascades on the delete, so the star went with
1909 // it), and anything still unread disappeared before it was ever read. Both
1910 // are the opposite of a cache.
1911 //
1912 // This is what the documentation has always described; the query did not
1913 // implement it.
1914 let soft_deleted = match &cutoff {
1915 Some(cutoff) => {
1916 delete_in_batches(
1917 pool,
1918 // **`NOT EXISTS`, not `id NOT IN (…)`.**
1919 //
1920 // The list form materialises the ENTIRE pinned set on every
1921 // batch, and that set scales with total users rather than with
1922 // the feed being swept; this probes `idx_entry_state_entry_id`
1923 // per candidate row instead. Measured on 1M entries with 600k
1924 // `entry_state` rows of which 10% are pinned: **64.8 s as a list,
1925 // 43.6 s as a correlated exists — 1.49x, for no disk and no write
1926 // amplification.**
1927 //
1928 // **An earlier version of this comment claimed 2.4x, and that a
1929 // partial index on the pinned predicate "changed the time by
1930 // nothing at all". Both were artifacts of a bad fixture.** It
1931 // made every `entry_state` row match `starred = 1 OR read = 0` —
1932 // no "read and not starred" rows at all, which is the commonest
1933 // state a reader leaves behind. That inflated the list form's
1934 // cost (the materialised set was the whole table) and made a
1935 // PARTIAL index on that predicate cover 100% of rows, so it could
1936 // not be selective and duly did nothing.
1937 //
1938 // On a realistic distribution the review's proposed index is NOT
1939 // useless: it takes the list form from 64.8 s to 44.0 s, most of
1940 // the way to the rewrite. The rewrite is still the better change
1941 // because it costs no disk and no insert throughput — but it wins
1942 // by less than claimed, against an alternative that was dismissed
1943 // on a measurement of the wrong thing.
1944 //
1945 // Indexes are still declined, now on honest numbers: the pinned
1946 // index buys 12% (43.6 → 38.5 s) for 6.9 MiB, the age index 22%
1947 // (→ 33.9 s) for 27.9 MiB, both with write amplification on a
1948 // poller that inserts constantly, against a daily sweep that is
1949 // already batched and interruptible. See
1950 // `store::tests::r6_measure_retention_sweep`.
1951 //
1952 // Also strictly safer. `NOT IN` against a subquery containing a
1953 // NULL evaluates to NULL for every row, which would silently
1954 // delete nothing. `entry_state.entry_id` is `NOT NULL` today, so
1955 // the two are equivalent — but the equivalence depends on a
1956 // column constraint somewhere else, and `NOT EXISTS` does not.
1957 // `sparing_honours_every_did_not_just_one` pins the multi-DID
1958 // case, which is the only one where the forms could diverge.
1959 &format!(
1960 "SELECT e.id FROM entries e \
1961 WHERE COALESCE(e.published, e.fetched_at) < ?1 \
1962 AND e.feed_id IN \
1963 (SELECT id FROM feeds WHERE kind IN ({AGED_KINDS_SQL})) \
1964 AND NOT EXISTS ( \
1965 SELECT 1 FROM entry_state s \
1966 WHERE s.entry_id = e.id \
1967 AND (s.starred = 1 OR s.read = 0) \
1968 )"
1969 ),
1970 cutoff,
1971 "window",
1972 )
1973 .await?
1974 }
1975 None => 0,
1976 };
1977 // **The archive ceiling, for every kind the window does not cover.**
1978 //
1979 // `kind NOT IN` rather than `kind = 'publication'` deliberately: a kind added
1980 // later and left out of `FeedKind::AGED` inherits a bound here rather than
1981 // inheriting immortality. Spares nothing, for the reason the hard ceiling
1982 // spares nothing — a saved record whose entry is gone still renders from the
1983 // PDS record as a link card, so the reader keeps the article's identity.
1984 let publication_deleted = match &publication_cutoff {
1985 Some(cutoff) => {
1986 delete_in_batches(
1987 pool,
1988 &format!(
1989 "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1 \
1990 AND feed_id IN (SELECT id FROM feeds WHERE kind NOT IN ({AGED_KINDS_SQL}))"
1991 ),
1992 cutoff,
1993 "archive ceiling",
1994 )
1995 .await?
1996 }
1997 None => 0,
1998 };
1999 let deleted = soft_deleted + hard_deleted + publication_deleted;
2000
2001 // Only touch cursors when rows actually went away — and OUTSIDE the deletes.
2002 //
2003 // This used to run inside the one transaction that wrapped both deletes,
2004 // which made the whole sweep a single write-lock hold: load every
2005 // `read_cursor` row, then issue a fresh per-cursor `SELECT … JOIN … WHERE
2006 // f.url = ?` returning up to `max_entries_per_feed` ids, all before the
2007 // commit. SQLite is single-writer and `busy_timeout` is 5 s, so for that
2008 // whole span every mark-read, every login write and every cursor flush
2009 // failed.
2010 //
2011 // Correctness survives the move because the scrub is idempotent — it
2012 // computes each cursor's surviving ids from what is in `entries` NOW, and
2013 // rewrites only cursors that actually change. If the process dies between
2014 // the deletes and the scrub, the next sweep finishes the job, and in the
2015 // meantime a stale id in an exception set is inert: the flusher sends it,
2016 // and it names an entry nobody can reach.
2017 if deleted > 0 {
2018 if let Err(err) = prune_orphan_cursor_ids(pool, None).await {
2019 // The deletes already committed and are the point of this call.
2020 // A failed scrub leaves stale ids to be cleaned up next sweep.
2021 tracing::warn!(%err, "retention sweep: cursor id scrub failed after the deletes");
2022 }
2023 }
2024
2025 Ok(deleted)
2026}
2027
2028/// Rows deleted per statement by [`delete_in_batches`].
2029///
2030/// Small enough that one batch — including its `entry_state` FK cascade — is a
2031/// short lock hold, large enough that a big sweep is tens of statements rather
2032/// than thousands.
2033const PRUNE_BATCH: i64 = 1_000;
2034
2035/// Backstop against a delete loop that never drains. `rows_affected == 0` is the
2036/// real terminator; this only bounds the damage if a future predicate change
2037/// makes that untrue. At [`PRUNE_BATCH`] this is 10M rows, far past anything a
2038/// 1 GB volume holds.
2039const PRUNE_MAX_BATCHES: usize = 10_000;
2040
2041/// How long [`delete_in_batches`] stands down between batches, so a writer
2042/// waiting on the SQLite write lock actually gets it rather than losing the race
2043/// to the loop's next statement.
2044///
2045/// Named because it is the one thing that makes batching a fix rather than
2046/// bookkeeping, and because `a_writer_gets_through_while_the_sweep_runs` derives
2047/// its "was this sweep long enough to measure" floor from it. A sweep that is
2048/// genuinely batched cannot finish faster than one hand-off per batch; that is a
2049/// structural lower bound, not a number calibrated against a particular machine.
2050const PRUNE_BATCH_HANDOFF: std::time::Duration = std::time::Duration::from_millis(10);
2051
2052/// Delete every entry matched by `select_ids` (a `SELECT id FROM entries …`
2053/// bound to one `?1` cutoff), in bounded batches, **one implicit transaction per
2054/// batch**.
2055///
2056/// The retention sweep used to be a single `DELETE` inside one explicit
2057/// transaction. On a populated instance that is one unbroken write-lock hold
2058/// covering tens of thousands of row deletes plus their `entry_state` cascades —
2059/// measured at ~10 minutes before `idx_entry_state_entry_id` existed, and still
2060/// a single indivisible span after it. Everything else that writes (mark-read,
2061/// login, cursor flush) has a 5 s `busy_timeout` and simply fails for the
2062/// duration.
2063///
2064/// Batching does not make the total work smaller; it makes it INTERRUPTIBLE. A
2065/// writer waiting on the lock gets in between batches instead of timing out, and
2066/// the short sleep below guarantees that window actually exists rather than
2067/// leaving it to chance against a tight loop.
2068///
2069/// A partial sweep is safe: each batch commits on its own, and the predicate is
2070/// a fixed cutoff, so a crash mid-sweep leaves fewer rows deleted and the next
2071/// run finishes the job.
2072async fn delete_in_batches(
2073 pool: &SqlitePool,
2074 select_ids: &str,
2075 cutoff: &str,
2076 label: &str,
2077) -> Result<u64> {
2078 let sql = format!("DELETE FROM entries WHERE id IN ({select_ids} LIMIT {PRUNE_BATCH})");
2079 let mut total: u64 = 0;
2080 for batch in 0..PRUNE_MAX_BATCHES {
2081 let n = sqlx::query(sqlx::AssertSqlSafe(sql.clone()))
2082 .bind(cutoff)
2083 .execute(pool)
2084 .await
2085 .with_context(|| format!("prune_old_entries {label} (cutoff {cutoff})"))?
2086 .rows_affected();
2087 total += n;
2088 if n == 0 {
2089 return Ok(total);
2090 }
2091 // Hand the write lock over, so the loop cannot re-acquire it the instant
2092 // it commits and leave a waiting writer to fight for the gap between two
2093 // statements. At `PRUNE_BATCH` rows per batch this adds one
2094 // `PRUNE_BATCH_HANDOFF` per 1,000 deleted rows to a sweep that runs once
2095 // a day.
2096 //
2097 // This comment has twice carried a number it could not support. It first
2098 // said a writer "still starves" without the hand-off; that was replaced
2099 // with "roughly 3x writer throughput", quoting one sample from each of
2100 // two runs. Repeated, the two distributions overlap heavily (medians
2101 // ~1.4 writes/ms with the sleep against ~1.0 without, and several
2102 // sleep-less runs beat the median with it), so 3x is not a figure this
2103 // comment can assert.
2104 //
2105 // What is defensible without a benchmark: removing it lets the loop
2106 // re-acquire immediately, so a waiting writer is left racing the gap
2107 // between two statements instead of being handed a window. Writers do
2108 // still get through either way. `a_writer_gets_through_while_the_sweep_runs`
2109 // catches the removal about three runs in five — see the note there; the
2110 // rest of the time the loop still looks batched, because it is.
2111 tokio::time::sleep(PRUNE_BATCH_HANDOFF).await;
2112 // Only warn if the backstop actually cut the sweep short. A final batch
2113 // that happened to drain the last rows would otherwise log "the rest
2114 // waits for the next run" with nothing left — and an operator who reads
2115 // that during an incident would go looking for a backlog that is not
2116 // there. `n < PRUNE_BATCH` means this batch found fewer rows than it
2117 // asked for, so there are none behind it.
2118 // Still a 1-in-`PRUNE_BATCH` false positive when the final batch drains
2119 // exactly a full batch with nothing behind it — distinguishing that
2120 // needs another COUNT per sweep, which is not worth paying to make a
2121 // backstop message that has never fired slightly more precise.
2122 if batch + 1 == PRUNE_MAX_BATCHES && n == PRUNE_BATCH as u64 {
2123 tracing::warn!(
2124 label,
2125 total,
2126 "retention sweep hit its batch backstop; the rest waits for the next run"
2127 );
2128 }
2129 }
2130 Ok(total)
2131}
2132
2133/// Scrub entry ids that no longer exist out of `read_cursor.read_ids` /
2134/// `unread_ids`. `read_cursor` is keyed by `(did, feed_url)` and its id-sets have
2135/// NO foreign key to `entries`, so a prune/trim that deletes entries would
2136/// otherwise leave dangling ids that (a) grow the sets without bound and (b) get
2137/// flushed to the PDS as references to vanished entries.
2138///
2139/// When `feed_id` is `Some`, only that feed's cursors are examined (the cheap
2140/// path used right after a per-feed trim); `None` scans every cursor (the
2141/// retention sweep, which can delete across many feeds at once). A cursor whose
2142/// sets actually change is rewritten and marked `dirty` so the flusher resyncs
2143/// it; unchanged cursors are left untouched (no spurious dirtying / PDS writes).
2144/// Returns the number of cursor rows modified.
2145///
2146/// This is the TRANSACTIONAL variant, used by the per-feed trim inside
2147/// `insert_entries`: it is scoped to one feed, examines that feed's cursors
2148/// only, and genuinely wants to land atomically with the trim that created the
2149/// orphans. The retention sweep uses [`prune_orphan_cursor_ids`] instead —
2150/// global scope inside one transaction is what made the sweep a multi-minute
2151/// write-lock hold.
2152async fn prune_orphan_cursor_ids_tx(
2153 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
2154 feed_id: Option<i64>,
2155) -> Result<u64> {
2156 // The set of live entry ids we prune against. Scope to the feed's URL when a
2157 // feed_id is given so we filter only that feed's cursors against that feed's
2158 // entries; otherwise consider all cursors / all entries.
2159 let feed_url = match feed_id {
2160 Some(fid) => match feed_url_for_id_tx(tx, fid).await? {
2161 Some(u) => Some(u),
2162 None => return Ok(0), // feed vanished mid-tx; nothing to prune
2163 },
2164 None => None,
2165 };
2166
2167 // Load the (did, feed_url, read_ids, unread_ids) of the candidate cursors.
2168 let cursors: Vec<(String, String, String, String)> = match &feed_url {
2169 Some(url) => sqlx::query(
2170 "SELECT did, feed_url, read_ids, unread_ids FROM read_cursor WHERE feed_url = ?1",
2171 )
2172 .bind(url)
2173 .fetch_all(&mut **tx)
2174 .await
2175 .context("prune_orphan_cursor_ids: load feed cursors")?,
2176 None => sqlx::query("SELECT did, feed_url, read_ids, unread_ids FROM read_cursor")
2177 .fetch_all(&mut **tx)
2178 .await
2179 .context("prune_orphan_cursor_ids: load all cursors")?,
2180 }
2181 .into_iter()
2182 .map(|r| {
2183 (
2184 r.get::<String, _>("did"),
2185 r.get::<String, _>("feed_url"),
2186 r.get::<String, _>("read_ids"),
2187 r.get::<String, _>("unread_ids"),
2188 )
2189 })
2190 .collect();
2191
2192 if cursors.is_empty() {
2193 return Ok(0);
2194 }
2195
2196 let now = now_rfc3339();
2197 let mut changed: u64 = 0;
2198 for (did, curl, read_ids, unread_ids) in cursors {
2199 // The live entry ids for THIS cursor's feed (join by URL — the cursor key).
2200 let live: std::collections::HashSet<i64> = sqlx::query_scalar::<_, i64>(
2201 "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id WHERE f.url = ?1",
2202 )
2203 .bind(&curl)
2204 .fetch_all(&mut **tx)
2205 .await
2206 .with_context(|| format!("prune_orphan_cursor_ids: live ids for {curl}"))?
2207 .into_iter()
2208 .collect();
2209
2210 let new_read = filter_id_set_to_live(&read_ids, &live);
2211 let new_unread = filter_id_set_to_live(&unread_ids, &live);
2212 if new_read == read_ids && new_unread == unread_ids {
2213 continue; // nothing orphaned — leave the cursor (and its dirty flag) alone
2214 }
2215 sqlx::query(
2216 "UPDATE read_cursor SET read_ids = ?3, unread_ids = ?4, dirty = 1, updated_at = ?5 \
2217 WHERE did = ?1 AND feed_url = ?2",
2218 )
2219 .bind(&did)
2220 .bind(&curl)
2221 .bind(&new_read)
2222 .bind(&new_unread)
2223 .bind(&now)
2224 .execute(&mut **tx)
2225 .await
2226 .with_context(|| format!("prune_orphan_cursor_ids: rewrite cursor {did}/{curl}"))?;
2227 changed += 1;
2228 }
2229 Ok(changed)
2230}
2231
2232/// [`prune_orphan_cursor_ids_tx`] over the pool — **no enclosing transaction**.
2233///
2234/// Same result, different locking. Each statement commits on its own, so the
2235/// single write lock is taken for one cursor rewrite at a time and released
2236/// between them, and the reads in between block nothing at all in WAL mode.
2237/// That matters because this is the global pass: the retention sweep's version
2238/// loads EVERY `read_cursor` row and then issues one live-ids query per cursor,
2239/// and holding all of that inside a transaction is what made a daily sweep look
2240/// like an outage to every writer on the instance.
2241///
2242/// **Each cursor's read-modify-write is one short transaction**, and that is not
2243/// optional. The first version of this loaded every cursor into a snapshot, then
2244/// walked them issuing an unguarded `UPDATE` per cursor from that snapshot. A
2245/// `mark_read` landing during the walk — seconds, on a global pass — had its new
2246/// id silently overwritten by the stale set, and the rewrite set `dirty = 1`, so
2247/// the flusher then pushed the truncated set to the PDS as authoritative. Local
2248/// `entry_state` still said read, so the loss was invisible here and visible
2249/// only in every OTHER atproto client. The transactional predecessor did not
2250/// have that bug: it held the write lock across the whole pass, so a concurrent
2251/// `mark_read` blocked and applied on top.
2252///
2253/// So the lock is not eliminated, it is SCOPED: one cursor's live-ids query plus
2254/// its update, rather than every cursor's. That keeps what T2.2 was for (a daily
2255/// sweep must not look like an outage) without trading it for lost writes.
2256///
2257/// Re-running is still safe — surviving ids are recomputed from the current
2258/// contents of `entries` — so dying partway just means the next sweep finishes.
2259///
2260/// `feed_id = Some(..)` scopes to one feed; `None` scans every cursor. Returns
2261/// the number of cursor rows modified.
2262async fn prune_orphan_cursor_ids(pool: &SqlitePool, feed_id: Option<i64>) -> Result<u64> {
2263 let feed_url = match feed_id {
2264 Some(fid) => match sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
2265 .bind(fid)
2266 .fetch_optional(pool)
2267 .await
2268 .context("prune_orphan_cursor_ids: feed url")?
2269 {
2270 Some(u) => Some(u),
2271 None => return Ok(0),
2272 },
2273 None => None,
2274 };
2275
2276 // Only the KEYS come from this snapshot. The id-sets are deliberately not
2277 // read here — they are re-read inside each cursor's own transaction below,
2278 // because anything read out here is stale by the time it is written back.
2279 let keys: Vec<(String, String)> = match &feed_url {
2280 Some(url) => sqlx::query_as("SELECT did, feed_url FROM read_cursor WHERE feed_url = ?1")
2281 .bind(url)
2282 .fetch_all(pool)
2283 .await
2284 .context("prune_orphan_cursor_ids: load feed cursors")?,
2285 None => sqlx::query_as("SELECT did, feed_url FROM read_cursor")
2286 .fetch_all(pool)
2287 .await
2288 .context("prune_orphan_cursor_ids: load all cursors")?,
2289 };
2290
2291 let mut changed: u64 = 0;
2292 for (did, curl) in keys {
2293 // A cursor that vanished between the key snapshot and now is simply
2294 // skipped; a cursor that APPEARED is missed until the next sweep. Both
2295 // are fine — the scrub is housekeeping, not a correctness barrier.
2296 match scrub_one_cursor(pool, &did, &curl).await {
2297 Ok(true) => changed += 1,
2298 Ok(false) => {}
2299 // One bad cursor must not abandon the rest of the pass.
2300 Err(err) => tracing::warn!(%err, %did, feed = %curl, "cursor id scrub failed"),
2301 }
2302 }
2303 Ok(changed)
2304}
2305
2306/// Scrub one cursor's id-sets inside its own transaction. Returns whether the
2307/// row changed.
2308///
2309/// The read of the id-sets, the live-ids query and the write all happen under
2310/// one transaction, so a `mark_read` that lands mid-sweep either goes first (and
2311/// is included) or waits (and applies on top). Reading the sets outside and
2312/// writing them back later is the lost-update shape this function exists to
2313/// avoid — see [`prune_orphan_cursor_ids`].
2314async fn scrub_one_cursor(pool: &SqlitePool, did: &str, feed_url: &str) -> Result<bool> {
2315 let mut tx = pool.begin().await.context("begin scrub_one_cursor tx")?;
2316
2317 let (_, read_ids, unread_ids) = cursor_sets(&mut tx, did, feed_url).await?;
2318 // An empty exception set has nothing to orphan, and skipping it avoids the
2319 // live-ids query entirely — the dominant cost of this pass, and the common
2320 // case for a cursor sitting at its high-water mark.
2321 if is_empty_id_set(&read_ids) && is_empty_id_set(&unread_ids) {
2322 return Ok(false);
2323 }
2324
2325 let live: std::collections::HashSet<i64> = sqlx::query_scalar::<_, i64>(
2326 "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id WHERE f.url = ?1",
2327 )
2328 .bind(feed_url)
2329 .fetch_all(&mut *tx)
2330 .await
2331 .with_context(|| format!("prune_orphan_cursor_ids: live ids for {feed_url}"))?
2332 .into_iter()
2333 .collect();
2334
2335 let new_read = filter_id_set_to_live(&read_ids, &live);
2336 let new_unread = filter_id_set_to_live(&unread_ids, &live);
2337 if new_read == read_ids && new_unread == unread_ids {
2338 return Ok(false); // nothing orphaned — leave the cursor (and its dirty flag) alone
2339 }
2340 sqlx::query(
2341 "UPDATE read_cursor SET read_ids = ?3, unread_ids = ?4, dirty = 1, updated_at = ?5 \
2342 WHERE did = ?1 AND feed_url = ?2",
2343 )
2344 .bind(did)
2345 .bind(feed_url)
2346 .bind(&new_read)
2347 .bind(&new_unread)
2348 .bind(now_rfc3339())
2349 .execute(&mut *tx)
2350 .await
2351 .with_context(|| format!("prune_orphan_cursor_ids: rewrite cursor {did}/{feed_url}"))?;
2352 tx.commit().await.context("commit scrub_one_cursor tx")?;
2353 Ok(true)
2354}
2355
2356/// Whether a stored id-set is *textually* empty — `[]` or blank.
2357///
2358/// Deliberately NOT a parse: this is a fast pre-filter, and
2359/// [`filter_id_set_to_live`] remains the authority on what a set contains. An
2360/// unparseable value returns `false` here, so it goes through the full path and
2361/// gets canonicalised to `[]` rather than being skipped — the pre-filter fails
2362/// toward doing the work, which is the safe direction.
2363fn is_empty_id_set(raw: &str) -> bool {
2364 let t = raw.trim();
2365 t.is_empty() || t == "[]"
2366}
2367
2368/// Filter a JSON id-array string down to only ids present in `live`, returning
2369/// the canonical JSON-array-of-strings form (matching [`json_id_set_toggle`]). A
2370/// malformed input yields `[]`.
2371fn filter_id_set_to_live(raw: &str, live: &std::collections::HashSet<i64>) -> String {
2372 let ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
2373 .ok()
2374 .map(|vals| {
2375 vals.into_iter()
2376 .filter_map(|v| match v {
2377 serde_json::Value::Number(n) => n.as_i64(),
2378 serde_json::Value::String(s) => s.parse::<i64>().ok(),
2379 _ => None,
2380 })
2381 .filter(|id| live.contains(id))
2382 .collect()
2383 })
2384 .unwrap_or_default();
2385 let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
2386 serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
2387}
2388
2389/// Replace the per-DID subscription projection (`sub_ref`) for `did` with
2390/// exactly `feed_ids`, in one transaction.
2391///
2392/// Called from the web layer's subscription-resolve/sync path so `sub_ref`
2393/// always mirrors the caller's *current* PDS subscription set. This is the
2394/// authority every scoped read/mutation checks against — a feed the caller no
2395/// longer subscribes to drops out of their read surface immediately.
2396pub async fn replace_sub_refs(pool: &SqlitePool, did: &str, feed_ids: &[i64]) -> Result<()> {
2397 let mut tx = pool.begin().await.context("begin replace_sub_refs tx")?;
2398 sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
2399 .bind(did)
2400 .execute(&mut *tx)
2401 .await
2402 .with_context(|| format!("clear sub_ref for {did}"))?;
2403 for &feed_id in feed_ids {
2404 sqlx::query("INSERT OR IGNORE INTO sub_ref (did, feed_id) VALUES (?1, ?2)")
2405 .bind(did)
2406 .bind(feed_id)
2407 .execute(&mut *tx)
2408 .await
2409 .with_context(|| format!("insert sub_ref {did}/{feed_id}"))?;
2410 }
2411 tx.commit().await.context("commit replace_sub_refs tx")?;
2412 Ok(())
2413}
2414
2415/// Whether `did` currently subscribes to the feed `feed_id` owns
2416/// (i.e. a `sub_ref` row exists). The authorization primitive behind every
2417/// per-DID scoped read/mutation.
2418pub async fn did_subscribes_to_entry(pool: &SqlitePool, did: &str, entry_id: i64) -> Result<bool> {
2419 let found: Option<i64> = sqlx::query_scalar(
2420 r#"
2421 SELECT 1
2422 FROM entries e
2423 JOIN sub_ref sr ON sr.feed_id = e.feed_id AND sr.did = ?1
2424 WHERE e.id = ?2
2425 "#,
2426 )
2427 .bind(did)
2428 .bind(entry_id)
2429 .fetch_optional(pool)
2430 .await
2431 .with_context(|| format!("did_subscribes_to_entry failed for {did}/{entry_id}"))?;
2432 Ok(found.is_some())
2433}
2434
2435/// The exact `(sql, bind_count)` `list_entries` runs, for a view and scope.
2436///
2437/// **One path, so a test cannot assert on something the query is free to
2438/// ignore.** A named `LIST_PROJECTION` constant was not enough: the test read
2439/// the constant while `list_entries` passed `list_query_sql` whatever it liked,
2440/// so swapping in an inline literal containing `e.content_html` still shipped
2441/// green. The test now calls this.
2442fn list_entries_sql(view: ListView, feed_ids: Option<&[i64]>) -> (String, usize) {
2443 list_query_sql(Projection::EntryList, view, feed_ids)
2444}
2445
2446/// Which columns a list query may select.
2447///
2448/// **A closed type, not a `&str`.** A named constant was not enough and neither
2449/// was a helper function: both left `list_query_sql` taking an arbitrary string,
2450/// so a call site could pass an inline literal containing `e.content_html` and
2451/// ship green — twice over, which is how this ended up as an enum. The article
2452/// body is up to 20 KB per row and the list renders 50 at a time, so reading it
2453/// is the difference between a bounded response and a megabyte per page.
2454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2455enum Projection {
2456 /// The list view. Deliberately omits `content_html`.
2457 EntryList,
2458 Count,
2459 Ids,
2460 FeedCounts,
2461 StarredUrls,
2462}
2463
2464impl Projection {
2465 const fn columns(self) -> &'static str {
2466 match self {
2467 Projection::EntryList => {
2468 "e.id, e.feed_id, e.guid, e.url, e.title, e.published, \
2469 COALESCE(s.read, 0) AS read, COALESCE(s.starred, 0) AS starred"
2470 }
2471 Projection::Count => "COUNT(*)",
2472 Projection::Ids => "e.id",
2473 Projection::FeedCounts => "e.feed_id, COUNT(*)",
2474 Projection::StarredUrls => "e.url, e.guid",
2475 }
2476 }
2477}
2478
2479/// The shared body of every list query: the per-DID `entry_state` LEFT JOIN, the
2480/// `sub_ref` authorization predicate, the view predicate and the optional
2481/// feed-id restriction. `projection` is spliced in as the `SELECT` list.
2482///
2483/// Returns the SQL plus the number of feed-id placeholders emitted, so the
2484/// caller knows where its own `LIMIT`/`OFFSET` placeholders start. `?1` is
2485/// always the DID; feed ids are `?2..`.
2486///
2487/// **Why the callers may assert this is SQL-safe.** Only three things vary, and
2488/// none is caller data: `projection` and [`ListView::predicate`] are `&'static
2489/// str` written in this file, and the feed-id restriction contributes only a
2490/// COUNT — the ids themselves are bound, never formatted in. Every runtime value
2491/// (the DID, the ids, the limit, the offset) reaches SQLite as a bind parameter.
2492fn list_query_sql(
2493 projection: Projection,
2494 view: ListView,
2495 feed_ids: Option<&[i64]>,
2496) -> (String, usize) {
2497 let cols = projection.columns();
2498 let scoped = feed_ids.is_some();
2499 let mut sql = format!(
2500 "SELECT {cols} \
2501 FROM entries e \
2502 LEFT JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1 \
2503 WHERE {} \
2504 AND EXISTS ( \
2505 SELECT 1 FROM sub_ref sr \
2506 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id \
2507 )",
2508 view.predicate()
2509 );
2510 if scoped {
2511 // **ONE bind parameter for any scope size.**
2512 //
2513 // This used to emit one placeholder per feed id, so the SQL string and
2514 // the bind list both grew with the reader's subscription count — which
2515 // is PDS-supplied and bounded only by the 20,000-record list ceiling.
2516 //
2517 // That was reachable-broken, not merely ugly: `SQLITE_LIMIT_VARIABLE_NUMBER`
2518 // is 32766 on the bundled build, and the ids were bound TWICE per render
2519 // (the count query and the page query), so the effective ceiling was
2520 // ~16,383 feeds — below the list ceiling. Past it, `prepare` fails with
2521 // "too many SQL variables" and the reader's page 500s. Measured: 20,000
2522 // ids through `json_each` is a 108 KB bind that runs in 9.9 ms; 32,767
2523 // placeholders does not prepare at all.
2524 // The first attempt at bounding it truncated the subscription list
2525 // instead, which traded a query-shape problem for an access problem:
2526 // `sync_sub_refs` writes `sub_ref` from that list, so dropped feeds
2527 // became unreadable AND unmutatable. `json_each` removes the need to
2528 // choose — the whole set rides in as one JSON text bind.
2529 sql.push_str(" AND e.feed_id IN (SELECT value FROM json_each(?2))");
2530 }
2531 (sql, usize::from(scoped))
2532}
2533
2534/// Bind the DID and the optional feed-id restriction, in the order
2535/// [`list_query_sql`] emits them — `?1` the DID, `?2` the scope JSON when there
2536/// is one.
2537fn bind_list_scope<'q, O>(
2538 q: sqlx::query::QueryAs<'q, sqlx::Sqlite, O, sqlx::sqlite::SqliteArguments>,
2539 did: &'q str,
2540 feed_ids: Option<&[i64]>,
2541) -> sqlx::query::QueryAs<'q, sqlx::Sqlite, O, sqlx::sqlite::SqliteArguments> {
2542 let q = q.bind(did);
2543 match feed_ids {
2544 // Serialising i64s cannot fail; the fallback is an empty array, which
2545 // matches nothing — the fail-closed direction for a scope filter.
2546 Some(ids) => q.bind(serde_json::to_string(ids).unwrap_or_else(|_| "[]".to_string())),
2547 None => q,
2548 }
2549}
2550
2551/// One page of a list view, newest-published first, scoped to `did`'s
2552/// subscriptions (`sub_ref`) and optionally narrowed to `feed_ids`.
2553///
2554/// **`limit` is a required parameter, not a convenience.** This function
2555/// replaced three `SELECT e.*` queries that had no `LIMIT` at all and pulled the
2556/// article body they never used; leaving an unbounded variant next to the
2557/// bounded one would just be the same trap with a longer name. If a caller wants
2558/// "everything", it has to say how much everything is allowed to be. See
2559/// [`EntryListRow`] for what the projection deliberately omits and why.
2560///
2561/// `feed_ids = Some(&[])` means "no feeds in scope" and returns empty without
2562/// touching the database — distinct from `None`, which means "every feed this
2563/// DID subscribes to".
2564pub async fn list_entries(
2565 pool: &SqlitePool,
2566 did: &str,
2567 view: ListView,
2568 feed_ids: Option<&[i64]>,
2569 limit: i64,
2570 offset: i64,
2571) -> Result<Vec<EntryListRow>> {
2572 if feed_ids.is_some_and(<[i64]>::is_empty) || limit <= 0 {
2573 return Ok(Vec::new());
2574 }
2575 let (mut sql, n) = list_entries_sql(view, feed_ids);
2576 sql.push_str(&format!(
2577 " ORDER BY e.published DESC, e.id DESC LIMIT ?{} OFFSET ?{}",
2578 n + 2,
2579 n + 3
2580 ));
2581 let q = sqlx::query_as::<_, EntryListRow>(sqlx::AssertSqlSafe(sql));
2582 let rows = bind_list_scope(q, did, feed_ids)
2583 .bind(limit)
2584 .bind(offset.max(0))
2585 .fetch_all(pool)
2586 .await
2587 .with_context(|| format!("list_entries({view:?}) failed for {did}"))?;
2588 Ok(rows)
2589}
2590
2591/// How many entries the same scope + view would return, unpaged. Used for the
2592/// "N entries" heading and to decide whether a next-page link is warranted —
2593/// both of which used to read `entries.len()` off a fully materialized list.
2594pub async fn count_entries_for_view(
2595 pool: &SqlitePool,
2596 did: &str,
2597 view: ListView,
2598 feed_ids: Option<&[i64]>,
2599) -> Result<i64> {
2600 if feed_ids.is_some_and(<[i64]>::is_empty) {
2601 return Ok(0);
2602 }
2603 let (sql, _) = list_query_sql(Projection::Count, view, feed_ids);
2604 // `query_as` over a 1-tuple keeps one binding helper for both shapes.
2605 let q = sqlx::query_as::<_, (i64,)>(sqlx::AssertSqlSafe(sql));
2606 let (n,) = bind_list_scope(q, did, feed_ids)
2607 .fetch_one(pool)
2608 .await
2609 .with_context(|| format!("count_entries_for_view({view:?}) failed for {did}"))?;
2610 Ok(n)
2611}
2612
2613/// The ordered entry ids for a scope + view — the same ordering [`list_entries`]
2614/// renders, used for the reader's prev/next links.
2615///
2616/// Ids only: this one genuinely spans the whole list rather than a page (prev/next
2617/// needs the reader's position in it), so it is the one query where row COUNT can
2618/// still be large. An id is 8 bytes against the 11.9 KB row this used to fetch,
2619/// and `limit` bounds it regardless. Past the limit, prev/next simply stops
2620/// finding neighbours — the article still opens.
2621pub async fn list_entry_ids(
2622 pool: &SqlitePool,
2623 did: &str,
2624 view: ListView,
2625 feed_ids: Option<&[i64]>,
2626 limit: i64,
2627) -> Result<Vec<i64>> {
2628 if feed_ids.is_some_and(<[i64]>::is_empty) || limit <= 0 {
2629 return Ok(Vec::new());
2630 }
2631 let (mut sql, n) = list_query_sql(Projection::Ids, view, feed_ids);
2632 sql.push_str(&format!(
2633 " ORDER BY e.published DESC, e.id DESC LIMIT ?{}",
2634 n + 2
2635 ));
2636 let q = sqlx::query_as::<_, (i64,)>(sqlx::AssertSqlSafe(sql));
2637 let rows = bind_list_scope(q, did, feed_ids)
2638 .bind(limit)
2639 .fetch_all(pool)
2640 .await
2641 .with_context(|| format!("list_entry_ids({view:?}) failed for {did}"))?;
2642 Ok(rows.into_iter().map(|(id,)| id).collect())
2643}
2644
2645/// Unread counts per `feed_id` for a DID — the sidebar's per-feed badges.
2646///
2647/// Counted in SQL. The sidebar used to fetch every unread entry (bodies and all)
2648/// and count them in Rust, on every page with chrome, which is the single most
2649/// frequent instance of the projection problem [`EntryListRow`] describes.
2650pub async fn unread_counts_by_feed(
2651 pool: &SqlitePool,
2652 did: &str,
2653) -> Result<std::collections::HashMap<i64, i64>> {
2654 let (sql, _) = list_query_sql(Projection::FeedCounts, ListView::Unread, None);
2655 let rows =
2656 sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!("{sql} GROUP BY e.feed_id")))
2657 .bind(did)
2658 .fetch_all(pool)
2659 .await
2660 .with_context(|| format!("unread_counts_by_feed failed for {did}"))?;
2661 Ok(rows.into_iter().collect())
2662}
2663
2664/// The `(url, guid)` identity pairs of every cached starred entry for a DID.
2665///
2666/// The starred view matches PDS saved records against these to decide which
2667/// records the cache can render itself. It must span the whole starred set, not
2668/// the visible page: a record that looks uncached gets an un-save button that
2669/// deletes the PDS RECORD rather than un-starring the entry, so narrowing this
2670/// set changes what a click destroys. Identity strings only — no bodies.
2671///
2672/// **Truncation is reported, not absorbed.** The `limit` is a memory backstop,
2673/// but hitting it violates the invariant above — and the first version had no
2674/// way to say so and no `ORDER BY`, so it silently returned an ARBITRARY subset
2675/// and every starred article outside it rendered with a record-destroying
2676/// button. `Truncated` lets the caller fail closed instead, and the ordering
2677/// makes the subset at least deterministic across renders rather than
2678/// whatever the query planner felt like returning.
2679pub enum StarredIdentities {
2680 /// The complete set for this DID.
2681 All(Vec<(Option<String>, String)>),
2682 /// `limit` was reached, so this is a partial set and MUST NOT be used to
2683 /// decide that a record is uncached.
2684 Truncated,
2685}
2686
2687pub async fn starred_identities(
2688 pool: &SqlitePool,
2689 did: &str,
2690 limit: i64,
2691) -> Result<StarredIdentities> {
2692 let (mut sql, n) = list_query_sql(Projection::StarredUrls, ListView::Starred, None);
2693 // One past the limit, so reaching it is distinguishable from landing on it
2694 // exactly. Ordered by id so the rows are stable; `url`/`guid` are not
2695 // guaranteed unique or non-NULL, and the id is both.
2696 //
2697 // The placeholder index comes from `list_query_sql` rather than being
2698 // hardcoded: it was `?2` only because this call passes `None` for the scope,
2699 // which is the kind of coupling that breaks silently when the shared builder
2700 // changes shape — as it just did.
2701 sql.push_str(&format!(" ORDER BY e.id LIMIT ?{}", n + 2));
2702 let rows = sqlx::query_as::<_, (Option<String>, String)>(sqlx::AssertSqlSafe(sql))
2703 .bind(did)
2704 .bind(limit.saturating_add(1))
2705 .fetch_all(pool)
2706 .await
2707 .with_context(|| format!("starred_identities failed for {did}"))?;
2708 if rows.len() as i64 > limit {
2709 return Ok(StarredIdentities::Truncated);
2710 }
2711 Ok(StarredIdentities::All(rows))
2712}
2713
2714/// Mark a single entry read/unread for a DID, upserting the per-DID state row
2715/// and stamping `updated_at`. Preserves any existing `starred` bit. Also
2716/// projects the change into the per-`(did, feed_url)` [`ReadCursor`] and marks
2717/// it `dirty` so the batched flusher pushes it to the PDS (see
2718/// `project_entry_into_cursor`).
2719///
2720/// AUTHORIZED per-DID: the upsert only touches an entry the caller subscribes
2721/// to (`sub_ref`). Returns `true` if a row was written, `false` if `did` does
2722/// not subscribe to the entry's feed (the web layer maps that to a 404 —
2723/// a non-subscriber can never mutate another user's state).
2724pub async fn mark_read(pool: &SqlitePool, did: &str, entry_id: i64, read: bool) -> Result<bool> {
2725 let now = now_rfc3339();
2726 let mut tx = pool.begin().await.context("begin mark_read tx")?;
2727 let res = sqlx::query(
2728 r#"
2729 INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
2730 SELECT ?1, e.id, ?3, 0, ?4
2731 FROM entries e
2732 WHERE e.id = ?2
2733 AND EXISTS (
2734 SELECT 1 FROM sub_ref sr
2735 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
2736 )
2737 ON CONFLICT (did, entry_id) DO UPDATE SET
2738 read = excluded.read,
2739 updated_at = excluded.updated_at
2740 "#,
2741 )
2742 .bind(did)
2743 .bind(entry_id)
2744 .bind(read)
2745 .bind(&now)
2746 .execute(&mut *tx)
2747 .await
2748 .with_context(|| format!("mark_read failed for {did}/{entry_id}"))?;
2749
2750 if res.rows_affected() == 0 {
2751 // Not authorized (no `sub_ref`) — nothing written, no cursor to dirty.
2752 tx.rollback().await.ok();
2753 return Ok(false);
2754 }
2755
2756 // Project the read/unread into this feed's read cursor (dirty=1) so the
2757 // flusher syncs it to the PDS. Same tx as the state write so a crash can't
2758 // leave the two out of step.
2759 project_entry_into_cursor(&mut tx, did, entry_id, read, &now).await?;
2760
2761 tx.commit().await.context("commit mark_read tx")?;
2762 Ok(true)
2763}
2764
2765/// Star/unstar a single entry for a DID (upsert, preserving `read`).
2766///
2767/// AUTHORIZED per-DID like [`mark_read`]: only touches an entry the caller
2768/// subscribes to. Returns `true` if a row was written, `false` if `did` does
2769/// not subscribe (→ 404 at the web layer).
2770pub async fn mark_starred(
2771 pool: &SqlitePool,
2772 did: &str,
2773 entry_id: i64,
2774 starred: bool,
2775) -> Result<bool> {
2776 let res = sqlx::query(
2777 r#"
2778 INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
2779 SELECT ?1, e.id, 0, ?3, ?4
2780 FROM entries e
2781 WHERE e.id = ?2
2782 AND EXISTS (
2783 SELECT 1 FROM sub_ref sr
2784 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
2785 )
2786 ON CONFLICT (did, entry_id) DO UPDATE SET
2787 starred = excluded.starred,
2788 updated_at = excluded.updated_at
2789 "#,
2790 )
2791 .bind(did)
2792 .bind(entry_id)
2793 .bind(starred)
2794 .bind(now_rfc3339())
2795 .execute(pool)
2796 .await
2797 .with_context(|| format!("mark_starred failed for {did}/{entry_id}"))?;
2798 Ok(res.rows_affected() > 0)
2799}
2800
2801/// Fold ids already covered by a high-water-mark into `read_through`, so the
2802/// exception set stops growing. Returns the new `read_through` when it advanced.
2803///
2804/// **What was wrong.** `read_through` was never COMPUTED — `project_entry_into_cursor`
2805/// only carried an existing value through, and it starts NULL, so in practice it
2806/// was always NULL. That left `read_ids` as the sole mechanism, growing one id
2807/// per article read, bounded only by `max_entries_per_feed` (2000) — while the
2808/// flusher caps the record at `ReadState::MAX_IDS` (1000) keeping the TAIL, with
2809/// no log line. Past 1000 read articles in one feed, the oldest read-state
2810/// silently stopped syncing, and those articles came back UNREAD in any other
2811/// atproto reader. The `cap` helper's own comment assumed "the exception sets
2812/// are expected to stay well under the cap in normal use"; against a 2000-entry
2813/// per-feed ceiling that does not hold.
2814///
2815/// **The rule.** `read_through` means "every entry at or before this time is
2816/// read". So it may advance only to a point with no unread entry at or before
2817/// it. That point is computed here as the newest entry timestamp STRICTLY OLDER
2818/// than the oldest unread entry — strictly, because entries can share a
2819/// timestamp, and a watermark equal to an unread entry's time would assert that
2820/// entry is read.
2821///
2822/// Once the watermark moves, every `read_ids` entry at or before it is
2823/// redundant and is dropped — that is the compaction. `unread_ids` is filtered
2824/// the same way; by construction nothing unread sits at or below the new
2825/// watermark, so it empties, but the filter is written rather than assumed so it
2826/// stays correct if that invariant ever shifts.
2827///
2828/// Timestamps compare lexicographically because every writer normalises to UTC
2829/// `...Z` at seconds precision (`feed::fmt_time`, `now_rfc3339`) — the same
2830/// assumption `poll_health` and the retention window already make.
2831pub async fn compact_cursor(
2832 pool: &SqlitePool,
2833 did: &str,
2834 feed_url: &str,
2835) -> Result<Option<String>> {
2836 let mut tx = pool.begin().await.context("begin compact_cursor tx")?;
2837 let (read_through, read_ids, unread_ids) = cursor_sets(&mut tx, did, feed_url).await?;
2838
2839 // The oldest entry on this feed that `did` has NOT read. `NULL` = nothing
2840 // unread, in which case the watermark can cover the whole feed.
2841 let oldest_unread: Option<String> = sqlx::query_scalar(
2842 r#"
2843 SELECT MIN(COALESCE(e.published, e.fetched_at))
2844 FROM entries e
2845 JOIN feeds f ON f.id = e.feed_id
2846 LEFT JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1
2847 WHERE f.url = ?2 AND COALESCE(s.read, 0) = 0
2848 "#,
2849 )
2850 .bind(did)
2851 .bind(feed_url)
2852 .fetch_one(&mut *tx)
2853 .await
2854 .with_context(|| format!("compact_cursor: oldest unread for {did}/{feed_url}"))?;
2855
2856 let watermark: Option<String> = match &oldest_unread {
2857 Some(oldest) => sqlx::query_scalar(
2858 r#"
2859 SELECT MAX(COALESCE(e.published, e.fetched_at))
2860 FROM entries e JOIN feeds f ON f.id = e.feed_id
2861 WHERE f.url = ?1 AND COALESCE(e.published, e.fetched_at) < ?2
2862 "#,
2863 )
2864 .bind(feed_url)
2865 .bind(oldest)
2866 .fetch_one(&mut *tx)
2867 .await
2868 .with_context(|| format!("compact_cursor: watermark for {did}/{feed_url}"))?,
2869 None => sqlx::query_scalar(
2870 r#"
2871 SELECT MAX(COALESCE(e.published, e.fetched_at))
2872 FROM entries e JOIN feeds f ON f.id = e.feed_id
2873 WHERE f.url = ?1
2874 "#,
2875 )
2876 .bind(feed_url)
2877 .fetch_one(&mut *tx)
2878 .await
2879 .with_context(|| format!("compact_cursor: watermark for {did}/{feed_url}"))?,
2880 };
2881
2882 // Nothing to cover, or the watermark is already at least this far along.
2883 // Never move it BACKWARDS: that would re-assert articles as unread.
2884 let Some(watermark) = watermark else {
2885 return Ok(None);
2886 };
2887 if read_through
2888 .as_deref()
2889 .is_some_and(|rt| rt >= &watermark[..])
2890 {
2891 return Ok(None);
2892 }
2893
2894 let keep_above = ids_published_after(&mut tx, feed_url, &read_ids, &watermark).await?;
2895 let keep_unread =
2896 ids_published_at_or_before(&mut tx, feed_url, &unread_ids, &watermark).await?;
2897
2898 write_cursor_sets(
2899 &mut tx,
2900 did,
2901 feed_url,
2902 Some(&watermark),
2903 &keep_above,
2904 &keep_unread,
2905 &now_rfc3339(),
2906 )
2907 .await?;
2908 tx.commit().await.context("commit compact_cursor tx")?;
2909 Ok(Some(watermark))
2910}
2911
2912/// The subset of `ids` whose entries are published strictly AFTER `watermark`,
2913/// as the canonical JSON array-of-strings the cursor stores.
2914async fn ids_published_after(
2915 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
2916 feed_url: &str,
2917 ids: &str,
2918 watermark: &str,
2919) -> Result<String> {
2920 let live = ids_matching_watermark(tx, feed_url, watermark, true).await?;
2921 Ok(filter_id_set_to_live(ids, &live))
2922}
2923
2924/// The subset of `ids` whose entries are published at or BEFORE `watermark`.
2925async fn ids_published_at_or_before(
2926 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
2927 feed_url: &str,
2928 ids: &str,
2929 watermark: &str,
2930) -> Result<String> {
2931 let live = ids_matching_watermark(tx, feed_url, watermark, false).await?;
2932 Ok(filter_id_set_to_live(ids, &live))
2933}
2934
2935/// Entry ids on `feed_url` on one side of `watermark`. `after = true` selects
2936/// strictly newer; `false` selects at-or-older.
2937async fn ids_matching_watermark(
2938 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
2939 feed_url: &str,
2940 watermark: &str,
2941 after: bool,
2942) -> Result<std::collections::HashSet<i64>> {
2943 let sql = if after {
2944 "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id \
2945 WHERE f.url = ?1 AND COALESCE(e.published, e.fetched_at) > ?2"
2946 } else {
2947 "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id \
2948 WHERE f.url = ?1 AND COALESCE(e.published, e.fetched_at) <= ?2"
2949 };
2950 Ok(sqlx::query_scalar::<_, i64>(sql)
2951 .bind(feed_url)
2952 .bind(watermark)
2953 .fetch_all(&mut **tx)
2954 .await
2955 .context("compact_cursor: ids on one side of the watermark")?
2956 .into_iter()
2957 .collect())
2958}
2959
2960/// Clear `did`'s star on any cached entry matching `url` or `guid`, **ignoring
2961/// the subscription projection**. Returns the number of `entry_state` rows
2962/// changed.
2963///
2964/// This closes a desync between the two places a star lives. The starred view
2965/// matches PDS saved records against cached entries through `sub_ref`, so an
2966/// entry that is cached AND starred in a feed the reader has since UNSUBSCRIBED
2967/// from does not match: it renders as an uncached row whose button is
2968/// `POST /saved/{rkey}/delete`. That deletes the PDS record and used to leave
2969/// `entry_state.starred = 1` behind — invisible, because the starred list is
2970/// `sub_ref`-scoped too, until the reader resubscribes and the star reappears
2971/// with no record backing it.
2972///
2973/// **Why omitting `sub_ref` is safe here, when it is the per-DID isolation hook
2974/// everywhere else.** Every row this can touch is keyed by `did` and this writes
2975/// only `starred = 0`. The worst a caller can do with it is clear one of their
2976/// OWN stars — which is what they just asked for. The predicate that matters for
2977/// isolation is the `did` in the `WHERE`, and it is not optional.
2978///
2979/// Matching on `url` OR `guid` mirrors how the view decides a record is already
2980/// cached, so the removal path and the render path agree on what "the same
2981/// article" means.
2982pub async fn clear_star_by_identity(
2983 pool: &SqlitePool,
2984 did: &str,
2985 url: Option<&str>,
2986 guid: Option<&str>,
2987) -> Result<u64> {
2988 // Neither identifier present: nothing to match on. Running the statement
2989 // would compare NULL to NULL and match nothing, but returning early says so.
2990 if url.is_none_or(str::is_empty) && guid.is_none_or(str::is_empty) {
2991 return Ok(0);
2992 }
2993 let res = sqlx::query(
2994 r#"
2995 UPDATE entry_state
2996 SET starred = 0, updated_at = ?4
2997 WHERE did = ?1
2998 AND starred = 1
2999 AND entry_id IN (
3000 SELECT id FROM entries
3001 WHERE (?2 IS NOT NULL AND url = ?2)
3002 OR (?3 IS NOT NULL AND guid = ?3)
3003 )
3004 "#,
3005 )
3006 .bind(did)
3007 .bind(url.filter(|u| !u.is_empty()))
3008 .bind(guid.filter(|g| !g.is_empty()))
3009 .bind(now_rfc3339())
3010 .execute(pool)
3011 .await
3012 .with_context(|| format!("clear_star_by_identity failed for {did}"))?;
3013 Ok(res.rows_affected())
3014}
3015
3016/// Mark every entry of a feed read (or unread) for a DID in one statement —
3017/// backs the "mark-all-read (per feed)" action. Also projects the change into
3018/// the feed's per-DID [`ReadCursor`] (dirty=1) so the batched flusher syncs the
3019/// new read-state to the PDS.
3020pub async fn mark_feed_read(pool: &SqlitePool, did: &str, feed_id: i64, read: bool) -> Result<u64> {
3021 let now = now_rfc3339();
3022 let mut tx = pool.begin().await.context("begin mark_feed_read tx")?;
3023 let res = sqlx::query(
3024 r#"
3025 INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
3026 SELECT ?1, e.id, ?2, 0, ?3 FROM entries e
3027 WHERE e.feed_id = ?4
3028 AND EXISTS (
3029 SELECT 1 FROM sub_ref sr
3030 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
3031 )
3032 ON CONFLICT (did, entry_id) DO UPDATE SET
3033 read = excluded.read,
3034 updated_at = excluded.updated_at
3035 "#,
3036 )
3037 .bind(did)
3038 .bind(read)
3039 .bind(&now)
3040 .bind(feed_id)
3041 .execute(&mut *tx)
3042 .await
3043 .with_context(|| format!("mark_feed_read failed for {did}/feed {feed_id}"))?;
3044
3045 if res.rows_affected() > 0 {
3046 // Project every affected entry into this feed's read cursor. `feed_id`
3047 // maps to exactly one feed URL, so this is a single per-feed cursor —
3048 // batched, not per-article. Only runs when the caller was authorized
3049 // (some rows changed), so an unsubscribed feed leaves no cursor behind.
3050 project_feed_into_cursor(&mut tx, did, feed_id, read, &now).await?;
3051 }
3052
3053 tx.commit().await.context("commit mark_feed_read tx")?;
3054 Ok(res.rows_affected())
3055}
3056
3057// ---------------------------------------------------------------------------
3058// Read-cursor projection (wires the local read/unread mutation into the
3059// PDS-bound `read_cursor`, so the batched flusher actually pushes read-state)
3060// ---------------------------------------------------------------------------
3061
3062/// Add or remove an entry id from a JSON id-array string, returning the new JSON.
3063/// Membership is set-like (no duplicates) and order-stable (append on add). A
3064/// malformed input is treated as empty so a cosmetic parse issue never blocks a
3065/// projection.
3066fn json_id_set_toggle(raw: &str, id: i64, present: bool) -> String {
3067 let mut ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
3068 .ok()
3069 .map(|vals| {
3070 vals.into_iter()
3071 .filter_map(|v| match v {
3072 serde_json::Value::Number(n) => n.as_i64(),
3073 serde_json::Value::String(s) => s.parse::<i64>().ok(),
3074 _ => None,
3075 })
3076 .collect()
3077 })
3078 .unwrap_or_default();
3079 if present {
3080 if !ids.contains(&id) {
3081 ids.push(id);
3082 }
3083 } else {
3084 ids.retain(|&x| x != id);
3085 }
3086 // Serialize as a JSON array of strings (the shape the flusher / lexicon
3087 // expect — `community.lexicon.rss.readState.readIds` is a string array).
3088 let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
3089 serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
3090}
3091
3092/// The feed URL owning `feed_id`, if the row exists (cursors are keyed by URL,
3093/// not feed id — they mirror the PDS-side `readState.feedUrl`).
3094async fn feed_url_for_id_tx(
3095 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3096 feed_id: i64,
3097) -> Result<Option<String>> {
3098 let url: Option<String> = sqlx::query_scalar("SELECT url FROM feeds WHERE id = ?1")
3099 .bind(feed_id)
3100 .fetch_optional(&mut **tx)
3101 .await
3102 .with_context(|| format!("feed_url_for_id_tx failed for feed {feed_id}"))?;
3103 Ok(url)
3104}
3105
3106/// Fetch the (read_through, read_ids, unread_ids) of an existing cursor, or the
3107/// empty defaults if there is none yet.
3108async fn cursor_sets(
3109 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3110 did: &str,
3111 feed_url: &str,
3112) -> Result<(Option<String>, String, String)> {
3113 let row = sqlx::query(
3114 "SELECT read_through, read_ids, unread_ids FROM read_cursor \
3115 WHERE did = ?1 AND feed_url = ?2",
3116 )
3117 .bind(did)
3118 .bind(feed_url)
3119 .fetch_optional(&mut **tx)
3120 .await
3121 .with_context(|| format!("cursor_sets failed for {did}/{feed_url}"))?;
3122 Ok(match row {
3123 Some(r) => (
3124 r.get::<Option<String>, _>("read_through"),
3125 r.get::<String, _>("read_ids"),
3126 r.get::<String, _>("unread_ids"),
3127 ),
3128 None => (None, "[]".to_string(), "[]".to_string()),
3129 })
3130}
3131
3132/// Upsert the cursor row for `(did, feed_url)` with the given exception sets,
3133/// stamping `updated_at` and marking it `dirty` so `dirty_cursors` returns it.
3134async fn write_cursor_sets(
3135 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3136 did: &str,
3137 feed_url: &str,
3138 read_through: Option<&str>,
3139 read_ids: &str,
3140 unread_ids: &str,
3141 now: &str,
3142) -> Result<()> {
3143 sqlx::query(
3144 r#"
3145 INSERT INTO read_cursor
3146 (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
3147 VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6)
3148 ON CONFLICT (did, feed_url) DO UPDATE SET
3149 read_through = excluded.read_through,
3150 read_ids = excluded.read_ids,
3151 unread_ids = excluded.unread_ids,
3152 dirty = 1,
3153 updated_at = excluded.updated_at
3154 "#,
3155 )
3156 .bind(did)
3157 .bind(feed_url)
3158 .bind(read_through)
3159 .bind(read_ids)
3160 .bind(unread_ids)
3161 .bind(now)
3162 .execute(&mut **tx)
3163 .await
3164 .with_context(|| format!("write_cursor_sets failed for {did}/{feed_url}"))?;
3165 Ok(())
3166}
3167
3168/// Project a single entry's read/unread flip into its feed's read cursor.
3169///
3170/// The cursor mirrors `community.lexicon.rss.readState`: a `read_through`
3171/// high-water-mark plus two bounded exception sets. A per-article flip is
3172/// recorded in those sets (`read_ids` when read, `unread_ids` when unread), the
3173/// opposite set is cleared of the id, and the cursor is stamped + marked dirty.
3174/// This keeps the write batched by touching only the ONE per-feed cursor. (Note:
3175/// there is no compaction step yet that folds covered ids back into
3176/// `read_through`; the exception sets are expected to stay well under the cap.)
3177async fn project_entry_into_cursor(
3178 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3179 did: &str,
3180 entry_id: i64,
3181 read: bool,
3182 now: &str,
3183) -> Result<()> {
3184 // The entry's feed id → feed URL (the cursor key).
3185 let feed_id: Option<i64> = sqlx::query_scalar("SELECT feed_id FROM entries WHERE id = ?1")
3186 .bind(entry_id)
3187 .fetch_optional(&mut **tx)
3188 .await
3189 .with_context(|| format!("project_entry_into_cursor: feed_id for entry {entry_id}"))?;
3190 let feed_id = match feed_id {
3191 Some(f) => f,
3192 None => return Ok(()), // entry vanished mid-tx; nothing to project
3193 };
3194 let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
3195 Some(u) => u,
3196 None => return Ok(()),
3197 };
3198
3199 let (read_through, read_ids, unread_ids) = cursor_sets(tx, did, &feed_url).await?;
3200 // read=true: id joins read_ids, leaves unread_ids. read=false: the inverse.
3201 let read_ids = json_id_set_toggle(&read_ids, entry_id, read);
3202 let unread_ids = json_id_set_toggle(&unread_ids, entry_id, !read);
3203 write_cursor_sets(
3204 tx,
3205 did,
3206 &feed_url,
3207 read_through.as_deref(),
3208 &read_ids,
3209 &unread_ids,
3210 now,
3211 )
3212 .await
3213}
3214
3215/// Project a mark-all-feed-read/unread into that feed's single read cursor.
3216///
3217/// Every entry the caller subscribes to on `feed_id` is folded into the cursor
3218/// in one write: on mark-all-READ each id joins `read_ids` (and leaves
3219/// `unread_ids`); on mark-all-UNREAD the inverse. Still ONE per-feed cursor row
3220/// (batched), stamped + dirtied for the flusher.
3221async fn project_feed_into_cursor(
3222 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3223 did: &str,
3224 feed_id: i64,
3225 read: bool,
3226 now: &str,
3227) -> Result<()> {
3228 let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
3229 Some(u) => u,
3230 None => return Ok(()),
3231 };
3232
3233 // The entry ids on this feed the caller is authorized for (subscribes to).
3234 let ids: Vec<i64> = sqlx::query_scalar(
3235 r#"
3236 SELECT e.id FROM entries e
3237 WHERE e.feed_id = ?2
3238 AND EXISTS (
3239 SELECT 1 FROM sub_ref sr
3240 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
3241 )
3242 "#,
3243 )
3244 .bind(did)
3245 .bind(feed_id)
3246 .fetch_all(&mut **tx)
3247 .await
3248 .with_context(|| format!("project_feed_into_cursor: entry ids for {did}/feed {feed_id}"))?;
3249
3250 let (read_through, mut read_ids, mut unread_ids) = cursor_sets(tx, did, &feed_url).await?;
3251 for id in ids {
3252 read_ids = json_id_set_toggle(&read_ids, id, read);
3253 unread_ids = json_id_set_toggle(&unread_ids, id, !read);
3254 }
3255 write_cursor_sets(
3256 tx,
3257 did,
3258 &feed_url,
3259 read_through.as_deref(),
3260 &read_ids,
3261 &unread_ids,
3262 now,
3263 )
3264 .await
3265}
3266
3267/// Test-only unbounded convenience wrappers over [`list_entries`].
3268///
3269/// Production code passes an explicit `limit`, because that is the whole point
3270/// of the change these replaced. Fixtures hold a handful of rows and asserting
3271/// on "the whole list" is what the tests actually mean, so they get a helper
3272/// with a stated ceiling instead of each spelling one out — and the ceiling is
3273/// high enough that a test hitting it is a broken fixture, not a truncation.
3274#[cfg(test)]
3275mod test_helpers {
3276 use super::*;
3277
3278 /// Far above any fixture; a test that reaches it has a bug of its own.
3279 const FIXTURE_MAX: i64 = 10_000;
3280
3281 pub(crate) async fn entries_for_feed(
3282 pool: &SqlitePool,
3283 did: &str,
3284 feed_id: i64,
3285 ) -> Result<Vec<EntryListRow>> {
3286 list_entries(pool, did, ListView::All, Some(&[feed_id]), FIXTURE_MAX, 0).await
3287 }
3288
3289 pub(crate) async fn get_unread_for_did(
3290 pool: &SqlitePool,
3291 did: &str,
3292 ) -> Result<Vec<EntryListRow>> {
3293 list_entries(pool, did, ListView::Unread, None, FIXTURE_MAX, 0).await
3294 }
3295
3296 pub(crate) async fn get_starred_for_did(
3297 pool: &SqlitePool,
3298 did: &str,
3299 ) -> Result<Vec<EntryListRow>> {
3300 list_entries(pool, did, ListView::Starred, None, FIXTURE_MAX, 0).await
3301 }
3302}
3303
3304#[cfg(test)]
3305pub(crate) use test_helpers::{entries_for_feed, get_starred_for_did, get_unread_for_did};
3306
3307/// Insert or update a per-`(did, feed_url)` read cursor, stamping `updated_at`.
3308/// The write path for local mark-read updates (and the seam a login-time PDS
3309/// merge would use, once that is wired).
3310pub async fn upsert_cursor(pool: &SqlitePool, cursor: &ReadCursor) -> Result<()> {
3311 sqlx::query(
3312 r#"
3313 INSERT INTO read_cursor
3314 (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
3315 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
3316 ON CONFLICT (did, feed_url) DO UPDATE SET
3317 read_through = excluded.read_through,
3318 read_ids = excluded.read_ids,
3319 unread_ids = excluded.unread_ids,
3320 dirty = excluded.dirty,
3321 updated_at = excluded.updated_at
3322 "#,
3323 )
3324 .bind(&cursor.did)
3325 .bind(&cursor.feed_url)
3326 .bind(&cursor.read_through)
3327 .bind(&cursor.read_ids)
3328 .bind(&cursor.unread_ids)
3329 .bind(cursor.dirty)
3330 .bind(&cursor.updated_at)
3331 .execute(pool)
3332 .await
3333 .with_context(|| {
3334 format!(
3335 "upsert_cursor failed for {}/{}",
3336 cursor.did, cursor.feed_url
3337 )
3338 })?;
3339 Ok(())
3340}
3341
3342/// Fetch a single read cursor, if present.
3343pub async fn get_cursor(
3344 pool: &SqlitePool,
3345 did: &str,
3346 feed_url: &str,
3347) -> Result<Option<ReadCursor>> {
3348 let cursor = sqlx::query_as::<_, ReadCursor>(
3349 "SELECT * FROM read_cursor WHERE did = ?1 AND feed_url = ?2",
3350 )
3351 .bind(did)
3352 .bind(feed_url)
3353 .fetch_optional(pool)
3354 .await
3355 .context("get_cursor failed")?;
3356 Ok(cursor)
3357}
3358
3359/// The flusher's hot query: every cursor with `dirty = 1` for a DID — the ones
3360/// whose read-state changed since the last batched PDS flush.
3361/// How many DIDs hold read-state that cannot currently be flushed: dirty
3362/// cursors with no OAuth session to send them with.
3363///
3364/// **The visible form of the parked state (#117).** The flusher deliberately
3365/// stops warning about these every round, and quiet-and-invisible would be a
3366/// worse bug than the noisy loop it replaces — so the count is surfaced on
3367/// `/admin/metrics`. A non-zero number is not itself an alarm: it is the normal
3368/// state of anyone signed out with unsynced reads. A number that only ever
3369/// grows is the thing to look at.
3370///
3371/// Rust-backend shaped: it asks about `oauth_session`, which is the Rust
3372/// backend's store. On the sidecar backend it over-reports, since those
3373/// sessions live in the sidecar's own database. Prod runs `rust` and the
3374/// sidecar is removed by #18.
3375pub async fn parked_readstate_dids(pool: &SqlitePool) -> Result<i64> {
3376 let row: (i64,) = sqlx::query_as(
3377 r#"
3378 SELECT COUNT(DISTINCT rc.did)
3379 FROM read_cursor rc
3380 WHERE rc.dirty = 1
3381 AND NOT EXISTS (SELECT 1 FROM oauth_session s WHERE s.sub = rc.did)
3382 "#,
3383 )
3384 .fetch_one(pool)
3385 .await
3386 .context("counting parked read-state DIDs")?;
3387 Ok(row.0)
3388}
3389
3390pub async fn dirty_cursors(pool: &SqlitePool, did: &str) -> Result<Vec<ReadCursor>> {
3391 let cursors =
3392 sqlx::query_as::<_, ReadCursor>("SELECT * FROM read_cursor WHERE did = ?1 AND dirty = 1")
3393 .bind(did)
3394 .fetch_all(pool)
3395 .await
3396 .with_context(|| format!("dirty_cursors failed for {did}"))?;
3397 Ok(cursors)
3398}
3399
3400// ---------------------------------------------------------------------------
3401// Network observations (the adoption probe's projection)
3402// ---------------------------------------------------------------------------
3403
3404/// Record one relay's observation, keyed by `(key, source)` so each relay's
3405/// number is kept separately (non-archival relays legitimately disagree).
3406///
3407/// An upsert: the table is bounded forever at (metrics × relays) rows — two
3408/// today — so this can never grow the DB. It must stay an upsert and never
3409/// become a per-DID insert.
3410///
3411/// **A truncated observation never lowers a stored count.** A truncated walk
3412/// saw only part of the network, so a smaller number is evidence about the
3413/// *walk*, not about adoption. Without the guard, one slow run that managed a
3414/// single 500-repo page would overwrite a complete 2 000 and drag the published
3415/// "at least N" down — and because `latest_network_stat` takes the max across
3416/// sources, two relays behind the same operator degrade together, so `/about`
3417/// would sit at the lower figure until a full walk succeeded again. A COMPLETE
3418/// observation always wins, even when smaller (repos genuinely can disappear);
3419/// a truncated one may only ever raise the floor — and an EQUAL count raises
3420/// nothing, so it is rejected too. That is why the guard reads `<=` and not
3421/// `<`: the strict form let a truncated walk that merely matched the stored
3422/// number rewrite the row and flip `truncated` on, degrading "2 000" to "at
3423/// least 2 000" with no change in adoption.
3424pub async fn record_network_stat(pool: &SqlitePool, stat: &NetworkStat) -> Result<()> {
3425 sqlx::query(
3426 r#"
3427 INSERT INTO network_stat (key, source, value, truncated, observed_at)
3428 VALUES (?1, ?2, ?3, ?4, ?5)
3429 ON CONFLICT (key, source) DO UPDATE SET
3430 value = excluded.value,
3431 truncated = excluded.truncated,
3432 observed_at = excluded.observed_at
3433 WHERE NOT (excluded.truncated = 1 AND excluded.value <= network_stat.value)
3434 "#,
3435 )
3436 .bind(&stat.key)
3437 .bind(&stat.source)
3438 .bind(stat.value)
3439 .bind(stat.truncated)
3440 .bind(&stat.observed_at)
3441 .execute(pool)
3442 .await
3443 .with_context(|| {
3444 format!(
3445 "record_network_stat failed for {}/{}",
3446 stat.key, stat.source
3447 )
3448 })?;
3449 Ok(())
3450}
3451
3452/// The highest observation for `key` across every relay — the number to surface
3453/// (`design/NETWORK-SPEC.md` §4.1: relays disagree; show the max). `None` when no
3454/// probe has ever succeeded.
3455pub async fn latest_network_stat(pool: &SqlitePool, key: &str) -> Result<Option<NetworkStat>> {
3456 let stat = sqlx::query_as::<_, NetworkStat>(
3457 "SELECT key, source, value, truncated, observed_at FROM network_stat \
3458 WHERE key = ?1 ORDER BY value DESC, observed_at DESC LIMIT 1",
3459 )
3460 .bind(key)
3461 .fetch_optional(pool)
3462 .await
3463 .with_context(|| format!("latest_network_stat failed for {key}"))?;
3464 Ok(stat)
3465}
3466
3467/// Mark a cursor's PDS `readState` record as CREATED after the flush that first
3468/// created it, so subsequent flushes emit an `update` instead of another
3469/// `create`. Idempotent; a no-op if the row is gone.
3470pub async fn mark_cursor_pds_created(pool: &SqlitePool, did: &str, feed_url: &str) -> Result<()> {
3471 sqlx::query("UPDATE read_cursor SET pds_created = 1 WHERE did = ?1 AND feed_url = ?2")
3472 .bind(did)
3473 .bind(feed_url)
3474 .execute(pool)
3475 .await
3476 .with_context(|| format!("mark_cursor_pds_created failed for {did}/{feed_url}"))?;
3477 Ok(())
3478}
3479
3480/// Clear the `dirty` flag on a cursor after a successful PDS flush — but ONLY if
3481/// the row still carries the exact `flushed_updated_at` snapshot we flushed.
3482///
3483/// The flusher reads a cursor, sends it to the PDS (a network round-trip), then
3484/// clears `dirty`. A concurrent [`upsert_cursor`] (a fresh mark-read) can land
3485/// DURING that in-flight write, bumping `updated_at` and re-setting `dirty = 1`
3486/// for reads that were NOT in the flushed snapshot. An unconditional
3487/// `SET dirty = 0` would silently drop those reads. Guarding on the snapshot's
3488/// `updated_at` makes this a compare-and-swap: if `updated_at` changed under us,
3489/// zero rows update, the row stays dirty, and it re-flushes next round.
3490pub async fn clear_cursor_dirty(
3491 pool: &SqlitePool,
3492 did: &str,
3493 feed_url: &str,
3494 flushed_updated_at: &str,
3495) -> Result<()> {
3496 sqlx::query(
3497 "UPDATE read_cursor SET dirty = 0 \
3498 WHERE did = ?1 AND feed_url = ?2 AND updated_at = ?3",
3499 )
3500 .bind(did)
3501 .bind(feed_url)
3502 .bind(flushed_updated_at)
3503 .execute(pool)
3504 .await
3505 .context("clear_cursor_dirty failed")?;
3506 Ok(())
3507}
3508
3509// ---------------------------------------------------------------------------
3510// Closed-beta invite gate (beta_access + invite_codes)
3511// ---------------------------------------------------------------------------
3512//
3513// Ported in SHAPE from a prior Go beta-gate (RedeemCode / CreateInviteCode /
3514// code_gen) but deliberately trimmed for FeatherReader's before-public
3515// experiment: NO viral invite-budget tree, NO generation cap, NO waitlist /
3516// invite-request table, and SQLite instead of Mongo. A code is minted by an
3517// existing member (or admin), and redeeming it grants a seat while seats remain
3518// under the configured cap.
3519
3520/// Unix-epoch seconds for "now" — the integer time base for the beta tables.
3521pub(crate) fn now_unix() -> i64 {
3522 chrono::Utc::now().timestamp()
3523}
3524
3525/// The invite-code alphabet: uppercase letters + digits with the
3526/// visually-ambiguous glyphs removed (`I`, `O`, `0`, `1`) so a code read aloud
3527/// or copied by hand is unambiguous.
3528const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
3529
3530/// Human-facing prefix so a FeatherReader invite code is recognisable at a
3531/// glance.
3532const CODE_PREFIX: &str = "FEATHER-";
3533
3534/// Number of random characters after the prefix.
3535const CODE_BODY_LEN: usize = 8;
3536
3537/// Generate a random, unguessable invite code of the form `FEATHER-XXXXXXXX`.
3538///
3539/// Draws from the OS CSPRNG (`getrandom`) and maps each byte onto
3540/// `CODE_ALPHABET` via rejection sampling so the alphabet distribution is
3541/// uniform (no modulo bias). Infallible in practice; a `getrandom` failure
3542/// (no entropy source) propagates as an error rather than a weak code.
3543pub fn generate_invite_code() -> Result<String> {
3544 let n = CODE_ALPHABET.len() as u16; // 31
3545 // Largest multiple of `n` that fits in a byte; bytes at or above it are
3546 // rejected so every accepted byte maps uniformly onto the alphabet.
3547 let limit = 256 / n * n; // 256 - (256 % n)
3548 let mut out = String::with_capacity(CODE_PREFIX.len() + CODE_BODY_LEN);
3549 out.push_str(CODE_PREFIX);
3550 let mut got = 0;
3551 let mut buf = [0u8; 1];
3552 while got < CODE_BODY_LEN {
3553 getrandom::fill(&mut buf).context("getrandom failed while minting invite code")?;
3554 let b = buf[0] as u16;
3555 if b < limit {
3556 out.push(CODE_ALPHABET[(b % n) as usize] as char);
3557 got += 1;
3558 }
3559 }
3560 Ok(out)
3561}
3562
3563/// Whether a DID currently holds a beta seat.
3564pub async fn has_beta_access(pool: &SqlitePool, did: &str) -> Result<bool> {
3565 let row = sqlx::query("SELECT 1 FROM beta_access WHERE did = ?1")
3566 .bind(did)
3567 .fetch_optional(pool)
3568 .await
3569 .with_context(|| format!("has_beta_access failed for {did}"))?;
3570 Ok(row.is_some())
3571}
3572
3573/// Count the beta seats currently granted — the numerator checked against the
3574/// configured cap on redeem.
3575pub async fn count_beta_access(pool: &SqlitePool) -> Result<i64> {
3576 let row = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
3577 .fetch_one(pool)
3578 .await
3579 .context("count_beta_access failed")?;
3580 Ok(row.get::<i64, _>("n"))
3581}
3582
3583/// Count `active`, unexpired invite codes — the outstanding-but-unredeemed seats
3584/// a bot has already promised. Added to [`count_beta_access`] this is the "seats
3585/// committed" figure the bot mint path (`POST /bot/claims`) checks against the
3586/// cap, so it doesn't over-promise more claims than seats remain (the redeem-time
3587/// cap in [`redeem_code`] is the hard backstop; this avoids telling a follower
3588/// "you're in" for a seat that will be full by the time they claim it).
3589pub async fn count_active_codes(pool: &SqlitePool) -> Result<i64> {
3590 let now = now_unix();
3591 let row = sqlx::query(
3592 "SELECT COUNT(*) AS n FROM invite_codes WHERE status = 'active' AND expires_at >= ?1",
3593 )
3594 .bind(now)
3595 .fetch_one(pool)
3596 .await
3597 .context("count_active_codes failed")?;
3598 Ok(row.get::<i64, _>("n"))
3599}
3600
3601/// Grant a beta seat directly (admin / seed path — no code consumed). Idempotent
3602/// on `did` (re-granting updates the row rather than erroring).
3603pub async fn grant_access(
3604 pool: &SqlitePool,
3605 did: &str,
3606 handle: Option<&str>,
3607 granted_by: &str,
3608 invite_code_used: Option<&str>,
3609) -> Result<()> {
3610 sqlx::query(
3611 r#"
3612 INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
3613 VALUES (?1, ?2, ?3, ?4, ?5)
3614 ON CONFLICT (did) DO UPDATE SET
3615 handle = COALESCE(excluded.handle, beta_access.handle),
3616 granted_by = excluded.granted_by,
3617 invite_code_used = COALESCE(excluded.invite_code_used, beta_access.invite_code_used)
3618 "#,
3619 )
3620 .bind(did)
3621 .bind(handle)
3622 .bind(granted_by)
3623 .bind(now_unix())
3624 .bind(invite_code_used)
3625 .execute(pool)
3626 .await
3627 .with_context(|| format!("grant_access failed for {did}"))?;
3628 Ok(())
3629}
3630
3631/// Mint a new `active` invite code owned by `creator_did`, expiring `ttl_secs`
3632/// from now. Returns the generated code string. The browser/admin path leaves the
3633/// bot idempotency key (`intended_did`) NULL; see [`mint_code_for_did`] for the
3634/// bot path that records the target follower.
3635pub async fn mint_code(pool: &SqlitePool, creator_did: &str, ttl_secs: i64) -> Result<String> {
3636 mint_code_inner(pool, creator_did, ttl_secs, None).await
3637}
3638
3639/// Like [`mint_code`] but records the follower `intended_did` the code is minted
3640/// FOR, so a later `POST /bot/claims` for the same DID can return the SAME code
3641/// (see [`find_active_code_for_did`]) rather than minting a duplicate. This is the
3642/// app-side idempotency backstop that survives a bot-host state loss.
3643pub async fn mint_code_for_did(
3644 pool: &SqlitePool,
3645 creator_did: &str,
3646 ttl_secs: i64,
3647 intended_did: &str,
3648) -> Result<String> {
3649 mint_code_inner(pool, creator_did, ttl_secs, Some(intended_did)).await
3650}
3651
3652async fn mint_code_inner(
3653 pool: &SqlitePool,
3654 creator_did: &str,
3655 ttl_secs: i64,
3656 intended_did: Option<&str>,
3657) -> Result<String> {
3658 let code = generate_invite_code()?;
3659 let now = now_unix();
3660 let expires_at = now.saturating_add(ttl_secs.max(0));
3661 sqlx::query(
3662 r#"
3663 INSERT INTO invite_codes
3664 (code, creator_did, status, invitee_did, intended_did, created_at, expires_at, redeemed_at)
3665 VALUES (?1, ?2, 'active', NULL, ?3, ?4, ?5, NULL)
3666 "#,
3667 )
3668 .bind(&code)
3669 .bind(creator_did)
3670 .bind(intended_did)
3671 .bind(now)
3672 .bind(expires_at)
3673 .execute(pool)
3674 .await
3675 .with_context(|| format!("mint_code failed for creator {creator_did}"))?;
3676 Ok(code)
3677}
3678
3679/// Does this error chain represent the partial-unique-index conflict raised when
3680/// a SECOND active claim is minted for a DID that already has one
3681/// (`idx_invite_codes_intended_active`)? The web layer uses this to recover from a
3682/// lost mint race (S4): on a conflict it re-reads the winner's code instead of
3683/// 500-ing. Matches on the sqlx `Database` error's UNIQUE-constraint code (SQLite
3684/// 2067 / primary 19) AND the offending COLUMN in the message
3685/// (`invite_codes.intended_did` — SQLite names the column(s), not the index), so an
3686/// unrelated constraint violation (e.g. the `code` PRIMARY KEY) is NOT swallowed.
3687pub fn is_intended_active_conflict(err: &anyhow::Error) -> bool {
3688 for cause in err.chain() {
3689 if let Some(sqlx::Error::Database(db)) = cause.downcast_ref::<sqlx::Error>() {
3690 let msg = db.message();
3691 // SQLite reports UNIQUE violations with (primary) code 19 /
3692 // (extended) 2067; the message names the offending column(s), e.g.
3693 // "UNIQUE constraint failed: invite_codes.intended_did".
3694 let is_unique = db.code().as_deref() == Some("2067")
3695 || db.code().as_deref() == Some("19")
3696 || msg.contains("UNIQUE constraint failed");
3697 // Scope to the intended_did index specifically. Only that index and the
3698 // `code` PRIMARY KEY can raise a UNIQUE error here; the partial unique
3699 // index is the only one over `intended_did`, so the column reference
3700 // uniquely identifies it.
3701 if is_unique && msg.contains("invite_codes.intended_did") {
3702 return true;
3703 }
3704 }
3705 }
3706 false
3707}
3708
3709/// The `code` of an outstanding (`active`, unexpired) invite minted FOR the
3710/// follower `intended_did`, if one exists — the app-side idempotency lookup for
3711/// `POST /bot/claims`. `Some(code)` means "return this existing code, do NOT mint
3712/// a second"; `None` means "no live code for this DID — mint one".
3713///
3714/// S3 — this lookup ONLY returns `active`, UNEXPIRED codes; once a code passes
3715/// `expires_at` (or `expire_old_codes` flips it to `expired`) this returns `None`,
3716/// so the next `POST /bot/claims` MINTS A FRESH code for the DID. There is no
3717/// in-place "refresh" of an expired code (the partial-unique index only constrains
3718/// `active` rows, so a fresh mint after expiry is allowed). The bot then re-posts:
3719/// its record rkey is deterministic per DID, so the existing skeet is UPDATED in
3720/// place with the new claim URL (see the bot's `reconcile_stale_record`, S1) rather
3721/// than a second skeet being posted. NOTE: a bot-`delivered` follower whose link
3722/// expired UNCLAIMED is only re-minted if the bot re-processes that DID (a re-seen
3723/// follow, a `waitlisted` retry, or a bot-store reset); manual recovery is to clear
3724/// the bot's `handled` row for that DID so the next cycle re-mints + re-posts.
3725/// If several live codes somehow exist (a race), the soonest-expiring is returned.
3726pub async fn find_active_code_for_did(
3727 pool: &SqlitePool,
3728 intended_did: &str,
3729) -> Result<Option<String>> {
3730 let now = now_unix();
3731 let row = sqlx::query(
3732 "SELECT code FROM invite_codes
3733 WHERE intended_did = ?1 AND status = 'active' AND expires_at >= ?2
3734 ORDER BY expires_at ASC
3735 LIMIT 1",
3736 )
3737 .bind(intended_did)
3738 .bind(now)
3739 .fetch_optional(pool)
3740 .await
3741 .with_context(|| format!("find_active_code_for_did failed for {intended_did}"))?;
3742 Ok(row.map(|r| r.get::<String, _>("code")))
3743}
3744
3745/// Atomically redeem an invite code for `did`, granting a beta seat.
3746///
3747/// Runs entirely in one transaction so the capacity check and the seat grant
3748/// cannot race (two redeems can't both slip past a `cap - 1` count). Steps:
3749/// 1. verify the code exists, is `active`, and is not past `expires_at`;
3750/// 2. verify the current seat count is `< cap`;
3751/// 3. flip the code `active`→`redeemed` (stamping `invitee_did` + `redeemed_at`);
3752/// 4. insert the `beta_access` row.
3753///
3754/// On a policy failure returns the matching [`RedeemError`] (the tx rolls back);
3755/// a real SQLite error propagates as the outer [`anyhow::Error`].
3756pub async fn redeem_code(
3757 pool: &SqlitePool,
3758 code: &str,
3759 did: &str,
3760 handle: Option<&str>,
3761 cap: i64,
3762) -> Result<std::result::Result<(), RedeemError>> {
3763 let now = now_unix();
3764 let mut tx = pool.begin().await.context("begin redeem_code tx")?;
3765
3766 // Take the write lock at the START of the transaction. sqlx issues a plain
3767 // deferred BEGIN, so without this the capacity SELECT below runs under a read
3768 // snapshot: two concurrent redeems could both pass the gate, and the loser's
3769 // later UPDATE would fail with SQLITE_BUSY_SNAPSHOT (which busy_timeout does
3770 // NOT retry) — an opaque error instead of a clean CapacityFull. A leading
3771 // no-op write against the target row acquires the RESERVED lock immediately
3772 // (SQLite locks on any write statement, even one matching zero rows), so the
3773 // second redeem blocks on the first, then reads the post-commit seat count
3774 // and returns CapacityFull. (The cap already held via snapshot isolation;
3775 // this upgrades the failure mode from a hard error to the right one.)
3776 sqlx::query("UPDATE invite_codes SET status = status WHERE code = ?1")
3777 .bind(code)
3778 .execute(&mut *tx)
3779 .await
3780 .context("redeem_code: acquire write lock")?;
3781
3782 // 1. Look the code up.
3783 let row =
3784 sqlx::query("SELECT status, expires_at, intended_did FROM invite_codes WHERE code = ?1")
3785 .bind(code)
3786 .fetch_optional(&mut *tx)
3787 .await
3788 .context("redeem_code: lookup")?;
3789 let row = match row {
3790 Some(r) => r,
3791 None => return Ok(Err(RedeemError::NotFound)),
3792 };
3793 let status: String = row.get("status");
3794 let expires_at: i64 = row.get("expires_at");
3795 let intended_did: Option<String> = row.get("intended_did");
3796
3797 // DID-binding gate (blocker B2). A bot-minted claim link is posted PUBLICLY
3798 // with a non-confidential token, so anyone who sees a follower's reply could
3799 // redeem it with a throwaway account — defeating the follow-gate, the daily
3800 // sybil budget, and the rate limit. When the code was minted FOR a specific
3801 // follower (`intended_did IS NOT NULL`), only that DID may redeem it; anyone
3802 // else gets a `NotFound` (indistinguishable from a bad code — no oracle).
3803 // Codes with a NULL `intended_did` (admin/browser-minted) stay open, as
3804 // before — those are meant to be sharable.
3805 if let Some(bound) = intended_did.as_deref() {
3806 if bound != did {
3807 return Ok(Err(RedeemError::NotFound));
3808 }
3809 }
3810
3811 // Status gate: only an `active` code is redeemable. Anything already
3812 // redeemed/revoked is "already redeemed" from the redeemer's view; an
3813 // `expired` status (or a past expiry) is "expired".
3814 if status == "expired" || now > expires_at {
3815 return Ok(Err(RedeemError::Expired));
3816 }
3817 if status != "active" {
3818 return Ok(Err(RedeemError::AlreadyRedeemed));
3819 }
3820
3821 // 2. Capacity gate (inside the tx so it can't race a concurrent redeem).
3822 let count: i64 = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
3823 .fetch_one(&mut *tx)
3824 .await
3825 .context("redeem_code: count")?
3826 .get("n");
3827 if count >= cap {
3828 return Ok(Err(RedeemError::CapacityFull));
3829 }
3830
3831 // 3. Flip the code active→redeemed. The `status = 'active'` guard in the
3832 // WHERE makes this a compare-and-swap: if a concurrent tx already flipped it
3833 // (despite the read above), zero rows change and we treat it as redeemed.
3834 let flipped = sqlx::query(
3835 r#"
3836 UPDATE invite_codes
3837 SET status = 'redeemed', invitee_did = ?2, redeemed_at = ?3
3838 WHERE code = ?1 AND status = 'active'
3839 "#,
3840 )
3841 .bind(code)
3842 .bind(did)
3843 .bind(now)
3844 .execute(&mut *tx)
3845 .await
3846 .context("redeem_code: flip")?;
3847 if flipped.rows_affected() == 0 {
3848 return Ok(Err(RedeemError::AlreadyRedeemed));
3849 }
3850
3851 // 4. Grant the seat.
3852 sqlx::query(
3853 r#"
3854 INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
3855 VALUES (?1, ?2, ?3, ?4, ?5)
3856 ON CONFLICT (did) DO UPDATE SET
3857 handle = COALESCE(excluded.handle, beta_access.handle),
3858 invite_code_used = excluded.invite_code_used
3859 "#,
3860 )
3861 .bind(did)
3862 .bind(handle)
3863 // granted_by is the code's creator; look it up in-tx to keep provenance.
3864 .bind(
3865 sqlx::query("SELECT creator_did FROM invite_codes WHERE code = ?1")
3866 .bind(code)
3867 .fetch_one(&mut *tx)
3868 .await
3869 .context("redeem_code: creator lookup")?
3870 .get::<String, _>("creator_did"),
3871 )
3872 .bind(now)
3873 .bind(code)
3874 .execute(&mut *tx)
3875 .await
3876 .context("redeem_code: grant")?;
3877
3878 tx.commit().await.context("commit redeem_code tx")?;
3879 Ok(Ok(()))
3880}
3881
3882/// Sweep: flip every `active` code whose `expires_at` is in the past to
3883/// `expired`. Returns the number of codes expired. Called periodically by the
3884/// scheduler.
3885pub async fn expire_old_codes(pool: &SqlitePool) -> Result<u64> {
3886 let now = now_unix();
3887 let res = sqlx::query(
3888 "UPDATE invite_codes SET status = 'expired' WHERE status = 'active' AND expires_at < ?1",
3889 )
3890 .bind(now)
3891 .execute(pool)
3892 .await
3893 .context("expire_old_codes failed")?;
3894 Ok(res.rows_affected())
3895}
3896
3897/// Seed the admin-bootstrap DIDs: for each, insert a `beta_access` row
3898/// (`granted_by = 'admin'`) if one does not already exist. Idempotent — an
3899/// existing seat is left untouched. Returns how many new seats were created.
3900pub async fn ensure_seed(pool: &SqlitePool, dids: &[String]) -> Result<u64> {
3901 let mut tx = pool.begin().await.context("begin ensure_seed tx")?;
3902 let now = now_unix();
3903 let mut created = 0u64;
3904 for did in dids {
3905 let res = sqlx::query(
3906 r#"
3907 INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
3908 VALUES (?1, NULL, 'admin', ?2, NULL)
3909 ON CONFLICT (did) DO NOTHING
3910 "#,
3911 )
3912 .bind(did)
3913 .bind(now)
3914 .execute(&mut *tx)
3915 .await
3916 .with_context(|| format!("ensure_seed insert failed for {did}"))?;
3917 created += res.rows_affected();
3918 }
3919 tx.commit().await.context("commit ensure_seed tx")?;
3920 Ok(created)
3921}
3922
3923/// The row counts purged by [`purge_did_data`], for a confirmable success
3924/// message and for assertions in tests.
3925#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
3926pub struct PurgeCounts {
3927 /// `entry_state` rows removed (per-DID read/star flags).
3928 pub entry_state: u64,
3929 /// `read_cursor` rows removed (per-DID per-feed read cursors).
3930 pub read_cursor: u64,
3931 /// `sub_ref` rows removed (the DID's subscription projection).
3932 pub sub_ref: u64,
3933 /// `beta_access` rows removed (the DID's closed-beta seat: 0 or 1).
3934 pub beta_access: u64,
3935 /// `invite_codes` rows removed (codes this DID *created*).
3936 pub invite_codes: u64,
3937 /// `invite_codes` rows *scrubbed* (the code this DID *redeemed* to join —
3938 /// its `invitee_did` back-reference cleared to NULL, row kept).
3939 pub invitee_scrubbed: u64,
3940 /// `beta_access` rows *scrubbed* (seats this DID *granted* to others — the
3941 /// `granted_by` back-reference redacted to a sentinel, row kept).
3942 pub granted_by_scrubbed: u64,
3943}
3944
3945impl PurgeCounts {
3946 /// Total rows removed across every per-DID table. (Scrub counts are tracked
3947 /// separately — those rows belong to *other* DIDs and are redacted, not
3948 /// deleted — so they are excluded from the delete total.)
3949 pub fn total(&self) -> u64 {
3950 self.entry_state + self.read_cursor + self.sub_ref + self.beta_access + self.invite_codes
3951 }
3952}
3953
3954/// Sentinel written into `beta_access.granted_by` when the granting DID deletes
3955/// its data: the column is `NOT NULL`, so we redact rather than NULL it. Keeps
3956/// the grantee's seat valid while removing the departed DID's back-reference.
3957pub const REDACTED_DID: &str = "__redacted__";
3958
3959/// Delete **all** local rows owned by `did` in a single transaction: the
3960/// per-DID read/star state (`entry_state`), per-feed read cursors
3961/// (`read_cursor`), the subscription projection (`sub_ref`), the closed-beta
3962/// seat (`beta_access`), and any invite codes this DID *created*
3963/// (`invite_codes`). The shared `feeds`/`entries` cache is intentionally left
3964/// intact — it is deduped and not owned by any single DID.
3965///
3966/// This is the local half of "delete my data": the caller pairs it with a
3967/// sidecar `POST /internal/revoke` so the OAuth tokens + sidecar session rows
3968/// are dropped too. Idempotent — deleting a DID with no rows returns all-zero
3969/// counts.
3970pub async fn purge_did_data(pool: &SqlitePool, did: &str) -> Result<PurgeCounts> {
3971 let mut tx = pool.begin().await.context("begin purge_did_data tx")?;
3972
3973 let entry_state = sqlx::query("DELETE FROM entry_state WHERE did = ?1")
3974 .bind(did)
3975 .execute(&mut *tx)
3976 .await
3977 .with_context(|| format!("purge entry_state for {did}"))?
3978 .rows_affected();
3979
3980 let read_cursor = sqlx::query("DELETE FROM read_cursor WHERE did = ?1")
3981 .bind(did)
3982 .execute(&mut *tx)
3983 .await
3984 .with_context(|| format!("purge read_cursor for {did}"))?
3985 .rows_affected();
3986
3987 let sub_ref = sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
3988 .bind(did)
3989 .execute(&mut *tx)
3990 .await
3991 .with_context(|| format!("purge sub_ref for {did}"))?
3992 .rows_affected();
3993
3994 let beta_access = sqlx::query("DELETE FROM beta_access WHERE did = ?1")
3995 .bind(did)
3996 .execute(&mut *tx)
3997 .await
3998 .with_context(|| format!("purge beta_access for {did}"))?
3999 .rows_affected();
4000
4001 let invite_codes = sqlx::query("DELETE FROM invite_codes WHERE creator_did = ?1")
4002 .bind(did)
4003 .execute(&mut *tx)
4004 .await
4005 .with_context(|| format!("purge invite_codes for {did}"))?
4006 .rows_affected();
4007
4008 // Scrub the DID's back-references from rows that belong to OTHER DIDs so no
4009 // per-DID residue survives the delete:
4010 // * the invite code this DID *redeemed* to join lives on the inviter's
4011 // row (`invitee_did`) — NULL it out (column is nullable).
4012 // * seats this DID *granted* to others carry `granted_by = <this did>` —
4013 // redact to a sentinel (column is NOT NULL) so the grantee keeps access
4014 // without retaining the departed DID.
4015 let invitee_scrubbed =
4016 sqlx::query("UPDATE invite_codes SET invitee_did = NULL WHERE invitee_did = ?1")
4017 .bind(did)
4018 .execute(&mut *tx)
4019 .await
4020 .with_context(|| format!("scrub invitee_did for {did}"))?
4021 .rows_affected();
4022
4023 // A departing DID may also be the TARGET of an outstanding bot claim
4024 // (`intended_did`, minted for them before they joined/left) — NULL it so no
4025 // per-DID residue survives. We ALSO expire the orphaned code in the same tx:
4026 // once `intended_did` is NULLed, an `active` row would otherwise keep counting
4027 // against the daily mint cap for its full 14-day TTL (and a re-follow would
4028 // double-count it), so `expired` it now. `redeemed`/already-`expired` rows are
4029 // untouched (the WHERE only matches `active`). (Cheap nit — purge orphan.)
4030 sqlx::query(
4031 "UPDATE invite_codes \
4032 SET intended_did = NULL, \
4033 status = CASE WHEN status = 'active' THEN 'expired' ELSE status END \
4034 WHERE intended_did = ?1",
4035 )
4036 .bind(did)
4037 .execute(&mut *tx)
4038 .await
4039 .with_context(|| format!("scrub intended_did for {did}"))?;
4040
4041 let granted_by_scrubbed =
4042 sqlx::query("UPDATE beta_access SET granted_by = ?2 WHERE granted_by = ?1")
4043 .bind(did)
4044 .bind(REDACTED_DID)
4045 .execute(&mut *tx)
4046 .await
4047 .with_context(|| format!("scrub granted_by for {did}"))?
4048 .rows_affected();
4049
4050 tx.commit().await.context("commit purge_did_data tx")?;
4051
4052 Ok(PurgeCounts {
4053 entry_state,
4054 read_cursor,
4055 sub_ref,
4056 beta_access,
4057 invite_codes,
4058 invitee_scrubbed,
4059 granted_by_scrubbed,
4060 })
4061}
4062
4063/// Aggregate poll health, for the public stats page.
4064///
4065/// **Deliberately aggregate-only.** No user counts, no error rates, no per-feed
4066/// detail: this is published to anyone, and a reader does not need to know how
4067/// many people use an instance or which feeds are failing. What it does answer
4068/// is the only question the page exists for — is the poller keeping up?
4069#[derive(Debug, Clone, PartialEq, Eq)]
4070pub struct PollHealth {
4071 /// Distinct feeds the poller is responsible for.
4072 pub feeds_tracked: i64,
4073 /// How many were polled within the last hour.
4074 pub polled_last_hour: i64,
4075 /// Feeds whose `next_poll` has passed — the backlog. A healthy instance
4076 /// clears this every tick; a growing number is the signal that the poller
4077 /// cannot keep up with the feed count.
4078 pub overdue: i64,
4079 /// Seconds since the most recent poll of any feed. `None` before the first.
4080 pub last_poll_secs_ago: Option<i64>,
4081 /// Seconds since the LEAST recently polled feed was polled — the worst
4082 /// staleness any reader is currently seeing.
4083 ///
4084 /// `None` when any feed has NEVER been polled, because that is a worse
4085 /// staleness than any finite age and reporting the finite one would make
4086 /// the page read healthiest exactly when it is least healthy.
4087 pub oldest_poll_secs_ago: Option<i64>,
4088 /// How many feeds have never been polled at all.
4089 pub never_polled: i64,
4090 /// Feeds currently in error backoff (`consecutive_errors > 0`).
4091 ///
4092 /// One of the two states that stop feeds updating, and previously visible
4093 /// nowhere: `consecutive_errors` was written by `bump_feed_errors` and read
4094 /// by nothing outside the backoff calculation — no page, no endpoint. Worse,
4095 /// a feed in backoff is NOT counted in `overdue`, because backoff is applied
4096 /// by pushing `next_poll` forward. So the one number a reader might have
4097 /// checked moved the wrong way: a feed failing every fetch made `overdue`
4098 /// look BETTER.
4099 pub in_backoff: i64,
4100 /// Of those, how many have reached `BADLY_BROKEN_ERRORS` consecutive
4101 /// failures — retried 2h40m apart rather than every 5 minutes.
4102 ///
4103 /// Not "will not recover on their own": the backoff ceiling is 24h at ten
4104 /// errors, and any of these recovers on its next successful poll. See
4105 /// `BADLY_BROKEN_ERRORS`.
4106 pub badly_broken: i64,
4107 /// Failing feeds grouped by **cause**, descending, as
4108 /// `(kind, count)` — `fetch`, `status`, `body`, `parse`.
4109 ///
4110 /// **Counts, never identities.** `/stats` is public and states that it
4111 /// reports machines rather than people: no per-feed detail, never which feed
4112 /// and never whose. A cause histogram keeps that promise and still answers
4113 /// the question `badly_broken` could not — whether sixty feeds are failing
4114 /// for sixty reasons or for one. Had this existed, #159 would have read
4115 /// `fetch: 60` on a page anyone could load, instead of costing a production
4116 /// investigation.
4117 pub failure_kinds: Vec<(String, i64)>,
4118}
4119
4120/// `consecutive_errors` at or above which a feed counts as `badly_broken`.
4121///
4122/// Chosen to mean "this is not a transient blip": `feed::backoff_for` climbs
4123/// exponentially, so by this many consecutive failures a feed is being retried
4124/// **2h40m apart** — `backoff_for(6)`.
4125///
4126/// **Not "at or near the ceiling", and not "effectively dead".** `BACKOFF_MAX`
4127/// is 24h and is first reached at *ten* errors, so a feed at this threshold is
4128/// still retried around nine times a day and recovers on its own the moment the
4129/// cause clears. Three doc comments claimed otherwise, and the claim was
4130/// load-bearing in the wrong direction.
4131///
4132/// **It says nothing about whose fault the failure is, and used to claim it
4133/// did.** This comment and the matching `/stats` copy read "almost certainly
4134/// gone rather than flaky" until 2026-09-20, when #159 found that 60-odd feeds
4135/// sat here because `guarded_get` was reading every `304 Not Modified` as a
4136/// malformed redirect. The publishers were live; the reader was broken. That
4137/// assertion is what stopped anyone looking, which is why `last_error_kind`
4138/// now exists — the row can answer the question the count never could.
4139const BADLY_BROKEN_ERRORS: i64 = 6;
4140
4141/// Compute [`PollHealth`] as of `now` (RFC3339, seconds precision — the same
4142/// format the scheduler writes, so the comparisons are lexicographic).
4143pub async fn poll_health(pool: &SqlitePool, now: &str, hour_ago: &str) -> Result<PollHealth> {
4144 // **Only what the poller sees.** `due_feeds` skips `at://` rows, so nothing
4145 // ever advances their `next_poll` or sets `last_polled`; counted here they
4146 // read as overdue and never-polled forever and force "oldest poll" to
4147 // `never` — unsupported shown as broken, on a public page, permanently.
4148 // The same predicate as the scheduler's, so the two cannot disagree.
4149 let aggregate = format!(
4150 r#"
4151 SELECT
4152 COUNT(*),
4153 COALESCE(SUM(CASE WHEN last_polled IS NOT NULL AND last_polled >= ?2 THEN 1 ELSE 0 END), 0),
4154 COALESCE(SUM(CASE WHEN next_poll IS NULL OR next_poll <= ?1 THEN 1 ELSE 0 END), 0),
4155 MAX(last_polled),
4156 -- NULL-AWARE. `MIN` skips NULLs, so an instance where most feeds
4157 -- had NEVER been polled reported the freshest of the few that had —
4158 -- the figure read healthiest in the most degraded state, which is
4159 -- the opposite of what a health page is for. A never-polled feed IS
4160 -- the worst staleness, so it wins outright.
4161 CASE WHEN SUM(CASE WHEN last_polled IS NULL THEN 1 ELSE 0 END) > 0
4162 THEN NULL ELSE MIN(last_polled) END,
4163 SUM(CASE WHEN last_polled IS NULL THEN 1 ELSE 0 END),
4164 COALESCE(SUM(CASE WHEN consecutive_errors > 0 THEN 1 ELSE 0 END), 0),
4165 COALESCE(SUM(CASE WHEN consecutive_errors >= ?3 THEN 1 ELSE 0 END), 0)
4166 FROM feeds
4167 WHERE kind IN ({POLLABLE_KINDS_SQL})
4168 "#
4169 );
4170 #[allow(clippy::type_complexity)]
4171 let row: (i64, i64, i64, Option<String>, Option<String>, i64, i64, i64) =
4172 sqlx::query_as(sqlx::AssertSqlSafe(aggregate))
4173 .bind(now)
4174 .bind(hour_ago)
4175 .bind(BADLY_BROKEN_ERRORS)
4176 .fetch_one(pool)
4177 .await
4178 .context("computing poll health")?;
4179
4180 // A second, tiny query rather than a join: the histogram groups rows the
4181 // aggregate above collapses, and one statement doing both would make the
4182 // counts above harder to read than the extra round trip is worth.
4183 //
4184 // **Every failing feed lands in a bucket, so this sums to `in_backoff`.**
4185 //
4186 // A row that predates the column is failing with no recorded cause, and it
4187 // must not be attributed to some other feed's reason — but it must not
4188 // vanish either. Filtering them out made the breakdown silently disagree
4189 // with the `Failing` figure beside it: on a migrated database that is EVERY
4190 // currently-failing feed, so the page would have read "70 failing" next to
4191 // "3 fetch" with 67 unexplained and no indication a remainder existed.
4192 //
4193 // `unknown` is a deliberate bucket rather than an omission. It cannot
4194 // collide with a real kind — `FailureKind::as_str` never returns it, and
4195 // `FailureKind::parse("unknown")` is `None`.
4196 let histogram = format!(
4197 r#"
4198 -- **`failure_kind`, not `kind`.** Aliasing this `kind` collided with
4199 -- the `feeds.kind` column added for the poller: SQLite resolved
4200 -- `GROUP BY kind` to the table column, so every failing feed collapsed
4201 -- into ONE bucket labelled from an arbitrary row — a public page
4202 -- reporting "10 fetch" for ten unrelated causes. Caught by
4203 -- `an_unrecognised_failure_kind_folds_into_unknown`.
4204 SELECT COALESCE(last_error_kind, 'unknown') AS failure_kind, COUNT(*) AS n
4205 FROM feeds
4206 WHERE consecutive_errors > 0 AND kind IN ({POLLABLE_KINDS_SQL})
4207 GROUP BY failure_kind
4208 ORDER BY n DESC, failure_kind ASC
4209 "#
4210 );
4211 let kinds: Vec<(String, i64)> = sqlx::query_as(sqlx::AssertSqlSafe(histogram))
4212 .fetch_all(pool)
4213 .await
4214 .context("computing the failure-cause histogram")?;
4215
4216 // **Close the vocabulary where it is READ.** `FailureKind::parse` promised
4217 // that a kind from a newer build would not be attributed to a cause this
4218 // one recognises — but nothing called it, so the raw column reached the
4219 // public template and an unrecognised string rendered as its own bucket.
4220 // Fold anything `parse` rejects into `unknown`, then re-aggregate and
4221 // re-order, so the histogram only ever shows the four kinds this build
4222 // knows plus the one honest bucket for what it does not.
4223 let mut folded: std::collections::BTreeMap<String, i64> = std::collections::BTreeMap::new();
4224 for (kind, n) in kinds {
4225 let key = if kind == "unknown" || crate::feed::FailureKind::parse(&kind).is_some() {
4226 kind
4227 } else {
4228 "unknown".to_string()
4229 };
4230 *folded.entry(key).or_insert(0) += n;
4231 }
4232 let mut kinds: Vec<(String, i64)> = folded.into_iter().collect();
4233 kinds.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
4234
4235 Ok(PollHealth {
4236 feeds_tracked: row.0,
4237 polled_last_hour: row.1,
4238 overdue: row.2,
4239 last_poll_secs_ago: secs_between(row.3.as_deref(), now),
4240 oldest_poll_secs_ago: secs_between(row.4.as_deref(), now),
4241 never_polled: row.5,
4242 in_backoff: row.6,
4243 badly_broken: row.7,
4244 failure_kinds: kinds,
4245 })
4246}
4247
4248/// Whole seconds from `then` to `now`, or `None` if `then` is absent or
4249/// unparseable. Never negative: a clock skew that puts a poll in the future
4250/// reads as "just now" rather than as a negative age.
4251fn secs_between(then: Option<&str>, now: &str) -> Option<i64> {
4252 let then = chrono::DateTime::parse_from_rfc3339(then?).ok()?;
4253 let now = chrono::DateTime::parse_from_rfc3339(now).ok()?;
4254 Some((now - then).num_seconds().max(0))
4255}
4256
4257#[cfg(test)]
4258mod tests {
4259 use super::*;
4260
4261 /// **A re-poll refreshes `published`; it never refreshes `fetched_at`.**
4262 ///
4263 /// The asymmetry is the whole reason a date must be stable. `published`
4264 /// comes back from the publisher on every poll, so a value the mapper
4265 /// recomputes — "now", say — is rewritten every hour and the row can never
4266 /// age. `fetched_at` is written once, at first insert, so an entry stored
4267 /// with no date is effectively dated when we first saw it, and that date
4268 /// does hold still. Both the per-feed cap and the retention sweep order on
4269 /// `COALESCE(published, fetched_at)`, so which of the two a row lands in
4270 /// decides whether it can ever be evicted or swept.
4271 #[tokio::test]
4272 async fn a_repoll_refreshes_published_but_never_fetched_at() -> Result<()> {
4273 let pool = init_url("sqlite::memory:").await?;
4274 let feed_id = upsert_feed(
4275 &pool,
4276 &NewFeed {
4277 url: "https://example.com/f.xml".to_string(),
4278 ..Default::default()
4279 },
4280 )
4281 .await?;
4282 let seen = |at: &str| {
4283 vec![NewEntry {
4284 guid: "g".to_string(),
4285 published: Some(at.to_string()),
4286 fetched_at: Some(at.to_string()),
4287 ..Default::default()
4288 }]
4289 };
4290 insert_entries(&pool, feed_id, &seen("2026-01-01T00:00:00Z"), 0).await?;
4291 insert_entries(&pool, feed_id, &seen("2026-09-20T00:00:00Z"), 0).await?;
4292
4293 let (published, fetched_at): (Option<String>, String) =
4294 sqlx::query_as("SELECT published, fetched_at FROM entries WHERE guid = 'g'")
4295 .fetch_one(&pool)
4296 .await?;
4297 assert_eq!(
4298 published.as_deref(),
4299 Some("2026-09-20T00:00:00Z"),
4300 "the second poll's date did not replace the first"
4301 );
4302 assert_eq!(
4303 fetched_at, "2026-01-01T00:00:00Z",
4304 "fetched_at moved, so an undated entry would never age either"
4305 );
4306 Ok(())
4307 }
4308
4309 /// A partial upsert must not erase the conditional-GET validators.
4310 ///
4311 /// `set_next_poll` supplies only `url` + `next_poll` and runs after EVERY
4312 /// poll of EVERY feed. While `upsert_feed` assigned etag/last_modified
4313 /// unconditionally, that call wrote both back to NULL, so `If-None-Match`
4314 /// was never sent, `304` was unreachable, and every feed was re-downloaded
4315 /// and re-parsed in full on every cycle. Nothing failed; it was invisible.
4316 #[tokio::test]
4317 async fn validators_survive_a_partial_upsert() -> Result<()> {
4318 let pool = init_url("sqlite::memory:").await?;
4319 let url = "https://example.com/feed.xml";
4320
4321 upsert_feed(
4322 &pool,
4323 &NewFeed {
4324 url: url.to_string(),
4325 etag: Some("\"abc123\"".to_string()),
4326 last_modified: Some("Wed, 01 Jan 2026 00:00:00 GMT".to_string()),
4327 ..Default::default()
4328 },
4329 )
4330 .await?;
4331
4332 // Exactly what `scheduler::set_next_poll` sends.
4333 upsert_feed(
4334 &pool,
4335 &NewFeed {
4336 url: url.to_string(),
4337 next_poll: Some("2026-07-12T00:00:00Z".to_string()),
4338 ..Default::default()
4339 },
4340 )
4341 .await?;
4342
4343 let feed = get_feed_by_url(&pool, url).await?.expect("feed");
4344 assert_eq!(
4345 feed.etag.as_deref(),
4346 Some("\"abc123\""),
4347 "a partial upsert erased the ETag, disabling conditional GET"
4348 );
4349 assert_eq!(
4350 feed.last_modified.as_deref(),
4351 Some("Wed, 01 Jan 2026 00:00:00 GMT"),
4352 "a partial upsert erased Last-Modified"
4353 );
4354 assert_eq!(feed.next_poll.as_deref(), Some("2026-07-12T00:00:00Z"));
4355 Ok(())
4356 }
4357
4358 /// A hard ceiling that is not strictly older than the window is IGNORED.
4359 ///
4360 /// `hard_days.max(days)` made `0` — the obvious "off" value, and the
4361 /// documented disable value for `RETENTION_DAYS` — collapse the ceiling onto
4362 /// the soft window, where the delete spares nothing. The starred and unread
4363 /// rows the window exists to protect were purged at `retention_days`.
4364 #[tokio::test]
4365 async fn a_ceiling_inside_the_window_is_ignored_not_applied() -> Result<()> {
4366 for hard in [0_i64, 1, 7, 14] {
4367 let pool = init_url("sqlite::memory:").await?;
4368 let feed_id = upsert_feed(
4369 &pool,
4370 &NewFeed {
4371 url: "https://example.com/f.xml".to_string(),
4372 ..Default::default()
4373 },
4374 )
4375 .await?;
4376 let old = (chrono::Utc::now() - chrono::Duration::days(30))
4377 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
4378 insert_entries(
4379 &pool,
4380 feed_id,
4381 &[
4382 NewEntry {
4383 guid: "starred-30d".to_string(),
4384 published: Some(old.clone()),
4385 ..Default::default()
4386 },
4387 NewEntry {
4388 guid: "unread-30d".to_string(),
4389 published: Some(old.clone()),
4390 ..Default::default()
4391 },
4392 ],
4393 0,
4394 )
4395 .await?;
4396 // Both need an explicit `entry_state` row: sparing keys off a
4397 // DELIBERATE mark, and an entry with no row at all is unclaimed
4398 // cache that the window is supposed to evict.
4399 sqlx::query(
4400 "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4401 SELECT 'did:plc:x', id, 1, 1, '2026-01-01T00:00:00Z'
4402 FROM entries WHERE guid = 'starred-30d'",
4403 )
4404 .execute(&pool)
4405 .await?;
4406 sqlx::query(
4407 "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4408 SELECT 'did:plc:x', id, 0, 0, '2026-01-01T00:00:00Z'
4409 FROM entries WHERE guid = 'unread-30d'",
4410 )
4411 .execute(&pool)
4412 .await?;
4413
4414 prune_old_entries(&pool, 14, hard, 0).await?;
4415
4416 let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries")
4417 .fetch_one(&pool)
4418 .await?;
4419 assert_eq!(
4420 left, 2,
4421 "hard_days={hard} destroyed starred/unread rows at the soft window"
4422 );
4423 }
4424 Ok(())
4425 }
4426
4427 /// Turning the rolling window off must NOT also turn the ceiling off.
4428 ///
4429 /// `prune_old_entries` used to return on `days <= 0` before the ceiling was
4430 /// even computed, so `RETENTION_DAYS=0` — advertised as "disables eviction" —
4431 /// meant no window AND no ceiling. That is the one configuration with no
4432 /// bound on the shared cache at all, and it stopped being survivable when the
4433 /// per-feed trim started sparing starred entries: nothing was left to catch
4434 /// them. The two knobs are independent now.
4435 #[tokio::test]
4436 async fn a_disabled_window_does_not_disable_the_ceiling() -> Result<()> {
4437 let pool = init_url("sqlite::memory:").await?;
4438 let feed_id = upsert_feed(
4439 &pool,
4440 &NewFeed {
4441 url: "https://example.com/f.xml".to_string(),
4442 ..Default::default()
4443 },
4444 )
4445 .await?;
4446 let age = |d: i64| {
4447 (chrono::Utc::now() - chrono::Duration::days(d))
4448 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
4449 };
4450 insert_entries(
4451 &pool,
4452 feed_id,
4453 &[
4454 NewEntry {
4455 guid: "starred-400d".to_string(),
4456 published: Some(age(400)),
4457 ..Default::default()
4458 },
4459 NewEntry {
4460 guid: "starred-30d".to_string(),
4461 published: Some(age(30)),
4462 ..Default::default()
4463 },
4464 ],
4465 0,
4466 )
4467 .await?;
4468 // Star both, so only the ceiling can remove either one — the soft
4469 // window's exception would spare them both even if it did run.
4470 sqlx::query(
4471 "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4472 SELECT 'did:plc:x', id, 1, 1, '2026-01-01T00:00:00Z' FROM entries",
4473 )
4474 .execute(&pool)
4475 .await?;
4476
4477 // No rolling window; a 180-day ceiling.
4478 let deleted = prune_old_entries(&pool, 0, 180, 0).await?;
4479
4480 assert_eq!(
4481 deleted, 1,
4482 "retention_days=0 skipped the hard ceiling, leaving the cache unbounded"
4483 );
4484 let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
4485 .fetch_all(&pool)
4486 .await?;
4487 assert_eq!(
4488 left,
4489 vec!["starred-30d".to_string()],
4490 "the ceiling removed the wrong rows with the window disabled"
4491 );
4492 Ok(())
4493 }
4494
4495 /// With BOTH knobs off, nothing is deleted — that is the documented
4496 /// "no eviction at all" configuration, and it must stay a true no-op rather
4497 /// than falling through to one of the two deletes with a degenerate cutoff.
4498 #[tokio::test]
4499 async fn both_knobs_off_deletes_nothing() -> Result<()> {
4500 let pool = init_url("sqlite::memory:").await?;
4501 let feed_id = upsert_feed(
4502 &pool,
4503 &NewFeed {
4504 url: "https://example.com/f.xml".to_string(),
4505 ..Default::default()
4506 },
4507 )
4508 .await?;
4509 insert_entries(
4510 &pool,
4511 feed_id,
4512 &[NewEntry {
4513 guid: "ancient".to_string(),
4514 published: Some(
4515 (chrono::Utc::now() - chrono::Duration::days(9999))
4516 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
4517 ),
4518 ..Default::default()
4519 }],
4520 0,
4521 )
4522 .await?;
4523
4524 assert_eq!(prune_old_entries(&pool, 0, 0, 0).await?, 0);
4525 let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries")
4526 .fetch_one(&pool)
4527 .await?;
4528 assert_eq!(left, 1);
4529 Ok(())
4530 }
4531
4532 /// Starred sparing must not remove the per-feed cap.
4533 ///
4534 /// The first version spared every starred row without limit: at cap=5 with
4535 /// 50 starred entries, 55 survived — 11x the cap, i.e. no cap at all.
4536 #[tokio::test]
4537 async fn per_feed_trim_stays_bounded_when_everything_is_starred() -> Result<()> {
4538 let pool = init_url("sqlite::memory:").await?;
4539 let feed_id = upsert_feed(
4540 &pool,
4541 &NewFeed {
4542 url: "https://example.com/f.xml".to_string(),
4543 ..Default::default()
4544 },
4545 )
4546 .await?;
4547 let entries: Vec<NewEntry> = (0..100)
4548 .map(|i| NewEntry {
4549 guid: format!("g-{i}"),
4550 published: Some(format!("2026-01-{:02}T00:00:00Z", (i % 28) + 1)),
4551 ..Default::default()
4552 })
4553 .collect();
4554 insert_entries(&pool, feed_id, &entries, 0).await?;
4555 sqlx::query(
4556 "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4557 SELECT 'did:plc:x', id, 0, 1, '2026-01-01T00:00:00Z'
4558 FROM entries LIMIT 50",
4559 )
4560 .execute(&pool)
4561 .await?;
4562
4563 // Re-run the trim with cap = 5.
4564 insert_entries(&pool, feed_id, &[], 5).await?;
4565
4566 let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries")
4567 .fetch_one(&pool)
4568 .await?;
4569 assert!(
4570 left <= 10,
4571 "per-feed trim kept {left} rows for a cap of 5; sparing removed the bound"
4572 );
4573 Ok(())
4574 }
4575
4576 /// Init an in-memory SQLite, insert a feed + entries, read them back.
4577 #[tokio::test]
4578 async fn init_insert_readback() -> Result<()> {
4579 let pool = init_url("sqlite::memory:").await?;
4580
4581 // Insert a feed.
4582 let feed_id = upsert_feed(
4583 &pool,
4584 &NewFeed {
4585 url: "https://example.com/feed.xml".to_string(),
4586 title: Some("Example".to_string()),
4587 site_url: Some("https://example.com".to_string()),
4588 next_poll: Some("2026-07-12T00:00:00Z".to_string()),
4589 ..Default::default()
4590 },
4591 )
4592 .await?;
4593 assert!(feed_id > 0);
4594
4595 // Read the feed back by URL.
4596 let feed = get_feed_by_url(&pool, "https://example.com/feed.xml")
4597 .await?
4598 .expect("feed should exist");
4599 assert_eq!(feed.id, feed_id);
4600 assert_eq!(feed.title.as_deref(), Some("Example"));
4601 assert_eq!(feed.site_url.as_deref(), Some("https://example.com"));
4602
4603 // Upsert on the same URL updates rather than duplicating.
4604 let feed_id2 = upsert_feed(
4605 &pool,
4606 &NewFeed {
4607 url: "https://example.com/feed.xml".to_string(),
4608 title: Some("Example (renamed)".to_string()),
4609 ..Default::default()
4610 },
4611 )
4612 .await?;
4613 assert_eq!(feed_id, feed_id2, "same URL must reuse the same row");
4614
4615 // Insert two entries.
4616 let n = insert_entries(
4617 &pool,
4618 feed_id,
4619 &[
4620 NewEntry {
4621 guid: "guid-1".to_string(),
4622 url: Some("https://example.com/a".to_string()),
4623 title: Some("First".to_string()),
4624 published: Some("2026-07-10T08:00:00Z".to_string()),
4625 content_html: Some("<p>hello</p>".to_string()),
4626 ..Default::default()
4627 },
4628 NewEntry {
4629 guid: "guid-2".to_string(),
4630 url: Some("https://example.com/b".to_string()),
4631 title: Some("Second".to_string()),
4632 published: Some("2026-07-11T08:00:00Z".to_string()),
4633 ..Default::default()
4634 },
4635 ],
4636 0, // per-feed trim disabled for this test
4637 )
4638 .await?;
4639 assert_eq!(n, 2);
4640
4641 // The reader must subscribe to the feed for the scoped reads to return
4642 // its entries (per-DID isolation projection).
4643 let did = "did:plc:abc123";
4644 replace_sub_refs(&pool, did, &[feed_id]).await?;
4645
4646 // Read entries back (newest-published first).
4647 let entries = entries_for_feed(&pool, did, feed_id).await?;
4648 assert_eq!(entries.len(), 2);
4649 assert_eq!(entries[0].guid, "guid-2");
4650 assert_eq!(entries[1].guid, "guid-1");
4651 // The body is stored, but it is NOT in the list projection — that is the
4652 // point of `EntryListRow`. Read it the way the single-entry reader does.
4653 let body: Option<String> =
4654 sqlx::query_scalar("SELECT content_html FROM entries WHERE guid = 'guid-1'")
4655 .fetch_one(&pool)
4656 .await?;
4657 assert_eq!(body.as_deref(), Some("<p>hello</p>"));
4658
4659 // Re-inserting the same GUID dedups (updates in place, no new row).
4660 let n2 = insert_entries(
4661 &pool,
4662 feed_id,
4663 &[NewEntry {
4664 guid: "guid-1".to_string(),
4665 title: Some("First (edited)".to_string()),
4666 ..Default::default()
4667 }],
4668 0,
4669 )
4670 .await?;
4671 assert_eq!(n2, 1);
4672 assert_eq!(entries_for_feed(&pool, did, feed_id).await?.len(), 2);
4673
4674 // --- per-DID read state ---
4675 let e1 = entries.iter().find(|e| e.guid == "guid-1").unwrap().id;
4676
4677 // Both entries start unread.
4678 assert_eq!(get_unread_for_did(&pool, did).await?.len(), 2);
4679
4680 // Mark one read; unread count drops to 1.
4681 mark_read(&pool, did, e1, true).await?;
4682 let unread = get_unread_for_did(&pool, did).await?;
4683 assert_eq!(unread.len(), 1);
4684 assert_eq!(unread[0].guid, "guid-2");
4685
4686 // Star it; it shows in the starred list.
4687 mark_starred(&pool, did, e1, true).await?;
4688 let starred = get_starred_for_did(&pool, did).await?;
4689 assert_eq!(starred.len(), 1);
4690 assert_eq!(starred[0].id, e1);
4691
4692 // Mark-all-read clears the remaining unread.
4693 mark_feed_read(&pool, did, feed_id, true).await?;
4694 assert_eq!(get_unread_for_did(&pool, did).await?.len(), 0);
4695
4696 // --- read cursor (batched-sync bookkeeping) ---
4697 let cursor = ReadCursor {
4698 did: did.to_string(),
4699 feed_url: "https://example.com/feed.xml".to_string(),
4700 read_through: Some("2026-07-11T08:00:00Z".to_string()),
4701 read_ids: "[]".to_string(),
4702 unread_ids: "[]".to_string(),
4703 dirty: true,
4704 pds_created: false,
4705 updated_at: now_rfc3339(),
4706 };
4707 upsert_cursor(&pool, &cursor).await?;
4708
4709 let fetched = get_cursor(&pool, did, "https://example.com/feed.xml")
4710 .await?
4711 .expect("cursor should exist");
4712 assert_eq!(
4713 fetched.read_through.as_deref(),
4714 Some("2026-07-11T08:00:00Z")
4715 );
4716 assert!(fetched.dirty);
4717
4718 // The flusher sees exactly one dirty cursor.
4719 let dirty = dirty_cursors(&pool, did).await?;
4720 assert_eq!(dirty.len(), 1);
4721 let flushed_at = dirty[0].updated_at.clone();
4722
4723 // After a flush, clearing dirty (with the flushed snapshot's updated_at)
4724 // removes it from the flusher's view.
4725 clear_cursor_dirty(&pool, did, "https://example.com/feed.xml", &flushed_at).await?;
4726 assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
4727
4728 Ok(())
4729 }
4730
4731 // -----------------------------------------------------------------------
4732 // The bounded, body-free list projection.
4733 //
4734 // The three queries these replaced were `SELECT e.*` with no `LIMIT`. Both
4735 // halves of that are load-bearing on a 512 MB box: the projection dragged
4736 // an ~11.9 KB article body per row that no list surface reads, and the
4737 // missing bound let one reader's backlog decide how much a handler
4738 // allocates.
4739 // -----------------------------------------------------------------------
4740
4741 /// Seed `count` entries in one feed, each with a large body, subscribed by
4742 /// `did`. Returns the feed id.
4743 async fn seed_big_entries(pool: &SqlitePool, did: &str, count: usize) -> Result<i64> {
4744 let feed_id = upsert_feed(
4745 pool,
4746 &NewFeed {
4747 url: "https://example.com/big.xml".to_string(),
4748 ..Default::default()
4749 },
4750 )
4751 .await?;
4752 let body = "x".repeat(20_000);
4753 let entries: Vec<NewEntry> = (0..count)
4754 .map(|i| NewEntry {
4755 guid: format!("guid-{i:04}"),
4756 url: Some(format!("https://example.com/a/{i}")),
4757 title: Some(format!("Article {i}")),
4758 // Descending guid order matches descending published order, so
4759 // assertions can name the rows they expect.
4760 published: Some(format!("2026-01-{:02}T00:00:00Z", (i % 28) + 1)),
4761 content_html: Some(body.clone()),
4762 ..Default::default()
4763 })
4764 .collect();
4765 insert_entries(pool, feed_id, &entries, 0).await?;
4766 replace_sub_refs(pool, did, &[feed_id]).await?;
4767 Ok(feed_id)
4768 }
4769
4770 /// `limit` is honoured, and `offset` walks the same ordering without gaps or
4771 /// repeats. Against the unbounded originals the first assertion returned all
4772 /// 250 rows.
4773 #[tokio::test]
4774 async fn list_entries_is_bounded_and_pages_without_overlap() -> Result<()> {
4775 let pool = init_url("sqlite::memory:").await?;
4776 let did = "did:plc:pager";
4777 seed_big_entries(&pool, did, 250).await?;
4778
4779 let page1 = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
4780 assert_eq!(page1.len(), 100, "limit was not applied");
4781 let page2 = list_entries(&pool, did, ListView::All, None, 100, 100).await?;
4782 let page3 = list_entries(&pool, did, ListView::All, None, 100, 200).await?;
4783 assert_eq!(page3.len(), 50, "the last page should be the remainder");
4784
4785 let walked: Vec<i64> = page1
4786 .iter()
4787 .chain(&page2)
4788 .chain(&page3)
4789 .map(|e| e.id)
4790 .collect();
4791 let unique: std::collections::HashSet<i64> = walked.iter().copied().collect();
4792 assert_eq!(unique.len(), 250, "paging repeated or skipped rows");
4793
4794 // And the walk is the same order an unpaged read would produce.
4795 let whole = list_entries(&pool, did, ListView::All, None, 1_000, 0).await?;
4796 assert_eq!(
4797 walked,
4798 whole.iter().map(|e| e.id).collect::<Vec<_>>(),
4799 "paging changed the ordering"
4800 );
4801
4802 // **The tie-break is pinned, not left to the engine.** The seed gives
4803 // 250 rows only 28 distinct dates, so the order is mostly ties; with
4804 // the `id DESC` tie-break deleted, SQLite happened to return ties in a
4805 // stable order and both assertions above still held. The expected
4806 // order is computed from the seed pattern here — newest date first,
4807 // then newest id — and must match exactly.
4808 let mut expected: Vec<(i64, i64)> = whole
4809 .iter()
4810 .map(|e| {
4811 let day: i64 = e.published.as_deref().unwrap()[8..10].parse().unwrap();
4812 (day, e.id)
4813 })
4814 .collect();
4815 expected.sort_by(|a, b| b.cmp(a));
4816 assert_eq!(
4817 walked,
4818 expected.iter().map(|(_, id)| *id).collect::<Vec<_>>(),
4819 "ties are not broken by newest id"
4820 );
4821
4822 assert_eq!(
4823 count_entries_for_view(&pool, did, ListView::All, None).await?,
4824 250,
4825 "the unpaged count must survive paging"
4826 );
4827 Ok(())
4828 }
4829
4830 /// The list projection must not read `content_html`.
4831 ///
4832 /// A type-level fact — `EntryListRow` has no body field — so the test proves
4833 /// it the only way that survives a refactor: by asking SQLite what the query
4834 /// it runs actually names. `SELECT e.*` would list every column.
4835 #[tokio::test]
4836 async fn the_list_projection_does_not_name_the_body_column() -> Result<()> {
4837 let pool = init_url("sqlite::memory:").await?;
4838 let did = "did:plc:projection";
4839 seed_big_entries(&pool, did, 3).await?;
4840
4841 // **The projection the query actually runs**, not a copy re-typed here.
4842 // The earlier version passed its own literal to `list_query_sql` and
4843 // asserted on that, so adding `e.content_html` to `list_entries` left
4844 // this green.
4845 let (sql, _) = list_entries_sql(ListView::All, None);
4846 assert!(
4847 !sql.contains("content_html") && !sql.contains("e.*"),
4848 "the list query reads the article body: {sql}"
4849 );
4850
4851 // And the rows really do come back without it, which is what bounds the
4852 // per-request allocation.
4853 let rows = list_entries(&pool, did, ListView::All, None, 10, 0).await?;
4854 assert_eq!(rows.len(), 3);
4855 let widest = rows
4856 .iter()
4857 .map(|r| {
4858 r.guid.len()
4859 + r.url.as_deref().map_or(0, str::len)
4860 + r.title.as_deref().map_or(0, str::len)
4861 })
4862 .max()
4863 .unwrap_or(0);
4864 assert!(
4865 widest < 1_000,
4866 "a list row carries {widest} bytes of text; the 20,000-byte body leaked in"
4867 );
4868 Ok(())
4869 }
4870
4871 /// **A large scope must not become a large SQL statement.**
4872 ///
4873 /// The scope filter used to emit one placeholder per feed id, so the SQL
4874 /// string and the bind list both grew with a reader's subscription count —
4875 /// which comes from the PDS and is bounded only by a 20,000-record list
4876 /// ceiling. The first attempt at fixing that truncated the subscription
4877 /// list, which silently removed the reader's access to the dropped feeds
4878 /// (`sub_ref` is written from the same list). `json_each` takes the whole
4879 /// set as ONE bind, so neither trade-off is needed.
4880 #[tokio::test]
4881 async fn a_large_scope_is_one_bind_and_still_filters() -> Result<()> {
4882 let pool = init_url("sqlite::memory:").await?;
4883 let did = "did:plc:widescope";
4884
4885 // 300 feeds, one entry each; the scope names 200 of them.
4886 let mut all_ids = Vec::new();
4887 for i in 0..300 {
4888 let feed_id = upsert_feed(
4889 &pool,
4890 &NewFeed {
4891 url: format!("https://wide{i}.example/f.xml"),
4892 ..Default::default()
4893 },
4894 )
4895 .await?;
4896 insert_entries(
4897 &pool,
4898 feed_id,
4899 &[NewEntry {
4900 guid: format!("w-{i}"),
4901 ..Default::default()
4902 }],
4903 0,
4904 )
4905 .await?;
4906 all_ids.push(feed_id);
4907 }
4908 replace_sub_refs(&pool, did, &all_ids).await?;
4909
4910 let scope: Vec<i64> = all_ids.iter().copied().take(200).collect();
4911 let rows = list_entries(&pool, did, ListView::All, Some(&scope), 1_000, 0).await?;
4912 assert_eq!(rows.len(), 200, "the scope filter did not narrow correctly");
4913 let in_scope: std::collections::HashSet<i64> = scope.iter().copied().collect();
4914 assert!(
4915 rows.iter().all(|r| in_scope.contains(&r.feed_id)),
4916 "a feed outside the scope came back"
4917 );
4918 assert_eq!(
4919 count_entries_for_view(&pool, did, ListView::All, Some(&scope)).await?,
4920 200
4921 );
4922
4923 // The statement itself carries no per-id placeholders — that is the
4924 // property, and it is what stops the SQL growing with the reader.
4925 let (sql, n) = list_query_sql(Projection::Ids, ListView::All, Some(&scope));
4926 assert_eq!(n, 1, "the scope must contribute exactly one placeholder");
4927 assert!(
4928 sql.contains("json_each(?2)") && !sql.contains("?3"),
4929 "the scope is still expanded into per-id placeholders: {sql}"
4930 );
4931 Ok(())
4932 }
4933
4934 /// Scope is applied INSIDE the query, so a page is a page of rows the reader
4935 /// will see. Filtering after the `LIMIT` (what the handler used to do) made
4936 /// pages arbitrarily short for any narrowed scope.
4937 #[tokio::test]
4938 async fn a_feed_scope_narrows_the_query_not_the_page() -> Result<()> {
4939 let pool = init_url("sqlite::memory:").await?;
4940 let did = "did:plc:scope";
4941 let wanted = seed_big_entries(&pool, did, 10).await?;
4942
4943 let other = upsert_feed(
4944 &pool,
4945 &NewFeed {
4946 url: "https://other.example/f.xml".to_string(),
4947 ..Default::default()
4948 },
4949 )
4950 .await?;
4951 let noise: Vec<NewEntry> = (0..40)
4952 .map(|i| NewEntry {
4953 guid: format!("noise-{i}"),
4954 // Newer than everything in `wanted`, so an unscoped query would
4955 // fill the whole page with these.
4956 published: Some("2027-01-01T00:00:00Z".to_string()),
4957 ..Default::default()
4958 })
4959 .collect();
4960 insert_entries(&pool, other, &noise, 0).await?;
4961 replace_sub_refs(&pool, did, &[wanted, other]).await?;
4962
4963 let scoped = list_entries(&pool, did, ListView::All, Some(&[wanted]), 10, 0).await?;
4964 assert_eq!(
4965 scoped.len(),
4966 10,
4967 "the scoped page came back short — the filter ran after the LIMIT"
4968 );
4969 assert!(scoped.iter().all(|e| e.feed_id == wanted));
4970
4971 // An EMPTY scope means "no feeds in scope", not "every feed".
4972 assert!(list_entries(&pool, did, ListView::All, Some(&[]), 10, 0)
4973 .await?
4974 .is_empty());
4975 assert_eq!(
4976 count_entries_for_view(&pool, did, ListView::All, Some(&[])).await?,
4977 0
4978 );
4979 Ok(())
4980 }
4981
4982 /// The per-row `read` / `starred` bits come off the row's own join, matching
4983 /// what the separate full-set queries used to compute — including the
4984 /// "no `entry_state` row means unread" rule the views depend on.
4985 #[tokio::test]
4986 async fn list_rows_carry_their_own_read_and_star_bits() -> Result<()> {
4987 let pool = init_url("sqlite::memory:").await?;
4988 let did = "did:plc:bits";
4989 seed_big_entries(&pool, did, 3).await?;
4990 let ids: Vec<i64> = list_entries(&pool, did, ListView::All, None, 10, 0)
4991 .await?
4992 .iter()
4993 .map(|e| e.id)
4994 .collect();
4995
4996 mark_read(&pool, did, ids[0], true).await?;
4997 mark_starred(&pool, did, ids[1], true).await?;
4998
4999 let all = list_entries(&pool, did, ListView::All, None, 10, 0).await?;
5000 let by_id = |id: i64| all.iter().find(|e| e.id == id).expect("row present");
5001 assert!(by_id(ids[0]).read && !by_id(ids[0]).starred);
5002 assert!(!by_id(ids[1]).read && by_id(ids[1]).starred);
5003 // Never touched: no state row at all, which must read as unread.
5004 assert!(!by_id(ids[2]).read && !by_id(ids[2]).starred);
5005
5006 // And the view predicates agree with the bits.
5007 let unread = list_entries(&pool, did, ListView::Unread, None, 10, 0).await?;
5008 assert_eq!(unread.len(), 2);
5009 assert!(unread.iter().all(|e| !e.read));
5010 let starred = list_entries(&pool, did, ListView::Starred, None, 10, 0).await?;
5011 assert_eq!(starred.len(), 1);
5012 assert_eq!(starred[0].id, ids[1]);
5013 Ok(())
5014 }
5015
5016 /// The sidebar's per-feed unread badges, counted in SQL rather than by
5017 /// materializing every unread entry and filtering in Rust.
5018 #[tokio::test]
5019 async fn unread_counts_are_per_feed_and_exclude_read_rows() -> Result<()> {
5020 let pool = init_url("sqlite::memory:").await?;
5021 let did = "did:plc:counts";
5022 let a = seed_big_entries(&pool, did, 5).await?;
5023 let b = upsert_feed(
5024 &pool,
5025 &NewFeed {
5026 url: "https://b.example/f.xml".to_string(),
5027 ..Default::default()
5028 },
5029 )
5030 .await?;
5031 insert_entries(
5032 &pool,
5033 b,
5034 &[
5035 NewEntry {
5036 guid: "b-1".to_string(),
5037 ..Default::default()
5038 },
5039 NewEntry {
5040 guid: "b-2".to_string(),
5041 ..Default::default()
5042 },
5043 ],
5044 0,
5045 )
5046 .await?;
5047 replace_sub_refs(&pool, did, &[a, b]).await?;
5048
5049 let first_a = list_entries(&pool, did, ListView::All, Some(&[a]), 1, 0).await?[0].id;
5050 mark_read(&pool, did, first_a, true).await?;
5051
5052 let counts = unread_counts_by_feed(&pool, did).await?;
5053 assert_eq!(counts.get(&a).copied(), Some(4));
5054 assert_eq!(counts.get(&b).copied(), Some(2));
5055
5056 // A feed the DID does not subscribe to contributes nothing.
5057 replace_sub_refs(&pool, did, &[b]).await?;
5058 let counts = unread_counts_by_feed(&pool, did).await?;
5059 assert_eq!(counts.get(&a), None);
5060 assert_eq!(counts.get(&b).copied(), Some(2));
5061 Ok(())
5062 }
5063
5064 /// **Read-state compaction: the water-mark must absorb the id set.**
5065 ///
5066 /// `read_through` was never computed, so `read_ids` was the only mechanism
5067 /// and grew one id per article read against a 2000-entry per-feed ceiling —
5068 /// while the flusher truncates the record at 1000, keeping the tail. Past
5069 /// 1000 read articles in a feed, the oldest read-state stopped syncing and
5070 /// those articles came back UNREAD in every other atproto reader.
5071 #[tokio::test]
5072 async fn compaction_folds_read_ids_into_the_water_mark() -> Result<()> {
5073 let pool = init_url("sqlite::memory:").await?;
5074 let did = "did:plc:compact";
5075 let feed_url = "https://compact.example/f.xml";
5076 let feed_id = upsert_feed(
5077 &pool,
5078 &NewFeed {
5079 url: feed_url.to_string(),
5080 ..Default::default()
5081 },
5082 )
5083 .await?;
5084 // 40 entries, oldest first by published date.
5085 let entries: Vec<NewEntry> = (0..40)
5086 .map(|i| NewEntry {
5087 guid: format!("c-{i:03}"),
5088 published: Some(format!("2026-01-{:02}T00:00:00Z", i + 1)),
5089 ..Default::default()
5090 })
5091 .collect();
5092 insert_entries(&pool, feed_id, &entries, 0).await?;
5093 replace_sub_refs(&pool, did, &[feed_id]).await?;
5094
5095 let all = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
5096 // Oldest first, so the read prefix is contiguous from the start.
5097 let mut oldest_first = all.clone();
5098 oldest_first.reverse();
5099 for row in oldest_first.iter().take(30) {
5100 mark_read(&pool, did, row.id, true).await?;
5101 }
5102
5103 let before = get_cursor(&pool, did, feed_url).await?.expect("cursor");
5104 assert!(before.read_through.is_none(), "read_through starts unset");
5105 let before_ids: Vec<String> = serde_json::from_str(&before.read_ids)?;
5106 assert_eq!(before_ids.len(), 30, "every read is its own exception");
5107
5108 let watermark = compact_cursor(&pool, did, feed_url)
5109 .await?
5110 .expect("the water-mark must advance");
5111
5112 let after = get_cursor(&pool, did, feed_url).await?.expect("cursor");
5113 assert_eq!(after.read_through.as_deref(), Some(watermark.as_str()));
5114 let after_ids: Vec<String> = serde_json::from_str(&after.read_ids)?;
5115 assert!(
5116 after_ids.is_empty(),
5117 "a contiguous read prefix must fold entirely into the water-mark, left {after_ids:?}"
5118 );
5119 // The 30th entry is read and the 31st is not, so the mark sits on the
5120 // 30th — STRICTLY below the oldest unread, never equal to it.
5121 assert_eq!(watermark, "2026-01-30T00:00:00Z");
5122 assert!(after.dirty, "a rewritten cursor must be re-flushed");
5123 Ok(())
5124 }
5125
5126 /// The water-mark may never cover an unread entry, and may never move
5127 /// backwards. Both would re-assert articles as read that are not.
5128 #[tokio::test]
5129 async fn compaction_stops_below_the_oldest_unread_entry() -> Result<()> {
5130 let pool = init_url("sqlite::memory:").await?;
5131 let did = "did:plc:gap";
5132 let feed_url = "https://gap.example/f.xml";
5133 let feed_id = upsert_feed(
5134 &pool,
5135 &NewFeed {
5136 url: feed_url.to_string(),
5137 ..Default::default()
5138 },
5139 )
5140 .await?;
5141 let entries: Vec<NewEntry> = (0..10)
5142 .map(|i| NewEntry {
5143 guid: format!("g-{i:02}"),
5144 published: Some(format!("2026-02-{:02}T00:00:00Z", i + 1)),
5145 ..Default::default()
5146 })
5147 .collect();
5148 insert_entries(&pool, feed_id, &entries, 0).await?;
5149 replace_sub_refs(&pool, did, &[feed_id]).await?;
5150
5151 let mut oldest_first = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
5152 oldest_first.reverse();
5153 // Read everything EXCEPT the third-oldest: a hole at 2026-02-03.
5154 for (i, row) in oldest_first.iter().enumerate() {
5155 if i != 2 {
5156 mark_read(&pool, did, row.id, true).await?;
5157 }
5158 }
5159
5160 let watermark = compact_cursor(&pool, did, feed_url)
5161 .await?
5162 .expect("advances");
5163 assert_eq!(
5164 watermark, "2026-02-02T00:00:00Z",
5165 "the water-mark jumped the unread hole"
5166 );
5167 let after = get_cursor(&pool, did, feed_url).await?.expect("cursor");
5168 let kept: Vec<String> = serde_json::from_str(&after.read_ids)?;
5169 assert_eq!(
5170 kept.len(),
5171 7,
5172 "the 7 reads ABOVE the hole must stay as explicit exceptions"
5173 );
5174 // The unread hole is above the water-mark, so it needs no unread
5175 // exception — everything above the mark is unread by default.
5176 let unread: Vec<String> = serde_json::from_str(&after.unread_ids)?;
5177 assert!(
5178 unread.is_empty(),
5179 "redundant unread exceptions survived: {unread:?}"
5180 );
5181
5182 // Idempotent, and never backwards: re-running changes nothing.
5183 assert_eq!(
5184 compact_cursor(&pool, did, feed_url).await?,
5185 None,
5186 "a second compaction moved a water-mark that was already correct"
5187 );
5188 Ok(())
5189 }
5190
5191 /// Nothing read yet, or nothing in the feed: compaction must be a no-op
5192 /// rather than inventing a water-mark that asserts the backlog is read.
5193 #[tokio::test]
5194 async fn compaction_never_invents_a_water_mark() -> Result<()> {
5195 let pool = init_url("sqlite::memory:").await?;
5196 let did = "did:plc:none";
5197 let feed_url = "https://none.example/f.xml";
5198 let feed_id = upsert_feed(
5199 &pool,
5200 &NewFeed {
5201 url: feed_url.to_string(),
5202 ..Default::default()
5203 },
5204 )
5205 .await?;
5206 replace_sub_refs(&pool, did, &[feed_id]).await?;
5207
5208 // Empty feed: no entries at all.
5209 assert_eq!(compact_cursor(&pool, did, feed_url).await?, None);
5210
5211 insert_entries(
5212 &pool,
5213 feed_id,
5214 &[
5215 NewEntry {
5216 guid: "n-1".to_string(),
5217 published: Some("2026-03-01T00:00:00Z".to_string()),
5218 ..Default::default()
5219 },
5220 NewEntry {
5221 guid: "n-2".to_string(),
5222 published: Some("2026-03-02T00:00:00Z".to_string()),
5223 ..Default::default()
5224 },
5225 ],
5226 0,
5227 )
5228 .await?;
5229
5230 // Nothing read: the OLDEST entry is unread, so there is no timestamp
5231 // strictly below it and the mark cannot move at all.
5232 assert_eq!(
5233 compact_cursor(&pool, did, feed_url).await?,
5234 None,
5235 "a water-mark appeared with nothing read — that asserts the backlog is read"
5236 );
5237 Ok(())
5238 }
5239
5240 /// **The unsave desync: clearing a star must work for an UNSUBSCRIBED feed.**
5241 ///
5242 /// That is the whole case. Every other starred path is `sub_ref`-scoped, so
5243 /// an entry that is cached AND starred in a feed the reader has since
5244 /// unsubscribed from is invisible to all of them — including the starred
5245 /// list itself. Its PDS record therefore renders as "not cached", and the
5246 /// button on that row deletes the record. If clearing the local star were
5247 /// `sub_ref`-scoped too, it would silently do nothing, and the star would
5248 /// reappear with no record behind it the moment the reader resubscribed.
5249 #[tokio::test]
5250 async fn a_star_can_be_cleared_after_unsubscribing_from_its_feed() -> Result<()> {
5251 let pool = init_url("sqlite::memory:").await?;
5252 let did = "did:plc:unsub";
5253 let feed_id = seed_big_entries(&pool, did, 3).await?;
5254 let rows = list_entries(&pool, did, ListView::All, None, 10, 0).await?;
5255 let target = rows[0].clone();
5256 mark_starred(&pool, did, target.id, true).await?;
5257 assert_eq!(get_starred_for_did(&pool, did).await?.len(), 1);
5258
5259 // Unsubscribe. The entry stays cached and stays starred, but every
5260 // sub_ref-scoped read now skips it.
5261 replace_sub_refs(&pool, did, &[]).await?;
5262 assert!(
5263 get_starred_for_did(&pool, did).await?.is_empty(),
5264 "fixture precondition: the star must be invisible to the scoped read"
5265 );
5266 assert!(
5267 matches!(
5268 starred_identities(&pool, did, 1_000).await?,
5269 StarredIdentities::All(ref v) if v.is_empty()
5270 ),
5271 "fixture precondition: the identity lookup must miss it too"
5272 );
5273 let still_starred: i64 =
5274 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND starred = 1")
5275 .bind(did)
5276 .fetch_one(&pool)
5277 .await?;
5278 assert_eq!(
5279 still_starred, 1,
5280 "the star is still there, just unreachable"
5281 );
5282
5283 // The removal path must reach it anyway.
5284 let cleared =
5285 clear_star_by_identity(&pool, did, target.url.as_deref(), Some(&target.guid)).await?;
5286 assert_eq!(cleared, 1, "the star survived the unsave");
5287 let after: i64 =
5288 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND starred = 1")
5289 .bind(did)
5290 .fetch_one(&pool)
5291 .await?;
5292 assert_eq!(after, 0);
5293
5294 // Resubscribing must NOT bring it back.
5295 replace_sub_refs(&pool, did, &[feed_id]).await?;
5296 assert!(
5297 get_starred_for_did(&pool, did).await?.is_empty(),
5298 "the star came back after resubscribing — the desync is still there"
5299 );
5300 Ok(())
5301 }
5302
5303 /// It clears only the CALLER's star, and only for the matching article.
5304 ///
5305 /// Omitting `sub_ref` is safe precisely because `did` is not optional; this
5306 /// pins that, and that a non-matching identity is a no-op rather than a
5307 /// wildcard.
5308 #[tokio::test]
5309 async fn clearing_a_star_touches_only_that_did_and_that_article() -> Result<()> {
5310 let pool = init_url("sqlite::memory:").await?;
5311 let mine = "did:plc:mine";
5312 let theirs = "did:plc:theirs";
5313 let feed_id = seed_big_entries(&pool, mine, 3).await?;
5314 replace_sub_refs(&pool, theirs, &[feed_id]).await?;
5315 let rows = list_entries(&pool, mine, ListView::All, None, 10, 0).await?;
5316
5317 for r in &rows {
5318 mark_starred(&pool, mine, r.id, true).await?;
5319 mark_starred(&pool, theirs, r.id, true).await?;
5320 }
5321
5322 let target = &rows[1];
5323 assert_eq!(
5324 clear_star_by_identity(&pool, mine, target.url.as_deref(), Some(&target.guid)).await?,
5325 1
5326 );
5327
5328 let count = |did: &'static str| {
5329 let pool = pool.clone();
5330 async move {
5331 sqlx::query_scalar::<_, i64>(
5332 "SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND starred = 1",
5333 )
5334 .bind(did)
5335 .fetch_one(&pool)
5336 .await
5337 .unwrap()
5338 }
5339 };
5340 assert_eq!(count(mine).await, 2, "it cleared more than the one article");
5341 assert_eq!(count(theirs).await, 3, "it cleared another DID's stars");
5342
5343 // An identity that matches nothing is a no-op, not a wildcard.
5344 assert_eq!(
5345 clear_star_by_identity(&pool, mine, Some("https://nope.example/x"), Some("nope"))
5346 .await?,
5347 0
5348 );
5349 assert_eq!(count(mine).await, 2);
5350 // **Clearing an already-cleared star is a no-op**, reported as one:
5351 // `web::unsave` branches on `Ok(0)` vs `Ok(n)` to decide whether a
5352 // local star was actually cleared. This used to be untested — every
5353 // article here was starred first — so `starred = 1` in the WHERE clause
5354 // could be widened to `IN (0, 1)` with the suite green, rewriting
5355 // `updated_at` on rows that changed nothing and logging clears that
5356 // never happened.
5357 let before: String = sqlx::query_scalar(
5358 "SELECT updated_at FROM entry_state WHERE did = ?1 AND entry_id = ?2",
5359 )
5360 .bind(mine)
5361 .bind(target.id)
5362 .fetch_one(&pool)
5363 .await?;
5364 assert_eq!(
5365 clear_star_by_identity(&pool, mine, target.url.as_deref(), Some(&target.guid)).await?,
5366 0,
5367 "a second clear reported rows it did not change"
5368 );
5369 let after: String = sqlx::query_scalar(
5370 "SELECT updated_at FROM entry_state WHERE did = ?1 AND entry_id = ?2",
5371 )
5372 .bind(mine)
5373 .bind(target.id)
5374 .fetch_one(&pool)
5375 .await?;
5376 assert_eq!(before, after, "a no-op clear rewrote updated_at");
5377
5378 // And neither identifier present does nothing at all.
5379 assert_eq!(clear_star_by_identity(&pool, mine, None, None).await?, 0);
5380 assert_eq!(
5381 clear_star_by_identity(&pool, mine, Some(""), Some("")).await?,
5382 0
5383 );
5384 assert_eq!(count(mine).await, 2);
5385 Ok(())
5386 }
5387
5388 /// `starred_identities` must span the WHOLE starred set, not a page.
5389 ///
5390 /// The starred view matches PDS saved records against it; a cached article
5391 /// missing from the set renders as "not cached", and that row's button
5392 /// deletes the PDS RECORD instead of un-starring the entry. Narrowing this
5393 /// set changes what a click destroys.
5394 #[tokio::test]
5395 async fn starred_identities_span_the_whole_set() -> Result<()> {
5396 let pool = init_url("sqlite::memory:").await?;
5397 let did = "did:plc:ident";
5398 seed_big_entries(&pool, did, 150).await?;
5399 for row in list_entries(&pool, did, ListView::All, None, 1_000, 0).await? {
5400 mark_starred(&pool, did, row.id, true).await?;
5401 }
5402
5403 let identities = match starred_identities(&pool, did, 20_000).await? {
5404 StarredIdentities::All(v) => v,
5405 StarredIdentities::Truncated => panic!("150 rows must not read as truncated"),
5406 };
5407 assert_eq!(
5408 identities.len(),
5409 150,
5410 "the identity set was truncated to a page"
5411 );
5412 assert!(identities
5413 .iter()
5414 .all(|(url, guid)| url.is_some() && !guid.is_empty()));
5415
5416 // **Hitting the cap must be REPORTED, not absorbed.** It used to return
5417 // an arbitrary subset with no way to tell, and every starred article
5418 // outside that subset then rendered an un-save button that deletes the
5419 // PDS record rather than un-starring the entry.
5420 assert!(
5421 matches!(
5422 starred_identities(&pool, did, 10).await?,
5423 StarredIdentities::Truncated
5424 ),
5425 "a truncated identity set reported itself as complete"
5426 );
5427 // Landing EXACTLY on the cap is complete, not truncated — the query asks
5428 // for one extra row precisely so the two are distinguishable.
5429 assert!(
5430 matches!(
5431 starred_identities(&pool, did, 150).await?,
5432 StarredIdentities::All(ref v) if v.len() == 150
5433 ),
5434 "a set exactly at the cap was misreported as truncated"
5435 );
5436 Ok(())
5437 }
5438
5439 /// Prev/next ids are bounded too, and keep the list's ordering.
5440 #[tokio::test]
5441 async fn entry_ids_are_ordered_and_capped() -> Result<()> {
5442 let pool = init_url("sqlite::memory:").await?;
5443 let did = "did:plc:ids";
5444 seed_big_entries(&pool, did, 60).await?;
5445
5446 let capped = list_entry_ids(&pool, did, ListView::All, None, 25).await?;
5447 assert_eq!(capped.len(), 25);
5448
5449 let rows = list_entries(&pool, did, ListView::All, None, 25, 0).await?;
5450 assert_eq!(
5451 capped,
5452 rows.iter().map(|e| e.id).collect::<Vec<_>>(),
5453 "the id list and the row list disagree on ordering"
5454 );
5455 Ok(())
5456 }
5457
5458 // -----------------------------------------------------------------------
5459 // Read-state PDS sync wiring: marking read/unread must project into the
5460 // per-feed `read_cursor` and mark it dirty so the batched flusher pushes it.
5461 // Before this wiring `mark_read` touched only `entry_state`; nothing dirtied
5462 // a cursor, so the flusher never synced read-state to the PDS.
5463 // -----------------------------------------------------------------------
5464
5465 #[tokio::test]
5466 async fn mark_read_dirties_the_feed_cursor() -> Result<()> {
5467 let pool = init_url("sqlite::memory:").await?;
5468 let feed_url = "https://example.com/feed.xml";
5469 let feed_id = upsert_feed(
5470 &pool,
5471 &NewFeed {
5472 url: feed_url.to_string(),
5473 title: Some("Example".to_string()),
5474 ..Default::default()
5475 },
5476 )
5477 .await?;
5478 insert_entries(
5479 &pool,
5480 feed_id,
5481 &[
5482 NewEntry {
5483 guid: "g1".to_string(),
5484 published: Some("2026-07-10T00:00:00Z".to_string()),
5485 ..Default::default()
5486 },
5487 NewEntry {
5488 guid: "g2".to_string(),
5489 published: Some("2026-07-11T00:00:00Z".to_string()),
5490 ..Default::default()
5491 },
5492 ],
5493 0,
5494 )
5495 .await?;
5496 let did = "did:plc:reader";
5497 replace_sub_refs(&pool, did, &[feed_id]).await?;
5498
5499 // No cursor exists yet.
5500 assert!(get_cursor(&pool, did, feed_url).await?.is_none());
5501 assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
5502
5503 // Mark one entry read → the feed's read_cursor row now exists, dirty=1,
5504 // and dirty_cursors returns it (the exact assertion the fix requires).
5505 let e1 = entries_for_feed(&pool, did, feed_id).await?[0].id;
5506 assert!(mark_read(&pool, did, e1, true).await?);
5507
5508 let cursor = get_cursor(&pool, did, feed_url)
5509 .await?
5510 .expect("mark_read must create the feed's read_cursor");
5511 assert!(cursor.dirty, "cursor must be dirty after mark_read");
5512 assert!(
5513 cursor.read_ids.contains(&e1.to_string()),
5514 "the read entry id must be in read_ids: {}",
5515 cursor.read_ids
5516 );
5517 let dirty = dirty_cursors(&pool, did).await?;
5518 assert_eq!(dirty.len(), 1, "flusher must see the newly dirty cursor");
5519 assert_eq!(dirty[0].feed_url, feed_url);
5520
5521 // Marking it unread again moves the id to unread_ids and keeps it dirty.
5522 assert!(mark_read(&pool, did, e1, false).await?);
5523 let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
5524 assert!(cursor.dirty);
5525 assert!(
5526 cursor.unread_ids.contains(&e1.to_string()),
5527 "unread id must be in unread_ids: {}",
5528 cursor.unread_ids
5529 );
5530 assert!(
5531 !cursor.read_ids.contains(&e1.to_string()),
5532 "id must have left read_ids: {}",
5533 cursor.read_ids
5534 );
5535
5536 // mark_feed_read dirties the one per-feed cursor too (batched, not
5537 // per-article).
5538 assert!(mark_feed_read(&pool, did, feed_id, true).await? > 0);
5539 let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
5540 assert!(cursor.dirty);
5541 assert_eq!(dirty_cursors(&pool, did).await?.len(), 1);
5542
5543 // A non-subscriber's mark_read is a no-op and dirties NO cursor.
5544 let outsider = "did:plc:outsider";
5545 assert!(!mark_read(&pool, outsider, e1, true).await?);
5546 assert_eq!(dirty_cursors(&pool, outsider).await?.len(), 0);
5547
5548 // The conditional clear only clears when updated_at matches the snapshot.
5549 let snap = dirty_cursors(&pool, did).await?[0].clone();
5550 // A stale updated_at must NOT clear (models a concurrent re-dirty).
5551 clear_cursor_dirty(&pool, did, feed_url, "1999-01-01T00:00:00Z").await?;
5552 assert_eq!(
5553 dirty_cursors(&pool, did).await?.len(),
5554 1,
5555 "stale-snapshot clear must be a no-op"
5556 );
5557 // The matching updated_at clears it.
5558 clear_cursor_dirty(&pool, did, feed_url, &snap.updated_at).await?;
5559 assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
5560
5561 Ok(())
5562 }
5563
5564 #[test]
5565 fn json_id_set_toggle_is_set_like() {
5566 // Add is idempotent, remove drops, output is a JSON string array.
5567 let s = json_id_set_toggle("[]", 5, true);
5568 assert_eq!(s, r#"["5"]"#);
5569 assert_eq!(json_id_set_toggle(&s, 5, true), r#"["5"]"#); // no dup
5570 let s = json_id_set_toggle(&s, 7, true);
5571 assert_eq!(s, r#"["5","7"]"#);
5572 let s = json_id_set_toggle(&s, 5, false);
5573 assert_eq!(s, r#"["7"]"#);
5574 // Tolerates numeric-array input and malformed input.
5575 assert_eq!(json_id_set_toggle("[1,2]", 3, true), r#"["1","2","3"]"#);
5576 assert_eq!(json_id_set_toggle("garbage", 1, true), r#"["1"]"#);
5577 }
5578
5579 // -----------------------------------------------------------------------
5580 // Per-DID isolation: the shared cache is one row per URL, but the READ
5581 // SURFACE (entries/unread/starred) and the read/star MUTATIONS are scoped
5582 // to the caller's own subscriptions (`sub_ref`). User A must never see or
5583 // mutate user B's entries.
5584 // -----------------------------------------------------------------------
5585
5586 #[tokio::test]
5587 async fn per_did_isolation_scopes_reads_and_mutations() -> Result<()> {
5588 let pool = init_url("sqlite::memory:").await?;
5589
5590 // Two feeds in the SHARED cache; A subscribes to feed_a, B to feed_b.
5591 let feed_a = upsert_feed(
5592 &pool,
5593 &NewFeed {
5594 url: "https://a.example/feed.xml".to_string(),
5595 title: Some("A".to_string()),
5596 ..Default::default()
5597 },
5598 )
5599 .await?;
5600 let feed_b = upsert_feed(
5601 &pool,
5602 &NewFeed {
5603 url: "https://b.example/feed.xml".to_string(),
5604 title: Some("B".to_string()),
5605 ..Default::default()
5606 },
5607 )
5608 .await?;
5609
5610 insert_entries(
5611 &pool,
5612 feed_a,
5613 &[NewEntry {
5614 guid: "a-1".to_string(),
5615 url: Some("https://a.example/1".to_string()),
5616 title: Some("A one".to_string()),
5617 published: Some("2026-07-10T00:00:00Z".to_string()),
5618 content_html: Some("<p>secret A body</p>".to_string()),
5619 ..Default::default()
5620 }],
5621 0,
5622 )
5623 .await?;
5624 insert_entries(
5625 &pool,
5626 feed_b,
5627 &[NewEntry {
5628 guid: "b-1".to_string(),
5629 url: Some("https://b.example/1".to_string()),
5630 title: Some("B one".to_string()),
5631 published: Some("2026-07-11T00:00:00Z".to_string()),
5632 content_html: Some("<p>secret B body</p>".to_string()),
5633 ..Default::default()
5634 }],
5635 0,
5636 )
5637 .await?;
5638
5639 let did_a = "did:plc:aaaa";
5640 let did_b = "did:plc:bbbb";
5641 replace_sub_refs(&pool, did_a, &[feed_a]).await?;
5642 replace_sub_refs(&pool, did_b, &[feed_b]).await?;
5643
5644 // The id of B's only entry (the one A must not be able to touch).
5645 let b_entry_id = entries_for_feed(&pool, did_b, feed_b).await?[0].id;
5646
5647 // --- entries_for_feed is scoped: A sees A's feed, not B's ------------
5648 assert_eq!(entries_for_feed(&pool, did_a, feed_a).await?.len(), 1);
5649 assert!(
5650 entries_for_feed(&pool, did_a, feed_b).await?.is_empty(),
5651 "A must not read entries of a feed it does not subscribe to"
5652 );
5653
5654 // --- unread list is scoped -------------------------------------------
5655 let unread_a = get_unread_for_did(&pool, did_a).await?;
5656 assert_eq!(unread_a.len(), 1);
5657 assert_eq!(unread_a[0].guid, "a-1");
5658 let unread_b = get_unread_for_did(&pool, did_b).await?;
5659 assert_eq!(unread_b.len(), 1);
5660 assert_eq!(unread_b[0].guid, "b-1");
5661
5662 // --- did_subscribes_to_entry authorizes correctly --------------------
5663 assert!(did_subscribes_to_entry(&pool, did_b, b_entry_id).await?);
5664 assert!(
5665 !did_subscribes_to_entry(&pool, did_a, b_entry_id).await?,
5666 "A does not subscribe to B's feed"
5667 );
5668
5669 // --- mark_read is authorized: A CANNOT mark B's entry ----------------
5670 assert!(
5671 !mark_read(&pool, did_a, b_entry_id, true).await?,
5672 "non-subscriber mark_read must be a no-op (→ 404), never a mutation"
5673 );
5674 // B's unread list is untouched by A's attempt.
5675 assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 1);
5676 // A subscriber CAN mark it.
5677 assert!(mark_read(&pool, did_b, b_entry_id, true).await?);
5678 assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 0);
5679
5680 // --- toggle_star is authorized the same way --------------------------
5681 assert!(
5682 !mark_starred(&pool, did_a, b_entry_id, true).await?,
5683 "non-subscriber mark_starred must be a no-op (→ 404)"
5684 );
5685 assert!(
5686 get_starred_for_did(&pool, did_a).await?.is_empty(),
5687 "A's starred list stays empty after the rejected attempt"
5688 );
5689 assert!(mark_starred(&pool, did_b, b_entry_id, true).await?);
5690 assert_eq!(get_starred_for_did(&pool, did_b).await?.len(), 1);
5691 // B's star never leaks into A's starred list.
5692 assert!(get_starred_for_did(&pool, did_a).await?.is_empty());
5693
5694 // --- feeds_for_did is scoped to the DID's OWN sub_ref ----------------
5695 // This is the PDS-unreachable fallback's projection: it must NEVER
5696 // widen a DID's surface to feeds it does not subscribe to. A sees only
5697 // feed_a; B (still subscribed to feed_b here) sees only feed_b.
5698 let a_feeds = feeds_for_did(&pool, did_a).await?;
5699 assert_eq!(a_feeds.len(), 1);
5700 assert_eq!(a_feeds[0].id, feed_a);
5701 let b_feeds = feeds_for_did(&pool, did_b).await?;
5702 assert_eq!(b_feeds.len(), 1);
5703 assert_eq!(b_feeds[0].id, feed_b);
5704
5705 // --- resync drops a feed from the surface when the sub goes away ------
5706 replace_sub_refs(&pool, did_b, &[]).await?;
5707 assert!(get_unread_for_did(&pool, did_b).await?.is_empty());
5708 assert!(get_starred_for_did(&pool, did_b).await?.is_empty());
5709 assert!(entries_for_feed(&pool, did_b, feed_b).await?.is_empty());
5710 // And the fallback projection is empty too — fail CLOSED, not open.
5711 assert!(feeds_for_did(&pool, did_b).await?.is_empty());
5712
5713 Ok(())
5714 }
5715
5716 // -----------------------------------------------------------------------
5717 // PDS-outage authorization (fail CLOSED). REGRESSION GUARD for the past
5718 // FAIL-OPEN bug (fixed in 2e53e0e): `resolve_subscriptions`' PDS/sidecar-
5719 // unreachable fallback used to synthesize a DID's `sub_ref` from EVERY
5720 // cached feed (`due_feeds(.., i64::MAX)`), granting cross-tenant read +
5721 // mutate during any outage. The fix serves the DID's OWN last-known
5722 // `sub_ref` via `feeds_for_did(did)` and NEVER widens it.
5723 //
5724 // This test replays that fixed fallback at the store layer — the seam the
5725 // web handler drives when `list_subscriptions_sorted(did) -> Err`. The
5726 // key adversarial shape is an ORPHAN cached feed (in the shared cache but
5727 // subscribed by NO ONE): the old fail-open code would have folded it into
5728 // the caller's surface. If the fail-open is reintroduced, `feeds_for_did`
5729 // would include that orphan and every assertion below flips — so this is a
5730 // real guard, not a tautology.
5731 // -----------------------------------------------------------------------
5732
5733 #[tokio::test]
5734 async fn pds_outage_fallback_fails_closed_not_open() -> Result<()> {
5735 let pool = init_url("sqlite::memory:").await?;
5736
5737 let did_a = "did:plc:aaaa";
5738
5739 // feed_a: A's own subscription (its last-known `sub_ref`; the fallback
5740 // may serve this stale but must not widen past it).
5741 let feed_a = upsert_feed(
5742 &pool,
5743 &NewFeed {
5744 url: "https://a.example/feed.xml".to_string(),
5745 title: Some("A".to_string()),
5746 ..Default::default()
5747 },
5748 )
5749 .await?;
5750 // feed_orphan: present in the SHARED cache but subscribed by NO DID.
5751 // This is exactly what the fail-open path would have leaked to A.
5752 let feed_orphan = upsert_feed(
5753 &pool,
5754 &NewFeed {
5755 url: "https://orphan.example/feed.xml".to_string(),
5756 title: Some("Orphan".to_string()),
5757 ..Default::default()
5758 },
5759 )
5760 .await?;
5761
5762 insert_entries(
5763 &pool,
5764 feed_a,
5765 &[NewEntry {
5766 guid: "a-1".to_string(),
5767 url: Some("https://a.example/1".to_string()),
5768 title: Some("A one".to_string()),
5769 published: Some("2026-07-10T00:00:00Z".to_string()),
5770 content_html: Some("<p>A body</p>".to_string()),
5771 ..Default::default()
5772 }],
5773 0,
5774 )
5775 .await?;
5776 insert_entries(
5777 &pool,
5778 feed_orphan,
5779 &[NewEntry {
5780 guid: "orphan-1".to_string(),
5781 url: Some("https://orphan.example/1".to_string()),
5782 title: Some("Orphan one".to_string()),
5783 published: Some("2026-07-11T00:00:00Z".to_string()),
5784 content_html: Some("<p>secret orphan body</p>".to_string()),
5785 ..Default::default()
5786 }],
5787 0,
5788 )
5789 .await?;
5790
5791 // A's last-known subscription set is feed_a ONLY. No `sub_ref` row ever
5792 // points any DID at feed_orphan.
5793 replace_sub_refs(&pool, did_a, &[feed_a]).await?;
5794
5795 // Grab the orphan entry id via a transient sub so we can address it,
5796 // then drop the sub — nobody subscribes to feed_orphan afterwards.
5797 replace_sub_refs(&pool, "did:plc:seed", &[feed_orphan]).await?;
5798 let orphan_entry_id = entries_for_feed(&pool, "did:plc:seed", feed_orphan).await?[0].id;
5799 replace_sub_refs(&pool, "did:plc:seed", &[]).await?;
5800
5801 // --- Replay the FIXED fallback projection ----------------------------
5802 // This is what `resolve_subscriptions` serves on the Err (outage) path:
5803 // the caller's OWN feeds, never widened. It must contain feed_a and
5804 // NEVER the orphan. (The old fail-open synthesized from every cached
5805 // feed → this vec would have held feed_orphan too.)
5806 let fallback = feeds_for_did(&pool, did_a).await?;
5807 let fallback_ids: Vec<i64> = fallback.iter().map(|f| f.id).collect();
5808 assert_eq!(
5809 fallback_ids,
5810 vec![feed_a],
5811 "outage fallback must serve ONLY A's own last-known sub_ref, \
5812 never widen to the orphan cached feed"
5813 );
5814 assert!(
5815 !fallback_ids.contains(&feed_orphan),
5816 "FAIL-OPEN regression: outage fallback leaked an unsubscribed \
5817 cached feed into A's surface"
5818 );
5819
5820 // --- With that projection in place, EVERY scoped read denies A -------
5821 assert!(
5822 !did_subscribes_to_entry(&pool, did_a, orphan_entry_id).await?,
5823 "A must not be authorized for an orphan feed's entry during an outage"
5824 );
5825 assert!(
5826 entries_for_feed(&pool, did_a, feed_orphan)
5827 .await?
5828 .is_empty(),
5829 "entries_for_feed must not expose the orphan feed to A during an outage"
5830 );
5831 // Neither the unread nor the starred list may surface the orphan entry.
5832 let unread_guids: Vec<String> = get_unread_for_did(&pool, did_a)
5833 .await?
5834 .into_iter()
5835 .map(|e| e.guid)
5836 .collect();
5837 assert!(
5838 !unread_guids.iter().any(|g| g == "orphan-1"),
5839 "orphan entry leaked into A's unread list during an outage"
5840 );
5841 assert!(
5842 get_starred_for_did(&pool, did_a).await?.is_empty(),
5843 "A has no starred entries; the orphan must not appear"
5844 );
5845
5846 // --- And EVERY scoped mutation is a no-op (→ 404 at the web layer) ---
5847 assert!(
5848 !mark_read(&pool, did_a, orphan_entry_id, true).await?,
5849 "A must not mark an orphan feed's entry read during an outage"
5850 );
5851 assert!(
5852 !mark_starred(&pool, did_a, orphan_entry_id, true).await?,
5853 "A must not star an orphan feed's entry during an outage"
5854 );
5855 assert_eq!(
5856 mark_feed_read(&pool, did_a, feed_orphan, true).await?,
5857 0,
5858 "A must not mark-all-read the orphan feed during an outage"
5859 );
5860
5861 // Nothing was written for A against the orphan entry.
5862 let es_count: i64 =
5863 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
5864 .bind(did_a)
5865 .bind(orphan_entry_id)
5866 .fetch_one(&pool)
5867 .await?;
5868 assert_eq!(es_count, 0, "no cross-tenant mutation during the outage");
5869
5870 Ok(())
5871 }
5872
5873 // -----------------------------------------------------------------------
5874 // Closed-beta invite gate
5875 // -----------------------------------------------------------------------
5876
5877 #[test]
5878 fn code_gen_shape_and_alphabet() {
5879 for _ in 0..200 {
5880 let code = generate_invite_code().unwrap();
5881 assert!(code.starts_with("FEATHER-"), "bad prefix: {code}");
5882 let body = &code["FEATHER-".len()..];
5883 assert_eq!(body.len(), CODE_BODY_LEN, "bad body length: {code}");
5884 // Every body char must be from the ambiguity-free alphabet — in
5885 // particular NEVER I/O/0/1.
5886 for c in body.chars() {
5887 assert!(
5888 CODE_ALPHABET.contains(&(c as u8)),
5889 "char {c:?} not in alphabet ({code})"
5890 );
5891 assert!(
5892 !matches!(c, 'I' | 'O' | '0' | '1'),
5893 "ambiguous char {c:?} leaked into {code}"
5894 );
5895 }
5896 }
5897 // Two codes in a row must differ (unguessable / random).
5898 assert_ne!(
5899 generate_invite_code().unwrap(),
5900 generate_invite_code().unwrap()
5901 );
5902 }
5903
5904 #[tokio::test]
5905 async fn busy_timeout_is_applied() -> Result<()> {
5906 // Opening an on-disk DB and reading back the PRAGMA proves the pool
5907 // carries busy_timeout = 5000 ms.
5908 let dir = std::env::temp_dir().join(format!("fr-busy-{}", std::process::id()));
5909 std::fs::create_dir_all(&dir).ok();
5910 let path = dir.join("busy.db");
5911 let url = format!("sqlite://{}", path.display());
5912 let pool = init_url(&url).await?;
5913 let row = sqlx::query("PRAGMA busy_timeout").fetch_one(&pool).await?;
5914 let timeout: i64 = row.get(0);
5915 assert_eq!(timeout, 5000, "busy_timeout should be 5000 ms");
5916 pool.close().await;
5917 std::fs::remove_dir_all(&dir).ok();
5918 Ok(())
5919 }
5920
5921 #[tokio::test]
5922 async fn redeem_valid_grants_seat() -> Result<()> {
5923 let pool = init_url("sqlite::memory:").await?;
5924 let code = mint_code(&pool, "did:plc:creator", 3600).await?;
5925 assert!(!has_beta_access(&pool, "did:plc:new").await?);
5926
5927 let out = redeem_code(&pool, &code, "did:plc:new", Some("new.bsky"), 100).await?;
5928 assert_eq!(out, Ok(()));
5929 assert!(has_beta_access(&pool, "did:plc:new").await?);
5930 assert_eq!(count_beta_access(&pool).await?, 1);
5931
5932 // The code is now spent — a second redeem is AlreadyRedeemed.
5933 let again = redeem_code(&pool, &code, "did:plc:other", None, 100).await?;
5934 assert_eq!(again, Err(RedeemError::AlreadyRedeemed));
5935 Ok(())
5936 }
5937
5938 #[tokio::test]
5939 async fn redeem_not_found() -> Result<()> {
5940 let pool = init_url("sqlite::memory:").await?;
5941 let out = redeem_code(&pool, "FEATHER-NOPENOPE", "did:plc:x", None, 100).await?;
5942 assert_eq!(out, Err(RedeemError::NotFound));
5943 Ok(())
5944 }
5945
5946 /// Insert an already-expired `active` code directly (mint_code clamps a
5947 /// negative ttl to 0, so the past-expiry case is set up by hand).
5948 async fn insert_expired_code(pool: &SqlitePool, code: &str, creator: &str) -> Result<()> {
5949 let now = now_unix();
5950 sqlx::query(
5951 r#"INSERT INTO invite_codes
5952 (code, creator_did, status, invitee_did, created_at, expires_at, redeemed_at)
5953 VALUES (?1, ?2, 'active', NULL, ?3, ?4, NULL)"#,
5954 )
5955 .bind(code)
5956 .bind(creator)
5957 .bind(now - 100)
5958 .bind(now - 10) // expires_at in the past
5959 .execute(pool)
5960 .await?;
5961 Ok(())
5962 }
5963
5964 #[tokio::test]
5965 async fn redeem_expired() -> Result<()> {
5966 let pool = init_url("sqlite::memory:").await?;
5967 insert_expired_code(&pool, "FEATHER-EXPIRED0", "did:plc:creator").await?;
5968 let out = redeem_code(&pool, "FEATHER-EXPIRED0", "did:plc:new", None, 100).await?;
5969 assert_eq!(out, Err(RedeemError::Expired));
5970 // No seat granted.
5971 assert_eq!(count_beta_access(&pool).await?, 0);
5972 Ok(())
5973 }
5974
5975 #[tokio::test]
5976 async fn redeem_capacity_full() -> Result<()> {
5977 let pool = init_url("sqlite::memory:").await?;
5978 // Cap of 1, one seat already taken by an admin seed.
5979 ensure_seed(&pool, &["did:plc:admin".to_string()]).await?;
5980 assert_eq!(count_beta_access(&pool).await?, 1);
5981
5982 let code = mint_code(&pool, "did:plc:admin", 3600).await?;
5983 let out = redeem_code(&pool, &code, "did:plc:new", None, 1).await?;
5984 assert_eq!(out, Err(RedeemError::CapacityFull));
5985 // Seat NOT granted and the code NOT consumed (tx rolled back).
5986 assert!(!has_beta_access(&pool, "did:plc:new").await?);
5987 // Raising the cap lets the same code redeem.
5988 let ok = redeem_code(&pool, &code, "did:plc:new", None, 2).await?;
5989 assert_eq!(ok, Ok(()));
5990 Ok(())
5991 }
5992
5993 #[tokio::test]
5994 async fn count_active_codes_excludes_expired_and_redeemed() -> Result<()> {
5995 let pool = init_url("sqlite::memory:").await?;
5996 assert_eq!(count_active_codes(&pool).await?, 0);
5997
5998 // Two live codes.
5999 let a = mint_code(&pool, "did:plc:bot", 3600).await?;
6000 let _b = mint_code(&pool, "did:plc:bot", 3600).await?;
6001 assert_eq!(count_active_codes(&pool).await?, 2);
6002
6003 // An expired code doesn't count.
6004 insert_expired_code(&pool, "FEATHER-EXPIRED0", "did:plc:bot").await?;
6005 assert_eq!(count_active_codes(&pool).await?, 2);
6006
6007 // Redeeming one drops the active count.
6008 let out = redeem_code(&pool, &a, "did:plc:new", None, 100).await?;
6009 assert_eq!(out, Ok(()));
6010 assert_eq!(count_active_codes(&pool).await?, 1);
6011 Ok(())
6012 }
6013
6014 #[tokio::test]
6015 async fn expire_and_seed() -> Result<()> {
6016 let pool = init_url("sqlite::memory:").await?;
6017 // An already-expired code is swept to `expired`.
6018 insert_expired_code(&pool, "FEATHER-EXPIRED1", "did:plc:creator").await?;
6019 let live = mint_code(&pool, "did:plc:creator", 3600).await?;
6020 let n = expire_old_codes(&pool).await?;
6021 assert_eq!(n, 1, "exactly the past-expiry code should flip");
6022 // The live code still redeems.
6023 assert_eq!(
6024 redeem_code(&pool, &live, "did:plc:new", None, 100).await?,
6025 Ok(())
6026 );
6027
6028 // ensure_seed is idempotent.
6029 let created = ensure_seed(
6030 &pool,
6031 &["did:plc:seed1".to_string(), "did:plc:seed2".to_string()],
6032 )
6033 .await?;
6034 assert_eq!(created, 2);
6035 let created2 = ensure_seed(&pool, &["did:plc:seed1".to_string()]).await?;
6036 assert_eq!(created2, 0, "re-seeding an existing DID is a no-op");
6037 assert!(has_beta_access(&pool, "did:plc:seed1").await?);
6038 Ok(())
6039 }
6040
6041 /// **The sweep spares a REDEEMED code that is past its TTL.** The
6042 /// existing sweep test seeds one active past-expiry code and one live
6043 /// one, so the `status = 'active'` guard never excludes anything — with
6044 /// it deleted the suite stayed green. Without it the hourly sweep rewrites
6045 /// redeemed codes to `expired`, destroying the redemption the invite audit
6046 /// trail depends on and inflating the logged sweep count.
6047 #[tokio::test]
6048 async fn the_expiry_sweep_spares_redeemed_codes() -> Result<()> {
6049 let pool = init_url("sqlite::memory:").await?;
6050 let code = mint_code(&pool, "did:plc:creator", 3600).await?;
6051 assert!(redeem_code(&pool, &code, "did:plc:new", None, 100)
6052 .await?
6053 .is_ok());
6054 // Time passes: the redeemed code is now past its TTL.
6055 sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6056 .bind(now_unix() - 10)
6057 .bind(&code)
6058 .execute(&pool)
6059 .await?;
6060 insert_expired_code(&pool, "FEATHER-EXPIRED2", "did:plc:creator").await?;
6061
6062 let n = expire_old_codes(&pool).await?;
6063 assert_eq!(n, 1, "the sweep counted the redeemed code");
6064 let status: String = sqlx::query_scalar("SELECT status FROM invite_codes WHERE code = ?1")
6065 .bind(&code)
6066 .fetch_one(&pool)
6067 .await?;
6068 assert_eq!(status, "redeemed", "the sweep rewrote a redemption");
6069 Ok(())
6070 }
6071
6072 // -----------------------------------------------------------------------
6073 // Hardening caps: per-DID sub count, global feed count, per-feed entry trim.
6074 // -----------------------------------------------------------------------
6075
6076 #[tokio::test]
6077 async fn count_helpers_track_feeds_and_subs() -> Result<()> {
6078 let pool = init_url("sqlite::memory:").await?;
6079 assert_eq!(count_feeds(&pool).await?, 0);
6080
6081 let mut ids = Vec::new();
6082 for i in 0..3 {
6083 let id = upsert_feed(
6084 &pool,
6085 &NewFeed {
6086 url: format!("https://f{i}.example/feed.xml"),
6087 ..Default::default()
6088 },
6089 )
6090 .await?;
6091 ids.push(id);
6092 }
6093 assert_eq!(count_feeds(&pool).await?, 3);
6094
6095 let did = "did:plc:capcheck";
6096 assert_eq!(count_subscriptions_for_did(&pool, did).await?, 0);
6097 replace_sub_refs(&pool, did, &ids).await?;
6098 assert_eq!(count_subscriptions_for_did(&pool, did).await?, 3);
6099 Ok(())
6100 }
6101
6102 #[tokio::test]
6103 async fn insert_entries_trims_over_cap_keeping_newest() -> Result<()> {
6104 let pool = init_url("sqlite::memory:").await?;
6105 let feed_id = upsert_feed(
6106 &pool,
6107 &NewFeed {
6108 url: "https://firehose.example/feed.xml".to_string(),
6109 ..Default::default()
6110 },
6111 )
6112 .await?;
6113
6114 // Insert 5 entries with ascending published dates, cap retained to 2.
6115 let batch: Vec<NewEntry> = (0..5)
6116 .map(|i| NewEntry {
6117 guid: format!("g-{i}"),
6118 title: Some(format!("E{i}")),
6119 published: Some(format!("2026-07-0{}T00:00:00Z", i + 1)),
6120 ..Default::default()
6121 })
6122 .collect();
6123 insert_entries(&pool, feed_id, &batch, 2).await?;
6124
6125 let did = "did:plc:trim";
6126 replace_sub_refs(&pool, did, &[feed_id]).await?;
6127 let kept = entries_for_feed(&pool, did, feed_id).await?;
6128 assert_eq!(
6129 kept.len(),
6130 2,
6131 "over-cap feed trimmed to the newest 2 entries"
6132 );
6133 // Newest first: g-4 (2026-07-05), g-3 (2026-07-04).
6134 assert_eq!(kept[0].guid, "g-4");
6135 assert_eq!(kept[1].guid, "g-3");
6136 Ok(())
6137 }
6138
6139 /// Regression: an UNDATED entry (NULL `published`) that was fetched most
6140 /// recently must NOT be evicted in favour of an older *dated* entry. The
6141 /// trim orders by `COALESCE(published, fetched_at) DESC`; under the old
6142 /// `ORDER BY published DESC` a NULL-published row sorts LAST and is dropped
6143 /// first even when it is the freshest thing in the feed.
6144 #[tokio::test]
6145 async fn insert_entries_trims_keeps_fresh_undated_over_stale_dated() -> Result<()> {
6146 let pool = init_url("sqlite::memory:").await?;
6147 let feed_id = upsert_feed(
6148 &pool,
6149 &NewFeed {
6150 url: "https://undated.example/feed.xml".to_string(),
6151 ..Default::default()
6152 },
6153 )
6154 .await?;
6155
6156 // Two OLD dated entries (fetched long ago), plus one UNDATED entry
6157 // fetched most recently. Cap = 2, so exactly one row must be evicted.
6158 let batch = vec![
6159 NewEntry {
6160 guid: "old-dated-1".to_string(),
6161 title: Some("Old A".to_string()),
6162 published: Some("2026-07-01T00:00:00Z".to_string()),
6163 fetched_at: Some("2026-07-01T00:00:00Z".to_string()),
6164 ..Default::default()
6165 },
6166 NewEntry {
6167 guid: "old-dated-2".to_string(),
6168 title: Some("Old B".to_string()),
6169 published: Some("2026-07-02T00:00:00Z".to_string()),
6170 fetched_at: Some("2026-07-02T00:00:00Z".to_string()),
6171 ..Default::default()
6172 },
6173 NewEntry {
6174 guid: "fresh-undated".to_string(),
6175 title: Some("Fresh undated".to_string()),
6176 published: None,
6177 fetched_at: Some("2026-07-11T00:00:00Z".to_string()),
6178 ..Default::default()
6179 },
6180 ];
6181 insert_entries(&pool, feed_id, &batch, 2).await?;
6182
6183 let did = "did:plc:undated";
6184 replace_sub_refs(&pool, did, &[feed_id]).await?;
6185 let kept = entries_for_feed(&pool, did, feed_id).await?;
6186 assert_eq!(kept.len(), 2, "over-cap feed trimmed to 2 entries");
6187 let guids: Vec<&str> = kept.iter().map(|e| e.guid.as_str()).collect();
6188 assert!(
6189 guids.contains(&"fresh-undated"),
6190 "the freshly-fetched undated entry must survive the trim, kept: {guids:?}"
6191 );
6192 assert!(
6193 guids.contains(&"old-dated-2"),
6194 "the newer dated entry survives; the OLDEST dated entry is the one evicted, kept: {guids:?}"
6195 );
6196 assert!(
6197 !guids.contains(&"old-dated-1"),
6198 "the oldest dated entry is the one that should be evicted, kept: {guids:?}"
6199 );
6200 Ok(())
6201 }
6202
6203 /// **It must actually GROW — the name used to be a lie.**
6204 ///
6205 /// The earlier body was three lines asserting only `before > 0`. There was
6206 /// no second measurement, so `db_size_bytes` returning a constant `1` passed.
6207 /// That matters because this number is the poller's disk watermark: a size
6208 /// that never moves means the pause never trips and the volume fills
6209 /// instead.
6210 #[tokio::test]
6211 async fn db_size_is_positive_and_grows() -> Result<()> {
6212 let pool = init_url("sqlite::memory:").await?;
6213 let before = db_size_bytes(&pool).await?;
6214 assert!(before > 0, "a schema-initialised DB has a non-zero size");
6215
6216 // Enough rows that the file must gain pages, not just fill slack.
6217 seed_big_entries(&pool, "did:plc:growth", 400).await?;
6218
6219 let after = db_size_bytes(&pool).await?;
6220 assert!(
6221 after > before,
6222 "the database grew by {} bytes after 400 seeded entries; the size is \
6223 not tracking the data, so the disk watermark can never trip",
6224 after.saturating_sub(before),
6225 );
6226 Ok(())
6227 }
6228
6229 /// `purge_did_data` removes every per-DID row the caller owns (read/star
6230 /// state, cursors, sub_ref projection, beta seat, created invite codes) —
6231 /// and touches no other DID's rows nor the shared feeds/entries cache.
6232 #[tokio::test]
6233 async fn purge_did_data_removes_only_the_callers_rows() -> Result<()> {
6234 let pool = init_url("sqlite::memory:").await?;
6235
6236 // A shared feed + entry both DIDs can subscribe to.
6237 let feed_id = upsert_feed(
6238 &pool,
6239 &NewFeed {
6240 url: "https://example.com/feed.xml".to_string(),
6241 title: Some("Example".to_string()),
6242 ..Default::default()
6243 },
6244 )
6245 .await?;
6246 insert_entries(
6247 &pool,
6248 feed_id,
6249 &[NewEntry {
6250 guid: "g-1".to_string(),
6251 url: Some("https://example.com/a".to_string()),
6252 title: Some("First".to_string()),
6253 published: Some("2026-07-10T08:00:00Z".to_string()),
6254 ..Default::default()
6255 }],
6256 0,
6257 )
6258 .await?;
6259 let entry_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'g-1'")
6260 .fetch_one(&pool)
6261 .await?;
6262
6263 let victim = "did:plc:victim";
6264 let bystander = "did:plc:bystander";
6265
6266 // Seed BOTH DIDs with a full spread of per-DID rows.
6267 for did in [victim, bystander] {
6268 replace_sub_refs(&pool, did, &[feed_id]).await?;
6269 assert!(mark_read(&pool, did, entry_id, true).await?);
6270 assert!(mark_starred(&pool, did, entry_id, true).await?);
6271 upsert_cursor(
6272 &pool,
6273 &ReadCursor {
6274 did: did.to_string(),
6275 feed_url: "https://example.com/feed.xml".to_string(),
6276 read_through: Some("2026-07-10T08:00:00Z".to_string()),
6277 read_ids: "[]".to_string(),
6278 unread_ids: "[]".to_string(),
6279 dirty: false,
6280 pds_created: false,
6281 updated_at: now_rfc3339(),
6282 },
6283 )
6284 .await?;
6285 grant_access(&pool, did, Some("h.example"), "admin", None).await?;
6286 mint_code(&pool, did, 3600).await?;
6287 }
6288
6289 // Purge only the victim.
6290 let counts = purge_did_data(&pool, victim).await?;
6291 assert_eq!(
6292 counts.entry_state, 1,
6293 "one entry_state row (read+star merge)"
6294 );
6295 assert_eq!(counts.read_cursor, 1);
6296 assert_eq!(counts.sub_ref, 1);
6297 assert_eq!(counts.beta_access, 1);
6298 assert_eq!(counts.invite_codes, 1);
6299 assert_eq!(counts.total(), 5);
6300
6301 // The victim has zero rows left in every per-DID table.
6302 let es: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1")
6303 .bind(victim)
6304 .fetch_one(&pool)
6305 .await?;
6306 assert_eq!(es, 0, "victim still had entry_state rows");
6307 let rc: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM read_cursor WHERE did = ?1")
6308 .bind(victim)
6309 .fetch_one(&pool)
6310 .await?;
6311 assert_eq!(rc, 0, "victim still had read_cursor rows");
6312 let sr: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
6313 .bind(victim)
6314 .fetch_one(&pool)
6315 .await?;
6316 assert_eq!(sr, 0, "victim still had sub_ref rows");
6317 assert!(
6318 !has_beta_access(&pool, victim).await?,
6319 "victim still had a beta seat"
6320 );
6321 let victim_codes: i64 =
6322 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
6323 .bind(victim)
6324 .fetch_one(&pool)
6325 .await?;
6326 assert_eq!(victim_codes, 0);
6327
6328 // The bystander is untouched.
6329 assert!(has_beta_access(&pool, bystander).await?);
6330 let bystander_subs = count_subscriptions_for_did(&pool, bystander).await?;
6331 assert_eq!(bystander_subs, 1, "bystander's sub_ref survived");
6332 let bystander_codes: i64 =
6333 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
6334 .bind(bystander)
6335 .fetch_one(&pool)
6336 .await?;
6337 assert_eq!(bystander_codes, 1);
6338
6339 // The shared cache is intact.
6340 assert_eq!(count_feeds(&pool).await?, 1);
6341
6342 // Idempotent: purging again removes nothing.
6343 let again = purge_did_data(&pool, victim).await?;
6344 assert_eq!(again.total(), 0);
6345
6346 Ok(())
6347 }
6348
6349 /// A departing DID leaves back-references on rows that belong to OTHER DIDs:
6350 /// * the invite code it *redeemed* to join (inviter's row: `invitee_did`);
6351 /// * seats it *granted* to others (`beta_access.granted_by`).
6352 /// `purge_did_data` must scrub both so no per-DID residue survives, while
6353 /// leaving those other DIDs' rows otherwise intact (their access is kept).
6354 #[tokio::test]
6355 async fn purge_did_data_scrubs_cross_did_back_references() -> Result<()> {
6356 let pool = init_url("sqlite::memory:").await?;
6357
6358 let inviter = "did:plc:inviter";
6359 let leaver = "did:plc:leaver";
6360 let friend = "did:plc:friend";
6361
6362 // inviter mints a code; leaver redeems it to join (stamps invitee_did).
6363 let inviter_code = mint_code(&pool, inviter, 3600).await?;
6364 grant_access(&pool, inviter, None, "admin", None).await?;
6365 assert_eq!(
6366 redeem_code(&pool, &inviter_code, leaver, Some("leaver.bsky"), 100).await?,
6367 Ok(())
6368 );
6369
6370 // leaver mints a code; friend redeems it (stamps friend's granted_by).
6371 let leaver_code = mint_code(&pool, leaver, 3600).await?;
6372 assert_eq!(
6373 redeem_code(&pool, &leaver_code, friend, Some("friend.bsky"), 100).await?,
6374 Ok(())
6375 );
6376
6377 // Precondition: the leaver DID is present in both back-reference columns.
6378 let invitee_before: i64 =
6379 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
6380 .bind(leaver)
6381 .fetch_one(&pool)
6382 .await?;
6383 assert_eq!(
6384 invitee_before, 1,
6385 "leaver should be an invitee before purge"
6386 );
6387 let granted_before: i64 =
6388 sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
6389 .bind(leaver)
6390 .fetch_one(&pool)
6391 .await?;
6392 assert_eq!(granted_before, 1, "leaver should be a granter before purge");
6393
6394 // Purge the leaver.
6395 let counts = purge_did_data(&pool, leaver).await?;
6396 assert_eq!(
6397 counts.invitee_scrubbed, 1,
6398 "the redeemed code's invitee_did"
6399 );
6400 assert_eq!(counts.granted_by_scrubbed, 1, "the seat leaver granted");
6401
6402 // No residue: the leaver DID appears in NEITHER back-reference column.
6403 let invitee_after: i64 =
6404 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
6405 .bind(leaver)
6406 .fetch_one(&pool)
6407 .await?;
6408 assert_eq!(invitee_after, 0, "leaver survived in invitee_did");
6409 let granted_after: i64 =
6410 sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
6411 .bind(leaver)
6412 .fetch_one(&pool)
6413 .await?;
6414 assert_eq!(granted_after, 0, "leaver survived in granted_by");
6415
6416 // The other DIDs' rows are kept: the friend still has a seat (redacted
6417 // granter), and the inviter's code row still exists (invitee NULLed).
6418 assert!(
6419 has_beta_access(&pool, friend).await?,
6420 "friend's seat must survive the leaver's scrub"
6421 );
6422 let friend_granted_by: String =
6423 sqlx::query_scalar("SELECT granted_by FROM beta_access WHERE did = ?1")
6424 .bind(friend)
6425 .fetch_one(&pool)
6426 .await?;
6427 assert_eq!(friend_granted_by, REDACTED_DID);
6428 let inviter_code_rows: i64 =
6429 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
6430 .bind(inviter)
6431 .fetch_one(&pool)
6432 .await?;
6433 assert_eq!(inviter_code_rows, 1, "inviter's code row must survive");
6434
6435 Ok(())
6436 }
6437
6438 // -- F2: consecutive-error count drives the poll backoff -----------------
6439
6440 /// **Rows that failed only because we could not poll them are cleared.**
6441 ///
6442 /// Excluding `at://` from `due_feeds` stops NEW failures; it does nothing
6443 /// about the ones already recorded. This instance carries 19 such rows at
6444 /// 35+ consecutive errors each — accumulated entirely by our own refusal to
6445 /// fetch a scheme we had not implemented. Left alone they keep counting
6446 /// toward `in_backoff` and `badly_broken`, so a public page would report
6447 /// unsupported feeds as broken publishers forever, with no poll that could
6448 /// ever clear them since they are no longer selected.
6449 ///
6450 /// Safe to re-run because of WHAT it clears, not because the count cannot
6451 /// grow: only rows never polled successfully (`last_polled IS NULL`) — see
6452 /// `the_at_uri_error_clearing_spares_a_row_that_has_been_polled`.
6453 #[tokio::test]
6454 async fn the_migration_clears_error_counts_on_unpollable_at_uri_rows() -> Result<()> {
6455 let pool = init_url("sqlite::memory:").await?;
6456 for url in [
6457 "https://real.example/feed.xml",
6458 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
6459 ] {
6460 upsert_feed(
6461 &pool,
6462 &NewFeed {
6463 url: url.to_string(),
6464 ..Default::default()
6465 },
6466 )
6467 .await?;
6468 sqlx::query(
6469 "UPDATE feeds SET consecutive_errors = 35, last_error_kind = 'fetch', \
6470 last_error = 'unsupported scheme' WHERE url = ?1",
6471 )
6472 .bind(url)
6473 .execute(&pool)
6474 .await?;
6475 }
6476
6477 apply_migrations(&pool).await?;
6478
6479 let (at_errors, at_kind, at_detail): (i64, Option<String>, Option<String>) =
6480 sqlx::query_as(sqlx::AssertSqlSafe(format!(
6481 "SELECT consecutive_errors, last_error_kind, last_error FROM feeds \
6482 WHERE kind = '{}'",
6483 crate::feed::FeedKind::Publication.as_str()
6484 )))
6485 .fetch_one(&pool)
6486 .await?;
6487 assert_eq!(at_errors, 0, "an unpollable row kept its failure count");
6488 // A row with no errors carries no reason — the invariant
6489 // `reset_feed_errors` upholds, and the migration must too.
6490 assert_eq!(at_kind, None, "an unpollable row kept its failure kind");
6491 assert_eq!(at_detail, None, "an unpollable row kept its failure detail");
6492
6493 // A real feed's failure history is NOT touched — it is still meaningful.
6494 let http_errors: i64 = sqlx::query_scalar(
6495 "SELECT consecutive_errors FROM feeds WHERE url = 'https://real.example/feed.xml'",
6496 )
6497 .fetch_one(&pool)
6498 .await?;
6499 assert_eq!(http_errors, 35, "a real feed's history was discarded");
6500 Ok(())
6501 }
6502
6503 /// **An `at://` feed is never selected for polling.**
6504 ///
6505 /// Nothing can poll one: `poll_feed` goes through `net::guarded_get`, whose
6506 /// `check_scheme` refuses any non-http(s) scheme, and the standard.site
6507 /// reader is not wired to the scheduler. Selecting them anyway does not
6508 /// leave the feature dormant — it manufactures a permanent failure per row,
6509 /// which since the cause histogram is *published* as an unreachable
6510 /// publisher. This instance already carries 19 such rows, subscribed before
6511 /// the scheme was refused.
6512 ///
6513 /// They are skipped rather than failed: unsupported is not broken, and the
6514 /// difference is the whole point of recording a cause at all.
6515 #[tokio::test]
6516 async fn an_at_uri_feed_is_never_due_for_polling() -> Result<()> {
6517 let pool = init_url("sqlite::memory:").await?;
6518 for url in [
6519 "https://example.com/feed.xml",
6520 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
6521 "at://alice.example.com/site.standard.publication/3lab",
6522 ] {
6523 upsert_feed(
6524 &pool,
6525 &NewFeed {
6526 url: url.to_string(),
6527 ..Default::default()
6528 },
6529 )
6530 .await?;
6531 }
6532 // All three have a NULL next_poll, which sorts FIRST — so if at:// were
6533 // selectable at all it would be selected before the http feed.
6534 let due = due_feeds(&pool, "2026-09-20T00:00:00Z", 50).await?;
6535 let urls: Vec<&str> = due.iter().map(|f| f.url.as_str()).collect();
6536 assert_eq!(
6537 urls,
6538 ["https://example.com/feed.xml"],
6539 "an at:// feed was handed to the poller"
6540 );
6541 Ok(())
6542 }
6543
6544 #[tokio::test]
6545 async fn feed_error_count_bumps_and_resets() -> Result<()> {
6546 let pool = init_url("sqlite::memory:").await?;
6547 let url = "https://broken.example/feed.xml";
6548 upsert_feed(
6549 &pool,
6550 &NewFeed {
6551 url: url.to_string(),
6552 ..Default::default()
6553 },
6554 )
6555 .await?;
6556
6557 // A fresh feed starts at 0 errors.
6558 let feed = get_feed_by_url(&pool, url).await?.expect("feed exists");
6559 assert_eq!(feed.consecutive_errors, 0);
6560
6561 // N consecutive failures grow the count 1,2,3, and — fed through
6562 // `backoff_for` — the backoff grows with it (never latched at the floor).
6563 let mut last = std::time::Duration::ZERO;
6564 for expected in 1..=3 {
6565 let count = bump_feed_errors(
6566 &pool,
6567 url,
6568 crate::feed::FailureKind::Fetch,
6569 "connection refused",
6570 )
6571 .await?;
6572 assert_eq!(count, expected, "bump returns the new count");
6573 let backoff = crate::feed::backoff_for(count as u32);
6574 assert!(
6575 backoff >= last,
6576 "backoff must not shrink as errors accumulate"
6577 );
6578 last = backoff;
6579 }
6580 // Growth actually happened (2 errors backs off longer than 1).
6581 assert!(crate::feed::backoff_for(2) > crate::feed::backoff_for(1));
6582 assert_eq!(
6583 get_feed_by_url(&pool, url)
6584 .await?
6585 .unwrap()
6586 .consecutive_errors,
6587 3
6588 );
6589
6590 // A success resets the streak to 0 (back to the normal cadence).
6591 reset_feed_errors(&pool, url).await?;
6592 assert_eq!(
6593 get_feed_by_url(&pool, url)
6594 .await?
6595 .unwrap()
6596 .consecutive_errors,
6597 0
6598 );
6599 Ok(())
6600 }
6601
6602 /// **A recovered feed keeps no reason for having failed.**
6603 ///
6604 /// Added because a mutation found this untested: deleting the
6605 /// `last_error_kind = NULL, last_error = NULL` half of `reset_feed_errors`
6606 /// left the entire suite green. The histogram filters on
6607 /// `consecutive_errors > 0`, so a stale row would not inflate the public
6608 /// count — but anything reading the row directly would be handed a cause
6609 /// that stopped applying, which is the exact failure this column was added
6610 /// to end. A guarantee nothing checks is a comment.
6611 #[tokio::test]
6612 async fn a_successful_poll_clears_the_recorded_failure_reason() -> Result<()> {
6613 let pool = init_url("sqlite::memory:").await?;
6614 let url = "https://recovers.example/feed.xml";
6615 upsert_feed(
6616 &pool,
6617 &NewFeed {
6618 url: url.to_string(),
6619 ..Default::default()
6620 },
6621 )
6622 .await?;
6623
6624 bump_feed_errors(&pool, url, crate::feed::FailureKind::Fetch, "SENTINEL_WHY").await?;
6625 let failing: (Option<String>, Option<String>) =
6626 sqlx::query_as("SELECT last_error_kind, last_error FROM feeds WHERE url = ?1")
6627 .bind(url)
6628 .fetch_one(&pool)
6629 .await?;
6630 assert_eq!(
6631 failing.0.as_deref(),
6632 Some("fetch"),
6633 "the kind was not stored"
6634 );
6635 assert_eq!(
6636 failing.1.as_deref(),
6637 Some("SENTINEL_WHY"),
6638 "the detail was not stored"
6639 );
6640
6641 reset_feed_errors(&pool, url).await?;
6642 let recovered: (Option<String>, Option<String>) =
6643 sqlx::query_as("SELECT last_error_kind, last_error FROM feeds WHERE url = ?1")
6644 .bind(url)
6645 .fetch_one(&pool)
6646 .await?;
6647 assert_eq!(
6648 recovered.0, None,
6649 "a healthy feed still names a failure kind"
6650 );
6651 assert_eq!(
6652 recovered.1, None,
6653 "a healthy feed still carries error detail"
6654 );
6655 Ok(())
6656 }
6657
6658 /// **The closed vocabulary is closed where it is READ, not only written.**
6659 ///
6660 /// `FailureKind::parse` promises that a kind string from a newer build is
6661 /// not "silently attributed to a cause this one recognises" — and the
6662 /// histogram's comment leaned on it. But review found `parse` had zero
6663 /// production callers: `poll_health` handed the raw column to the public
6664 /// template, so an unrecognised string got its own bucket, rendered
6665 /// verbatim. The protection existed only as a doc comment.
6666 ///
6667 /// A row written by a future build must land in `unknown`.
6668 #[tokio::test]
6669 async fn an_unrecognised_failure_kind_folds_into_unknown() -> Result<()> {
6670 let pool = init_url("sqlite::memory:").await?;
6671 for (url, kind) in [
6672 ("https://a.example/f.xml", Some("fetch")),
6673 ("https://b.example/f.xml", Some("quota")), // a newer build's kind
6674 ("https://c.example/f.xml", None), // a legacy row
6675 ] {
6676 upsert_feed(
6677 &pool,
6678 &NewFeed {
6679 url: url.to_string(),
6680 ..Default::default()
6681 },
6682 )
6683 .await?;
6684 sqlx::query(
6685 "UPDATE feeds SET consecutive_errors = 1, last_error_kind = ?2 WHERE url = ?1",
6686 )
6687 .bind(url)
6688 .bind(kind)
6689 .execute(&pool)
6690 .await?;
6691 }
6692 let now = chrono::Utc::now();
6693 let health = poll_health(
6694 &pool,
6695 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
6696 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
6697 )
6698 .await?;
6699 let mut kinds = health.failure_kinds.clone();
6700 kinds.sort();
6701 assert_eq!(
6702 kinds,
6703 vec![("fetch".to_string(), 1), ("unknown".to_string(), 2)],
6704 "an unrecognised kind reached the public histogram as its own bucket: {:?}",
6705 health.failure_kinds
6706 );
6707 Ok(())
6708 }
6709
6710 /// **The migration is exercised against a table that predates the columns.**
6711 ///
6712 /// Every other test here builds a fresh database, where `CREATE TABLE`
6713 /// already contains `last_error_kind` / `last_error` — so `ensure_column`,
6714 /// the code path that actually runs against the production volume, was
6715 /// never executed by any of them. A bad `ALTER` would have been found at
6716 /// boot, on the one machine, by crash-looping: `apply_migrations` runs
6717 /// inside `init`, and the entrypoint takes the container down when a child
6718 /// dies.
6719 ///
6720 /// Builds the OLD table shape by hand, puts a failing row in it, migrates,
6721 /// and asserts both that the columns arrive and that the pre-existing row
6722 /// survives with NULLs rather than being rewritten or dropped.
6723 #[tokio::test]
6724 async fn the_last_error_columns_migrate_onto_a_table_that_predates_them() -> Result<()> {
6725 let pool = init_url("sqlite::memory:").await?;
6726
6727 // Drop the current shape and rebuild the pre-migration one.
6728 sqlx::query("DROP TABLE feeds").execute(&pool).await?;
6729 sqlx::query(
6730 "CREATE TABLE feeds (
6731 id INTEGER PRIMARY KEY AUTOINCREMENT,
6732 url TEXT NOT NULL UNIQUE,
6733 title TEXT,
6734 site_url TEXT,
6735 etag TEXT,
6736 last_modified TEXT,
6737 last_polled TEXT,
6738 next_poll TEXT,
6739 consecutive_errors INTEGER NOT NULL DEFAULT 0
6740 )",
6741 )
6742 .execute(&pool)
6743 .await?;
6744 sqlx::query("INSERT INTO feeds (url, consecutive_errors) VALUES (?1, 7)")
6745 .bind("https://legacy.example/feed.xml")
6746 .execute(&pool)
6747 .await?;
6748
6749 apply_migrations(&pool).await?;
6750
6751 // The columns exist...
6752 let cols: Vec<String> = sqlx::query("PRAGMA table_info(feeds)")
6753 .fetch_all(&pool)
6754 .await?
6755 .iter()
6756 .map(|r| r.get::<String, _>("name"))
6757 .collect();
6758 assert!(cols.iter().any(|c| c == "last_error_kind"), "{cols:?}");
6759 assert!(cols.iter().any(|c| c == "last_error"), "{cols:?}");
6760
6761 // ...and the pre-existing row is intact, with no invented cause.
6762 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
6763 "SELECT consecutive_errors, last_error_kind, last_error FROM feeds WHERE url = ?1",
6764 )
6765 .bind("https://legacy.example/feed.xml")
6766 .fetch_one(&pool)
6767 .await?;
6768 assert_eq!(row.0, 7, "the migration disturbed an existing error count");
6769 assert_eq!(row.1, None, "a legacy row was given a cause it never had");
6770 assert_eq!(row.2, None);
6771
6772 // And it is idempotent — `init` runs this on every boot.
6773 apply_migrations(&pool).await?;
6774 Ok(())
6775 }
6776
6777 /// The stored detail is bounded — it is a remote server's text on an
6778 /// unattended path.
6779 #[tokio::test]
6780 async fn the_stored_error_detail_is_truncated() -> Result<()> {
6781 let pool = init_url("sqlite::memory:").await?;
6782 let url = "https://verbose.example/feed.xml";
6783 upsert_feed(
6784 &pool,
6785 &NewFeed {
6786 url: url.to_string(),
6787 ..Default::default()
6788 },
6789 )
6790 .await?;
6791 bump_feed_errors(
6792 &pool,
6793 url,
6794 crate::feed::FailureKind::Body,
6795 &"x".repeat(10_000),
6796 )
6797 .await?;
6798 let stored: (Option<String>,) =
6799 sqlx::query_as("SELECT last_error FROM feeds WHERE url = ?1")
6800 .bind(url)
6801 .fetch_one(&pool)
6802 .await?;
6803 assert_eq!(stored.0.unwrap().chars().count(), MAX_ERROR_DETAIL_CHARS);
6804 Ok(())
6805 }
6806
6807 // -- F3: db_size_bytes ignores freed pages and drops after reclaim -------
6808
6809 /// A new on-disk database must be created in INCREMENTAL mode.
6810 ///
6811 /// This is the whole fix for new instances: `auto_vacuum` was read by
6812 /// `reclaim` and set nowhere, so every database ran in NONE and `reclaim`
6813 /// always took its full-`VACUUM` branch — the one that cannot complete on a
6814 /// volume under the pressure that triggered the sweep. The pragma only binds
6815 /// on a database with no tables yet, so "at creation" is the load-bearing
6816 /// part, not "somewhere in init".
6817 #[tokio::test]
6818 async fn a_new_database_is_created_in_incremental_vacuum_mode() -> Result<()> {
6819 let dir = std::env::temp_dir();
6820 let path = dir.join(format!("fr-autovac-{}.db", std::process::id()));
6821 for p in [
6822 path.display().to_string(),
6823 format!("{}-wal", path.display()),
6824 format!("{}-shm", path.display()),
6825 ] {
6826 std::fs::remove_file(&p).ok();
6827 }
6828 let pool = init_url(&format!("sqlite://{}", path.display())).await?;
6829
6830 assert_eq!(
6831 auto_vacuum_mode(&pool).await?,
6832 AutoVacuum::Incremental,
6833 "a fresh database is still in the mode where reclaim needs a full VACUUM"
6834 );
6835 // And the WAL is bounded rather than growing to its high-water mark
6836 // forever.
6837 let limit: i64 = sqlx::query_scalar("PRAGMA journal_size_limit")
6838 .fetch_one(&pool)
6839 .await?;
6840 assert_eq!(
6841 limit, WAL_SIZE_LIMIT_BYTES,
6842 "journal_size_limit not applied"
6843 );
6844
6845 // Being INCREMENTAL, the migration is a no-op — which is what makes the
6846 // flag safe for an operator to run without checking first.
6847 assert_eq!(
6848 migrate_to_incremental_vacuum(&pool, None).await?,
6849 VacuumMigration::NotNeeded(AutoVacuum::Incremental)
6850 );
6851
6852 pool.close().await;
6853 for p in [
6854 path.display().to_string(),
6855 format!("{}-wal", path.display()),
6856 format!("{}-shm", path.display()),
6857 ] {
6858 std::fs::remove_file(&p).ok();
6859 }
6860 Ok(())
6861 }
6862
6863 /// The migration refuses itself when the volume cannot hold the rebuild.
6864 ///
6865 /// A full `VACUUM` writes a complete second copy, so attempting one without
6866 /// headroom burns I/O on a box that has none and finishes nothing. Refusing
6867 /// is the entire reason this is an operator step rather than something
6868 /// `reclaim` does on its own.
6869 #[tokio::test]
6870 async fn the_vacuum_migration_refuses_without_headroom() -> Result<()> {
6871 let dir = std::env::temp_dir();
6872 let path = dir.join(format!("fr-autovac-none-{}.db", std::process::id()));
6873 for p in [
6874 path.display().to_string(),
6875 format!("{}-wal", path.display()),
6876 format!("{}-shm", path.display()),
6877 ] {
6878 std::fs::remove_file(&p).ok();
6879 }
6880 // Build a database the way one that predates this change looks: create
6881 // the file in NONE mode explicitly, then populate it.
6882 let url = format!("sqlite://{}", path.display());
6883 let opts = SqliteConnectOptions::from_str(&url)?
6884 .create_if_missing(true)
6885 .foreign_keys(true)
6886 .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
6887 .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::None);
6888 let pool = SqlitePoolOptions::new()
6889 .min_connections(1)
6890 .max_connections(1)
6891 .connect_with(opts)
6892 .await?;
6893 init_schema(&pool).await?;
6894 assert_eq!(auto_vacuum_mode(&pool).await?, AutoVacuum::None);
6895
6896 // Zero free space: refused, and the mode is untouched.
6897 let refused = migrate_to_incremental_vacuum(&pool, Some(0)).await?;
6898 assert!(
6899 matches!(refused, VacuumMigration::RefusedNoHeadroom { .. }),
6900 "expected a refusal, got {refused:?}"
6901 );
6902 assert_eq!(
6903 auto_vacuum_mode(&pool).await?,
6904 AutoVacuum::None,
6905 "a refused migration must not have changed the mode"
6906 );
6907
6908 // With headroom it runs, and the database ends up INCREMENTAL — which is
6909 // what makes `reclaim` cheap from then on.
6910 let done = migrate_to_incremental_vacuum(&pool, Some(u64::MAX)).await?;
6911 let VacuumMigration::Migrated {
6912 bytes_after,
6913 file_after,
6914 ..
6915 } = done
6916 else {
6917 panic!("expected a migration, got {done:?}");
6918 };
6919 assert_eq!(auto_vacuum_mode(&pool).await?, AutoVacuum::Incremental);
6920 // The reported size must not include the WAL the VACUUM just filled. In
6921 // WAL mode a VACUUM writes the whole rebuilt database through the WAL,
6922 // so without the truncating checkpoint this reads as roughly double —
6923 // "the migration doubled my database", from the one line the command
6924 // prints.
6925 let file_after = file_after.expect("an on-disk database has a file size") as i64;
6926 assert!(
6927 bytes_after <= file_after * 2,
6928 "bytes_after ({bytes_after}) is inflated by an untruncated WAL against a \
6929 {file_after}-byte file"
6930 );
6931
6932 pool.close().await;
6933 for p in [
6934 path.display().to_string(),
6935 format!("{}-wal", path.display()),
6936 format!("{}-shm", path.display()),
6937 ] {
6938 std::fs::remove_file(&p).ok();
6939 }
6940 Ok(())
6941 }
6942
6943 /// **R6 benchmark: what the retention sweep actually costs, and what fixes it.**
6944 ///
6945 /// `#[ignore]` — builds a ~1M-row database once per shape per scale (ten
6946 /// times), so it is a measurement tool rather than a test. Run with:
6947 ///
6948 /// ```text
6949 /// cargo test --lib -- --ignored --nocapture r6_measure_retention_sweep
6950 /// ```
6951 ///
6952 /// It exists because R6 was "every delete batch re-scans `entry_state`" and
6953 /// the honest answer was "measure before changing an index". Kept so the next
6954 /// candidate index can be tried against the same fixture rather than a new
6955 /// one. Findings are recorded in `design/REVIEW-ROUND-2.md`.
6956 #[tokio::test]
6957 #[ignore]
6958 async fn r6_measure_retention_sweep() -> Result<()> {
6959 const FEEDS: i64 = 500;
6960 const PER_FEED: i64 = 2_000; // matches `max_entries_per_feed`
6961 const PINNED: i64 = 50_000; // entry_state rows a reader has touched
6962
6963 /// Build the fixture, apply `extra_indexes`, then plan and time a sweep.
6964 async fn run(
6965 label: &str,
6966 extra_indexes: &[&str],
6967 pinned: i64,
6968 old_list_form: bool,
6969 ) -> Result<()> {
6970 let dir = std::env::temp_dir();
6971 let path = dir.join(format!("fr-r6-{}-{label}.db", std::process::id()));
6972 // RAII, because every `?` between here and the end used to leak a
6973 // 1M-row fixture plus its -wal/-shm into the temp dir — six per run.
6974 struct Fixture(std::path::PathBuf);
6975 impl Fixture {
6976 fn wipe(&self) {
6977 for p in [
6978 self.0.display().to_string(),
6979 format!("{}-wal", self.0.display()),
6980 format!("{}-shm", self.0.display()),
6981 ] {
6982 std::fs::remove_file(&p).ok();
6983 }
6984 }
6985 }
6986 impl Drop for Fixture {
6987 fn drop(&mut self) {
6988 self.wipe();
6989 }
6990 }
6991 let fixture = Fixture(path.clone());
6992 fixture.wipe();
6993 let pool = init_url(&format!("sqlite://{}", path.display())).await?;
6994
6995 // Bulk-build with SQL: a million round trips would measure the
6996 // fixture, not the sweep. Recursive CTE because `generate_series` is
6997 // not compiled into the bundled SQLite.
6998 sqlx::query(
6999 "WITH RECURSIVE n(value) AS ( \
7000 SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < ?1 \
7001 ) \
7002 INSERT INTO feeds (url) \
7003 SELECT 'https://f' || value || '.example/x.xml' FROM n",
7004 )
7005 .bind(FEEDS)
7006 .execute(&pool)
7007 .await
7008 .context("seeding feeds")?;
7009
7010 // Half the entries older than the window, half inside it.
7011 sqlx::query(
7012 "WITH RECURSIVE n(value) AS ( \
7013 SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < ?1 \
7014 ) \
7015 INSERT INTO entries (feed_id, guid, title, published, fetched_at) \
7016 SELECT f.id, \
7017 'g' || f.id || '-' || s.value, \
7018 'Entry ' || s.value, \
7019 CASE WHEN s.value % 2 = 0 THEN '2020-01-01T00:00:00Z' \
7020 ELSE '2099-01-01T00:00:00Z' END, \
7021 '2026-01-01T00:00:00Z' \
7022 FROM feeds f, n s",
7023 )
7024 .bind(PER_FEED)
7025 .execute(&pool)
7026 .await?;
7027
7028 // **A REALISTIC pin distribution, which the first version did not
7029 // have.** It made every row `read=0,starred=0` or `read=1,starred=1`,
7030 // so 100% of `entry_state` matched `starred = 1 OR read = 0` — there
7031 // were no "read and not starred" rows at all, which is the commonest
7032 // state a reader leaves behind. That mattered: a PARTIAL index on the
7033 // pinned predicate then covers the whole table and cannot be
7034 // selective, so measuring one against that fixture measures nothing.
7035 //
7036 // 90% read-and-unstarred (evictable), 10% pinned, split between
7037 // starred and unread.
7038 sqlx::query(
7039 "INSERT INTO entry_state (did, entry_id, read, starred, updated_at) \
7040 SELECT 'did:plc:reader', id, \
7041 CASE WHEN id % 10 <> 0 THEN 1 \
7042 WHEN id % 20 = 0 THEN 1 ELSE 0 END, \
7043 CASE WHEN id % 10 <> 0 THEN 0 \
7044 WHEN id % 20 = 0 THEN 1 ELSE 0 END, \
7045 '2026-01-01T00:00:00Z' \
7046 FROM entries LIMIT ?1",
7047 )
7048 .bind(pinned)
7049 .execute(&pool)
7050 .await?;
7051
7052 // Space is the other half of the trade: this is a 1 GB volume with a
7053 // 768 MiB watermark, so an index that buys time and costs disk can be
7054 // a net loss.
7055 let pages_before: i64 = sqlx::query_scalar("PRAGMA page_count")
7056 .fetch_one(&pool)
7057 .await?;
7058 let page_size: i64 = sqlx::query_scalar("PRAGMA page_size")
7059 .fetch_one(&pool)
7060 .await?;
7061 for idx in extra_indexes {
7062 sqlx::query(sqlx::AssertSqlSafe((*idx).to_string()))
7063 .execute(&pool)
7064 .await
7065 .with_context(|| format!("creating {idx}"))?;
7066 }
7067 let pages_after: i64 = sqlx::query_scalar("PRAGMA page_count")
7068 .fetch_one(&pool)
7069 .await?;
7070 let index_bytes = (pages_after - pages_before) * page_size;
7071
7072 // What the index costs on the WRITE path — the poller inserts
7073 // constantly, the sweep runs once a day.
7074 let t_ins = std::time::Instant::now();
7075 sqlx::query(
7076 "WITH RECURSIVE n(value) AS ( \
7077 SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < 10000 \
7078 ) \
7079 INSERT INTO entries (feed_id, guid, published, fetched_at) \
7080 SELECT 1, 'ins-' || value, '2099-06-01T00:00:00Z', '2026-01-01T00:00:00Z' \
7081 FROM n",
7082 )
7083 .execute(&pool)
7084 .await?;
7085 let insert_10k = t_ins.elapsed();
7086
7087 // Give the planner statistics, as a long-lived instance would have.
7088 sqlx::query("ANALYZE").execute(&pool).await?;
7089
7090 // The plan must describe the query this run actually TIMES. It used
7091 // to be hardcoded to the `NOT IN` form regardless, so four of six
7092 // runs printed a plan for a different query than the one measured —
7093 // in the artifact kept precisely to be the evidence.
7094 let planned = if old_list_form {
7095 "EXPLAIN QUERY PLAN SELECT id FROM entries \
7096 WHERE COALESCE(published, fetched_at) < '2026-06-01T00:00:00Z' \
7097 AND id NOT IN (SELECT entry_id FROM entry_state \
7098 WHERE starred = 1 OR read = 0) \
7099 LIMIT 1000"
7100 } else {
7101 "EXPLAIN QUERY PLAN SELECT e.id FROM entries e \
7102 WHERE COALESCE(e.published, e.fetched_at) < '2026-06-01T00:00:00Z' \
7103 AND NOT EXISTS (SELECT 1 FROM entry_state s \
7104 WHERE s.entry_id = e.id \
7105 AND (s.starred = 1 OR s.read = 0)) \
7106 LIMIT 1000"
7107 };
7108 let plan: Vec<String> = sqlx::query(sqlx::AssertSqlSafe(planned))
7109 .fetch_all(&pool)
7110 .await?
7111 .into_iter()
7112 .map(|r| r.get::<String, _>("detail"))
7113 .collect();
7114
7115 // ONE variant per fixture — running both against the same database
7116 // measured the second against an already-emptied table, which
7117 // reported a 0-row "win" the first time this was written.
7118 let cutoff = (chrono::Utc::now() - chrono::Duration::days(30))
7119 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7120 let t = std::time::Instant::now();
7121 let deleted = if old_list_form {
7122 // The shape `prune_old_entries` used to have: the pinned set as
7123 // an `IN` list, re-materialised on every batch.
7124 let mut n = 0u64;
7125 loop {
7126 let got = sqlx::query(
7127 "DELETE FROM entries WHERE id IN ( \
7128 SELECT id FROM entries \
7129 WHERE COALESCE(published, fetched_at) < ?1 \
7130 AND id NOT IN ( \
7131 SELECT entry_id FROM entry_state \
7132 WHERE starred = 1 OR read = 0 \
7133 ) \
7134 LIMIT 1000)",
7135 )
7136 .bind(&cutoff)
7137 .execute(&pool)
7138 .await?
7139 .rows_affected();
7140 n += got;
7141 if got == 0 {
7142 break;
7143 }
7144 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
7145 }
7146 n
7147 } else {
7148 // NOTE the arms are not identical work: this one goes through the
7149 // real `prune_old_entries`, which also runs the hard-ceiling pass
7150 // and the cursor scrub. The bias therefore runs AGAINST the
7151 // shipped form, so a win measured here is a lower bound — but the
7152 // two numbers are not a like-for-like microbenchmark.
7153 prune_old_entries(&pool, 30, 3650, 0).await?
7154 };
7155 let elapsed = t.elapsed();
7156
7157 // State the fixture's shape, so a future reader cannot mistake a
7158 // degenerate distribution for a representative one again.
7159 let matching: i64 = sqlx::query_scalar(
7160 "SELECT COUNT(*) FROM entry_state WHERE starred = 1 OR read = 0",
7161 )
7162 .fetch_one(&pool)
7163 .await?;
7164 println!("\n=== {label} (entry_state = {pinned}, pinned = {matching}) ===");
7165 println!(
7166 " index cost: {:.1} MiB on disk, 10k inserts in {insert_10k:?}",
7167 index_bytes as f64 / 1024.0 / 1024.0
7168 );
7169 for l in &plan {
7170 println!(" plan: {l}");
7171 }
7172 println!(
7173 " deleted {deleted} in {elapsed:?} ({:?}/batch)",
7174 elapsed / (deleted as u32 / PRUNE_BATCH as u32).max(1)
7175 );
7176
7177 pool.close().await;
7178 drop(fixture);
7179 Ok(())
7180 }
7181
7182 // R6's own hypothesis was that the per-batch `entry_state` scan is the
7183 // cost. Both scales are measured because that scan grows with TOTAL
7184 // users, not with the feed being swept — 50k is one active reader,
7185 // 600k is the figure the schema comment cites as realistic.
7186 const AGE_IDX: &str =
7187 "CREATE INDEX idx_entries_age ON entries(COALESCE(published, fetched_at))";
7188 // The index R6 actually asked for. Its row is the one the rejection
7189 // turns on — "changes the plan, changes the time by nothing" — and an
7190 // earlier version of this benchmark dropped it, leaving that claim
7191 // resting on prose while the artifact kept to prove it could not.
7192 const PINNED_IDX: &str = "CREATE INDEX idx_es_pinned ON entry_state(entry_id) \
7193 WHERE starred = 1 OR read = 0";
7194 for pinned in [PINNED, 600_000] {
7195 // `false` = the shipped `prune_old_entries`, whatever shape it
7196 // currently uses; `true` = the raw `NOT IN` list form it replaced,
7197 // kept so the regression stays measurable rather than remembered.
7198 run("as shipped (NOT EXISTS)", &[], pinned, false).await?;
7199 run("old NOT IN list form", &[], pinned, true).await?;
7200 run("old NOT IN + pinned index", &[PINNED_IDX], pinned, true).await?;
7201 // The row that was never measured: the pinned index against the
7202 // query that SHIPPED, rather than against the one being deleted.
7203 // Rejecting it on the strength of the latter was the error.
7204 run("as shipped + pinned index", &[PINNED_IDX], pinned, false).await?;
7205 run("as shipped + age index", &[AGE_IDX], pinned, false).await?;
7206 }
7207 Ok(())
7208 }
7209
7210 /// **The migration must not ask the pool for anything while holding a
7211 /// connection.** A single-connection pool is always saturated, so any such
7212 /// call stalls for the full acquire timeout.
7213 ///
7214 /// This has now been introduced twice — once by acquiring a connection for
7215 /// the pragma pair, and once by resolving the temp directory inside that
7216 /// block. The second was worse than a stall: `main_db_path` swallows errors
7217 /// into `None`, so it waited 30 s and then silently skipped the pragma it
7218 /// existed to set. A wall-clock assertion is crude, but it is the only thing
7219 /// that distinguishes "works" from "works after a 30-second timeout".
7220 #[tokio::test]
7221 async fn the_vacuum_migration_never_waits_on_its_own_pool() -> Result<()> {
7222 let dir = std::env::temp_dir();
7223 let path = dir.join(format!("fr-nodeadlock-{}.db", std::process::id()));
7224 for p in [
7225 path.display().to_string(),
7226 format!("{}-wal", path.display()),
7227 format!("{}-shm", path.display()),
7228 ] {
7229 std::fs::remove_file(&p).ok();
7230 }
7231 let url = format!("sqlite://{}", path.display());
7232 let opts = SqliteConnectOptions::from_str(&url)?
7233 .create_if_missing(true)
7234 .foreign_keys(true)
7235 .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
7236 .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::None);
7237 // ONE connection: any pool call made while the migration holds it will
7238 // block until the acquire timeout rather than deadlocking forever.
7239 let pool = SqlitePoolOptions::new()
7240 .min_connections(1)
7241 .max_connections(1)
7242 .connect_with(opts)
7243 .await?;
7244 init_schema(&pool).await?;
7245
7246 let t0 = std::time::Instant::now();
7247 let outcome = migrate_to_incremental_vacuum(&pool, Some(u64::MAX)).await?;
7248 let elapsed = t0.elapsed();
7249
7250 assert!(
7251 matches!(outcome, VacuumMigration::Migrated { .. }),
7252 "expected a migration, got {outcome:?}"
7253 );
7254 assert!(
7255 elapsed < std::time::Duration::from_secs(5),
7256 "the migration took {elapsed:?} on an empty database — it is waiting on \
7257 its own pool while holding a connection"
7258 );
7259
7260 pool.close().await;
7261 for p in [
7262 path.display().to_string(),
7263 format!("{}-wal", path.display()),
7264 format!("{}-shm", path.display()),
7265 ] {
7266 std::fs::remove_file(&p).ok();
7267 }
7268 Ok(())
7269 }
7270
7271 /// `reclaim` must NOT run a full VACUUM in NONE mode — the branch that used
7272 /// to be the only one that ever executed, and the one that cannot finish on
7273 /// a volume under the pressure that triggers a sweep.
7274 ///
7275 /// Observable without timing a VACUUM: a full VACUUM returns freed pages to
7276 /// the OS, so `page_count` falls. Skipping it leaves the allocation in
7277 /// place — while `db_size_bytes`, which subtracts the freelist, still drops.
7278 /// That pairing is the actual claim: the watermark does not latch even
7279 /// though the file does not shrink.
7280 #[tokio::test]
7281 async fn reclaim_does_not_full_vacuum_in_none_mode() -> Result<()> {
7282 let dir = std::env::temp_dir();
7283 let path = dir.join(format!("fr-noneclaim-{}.db", std::process::id()));
7284 for p in [
7285 path.display().to_string(),
7286 format!("{}-wal", path.display()),
7287 format!("{}-shm", path.display()),
7288 ] {
7289 std::fs::remove_file(&p).ok();
7290 }
7291 let url = format!("sqlite://{}", path.display());
7292 let opts = SqliteConnectOptions::from_str(&url)?
7293 .create_if_missing(true)
7294 .foreign_keys(true)
7295 .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
7296 .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::None);
7297 let pool = SqlitePoolOptions::new()
7298 .min_connections(1)
7299 .max_connections(1)
7300 .connect_with(opts)
7301 .await?;
7302 init_schema(&pool).await?;
7303
7304 let feed_id = upsert_feed(
7305 &pool,
7306 &NewFeed {
7307 url: "https://none.example/f.xml".to_string(),
7308 ..Default::default()
7309 },
7310 )
7311 .await?;
7312 let entries: Vec<NewEntry> = (0..1500)
7313 .map(|i| NewEntry {
7314 guid: format!("n-{i}"),
7315 content_html: Some("x".repeat(800)),
7316 ..Default::default()
7317 })
7318 .collect();
7319 insert_entries(&pool, feed_id, &entries, 0).await?;
7320 // Fold the WAL in so the "full" baseline is file pages, not WAL churn.
7321 sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
7322 .execute(&pool)
7323 .await?;
7324 let used_full = db_size_bytes(&pool).await?;
7325
7326 sqlx::query("DELETE FROM entries").execute(&pool).await?;
7327 let pages_before: i64 = sqlx::query_scalar("PRAGMA page_count")
7328 .fetch_one(&pool)
7329 .await?;
7330
7331 reclaim(&pool).await?;
7332
7333 let pages_after: i64 = sqlx::query_scalar("PRAGMA page_count")
7334 .fetch_one(&pool)
7335 .await?;
7336 assert_eq!(
7337 pages_after, pages_before,
7338 "reclaim shrank the file in NONE mode, so it ran the full VACUUM this \
7339 branch exists to avoid"
7340 );
7341 // …and the watermark still falls, which is what makes skipping safe.
7342 // `db_size_bytes` subtracts the freelist, so the delete alone lowers it
7343 // even though the file kept every page it had allocated.
7344 let used_after = db_size_bytes(&pool).await?;
7345 assert!(
7346 used_after < used_full,
7347 "used size did not fall after the delete ({used_after} !< {used_full}); \
7348 without a VACUUM the DB-size watermark would latch the poller off"
7349 );
7350
7351 pool.close().await;
7352 for p in [
7353 path.display().to_string(),
7354 format!("{}-wal", path.display()),
7355 format!("{}-shm", path.display()),
7356 ] {
7357 std::fs::remove_file(&p).ok();
7358 }
7359 Ok(())
7360 }
7361
7362 #[tokio::test]
7363 async fn db_size_drops_after_prune_and_reclaim() -> Result<()> {
7364 // On-disk DB so VACUUM has a file to shrink (in-memory has no freelist to
7365 // speak of the same way). Temp path, cleaned up at the end.
7366 let dir = std::env::temp_dir();
7367 let path = dir.join(format!("fr-reclaim-{}.db", std::process::id()));
7368 let url = format!("sqlite://{}", path.display());
7369 let pool = init_url(&url).await?;
7370
7371 let feed_id = upsert_feed(
7372 &pool,
7373 &NewFeed {
7374 url: "https://bulk.example/feed.xml".to_string(),
7375 ..Default::default()
7376 },
7377 )
7378 .await?;
7379
7380 // Insert a large batch so the file allocates real pages.
7381 let entries: Vec<NewEntry> = (0..2000)
7382 .map(|i| NewEntry {
7383 guid: format!("guid-{i}"),
7384 title: Some(format!("Entry number {i} with some padding text")),
7385 content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
7386 published: Some("2026-01-01T00:00:00Z".to_string()),
7387 ..Default::default()
7388 })
7389 .collect();
7390 insert_entries(&pool, feed_id, &entries, 0).await?;
7391 let full = db_size_bytes(&pool).await?;
7392 assert!(full > 0);
7393
7394 // Prune: delete every entry (the retention sweep's effect). This frees
7395 // pages onto the freelist but does NOT shrink the file yet.
7396 sqlx::query("DELETE FROM entries WHERE feed_id = ?1")
7397 .bind(feed_id)
7398 .execute(&pool)
7399 .await?;
7400
7401 // Because db_size_bytes subtracts freelist pages, the USED size already
7402 // reflects the delete even before the file shrinks.
7403 let after_delete = db_size_bytes(&pool).await?;
7404 assert!(
7405 after_delete < full,
7406 "used size must drop once rows are deleted (freed pages excluded): \
7407 {after_delete} !< {full}"
7408 );
7409
7410 // Reclaim returns the freed pages to the OS; used size stays low (and the
7411 // file itself shrinks). The key property F3 needs: the watermark can now
7412 // fall back below its threshold instead of latching polling off.
7413 reclaim(&pool).await?;
7414 let after_reclaim = db_size_bytes(&pool).await?;
7415 assert!(
7416 after_reclaim <= after_delete,
7417 "reclaim must not grow used size: {after_reclaim} !<= {after_delete}"
7418 );
7419 assert!(
7420 after_reclaim < full,
7421 "after prune+reclaim the DB is smaller than when full: \
7422 {after_reclaim} !< {full}"
7423 );
7424
7425 drop(pool);
7426 let _ = std::fs::remove_file(&path);
7427 let _ = std::fs::remove_file(format!("{}-wal", path.display()));
7428 let _ = std::fs::remove_file(format!("{}-shm", path.display()));
7429 Ok(())
7430 }
7431
7432 // -- F4 support: pds_created flag round-trips + flips ---------------------
7433
7434 #[tokio::test]
7435 async fn cursor_pds_created_defaults_false_and_flips() -> Result<()> {
7436 let pool = init_url("sqlite::memory:").await?;
7437 let did = "did:plc:f4";
7438 let feed_url = "https://example.com/feed.xml";
7439 upsert_cursor(
7440 &pool,
7441 &ReadCursor {
7442 did: did.to_string(),
7443 feed_url: feed_url.to_string(),
7444 read_through: None,
7445 read_ids: r#"["1"]"#.to_string(),
7446 unread_ids: "[]".to_string(),
7447 dirty: true,
7448 pds_created: false,
7449 updated_at: now_rfc3339(),
7450 },
7451 )
7452 .await?;
7453
7454 // A brand-new cursor's PDS record does NOT yet exist.
7455 let c = get_cursor(&pool, did, feed_url).await?.unwrap();
7456 assert!(!c.pds_created, "first flush must emit a create, not update");
7457
7458 // Two bystanders: the same DID on another feed, another DID on the same
7459 // feed. **The UPDATE must be scoped to exactly one row.** With its WHERE
7460 // clause deleted this test still passed — it seeded one cursor, so
7461 // "every row" and "this row" were the same row. Unscoped, every DID's
7462 // every cursor is flagged as created, their readState records are never
7463 // created, and every later flush emits `update` against nothing.
7464 for (d, f) in [
7465 (did, "https://other.example/feed.xml"),
7466 ("did:plc:other", feed_url),
7467 ] {
7468 upsert_cursor(
7469 &pool,
7470 &ReadCursor {
7471 did: d.to_string(),
7472 feed_url: f.to_string(),
7473 read_through: None,
7474 read_ids: "[]".to_string(),
7475 unread_ids: "[]".to_string(),
7476 dirty: false,
7477 pds_created: false,
7478 updated_at: now_rfc3339(),
7479 },
7480 )
7481 .await?;
7482 }
7483
7484 // After the create-flush lands, the flag flips so future flushes update.
7485 mark_cursor_pds_created(&pool, did, feed_url).await?;
7486 let c = get_cursor(&pool, did, feed_url).await?.unwrap();
7487 assert!(c.pds_created);
7488 for (d, f) in [
7489 (did, "https://other.example/feed.xml"),
7490 ("did:plc:other", feed_url),
7491 ] {
7492 let bystander = get_cursor(&pool, d, f).await?.unwrap();
7493 assert!(
7494 !bystander.pds_created,
7495 "marking ({did}, {feed_url}) also flagged ({d}, {f})"
7496 );
7497 }
7498 Ok(())
7499 }
7500
7501 // -- STORAGE HYGIENE: retention prune + orphan-id scrub -------------------
7502
7503 /// Count entries currently in the cache.
7504 async fn count_entries(pool: &SqlitePool) -> Result<i64> {
7505 Ok(sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM entries")
7506 .fetch_one(pool)
7507 .await?)
7508 }
7509
7510 /// **The rolling window and the hard ceiling do not touch a publication, and
7511 /// this is the test that says the feature works at all.**
7512 ///
7513 /// Measured on 2026-09-27 against three real publications: the newest
7514 /// document Standard.site offered was 131 days old, Annotated's 109, minus
7515 /// listens' 241. Under the 14-day window every one of them stored **zero**
7516 /// rows — a successful poll and an empty feed. So age is not the policy here;
7517 /// COUNT is (`max_entries_per_feed`), and the ceiling below is only the
7518 /// not-immortal backstop.
7519 ///
7520 /// Both directions in one test on purpose: the RSS twin must still be
7521 /// deleted, or "nothing is ever swept" would pass.
7522 #[tokio::test]
7523 async fn the_window_and_the_ceiling_spare_a_publication_but_not_an_rss_entry() -> Result<()> {
7524 let pool = init_url("sqlite::memory:").await?;
7525 let rss = upsert_feed(
7526 &pool,
7527 &NewFeed {
7528 url: "https://aged.example/feed.xml".to_string(),
7529 ..Default::default()
7530 },
7531 )
7532 .await?;
7533 let publication = upsert_feed(
7534 &pool,
7535 &NewFeed {
7536 url: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab"
7537 .to_string(),
7538 ..Default::default()
7539 },
7540 )
7541 .await?;
7542 // The kind column is what the sweep filters on, so assert the fixture
7543 // really produced two different kinds rather than trusting `FeedKind::of`.
7544 let kinds: Vec<String> = sqlx::query_scalar("SELECT kind FROM feeds ORDER BY id")
7545 .fetch_all(&pool)
7546 .await?;
7547 assert_eq!(kinds, vec!["rss".to_string(), "publication".to_string()]);
7548
7549 // A year old, and READ by somebody — so the window's own sparing rule
7550 // ("starred or unread survives") cannot be what keeps either row.
7551 let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
7552 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7553 for feed_id in [rss, publication] {
7554 insert_entries(
7555 &pool,
7556 feed_id,
7557 &[NewEntry {
7558 guid: format!("ancient-{feed_id}"),
7559 published: Some(ancient.clone()),
7560 fetched_at: Some(ancient.clone()),
7561 ..Default::default()
7562 }],
7563 0,
7564 )
7565 .await?;
7566 }
7567 replace_sub_refs(&pool, "did:plc:reader", &[rss, publication]).await?;
7568 for id in sqlx::query_scalar::<_, i64>("SELECT id FROM entries ORDER BY id")
7569 .fetch_all(&pool)
7570 .await?
7571 {
7572 mark_read(&pool, "did:plc:reader", id, true).await?;
7573 }
7574 assert_eq!(count_entries(&pool).await?, 2);
7575
7576 // **The shipped configuration, all three knobs at their defaults.** An
7577 // earlier version of this test passed `0` for the archive ceiling, so the
7578 // combination under test was not the one any instance runs; at 3650 the
7579 // publication's year-old document is inside the ceiling and must still
7580 // survive.
7581 let deleted = prune_old_entries(&pool, 14, 180, 3_650).await?;
7582 assert_eq!(deleted, 1, "exactly one of the two should have gone");
7583 let surviving: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM entries")
7584 .fetch_all(&pool)
7585 .await?;
7586 assert_eq!(
7587 surviving,
7588 vec![publication],
7589 "the publication's year-old document was swept — under the 14-day \
7590 window that is every document a real publication has, so the feed a \
7591 reader subscribed to would be permanently empty",
7592 );
7593 Ok(())
7594 }
7595
7596 /// **"Not aged out" must not mean "immortal".**
7597 ///
7598 /// The per-feed trim is what bounds a publication, and it only runs when a
7599 /// poll stores something — so entries of a feed nobody polls any more have
7600 /// nothing else to reap them. This ceiling is that backstop, and it spares
7601 /// nothing, for the same reason the hard ceiling spares nothing: a saved
7602 /// record whose entry is gone still renders from the PDS record as a link.
7603 #[tokio::test]
7604 async fn the_archive_ceiling_reaps_a_publication_entry_past_it() -> Result<()> {
7605 let pool = init_url("sqlite::memory:").await?;
7606 // An RSS twin, to pin that this pass is SCOPED. Verified needed: dropping
7607 // the `kind NOT IN` clause from it left all 909 tests passing, and that
7608 // mutation quietly re-enables age-based eviction for RSS on an instance
7609 // whose operator set both RSS knobs to zero.
7610 let rss = upsert_feed(
7611 &pool,
7612 &NewFeed {
7613 url: "https://not-swept.example/feed.xml".to_string(),
7614 ..Default::default()
7615 },
7616 )
7617 .await?;
7618 let publication = upsert_feed(
7619 &pool,
7620 &NewFeed {
7621 url: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab"
7622 .to_string(),
7623 ..Default::default()
7624 },
7625 )
7626 .await?;
7627 let ancient = (chrono::Utc::now() - chrono::Duration::days(400))
7628 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7629 let recent = now_rfc3339();
7630 insert_entries(
7631 &pool,
7632 publication,
7633 &[
7634 NewEntry {
7635 guid: "past-the-ceiling".into(),
7636 published: Some(ancient.clone()),
7637 fetched_at: Some(ancient),
7638 ..Default::default()
7639 },
7640 NewEntry {
7641 guid: "inside-the-ceiling".into(),
7642 published: Some(recent.clone()),
7643 fetched_at: Some(recent),
7644 ..Default::default()
7645 },
7646 ],
7647 0,
7648 )
7649 .await?;
7650 let long_ago = (chrono::Utc::now() - chrono::Duration::days(400))
7651 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7652 insert_entries(
7653 &pool,
7654 rss,
7655 &[NewEntry {
7656 guid: "rss-past-the-archive-ceiling".into(),
7657 published: Some(long_ago.clone()),
7658 fetched_at: Some(long_ago),
7659 ..Default::default()
7660 }],
7661 0,
7662 )
7663 .await?;
7664
7665 // STARRED, so this also pins that the ceiling spares nothing.
7666 replace_sub_refs(&pool, "did:plc:reader", &[rss, publication]).await?;
7667 for id in sqlx::query_scalar::<_, i64>("SELECT id FROM entries ORDER BY id")
7668 .fetch_all(&pool)
7669 .await?
7670 {
7671 mark_starred(&pool, "did:plc:reader", id, true).await?;
7672 }
7673
7674 // Rolling window and hard ceiling off: the archive ceiling is the only
7675 // thing that can delete here.
7676 let deleted = prune_old_entries(&pool, 0, 0, 365).await?;
7677 assert_eq!(
7678 deleted, 1,
7679 "the entry past the archive ceiling was not reaped"
7680 );
7681 let mut guids: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries")
7682 .fetch_all(&pool)
7683 .await?;
7684 guids.sort();
7685 assert_eq!(
7686 guids,
7687 vec![
7688 "inside-the-ceiling".to_string(),
7689 "rss-past-the-archive-ceiling".to_string(),
7690 ],
7691 "the archive ceiling must reap the publication's over-age entry and \
7692 ONLY that — an RSS entry on an instance with both RSS knobs at zero \
7693 is one the operator chose to keep",
7694 );
7695
7696 // And zero disables it, consistently with the other two knobs.
7697 assert_eq!(
7698 prune_old_entries(&pool, 0, 0, 0).await?,
7699 0,
7700 "publication_retention_days = 0 still deleted something",
7701 );
7702 Ok(())
7703 }
7704
7705 /// **A retention window too large to be a date must disable that pass, not
7706 /// kill the sweeper.**
7707 ///
7708 /// Every knob parses from a `u32` with no upper bound, and `Duration::days` /
7709 /// `DateTime - TimeDelta` both panic out of range — measured, anything past
7710 /// roughly 96 million days, and `u32::MAX` is. A unit slip (seconds or
7711 /// milliseconds typed into a days field) reaches it.
7712 ///
7713 /// The old failure was quiet: this runs in a spawned task, so tokio catches
7714 /// the panic and the sweeper stops for the life of the process, taking the
7715 /// release valve for `db_size_watermark_bytes` with it — the one thing that
7716 /// stops polling for every reader on the instance.
7717 ///
7718 /// `standard_site::ingest_floor` already answers the same input with "no
7719 /// floor", and `Config::retention_for` exists to keep the two agreeing, so
7720 /// this is also the end of a disagreement: unrepresentable meant "store
7721 /// everything" on one side and "panic" on the other.
7722 #[tokio::test]
7723 async fn an_unrepresentable_retention_window_disables_the_pass_it_belongs_to() -> Result<()> {
7724 let pool = init_url("sqlite::memory:").await?;
7725 let feed_id = upsert_feed(
7726 &pool,
7727 &NewFeed {
7728 url: "https://absurd.example/feed.xml".to_string(),
7729 ..Default::default()
7730 },
7731 )
7732 .await?;
7733 let ancient = (chrono::Utc::now() - chrono::Duration::days(1_000))
7734 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7735 insert_entries(
7736 &pool,
7737 feed_id,
7738 &[NewEntry {
7739 guid: "ancient".into(),
7740 published: Some(ancient.clone()),
7741 fetched_at: Some(ancient),
7742 ..Default::default()
7743 }],
7744 0,
7745 )
7746 .await?;
7747
7748 // Each knob in turn, since each computes its own cutoff.
7749 let absurd = u32::MAX as i64;
7750 assert_eq!(
7751 prune_old_entries(&pool, absurd, 0, 0).await?,
7752 0,
7753 "an absurd rolling window deleted something",
7754 );
7755 assert_eq!(
7756 prune_old_entries(&pool, 0, absurd, 0).await?,
7757 0,
7758 "an absurd hard ceiling deleted something",
7759 );
7760 assert_eq!(
7761 prune_old_entries(&pool, 0, 0, absurd).await?,
7762 0,
7763 "an absurd archive ceiling deleted something",
7764 );
7765 assert_eq!(
7766 count_entries(&pool).await?,
7767 1,
7768 "the entry went away under a window that cannot even be expressed",
7769 );
7770
7771 // And the sweep still works for the same knobs at a sane value — a
7772 // function that returned early on every input would satisfy the above.
7773 assert_eq!(
7774 prune_old_entries(&pool, 30, 0, 0).await?,
7775 1,
7776 "a 30-day window did not delete a 1000-day-old entry",
7777 );
7778 Ok(())
7779 }
7780
7781 /// The SQL list and the Rust slice are asserted equal, for the same reason
7782 /// [`POLLABLE_KINDS_SQL`] is: a literal here and a slice there is the drift
7783 /// the `kind` column was introduced to end.
7784 #[test]
7785 fn the_sql_aged_kind_list_matches_the_rust_one() {
7786 let expected = crate::feed::FeedKind::AGED
7787 .iter()
7788 .map(|k| format!("'{}'", k.as_str()))
7789 .collect::<Vec<_>>()
7790 .join(", ");
7791 assert_eq!(AGED_KINDS_SQL, expected);
7792 }
7793
7794 #[tokio::test]
7795 async fn prune_old_entries_deletes_only_old_and_cascades_entry_state() -> Result<()> {
7796 let pool = init_url("sqlite::memory:").await?;
7797 let feed_id = upsert_feed(
7798 &pool,
7799 &NewFeed {
7800 url: "https://ret.example/feed.xml".to_string(),
7801 ..Default::default()
7802 },
7803 )
7804 .await?;
7805
7806 let recent = now_rfc3339();
7807 let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
7808 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7809
7810 // One fresh (published now), one ancient (published a year ago), and one
7811 // UNDATED-but-freshly-fetched (published NULL, fetched_at now) — the last
7812 // must survive because COALESCE falls back to fetched_at, not to "old".
7813 insert_entries(
7814 &pool,
7815 feed_id,
7816 &[
7817 NewEntry {
7818 guid: "fresh".into(),
7819 published: Some(recent.clone()),
7820 fetched_at: Some(recent.clone()),
7821 ..Default::default()
7822 },
7823 NewEntry {
7824 guid: "ancient".into(),
7825 published: Some(ancient.clone()),
7826 fetched_at: Some(ancient.clone()),
7827 ..Default::default()
7828 },
7829 NewEntry {
7830 guid: "undated-fresh".into(),
7831 published: None,
7832 fetched_at: Some(recent.clone()),
7833 ..Default::default()
7834 },
7835 ],
7836 0,
7837 )
7838 .await?;
7839 assert_eq!(count_entries(&pool).await?, 3);
7840 // Subscribe so mark_read is authorized to write an entry_state row.
7841 replace_sub_refs(&pool, "did:plc:reader", &[feed_id]).await?;
7842
7843 // Give the ancient entry an entry_state row so we can prove the FK cascade.
7844 let ancient_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'ancient'")
7845 .fetch_one(&pool)
7846 .await?;
7847 let wrote = mark_read(&pool, "did:plc:reader", ancient_id, true).await?;
7848 assert!(wrote, "mark_read must write with a sub_ref in place");
7849 let state_before: i64 =
7850 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
7851 .bind(ancient_id)
7852 .fetch_one(&pool)
7853 .await?;
7854 assert_eq!(state_before, 1);
7855
7856 // Prune at a 90-day window: only the ancient entry is old.
7857 let deleted = prune_old_entries(&pool, 90, 3650, 0).await?;
7858 assert_eq!(deleted, 1, "only the year-old entry should be pruned");
7859 assert_eq!(
7860 count_entries(&pool).await?,
7861 2,
7862 "fresh + undated-fresh survive"
7863 );
7864
7865 // The surviving guids are exactly the two fresh ones.
7866 let surviving: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
7867 .fetch_all(&pool)
7868 .await?;
7869 assert_eq!(surviving, vec!["fresh", "undated-fresh"]);
7870
7871 // entry_state for the deleted entry cascaded away via the FK.
7872 let state_after: i64 =
7873 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
7874 .bind(ancient_id)
7875 .fetch_one(&pool)
7876 .await?;
7877 assert_eq!(state_after, 0, "entry_state must cascade on entry delete");
7878
7879 // days == 0 disables the rolling WINDOW. The 3650-day ceiling still runs
7880 // (see `a_disabled_window_does_not_disable_the_ceiling`); it deletes
7881 // nothing here because both survivors are fresh.
7882 assert_eq!(prune_old_entries(&pool, 0, 3650, 0).await?, 0);
7883 assert_eq!(count_entries(&pool).await?, 2);
7884 Ok(())
7885 }
7886
7887 #[tokio::test]
7888 async fn prune_removes_orphan_ids_from_read_cursor() -> Result<()> {
7889 let pool = init_url("sqlite::memory:").await?;
7890 let did = "did:plc:reader";
7891 let feed_url = "https://orphan.example/feed.xml";
7892 let feed_id = upsert_feed(
7893 &pool,
7894 &NewFeed {
7895 url: feed_url.to_string(),
7896 ..Default::default()
7897 },
7898 )
7899 .await?;
7900 // Caller subscribes so mark-read is authorized to project into the cursor.
7901 replace_sub_refs(&pool, did, &[feed_id]).await?;
7902
7903 let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
7904 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7905 let recent = now_rfc3339();
7906 insert_entries(
7907 &pool,
7908 feed_id,
7909 &[
7910 NewEntry {
7911 guid: "old".into(),
7912 published: Some(ancient.clone()),
7913 fetched_at: Some(ancient.clone()),
7914 ..Default::default()
7915 },
7916 NewEntry {
7917 guid: "new".into(),
7918 published: Some(recent.clone()),
7919 fetched_at: Some(recent.clone()),
7920 ..Default::default()
7921 },
7922 ],
7923 0,
7924 )
7925 .await?;
7926 let old_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'old'")
7927 .fetch_one(&pool)
7928 .await?;
7929 let new_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'new'")
7930 .fetch_one(&pool)
7931 .await?;
7932
7933 // Mark BOTH read — the cursor's read_ids now references both entry ids.
7934 mark_read(&pool, did, old_id, true).await?;
7935 mark_read(&pool, did, new_id, true).await?;
7936 let before = get_cursor(&pool, did, feed_url).await?.unwrap();
7937 let ids_before: Vec<String> = serde_json::from_str(&before.read_ids)?;
7938 assert!(ids_before.contains(&old_id.to_string()));
7939 assert!(ids_before.contains(&new_id.to_string()));
7940
7941 // Prune the old entry — its id must be scrubbed from the cursor's id-set.
7942 let deleted = prune_old_entries(&pool, 90, 3650, 0).await?;
7943 assert_eq!(deleted, 1);
7944 let after = get_cursor(&pool, did, feed_url).await?.unwrap();
7945 let ids_after: Vec<String> = serde_json::from_str(&after.read_ids)?;
7946 assert_eq!(
7947 ids_after,
7948 vec![new_id.to_string()],
7949 "orphaned (deleted) entry id must be removed; live id kept"
7950 );
7951 // The scrub re-dirties the cursor so the flusher resyncs the PDS record.
7952 assert!(
7953 after.dirty,
7954 "cursor must be marked dirty after orphan scrub"
7955 );
7956 Ok(())
7957 }
7958
7959 #[tokio::test]
7960 async fn insert_entries_trim_scrubs_orphan_cursor_ids() -> Result<()> {
7961 // The per-feed max_entries trim path must ALSO scrub orphaned cursor ids.
7962 let pool = init_url("sqlite::memory:").await?;
7963 let did = "did:plc:reader";
7964 let feed_url = "https://trim.example/feed.xml";
7965 let feed_id = upsert_feed(
7966 &pool,
7967 &NewFeed {
7968 url: feed_url.to_string(),
7969 ..Default::default()
7970 },
7971 )
7972 .await?;
7973 replace_sub_refs(&pool, did, &[feed_id]).await?;
7974
7975 // Two entries, cap of 2 for now (no trim yet).
7976 insert_entries(
7977 &pool,
7978 feed_id,
7979 &[
7980 NewEntry {
7981 guid: "a".into(),
7982 published: Some("2026-01-01T00:00:00Z".into()),
7983 ..Default::default()
7984 },
7985 NewEntry {
7986 guid: "b".into(),
7987 published: Some("2026-01-02T00:00:00Z".into()),
7988 ..Default::default()
7989 },
7990 ],
7991 2,
7992 )
7993 .await?;
7994 let a_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'a'")
7995 .fetch_one(&pool)
7996 .await?;
7997 mark_read(&pool, did, a_id, true).await?;
7998
7999 // Insert a newer entry with cap=1 → the oldest ('a') is trimmed away.
8000 insert_entries(
8001 &pool,
8002 feed_id,
8003 &[NewEntry {
8004 guid: "c".into(),
8005 published: Some("2026-01-03T00:00:00Z".into()),
8006 ..Default::default()
8007 }],
8008 1,
8009 )
8010 .await?;
8011 // 'a' is gone.
8012 let a_still: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries WHERE guid = 'a'")
8013 .fetch_one(&pool)
8014 .await?;
8015 assert_eq!(a_still, 0, "oldest entry trimmed by the per-feed cap");
8016
8017 // The cursor no longer references the trimmed id.
8018 let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
8019 let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
8020 assert!(
8021 !ids.contains(&a_id.to_string()),
8022 "trimmed entry id must be scrubbed from the cursor"
8023 );
8024 Ok(())
8025 }
8026
8027 /// A sweep spanning several batches must still delete everything.
8028 ///
8029 /// The batching exists to make the write-lock hold interruptible, not to
8030 /// make the sweep partial — so the obvious way to get it wrong is an
8031 /// off-by-one that leaves a batch behind, or a loop that exits on the first
8032 /// short batch instead of the first empty one.
8033 #[tokio::test]
8034 async fn a_sweep_larger_than_one_batch_still_drains() -> Result<()> {
8035 let pool = init_url("sqlite::memory:").await?;
8036 let feed_id = upsert_feed(
8037 &pool,
8038 &NewFeed {
8039 url: "https://bulk.example/f.xml".to_string(),
8040 ..Default::default()
8041 },
8042 )
8043 .await?;
8044 let old = (chrono::Utc::now() - chrono::Duration::days(400))
8045 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8046 // Deliberately not a multiple of PRUNE_BATCH, so the final batch is
8047 // short and the loop has to keep going to the empty one.
8048 let count = (PRUNE_BATCH * 2 + 137) as usize;
8049 let entries: Vec<NewEntry> = (0..count)
8050 .map(|i| NewEntry {
8051 guid: format!("bulk-{i}"),
8052 published: Some(old.clone()),
8053 ..Default::default()
8054 })
8055 .collect();
8056 insert_entries(&pool, feed_id, &entries, 0).await?;
8057 assert_eq!(count_entries(&pool).await? as usize, count);
8058
8059 let deleted = prune_old_entries(&pool, 30, 180, 0).await?;
8060 assert_eq!(deleted as usize, count, "the sweep left rows behind");
8061 assert_eq!(count_entries(&pool).await?, 0);
8062 Ok(())
8063 }
8064
8065 /// What a sweep driven by a lock test actually did.
8066 ///
8067 /// `Contended` is NOT a failure. `SQLITE_BUSY` on the pruner is an outcome
8068 /// production expects and handles — `scheduler.rs` logs it and the next tick
8069 /// retries — so a test that treats it as a regression is stricter than the
8070 /// system it guards, and fails for a reason its own assertions are not
8071 /// about. See #146.
8072 enum SweepOutcome {
8073 Completed(u64),
8074 Contended,
8075 }
8076
8077 /// True for the `SQLITE_BUSY` FAMILY anywhere in the chain.
8078 ///
8079 /// Matched on the DRIVER CODE, not on the message text: "database is
8080 /// locked" is a string another error could plausibly carry, and this
8081 /// decides whether a test failure is suppressed.
8082 ///
8083 /// **Masked to the primary code.** sqlx-sqlite's `code()` returns
8084 /// `sqlite3_extended_errcode` verbatim, so comparing it to `"5"` matches
8085 /// only bare `SQLITE_BUSY` and treats the WAL variants as hard failures:
8086 /// `BUSY_RECOVERY` (261), `BUSY_SNAPSHOT` (517), `BUSY_TIMEOUT` (773).
8087 /// This database runs in WAL mode and `store.rs` already documents hitting
8088 /// `SQLITE_BUSY_SNAPSHOT`, so that gap is not hypothetical — the narrowing
8089 /// would have rejected the very class this tolerance exists for.
8090 ///
8091 /// `& 0xFF` is how SQLite defines the relationship: the low byte of an
8092 /// extended code IS the primary code.
8093 fn is_sqlite_busy(err: &anyhow::Error) -> bool {
8094 err.chain().any(|e| {
8095 e.downcast_ref::<sqlx::Error>().is_some_and(|e| match e {
8096 sqlx::Error::Database(db) => db
8097 .code()
8098 .and_then(|c| c.parse::<i32>().ok())
8099 .is_some_and(is_busy_code),
8100 _ => false,
8101 })
8102 })
8103 }
8104
8105 /// The classification, split out so the WAL variants are TESTABLE.
8106 ///
8107 /// A `BUSY_SNAPSHOT` cannot be produced on demand in a test, so without
8108 /// this the claim that 261/517/773 are tolerated would be a comment and
8109 /// nothing else. The wiring — that `is_sqlite_busy` consults this at all —
8110 /// is pinned separately by `a_busy_sweep_is_reported_as_contended_not_as_a_failure`,
8111 /// which drives a real `SQLITE_BUSY` end to end.
8112 fn is_busy_code(code: i32) -> bool {
8113 code & 0xFF == 5
8114 }
8115
8116 /// **The whole `SQLITE_BUSY` family, and nothing else.**
8117 #[test]
8118 fn busy_codes_cover_the_wal_variants() {
8119 for code in [
8120 5, // SQLITE_BUSY
8121 261, // SQLITE_BUSY_RECOVERY
8122 517, // SQLITE_BUSY_SNAPSHOT
8123 773, // SQLITE_BUSY_TIMEOUT
8124 ] {
8125 assert!(
8126 is_busy_code(code),
8127 "{code} is in the BUSY family but would be treated as a hard failure"
8128 );
8129 }
8130 for code in [
8131 0, // SQLITE_OK
8132 1, // SQLITE_ERROR
8133 6, // SQLITE_LOCKED — adjacent, and deliberately NOT tolerated
8134 262, // SQLITE_LOCKED_SHAREDCACHE
8135 11, // SQLITE_CORRUPT
8136 ] {
8137 assert!(
8138 !is_busy_code(code),
8139 "{code} is not contention, but would be swallowed as though it were"
8140 );
8141 }
8142 }
8143
8144 /// Run the batched delete, separating "the write lock was contended" from
8145 /// "the loop misbehaved". Only the second is this test's subject.
8146 async fn sweep_tolerating_busy(
8147 pool: &SqlitePool,
8148 select_ids: &str,
8149 cutoff: &str,
8150 label: &str,
8151 ) -> Result<SweepOutcome> {
8152 match delete_in_batches(pool, select_ids, cutoff, label).await {
8153 Ok(n) => Ok(SweepOutcome::Completed(n)),
8154 // Contended, not broken. Narrowed to SQLITE_BUSY on purpose: every
8155 // other error still fails the caller, so this is not a blanket
8156 // `let _ =` that would delete the test while keeping its name.
8157 Err(err) if is_sqlite_busy(&err) => Ok(SweepOutcome::Contended),
8158 Err(err) => Err(err),
8159 }
8160 }
8161
8162 /// **A sweep that loses the write lock is inconclusive, not a failure.**
8163 ///
8164 /// CI hit this on `main` at `d05a716`: the sweeper took `SQLITE_BUSY` and
8165 /// the test reported a regression, on a tree whose only changes were two
8166 /// version strings and a changelog.
8167 ///
8168 /// Forced deterministically rather than waiting for a contended runner — it
8169 /// did not reproduce in 48 local runs — by holding a write transaction open
8170 /// and giving the sweep a `busy_timeout` short enough to give up at once.
8171 #[tokio::test]
8172 async fn a_busy_sweep_is_reported_as_contended_not_as_a_failure() -> Result<()> {
8173 struct TempDb(std::path::PathBuf);
8174 impl Drop for TempDb {
8175 fn drop(&mut self) {
8176 for suffix in ["", "-wal", "-shm"] {
8177 std::fs::remove_file(format!("{}{suffix}", self.0.display())).ok();
8178 }
8179 }
8180 }
8181 let path = std::env::temp_dir().join(format!("fr-busysweep-{}.db", std::process::id()));
8182 drop(TempDb(path.clone()));
8183 let _tmp = TempDb(path.clone());
8184 let url = format!("sqlite://{}", path.display());
8185 let pool = init_url(&url).await?;
8186
8187 let feed_id = upsert_feed(
8188 &pool,
8189 &NewFeed {
8190 url: "https://busy.example/f.xml".to_string(),
8191 ..Default::default()
8192 },
8193 )
8194 .await?;
8195 let old = (chrono::Utc::now() - chrono::Duration::days(400))
8196 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8197 let entries: Vec<NewEntry> = (0..4)
8198 .map(|i| NewEntry {
8199 guid: format!("busy-{i}"),
8200 url: Some(format!("https://busy.example/{i}")),
8201 title: Some(format!("e{i}")),
8202 published: Some(old.clone()),
8203 ..Default::default()
8204 })
8205 .collect();
8206 insert_entries(&pool, feed_id, &entries, 1_000).await?;
8207
8208 // A sweep pool that gives up on a contended write immediately.
8209 let sweep_pool = SqlitePoolOptions::new()
8210 .max_connections(1)
8211 .connect_with(
8212 url.parse::<sqlx::sqlite::SqliteConnectOptions>()?
8213 .busy_timeout(std::time::Duration::from_millis(2)),
8214 )
8215 .await?;
8216
8217 // Hold the write lock for the duration of the sweep below.
8218 let mut blocker = pool.acquire().await?;
8219 sqlx::query("BEGIN IMMEDIATE")
8220 .execute(&mut *blocker)
8221 .await?;
8222
8223 let cutoff = (chrono::Utc::now() - chrono::Duration::days(180))
8224 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8225 let outcome = sweep_tolerating_busy(
8226 &sweep_pool,
8227 "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1",
8228 &cutoff,
8229 "busy-sweep-test",
8230 )
8231 .await;
8232
8233 sqlx::query("ROLLBACK").execute(&mut *blocker).await.ok();
8234
8235 match outcome {
8236 Ok(SweepOutcome::Contended) => Ok(()),
8237 Ok(SweepOutcome::Completed(n)) => panic!(
8238 "the sweep completed ({n} rows) while the write lock was held — \
8239 the fixture is not actually contending, so this test proves nothing"
8240 ),
8241 Err(err) => panic!(
8242 "a contended sweep was reported as a failure rather than as \
8243 inconclusive; production logs this and retries on the next \
8244 tick (scheduler.rs): {err:#}"
8245 ),
8246 }
8247 }
8248
8249 /// **A sweep error that is NOT `SQLITE_BUSY` must still fail.**
8250 ///
8251 /// `sweep_tolerating_busy` claims to narrow its tolerance to contention.
8252 /// Without this, that claim is unenforced: widening the arm to `Err(_) =>
8253 /// Contended` swallows every sweep error — a malformed query, a missing
8254 /// table, a corrupt file — and the whole suite stays green. Measured, not
8255 /// assumed: that mutation passed 733 tests before this test existed.
8256 #[tokio::test]
8257 async fn a_non_busy_sweep_error_still_fails() -> Result<()> {
8258 let pool = init_url("sqlite::memory:").await?;
8259 // A table that does not exist: SQLITE_ERROR (1), not SQLITE_BUSY (5).
8260 let outcome = sweep_tolerating_busy(
8261 &pool,
8262 "SELECT id FROM no_such_table WHERE created < ?1",
8263 "2026-01-01T00:00:00Z",
8264 "bad-query-test",
8265 )
8266 .await;
8267
8268 match outcome {
8269 Err(err) => {
8270 assert!(
8271 !is_sqlite_busy(&err),
8272 "fixture drifted: this must be a non-BUSY error, got {err:#}"
8273 );
8274 Ok(())
8275 }
8276 Ok(SweepOutcome::Contended) => panic!(
8277 "a malformed sweep was reported as lock contention — the \
8278 tolerance is a blanket error swallow, not a narrowing"
8279 ),
8280 Ok(SweepOutcome::Completed(n)) => {
8281 panic!("a sweep over a missing table reported {n} rows deleted")
8282 }
8283 }
8284 }
8285
8286 /// **An UNCONTENDED sweep must report `Completed`.**
8287 ///
8288 /// This exists to stop the `Contended` arm above becoming a way to never
8289 /// run the hand-off assertions. Make `sweep_tolerating_busy` return
8290 /// `Contended` unconditionally and the sweep-lock test still passes — it
8291 /// just silently stops testing anything. This one fails instead.
8292 ///
8293 /// That is the difference between tolerating a real contention loss and
8294 /// deleting a test while keeping its name.
8295 #[tokio::test]
8296 async fn a_sweep_with_no_contention_completes() -> Result<()> {
8297 struct TempDb(std::path::PathBuf);
8298 impl Drop for TempDb {
8299 fn drop(&mut self) {
8300 for suffix in ["", "-wal", "-shm"] {
8301 std::fs::remove_file(format!("{}{suffix}", self.0.display())).ok();
8302 }
8303 }
8304 }
8305 let path = std::env::temp_dir().join(format!("fr-calmsweep-{}.db", std::process::id()));
8306 drop(TempDb(path.clone()));
8307 let _tmp = TempDb(path.clone());
8308 let pool = init_url(&format!("sqlite://{}", path.display())).await?;
8309
8310 let feed_id = upsert_feed(
8311 &pool,
8312 &NewFeed {
8313 url: "https://calm.example/f.xml".to_string(),
8314 ..Default::default()
8315 },
8316 )
8317 .await?;
8318 let old = (chrono::Utc::now() - chrono::Duration::days(400))
8319 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8320 let entries: Vec<NewEntry> = (0..3)
8321 .map(|i| NewEntry {
8322 guid: format!("calm-{i}"),
8323 published: Some(old.clone()),
8324 ..Default::default()
8325 })
8326 .collect();
8327 insert_entries(&pool, feed_id, &entries, 1_000).await?;
8328
8329 let cutoff = (chrono::Utc::now() - chrono::Duration::days(180))
8330 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8331 match sweep_tolerating_busy(
8332 &pool,
8333 "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1",
8334 &cutoff,
8335 "calm-sweep-test",
8336 )
8337 .await?
8338 {
8339 SweepOutcome::Completed(n) => {
8340 assert_eq!(n, 3, "the uncontended sweep did not delete the fixture");
8341 Ok(())
8342 }
8343 SweepOutcome::Contended => panic!(
8344 "nothing was holding the write lock, yet the sweep reported \
8345 contention — every test that skips on `Contended` is now \
8346 skipping unconditionally"
8347 ),
8348 }
8349 }
8350
8351 /// **The sweep must not lock other writers out for its duration.**
8352 ///
8353 /// The whole sweep used to be one transaction — both deletes plus a global
8354 /// cursor scrub that loads every `read_cursor` row and then issues a
8355 /// per-cursor live-ids query. SQLite is single-writer with a 5 s
8356 /// `busy_timeout`, so every mark-read, login write and cursor flush failed
8357 /// for that whole span.
8358 ///
8359 /// On-disk (WAL) because the in-memory pool is deliberately
8360 /// single-connection, which would make a concurrency test meaningless.
8361 ///
8362 /// **The writer runs on its own pool with a short `busy_timeout`, and the
8363 /// runtime is multi-thread.** Both are load-bearing — a 5 s `busy_timeout`
8364 /// on a shared runtime is what made this test flake on CI. See the comment
8365 /// on the writer pool and the `attempts` assertion.
8366 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8367 async fn a_writer_gets_through_while_the_sweep_runs() -> Result<()> {
8368 // **Cleanup on EVERY exit, including a panicking assertion.**
8369 //
8370 // The three `remove_file` calls used to sit after the assertions, so any
8371 // failure leaked the database and its `-wal`/`-shm` — 2.7–12.8 MB a time,
8372 // and this test is deliberately the one most likely to fail. Worse, setup
8373 // removed only the `.db`, so a recycled PID paired a fresh database with a
8374 // stale WAL. A guard drops on the unwind path too and takes all three.
8375 struct TempDb(std::path::PathBuf);
8376 impl Drop for TempDb {
8377 fn drop(&mut self) {
8378 for suffix in ["", "-wal", "-shm"] {
8379 std::fs::remove_file(format!("{}{suffix}", self.0.display())).ok();
8380 }
8381 }
8382 }
8383 let dir = std::env::temp_dir();
8384 let path = dir.join(format!("fr-sweeplock-{}.db", std::process::id()));
8385 // Drops the previous run's leftovers, WAL and all, before opening.
8386 drop(TempDb(path.clone()));
8387 let _tmp = TempDb(path.clone());
8388 let url = format!("sqlite://{}", path.display());
8389 let pool = init_url(&url).await?;
8390
8391 let feed_id = upsert_feed(
8392 &pool,
8393 &NewFeed {
8394 url: "https://lock.example/f.xml".to_string(),
8395 ..Default::default()
8396 },
8397 )
8398 .await?;
8399 // **The fixture is DERIVED from the batch count, not described by it.**
8400 //
8401 // Every assertion below reasons about "ten hand-off windows". That was
8402 // prose — a `const BATCHES: u32 = 10` sitting next to a `PRUNE_BATCH *
8403 // 10` fixture with nothing tying them together. Editing the fixture
8404 // alone to `PRUNE_BATCH * 4` left the floor still demanding ten
8405 // hand-offs' worth of time from a four-batch loop, and correct code was
8406 // accused of not handing the lock over at all (1 run in 6). Now the
8407 // compiler carries the coupling.
8408 const BATCHES: i64 = 10;
8409 // **The 50% ceiling below is only safe because BATCHES is large.**
8410 //
8411 // `max_refused_run / attempts` is bounded by roughly `1 / BATCHES` only
8412 // because the fixture opens that many hand-off windows. Shrink it and
8413 // correct code walks into the ceiling: measured with production code
8414 // untouched and the per-batch hold grown 10x, `BATCHES = 4` gives ratios
8415 // of 0.21–0.35 and `BATCHES = 2` gives 0.45–0.56, **failing 3 runs in
8416 // 5**. The comment above invites editing this fixture; this stops that
8417 // edit from silently turning the assertion against the code it guards.
8418 const _: () = assert!(
8419 BATCHES >= 5,
8420 "the 50% ceiling assumes ~1/BATCHES; below 5 batches correct code false-fails",
8421 );
8422 let old = (chrono::Utc::now() - chrono::Duration::days(400))
8423 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8424 let entries: Vec<NewEntry> = (0..(PRUNE_BATCH * BATCHES) as usize)
8425 .map(|i| NewEntry {
8426 guid: format!("lock-{i}"),
8427 published: Some(old.clone()),
8428 ..Default::default()
8429 })
8430 .collect();
8431 insert_entries(&pool, feed_id, &entries, 0).await?;
8432
8433 // **The writer gets its OWN pool, with a SHORT `busy_timeout`.**
8434 //
8435 // This is the fix for the CI flake described on the `attempts` assertion
8436 // below, and it is two separate changes.
8437 //
8438 // *Its own pool*, so the only thing that can block a write is SQLite's
8439 // write lock — the thing under test. Sharing the 5-connection pool with
8440 // the sweep meant a write could also stall waiting to ACQUIRE a pooled
8441 // connection the sweep was holding, which is a confounder that looks
8442 // identical from the outside.
8443 //
8444 // *A short `busy_timeout`*, so a contended write FAILS FAST and the loop
8445 // takes another shot. At the production 5 s, SQLite's busy handler backs
8446 // off internally — 1, 2, 5, 10, 25, 50, 100 ms and up — all inside a
8447 // single `execute()`. The writer therefore gets ONE attempt per blocked
8448 // write, and once the ladder reaches 100 ms it sleeps straight past the
8449 // `PRUNE_BATCH_HANDOFF` windows `delete_in_batches` opens. Failing fast
8450 // turns one low-probability attempt into hundreds of independent ones:
8451 // measured 54 attempts at 5 ms, 517 at 2 ms, over the same sweep.
8452 const WRITER_BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(2);
8453 let writer_pool = SqlitePoolOptions::new()
8454 .min_connections(1)
8455 .max_connections(1)
8456 .connect_with(
8457 SqliteConnectOptions::from_str(&url)?
8458 .foreign_keys(true)
8459 .busy_timeout(WRITER_BUSY_TIMEOUT)
8460 .log_statements(tracing::log::LevelFilter::Debug),
8461 )
8462 .await?;
8463
8464 let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
8465 let writer_done = std::sync::Arc::clone(&done);
8466 let writer = tokio::spawn(async move {
8467 // `(when the attempt STARTED, whether it landed)`.
8468 //
8469 // The ORDER is what the assertions read, not the timestamps: they
8470 // count consecutive failures. The instants serve only to select the
8471 // attempts made inside the measured window — the writer is spawned
8472 // before `t0`, so a plain counter would fold in attempts that can
8473 // never appear in `during`.
8474 //
8475 // (An earlier version of this comment, left behind by the switch away
8476 // from elapsed time, said completions were recorded and that "the
8477 // timestamps are the load-bearing part". Neither is true now.)
8478 let mut outcomes: Vec<(std::time::Instant, bool)> = Vec::new();
8479 // Kept for the failure message: if the writes are failing for a
8480 // reason that is NOT lock contention, nothing lands and the test
8481 // fails — this is what says why. Timestamped so the test can drop it
8482 // when it describes an attempt OUTSIDE the measured window; the
8483 // writer starts before `t0`, so the very first error is usually from
8484 // an attempt the assertions never look at.
8485 let mut first_err: Option<(std::time::Instant, String)> = None;
8486 while !writer_done.load(std::sync::atomic::Ordering::Relaxed) {
8487 let started = std::time::Instant::now();
8488 match grant_access(
8489 &writer_pool,
8490 &format!("did:plc:writer{}", outcomes.len()),
8491 None,
8492 "sweep-test",
8493 None,
8494 )
8495 .await
8496 {
8497 Ok(()) => outcomes.push((started, true)),
8498 // Expected: the sweep holds the write lock right now.
8499 // Retrying is the entire point, so this is counted, not
8500 // fatal. A `?` here would abort the writer on the first
8501 // contended write and destroy the measurement.
8502 Err(err) => {
8503 outcomes.push((started, false));
8504 if first_err.is_none() {
8505 first_err = Some((started, format!("{err:#}")));
8506 }
8507 }
8508 }
8509 tokio::task::yield_now().await;
8510 }
8511 writer_pool.close().await;
8512 (outcomes, first_err)
8513 });
8514
8515 // **Drive `delete_in_batches` directly, not `prune_old_entries`.**
8516 //
8517 // The subject is the batched delete loop and whether it hands the write
8518 // lock over between batches. `prune_old_entries` wraps it in work that
8519 // is not that — two delete passes plus `prune_orphan_cursor_ids` — so
8520 // timing the whole call measures a window in which the lock was never
8521 // meant to be held throughout, and writes landing outside the loop
8522 // count as though the loop had handed the lock over.
8523 //
8524 // A correction to what this comment first claimed. It said the cursor
8525 // scrub was a tail that "grows with the number of rows deleted", and
8526 // that this explained a `42 of 358` measurement. **That is false, and
8527 // measured to be false**: this fixture creates no `read_cursor` rows at
8528 // all, so the scrub does one `SELECT` over an empty table and loops zero
8529 // times — 0.16–2 ms, 0.03–0.5% of the window, at any fixture size. It
8530 // cannot explain anything. Narrowing the window is still right, for the
8531 // reason above; the mechanism originally given for it was not real.
8532 //
8533 // The consequence worth stating: because the fixture has no cursors, the
8534 // old form never covered the scrub's locking either — it only appeared
8535 // to. Nothing here regressed. `prune_orphan_cursor_ids` holding the lock
8536 // across a whole pass is a real production invariant (see its own doc)
8537 // and remains untested; that needs a test with actual cursors, not this
8538 // one.
8539 let hard_cutoff = (chrono::Utc::now() - chrono::Duration::days(180))
8540 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8541 let t0 = std::time::Instant::now();
8542 let outcome = sweep_tolerating_busy(
8543 &pool,
8544 "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1",
8545 &hard_cutoff,
8546 "sweep-lock-test",
8547 )
8548 .await?;
8549 let sweep = t0.elapsed();
8550
8551 // **Teardown happens on BOTH paths, before the outcome is inspected.**
8552 //
8553 // The contended arm below used to carry its own copy of these two lines.
8554 // A probe proved that arm is never reached by the suite — a `panic!` in
8555 // it failed nothing — so it was five lines of unexercised teardown that
8556 // would run for the first time on a contended CI runner, which is
8557 // exactly when it has to work. Hoisting leaves the arm with nothing that
8558 // can be wrong.
8559 done.store(true, std::sync::atomic::Ordering::Relaxed);
8560 let (outcomes, first_err) = writer.await?;
8561
8562 let deleted = match outcome {
8563 SweepOutcome::Completed(n) => n,
8564 // **Inconclusive, not a regression.** The sweeper lost the write
8565 // lock, which says nothing about whether it hands the lock over
8566 // between batches — the property below. Production logs this and
8567 // retries on the next tick (`scheduler.rs`), so a test that failed
8568 // here would be stricter than the system it guards. Observed on CI
8569 // at `d05a716`, on a tree with no `.rs` change at all.
8570 //
8571 // `a_sweep_with_no_contention_completes` is what stops this arm
8572 // becoming a way to never run the assertions.
8573 SweepOutcome::Contended => {
8574 eprintln!(
8575 "sweep-lock test INCONCLUSIVE: the sweeper took SQLITE_BUSY; \
8576 the hand-off assertions did not run"
8577 );
8578 return Ok(());
8579 }
8580 };
8581 let sweep_end = t0 + sweep;
8582 // Attempts actually made inside the measured window, in order.
8583 let inside: Vec<bool> = outcomes
8584 .iter()
8585 .filter(|(t, _)| *t >= t0 && *t < sweep_end)
8586 .map(|(_, ok)| *ok)
8587 .collect();
8588 let attempts = inside.len();
8589 let during = inside.iter().filter(|ok| **ok).count();
8590 // **The longest unbroken run of REFUSED attempts.**
8591 //
8592 // Counted in attempts, not elapsed time — see the note on the assertion
8593 // for why that distinction is the whole point.
8594 let max_refused_run = {
8595 let (mut worst, mut run) = (0usize, 0usize);
8596 for ok in &inside {
8597 run = if *ok { 0 } else { run + 1 };
8598 worst = worst.max(run);
8599 }
8600 worst
8601 };
8602 let why = first_err
8603 .filter(|(t, _)| *t >= t0 && *t < sweep_end)
8604 .map(|(_, e)| format!(" (first in-window write error: {e})"))
8605 .unwrap_or_default();
8606
8607 assert_eq!(deleted as usize, entries.len());
8608 // **The sweep has to BE batched before anything downstream means
8609 // anything, and this floor is derived, not calibrated.**
8610 //
8611 // The fixture is `PRUNE_BATCH * 10` rows, all older than the hard
8612 // ceiling, so the hard-ceiling delete drains them in ten full batches
8613 // and stands down `PRUNE_BATCH_HANDOFF` after each. A genuinely batched
8614 // sweep therefore cannot finish in under `10 * PRUNE_BATCH_HANDOFF` on
8615 // any machine, however fast its disk — the sleeps are a floor the
8616 // hardware cannot undercut, and the deletes themselves only add to it.
8617 //
8618 // A loop that has LOST its batching is faster, not slower: measured at
8619 // 56 ms with the `LIMIT` dropped, against 305 ms batched. That is why
8620 // this fires before the two assertions below — without it, removing the
8621 // batching starves the writer of attempts and gets reported as "invalid
8622 // measurement", blaming the test for the defect it just detected.
8623 //
8624 // **Partial coverage, measured rather than asserted.** Two mutations
8625 // that keep the loop looking roughly batched are caught only sometimes:
8626 //
8627 // `LIMIT` dropped (no batching at all) 3-4 runs in 5-6, MOSTLY by
8628 // the refusal assertion below,
8629 // not by this floor
8630 // `PRUNE_BATCH_HANDOFF` sleep removed 1 run in 5-6, by this floor
8631 //
8632 // (An earlier version attributed both to this floor. Re-measured: of
8633 // four catches of the `LIMIT` mutation in six runs, three panicked at
8634 // the refusal assertion and one here.)
8635 //
8636 // Both were caught more often — 5/5 and 3/5 — by the wall-clock form
8637 // this replaced. That is a real coverage loss and it was taken on
8638 // purpose: the wall-clock form FALSE-FAILED correct code, which is a
8639 // worse defect than missing a deliberate deletion of a commented line.
8640 // See the note on the assertion below for the measurement.
8641 //
8642 // Nothing here is tuned to make those two reliable. Doing so means
8643 // thresholding a rate, which is what this test has now been wrong about
8644 // three separate times.
8645 let handoff_floor = PRUNE_BATCH_HANDOFF * BATCHES as u32;
8646 assert!(
8647 sweep > handoff_floor,
8648 "the delete loop finished in {sweep:?}, under the {handoff_floor:?} that \
8649 {BATCHES} batches of `PRUNE_BATCH_HANDOFF` alone would take — it is not \
8650 handing the write lock over between batches at all"
8651 );
8652 // **Assert a RATIO OF TWO DURATIONS THAT SCALE TOGETHER.**
8653 //
8654 // Three thresholds have now failed here, each for the same reason: they
8655 // compared something machine-scaled against something fixed.
8656 //
8657 // `worst * 3 < sweep` — broke when the sweep got FASTER (the
8658 // `NOT EXISTS` rewrite, 1.49x) and tightened a
8659 // threshold calibrated against the slow version.
8660 // `wrote >= 10` — a raw count is writes-per-unit-time, so it
8661 // measured the runner. Flaked on CI at 4 writes.
8662 // `during * 2 >=` — a success FRACTION, which I claimed was
8663 // `attempts` scale-free. It is not, and this is the
8664 // important one, because the argument sounds
8665 // right. Successes come from the FIXED
8666 // `BATCHES * PRUNE_BATCH_HANDOFF` of open
8667 // window divided by write latency; failures
8668 // come from the machine-scaled lock hold
8669 // divided by the FIXED `WRITER_BUSY_TIMEOUT`.
8670 // Slow the machine by k and the fraction decays
8671 // as roughly 1/(1 + k²c) — quadratically,
8672 // toward failure. Measured with production code
8673 // fully correct and only the per-batch hold
8674 // grown 10x: **188/949 (19.8%) and 383/1028
8675 // (37.3%), two false failures in three runs**,
8676 // at loop durations of 3.4 s. CPU saturation
8677 // cannot find this — it slows writer and
8678 // sweeper together, which is the wrong axis.
8679 //
8680 // `max_gap * 2 <` — the longest WALL-CLOCK stretch with no write
8681 // `sweep` landing, against the loop's duration. Both
8682 // sides scale with the machine, which fixed the
8683 // fraction's problem and introduced a new one:
8684 // a gap opens when the writer is DESCHEDULED
8685 // just as surely as when the lock is held.
8686 // Observed under 4x CPU saturation, full suite:
8687 // `went 319.95ms of 609.83ms` — while **622 of
8688 // 626 attempts landed**. The lock was fine; the
8689 // writer task simply did not run for 320 ms.
8690 //
8691 // So count REFUSALS, not time. The longest unbroken run of `SQLITE_BUSY`
8692 // against the number of attempts made:
8693 //
8694 // handed over : the lock is free for `PRUNE_BATCH_HANDOFF` after every
8695 // batch, so the longest refused run is bounded by about
8696 // one batch's worth of attempts.
8697 // held across : every attempt in the window is refused — 100%.
8698 //
8699 // **The ~10% this comment used to quote for the handed-over case is not
8700 // what the shipped configuration produces.** Measured here: 0.001–0.05,
8701 // and in roughly a quarter of runs the writer is refused ZERO times
8702 // (`max_refused_run == 0`, every attempt landing), so the assertion is
8703 // vacuously true and certifies the hand-off by never observing one. That
8704 // is a weak test, not a wrong one — but it is worth knowing that the
8705 // enormous margin comes from the writer rarely colliding at all, not
8706 // from a measured 10%. 10% is what appears only once the per-batch hold
8707 // dominates the hand-off (`PRUNE_BATCH` x10 gives 0.115–0.143).
8708 //
8709 // This is immune to descheduling in a way no wall-clock measure can be:
8710 // a starved writer makes no attempts, so it contributes to neither side
8711 // of the ratio. Machine speed still cancels, because both sides are
8712 // counts of the same attempts. Re-checked against the failure above:
8713 // 622 of 626 landing means a refused run of at most 4, nowhere near the
8714 // 313 it would take to trip.
8715 //
8716 // **Detection is near all-or-nothing, and that is a known limit rather
8717 // than an oversight.** Holding one transaction across only the FIRST
8718 // HALF of the batches — production code otherwise correct — is not
8719 // caught at all:
8720 //
8721 // batches held in one tx (of 10) runs failing
8722 // 5 0 of 6 (ratios 0.05-0.27)
8723 // 7 2 of 6
8724 // 9 5 of 5
8725 // 10 22 of 22
8726 //
8727 // The ratio systematically UNDERSTATES the wall-clock fraction the lock
8728 // was held, because a refused attempt costs ~2 ms and leaves the sweeper
8729 // running uncontended, while a successful write actively blocks it and
8730 // stretches the loop. So attempts pile up during free time. The
8731 // "10% vs 100%" framing above describes the endpoints, not the curve.
8732 //
8733 // Closing that would mean measuring the wall-clock SPAN of a refusal run
8734 // rather than its length — which is most of the way back to `max_gap`,
8735 // the form that false-failed correct code on a descheduled writer. Given
8736 // this assertion has now been wrong four times in a row, and the current
8737 // one has zero false failures across 134 runs in six environments while
8738 // catching the real defect 22/22, a fifth redesign to catch a
8739 // half-transaction — a mutation no plausible edit produces — is not a
8740 // trade worth making. Stated here so the next reader knows the gap is
8741 // chosen, not missed.
8742 //
8743 // Measured, with the apparatus verified before each run:
8744 //
8745 // correct, 1x / 10x per-batch hold passes
8746 // one tx across batches, 1x CAUGHT — refused 128 of 129
8747 // one tx across batches, 10x CAUGHT — refused 1841 of 1843
8748 // `LIMIT` dropped caught 3 runs in 5 (by the floor)
8749 // hand-off sleep removed caught 1 run in 5 (by the floor)
8750 //
8751 // The last two were 5/5 and 3/5 under the wall-clock form. Losing that
8752 // is the price of not false-failing correct code, and it is the right
8753 // way round: the named defect is now caught by two orders of magnitude,
8754 // and the mutations that got weaker are deliberate deletions of lines
8755 // that carry their own explanation.
8756 assert!(
8757 attempts >= 20,
8758 "the writer only got {attempts} attempts inside a {sweep:?} delete loop \
8759 — too few for the ratio below to mean anything. That is USUALLY an \
8760 invalid measurement rather than a held lock, but note that a loop \
8761 holding the lock throughout is itself one cause of a starved writer, \
8762 so check {during} (landed) before concluding the test is at \
8763 fault{why}"
8764 );
8765 assert!(
8766 max_refused_run * 2 < attempts,
8767 "the delete loop refused {max_refused_run} consecutive write attempts out \
8768 of {attempts} ({during} landed) — a loop that hands the write lock over \
8769 between batches refuses at most about one batch's worth in a row; one \
8770 that holds the lock across them refuses nearly every attempt it sees{why}"
8771 );
8772
8773 pool.close().await;
8774 // `_tmp` removes the database, WAL and shm as it drops — on this path
8775 // and on the unwind from any assertion above.
8776 Ok(())
8777 }
8778
8779 /// **The sparing predicate must quantify over ALL DIDs, not just one.**
8780 ///
8781 /// `entry_state`'s primary key is `(did, entry_id)`, so several readers can
8782 /// hold rows on the same shared entry. The window spares an entry when ANY of
8783 /// them has starred it or left it unread — one person's star protects the
8784 /// cached copy everyone reads.
8785 ///
8786 /// This is the ONLY case where `id NOT IN (…)` and the correlated
8787 /// `NOT EXISTS` that replaced it could diverge, and it had no test. Every
8788 /// other retention test writes one `entry_state` row per entry under a single
8789 /// DID, where the two forms are trivially identical — so the claim that the
8790 /// suite made the equivalence executable was false when it was written. It is
8791 /// true now.
8792 #[tokio::test]
8793 async fn sparing_honours_every_did_not_just_one() -> Result<()> {
8794 let pool = init_url("sqlite::memory:").await?;
8795 let feed_id = upsert_feed(
8796 &pool,
8797 &NewFeed {
8798 url: "https://shared.example/f.xml".to_string(),
8799 ..Default::default()
8800 },
8801 )
8802 .await?;
8803 let old = (chrono::Utc::now() - chrono::Duration::days(400))
8804 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8805 let guids = [
8806 "nobody-touched", // no state row at all -> evicted
8807 "both-read-unstarred", // two DIDs, both read+unstarred -> evicted
8808 "one-starred", // A read+unstarred, B starred -> SPARED by B
8809 "one-unread", // A read+unstarred, B unread -> SPARED by B
8810 ];
8811 let entries: Vec<NewEntry> = guids
8812 .iter()
8813 .map(|g| NewEntry {
8814 guid: (*g).to_string(),
8815 published: Some(old.clone()),
8816 ..Default::default()
8817 })
8818 .collect();
8819 insert_entries(&pool, feed_id, &entries, 0).await?;
8820
8821 let id_of = |g: &'static str| {
8822 let pool = pool.clone();
8823 async move {
8824 sqlx::query_scalar::<_, i64>("SELECT id FROM entries WHERE guid = ?1")
8825 .bind(g)
8826 .fetch_one(&pool)
8827 .await
8828 .unwrap()
8829 }
8830 };
8831 // (did, entry, read, starred)
8832 let rows: [(&str, &'static str, i64, i64); 6] = [
8833 ("did:plc:a", "both-read-unstarred", 1, 0),
8834 ("did:plc:b", "both-read-unstarred", 1, 0),
8835 ("did:plc:a", "one-starred", 1, 0),
8836 ("did:plc:b", "one-starred", 1, 1),
8837 ("did:plc:a", "one-unread", 1, 0),
8838 ("did:plc:b", "one-unread", 0, 0),
8839 ];
8840 for (did, guid, read, starred) in rows {
8841 let id = id_of(guid).await;
8842 sqlx::query(
8843 "INSERT INTO entry_state (did, entry_id, read, starred, updated_at) \
8844 VALUES (?1, ?2, ?3, ?4, '2026-01-01T00:00:00Z')",
8845 )
8846 .bind(did)
8847 .bind(id)
8848 .bind(read)
8849 .bind(starred)
8850 .execute(&pool)
8851 .await?;
8852 }
8853
8854 // Window only — no ceiling, so nothing is swept for age alone.
8855 let deleted = prune_old_entries(&pool, 30, 0, 0).await?;
8856 assert_eq!(
8857 deleted, 2,
8858 "expected the untouched and the all-read entries to go"
8859 );
8860
8861 let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
8862 .fetch_all(&pool)
8863 .await?;
8864 assert_eq!(
8865 left,
8866 vec!["one-starred".to_string(), "one-unread".to_string()],
8867 "a second reader's star or unread mark must spare the SHARED entry"
8868 );
8869 Ok(())
8870 }
8871
8872 /// **A mark-read landing during the scrub must not be overwritten.**
8873 ///
8874 /// Moving the scrub out of the sweep's transaction removed a multi-minute
8875 /// write-lock hold and introduced a lost update in its place: the id-sets
8876 /// were read into a snapshot up front and written back unguarded, so a
8877 /// `mark_read` arriving mid-pass had its id silently dropped — and the
8878 /// rewrite set `dirty = 1`, so the flusher pushed the truncated set to the
8879 /// PDS as authoritative. Local `entry_state` still said read, so the loss was
8880 /// invisible here and visible only in every other atproto client.
8881 ///
8882 /// **⚠️ THIS TEST DOES NOT PROVE THAT, AND THE NAME NO LONGER CLAIMS IT.**
8883 ///
8884 /// The mark-read below lands BEFORE the scrub is called, not during it — so
8885 /// a snapshot-then-write implementation taking its snapshot at the top of
8886 /// `prune_orphan_cursor_ids` would see it too, and pass. The discriminator
8887 /// does not discriminate; what is actually pinned is the ordinary outcome:
8888 /// orphaned ids go, live ids stay.
8889 ///
8890 /// What the lost-update shape is really prevented by is a TYPE fact, not
8891 /// this test: `scrub_one_cursor(pool, did, feed_url)` is handed no id-sets,
8892 /// so it cannot write back anything but what it read itself, and
8893 /// re-introducing the bug means changing its signature.
8894 ///
8895 /// Proving it by test needs a real interleave — hold the write lock on a
8896 /// second connection, let the scrub block on it, commit a `mark_read`, then
8897 /// release — which needs a file-backed database and, without a hook inside
8898 /// the pass, a sleep to be sure the key snapshot has already run. A sleep is
8899 /// how this suite gets flaky in CI, and a flaky test is worse than an honest
8900 /// one, so it is left undone and written down instead.
8901 #[tokio::test]
8902 async fn the_cursor_scrub_drops_orphans_and_keeps_live_ids() -> Result<()> {
8903 let pool = init_url("sqlite::memory:").await?;
8904 let did = "did:plc:race";
8905 let feed_url = "https://race.example/f.xml";
8906 let feed_id = upsert_feed(
8907 &pool,
8908 &NewFeed {
8909 url: feed_url.to_string(),
8910 ..Default::default()
8911 },
8912 )
8913 .await?;
8914 insert_entries(
8915 &pool,
8916 feed_id,
8917 &[
8918 NewEntry {
8919 guid: "live".to_string(),
8920 ..Default::default()
8921 },
8922 NewEntry {
8923 guid: "doomed".to_string(),
8924 ..Default::default()
8925 },
8926 ],
8927 0,
8928 )
8929 .await?;
8930 replace_sub_refs(&pool, did, &[feed_id]).await?;
8931 let live_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'live'")
8932 .fetch_one(&pool)
8933 .await?;
8934 let doomed_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'doomed'")
8935 .fetch_one(&pool)
8936 .await?;
8937
8938 // A cursor holding only the id that is about to be deleted.
8939 upsert_cursor(
8940 &pool,
8941 &ReadCursor {
8942 did: did.to_string(),
8943 feed_url: feed_url.to_string(),
8944 read_through: None,
8945 read_ids: format!("[\"{doomed_id}\"]"),
8946 unread_ids: "[]".to_string(),
8947 dirty: false,
8948 pds_created: false,
8949 updated_at: now_rfc3339(),
8950 },
8951 )
8952 .await?;
8953 sqlx::query("DELETE FROM entries WHERE guid = 'doomed'")
8954 .execute(&pool)
8955 .await?;
8956
8957 // A reader marks the surviving entry read. NOTE this lands before the
8958 // scrub, not during it — see the caveat on this test. It is here because
8959 // the live id must survive the pass, not because it catches the race.
8960 mark_read(&pool, did, live_id, true).await?;
8961
8962 assert_eq!(prune_orphan_cursor_ids(&pool, None).await?, 1);
8963
8964 let cursor = get_cursor(&pool, did, feed_url).await?.expect("cursor");
8965 let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
8966 assert_eq!(
8967 ids,
8968 vec![live_id.to_string()],
8969 "the scrub dropped a live id"
8970 );
8971 assert!(
8972 !ids.contains(&doomed_id.to_string()),
8973 "the orphaned id survived the scrub"
8974 );
8975 Ok(())
8976 }
8977
8978 /// The cursor scrub still happens — it just no longer rides inside the
8979 /// delete transaction. Moving it out is only safe because it is idempotent;
8980 /// this pins that it still runs at all, which is the thing a "move it out"
8981 /// refactor can silently drop.
8982 #[tokio::test]
8983 async fn the_sweep_still_scrubs_orphaned_cursor_ids() -> Result<()> {
8984 let pool = init_url("sqlite::memory:").await?;
8985 let did = "did:plc:scrub";
8986 let feed_url = "https://scrub.example/f.xml";
8987 let feed_id = upsert_feed(
8988 &pool,
8989 &NewFeed {
8990 url: feed_url.to_string(),
8991 ..Default::default()
8992 },
8993 )
8994 .await?;
8995 let old = (chrono::Utc::now() - chrono::Duration::days(400))
8996 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8997 insert_entries(
8998 &pool,
8999 feed_id,
9000 &[NewEntry {
9001 guid: "doomed".to_string(),
9002 published: Some(old),
9003 ..Default::default()
9004 }],
9005 0,
9006 )
9007 .await?;
9008 let doomed = entries_for_feed(&pool, did, feed_id).await;
9009 // `entries_for_feed` is sub_ref-scoped; read the id directly instead.
9010 drop(doomed);
9011 let doomed_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'doomed'")
9012 .fetch_one(&pool)
9013 .await?;
9014
9015 upsert_cursor(
9016 &pool,
9017 &ReadCursor {
9018 did: did.to_string(),
9019 feed_url: feed_url.to_string(),
9020 read_through: None,
9021 read_ids: format!("[\"{doomed_id}\"]"),
9022 unread_ids: "[]".to_string(),
9023 dirty: false,
9024 pds_created: false,
9025 updated_at: now_rfc3339(),
9026 },
9027 )
9028 .await?;
9029
9030 assert_eq!(prune_old_entries(&pool, 30, 180, 0).await?, 1);
9031
9032 let cursor = get_cursor(&pool, did, feed_url).await?.expect("cursor");
9033 let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
9034 assert!(
9035 ids.is_empty(),
9036 "the deleted entry's id survived in the cursor: {ids:?}"
9037 );
9038 assert!(cursor.dirty, "a rewritten cursor must be re-flushed");
9039 Ok(())
9040 }
9041
9042 #[tokio::test]
9043 async fn prune_and_reclaim_drops_db_size() -> Result<()> {
9044 // On-disk DB so VACUUM has a file to shrink.
9045 let dir = std::env::temp_dir();
9046 let path = dir.join(format!("fr-prune-{}.db", std::process::id()));
9047 let url = format!("sqlite://{}", path.display());
9048 let pool = init_url(&url).await?;
9049
9050 let feed_id = upsert_feed(
9051 &pool,
9052 &NewFeed {
9053 url: "https://bulk.example/feed.xml".to_string(),
9054 ..Default::default()
9055 },
9056 )
9057 .await?;
9058 let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
9059 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
9060 let entries: Vec<NewEntry> = (0..2000)
9061 .map(|i| NewEntry {
9062 guid: format!("guid-{i}"),
9063 content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
9064 published: Some(ancient.clone()),
9065 fetched_at: Some(ancient.clone()),
9066 ..Default::default()
9067 })
9068 .collect();
9069 insert_entries(&pool, feed_id, &entries, 0).await?;
9070 let full = db_size_bytes(&pool).await?;
9071 assert!(full > 0);
9072
9073 // A retention sweep prunes every (year-old) entry, then reclaim shrinks.
9074 let deleted = prune_old_entries(&pool, 90, 3650, 0).await?;
9075 assert_eq!(deleted, 2000);
9076 reclaim(&pool).await?;
9077 let after = db_size_bytes(&pool).await?;
9078 assert!(
9079 after < full,
9080 "prune + reclaim must shrink db_size_bytes: {after} !< {full}"
9081 );
9082
9083 drop(pool);
9084 let _ = std::fs::remove_file(&path);
9085 let _ = std::fs::remove_file(format!("{}-wal", path.display()));
9086 let _ = std::fs::remove_file(format!("{}-shm", path.display()));
9087 Ok(())
9088 }
9089
9090 // ---- B1: an existing PRE-0.2.2 invite_codes table (no intended_did) must
9091 // migrate cleanly, not crash-loop boot. ----------------------------------
9092
9093 #[tokio::test]
9094 async fn migrates_pre_intended_did_invite_codes_table() -> Result<()> {
9095 // Build an on-disk DB whose `invite_codes` table has the OLD 0.2.1 shape
9096 // (NO `intended_did` column, and therefore no `intended_did` index), then
9097 // run init_schema/migrations against it — this is exactly the existing-prod
9098 // volume that blocker B1 crash-looped (the SCHEMA's `CREATE INDEX ...
9099 // (intended_did, ...)` fired before the ALTER TABLE added the column).
9100 let dir = std::env::temp_dir();
9101 let path = dir.join(format!("fr-b1-{}.db", std::process::id()));
9102 let url = format!("sqlite://{}", path.display());
9103
9104 // Open a raw pool WITHOUT init_schema and hand-build the old table shape.
9105 let opts = SqliteConnectOptions::from_str(&url)?
9106 .create_if_missing(true)
9107 .foreign_keys(true);
9108 let pool = SqlitePoolOptions::new()
9109 .min_connections(1)
9110 .max_connections(1)
9111 .connect_with(opts)
9112 .await?;
9113 sqlx::query(
9114 r#"CREATE TABLE invite_codes (
9115 code TEXT PRIMARY KEY,
9116 creator_did TEXT NOT NULL,
9117 status TEXT NOT NULL,
9118 invitee_did TEXT,
9119 created_at INTEGER NOT NULL,
9120 expires_at INTEGER NOT NULL,
9121 redeemed_at INTEGER
9122 );"#,
9123 )
9124 .execute(&pool)
9125 .await?;
9126 // Seed a legacy active code so the migration runs against real data.
9127 sqlx::query(
9128 "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
9129 VALUES ('FEATHER-LEGACY00', 'did:plc:old', 'active', 1, 9999999999)",
9130 )
9131 .execute(&pool)
9132 .await?;
9133
9134 // The column is genuinely absent to start with (pre-condition of B1).
9135 let cols: Vec<String> = sqlx::query("PRAGMA table_info(invite_codes)")
9136 .fetch_all(&pool)
9137 .await?
9138 .iter()
9139 .map(|r| r.get::<String, _>("name"))
9140 .collect();
9141 assert!(
9142 !cols.iter().any(|c| c == "intended_did"),
9143 "pre-condition: legacy table must lack intended_did"
9144 );
9145
9146 // THE FIX: init_schema must succeed (not error with "no such column").
9147 init_schema(&pool)
9148 .await
9149 .expect("init_schema on a pre-0.2.2 invite_codes table must not crash");
9150
9151 // Post-condition: the column now exists, both indexes were created, and the
9152 // legacy row is intact.
9153 let cols: Vec<String> = sqlx::query("PRAGMA table_info(invite_codes)")
9154 .fetch_all(&pool)
9155 .await?
9156 .iter()
9157 .map(|r| r.get::<String, _>("name"))
9158 .collect();
9159 assert!(cols.iter().any(|c| c == "intended_did"));
9160 let idx: Vec<String> = sqlx::query(
9161 "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='invite_codes'",
9162 )
9163 .fetch_all(&pool)
9164 .await?
9165 .iter()
9166 .map(|r| r.get::<String, _>("name"))
9167 .collect();
9168 assert!(idx.iter().any(|n| n == "idx_invite_codes_intended"));
9169 assert!(idx.iter().any(|n| n == "idx_invite_codes_intended_active"));
9170
9171 // Idempotent: running it again is a no-op, not an error.
9172 init_schema(&pool)
9173 .await
9174 .expect("re-running init_schema must be idempotent");
9175
9176 // **The OAuth tables must exist too.** They live in this database, and
9177 // creating them only when the Rust backend is selected would make the
9178 // first request after a cutover flip fail with "no such table" -- at the
9179 // one moment nobody wants to find out a migration was missed. They are
9180 // empty and harmless while the sidecar is serving.
9181 let tables: Vec<String> =
9182 sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table'")
9183 .fetch_all(&pool)
9184 .await
9185 .unwrap();
9186 for table in ["oauth_state", "oauth_session", "oauth_nonce"] {
9187 assert!(
9188 tables.iter().any(|t| t == table),
9189 "{table} is missing, so the rust backend would fail on its first request: {tables:?}"
9190 );
9191 }
9192
9193 // The legacy code still redeems (NULL intended_did → open, as before).
9194 let out = redeem_code(&pool, "FEATHER-LEGACY00", "did:plc:new", None, 100).await?;
9195 assert_eq!(out, Ok(()));
9196
9197 drop(pool);
9198 let _ = std::fs::remove_file(&path);
9199 let _ = std::fs::remove_file(format!("{}-wal", path.display()));
9200 let _ = std::fs::remove_file(format!("{}-shm", path.display()));
9201 Ok(())
9202 }
9203
9204 // ---- B2: a code minted FOR a specific DID is redeemable ONLY by that DID. --
9205
9206 #[tokio::test]
9207 async fn redeem_enforces_intended_did_binding() -> Result<()> {
9208 let pool = init_url("sqlite::memory:").await?;
9209 // Mint a claim FOR did:plc:A (the follower the bot posted the link to).
9210 let code = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:A").await?;
9211
9212 // A DIFFERENT DID (a throwaway that stole the public link) is refused as if
9213 // the code didn't exist — no seat granted, code still active.
9214 let stolen = redeem_code(&pool, &code, "did:plc:B", Some("thief.bsky"), 100).await?;
9215 assert_eq!(stolen, Err(RedeemError::NotFound));
9216 assert!(!has_beta_access(&pool, "did:plc:B").await?);
9217 assert_eq!(count_active_codes(&pool).await?, 1, "code must stay active");
9218
9219 // The INTENDED DID redeems successfully.
9220 let ok = redeem_code(&pool, &code, "did:plc:A", Some("alice.bsky"), 100).await?;
9221 assert_eq!(ok, Ok(()));
9222 assert!(has_beta_access(&pool, "did:plc:A").await?);
9223
9224 // A NULL-intended (admin/browser) code stays open to anyone (unchanged).
9225 let open = mint_code(&pool, "did:plc:admin", 3600).await?;
9226 let anyone = redeem_code(&pool, &open, "did:plc:C", None, 100).await?;
9227 assert_eq!(anyone, Ok(()));
9228 assert!(has_beta_access(&pool, "did:plc:C").await?);
9229 Ok(())
9230 }
9231
9232 // ---- S4: at most one ACTIVE code per intended DID; a concurrent second mint
9233 // hits the partial-unique index, and is_intended_active_conflict recognises it.
9234
9235 #[tokio::test]
9236 async fn intended_active_partial_unique_index_blocks_double_mint() -> Result<()> {
9237 let pool = init_url("sqlite::memory:").await?;
9238 // First mint for the DID succeeds.
9239 mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup").await?;
9240 // A SECOND active mint for the SAME DID violates the partial unique index.
9241 let err = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup")
9242 .await
9243 .expect_err("second active mint for the same DID must fail the unique index");
9244 assert!(
9245 is_intended_active_conflict(&err),
9246 "the conflict must be recognised so the web layer can recover: {err:?}"
9247 );
9248 // Still exactly one active code for the DID.
9249 assert!(find_active_code_for_did(&pool, "did:plc:dup")
9250 .await?
9251 .is_some());
9252
9253 // Once the first code is redeemed (no longer active), a fresh mint for the
9254 // DID is allowed again (partial index only constrains active rows).
9255 let existing = find_active_code_for_did(&pool, "did:plc:dup")
9256 .await?
9257 .unwrap();
9258 redeem_code(&pool, &existing, "did:plc:dup", None, 100).await??;
9259 mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup")
9260 .await
9261 .expect("a new mint is allowed after the prior one is redeemed");
9262
9263 // And the conflict helper does NOT fire on an unrelated error (a PRIMARY KEY
9264 // clash on `code`, i.e. a different constraint).
9265 sqlx::query(
9266 "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
9267 VALUES ('FEATHER-DUPEKEY0', 'did:x', 'active', 1, 9999999999)",
9268 )
9269 .execute(&pool)
9270 .await?;
9271 let pk_err = sqlx::query(
9272 "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
9273 VALUES ('FEATHER-DUPEKEY0', 'did:x', 'active', 1, 9999999999)",
9274 )
9275 .execute(&pool)
9276 .await
9277 .expect_err("duplicate PRIMARY KEY must error");
9278 let as_anyhow = anyhow::Error::new(pk_err);
9279 assert!(
9280 !is_intended_active_conflict(&as_anyhow),
9281 "a non-intended-index conflict must NOT be mistaken for the recover-able one"
9282 );
9283 Ok(())
9284 }
9285
9286 #[tokio::test]
9287 async fn purge_expires_orphaned_active_intended_code() -> Result<()> {
9288 // Cheap nit: purging a DID that is the TARGET of an active claim must both
9289 // NULL intended_did AND expire the (now orphaned) active code, so it stops
9290 // counting against the mint cap for its full TTL.
9291 let pool = init_url("sqlite::memory:").await?;
9292 let code = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:leaver").await?;
9293 assert_eq!(count_active_codes(&pool).await?, 1);
9294
9295 purge_did_data(&pool, "did:plc:leaver").await?;
9296
9297 // The code is no longer active (expired), so it no longer counts.
9298 assert_eq!(
9299 count_active_codes(&pool).await?,
9300 0,
9301 "orphaned code must be expired by purge, not left active"
9302 );
9303 // And intended_did was scrubbed.
9304 let intended: Option<String> =
9305 sqlx::query("SELECT intended_did FROM invite_codes WHERE code = ?1")
9306 .bind(&code)
9307 .fetch_one(&pool)
9308 .await?
9309 .get("intended_did");
9310 assert!(intended.is_none(), "intended_did must be NULLed");
9311 Ok(())
9312 }
9313
9314 /// A `(key, source)` observation upserts in place: two writes for the same
9315 /// relay leave ONE row, carrying the newer value.
9316 #[tokio::test]
9317 async fn network_stat_upserts_per_source() -> Result<()> {
9318 let pool = init_url("sqlite::memory:").await?;
9319 let mut stat = NetworkStat {
9320 key: ADOPTION_STAT_KEY.to_string(),
9321 source: "https://relay1.us-west.bsky.network".to_string(),
9322 value: 1,
9323 truncated: false,
9324 observed_at: "2026-08-12T00:00:00Z".to_string(),
9325 };
9326 record_network_stat(&pool, &stat).await?;
9327 stat.value = 4;
9328 stat.observed_at = "2026-08-13T00:00:00Z".to_string();
9329 record_network_stat(&pool, &stat).await?;
9330
9331 let rows: i64 = sqlx::query("SELECT COUNT(*) AS n FROM network_stat")
9332 .fetch_one(&pool)
9333 .await?
9334 .get("n");
9335 assert_eq!(rows, 1, "the same relay must update, not duplicate");
9336 let latest = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9337 .await?
9338 .expect("a stat");
9339 assert_eq!(latest.value, 4);
9340 assert_eq!(latest.observed_at, "2026-08-13T00:00:00Z");
9341 Ok(())
9342 }
9343
9344 /// **Regression (v0.2.9 review).** Once a slow walk can return a PARTIAL
9345 /// count, a plain upsert lets it overwrite a complete, larger one — moving
9346 /// the published "at least N" DOWN because a relay was slow, not because
9347 /// adoption fell. A truncated observation may only ever raise the floor.
9348 #[tokio::test]
9349 async fn a_truncated_observation_never_lowers_a_stored_count() -> Result<()> {
9350 let pool = init_url("sqlite::memory:").await?;
9351 let mut stat = NetworkStat {
9352 key: ADOPTION_STAT_KEY.to_string(),
9353 source: "https://relay1.us-west.bsky.network".to_string(),
9354 value: 2000,
9355 truncated: false,
9356 observed_at: "2026-08-13T00:00:00Z".to_string(),
9357 };
9358 record_network_stat(&pool, &stat).await?;
9359
9360 // A budget-truncated walk that only got one page in.
9361 stat.value = 500;
9362 stat.truncated = true;
9363 stat.observed_at = "2026-08-14T00:00:00Z".to_string();
9364 record_network_stat(&pool, &stat).await?;
9365
9366 let kept = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9367 .await?
9368 .expect("a stat");
9369 assert_eq!(kept.value, 2000, "a partial walk must not lower the count");
9370 assert!(!kept.truncated, "and must not mark the kept row truncated");
9371 assert_eq!(kept.observed_at, "2026-08-13T00:00:00Z");
9372
9373 // A truncated observation that RAISES the floor is still accepted...
9374 stat.value = 3000;
9375 record_network_stat(&pool, &stat).await?;
9376 assert_eq!(
9377 latest_network_stat(&pool, ADOPTION_STAT_KEY)
9378 .await?
9379 .expect("a stat")
9380 .value,
9381 3000
9382 );
9383
9384 // ...and a COMPLETE observation wins even when it is smaller, because
9385 // repos genuinely can go away and a full walk is authoritative.
9386 stat.value = 42;
9387 stat.truncated = false;
9388 record_network_stat(&pool, &stat).await?;
9389 assert_eq!(
9390 latest_network_stat(&pool, ADOPTION_STAT_KEY)
9391 .await?
9392 .expect("a stat")
9393 .value,
9394 42,
9395 "a complete walk is authoritative even when it shrinks"
9396 );
9397
9398 // An EQUAL-valued truncated observation must not downgrade the row
9399 // either: it proves nothing the stored complete count did not already
9400 // prove, but flipping `truncated` would silently degrade /about from
9401 // "42" to "at least 42" with no change in actual adoption. The strict
9402 // `<` in the guard let exactly this through — the equal case is the one
9403 // the two assertions above cannot reach, because both move the value.
9404 stat.truncated = true;
9405 stat.observed_at = "2026-08-15T00:00:00Z".to_string();
9406 record_network_stat(&pool, &stat).await?;
9407 let kept = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9408 .await?
9409 .expect("a stat");
9410 assert_eq!(kept.value, 42);
9411 assert!(
9412 !kept.truncated,
9413 "an equal truncated observation must not mark the kept row truncated"
9414 );
9415 assert_eq!(
9416 kept.observed_at, "2026-08-14T00:00:00Z",
9417 "the rejected observation must not have rewritten the row at all"
9418 );
9419 Ok(())
9420 }
9421
9422 /// Relays disagree by design (non-archival indexes); the max is surfaced.
9423 #[tokio::test]
9424 async fn latest_network_stat_picks_the_max_across_sources() -> Result<()> {
9425 let pool = init_url("sqlite::memory:").await?;
9426 for (source, value, truncated) in [
9427 ("https://relay1.us-west.bsky.network", 2i64, false),
9428 ("https://relay1.us-east.bsky.network", 40i64, true),
9429 ] {
9430 record_network_stat(
9431 &pool,
9432 &NetworkStat {
9433 key: ADOPTION_STAT_KEY.to_string(),
9434 source: source.to_string(),
9435 value,
9436 truncated,
9437 observed_at: "2026-08-13T00:00:00Z".to_string(),
9438 },
9439 )
9440 .await?;
9441 }
9442 let latest = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9443 .await?
9444 .expect("a stat");
9445 assert_eq!(latest.value, 40);
9446 assert_eq!(latest.source, "https://relay1.us-east.bsky.network");
9447 // `truncated` round-trips as a bool.
9448 assert!(latest.truncated);
9449 Ok(())
9450 }
9451
9452 #[tokio::test]
9453 async fn latest_network_stat_is_none_on_an_empty_table() -> Result<()> {
9454 let pool = init_url("sqlite::memory:").await?;
9455 assert!(latest_network_stat(&pool, ADOPTION_STAT_KEY)
9456 .await?
9457 .is_none());
9458 Ok(())
9459 }
9460
9461 /// **The lookup is keyed.** Every existing network-stat test writes only
9462 /// `ADOPTION_STAT_KEY`, so the `WHERE key = ?1` never discriminated; with
9463 /// it widened to `OR 1=1` the suite stayed green. The public `/stats`
9464 /// page asks for the adoption count, and unkeyed it would render the
9465 /// largest value of ANY stat as the network size.
9466 #[tokio::test]
9467 async fn latest_network_stat_ignores_other_keys() -> Result<()> {
9468 let pool = init_url("sqlite::memory:").await?;
9469 for (key, source, value) in [
9470 (ADOPTION_STAT_KEY, "https://relay1.example", 40),
9471 ("some.other.metric", "https://relay1.example", 9_999),
9472 ] {
9473 record_network_stat(
9474 &pool,
9475 &NetworkStat {
9476 key: key.to_string(),
9477 source: source.to_string(),
9478 value,
9479 truncated: false,
9480 observed_at: now_rfc3339(),
9481 },
9482 )
9483 .await?;
9484 }
9485 let latest = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9486 .await?
9487 .expect("the adoption stat was recorded");
9488 assert_eq!(
9489 latest.value, 40,
9490 "another key's value was returned as the adoption count"
9491 );
9492 Ok(())
9493 }
9494
9495 // ── poll health (the public stats page) ─────────────────────────────────
9496
9497 /// Seed a feed row **through the real writer**, so its `kind` is whatever
9498 /// production would store.
9499 ///
9500 /// This used to be a raw `INSERT`, which took the `kind` column's
9501 /// `DEFAULT 'rss'`. That is correct for an http(s) URL and silently wrong
9502 /// for an `at://` one — the helper claimed to seed a row the poller skips
9503 /// while seeding one it selects.
9504 async fn feed_polled(
9505 pool: &SqlitePool,
9506 url: &str,
9507 last_polled: Option<&str>,
9508 next_poll: Option<&str>,
9509 ) {
9510 upsert_feed(
9511 pool,
9512 &NewFeed {
9513 url: url.to_string(),
9514 last_polled: last_polled.map(str::to_string),
9515 next_poll: next_poll.map(str::to_string),
9516 ..Default::default()
9517 },
9518 )
9519 .await
9520 .unwrap();
9521 }
9522
9523 /// The numbers on the public page must describe the poller's actual state.
9524 #[tokio::test]
9525 async fn poll_health_counts_tracked_recent_and_overdue() -> anyhow::Result<()> {
9526 let pool = init_url("sqlite::memory:").await?;
9527 let now = "2026-01-01T12:00:00Z";
9528 let hour_ago = "2026-01-01T11:00:00Z";
9529
9530 // Polled 10 minutes ago, due in 50 minutes: healthy.
9531 feed_polled(
9532 &pool,
9533 "https://a.example/f",
9534 Some("2026-01-01T11:50:00Z"),
9535 Some("2026-01-01T12:50:00Z"),
9536 )
9537 .await;
9538 // Polled 3 hours ago and overdue: the backlog case.
9539 feed_polled(
9540 &pool,
9541 "https://b.example/f",
9542 Some("2026-01-01T09:00:00Z"),
9543 Some("2026-01-01T10:00:00Z"),
9544 )
9545 .await;
9546 // Never polled: counts as overdue (next_poll IS NULL), and must not
9547 // corrupt the "oldest poll" figure with a NULL.
9548 feed_polled(&pool, "https://c.example/f", None, None).await;
9549
9550 let h = poll_health(&pool, now, hour_ago).await?;
9551 assert_eq!(h.feeds_tracked, 3);
9552 assert_eq!(
9553 h.polled_last_hour, 1,
9554 "only the 11:50 poll is within the hour"
9555 );
9556 assert_eq!(h.overdue, 2, "the stale feed and the never-polled one");
9557 assert_eq!(
9558 h.last_poll_secs_ago,
9559 Some(600),
9560 "most recent poll was 10 minutes ago"
9561 );
9562 // **A never-polled feed IS the worst staleness.**
9563 //
9564 // This originally asserted `Some(10_800)` — the oldest FINITE age — and
9565 // in doing so pinned a defect: `MIN` skips NULLs, so the page reported
9566 // "3h ago" while a quarter of the feeds had never been fetched at all.
9567 // The figure read healthiest in the most degraded state, which is the
9568 // opposite of what a health page is for.
9569 assert_eq!(
9570 h.oldest_poll_secs_ago, None,
9571 "a never-polled feed must outrank any finite age"
9572 );
9573 assert_eq!(h.never_polled, 1);
9574
9575 // With every feed polled, the finite worst case is reported again.
9576 sqlx::query("UPDATE feeds SET last_polled = ?1 WHERE last_polled IS NULL")
9577 .bind("2026-01-01T09:00:00Z")
9578 .execute(&pool)
9579 .await?;
9580 let h = poll_health(&pool, now, hour_ago).await?;
9581 assert_eq!(h.never_polled, 0);
9582 assert_eq!(h.oldest_poll_secs_ago, Some(10_800));
9583 Ok(())
9584 }
9585
9586 /// **`/stats` measures the poller, so it counts only what the poller sees.**
9587 ///
9588 /// `due_feeds` skips `at://` rows; nothing ever advances their `next_poll`
9589 /// or sets `last_polled`. Counted, they read as overdue and never-polled
9590 /// forever, and force "oldest poll" to `never` — the same "unsupported
9591 /// shown as broken" the exclusion exists to end, moved to different rows on
9592 /// a public page. The same predicate decides both queries so they cannot
9593 /// drift.
9594 #[tokio::test]
9595 async fn poll_health_ignores_unpollable_at_uri_rows() -> anyhow::Result<()> {
9596 let pool = init_url("sqlite::memory:").await?;
9597 let now = "2026-01-01T12:00:00Z";
9598 let hour_ago = "2026-01-01T11:00:00Z";
9599 feed_polled(
9600 &pool,
9601 "https://a.example/f",
9602 Some("2026-01-01T11:50:00Z"),
9603 Some("2026-01-01T12:50:00Z"),
9604 )
9605 .await;
9606 // Never polled, never due: the shape every at:// row has.
9607 feed_polled(
9608 &pool,
9609 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
9610 None,
9611 None,
9612 )
9613 .await;
9614
9615 let h = poll_health(&pool, now, hour_ago).await?;
9616 assert_eq!(
9617 h.feeds_tracked, 1,
9618 "an unpollable row was counted as tracked"
9619 );
9620 assert_eq!(h.overdue, 0, "an unpollable row was counted as overdue");
9621 assert_eq!(
9622 h.never_polled, 0,
9623 "an unpollable row was counted as never polled"
9624 );
9625 assert_eq!(
9626 h.oldest_poll_secs_ago,
9627 Some(600),
9628 "an unpollable row forced the oldest poll to `never`"
9629 );
9630 assert_eq!(h.polled_last_hour, 1);
9631 Ok(())
9632 }
9633
9634 /// **The admin's failing-feeds list is the poller's too.** `failing_feeds`
9635 /// feeds `/admin/metrics`; it was not given the exclusion both `/stats`
9636 /// queries got. An `at://` row that carries errors — from a rollback to a
9637 /// build that polled them, say — would then sit at the top of the one page
9638 /// an operator uses to diagnose "unsupported shown as broken", with no
9639 /// poll ever coming to clear it and the one-shot migration already spent.
9640 #[tokio::test]
9641 async fn failing_feeds_ignores_unpollable_at_uri_rows() -> anyhow::Result<()> {
9642 let pool = init_url("sqlite::memory:").await?;
9643 for url in [
9644 "https://broken.example/feed.xml",
9645 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
9646 ] {
9647 upsert_feed(
9648 &pool,
9649 &NewFeed {
9650 url: url.to_string(),
9651 ..Default::default()
9652 },
9653 )
9654 .await?;
9655 bump_feed_errors(&pool, url, crate::feed::FailureKind::Fetch, "down").await?;
9656 }
9657 let failing = failing_feeds(&pool, 10).await?;
9658 let urls: Vec<&str> = failing.iter().map(|f| f.url.as_str()).collect();
9659 assert_eq!(
9660 urls,
9661 vec!["https://broken.example/feed.xml"],
9662 "an unpollable row was listed as a failing feed"
9663 );
9664 Ok(())
9665 }
9666
9667 /// **The clearing is idempotent by predicate, not by stamp.** It touches
9668 /// only rows that have never been polled successfully: `bump_feed_errors`
9669 /// never sets `last_polled`, both success paths do. So a row a wired
9670 /// reader has fetched once keeps its later failures across restarts, and
9671 /// a row that only ever failed under our own refusal is cleared at every
9672 /// boot — including after a rollback to a build that polled it. No
9673 /// version stamp, nothing for a test to rewind.
9674 #[tokio::test]
9675 async fn the_at_uri_error_clearing_spares_a_row_that_has_been_polled() -> anyhow::Result<()> {
9676 let pool = init_url("sqlite::memory:").await?;
9677 let polled = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/polled";
9678 let never = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/never";
9679 for url in [polled, never] {
9680 upsert_feed(
9681 &pool,
9682 &NewFeed {
9683 url: url.to_string(),
9684 ..Default::default()
9685 },
9686 )
9687 .await?;
9688 bump_feed_errors(&pool, url, crate::feed::FailureKind::Fetch, "down").await?;
9689 }
9690 // A wired reader fetched this one once, then it started failing.
9691 sqlx::query("UPDATE feeds SET last_polled = '2026-01-01T00:00:00Z' WHERE url = ?1")
9692 .bind(polled)
9693 .execute(&pool)
9694 .await?;
9695
9696 for boot in 1..=2 {
9697 apply_migrations(&pool).await?;
9698 let mut errors = std::collections::HashMap::new();
9699 for url in [polled, never] {
9700 let n: i64 =
9701 sqlx::query_scalar("SELECT consecutive_errors FROM feeds WHERE url = ?1")
9702 .bind(url)
9703 .fetch_one(&pool)
9704 .await?;
9705 errors.insert(url, n);
9706 }
9707 assert_eq!(
9708 errors[polled], 1,
9709 "boot {boot} wiped a polled row's failure"
9710 );
9711 assert_eq!(
9712 errors[never], 0,
9713 "boot {boot} left a never-polled row failing"
9714 );
9715 }
9716 Ok(())
9717 }
9718
9719 /// **The SQL kind list and the Rust one are the same list.** A literal in
9720 /// SQL and a slice in Rust is the drift the column exists to end; wiring
9721 /// the standard.site reader changes both, and this is what makes
9722 /// forgetting one a failure rather than a silently dormant feature.
9723 #[test]
9724 fn the_sql_kind_list_matches_the_rust_one() {
9725 let expected = crate::feed::FeedKind::POLLABLE
9726 .iter()
9727 .map(|k| format!("'{}'", k.as_str()))
9728 .collect::<Vec<_>>()
9729 .join(", ");
9730 assert_eq!(POLLABLE_KINDS_SQL, expected);
9731 }
9732
9733 /// **A feed's kind is recorded at insert, not re-derived from its URL.**
9734 ///
9735 /// "Can the poller fetch this?" was a substring predicate spliced into
9736 /// four statements, and a review found a fifth reader that had drifted
9737 /// from it. A column the writers set cannot drift: the Rust side decides
9738 /// once, SQL reads a value.
9739 #[tokio::test]
9740 async fn a_feed_row_records_its_kind_at_insert() -> anyhow::Result<()> {
9741 let pool = init_url("sqlite::memory:").await?;
9742 for (url, want) in [
9743 ("https://real.example/feed.xml", crate::feed::FeedKind::Rss),
9744 ("http://real.example/feed.xml", crate::feed::FeedKind::Rss),
9745 (
9746 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
9747 crate::feed::FeedKind::Publication,
9748 ),
9749 ] {
9750 upsert_feed(
9751 &pool,
9752 &NewFeed {
9753 url: url.to_string(),
9754 ..Default::default()
9755 },
9756 )
9757 .await?;
9758 let got: String = sqlx::query_scalar("SELECT kind FROM feeds WHERE url = ?1")
9759 .bind(url)
9760 .fetch_one(&pool)
9761 .await?;
9762 assert_eq!(got, want.as_str(), "wrong kind recorded for {url}");
9763 }
9764 Ok(())
9765 }
9766
9767 /// **A row written before the column existed is back-filled from its URL.**
9768 /// That back-fill is the LAST use of the string predicate; every reader
9769 /// keys on `kind` afterwards.
9770 #[tokio::test]
9771 async fn the_migration_backfills_kind_from_the_url() -> anyhow::Result<()> {
9772 let pool = init_url("sqlite::memory:").await?;
9773 // A table that predates the column, with both shapes in it.
9774 sqlx::query("DROP TABLE feeds").execute(&pool).await?;
9775 sqlx::query(
9776 "CREATE TABLE feeds (
9777 id INTEGER PRIMARY KEY AUTOINCREMENT,
9778 url TEXT NOT NULL UNIQUE,
9779 title TEXT, site_url TEXT, etag TEXT, last_modified TEXT,
9780 last_polled TEXT, next_poll TEXT,
9781 consecutive_errors INTEGER NOT NULL DEFAULT 0,
9782 last_error_kind TEXT, last_error TEXT
9783 )",
9784 )
9785 .execute(&pool)
9786 .await?;
9787 for url in [
9788 "https://real.example/feed.xml",
9789 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
9790 "At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lac",
9791 ] {
9792 sqlx::query("INSERT INTO feeds (url) VALUES (?1)")
9793 .bind(url)
9794 .execute(&pool)
9795 .await?;
9796 }
9797
9798 apply_migrations(&pool).await?;
9799
9800 let kinds: Vec<(String, String)> =
9801 sqlx::query_as("SELECT url, kind FROM feeds ORDER BY url")
9802 .fetch_all(&pool)
9803 .await?;
9804 let by_url: std::collections::HashMap<_, _> = kinds.into_iter().collect();
9805 assert_eq!(by_url["https://real.example/feed.xml"], "rss");
9806 assert_eq!(
9807 by_url["at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab"],
9808 "publication"
9809 );
9810 assert_eq!(
9811 by_url["At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lac"],
9812 "publication",
9813 "the back-fill must recognise a non-canonical spelling, like every other guard"
9814 );
9815 Ok(())
9816 }
9817
9818 /// **`feeds.kind` is derived from the URL, so it has to be re-derivable.**
9819 ///
9820 /// The back-fill translated one direction only — a row the Rust side would
9821 /// call `rss` was never touched — which is correct for a one-time migration
9822 /// and wrong for a column that has to survive the rule changing. A kind that
9823 /// disagrees with its own URL is currently permanent: nothing re-reads it.
9824 #[tokio::test]
9825 async fn the_back_fill_corrects_a_kind_that_disagrees_with_the_url() -> anyhow::Result<()> {
9826 let pool = init_url("sqlite::memory:").await?;
9827 let at = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
9828 for (url, wrong) in [
9829 ("https://real.example/feed.xml", "publication"),
9830 (at, "rss"),
9831 ] {
9832 sqlx::query("INSERT INTO feeds (url, kind) VALUES (?1, ?2)")
9833 .bind(url)
9834 .bind(wrong)
9835 .execute(&pool)
9836 .await?;
9837 }
9838
9839 apply_migrations(&pool).await?;
9840
9841 let by_url: std::collections::HashMap<String, String> =
9842 sqlx::query_as("SELECT url, kind FROM feeds")
9843 .fetch_all(&pool)
9844 .await?
9845 .into_iter()
9846 .collect();
9847 assert_eq!(
9848 by_url["https://real.example/feed.xml"], "rss",
9849 "an http feed marked as a publication stayed one, and nothing polls it"
9850 );
9851 assert_eq!(by_url[at], "publication", "the at:// direction regressed");
9852 Ok(())
9853 }
9854
9855 /// **Taking a row out of the poller orphans its poll state, so clear it.**
9856 ///
9857 /// `last_polled` is set here on purpose: the migration's other cleanup step
9858 /// only clears rows we never polled, so a row that HAS been polled proves
9859 /// this reset is the one doing the work. An error count left on a row the
9860 /// scheduler will never select again is hidden from `/stats`, which filters
9861 /// on kind — and if a later rule change readmits the row, it resumes at a
9862 /// backoff earned under a classification that no longer applies.
9863 #[tokio::test]
9864 async fn a_row_taken_out_of_the_poller_loses_the_poll_state_it_cannot_use() -> anyhow::Result<()>
9865 {
9866 let pool = init_url("sqlite::memory:").await?;
9867 let at = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
9868 sqlx::query(
9869 "INSERT INTO feeds (url, kind, consecutive_errors, last_error_kind, last_error, \
9870 next_poll, last_polled) \
9871 VALUES (?1, 'rss', 7, 'fetch', 'connection refused', ?2, ?3)",
9872 )
9873 .bind(at)
9874 .bind("2026-09-10T00:00:00Z")
9875 .bind("2026-09-01T00:00:00Z")
9876 .execute(&pool)
9877 .await?;
9878
9879 apply_migrations(&pool).await?;
9880
9881 let (kind, errors, error_kind, error, next_poll): (
9882 String,
9883 i64,
9884 Option<String>,
9885 Option<String>,
9886 Option<String>,
9887 ) = sqlx::query_as(
9888 "SELECT kind, consecutive_errors, last_error_kind, last_error, next_poll \
9889 FROM feeds WHERE url = ?1",
9890 )
9891 .bind(at)
9892 .fetch_one(&pool)
9893 .await?;
9894 assert_eq!(kind, "publication", "the row was not reclassified at all");
9895 assert_eq!(
9896 (errors, error_kind, error, next_poll),
9897 (0, None, None, None),
9898 "a row the scheduler will never select again kept its backoff and failure history"
9899 );
9900 Ok(())
9901 }
9902
9903 /// **A row we cannot read must not stop the process from starting.**
9904 ///
9905 /// This runs on the boot path. Refusing to start is a strictly worse
9906 /// outcome than declining to have an opinion about one row, and it is a
9907 /// failure mode the SQL predicate this replaced did not have: it evaluated
9908 /// a non-text `url` happily and returned false.
9909 #[tokio::test]
9910 async fn an_unreadable_feeds_row_does_not_stop_the_boot() -> anyhow::Result<()> {
9911 let pool = init_url("sqlite::memory:").await?;
9912 sqlx::query("INSERT INTO feeds (url, kind) VALUES (X'ff41', 'rss')")
9913 .execute(&pool)
9914 .await?;
9915 sqlx::query("INSERT INTO feeds (url, kind) VALUES (?1, 'publication')")
9916 .bind("https://real.example/feed.xml")
9917 .execute(&pool)
9918 .await?;
9919
9920 apply_migrations(&pool).await?;
9921
9922 let corrected: String = sqlx::query_scalar("SELECT kind FROM feeds WHERE url = ?1")
9923 .bind("https://real.example/feed.xml")
9924 .fetch_one(&pool)
9925 .await?;
9926 assert_eq!(
9927 corrected, "rss",
9928 "one unreadable row aborted the pass before the readable ones were corrected"
9929 );
9930 let untouched: String =
9931 sqlx::query_scalar("SELECT kind FROM feeds WHERE typeof(url) = 'blob'")
9932 .fetch_one(&pool)
9933 .await?;
9934 assert_eq!(
9935 untouched, "rss",
9936 "a row we declined to classify was classified anyway"
9937 );
9938 Ok(())
9939 }
9940
9941 /// Re-subscribing must re-derive the kind, not preserve whatever is there.
9942 ///
9943 /// `upsert_feed` binds `FeedKind::of` on the way in, but its conflict clause
9944 /// never carried `kind`, so the value a row was first written with is the
9945 /// value it keeps. Harmless while the rule is fixed; the rule is about to
9946 /// change.
9947 #[tokio::test]
9948 async fn a_re_upsert_re_derives_the_kind() -> anyhow::Result<()> {
9949 let pool = init_url("sqlite::memory:").await?;
9950 let url = "https://real.example/feed.xml";
9951 let feed = NewFeed {
9952 url: url.to_string(),
9953 ..Default::default()
9954 };
9955 upsert_feed(&pool, &feed).await?;
9956 sqlx::query("UPDATE feeds SET kind = 'publication' WHERE url = ?1")
9957 .bind(url)
9958 .execute(&pool)
9959 .await?;
9960
9961 upsert_feed(&pool, &feed).await?;
9962
9963 let kind: String = sqlx::query_scalar("SELECT kind FROM feeds WHERE url = ?1")
9964 .bind(url)
9965 .fetch_one(&pool)
9966 .await?;
9967 assert_eq!(
9968 kind, "rss",
9969 "a second subscription to the same URL kept the stale classification"
9970 );
9971 Ok(())
9972 }
9973
9974 /// **The readers key on `kind`, not on the URL.** A row whose kind says
9975 /// publication is unpollable even if its URL looks ordinary — which is
9976 /// what makes the column, rather than the string, the source of truth.
9977 #[tokio::test]
9978 async fn the_poller_and_the_pages_key_on_kind() -> anyhow::Result<()> {
9979 let pool = init_url("sqlite::memory:").await?;
9980 upsert_feed(
9981 &pool,
9982 &NewFeed {
9983 url: "https://looks-ordinary.example/feed.xml".to_string(),
9984 ..Default::default()
9985 },
9986 )
9987 .await?;
9988 // Force the kind independently of the URL: only the column should matter.
9989 sqlx::query("UPDATE feeds SET kind = 'publication' WHERE url LIKE 'https://looks%'")
9990 .execute(&pool)
9991 .await?;
9992
9993 let due = due_feeds(&pool, "2026-01-01T12:00:00Z", 10).await?;
9994 assert!(due.is_empty(), "due_feeds read the URL, not the kind");
9995 assert_eq!(
9996 unpollable_feeds(&pool).await?,
9997 1,
9998 "unpollable_feeds read the URL"
9999 );
10000
10001 let h = poll_health(&pool, "2026-01-01T12:00:00Z", "2026-01-01T11:00:00Z").await?;
10002 assert_eq!(h.feeds_tracked, 0, "poll_health read the URL, not the kind");
10003 Ok(())
10004 }
10005
10006 /// **SQL and Rust agree on what an at-URI is — case-insensitively.**
10007 ///
10008 /// This test used to pin the opposite, and pinned a bug. It asserted that a
10009 /// mixed-case `At://` row IS handed to the poller, reasoning that the Rust
10010 /// guards use a case-sensitive `strip_prefix` so "every other check treats
10011 /// it as a plain URL". They do not: URL schemes are case-insensitive, so
10012 /// `Url::parse` folds `At://` to scheme `at`, which `net::check_scheme`
10013 /// refuses — and the DID form does not parse at all. Such a row can only
10014 /// fail, every tick, forever, and be published in the `fetch` bucket as an
10015 /// unreachable publisher. That is the exact conflation the exclusion exists
10016 /// to end.
10017 ///
10018 /// Recognition is case-insensitive on both sides now. Storing one is still
10019 /// refused: `feeds.url` is UNIQUE, so two spellings of one publication are
10020 /// two rows — the same rule the canonical-handle check applies.
10021 #[tokio::test]
10022 async fn a_mixed_case_at_uri_is_unpollable_on_both_sides() -> anyhow::Result<()> {
10023 let pool = init_url("sqlite::memory:").await?;
10024 let odd = "At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
10025 assert!(
10026 !crate::feed::is_storable_feed_url(odd, true),
10027 "a non-canonical spelling must not be storable"
10028 );
10029 upsert_feed(
10030 &pool,
10031 &NewFeed {
10032 url: odd.to_string(),
10033 ..Default::default()
10034 },
10035 )
10036 .await?;
10037 let due = due_feeds(&pool, "2026-01-01T12:00:00Z", 10).await?;
10038 assert!(
10039 due.is_empty(),
10040 "a row nothing can fetch was handed to the poller: {:?}",
10041 due.iter().map(|f| &f.url).collect::<Vec<_>>()
10042 );
10043
10044 // And the boot-time clearing reaches it, so a legacy row that already
10045 // accrued errors stops counting as a broken publisher.
10046 bump_feed_errors(&pool, odd, crate::feed::FailureKind::Fetch, "refused").await?;
10047 apply_migrations(&pool).await?;
10048 let n: i64 = sqlx::query_scalar("SELECT consecutive_errors FROM feeds WHERE url = ?1")
10049 .bind(odd)
10050 .fetch_one(&pool)
10051 .await?;
10052 assert_eq!(n, 0, "the clearing skipped a mixed-case at-URI row");
10053 Ok(())
10054 }
10055
10056 /// **The global feeds ceiling counts every row, including unpollable ones
10057 /// — deliberately, and visibly.**
10058 ///
10059 /// `count_feeds` is a fifth reader of "is this an at-URI" that does NOT use
10060 /// the unpollable kinds, and that is the right call: the ceiling bounds
10061 /// STORAGE on a small box, and an unpollable row occupies a row. What was
10062 /// wrong is that the capacity it consumed appeared on no surface — `/stats`
10063 /// measures the poller and excludes them, so an operator could be at the
10064 /// cap while every page said otherwise. `unpollable_feeds` is what
10065 /// `/admin/metrics` renders to close that gap.
10066 #[tokio::test]
10067 async fn the_ceiling_counts_unpollable_rows_and_they_are_countable() -> anyhow::Result<()> {
10068 let pool = init_url("sqlite::memory:").await?;
10069 for url in [
10070 "https://real.example/feed.xml",
10071 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
10072 "At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lac",
10073 ] {
10074 upsert_feed(
10075 &pool,
10076 &NewFeed {
10077 url: url.to_string(),
10078 ..Default::default()
10079 },
10080 )
10081 .await?;
10082 }
10083 assert_eq!(
10084 count_feeds(&pool).await?,
10085 3,
10086 "the ceiling must bound storage, so every row counts"
10087 );
10088 assert_eq!(
10089 unpollable_feeds(&pool).await?,
10090 2,
10091 "both at-URI spellings are unpollable and must be countable"
10092 );
10093 Ok(())
10094 }
10095
10096 /// A fresh instance has no polls yet. The page must say so rather than
10097 /// rendering a zero that reads as "polled just now".
10098 #[tokio::test]
10099 async fn poll_health_on_an_empty_instance_reports_no_polls() -> anyhow::Result<()> {
10100 let pool = init_url("sqlite::memory:").await?;
10101 let h = poll_health(&pool, "2026-01-01T12:00:00Z", "2026-01-01T11:00:00Z").await?;
10102 assert_eq!(h.feeds_tracked, 0);
10103 assert_eq!(h.last_poll_secs_ago, None);
10104 assert_eq!(h.oldest_poll_secs_ago, None);
10105 Ok(())
10106 }
10107
10108 /// A poll timestamped in the future — clock skew, or a restored backup —
10109 /// reads as "just now", never as a negative age.
10110 #[tokio::test]
10111 async fn a_future_poll_timestamp_does_not_go_negative() -> anyhow::Result<()> {
10112 let pool = init_url("sqlite::memory:").await?;
10113 feed_polled(
10114 &pool,
10115 "https://a.example/f",
10116 Some("2026-01-01T13:00:00Z"),
10117 None,
10118 )
10119 .await;
10120 let h = poll_health(&pool, "2026-01-01T12:00:00Z", "2026-01-01T11:00:00Z").await?;
10121 assert_eq!(h.last_poll_secs_ago, Some(0));
10122 Ok(())
10123 }
10124
10125 // ── retention is a CACHE policy, not a data-retention policy ────────────
10126
10127 async fn aged_entry(pool: &SqlitePool, url: &str, days_old: i64) -> i64 {
10128 let when = (chrono::Utc::now() - chrono::Duration::days(days_old))
10129 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
10130 sqlx::query("INSERT INTO feeds (url) VALUES (?1) ON CONFLICT(url) DO NOTHING")
10131 .bind("https://f.example/feed")
10132 .execute(pool)
10133 .await
10134 .unwrap();
10135 let feed_id: i64 = sqlx::query_scalar("SELECT id FROM feeds WHERE url = ?1")
10136 .bind("https://f.example/feed")
10137 .fetch_one(pool)
10138 .await
10139 .unwrap();
10140 sqlx::query("INSERT INTO entries (feed_id, guid, url, title, published, fetched_at) VALUES (?1,?2,?3,'t',?4,?4)")
10141 .bind(feed_id).bind(url).bind(url).bind(&when)
10142 .execute(pool).await.unwrap();
10143 sqlx::query_scalar("SELECT id FROM entries WHERE guid = ?1")
10144 .bind(url)
10145 .fetch_one(pool)
10146 .await
10147 .unwrap()
10148 }
10149
10150 async fn mark(pool: &SqlitePool, entry_id: i64, read: i64, starred: i64) {
10151 sqlx::query("INSERT INTO entry_state (did, entry_id, read, starred, updated_at) VALUES ('did:plc:x',?1,?2,?3,'2026-01-01T00:00:00Z')")
10152 .bind(entry_id).bind(read).bind(starred)
10153 .execute(pool).await.unwrap();
10154 }
10155
10156 /// **A STARRED article is never evicted, however old.**
10157 ///
10158 /// The starred view joins `entries`, and `entry_state` cascades on delete,
10159 /// so pruning a starred entry removed it from the starred list entirely —
10160 /// and the content is not recoverable, because a feed serves only its last
10161 /// few dozen items. The PDS keeps the saved RECORD; it has never held the
10162 /// article.
10163 #[tokio::test]
10164 async fn retention_keeps_starred_and_unread_entries() -> anyhow::Result<()> {
10165 let pool = init_url("sqlite::memory:").await?;
10166 let old_read = aged_entry(&pool, "old-read", 30).await;
10167 let old_starred = aged_entry(&pool, "old-starred", 30).await;
10168 let old_unread = aged_entry(&pool, "old-unread", 30).await;
10169 let recent_read = aged_entry(&pool, "recent-read", 1).await;
10170 mark(&pool, old_read, 1, 0).await;
10171 mark(&pool, old_starred, 1, 1).await; // read AND starred
10172 mark(&pool, old_unread, 0, 0).await;
10173 mark(&pool, recent_read, 1, 0).await;
10174
10175 let deleted = prune_old_entries(&pool, 14, 3650, 0).await?;
10176 assert_eq!(deleted, 1, "only the old, read, unstarred entry should go");
10177
10178 let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
10179 .fetch_all(&pool)
10180 .await?;
10181 assert_eq!(left, vec!["old-starred", "old-unread", "recent-read"]);
10182 Ok(())
10183 }
10184
10185 /// An entry nobody has interacted with at all — no `entry_state` row — is
10186 /// still evicted once it ages out. Otherwise the cache never shrinks, since
10187 /// most entries are never opened.
10188 #[tokio::test]
10189 async fn retention_evicts_entries_with_no_reader_state() -> anyhow::Result<()> {
10190 let pool = init_url("sqlite::memory:").await?;
10191 aged_entry(&pool, "untouched-old", 30).await;
10192 aged_entry(&pool, "untouched-new", 1).await;
10193 assert_eq!(prune_old_entries(&pool, 14, 3650, 0).await?, 1);
10194 Ok(())
10195 }
10196
10197 /// **A recently-polled feed is NOT made due again.**
10198 ///
10199 /// `due_feeds` treats NULL as due immediately, so an unbounded nudge from a
10200 /// page handler turned every reload of the starred view into another poll of
10201 /// those feeds — outbound amplification against third-party origins, and one
10202 /// reader monopolising a poll budget that is shared and already the binding
10203 /// constraint on user count.
10204 #[tokio::test]
10205 async fn a_recently_polled_feed_is_not_nudged_again() -> anyhow::Result<()> {
10206 let pool = init_url("sqlite::memory:").await?;
10207 let recent = "2026-01-01T11:59:00Z";
10208 let stale_before = "2026-01-01T11:00:00Z"; // one hour before "now"
10209
10210 sqlx::query("INSERT INTO feeds (url, last_polled, next_poll) VALUES (?1, ?2, ?3)")
10211 .bind("https://fresh.example/f")
10212 .bind(recent)
10213 .bind("2026-01-01T12:59:00Z")
10214 .execute(&pool)
10215 .await?;
10216 // Polled long ago: this one SHOULD be nudged.
10217 sqlx::query("INSERT INTO feeds (url, last_polled, next_poll) VALUES (?1, ?2, ?3)")
10218 .bind("https://stale.example/f")
10219 .bind("2026-01-01T06:00:00Z")
10220 .bind("2026-01-01T07:00:00Z")
10221 .execute(&pool)
10222 .await?;
10223
10224 mark_feed_due(&pool, "https://fresh.example/f", stale_before).await?;
10225 mark_feed_due(&pool, "https://stale.example/f", stale_before).await?;
10226
10227 let fresh: Option<String> =
10228 sqlx::query_scalar("SELECT next_poll FROM feeds WHERE url = 'https://fresh.example/f'")
10229 .fetch_one(&pool)
10230 .await?;
10231 let stale: Option<String> =
10232 sqlx::query_scalar("SELECT next_poll FROM feeds WHERE url = 'https://stale.example/f'")
10233 .fetch_one(&pool)
10234 .await?;
10235
10236 assert!(
10237 fresh.is_some(),
10238 "a feed polled a minute ago was made due again — a reload loop is an \
10239 amplification vector"
10240 );
10241 assert!(stale.is_none(), "a long-unpolled feed should be nudged");
10242 Ok(())
10243 }
10244
10245 /// A feed that has never been polled is always nudgeable — there is no
10246 /// recent fetch to argue it would be wasted.
10247 #[tokio::test]
10248 async fn a_never_polled_feed_is_nudged() -> anyhow::Result<()> {
10249 let pool = init_url("sqlite::memory:").await?;
10250 sqlx::query("INSERT INTO feeds (url, last_polled, next_poll) VALUES (?1, NULL, ?2)")
10251 .bind("https://new.example/f")
10252 .bind("2026-01-01T12:59:00Z")
10253 .execute(&pool)
10254 .await?;
10255 mark_feed_due(&pool, "https://new.example/f", "2026-01-01T11:00:00Z").await?;
10256 let next: Option<String> =
10257 sqlx::query_scalar("SELECT next_poll FROM feeds WHERE url = 'https://new.example/f'")
10258 .fetch_one(&pool)
10259 .await?;
10260 assert!(next.is_none());
10261 Ok(())
10262 }
10263
10264 /// **The hard ceiling is the bound that sparing would otherwise remove.**
10265 ///
10266 /// "Mark unread" is a one-click control and `entries` is shared across every
10267 /// reader, so an unbounded `read = 0` exception lets one person pin rows
10268 /// permanently — and since the poller stops entirely above
10269 /// `db_size_watermark_bytes` with this DELETE as its only release valve,
10270 /// those pins could stop polling for everyone.
10271 #[tokio::test]
10272 async fn the_hard_ceiling_evicts_even_starred_and_unread() -> anyhow::Result<()> {
10273 let pool = init_url("sqlite::memory:").await?;
10274 let ancient_starred = aged_entry(&pool, "ancient-starred", 400).await;
10275 let ancient_unread = aged_entry(&pool, "ancient-unread", 400).await;
10276 let recent_starred = aged_entry(&pool, "recent-starred", 30).await;
10277 mark(&pool, ancient_starred, 1, 1).await;
10278 mark(&pool, ancient_unread, 0, 0).await;
10279 mark(&pool, recent_starred, 1, 1).await;
10280
10281 // 14-day soft window, 180-day hard ceiling.
10282 prune_old_entries(&pool, 14, 180, 0).await?;
10283
10284 let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
10285 .fetch_all(&pool)
10286 .await?;
10287 assert_eq!(
10288 left,
10289 vec!["recent-starred"],
10290 "past the ceiling nothing is pinned — otherwise one reader can stall the poller \
10291 for every reader"
10292 );
10293 Ok(())
10294 }
10295
10296 /// The per-feed trim spares starred entries too. It was fixed in the
10297 /// retention sweep and NOT here, which left the documented guarantee false —
10298 /// and this path runs on every poll of every feed rather than daily.
10299 #[tokio::test]
10300 async fn the_per_feed_trim_spares_starred_entries() -> anyhow::Result<()> {
10301 let pool = init_url("sqlite::memory:").await?;
10302 let old_starred = aged_entry(&pool, "old-starred", 5).await;
10303 mark(&pool, old_starred, 1, 1).await;
10304 for i in 0..5 {
10305 aged_entry(&pool, &format!("filler-{i}"), 1).await;
10306 }
10307 let feed_id: i64 = sqlx::query_scalar("SELECT id FROM feeds LIMIT 1")
10308 .fetch_one(&pool)
10309 .await?;
10310
10311 // Trim hard enough that the older starred entry would be cut. The trim
10312 // runs inside `insert_entries`, so drive it the way production does.
10313 insert_entries(&pool, feed_id, &[], 2).await?;
10314
10315 let left: Vec<String> =
10316 sqlx::query_scalar("SELECT guid FROM entries WHERE guid = 'old-starred'")
10317 .fetch_all(&pool)
10318 .await?;
10319 assert_eq!(
10320 left,
10321 vec!["old-starred"],
10322 "the per-feed trim evicted a starred entry"
10323 );
10324 Ok(())
10325 }
10326
10327 /// **When more entries are starred than the cap, the NEWEST starred ones
10328 /// are spared.** The sparing subquery orders by date and takes `cap`; the
10329 /// existing tests seed one starred row (fewer than the cap, so the order
10330 /// never chooses) or assert only a count. With `DESC` flipped to `ASC` the
10331 /// suite stayed green — and in production the trim would spare the OLDEST
10332 /// starred articles and evict the newest, on every poll of every feed.
10333 #[tokio::test]
10334 async fn the_trim_spares_the_newest_starred_entries_when_over_cap() -> anyhow::Result<()> {
10335 let pool = init_url("sqlite::memory:").await?;
10336 // Five starred entries, one per day, cap of two: only the two newest
10337 // may survive.
10338 let mut ids = Vec::new();
10339 for days_old in 1..=5 {
10340 let id = aged_entry(&pool, &format!("starred-{days_old}"), days_old).await;
10341 mark(&pool, id, 1, 1).await;
10342 ids.push((days_old, id));
10343 }
10344 let feed_id: i64 = sqlx::query_scalar("SELECT feed_id FROM entries WHERE id = ?1")
10345 .bind(ids[0].1)
10346 .fetch_one(&pool)
10347 .await?;
10348 insert_entries(&pool, feed_id, &[], 2).await?;
10349
10350 let mut survivors: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries")
10351 .fetch_all(&pool)
10352 .await?;
10353 survivors.sort();
10354 assert_eq!(
10355 survivors,
10356 vec!["starred-1".to_string(), "starred-2".to_string()],
10357 "the trim spared the wrong starred entries"
10358 );
10359 Ok(())
10360 }
10361}