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 batched read-state 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, sanitized (ammonia) by ingest before it is stored.
92 ///
93 /// **Not trusted on the way out.** The column is plain `TEXT`, so nothing
94 /// here can prove which writer produced a row. The reader re-cleans it at
95 /// render with the same sanitizer, through
96 /// [`crate::sanitized_html::SanitizedHtml`], and never emits this `String`
97 /// unescaped (#151). Ingest sanitizing is still what keeps the stored
98 /// value clean, and a clean value is what re-cleans byte-identically.
99 pub content_html: Option<String>,
100 /// When FeatherReader first fetched/stored this entry (RFC3339).
101 pub fetched_at: String,
102}
103
104/// One row of a LIST view — deliberately **without** `content_html`.
105///
106/// The list queries used to be `SELECT e.*` into [`Entry`], which carries the
107/// sanitized article body. The body is essentially the whole of a cached entry
108/// (measured: 11.9 KB/entry), and no list surface has ever rendered it — the
109/// reader's `EntryRow` reads id, title, feed title, date, read, starred and
110/// link, and nothing else. So every article on every page load was read off
111/// disk, allocated, and dropped unexamined. On a 512 MB box with 250 concurrent
112/// requests permitted, one reader with a large backlog could ask for hundreds of
113/// megabytes in a single handler, and the resulting OOM/restart looked like a
114/// healthy machine that simply fell over.
115///
116/// `read` / `starred` come from the same `LEFT JOIN` that filters the view, so a
117/// caller does not have to fetch the whole unread or starred set a second time
118/// just to decorate the rows it is showing.
119///
120/// [`Entry`] is still the right type for the single-entry reader, which is the
121/// one surface that genuinely needs the body.
122#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
123pub struct EntryListRow {
124 pub id: i64,
125 pub feed_id: i64,
126 /// Feed-native GUID — used to match a cached entry against a PDS saved record.
127 pub guid: String,
128 pub url: Option<String>,
129 pub title: Option<String>,
130 pub published: Option<String>,
131 /// This DID's read bit. `false` when there is no `entry_state` row at all.
132 pub read: bool,
133 /// This DID's star bit. `false` when there is no `entry_state` row at all.
134 pub starred: bool,
135}
136
137/// Which list [`list_entries`] (and its siblings) is producing.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum ListView {
140 /// No `entry_state` row for this DID, or one with `read = 0`.
141 Unread,
142 /// An `entry_state` row with `starred = 1`.
143 Starred,
144 /// Every subscribed entry, read or not.
145 All,
146}
147
148impl ListView {
149 /// The `WHERE` fragment that selects this view, given `s` as the per-DID
150 /// `entry_state` LEFT JOIN alias.
151 fn predicate(self) -> &'static str {
152 match self {
153 // An entry with no state row is unread — hence LEFT JOIN + COALESCE
154 // rather than a join that would drop never-touched entries.
155 ListView::Unread => "COALESCE(s.read, 0) = 0",
156 ListView::Starred => "COALESCE(s.starred, 0) = 1",
157 ListView::All => "1 = 1",
158 }
159 }
160}
161
162/// Per-`(did, entry)` read/star state — the fast in-session working copy that the
163/// batched flusher later syncs to the PDS as a per-feed read cursor.
164#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
165pub struct EntryState {
166 pub did: String,
167 pub entry_id: i64,
168 pub read: bool,
169 pub starred: bool,
170 pub updated_at: String,
171}
172
173/// Per-`(did, feed_url)` read cursor — the local mirror of the PDS
174/// `community.lexicon.rss.readState` record plus flush bookkeeping.
175///
176/// `read_ids` / `unread_ids` are stored as JSON arrays of entry ids (the two
177/// bounded exception sets around the `read_through` high-water-mark); `dirty`
178/// marks that local `entry_state` has changed since the last PDS flush.
179#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
180pub struct ReadCursor {
181 pub did: String,
182 pub feed_url: String,
183 /// High-water-mark (RFC3339): every entry seen/published `<=` this is read.
184 pub read_through: Option<String>,
185 /// JSON array of entry ids newer than `read_through` that are also read.
186 pub read_ids: String,
187 /// JSON array of entry ids older than `read_through` explicitly kept unread.
188 pub unread_ids: String,
189 /// Set when `entry_state` changed since the last flush (debounce trigger).
190 pub dirty: bool,
191 /// Whether this cursor's `readState` record has been CREATED in the PDS yet.
192 /// The first flush of a feed must emit an `applyWrites#create` (an `#update`
193 /// errors on a record that does not pre-exist, and applyWrites is atomic
194 /// per-repo, so one not-yet-created cursor would drop the whole DID batch).
195 /// Flipped to `true` on the flush that creates it.
196 #[sqlx(default)]
197 pub pds_created: bool,
198 pub updated_at: String,
199}
200
201/// The `network_stat` key the relay adoption probe writes under.
202///
203/// Lives here, beside [`NetworkStat`], because **both** the writer (the
204/// scheduler's probe, compiled into the binary) and the reader (`web::about`,
205/// compiled into the library) name it — a literal in either place would be two
206/// strings free to drift apart.
207pub const ADOPTION_STAT_KEY: &str = "adoption.subscription";
208
209/// One relay's observation of how many repos hold a collection
210/// (`design/NETWORK-SPEC.md` §4.3). A projection: droppable, rebuildable from
211/// the network, and never read by anything on the reading path.
212#[derive(Debug, Clone, FromRow, PartialEq, Eq)]
213pub struct NetworkStat {
214 /// The metric key, e.g. [`ADOPTION_STAT_KEY`].
215 pub key: String,
216 /// The relay base URL the number came from.
217 pub source: String,
218 /// The observed count.
219 pub value: i64,
220 /// Set when the probe hit its page cap: the value is a floor, not a count.
221 pub truncated: bool,
222 /// When the observation was taken (RFC3339, UTC).
223 pub observed_at: String,
224}
225
226/// New-feed payload for [`upsert_feed`] (id is assigned by SQLite).
227#[derive(Debug, Clone, Default)]
228pub struct NewFeed {
229 pub url: String,
230 pub title: Option<String>,
231 pub site_url: Option<String>,
232 pub etag: Option<String>,
233 pub last_modified: Option<String>,
234 pub last_polled: Option<String>,
235 pub next_poll: Option<String>,
236}
237
238/// New-entry payload for [`insert_entries`] (id is assigned by SQLite,
239/// `fetched_at` defaults to "now" when not supplied).
240#[derive(Debug, Clone, Default)]
241pub struct NewEntry {
242 pub guid: String,
243 pub url: Option<String>,
244 pub title: Option<String>,
245 pub author: Option<String>,
246 pub published: Option<String>,
247 /// Already-sanitized HTML. `insert_entries` stores it as given; the
248 /// reader re-cleans it at render regardless (see [`Entry::content_html`]).
249 pub content_html: Option<String>,
250 /// Optional explicit fetch time (RFC3339); defaults to now if `None`.
251 pub fetched_at: Option<String>,
252 /// When the entry is already stored, keep its `content_html` rather than
253 /// overwrite it with this one's. Set when this poll could not sanitize the
254 /// body in time (#226): a body that exists must not be replaced by none. A
255 /// new entry is inserted with `content_html` as given.
256 pub keep_stored_content: bool,
257}
258
259/// The SQLite schema. Idempotent — safe to run on every startup.
260///
261/// `feeds`/`entries` are the shared cache; `entry_state`/`read_cursor` are
262/// per-DID. Indices cover the scheduler's due-feed query, the read/unread list
263/// query, and the flusher's dirty-cursor scan.
264const SCHEMA: &str = r#"
265PRAGMA foreign_keys = ON;
266
267CREATE TABLE IF NOT EXISTS feeds (
268 id INTEGER PRIMARY KEY AUTOINCREMENT,
269 url TEXT NOT NULL UNIQUE,
270 title TEXT,
271 site_url TEXT,
272 etag TEXT,
273 last_modified TEXT,
274 last_polled TEXT,
275 next_poll TEXT,
276 consecutive_errors INTEGER NOT NULL DEFAULT 0,
277 last_error_kind TEXT,
278 last_error TEXT,
279 -- What the poller does with this row; see `feed::FeedKind`. Written by the
280 -- Rust side at insert so SQL never re-derives it from the URL.
281 kind TEXT NOT NULL DEFAULT 'rss'
282);
283CREATE INDEX IF NOT EXISTS idx_feeds_next_poll ON feeds (next_poll);
284-- NOTE: `idx_feeds_kind` is created in `apply_migrations`, AFTER `kind` is
285-- ensured, for the same reason as the `intended_did` indexes below. 0.3.9 put
286-- it here and crash-looped production on its first boot: on an existing volume
287-- the CREATE TABLE above is a no-op, so the column does not exist yet.
288
289CREATE TABLE IF NOT EXISTS entries (
290 id INTEGER PRIMARY KEY AUTOINCREMENT,
291 feed_id INTEGER NOT NULL REFERENCES feeds (id) ON DELETE CASCADE,
292 guid TEXT NOT NULL,
293 url TEXT,
294 title TEXT,
295 author TEXT,
296 published TEXT,
297 content_html TEXT,
298 fetched_at TEXT NOT NULL,
299 UNIQUE (feed_id, guid)
300);
301-- The list and prev/next queries order on `COALESCE(published, fetched_at)`
302-- (#187). Measured on the real query shape (LEFT JOIN entry_state, EXISTS
303-- sub_ref), this index serves them as well as it served bare `published`; a
304-- `(feed_id, published, fetched_at)` replacement was tried and was ~3.8x
305-- slower on the default prev/next query, which never chose it (review of #213).
306CREATE INDEX IF NOT EXISTS idx_entries_feed_published ON entries (feed_id, published);
307
308CREATE TABLE IF NOT EXISTS entry_state (
309 did TEXT NOT NULL,
310 entry_id INTEGER NOT NULL REFERENCES entries (id) ON DELETE CASCADE,
311 read INTEGER NOT NULL DEFAULT 0,
312 starred INTEGER NOT NULL DEFAULT 0,
313 updated_at TEXT NOT NULL,
314 PRIMARY KEY (did, entry_id)
315);
316CREATE INDEX IF NOT EXISTS idx_entry_state_did_read ON entry_state (did, read);
317-- The FK child key. `entry_id` is the TRAILING column of the primary key, so
318-- without this index it is not the leading column of anything and SQLite must
319-- FULL SCAN entry_state for EVERY row deleted from `entries` to service
320-- ON DELETE CASCADE.
321--
322-- That is not theoretical. Measured on 600k entry_state rows: 500 deletes took
323-- 10.3s and 2,000 took 38.3s, against a busy_timeout of 5s — so any retention
324-- sweep removing more than roughly 260 entries made every concurrent writer
325-- (star, mark-read, OAuth session write) fail with SQLITE_BUSY. With this index
326-- the same 32,850-row delete goes from ~10 minutes to 0.7s.
327--
328-- It also fixes the per-feed trim, whose starred-sparing subquery scans
329-- entry_state on every poll of every feed and scales with TOTAL rows across all
330-- users rather than with the feed being trimmed (2ms -> 21ms at 1M rows).
331CREATE INDEX IF NOT EXISTS idx_entry_state_entry_id ON entry_state (entry_id);
332
333-- Per-DID subscription projection. The shared `feeds`/`entries` cache is
334-- deduped by URL and NOT owned by any single DID; `sub_ref` records which
335-- feeds a given DID actually subscribes to (mirrored from the caller's PDS
336-- subscription set on every resolve/sync). Every entry/feed READ and every
337-- read/star MUTATION is scoped through this table so one user can never read
338-- or mutate another user's cached articles. Rows are refreshed by
339-- `replace_sub_refs`.
340CREATE TABLE IF NOT EXISTS sub_ref (
341 did TEXT NOT NULL,
342 feed_id INTEGER NOT NULL REFERENCES feeds (id) ON DELETE CASCADE,
343 PRIMARY KEY (did, feed_id)
344);
345CREATE INDEX IF NOT EXISTS idx_sub_ref_feed ON sub_ref (feed_id);
346
347CREATE TABLE IF NOT EXISTS read_cursor (
348 did TEXT NOT NULL,
349 feed_url TEXT NOT NULL,
350 read_through TEXT,
351 read_ids TEXT NOT NULL DEFAULT '[]',
352 unread_ids TEXT NOT NULL DEFAULT '[]',
353 dirty INTEGER NOT NULL DEFAULT 0,
354 pds_created INTEGER NOT NULL DEFAULT 0,
355 updated_at TEXT NOT NULL,
356 PRIMARY KEY (did, feed_url)
357);
358CREATE INDEX IF NOT EXISTS idx_read_cursor_dirty ON read_cursor (did, dirty);
359-- The (did, feed_url) PRIMARY KEY can't serve a feed_url-only lookup (did is the
360-- leading column). The retention path's orphan-cursor cleanup filters cursors by
361-- feed_url alone, so give it an index.
362CREATE INDEX IF NOT EXISTS idx_read_cursor_feed_url ON read_cursor (feed_url);
363
364CREATE TABLE IF NOT EXISTS beta_access (
365 did TEXT PRIMARY KEY,
366 handle TEXT,
367 granted_by TEXT NOT NULL,
368 granted_at INTEGER NOT NULL,
369 invite_code_used TEXT
370);
371
372CREATE TABLE IF NOT EXISTS invite_codes (
373 code TEXT PRIMARY KEY,
374 creator_did TEXT NOT NULL,
375 status TEXT NOT NULL,
376 invitee_did TEXT,
377 -- The follower DID a bot-minted claim was minted FOR (recorded at mint time,
378 -- distinct from `invitee_did` which is stamped at redeem). This is the
379 -- server-side idempotency key: a second `POST /bot/claims` for a DID that
380 -- already holds an outstanding active code returns the SAME code instead of
381 -- minting a duplicate, so a bot-host state loss cannot re-mint per follower.
382 intended_did TEXT,
383 created_at INTEGER NOT NULL,
384 expires_at INTEGER NOT NULL,
385 redeemed_at INTEGER
386);
387CREATE INDEX IF NOT EXISTS idx_invite_codes_status ON invite_codes (status, expires_at);
388-- NOTE: the `intended_did` indexes are created in `apply_migrations`, AFTER the
389-- `intended_did` column is ensured. They MUST NOT live in this base SCHEMA batch:
390-- on an existing pre-0.2.2 volume the `CREATE TABLE IF NOT EXISTS invite_codes`
391-- above is a no-op (the table already exists without `intended_did`), so a
392-- `CREATE INDEX ... (intended_did, ...)` here would fail with "no such column"
393-- and crash-loop the boot before migrations ever run.
394
395-- Network-observation counters (v0.2.8, design/NETWORK-SPEC.md §4.3). One row
396-- per (metric, relay): the adoption probe records how many repos a given relay
397-- has INDEXED as holding a collection. We store the COUNT, never the DID list —
398-- persisting the DIDs would build a durable register of "accounts that use an
399-- RSS reader" on our disk for a feature whose only output is an integer. This
400-- table is a PROJECTION, not a source of truth: `DROP TABLE` it and the next
401-- probe rebuilds it, and nothing in the reader path reads it. Bounded forever at
402-- (metrics × relays) rows, so it never interacts with the DB-size watermark.
403CREATE TABLE IF NOT EXISTS network_stat (
404 key TEXT NOT NULL, -- e.g. 'adoption.subscription'
405 source TEXT NOT NULL, -- the relay host the number came from
406 value INTEGER NOT NULL,
407 truncated INTEGER NOT NULL DEFAULT 0,
408 observed_at TEXT NOT NULL,
409 PRIMARY KEY (key, source)
410);
411-- Repo-operation timings, for comparing the two backends across a CUTOVER.
412--
413-- Persisted rather than held in memory because flipping the backend requires a
414-- restart, and an in-memory table would lose the outgoing backend's numbers at
415-- exactly the moment they became worth comparing against. These rows are the
416-- only reason a "side by side" table can show two backends at once.
417--
418-- `repo_timing` is a bounded window of recent samples (pruned per backend+op);
419-- `repo_timing_total` carries the all-time counts, which must survive that
420-- pruning or a long-running backend would appear to have served fewer calls
421-- than a fresh one.
422CREATE TABLE IF NOT EXISTS repo_timing (
423 id INTEGER PRIMARY KEY AUTOINCREMENT,
424 backend TEXT NOT NULL,
425 op TEXT NOT NULL,
426 micros INTEGER NOT NULL,
427 ok INTEGER NOT NULL,
428 at INTEGER NOT NULL
429);
430
431CREATE INDEX IF NOT EXISTS idx_repo_timing_key ON repo_timing(backend, op, id);
432
433CREATE TABLE IF NOT EXISTS repo_timing_total (
434 backend TEXT NOT NULL,
435 op TEXT NOT NULL,
436 ok_count INTEGER NOT NULL DEFAULT 0,
437 err_count INTEGER NOT NULL DEFAULT 0,
438 PRIMARY KEY (backend, op)
439);
440
441"#;
442
443/// RFC3339 timestamp for "now" (UTC, seconds precision), used as the default for
444/// `*_at` columns. Uses `chrono` to match the shape written by [`crate::feed`]
445/// and [`crate::web`] (one timestamp format across the whole crate).
446fn now_rfc3339() -> String {
447 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
448}
449
450/// Open the per-DID SQLite cache described by [`Config`] (its `db_path`), run
451/// schema creation, and return the pool.
452///
453/// This is the entrypoint `main` calls: it derives the sqlx SQLite URL from the
454/// configured filesystem path and delegates to [`init_url`]. Kept separate from
455/// [`init_url`] so tests can open an in-memory database directly.
456pub async fn init(config: &Config) -> Result<Pool> {
457 // sqlx wants a `sqlite://<path>` URL; build it from the configured path.
458 let db_url = format!("sqlite://{}", config.db_path.display());
459 init_url(&db_url).await
460}
461
462/// Open (creating if needed) the SQLite database at `db_url`, run schema
463/// creation, and return a connection pool.
464///
465/// `db_url` is a sqlx SQLite URL, e.g. `sqlite://featherreader.db` or
466/// `sqlite::memory:` for an ephemeral in-memory database. The file is created
467/// if it does not exist; WAL journaling is enabled for on-disk databases and
468/// foreign keys are enforced on every connection.
469/// Ceiling the WAL is truncated back to at each checkpoint.
470///
471/// The WAL lives on the same volume as the database and counts against the same
472/// 1 GB, but nothing bounded it: SQLite grows the WAL to fit the largest
473/// transaction it has ever seen and never shrinks it again without this limit.
474const WAL_SIZE_LIMIT_BYTES: i64 = 64 * 1024 * 1024;
475
476pub async fn init_url(db_url: &str) -> Result<Pool> {
477 // An in-memory DB must run on a SINGLE connection: each `:memory:` connection
478 // is a *separate* database, and a multi-connection in-memory pool can also
479 // deadlock a writer against an idle pooled connection's shared-cache table
480 // read-lock (SQLITE_LOCKED, code 262 — which `busy_timeout` does NOT retry;
481 // seen as a Linux-only flaky failure in redeem_code's UPDATE). On-disk uses
482 // WAL + a 5-connection pool as normal.
483 let is_memory = db_url.contains(":memory:");
484 let mut opts = SqliteConnectOptions::from_str(db_url)
485 .with_context(|| format!("invalid sqlite url: {db_url}"))?
486 .create_if_missing(true)
487 .foreign_keys(true);
488 // WAL is a no-op / unsupported for :memory:, so only request it on-disk.
489 if !is_memory {
490 opts = opts.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
491 // **Incremental auto-vacuum, set at CREATION.**
492 //
493 // `auto_vacuum` was read by `reclaim` and never set anywhere, so every
494 // database ran in SQLite's default NONE mode and `reclaim` always took
495 // its full-`VACUUM` branch — daily, and again after every prune. A full
496 // VACUUM needs free disk roughly equal to the live database because it
497 // writes a whole new file, which is exactly what is scarce under the
498 // disk pressure that triggers a sweep; on a ~700 MiB database on a 1 GB
499 // volume it cannot complete at all.
500 //
501 // This pragma only takes effect on a database with no tables yet, so it
502 // fixes NEW instances permanently and does nothing to existing ones —
503 // deliberately. Changing it on a populated database requires running the
504 // very full VACUUM that is unsafe here, so that is a separate,
505 // operator-invoked step: see [`migrate_to_incremental_vacuum`].
506 opts = opts.auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Incremental);
507 // Truncate the WAL back down at checkpoints. Without a limit, a WAL
508 // grown once by a single large transaction stays that size for the life
509 // of the file — permanently occupying volume the watermark is trying to
510 // protect. The batched retention deletes keep transactions small now, so
511 // in practice the WAL should rarely approach this; the limit is what
512 // makes that a guarantee rather than a hope.
513 opts = opts.pragma("journal_size_limit", WAL_SIZE_LIMIT_BYTES.to_string());
514 }
515 // Under a concurrent write burst (the poller's insert_entries tx racing the
516 // web layer's mark_read / redeem_code tx) SQLite would otherwise return
517 // SQLITE_BUSY the instant a writer holds the lock. `busy_timeout` makes a
518 // blocked connection WAIT (retry) for up to this long before erroring, so
519 // short lock contention resolves transparently instead of surfacing a
520 // spurious failure. Mirrors the OAuth sidecar's `stores.ts`
521 // (`PRAGMA busy_timeout = 5000`). 5 s is comfortably above any single
522 // FeatherReader transaction.
523 opts = opts.busy_timeout(std::time::Duration::from_millis(5000));
524 // Quiet sqlx's per-statement query logging.
525 opts = opts.log_statements(tracing::log::LevelFilter::Debug);
526
527 let pool = SqlitePoolOptions::new()
528 // Keep at least one connection alive so an in-memory DB isn't dropped
529 // (each `:memory:` connection is a *separate* database otherwise).
530 .min_connections(1)
531 .max_connections(if is_memory { 1 } else { 5 })
532 .connect_with(opts)
533 .await
534 .with_context(|| format!("failed to open sqlite pool: {db_url}"))?;
535
536 init_schema(&pool).await?;
537 Ok(pool)
538}
539
540/// Run the idempotent schema creation. Split out so callers/tests can (re)apply
541/// it against an already-open pool.
542pub async fn init_schema(pool: &SqlitePool) -> Result<()> {
543 // `execute` runs the multi-statement batch (sqlite allows this).
544 sqlx::query(SCHEMA)
545 .execute(pool)
546 .await
547 .context("failed to create schema")?;
548 apply_migrations(pool).await?;
549 // The Rust OAuth client's tables live in the same database. Created
550 // UNCONDITIONALLY, not only when that backend is selected: the tables are
551 // empty and harmless under the sidecar, whereas creating them lazily would
552 // make the first request after a cutover flip fail with "no such table" --
553 // at the one moment nobody wants to discover a migration was missed.
554 crate::oauth::store::init_schema(pool)
555 .await
556 .context("failed to create the OAuth schema")?;
557 Ok(())
558}
559
560/// Apply additive, idempotent migrations to bring an EXISTING database up to the
561/// current [`SCHEMA`]. `CREATE TABLE IF NOT EXISTS` never alters a table that
562/// already exists, so a column added to a shipped table must be back-filled here
563/// (SQLite has no `ADD COLUMN IF NOT EXISTS`, so we probe `table_info` first).
564async fn apply_migrations(pool: &SqlitePool) -> Result<()> {
565 // feeds.consecutive_errors — drives the exponential poll backoff. Older DBs
566 // predate the column; add it (defaulting to 0) if it is missing.
567 ensure_column(
568 pool,
569 "PRAGMA table_info(feeds)",
570 "consecutive_errors",
571 "ALTER TABLE feeds ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0",
572 )
573 .await?;
574 // feeds.last_error_kind / feeds.last_error — WHY a feed is failing, not just
575 // how often. `consecutive_errors` recorded a count and nothing else, which is
576 // how a systematic defect across sixty feeds stayed indistinguishable from
577 // sixty dead blogs until #159: every one of them was our own 304 handling,
578 // and the table could not say so. Nullable, and NULL once a poll succeeds.
579 ensure_column(
580 pool,
581 "PRAGMA table_info(feeds)",
582 "last_error_kind",
583 "ALTER TABLE feeds ADD COLUMN last_error_kind TEXT",
584 )
585 .await?;
586 ensure_column(
587 pool,
588 "PRAGMA table_info(feeds)",
589 "last_error",
590 "ALTER TABLE feeds ADD COLUMN last_error TEXT",
591 )
592 .await?;
593
594 // feeds.kind — what the poller does with a row. Older DBs predate it and
595 // get `'rss'` from the DEFAULT, which is wrong for the at:// rows, so it is
596 // back-filled below.
597 ensure_column(
598 pool,
599 "PRAGMA table_info(feeds)",
600 "kind",
601 "ALTER TABLE feeds ADD COLUMN kind TEXT NOT NULL DEFAULT 'rss'",
602 )
603 .await?;
604 // Here, not in the base SCHEMA batch: it names a column that only exists
605 // after the line above. See the note beside `idx_feeds_next_poll`.
606 sqlx::query("CREATE INDEX IF NOT EXISTS idx_feeds_kind ON feeds (kind)")
607 .execute(pool)
608 .await
609 .context("creating idx_feeds_kind")?;
610
611 // **Re-derived in Rust, every row, every start — not translated once.**
612 //
613 // `kind` is a pure function of `url`, so it is a cache, and a cache that is
614 // only ever written forward goes stale the moment the function changes.
615 // The first version of this was a one-directional SQL `UPDATE` carrying its
616 // own copy of the rule as a string predicate: it agreed with
617 // `FeedKind::of` on the day it was written, translated `rss` to
618 // `publication` and never the reverse, and had no way to notice either
619 // fact. Asking the Rust classifier about every row instead means the column
620 // cannot disagree with the one function that defines it, and a future kind
621 // — or a corrected rule — needs no migration of its own.
622 //
623 // Cheap by shape, not by assumption: it writes only rows that are actually
624 // wrong, so the steady state is a single scan of a table that holds one row
625 // per subscribed feed.
626 let rows = sqlx::query("SELECT id, url, kind FROM feeds")
627 .fetch_all(pool)
628 .await
629 .context("reading feeds to re-derive kind")?;
630 let mut tx = pool.begin().await.context("begin kind re-derivation")?;
631 let (mut to_pollable, mut to_unpollable, mut unreadable) = (0u64, 0u64, 0u64);
632 for row in rows {
633 // **A row we cannot read is skipped, not fatal.** This runs on the boot
634 // path, so anything that returns `Err` here is the difference between a
635 // wedged poller and a site that will not start. A `url` or `kind` that
636 // is not decodable as text takes no opinion from us and keeps whatever
637 // it has; every reader downstream already treats an unknown kind as
638 // unpollable. Nothing sqlx writes produces such a row — it binds `&str`
639 // as TEXT everywhere — so reaching this means the file was edited by
640 // hand, which is exactly when refusing to boot is the least helpful
641 // thing to do.
642 let (Ok(id), Ok(url), Ok(kind)) = (
643 row.try_get::<i64, _>("id"),
644 row.try_get::<String, _>("url"),
645 row.try_get::<String, _>("kind"),
646 ) else {
647 unreadable += 1;
648 continue;
649 };
650 let want = crate::feed::FeedKind::of(&url);
651 if kind == want.as_str() {
652 continue;
653 }
654 sqlx::query("UPDATE feeds SET kind = ?1 WHERE id = ?2")
655 .bind(want.as_str())
656 .bind(id)
657 .execute(&mut *tx)
658 .await
659 .with_context(|| format!("re-deriving kind for feed {id}"))?;
660 if crate::feed::FeedKind::POLLABLE.contains(&want) {
661 to_pollable += 1;
662 } else {
663 // **Declaring a row unpollable orphans its poll state, so clear
664 // it.** A backoff horizon and an error count belong to a feed the
665 // scheduler selects; on a row it will never select again they are
666 // dead, and not inert. They are hidden from `/stats` and the cause
667 // histogram, which filter on kind, so they rot unseen — and if a
668 // later rule change makes the row pollable again it resumes at
669 // `backoff_for(n)` on an `n` earned under a classification that no
670 // longer applies, which for seven prior errors is a first retry ten
671 // hours out instead of five minutes.
672 //
673 // Narrower than the step below, deliberately: that one clears only
674 // rows we never polled, on the grounds that a real feed's history
675 // still means something. This clears rows whose history can no
676 // longer mean anything, because nothing will add to it or act on
677 // it.
678 sqlx::query(
679 "UPDATE feeds SET consecutive_errors = 0, last_error_kind = NULL, \
680 last_error = NULL, next_poll = NULL WHERE id = ?1",
681 )
682 .bind(id)
683 .execute(&mut *tx)
684 .await
685 .with_context(|| format!("clearing orphaned poll state for feed {id}"))?;
686 to_unpollable += 1;
687 }
688 }
689 tx.commit().await.context("commit kind re-derivation")?;
690 // Quiet in the steady state, which is every boot where nothing changed.
691 // Split by direction because the two mean opposite things to an operator:
692 // one puts feeds back in the poller's queue, the other takes them out of
693 // every figure `/stats` reports.
694 if to_pollable > 0 || to_unpollable > 0 {
695 tracing::info!(
696 to_pollable,
697 to_unpollable,
698 "feeds.kind re-derived from the URL"
699 );
700 }
701 if unreadable > 0 {
702 tracing::warn!(
703 unreadable,
704 "feeds rows are not readable as text; their kind was left alone"
705 );
706 }
707
708 // **Clear failure counts on rows we never actually polled.**
709 //
710 // `due_feeds` excludes them by kind (see `feed::FeedKind`) — but rows
711 // subscribed before the scheme check already carry the errors OUR refusal
712 // produced. Left alone they would count as failing forever, since no poll
713 // that could clear them will ever be scheduled.
714 //
715 // A real feed's history is untouched: it still means something. The
716 // recorded reason goes with the count: a row with no errors must carry no
717 // reason, which is what `reset_feed_errors` promises and a test asserts.
718 //
719 // **Idempotent by predicate.** `last_polled` is set only by a successful
720 // poll — `bump_feed_errors` never touches it — so `last_polled IS NULL`
721 // selects exactly the rows whose every error came from our own refusal.
722 // A row a wired standard.site reader has fetched once keeps its later
723 // failures across restarts; a row that only ever failed under the refusal
724 // is cleared at every boot, including after a rollback to a build that
725 // polled it. A version stamp was the first design and left that rollback
726 // case a permanent hole (re-accumulated errors hidden by the filters,
727 // never cleared). Trade-off accepted: a publication that has never once
728 // succeeded restarts its backoff at the floor on every boot.
729 sqlx::query(sqlx::AssertSqlSafe(format!(
730 "UPDATE feeds SET consecutive_errors = 0, last_error_kind = NULL, last_error = NULL \
731 WHERE kind NOT IN ({POLLABLE_KINDS_SQL}) AND last_polled IS NULL \
732 AND consecutive_errors > 0"
733 )))
734 .execute(pool)
735 .await
736 .context("clearing error counts on unpollable at:// feeds")?;
737 // read_cursor.pds_created — tracks whether a feed's readState record has been
738 // created in the PDS, so the first flush emits a `create` (not a bare
739 // `update`, which errors on a not-yet-existing record). Older DBs predate it.
740 ensure_column(
741 pool,
742 "PRAGMA table_info(read_cursor)",
743 "pds_created",
744 "ALTER TABLE read_cursor ADD COLUMN pds_created INTEGER NOT NULL DEFAULT 0",
745 )
746 .await?;
747 // invite_codes.intended_did — the follower DID a bot claim was minted for, the
748 // server-side idempotency key for `POST /bot/claims`. Older DBs (before the
749 // follow→invite bot) predate it; it is nullable (browser/admin-minted codes
750 // leave it NULL).
751 ensure_column(
752 pool,
753 "PRAGMA table_info(invite_codes)",
754 "intended_did",
755 "ALTER TABLE invite_codes ADD COLUMN intended_did TEXT",
756 )
757 .await?;
758 // Indexes on `intended_did` are created HERE (not in the base SCHEMA batch)
759 // because they reference a column that only exists after the migration above.
760 // On an existing pre-0.2.2 DB the `invite_codes` CREATE TABLE is a no-op, so
761 // an index on `intended_did` in SCHEMA would fail before this migration ran
762 // (that was blocker B1). All are `IF NOT EXISTS`, so re-running is a no-op.
763 //
764 // Look up an outstanding active claim by the DID it was minted for (bot dedupe).
765 sqlx::query(
766 "CREATE INDEX IF NOT EXISTS idx_invite_codes_intended \
767 ON invite_codes (intended_did, status)",
768 )
769 .execute(pool)
770 .await
771 .context("creating idx_invite_codes_intended")?;
772 // Enforce at MOST one outstanding active claim per intended DID. This makes
773 // the bot's dedupe check-then-mint race-safe: two concurrent `POST /bot/claims`
774 // for the same follower can no longer both insert an active code (the second
775 // INSERT hits this unique constraint). Partial so it only constrains active
776 // bot-minted rows — redeemed/expired rows and NULL-intended (admin/browser)
777 // codes are unconstrained. (Blocker/should-fix S4.)
778 sqlx::query(
779 "CREATE UNIQUE INDEX IF NOT EXISTS idx_invite_codes_intended_active \
780 ON invite_codes (intended_did) \
781 WHERE intended_did IS NOT NULL AND status = 'active'",
782 )
783 .execute(pool)
784 .await
785 .context("creating idx_invite_codes_intended_active")?;
786
787 // **Re-date rows stored with a future date before ingest refused them**
788 // (#188). An item dated 2999 that has since left its feed is never polled
789 // again to be corrected, so it would stay first in the list and survive the
790 // per-feed cap. Cleared, `fetched_at` dates it. The same bound ingest uses;
791 // a no-op once there are none.
792 let ceiling = (chrono::Utc::now()
793 + chrono::Duration::days(crate::feed::MAX_FUTURE_PUBLISHED_DAYS))
794 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
795 sqlx::query("UPDATE entries SET published = NULL WHERE published > ?1")
796 .bind(&ceiling)
797 .execute(pool)
798 .await
799 .context("clearing stored future publication dates")?;
800 Ok(())
801}
802
803/// Add a column via `alter_sql` iff `info_sql` (a `PRAGMA table_info(<table>)`)
804/// does not already report `column`. All three SQL args are hard-coded internal
805/// literals (never user input), so they are safe `&'static str`s — the table name
806/// can't be a bind parameter in `PRAGMA`, which is why they're passed whole.
807async fn ensure_column(
808 pool: &SqlitePool,
809 info_sql: &'static str,
810 column: &str,
811 alter_sql: &'static str,
812) -> Result<()> {
813 let rows = sqlx::query(info_sql)
814 .fetch_all(pool)
815 .await
816 .with_context(|| format!("{info_sql} failed"))?;
817 let present = rows.iter().any(|r| r.get::<String, _>("name") == column);
818 if !present {
819 sqlx::query(alter_sql)
820 .execute(pool)
821 .await
822 .with_context(|| format!("adding column {column} via {alter_sql}"))?;
823 }
824 Ok(())
825}
826
827/// Insert a feed by URL, or update its metadata if the URL already exists.
828/// Returns the feed's row id (existing or newly assigned).
829///
830/// EVERY updatable column is COALESCE'd, so `None` means "leave alone" for all
831/// of them and a partial upsert cannot clobber a field it never mentioned.
832///
833/// `etag`/`last_modified` were the exception until now, and the exception was
834/// silently disabling conditional GET for the entire instance. `set_next_poll`
835/// in the scheduler supplies only `url` + `next_poll` after every single poll,
836/// which wrote both validators back to NULL — so `304 Not Modified` was
837/// unreachable and every feed was re-downloaded, re-parsed, re-sanitised and
838/// re-inserted in full, hourly, forever. `feed::touch_polled` had discovered the
839/// same trap earlier and worked around it in its own caller by re-reading the
840/// row first; that local fix is what let the next caller walk into it.
841///
842/// A stale validator is not a hazard: if the origin no longer issues one it
843/// ignores our `If-None-Match` and returns `200`, and if it still matches then
844/// `304` was the correct answer anyway.
845pub async fn upsert_feed(pool: &SqlitePool, feed: &NewFeed) -> Result<i64> {
846 let row = sqlx::query(
847 r#"
848 INSERT INTO feeds (url, title, site_url, etag, last_modified, last_polled, next_poll, kind)
849 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
850 ON CONFLICT (url) DO UPDATE SET
851 title = COALESCE(excluded.title, feeds.title),
852 site_url = COALESCE(excluded.site_url, feeds.site_url),
853 etag = COALESCE(excluded.etag, feeds.etag),
854 last_modified = COALESCE(excluded.last_modified, feeds.last_modified),
855 last_polled = COALESCE(excluded.last_polled, feeds.last_polled),
856 next_poll = COALESCE(excluded.next_poll, feeds.next_poll),
857 -- Not COALESCE: `kind` is derived from the URL, and `excluded`
858 -- always carries the current answer. Preserving the stored value
859 -- would make a row's classification a function of when it was
860 -- first subscribed rather than of what it is.
861 kind = excluded.kind
862 RETURNING id
863 "#,
864 )
865 .bind(&feed.url)
866 .bind(&feed.title)
867 .bind(&feed.site_url)
868 .bind(&feed.etag)
869 .bind(&feed.last_modified)
870 .bind(&feed.last_polled)
871 .bind(&feed.next_poll)
872 // Decided once, in Rust, and never re-derived from the URL by SQL.
873 .bind(crate::feed::FeedKind::of(&feed.url).as_str())
874 .fetch_one(pool)
875 .await
876 .with_context(|| format!("upsert_feed failed for {}", feed.url))?;
877
878 Ok(row.get::<i64, _>("id"))
879}
880
881/// Fetch a feed by its URL, if present.
882pub async fn get_feed_by_url(pool: &SqlitePool, url: &str) -> Result<Option<Feed>> {
883 let feed = sqlx::query_as::<_, Feed>("SELECT * FROM feeds WHERE url = ?1")
884 .bind(url)
885 .fetch_optional(pool)
886 .await
887 .with_context(|| format!("get_feed_by_url failed for {url}"))?;
888 Ok(feed)
889}
890
891/// The `kind` values the scheduler may select, as a SQL list.
892///
893/// Pinned against [`crate::feed::FeedKind::POLLABLE`] by
894/// `the_sql_kind_list_matches_the_rust_one` — a literal here and a slice there
895/// is exactly the drift the column was introduced to end, so the two are
896/// asserted equal rather than trusted. Wiring the standard.site reader means
897/// changing both, and that test is what makes forgetting one a failure.
898pub(crate) const POLLABLE_KINDS_SQL: &str = "'rss', 'publication'";
899
900/// The `kind` values the retention **window** applies to, as a SQL list.
901///
902/// Pinned against [`crate::feed::FeedKind::AGED`] by
903/// `the_sql_aged_kind_list_matches_the_rust_one`, for the same reason
904/// [`POLLABLE_KINDS_SQL`] is pinned against `POLLABLE`.
905///
906/// Why a publication is not in it: see `FeedKind::AGED`. Measured — a 14-day
907/// window stored zero rows from every real publication tried, because their
908/// newest documents were 109 to 241 days old.
909pub(crate) const AGED_KINDS_SQL: &str = "'rss'";
910
911/// How many rows the poller will never select — the capacity consumed by feeds
912/// that cannot be fetched.
913///
914/// Rendered on `/admin/metrics` because the global ceiling counts these rows
915/// (see [`count_feeds`]) while `/stats` does not, so without this the cap could
916/// be reached with every public number saying otherwise.
917pub async fn unpollable_feeds(pool: &SqlitePool) -> Result<i64> {
918 sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
919 "SELECT COUNT(*) FROM feeds WHERE kind NOT IN ({POLLABLE_KINDS_SQL})"
920 )))
921 .fetch_one(pool)
922 .await
923 .context("counting unpollable feeds")
924}
925
926/// How many rows the poller will never select. Test-only: the assertion the
927/// at:// tests make, spelled once, against the predicate the code uses.
928#[cfg(test)]
929pub(crate) async fn count_unpollable_feeds(pool: &SqlitePool) -> Result<i64> {
930 sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
931 "SELECT COUNT(*) FROM feeds WHERE kind NOT IN ({POLLABLE_KINDS_SQL})"
932 )))
933 .fetch_one(pool)
934 .await
935 .context("counting unpollable feeds")
936}
937
938/// The scheduler's hot query: feeds whose `next_poll` is due (`<= as_of`, or
939/// never polled), oldest-due first. `as_of` is an RFC3339 timestamp.
940pub async fn due_feeds(pool: &SqlitePool, as_of: &str, limit: i64) -> Result<Vec<Feed>> {
941 let sql = format!(
942 r#"
943 SELECT * FROM feeds
944 WHERE (next_poll IS NULL OR next_poll <= ?1)
945 -- Only POLLABLE kinds are due; an `unsupported` row is skipped, not
946 -- failed. The why lives on `feed::FeedKind::POLLABLE`.
947 AND kind IN ({POLLABLE_KINDS_SQL})
948 ORDER BY next_poll IS NOT NULL, next_poll ASC
949 LIMIT ?2
950 "#
951 );
952 let feeds = sqlx::query_as::<_, Feed>(sqlx::AssertSqlSafe(sql))
953 .bind(as_of)
954 .bind(limit)
955 .fetch_all(pool)
956 .await
957 .context("due_feeds failed")?;
958 Ok(feeds)
959}
960
961/// One failing feed, named, for the ADMIN view only.
962///
963/// The public `/stats` histogram is counts by cause and nothing else, by that
964/// page's own stated promise. This is the other half: the coarse bucket
965/// `fetch` covers DNS failure, timeout, SSRF refusal and — as #159 proved —
966/// this reader's own bugs, so a count alone cannot separate "the publishers are
967/// gone" from "we are broken". The detail can, and it lives behind the
968/// `ALLOWED_DIDS` gate where per-feed data is already permitted.
969#[derive(Debug, Clone, PartialEq, Eq)]
970pub struct FailingFeed {
971 pub url: String,
972 pub consecutive_errors: i64,
973 /// `None` for a row that predates the column — see the `unknown` bucket.
974 pub kind: Option<String>,
975 pub detail: Option<String>,
976}
977
978/// Every currently-failing feed with its recorded cause, worst first.
979///
980/// **Admin-gated callers only.** Bounded because this renders into one response
981/// and a large instance should not be able to make that response unbounded.
982pub async fn failing_feeds(pool: &SqlitePool, limit: i64) -> Result<Vec<FailingFeed>> {
983 // The same exclusion as `poll_health`: a row the poller never selects
984 // can never have its errors cleared, so listing it here would pin it to
985 // the top of the operator's page for good.
986 let sql = format!(
987 r#"
988 SELECT url, consecutive_errors, last_error_kind, last_error
989 FROM feeds
990 WHERE consecutive_errors > 0 AND kind IN ({POLLABLE_KINDS_SQL})
991 ORDER BY consecutive_errors DESC, url ASC
992 LIMIT ?1
993 "#
994 );
995 let rows: Vec<(String, i64, Option<String>, Option<String>)> =
996 sqlx::query_as(sqlx::AssertSqlSafe(sql))
997 .bind(limit)
998 .fetch_all(pool)
999 .await
1000 .context("listing failing feeds")?;
1001 Ok(rows
1002 .into_iter()
1003 .map(|(url, consecutive_errors, kind, detail)| FailingFeed {
1004 url,
1005 consecutive_errors,
1006 kind,
1007 detail,
1008 })
1009 .collect())
1010}
1011
1012/// Cap on the stored `last_error` detail. Remote text on an unattended path.
1013const MAX_ERROR_DETAIL_CHARS: usize = 300;
1014
1015/// Record a poll FAILURE for a feed: bump its `consecutive_errors` by one and
1016/// return the NEW count. The count drives the exponential poll backoff, so a
1017/// persistently-failing feed spaces its retries out toward the ceiling instead of
1018/// hammering the 5-minute floor forever. Reset to 0 by [`reset_feed_errors`] on
1019/// any success/304.
1020pub async fn bump_feed_errors(
1021 pool: &SqlitePool,
1022 url: &str,
1023 kind: crate::feed::FailureKind,
1024 detail: &str,
1025) -> Result<i64> {
1026 let row = sqlx::query(
1027 "UPDATE feeds SET consecutive_errors = consecutive_errors + 1, \
1028 last_error_kind = ?2, last_error = ?3 \
1029 WHERE url = ?1 RETURNING consecutive_errors",
1030 )
1031 .bind(url)
1032 .bind(kind.as_str())
1033 // **Truncated.** This is a remote server's error text on an unattended path;
1034 // an upstream that returns a megabyte of prose should cost a bounded row,
1035 // not an unbounded one.
1036 .bind(
1037 detail
1038 .chars()
1039 .take(MAX_ERROR_DETAIL_CHARS)
1040 .collect::<String>(),
1041 )
1042 .fetch_optional(pool)
1043 .await
1044 .with_context(|| format!("bump_feed_errors failed for {url}"))?;
1045 // If the feed row somehow vanished, treat it as the first error.
1046 Ok(row
1047 .map(|r| r.get::<i64, _>("consecutive_errors"))
1048 .unwrap_or(1))
1049}
1050
1051/// Schedule a feed's next poll `delay` from now.
1052///
1053/// Lived as a private fn in the scheduler until `web::add_subscription`
1054/// needed it too: a poll taken off the scheduler settled the error columns but
1055/// never rescheduled, so a re-subscribed working feed stayed parked on its stale
1056/// backoff horizon for up to 24h. One implementation, two callers.
1057///
1058/// `upsert_feed` COALESCEs unset fields, so supplying only url + next_poll bumps
1059/// the schedule without clobbering title/validators/last_polled.
1060pub async fn set_next_poll(pool: &SqlitePool, url: &str, delay: std::time::Duration) -> Result<()> {
1061 let next = chrono::Utc::now()
1062 + chrono::Duration::from_std(delay).unwrap_or_else(|_| chrono::Duration::hours(1));
1063 let next_poll = next.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
1064 let nf = NewFeed {
1065 url: url.to_string(),
1066 next_poll: Some(next_poll),
1067 ..Default::default()
1068 };
1069 upsert_feed(pool, &nf).await.map(|_| ())
1070}
1071
1072/// [`due_feeds`] for one kind only. The RSS poller and the publication poller
1073/// each select their own, so neither can be held by the other's reads.
1074pub async fn due_feeds_of_kind(
1075 pool: &SqlitePool,
1076 as_of: &str,
1077 kind: crate::feed::FeedKind,
1078 limit: i64,
1079) -> Result<Vec<Feed>> {
1080 sqlx::query_as::<_, Feed>(
1081 "SELECT * FROM feeds WHERE (next_poll IS NULL OR next_poll <= ?1) AND kind = ?2 \
1082 ORDER BY next_poll IS NOT NULL, next_poll ASC LIMIT ?3",
1083 )
1084 .bind(as_of)
1085 .bind(kind.as_str())
1086 .bind(limit)
1087 .fetch_all(pool)
1088 .await
1089 .context("due_feeds_of_kind failed")
1090}
1091
1092/// Spread the first polls of never-polled `kind` rows across `spread`.
1093///
1094/// **Admitting a kind to the poller makes every row of it due at once.**
1095/// `due_feeds` sorts `next_poll IS NULL` ahead of every dated row, and rows that
1096/// were never pollable have no schedule, so the boot that admits them hands the
1097/// poller a block that outranks every regular feed — including an overdue one —
1098/// until it drains (`feed::FeedKind::POLLABLE` documents the measurement). This
1099/// gives each such row its own slot in `[now, now + spread)`, in id order, so
1100/// the block arrives as a trickle. Rows that have been polled, or already carry
1101/// a schedule, are untouched. Returns how many rows were scheduled.
1102pub async fn stagger_unscheduled(
1103 pool: &SqlitePool,
1104 kind: crate::feed::FeedKind,
1105 spread: std::time::Duration,
1106) -> Result<u64> {
1107 let ids: Vec<i64> = sqlx::query_scalar(
1108 "SELECT id FROM feeds WHERE kind = ?1 AND next_poll IS NULL AND last_polled IS NULL \
1109 ORDER BY id",
1110 )
1111 .bind(kind.as_str())
1112 .fetch_all(pool)
1113 .await
1114 .context("listing unscheduled feeds to stagger")?;
1115 if ids.is_empty() {
1116 return Ok(0);
1117 }
1118 let now = chrono::Utc::now();
1119 let spread = chrono::Duration::from_std(spread).unwrap_or_else(|_| chrono::Duration::hours(1));
1120 let n = ids.len() as i32;
1121 let mut tx = pool.begin().await.context("begin stagger")?;
1122 for (i, id) in ids.iter().enumerate() {
1123 // Evenly spaced, the first due now: slot i of n across the spread.
1124 let at = now + spread * i as i32 / n;
1125 let next_poll = at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
1126 sqlx::query("UPDATE feeds SET next_poll = ?1 WHERE id = ?2 AND next_poll IS NULL")
1127 .bind(next_poll)
1128 .bind(id)
1129 .execute(&mut *tx)
1130 .await
1131 .context("staggering a feed's first poll")?;
1132 }
1133 tx.commit().await.context("commit stagger")?;
1134 Ok(ids.len() as u64)
1135}
1136
1137/// Reset a feed's `consecutive_errors` to 0 after a successful poll (or a 304).
1138/// A no-op UPDATE if the row is missing.
1139pub async fn reset_feed_errors(pool: &SqlitePool, url: &str) -> Result<()> {
1140 // **Clears the reason too.** A stale `last_error` on a feed that is now
1141 // succeeding is worse than none: it is the aggregate below reporting a cause
1142 // that stopped applying, which is the failure this column exists to end.
1143 sqlx::query(
1144 "UPDATE feeds SET consecutive_errors = 0, last_error_kind = NULL, last_error = NULL \
1145 WHERE url = ?1",
1146 )
1147 .bind(url)
1148 .execute(pool)
1149 .await
1150 .with_context(|| format!("reset_feed_errors failed for {url}"))?;
1151 Ok(())
1152}
1153
1154/// The feeds a `did` currently subscribes to, per its `sub_ref` projection.
1155/// Used by the PDS-unreachable fallback in `resolve_subscriptions` to render
1156/// the sidebar from the caller's OWN last-known subscriptions (fail closed)
1157/// rather than every cached feed.
1158pub async fn feeds_for_did(pool: &SqlitePool, did: &str) -> Result<Vec<Feed>> {
1159 let feeds = sqlx::query_as::<_, Feed>(
1160 r#"
1161 SELECT f.* FROM feeds f
1162 JOIN sub_ref sr ON sr.feed_id = f.id AND sr.did = ?1
1163 ORDER BY f.title IS NULL, f.title, f.url
1164 "#,
1165 )
1166 .bind(did)
1167 .fetch_all(pool)
1168 .await
1169 .with_context(|| format!("feeds_for_did failed for {did}"))?;
1170 Ok(feeds)
1171}
1172
1173/// The feed ids a `did` currently subscribes to (its `sub_ref` rows).
1174///
1175/// **Not bounded by `max_subs_per_did`.** This comment used to claim it was, and
1176/// callers leaned on that: the cap is enforced on the ADD and OPML paths only,
1177/// never on read, and `sub_ref` is rebuilt from whatever the PDS returns — which
1178/// any client can write to, bounded only by the list-pages ceiling at 20,000
1179/// records. A claim in a comment is not a bound.
1180///
1181/// Callers must therefore not assume a small result. The one that cared — the
1182/// list views' scope filter — no longer does: it passes the whole set as a
1183/// single `json_each` bind rather than one SQL placeholder per feed.
1184pub async fn subscribed_feed_ids(pool: &SqlitePool, did: &str) -> Result<Vec<i64>> {
1185 let ids: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM sub_ref WHERE did = ?1")
1186 .bind(did)
1187 .fetch_all(pool)
1188 .await
1189 .with_context(|| format!("subscribed_feed_ids failed for {did}"))?;
1190 Ok(ids)
1191}
1192
1193/// The number of feeds a `did` currently subscribes to (its `sub_ref` rows).
1194/// Backs the per-DID subscription cap enforced at the add/import paths.
1195pub async fn count_subscriptions_for_did(pool: &SqlitePool, did: &str) -> Result<i64> {
1196 let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
1197 .bind(did)
1198 .fetch_one(pool)
1199 .await
1200 .with_context(|| format!("count_subscriptions_for_did failed for {did}"))?;
1201 Ok(n)
1202}
1203
1204/// The number of distinct feeds in the shared cache. Backs the global feeds
1205/// ceiling checked before a brand-new feed is inserted.
1206pub async fn count_feeds(pool: &SqlitePool) -> Result<i64> {
1207 let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM feeds")
1208 .fetch_one(pool)
1209 .await
1210 .context("count_feeds failed")?;
1211 Ok(n)
1212}
1213
1214/// The **used** size of the SQLite database, in bytes, computed as
1215/// `(page_count - freelist_count) * page_size`. Backs the DB-size watermark that
1216/// disables new polling.
1217///
1218/// Subtracting the freelist is what keeps the watermark from latching the poller
1219/// off: `page_count` counts pages the file has *allocated*, including ones freed
1220/// by a `DELETE` but not yet returned to the OS (SQLite keeps them on a freelist
1221/// for reuse and never shrinks the file without a VACUUM). Counting only the
1222/// live pages means a retention prune (which frees pages, see [`reclaim`]) is
1223/// actually reflected here, so the watermark can drop back below its threshold
1224/// and polling resumes. Cheap (three `PRAGMA` reads); works for file + `:memory:`.
1225///
1226/// **The WAL counts too.** This is the number the DB-size watermark compares
1227/// against a VOLUME size, and in WAL mode the `-wal` sidecar sits on that same
1228/// volume — so leaving it out understated exactly the quantity the watermark
1229/// exists to bound. It is added back below, best-effort: a WAL that cannot be
1230/// stat'd contributes zero rather than failing the check, since a watermark that
1231/// errors is worse than one that is slightly optimistic.
1232pub async fn db_size_bytes(pool: &SqlitePool) -> Result<i64> {
1233 let page_count: i64 = sqlx::query_scalar("PRAGMA page_count")
1234 .fetch_one(pool)
1235 .await
1236 .context("PRAGMA page_count failed")?;
1237 let freelist_count: i64 = sqlx::query_scalar("PRAGMA freelist_count")
1238 .fetch_one(pool)
1239 .await
1240 .context("PRAGMA freelist_count failed")?;
1241 let page_size: i64 = sqlx::query_scalar("PRAGMA page_size")
1242 .fetch_one(pool)
1243 .await
1244 .context("PRAGMA page_size failed")?;
1245 let used_pages = page_count.saturating_sub(freelist_count).max(0);
1246 Ok(used_pages
1247 .saturating_mul(page_size)
1248 .saturating_add(wal_bytes(pool).await))
1249}
1250
1251/// Bytes the write-ahead log currently occupies on the database's volume, or 0
1252/// when there is no WAL (`:memory:`, non-WAL journal modes) or it cannot be
1253/// stat'd. Best-effort by design — see [`db_size_bytes`].
1254async fn wal_bytes(pool: &SqlitePool) -> i64 {
1255 let Some(path) = main_db_path(pool).await else {
1256 return 0;
1257 };
1258 std::fs::metadata(format!("{path}-wal"))
1259 .map(|m| i64::try_from(m.len()).unwrap_or(i64::MAX))
1260 .unwrap_or(0)
1261}
1262
1263/// The main database's file path, or `None` for `:memory:`.
1264async fn main_db_path(pool: &SqlitePool) -> Option<String> {
1265 sqlx::query_scalar("SELECT file FROM pragma_database_list WHERE name = 'main' AND file <> ''")
1266 .fetch_optional(pool)
1267 .await
1268 .ok()
1269 .flatten()
1270}
1271
1272/// Freelist pages returned to the OS per `incremental_vacuum` step. At a 4 KiB
1273/// page that is ~8 MiB per batch — a short lock hold, and few enough steps that
1274/// a large reclaim is tens of statements rather than thousands.
1275const RECLAIM_BATCH_PAGES: i64 = 2_000;
1276
1277/// Backstop on the reclaim loop. `freelist_count == 0` and the no-progress check
1278/// are the real terminators; at [`RECLAIM_BATCH_PAGES`] this is 2M pages (~8 GiB),
1279/// far past anything a 1 GB volume holds.
1280const RECLAIM_MAX_BATCHES: usize = 1_000;
1281
1282/// Reclaim freed pages so the database file (and its used-page accounting) can
1283/// actually shrink after a retention/prune sweep DELETEs rows.
1284///
1285/// Without this, a `DELETE` moves pages onto the freelist but never shrinks the
1286/// file — so once the DB-size watermark trips and retention deletes rows,
1287/// `page_count` stays put and [`db_size_bytes`] (well, its raw `page_count`
1288/// form) would never fall back below the watermark, latching the poller off
1289/// forever. Call this AFTER a prune. It uses incremental vacuum when the database
1290/// is in `auto_vacuum = INCREMENTAL` mode (cheap, no full rewrite), and otherwise
1291/// falls back to a full `VACUUM`.
1292pub async fn reclaim(pool: &SqlitePool) -> Result<()> {
1293 match auto_vacuum_mode(pool).await? {
1294 AutoVacuum::Incremental => {
1295 // **Bounded, like the deletes that precede it.**
1296 //
1297 // With no page argument this reclaims the ENTIRE freelist in one
1298 // transaction — handing straight back the write-lock hold that
1299 // batching the retention deletes had just won, immediately after the
1300 // sweep that created the freelist in the first place. Same shape as
1301 // `delete_in_batches`: a bounded unit of work, then an explicit
1302 // hand-off so a waiting writer actually gets in.
1303 // **Both early exits are LOUD.** Failing to reclaim is the failure
1304 // this function exists to prevent: `db_size_bytes` stays high,
1305 // `poll_due_once` keeps polling paused, and `/stats` says "paused"
1306 // with nothing anywhere saying reclaim gave up. Exiting silently
1307 // makes that indistinguishable from a sweep that had nothing to do.
1308 let mut drained = true;
1309 for batch in 0..RECLAIM_MAX_BATCHES {
1310 let before: i64 = sqlx::query_scalar("PRAGMA freelist_count")
1311 .fetch_one(pool)
1312 .await
1313 .context("PRAGMA freelist_count failed")?;
1314 if before == 0 {
1315 break;
1316 }
1317 // A PRAGMA argument cannot be a bind parameter, and this one is
1318 // a `const i64` declared in this file — nothing external reaches
1319 // it.
1320 sqlx::query(sqlx::AssertSqlSafe(format!(
1321 "PRAGMA incremental_vacuum({RECLAIM_BATCH_PAGES})"
1322 )))
1323 .execute(pool)
1324 .await
1325 .context("PRAGMA incremental_vacuum failed")?;
1326 let after: i64 = sqlx::query_scalar("PRAGMA freelist_count")
1327 .fetch_one(pool)
1328 .await
1329 .context("PRAGMA freelist_count failed")?;
1330 // No progress: either nothing more can be freed, or a
1331 // concurrent retention delete pushed `after` back up. Both leave
1332 // pages allocated, which is what an operator needs to know.
1333 //
1334 // This comment previously also claimed "a long-lived WAL read
1335 // snapshot pins freelist pages". MEASURED AND FALSE: with a
1336 // reader holding a snapshot taken BEFORE the delete, the
1337 // freelist still drained 2000 → 0 and `page_count` halved. A
1338 // reader blocks the CHECKPOINT, not the incremental vacuum — so
1339 // that case exits this loop through the SUCCESS path and is
1340 // reported below, not here.
1341 if after >= before {
1342 tracing::warn!(
1343 freelist_pages = after,
1344 batches_run = batch + 1,
1345 "reclaim stopped making progress with pages still on the \
1346 freelist; the file will not shrink and the DB-size watermark \
1347 may stay engaged until the next sweep"
1348 );
1349 drained = false;
1350 break;
1351 }
1352 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1353 // `after > 0` matters: the final batch can drain the freelist
1354 // completely, in which case the loop reaches here having
1355 // SUCCEEDED and would otherwise log "with pages still on the
1356 // freelist" for an empty one — and suppress the success line.
1357 // This is the same guard `delete_in_batches` carries, and the
1358 // same defect it already had; reproduced here verbatim by
1359 // copying the loop's shape without its condition.
1360 if batch + 1 == RECLAIM_MAX_BATCHES && after > 0 {
1361 tracing::warn!(
1362 batches_run = batch + 1,
1363 freelist_pages = after,
1364 "reclaim hit its batch backstop with pages still on the \
1365 freelist; the rest waits for the next sweep"
1366 );
1367 drained = false;
1368 }
1369 }
1370 if drained {
1371 tracing::debug!("reclaim: freelist drained");
1372 }
1373 }
1374 // SQLite already returns freed pages at every commit in this mode.
1375 // Nothing to do, and a VACUUM would be pure cost.
1376 AutoVacuum::Full => {}
1377 // **Deliberately a no-op, where this used to run a full VACUUM.**
1378 //
1379 // Nothing ever set `auto_vacuum`, so NONE was the mode every database
1380 // actually ran in — which made the full-VACUUM branch the one that
1381 // always executed, daily and after every prune. A full VACUUM writes a
1382 // complete second copy of the database, so it needs free disk roughly
1383 // equal to the live file; that is precisely what is missing under the
1384 // disk pressure that triggers a retention sweep. `poll_due_once` already
1385 // carries a comment explaining this danger and removed VACUUM from the
1386 // poll path — while leaving it in the retention path that runs under the
1387 // same pressure.
1388 //
1389 // Skipping it does NOT latch the DB-size watermark, which is the failure
1390 // this branch was written to prevent: `db_size_bytes` subtracts the
1391 // freelist, so a DELETE lowers the measured size with no VACUUM at all.
1392 // What is lost is the FILE shrinking, and the fix for that is to get the
1393 // database into INCREMENTAL mode — see `migrate_to_incremental_vacuum`,
1394 // which is operator-invoked precisely because it needs the one operation
1395 // that is unsafe to attempt automatically.
1396 AutoVacuum::None => {
1397 tracing::warn!(
1398 "auto_vacuum=NONE: skipping reclaim. Freed pages stay allocated and \
1399 the file will not shrink. Run `featherreader --migrate-auto-vacuum` \
1400 once, while the volume has headroom, to move this database to \
1401 INCREMENTAL mode."
1402 );
1403 }
1404 }
1405
1406 // Truncate the WAL as well. It lives on the same volume and is counted by
1407 // `db_size_bytes`, so reclaiming database pages while leaving a WAL grown by
1408 // the sweep that just ran would give back part of the space and hold the
1409 // rest. Worth doing even in the NONE branch above, where it is the only
1410 // space this function can return at all.
1411 //
1412 // **A blocked checkpoint is the real way the file stays big, so it warns.**
1413 //
1414 // Measured: with a reader holding an open snapshot, `incremental_vacuum`
1415 // still drains the freelist and `page_count` halves — but the main file
1416 // stayed at 16.4 MB until the reader released and the checkpoint could
1417 // truncate it to 8.2 MB. So a reader does not stop the reclaim; it stops the
1418 // SHRINK. That is the operator-visible outcome (`db_size_bytes` counts the
1419 // WAL, and the watermark is compared against a volume), and it used to be
1420 // reported at `debug!` — below any realistic filter — while the loop above
1421 // warned loudly about a mechanism that does not actually occur.
1422 //
1423 // Not an error: the next sweep checkpoints again once the reader is gone.
1424 match checkpoint_wal(pool).await {
1425 Ok(true) => {}
1426 Ok(false) => tracing::warn!(
1427 "the WAL could not be truncated after reclaim (busy: a concurrent reader \
1428 OR writer held it); the freed pages are gone but the file has not \
1429 shrunk yet, and the DB-size watermark may stay engaged until the next \
1430 sweep"
1431 ),
1432 Err(err) => tracing::warn!(%err, "wal checkpoint after reclaim failed"),
1433 }
1434 Ok(())
1435}
1436
1437/// Run a truncating WAL checkpoint. `Ok(false)` means SQLite declined because a
1438/// reader held the WAL.
1439///
1440/// **The busy case is a ROW, not an error.** `PRAGMA wal_checkpoint` returns
1441/// `(busy, log_frames, checkpointed_frames)` and sets `busy = 1` when it could
1442/// not run — measured: `(1, 3, 3)` with one open read transaction versus
1443/// `(0, 0, 0)` without. So `if let Err(..)` never fires on the case it was
1444/// written for, and a caller that depends on the WAL actually being truncated
1445/// (the migration's size report does) would silently get the untruncated one.
1446async fn checkpoint_wal<'e, E>(conn: E) -> Result<bool>
1447where
1448 E: sqlx::Executor<'e, Database = sqlx::Sqlite>,
1449{
1450 let row: (i64, i64, i64) = sqlx::query_as("PRAGMA wal_checkpoint(TRUNCATE)")
1451 .fetch_one(conn)
1452 .await
1453 .context("PRAGMA wal_checkpoint(TRUNCATE) failed")?;
1454 Ok(row.0 == 0)
1455}
1456
1457/// A database's `auto_vacuum` mode.
1458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1459pub enum AutoVacuum {
1460 /// 0 — freed pages stay on the freelist; only a full `VACUUM` returns them.
1461 None,
1462 /// 1 — SQLite returns freed pages at every commit.
1463 Full,
1464 /// 2 — freed pages are returned on demand by `PRAGMA incremental_vacuum`.
1465 Incremental,
1466}
1467
1468/// Read the database's `auto_vacuum` mode.
1469pub async fn auto_vacuum_mode(pool: &SqlitePool) -> Result<AutoVacuum> {
1470 let mode: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
1471 .fetch_one(pool)
1472 .await
1473 .context("PRAGMA auto_vacuum failed")?;
1474 Ok(match mode {
1475 1 => AutoVacuum::Full,
1476 2 => AutoVacuum::Incremental,
1477 _ => AutoVacuum::None,
1478 })
1479}
1480
1481/// What [`migrate_to_incremental_vacuum`] did.
1482#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1483pub enum VacuumMigration {
1484 /// Already in a mode that reclaims; nothing was run.
1485 NotNeeded(AutoVacuum),
1486 /// Refused: not enough free space on the volume to hold the rebuilt file.
1487 ///
1488 /// `file_bytes` is the on-disk size, reported alongside the live-page figure
1489 /// the requirement is computed from, because on exactly this population
1490 /// (`NONE` mode, large freelist) the two differ a lot and only one of them
1491 /// matches what `ls -l` says.
1492 RefusedNoHeadroom {
1493 needed: u64,
1494 available: u64,
1495 file_bytes: Option<u64>,
1496 },
1497 /// Ran the pragma + full VACUUM; the database is now INCREMENTAL.
1498 Migrated {
1499 bytes_before: i64,
1500 bytes_after: i64,
1501 file_before: Option<u64>,
1502 file_after: Option<u64>,
1503 },
1504}
1505
1506/// Move a populated database from `auto_vacuum = NONE` to `INCREMENTAL`.
1507///
1508/// **Why this cannot happen at boot.** SQLite ignores `PRAGMA auto_vacuum` on a
1509/// database that already has tables unless it is followed by a full `VACUUM`,
1510/// which rebuilds the file. So the migration off the dangerous mode requires the
1511/// exact operation that is dangerous — a genuine chicken-and-egg, and the reason
1512/// this is an explicit operator step run when the volume has headroom rather
1513/// than something attempted lazily on a machine that is already under pressure.
1514///
1515/// Doing it automatically would also reintroduce the failure shape T2.1 just
1516/// removed: a boot-time VACUUM that cannot complete on a full volume, on a
1517/// supervisor that restarts the machine whenever a child exits, is a crash loop.
1518///
1519/// `available_bytes` is the caller's measurement of free space on the database's
1520/// volume (`None` where the platform cannot report it). The check is a refusal,
1521/// not a warning: starting a VACUUM that cannot finish wastes I/O on a box that
1522/// has none to spare. `VACUUM` itself is atomic — an interrupted one leaves the
1523/// original database intact — so the risk being managed here is wasted work and
1524/// a long write-lock hold, not corruption.
1525pub async fn migrate_to_incremental_vacuum(
1526 pool: &SqlitePool,
1527 available_bytes: Option<u64>,
1528) -> Result<VacuumMigration> {
1529 let mode = auto_vacuum_mode(pool).await?;
1530 if mode != AutoVacuum::None {
1531 return Ok(VacuumMigration::NotNeeded(mode));
1532 }
1533
1534 // **The on-disk file, not the live-page count.** `db_size_bytes` subtracts
1535 // the freelist, and the population this migration exists for is precisely
1536 // `auto_vacuum = NONE` with a large freelist — so the live size can be far
1537 // smaller than the file, and an operator comparing the refusal message to
1538 // `ls -l` would not trust either number. The rebuild is sized by the LIVE
1539 // pages (that is what gets copied), but the report shows both.
1540 let bytes_before = db_size_bytes(pool).await?;
1541 let file_before = main_db_file_bytes(pool).await;
1542 // Resolved BEFORE a connection is acquired below. Asking the pool for
1543 // anything while holding one of its connections deadlocks a saturated pool —
1544 // and a single-connection pool is always saturated. The first version of the
1545 // temp-directory block did exactly that, and because `main_db_path` swallows
1546 // errors into `None` it did not even fail loudly: it stalled for the full
1547 // acquire timeout and then silently skipped setting the directory, which is
1548 // the one thing it exists to do.
1549 let temp_dir = main_db_path(pool).await.and_then(|p| {
1550 std::path::Path::new(&p)
1551 .parent()
1552 .map(std::path::Path::to_path_buf)
1553 });
1554 let needed = (bytes_before.max(0) as u64).saturating_mul(2);
1555 if let Some(available) = available_bytes {
1556 if available < needed {
1557 return Ok(VacuumMigration::RefusedNoHeadroom {
1558 needed,
1559 available,
1560 file_bytes: file_before,
1561 });
1562 }
1563 }
1564
1565 // **One connection for both statements.**
1566 //
1567 // `PRAGMA auto_vacuum` on a populated database is connection-scoped INTENT
1568 // that only takes effect when the SAME connection runs the VACUUM. Issued
1569 // against the pool they can land on different connections, and the rebuild
1570 // then happens in NONE mode — caught by the `ensure!` below, so loud rather
1571 // than silent, but the operator has paid a whole-file rewrite for nothing on
1572 // a box chosen for being short of disk.
1573 let mut conn = pool
1574 .acquire()
1575 .await
1576 .context("acquiring a connection for the auto_vacuum migration")?;
1577
1578 // **Put the temp copy on the DATABASE's volume.**
1579 //
1580 // A VACUUM rebuilds through a temporary database, and the headroom check
1581 // above measures the data volume. `temp_store = FILE` alone only chooses
1582 // file-over-memory; it does NOT choose which filesystem, so the temp copy
1583 // resolved via `SQLITE_TMPDIR`/`TMPDIR`/`/var/tmp`/`/tmp` — the container
1584 // rootfs. The check could pass on `/data` and the VACUUM still hit
1585 // `SQLITE_FULL`, or fill the rootfs out from under Caddy.
1586 //
1587 // `temp_store_directory` is the pragma that actually decides — measured:
1588 // setting it alone moves the file, setting `temp_store = FILE` alone does
1589 // not. It is deprecated but fully functional in the bundled SQLite (3.51.3,
1590 // built without `SQLITE_OMIT_DEPRECATED`), and there is no non-deprecated
1591 // equivalent reachable from a connection.
1592 //
1593 // `temp_store = FILE` is kept as belt-and-braces rather than because it is
1594 // needed: this build's compile-time default is already FILE, but a build
1595 // defaulting to MEMORY would silently ignore the directory entirely.
1596 //
1597 // Note it sets the PROCESS-GLOBAL `sqlite3_temp_directory`, not connection
1598 // state — visible on other connections and other pools. Harmless because
1599 // this function is only reachable from the one-shot `--migrate-auto-vacuum`
1600 // CLI path, which does nothing else.
1601 sqlx::query("PRAGMA temp_store = FILE")
1602 .execute(&mut *conn)
1603 .await
1604 .context("PRAGMA temp_store = FILE failed")?;
1605 if let Some(dir) = temp_dir.clone() {
1606 // The path comes from SQLite's own `database_list`, not from a caller.
1607 let quoted = dir.display().to_string().replace('\'', "''");
1608 if let Err(err) = sqlx::query(sqlx::AssertSqlSafe(format!(
1609 "PRAGMA temp_store_directory = '{quoted}'"
1610 )))
1611 .execute(&mut *conn)
1612 .await
1613 {
1614 // Not fatal: the VACUUM can still succeed if the default temp
1615 // location happens to have room. But the headroom check is then
1616 // measuring the wrong filesystem, so say so.
1617 tracing::warn!(
1618 %err, dir = %dir.display(),
1619 "could not point SQLite's temp storage at the database volume; the \
1620 headroom check may not cover where the VACUUM actually writes"
1621 );
1622 }
1623 }
1624
1625 // Order matters: the pragma records the INTENT, and the VACUUM is what
1626 // actually rewrites the file in the new mode. Reversed, the VACUUM would
1627 // rebuild in NONE mode and the pragma would then be ignored again.
1628 sqlx::query("PRAGMA auto_vacuum = INCREMENTAL")
1629 .execute(&mut *conn)
1630 .await
1631 .context("PRAGMA auto_vacuum = INCREMENTAL failed")?;
1632 sqlx::query("VACUUM")
1633 .execute(&mut *conn)
1634 .await
1635 .context("VACUUM failed during the auto_vacuum migration")?;
1636
1637 // Fold the WAL back in BEFORE measuring. A VACUUM in WAL mode writes the
1638 // entire rebuilt database through the WAL, which keeps that high-water size
1639 // until a truncating checkpoint — and `db_size_bytes` now counts the WAL. So
1640 // the one number this command reports read as "the migration doubled my
1641 // database", which is the opposite of what it did.
1642 match checkpoint_wal(&mut *conn).await {
1643 Ok(true) => {}
1644 // Reported, because the size this function returns is computed straight
1645 // after and would otherwise read as "the migration doubled my database"
1646 // with nothing saying why.
1647 Ok(false) => tracing::warn!(
1648 "the WAL could not be truncated (a concurrent reader OR writer held it), \
1649 so the reported size below includes it"
1650 ),
1651 Err(err) => tracing::warn!(%err, "post-migration wal checkpoint failed"),
1652 }
1653
1654 // Verified on the HELD connection, then released before anything that goes
1655 // back to the pool. The test pool is single-connection, and so is a
1656 // production pool that happens to be saturated — reaching for a second one
1657 // while still holding the first is a deadlock waiting for a busy moment.
1658 let after_raw: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
1659 .fetch_one(&mut *conn)
1660 .await
1661 .context("PRAGMA auto_vacuum failed after the migration")?;
1662 drop(conn);
1663 let after = match after_raw {
1664 1 => AutoVacuum::Full,
1665 2 => AutoVacuum::Incremental,
1666 _ => AutoVacuum::None,
1667 };
1668 anyhow::ensure!(
1669 after == AutoVacuum::Incremental,
1670 "the auto_vacuum migration ran but the database is still in {after:?} mode"
1671 );
1672 Ok(VacuumMigration::Migrated {
1673 bytes_before,
1674 bytes_after: db_size_bytes(pool).await?,
1675 file_before,
1676 file_after: main_db_file_bytes(pool).await,
1677 })
1678}
1679
1680/// Size of the main database FILE on disk, or `None` for `:memory:` / an
1681/// unstattable path. Distinct from [`db_size_bytes`], which reports live pages.
1682async fn main_db_file_bytes(pool: &SqlitePool) -> Option<u64> {
1683 let path = main_db_path(pool).await?;
1684 std::fs::metadata(path).ok().map(|m| m.len())
1685}
1686
1687/// Insert a batch of entries for `feed_id`, deduping on `(feed_id, guid)`, then
1688/// trim the feed to at most [`crate::config`]-configured `max_entries_per_feed`
1689/// rows (newest by published date) so one firehose feed can't fill the disk.
1690///
1691/// On a GUID collision the existing entry is updated in place (title/url/body
1692/// may have changed on re-fetch) rather than duplicated. Runs in one
1693/// transaction. Returns the number of rows processed.
1694///
1695/// `max_entries_per_feed <= 0` disables the per-feed trim.
1696pub async fn insert_entries(
1697 pool: &SqlitePool,
1698 feed_id: i64,
1699 entries: &[NewEntry],
1700 max_entries_per_feed: i64,
1701) -> Result<u64> {
1702 let mut tx = pool.begin().await.context("begin insert_entries tx")?;
1703 let mut count: u64 = 0;
1704 for e in entries {
1705 let fetched_at = e.fetched_at.clone().unwrap_or_else(now_rfc3339);
1706 let res = sqlx::query(
1707 r#"
1708 INSERT INTO entries
1709 (feed_id, guid, url, title, author, published, content_html, fetched_at)
1710 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1711 ON CONFLICT (feed_id, guid) DO UPDATE SET
1712 url = excluded.url,
1713 title = excluded.title,
1714 author = excluded.author,
1715 published = excluded.published,
1716 content_html = CASE WHEN ?9 THEN entries.content_html
1717 ELSE excluded.content_html END
1718 "#,
1719 )
1720 .bind(feed_id)
1721 .bind(&e.guid)
1722 .bind(&e.url)
1723 .bind(&e.title)
1724 .bind(&e.author)
1725 .bind(&e.published)
1726 .bind(&e.content_html)
1727 .bind(&fetched_at)
1728 .bind(e.keep_stored_content)
1729 .execute(&mut *tx)
1730 .await
1731 .with_context(|| format!("insert entry {} failed", e.guid))?;
1732 count += res.rows_affected();
1733 }
1734
1735 // Entries-per-feed cap: keep only the newest `max_entries_per_feed` rows for
1736 // this feed, deleting the overflow in the same transaction. "Newest" is
1737 // COALESCE(published, fetched_at) so an UNDATED entry (NULL published) sorts
1738 // by when we fetched it (NOT NULL) rather than always sorting LAST and being
1739 // evicted first — otherwise a feed of undated items would trim its freshest
1740 // rows. This bounds a single firehose/misbehaving feed's storage footprint
1741 // independent of the global retention sweep. `<= 0` disables it.
1742 //
1743 // The bound is `2 * max_entries_per_feed`, not `max_entries_per_feed`: the
1744 // newest N by date, plus up to N starred. See the sparing subquery below.
1745 if max_entries_per_feed > 0 {
1746 sqlx::query(
1747 r#"
1748 DELETE FROM entries
1749 WHERE feed_id = ?1
1750 AND id NOT IN (
1751 SELECT id FROM entries
1752 WHERE feed_id = ?1
1753 ORDER BY COALESCE(published, fetched_at) DESC, id DESC
1754 LIMIT ?2
1755 )
1756 -- Starred entries survive the per-feed trim, exactly as they
1757 -- survive the retention sweep. This predicate was added to the
1758 -- sweep and NOT here, which left the documented guarantee
1759 -- ("starred entries are never evicted") false — and made this
1760 -- path, which runs on every poll of every feed rather than daily,
1761 -- the main producer of the very "starred but not cached" case the
1762 -- saved-record rendering exists to paper over.
1763 --
1764 -- The sparing is BOUNDED and SCOPED, and both matter:
1765 --
1766 -- Bounded, because the first version spared every starred row
1767 -- without limit, which did not weaken the cap so much as remove
1768 -- it — measured at cap=5 with 50 starred rows, 55 survived, 11x
1769 -- the cap. That is the same unbounded-sparing mistake the
1770 -- retention hard ceiling was added to fix, reintroduced in the
1771 -- other sweep. Worst case is now cap + cap.
1772 --
1773 -- Scoped, because `SELECT entry_id FROM entry_state WHERE
1774 -- starred = 1` reads EVERY starred row on the instance, for every
1775 -- poll of every feed — cost scaling with total users rather than
1776 -- with the feed being trimmed.
1777 AND id NOT IN (
1778 SELECT e2.id FROM entries e2
1779 WHERE e2.feed_id = ?1
1780 AND EXISTS (
1781 SELECT 1 FROM entry_state s
1782 WHERE s.entry_id = e2.id AND s.starred = 1
1783 )
1784 ORDER BY COALESCE(e2.published, e2.fetched_at) DESC, e2.id DESC
1785 LIMIT ?2
1786 )
1787 "#,
1788 )
1789 .bind(feed_id)
1790 .bind(max_entries_per_feed)
1791 .execute(&mut *tx)
1792 .await
1793 .with_context(|| format!("trimming feed {feed_id} to {max_entries_per_feed} entries"))?;
1794 }
1795
1796 // Per-feed trim above may have DELETEd entries; their ids can linger in the
1797 // read_cursor exception sets (read_ids/unread_ids have no FK to entries), so
1798 // scrub the orphaned ids out of THIS feed's cursors in the same transaction.
1799 // Bounds id-set growth and keeps the flushed PDS record from referencing
1800 // entries that no longer exist. Scoped to the one feed for cheapness.
1801 if max_entries_per_feed > 0 {
1802 prune_orphan_cursor_ids_tx(&mut tx, Some(feed_id)).await?;
1803 }
1804
1805 tx.commit().await.context("commit insert_entries tx")?;
1806 Ok(count)
1807}
1808
1809/// Make a feed due for polling on the next tick.
1810///
1811/// Used when a saved article is missing from the cache: if the reader still
1812/// subscribes to the feed, the poller may be able to bring the article back on
1813/// its own. Clearing `next_poll` is the whole mechanism — `due_feeds` treats
1814/// NULL as due — so this adds no synthetic rows and no special-case fetch path.
1815///
1816/// **Rate-limited by `not_polled_since`**, and that is not a nicety.
1817///
1818/// `due_feeds` treats a NULL `next_poll` as due immediately, so clearing it
1819/// unconditionally from a page handler meant every reload of the starred view
1820/// made those feeds due again — bypassing the poll interval entirely. That is
1821/// outbound amplification against third-party feed origins, and it lets one
1822/// reader's feeds monopolise a poll budget that is shared and already the
1823/// binding constraint on how many readers an instance can serve.
1824///
1825/// A feed polled within the window is left alone: if the article was not in the
1826/// feed a minute ago, another fetch now will not find it either. The nudge is
1827/// therefore worth at most one extra poll per feed per interval, which is the
1828/// cadence the poller already targets.
1829///
1830/// A no-op if the URL is not a known feed.
1831pub async fn mark_feed_due(
1832 pool: &SqlitePool,
1833 feed_url: &str,
1834 not_polled_since: &str,
1835) -> Result<()> {
1836 sqlx::query(
1837 "UPDATE feeds SET next_poll = NULL \
1838 WHERE url = ?1 AND (last_polled IS NULL OR last_polled < ?2)",
1839 )
1840 .bind(feed_url)
1841 .bind(not_polled_since)
1842 .execute(pool)
1843 .await
1844 .context("marking a feed due")?;
1845 Ok(())
1846}
1847
1848/// Delete entries whose age exceeds the retention window — the shared cache's
1849/// **rolling window** — except those a reader has starred or not yet read. "Age" is `COALESCE(published, fetched_at)` so an UNDATED
1850/// entry falls back to when it was fetched (never NULL) rather than being treated
1851/// as infinitely old. `entry_state` cascades via its `ON DELETE CASCADE` FK.
1852///
1853/// After the delete, orphaned entry ids are scrubbed out of every affected feed's
1854/// `read_cursor` exception sets (which have no FK to `entries`) so the id-sets do
1855/// not grow without bound and the flushed PDS record never references a vanished
1856/// entry. The caller (the retention sweep) should follow a non-zero return with
1857/// [`reclaim`] so freed pages return to the OS.
1858///
1859/// The two knobs are **independent**. `days == 0` disables the rolling window and
1860/// nothing else; `hard_days == 0` disables the ceiling and nothing else. Only
1861/// when both are off is this a no-op. Returns the number of entry rows deleted.
1862pub async fn prune_old_entries(
1863 pool: &SqlitePool,
1864 days: i64,
1865 hard_days: i64,
1866 publication_days: i64,
1867) -> Result<u64> {
1868 let now = chrono::Utc::now();
1869 // **A window too large to be a date disables that pass; it must not panic.**
1870 //
1871 // `chrono::Duration::days` and `DateTime - TimeDelta` both panic out of
1872 // range, and every knob here parses from a `u32` with no upper bound — so
1873 // `FEATHERREADER_RETENTION_DAYS=1000000000` (a plausible unit slip: seconds or
1874 // milliseconds typed into a days field) panicked this function. Measured:
1875 // anything past roughly 96 million days overflows, and `u32::MAX` does.
1876 //
1877 // The consequence was not a crash an operator would notice. This runs in a
1878 // spawned task, so tokio catches the panic and the retention sweeper simply
1879 // stops for the life of the process — silently, permanently, and taking the
1880 // release valve for `db_size_watermark_bytes` with it, which is the one thing
1881 // that stops polling for every reader.
1882 //
1883 // Disabled-not-panicking is also the answer `standard_site::ingest_floor`
1884 // already gives for the same input, and the two are supposed to mirror each
1885 // other — `Config::retention_for` exists to keep them agreeing. An
1886 // unrepresentable window meant "store everything" there and "panic" here.
1887 let at = |d: i64, knob: &str| -> Option<String> {
1888 let cutoff = chrono::Duration::try_days(d).and_then(|w| now.checked_sub_signed(w));
1889 if cutoff.is_none() {
1890 tracing::warn!(
1891 days = d,
1892 knob,
1893 "retention window is too large to express as a date; treating it as \
1894 disabled for this sweep rather than failing the sweeper"
1895 );
1896 }
1897 cutoff.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
1898 };
1899
1900 let cutoff = (days > 0).then(|| at(days, "retention_days")).flatten();
1901 // **The third window, for the kinds age does not bound.** See
1902 // [`AGED_KINDS_SQL`] and `FeedKind::AGED`: a publication's entries are
1903 // bounded by COUNT (the per-feed trim), because a 14-day window stored zero
1904 // rows from every real publication measured. This is the backstop that keeps
1905 // "not aged out" from meaning "immortal" — the per-feed trim only runs when a
1906 // poll stores something, so rows belonging to a feed nobody polls any more
1907 // have nothing else to reap them.
1908 let publication_cutoff = (publication_days > 0)
1909 .then(|| at(publication_days, "publication_retention_days"))
1910 .flatten();
1911 // The ceiling only means anything if it is STRICTLY OLDER than the window.
1912 // At `0 < hard_days <= days` the two cutoffs coincide, and since the hard
1913 // delete spares nothing, it would delete exactly the rows the soft delete
1914 // exists to spare — turning the whole starred/unread exception into a no-op.
1915 // With no window at all (`days <= 0`) there is nothing to be inside of, so a
1916 // positive ceiling stands on its own.
1917 //
1918 // This used to be `hard_days.max(days)`, which clamps the wrong way: it made
1919 // `0` — the value an operator reaches for to turn a ceiling OFF, and the
1920 // documented "disabled" value for `RETENTION_DAYS` one line above it in the
1921 // same table — the single most destructive setting available, silently
1922 // purging starred and unread entries at the soft window. Measured: with
1923 // `days=14`, `hard=0` deleted a 30-day starred entry and a 30-day unread one.
1924 //
1925 // `<= 0` now means disabled, consistently with `days`. A contradictory
1926 // positive value is refused rather than reinterpreted downward.
1927 //
1928 // The ceiling is deliberately NOT gated on the window being enabled. It used
1929 // to be — this function returned on `days <= 0` before the ceiling was even
1930 // computed — which made `RETENTION_DAYS=0` mean "no window AND no ceiling":
1931 // the one configuration with no bound on the shared cache whatsoever. That
1932 // became load-bearing when the per-feed trim started sparing starred entries.
1933 // Before, the trim was a backstop for them; now nothing was. "I don't want a
1934 // rolling window" and "I don't want any ceiling at all" are different
1935 // statements, and are now configured separately.
1936 let hard_cutoff = if hard_days > 0 && (days <= 0 || hard_days > days) {
1937 at(hard_days, "retention_hard_days")
1938 } else {
1939 if hard_days > 0 {
1940 tracing::warn!(
1941 hard_days,
1942 days,
1943 "retention hard ceiling is not older than the retention window; \
1944 ignoring it — set it above the window or to 0 to disable"
1945 );
1946 }
1947 None
1948 };
1949
1950 if cutoff.is_none() && hard_cutoff.is_none() && publication_cutoff.is_none() {
1951 return Ok(0);
1952 }
1953
1954 // **The hard ceiling — the bound that sparing would otherwise remove.**
1955 //
1956 // Sparing `read = 0` is not a small exception: "mark unread" is a one-click
1957 // UI control, and `entries` is SHARED across every reader on the instance.
1958 // Without a ceiling, one person can pin unbounded rows, and the pins are
1959 // permanent.
1960 //
1961 // That matters beyond disk. `poll_due_once` stops ALL polling once the
1962 // database crosses `db_size_watermark_bytes`, and the retention DELETE is
1963 // the documented release valve. Pinned rows can hold the valve shut
1964 // forever, so the failure mode is: one reader pins enough content, the DB
1965 // latches above the watermark, and polling stops for EVERY reader with no
1966 // self-healing path. The window used to be an unconditional bound; sparing
1967 // removed it, and this restores it.
1968 //
1969 // Starred entries go too at this age, and that is now safe: a saved record
1970 // whose entry is gone renders from the PDS record as a link card, so the
1971 // reader keeps the article's identity even when the cache does not keep its
1972 // text.
1973 let hard_deleted = match &hard_cutoff {
1974 Some(cutoff) => {
1975 delete_in_batches(
1976 pool,
1977 // Scoped to the kinds the window applies to. A publication's
1978 // entries answer to `publication_cutoff` below instead, which is
1979 // generous where this is tight — an archive read is not a cache
1980 // of the last few days.
1981 &format!(
1982 "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1 \
1983 AND feed_id IN (SELECT id FROM feeds WHERE kind IN ({AGED_KINDS_SQL}))"
1984 ),
1985 cutoff,
1986 "hard ceiling",
1987 )
1988 .await?
1989 }
1990 None => 0,
1991 };
1992 // **Entries a reader has DELIBERATELY marked are kept, whatever their age.**
1993 //
1994 // Precisely: an entry is spared when some DID has an `entry_state` row for
1995 // it with `starred = 1` or `read = 0`. An entry nobody has ever touched has
1996 // no `entry_state` row at all and is NOT spared, even though every read path
1997 // treats "no row" as unread.
1998 //
1999 // That asymmetry is deliberate and load-bearing. Sparing every never-touched
2000 // entry would spare essentially the whole table — almost no entry is ever
2001 // interacted with — which would make the window a no-op and leave the hard
2002 // ceiling as the only bound. The window is for evicting cache nobody claimed;
2003 // the exception is for the things a reader acted on.
2004 //
2005 // This comment used to read "starred and unread entries are kept", which is
2006 // the reading that would motivate exactly that change.
2007 //
2008 // The window is a cache eviction policy, not a data-retention policy. The
2009 // PDS is the source of truth for what a reader CHOSE — subscriptions,
2010 // folders, stars, read-state — but the entry CONTENT was never there. It
2011 // exists here and at the origin feed, and a feed typically serves only its
2012 // last few dozen items, so a pruned article is usually unrecoverable.
2013 //
2014 // Deleting indiscriminately therefore lost two things a reader would notice:
2015 // a starred article vanished from the starred view entirely (the view joins
2016 // `entries`, and `entry_state` cascades on the delete, so the star went with
2017 // it), and anything still unread disappeared before it was ever read. Both
2018 // are the opposite of a cache.
2019 //
2020 // This is what the documentation has always described; the query did not
2021 // implement it.
2022 let soft_deleted = match &cutoff {
2023 Some(cutoff) => {
2024 delete_in_batches(
2025 pool,
2026 // **`NOT EXISTS`, not `id NOT IN (…)`.**
2027 //
2028 // The list form materialises the ENTIRE pinned set on every
2029 // batch, and that set scales with total users rather than with
2030 // the feed being swept; this probes `idx_entry_state_entry_id`
2031 // per candidate row instead. Measured on 1M entries with 600k
2032 // `entry_state` rows of which 10% are pinned: **64.8 s as a list,
2033 // 43.6 s as a correlated exists — 1.49x, for no disk and no write
2034 // amplification.**
2035 //
2036 // **An earlier version of this comment claimed 2.4x, and that a
2037 // partial index on the pinned predicate "changed the time by
2038 // nothing at all". Both were artifacts of a bad fixture.** It
2039 // made every `entry_state` row match `starred = 1 OR read = 0` —
2040 // no "read and not starred" rows at all, which is the commonest
2041 // state a reader leaves behind. That inflated the list form's
2042 // cost (the materialised set was the whole table) and made a
2043 // PARTIAL index on that predicate cover 100% of rows, so it could
2044 // not be selective and duly did nothing.
2045 //
2046 // On a realistic distribution the review's proposed index is NOT
2047 // useless: it takes the list form from 64.8 s to 44.0 s, most of
2048 // the way to the rewrite. The rewrite is still the better change
2049 // because it costs no disk and no insert throughput — but it wins
2050 // by less than claimed, against an alternative that was dismissed
2051 // on a measurement of the wrong thing.
2052 //
2053 // Indexes are still declined, now on honest numbers: the pinned
2054 // index buys 12% (43.6 → 38.5 s) for 6.9 MiB, the age index 22%
2055 // (→ 33.9 s) for 27.9 MiB, both with write amplification on a
2056 // poller that inserts constantly, against a daily sweep that is
2057 // already batched and interruptible. See
2058 // `store::tests::r6_measure_retention_sweep`.
2059 //
2060 // Also strictly safer. `NOT IN` against a subquery containing a
2061 // NULL evaluates to NULL for every row, which would silently
2062 // delete nothing. `entry_state.entry_id` is `NOT NULL` today, so
2063 // the two are equivalent — but the equivalence depends on a
2064 // column constraint somewhere else, and `NOT EXISTS` does not.
2065 // `sparing_honours_every_did_not_just_one` pins the multi-DID
2066 // case, which is the only one where the forms could diverge.
2067 &format!(
2068 "SELECT e.id FROM entries e \
2069 WHERE COALESCE(e.published, e.fetched_at) < ?1 \
2070 AND e.feed_id IN \
2071 (SELECT id FROM feeds WHERE kind IN ({AGED_KINDS_SQL})) \
2072 AND NOT EXISTS ( \
2073 SELECT 1 FROM entry_state s \
2074 WHERE s.entry_id = e.id \
2075 AND (s.starred = 1 OR s.read = 0) \
2076 )"
2077 ),
2078 cutoff,
2079 "window",
2080 )
2081 .await?
2082 }
2083 None => 0,
2084 };
2085 // **The archive ceiling, for every kind the window does not cover.**
2086 //
2087 // `kind NOT IN` rather than `kind = 'publication'` deliberately: a kind added
2088 // later and left out of `FeedKind::AGED` inherits a bound here rather than
2089 // inheriting immortality. Spares nothing, for the reason the hard ceiling
2090 // spares nothing — a saved record whose entry is gone still renders from the
2091 // PDS record as a link card, so the reader keeps the article's identity.
2092 let publication_deleted = match &publication_cutoff {
2093 Some(cutoff) => {
2094 delete_in_batches(
2095 pool,
2096 &format!(
2097 "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1 \
2098 AND feed_id IN (SELECT id FROM feeds WHERE kind NOT IN ({AGED_KINDS_SQL}))"
2099 ),
2100 cutoff,
2101 "archive ceiling",
2102 )
2103 .await?
2104 }
2105 None => 0,
2106 };
2107 let deleted = soft_deleted + hard_deleted + publication_deleted;
2108
2109 // Only touch cursors when rows actually went away — and OUTSIDE the deletes.
2110 //
2111 // This used to run inside the one transaction that wrapped both deletes,
2112 // which made the whole sweep a single write-lock hold: load every
2113 // `read_cursor` row, then issue a fresh per-cursor `SELECT … JOIN … WHERE
2114 // f.url = ?` returning up to `max_entries_per_feed` ids, all before the
2115 // commit. SQLite is single-writer and `busy_timeout` is 5 s, so for that
2116 // whole span every mark-read, every login write and every cursor flush
2117 // failed.
2118 //
2119 // Correctness survives the move because the scrub is idempotent — it
2120 // computes each cursor's surviving ids from what is in `entries` NOW, and
2121 // rewrites only cursors that actually change. If the process dies between
2122 // the deletes and the scrub, the next sweep finishes the job, and in the
2123 // meantime a stale id in an exception set is inert: the flusher sends it,
2124 // and it names an entry nobody can reach.
2125 if deleted > 0 {
2126 if let Err(err) = prune_orphan_cursor_ids(pool, None).await {
2127 // The deletes already committed and are the point of this call.
2128 // A failed scrub leaves stale ids to be cleaned up next sweep.
2129 tracing::warn!(%err, "retention sweep: cursor id scrub failed after the deletes");
2130 }
2131 }
2132
2133 Ok(deleted)
2134}
2135
2136/// Rows deleted per statement by [`delete_in_batches`].
2137///
2138/// Small enough that one batch — including its `entry_state` FK cascade — is a
2139/// short lock hold, large enough that a big sweep is tens of statements rather
2140/// than thousands.
2141const PRUNE_BATCH: i64 = 1_000;
2142
2143/// Backstop against a delete loop that never drains. `rows_affected == 0` is the
2144/// real terminator; this only bounds the damage if a future predicate change
2145/// makes that untrue. At [`PRUNE_BATCH`] this is 10M rows, far past anything a
2146/// 1 GB volume holds.
2147const PRUNE_MAX_BATCHES: usize = 10_000;
2148
2149/// How long [`delete_in_batches`] stands down between batches, so a writer
2150/// waiting on the SQLite write lock actually gets it rather than losing the race
2151/// to the loop's next statement.
2152///
2153/// Named because it is the one thing that makes batching a fix rather than
2154/// bookkeeping, and because `a_writer_gets_through_while_the_sweep_runs` derives
2155/// its "was this sweep long enough to measure" floor from it. A sweep that is
2156/// genuinely batched cannot finish faster than one hand-off per batch; that is a
2157/// structural lower bound, not a number calibrated against a particular machine.
2158const PRUNE_BATCH_HANDOFF: std::time::Duration = std::time::Duration::from_millis(10);
2159
2160/// Delete every entry matched by `select_ids` (a `SELECT id FROM entries …`
2161/// bound to one `?1` cutoff), in bounded batches, **one implicit transaction per
2162/// batch**.
2163///
2164/// The retention sweep used to be a single `DELETE` inside one explicit
2165/// transaction. On a populated instance that is one unbroken write-lock hold
2166/// covering tens of thousands of row deletes plus their `entry_state` cascades —
2167/// measured at ~10 minutes before `idx_entry_state_entry_id` existed, and still
2168/// a single indivisible span after it. Everything else that writes (mark-read,
2169/// login, cursor flush) has a 5 s `busy_timeout` and simply fails for the
2170/// duration.
2171///
2172/// Batching does not make the total work smaller; it makes it INTERRUPTIBLE. A
2173/// writer waiting on the lock gets in between batches instead of timing out, and
2174/// the short sleep below guarantees that window actually exists rather than
2175/// leaving it to chance against a tight loop.
2176///
2177/// A partial sweep is safe: each batch commits on its own, and the predicate is
2178/// a fixed cutoff, so a crash mid-sweep leaves fewer rows deleted and the next
2179/// run finishes the job.
2180async fn delete_in_batches(
2181 pool: &SqlitePool,
2182 select_ids: &str,
2183 cutoff: &str,
2184 label: &str,
2185) -> Result<u64> {
2186 let sql = format!("DELETE FROM entries WHERE id IN ({select_ids} LIMIT {PRUNE_BATCH})");
2187 let mut total: u64 = 0;
2188 for batch in 0..PRUNE_MAX_BATCHES {
2189 let n = sqlx::query(sqlx::AssertSqlSafe(sql.clone()))
2190 .bind(cutoff)
2191 .execute(pool)
2192 .await
2193 .with_context(|| format!("prune_old_entries {label} (cutoff {cutoff})"))?
2194 .rows_affected();
2195 total += n;
2196 if n == 0 {
2197 return Ok(total);
2198 }
2199 // Hand the write lock over, so the loop cannot re-acquire it the instant
2200 // it commits and leave a waiting writer to fight for the gap between two
2201 // statements. At `PRUNE_BATCH` rows per batch this adds one
2202 // `PRUNE_BATCH_HANDOFF` per 1,000 deleted rows to a sweep that runs once
2203 // a day.
2204 //
2205 // This comment has twice carried a number it could not support. It first
2206 // said a writer "still starves" without the hand-off; that was replaced
2207 // with "roughly 3x writer throughput", quoting one sample from each of
2208 // two runs. Repeated, the two distributions overlap heavily (medians
2209 // ~1.4 writes/ms with the sleep against ~1.0 without, and several
2210 // sleep-less runs beat the median with it), so 3x is not a figure this
2211 // comment can assert.
2212 //
2213 // What is defensible without a benchmark: removing it lets the loop
2214 // re-acquire immediately, so a waiting writer is left racing the gap
2215 // between two statements instead of being handed a window. Writers do
2216 // still get through either way. `a_writer_gets_through_while_the_sweep_runs`
2217 // catches the removal about three runs in five — see the note there; the
2218 // rest of the time the loop still looks batched, because it is.
2219 tokio::time::sleep(PRUNE_BATCH_HANDOFF).await;
2220 // Only warn if the backstop actually cut the sweep short. A final batch
2221 // that happened to drain the last rows would otherwise log "the rest
2222 // waits for the next run" with nothing left — and an operator who reads
2223 // that during an incident would go looking for a backlog that is not
2224 // there. `n < PRUNE_BATCH` means this batch found fewer rows than it
2225 // asked for, so there are none behind it.
2226 // Still a 1-in-`PRUNE_BATCH` false positive when the final batch drains
2227 // exactly a full batch with nothing behind it — distinguishing that
2228 // needs another COUNT per sweep, which is not worth paying to make a
2229 // backstop message that has never fired slightly more precise.
2230 if batch + 1 == PRUNE_MAX_BATCHES && n == PRUNE_BATCH as u64 {
2231 tracing::warn!(
2232 label,
2233 total,
2234 "retention sweep hit its batch backstop; the rest waits for the next run"
2235 );
2236 }
2237 }
2238 Ok(total)
2239}
2240
2241/// Scrub entry ids that no longer exist out of `read_cursor.read_ids` /
2242/// `unread_ids`. `read_cursor` is keyed by `(did, feed_url)` and its id-sets have
2243/// NO foreign key to `entries`, so a prune/trim that deletes entries would
2244/// otherwise leave dangling ids that (a) grow the sets without bound and (b) get
2245/// flushed to the PDS as references to vanished entries.
2246///
2247/// When `feed_id` is `Some`, only that feed's cursors are examined (the cheap
2248/// path used right after a per-feed trim); `None` scans every cursor (the
2249/// retention sweep, which can delete across many feeds at once). A cursor whose
2250/// sets actually change is rewritten and marked `dirty` so the flusher resyncs
2251/// it; unchanged cursors are left untouched (no spurious dirtying / PDS writes).
2252/// Returns the number of cursor rows modified.
2253///
2254/// This is the TRANSACTIONAL variant, used by the per-feed trim inside
2255/// `insert_entries`: it is scoped to one feed, examines that feed's cursors
2256/// only, and genuinely wants to land atomically with the trim that created the
2257/// orphans. The retention sweep uses [`prune_orphan_cursor_ids`] instead —
2258/// global scope inside one transaction is what made the sweep a multi-minute
2259/// write-lock hold.
2260async fn prune_orphan_cursor_ids_tx(
2261 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
2262 feed_id: Option<i64>,
2263) -> Result<u64> {
2264 // The set of live entry ids we prune against. Scope to the feed's URL when a
2265 // feed_id is given so we filter only that feed's cursors against that feed's
2266 // entries; otherwise consider all cursors / all entries.
2267 let feed_url = match feed_id {
2268 Some(fid) => match feed_url_for_id_tx(tx, fid).await? {
2269 Some(u) => Some(u),
2270 None => return Ok(0), // feed vanished mid-tx; nothing to prune
2271 },
2272 None => None,
2273 };
2274
2275 // Load the (did, feed_url, read_ids, unread_ids) of the candidate cursors.
2276 let cursors: Vec<(String, String, String, String)> = match &feed_url {
2277 Some(url) => sqlx::query(
2278 "SELECT did, feed_url, read_ids, unread_ids FROM read_cursor WHERE feed_url = ?1",
2279 )
2280 .bind(url)
2281 .fetch_all(&mut **tx)
2282 .await
2283 .context("prune_orphan_cursor_ids: load feed cursors")?,
2284 None => sqlx::query("SELECT did, feed_url, read_ids, unread_ids FROM read_cursor")
2285 .fetch_all(&mut **tx)
2286 .await
2287 .context("prune_orphan_cursor_ids: load all cursors")?,
2288 }
2289 .into_iter()
2290 .map(|r| {
2291 (
2292 r.get::<String, _>("did"),
2293 r.get::<String, _>("feed_url"),
2294 r.get::<String, _>("read_ids"),
2295 r.get::<String, _>("unread_ids"),
2296 )
2297 })
2298 .collect();
2299
2300 if cursors.is_empty() {
2301 return Ok(0);
2302 }
2303
2304 let now = now_rfc3339();
2305 let mut changed: u64 = 0;
2306 for (did, curl, read_ids, unread_ids) in cursors {
2307 // The live entry ids for THIS cursor's feed (join by URL — the cursor key).
2308 let live: std::collections::HashSet<i64> = sqlx::query_scalar::<_, i64>(
2309 "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id WHERE f.url = ?1",
2310 )
2311 .bind(&curl)
2312 .fetch_all(&mut **tx)
2313 .await
2314 .with_context(|| format!("prune_orphan_cursor_ids: live ids for {curl}"))?
2315 .into_iter()
2316 .collect();
2317
2318 let new_read = filter_id_set_to_live(&read_ids, &live);
2319 let new_unread = filter_id_set_to_live(&unread_ids, &live);
2320 if new_read == read_ids && new_unread == unread_ids {
2321 continue; // nothing orphaned — leave the cursor (and its dirty flag) alone
2322 }
2323 sqlx::query(
2324 "UPDATE read_cursor SET read_ids = ?3, unread_ids = ?4, dirty = 1, updated_at = ?5 \
2325 WHERE did = ?1 AND feed_url = ?2",
2326 )
2327 .bind(&did)
2328 .bind(&curl)
2329 .bind(&new_read)
2330 .bind(&new_unread)
2331 .bind(&now)
2332 .execute(&mut **tx)
2333 .await
2334 .with_context(|| format!("prune_orphan_cursor_ids: rewrite cursor {did}/{curl}"))?;
2335 changed += 1;
2336 }
2337 Ok(changed)
2338}
2339
2340/// [`prune_orphan_cursor_ids_tx`] over the pool — **no enclosing transaction**.
2341///
2342/// Same result, different locking. Each statement commits on its own, so the
2343/// single write lock is taken for one cursor rewrite at a time and released
2344/// between them, and the reads in between block nothing at all in WAL mode.
2345/// That matters because this is the global pass: the retention sweep's version
2346/// loads EVERY `read_cursor` row and then issues one live-ids query per cursor,
2347/// and holding all of that inside a transaction is what made a daily sweep look
2348/// like an outage to every writer on the instance.
2349///
2350/// **Each cursor's read-modify-write is one short transaction**, and that is not
2351/// optional. The first version of this loaded every cursor into a snapshot, then
2352/// walked them issuing an unguarded `UPDATE` per cursor from that snapshot. A
2353/// `mark_read` landing during the walk — seconds, on a global pass — had its new
2354/// id silently overwritten by the stale set, and the rewrite set `dirty = 1`, so
2355/// the flusher then pushed the truncated set to the PDS as authoritative. Local
2356/// `entry_state` still said read, so the loss was invisible here and visible
2357/// only in every OTHER atproto client. The transactional predecessor did not
2358/// have that bug: it held the write lock across the whole pass, so a concurrent
2359/// `mark_read` blocked and applied on top.
2360///
2361/// So the lock is not eliminated, it is SCOPED: one cursor's live-ids query plus
2362/// its update, rather than every cursor's. That keeps what T2.2 was for (a daily
2363/// sweep must not look like an outage) without trading it for lost writes.
2364///
2365/// Re-running is still safe — surviving ids are recomputed from the current
2366/// contents of `entries` — so dying partway just means the next sweep finishes.
2367///
2368/// `feed_id = Some(..)` scopes to one feed; `None` scans every cursor. Returns
2369/// the number of cursor rows modified.
2370async fn prune_orphan_cursor_ids(pool: &SqlitePool, feed_id: Option<i64>) -> Result<u64> {
2371 let feed_url = match feed_id {
2372 Some(fid) => match sqlx::query_scalar::<_, String>("SELECT url FROM feeds WHERE id = ?1")
2373 .bind(fid)
2374 .fetch_optional(pool)
2375 .await
2376 .context("prune_orphan_cursor_ids: feed url")?
2377 {
2378 Some(u) => Some(u),
2379 None => return Ok(0),
2380 },
2381 None => None,
2382 };
2383
2384 // Only the KEYS come from this snapshot. The id-sets are deliberately not
2385 // read here — they are re-read inside each cursor's own transaction below,
2386 // because anything read out here is stale by the time it is written back.
2387 let keys: Vec<(String, String)> = match &feed_url {
2388 Some(url) => sqlx::query_as("SELECT did, feed_url FROM read_cursor WHERE feed_url = ?1")
2389 .bind(url)
2390 .fetch_all(pool)
2391 .await
2392 .context("prune_orphan_cursor_ids: load feed cursors")?,
2393 None => sqlx::query_as("SELECT did, feed_url FROM read_cursor")
2394 .fetch_all(pool)
2395 .await
2396 .context("prune_orphan_cursor_ids: load all cursors")?,
2397 };
2398
2399 let mut changed: u64 = 0;
2400 for (did, curl) in keys {
2401 // A cursor that vanished between the key snapshot and now is simply
2402 // skipped; a cursor that APPEARED is missed until the next sweep. Both
2403 // are fine — the scrub is housekeeping, not a correctness barrier.
2404 match scrub_one_cursor(pool, &did, &curl).await {
2405 Ok(true) => changed += 1,
2406 Ok(false) => {}
2407 // One bad cursor must not abandon the rest of the pass.
2408 Err(err) => tracing::warn!(%err, %did, feed = %curl, "cursor id scrub failed"),
2409 }
2410 }
2411 Ok(changed)
2412}
2413
2414/// Scrub one cursor's id-sets inside its own transaction. Returns whether the
2415/// row changed.
2416///
2417/// The read of the id-sets, the live-ids query and the write all happen under
2418/// one transaction, so a `mark_read` that lands mid-sweep either goes first (and
2419/// is included) or waits (and applies on top). Reading the sets outside and
2420/// writing them back later is the lost-update shape this function exists to
2421/// avoid — see [`prune_orphan_cursor_ids`].
2422async fn scrub_one_cursor(pool: &SqlitePool, did: &str, feed_url: &str) -> Result<bool> {
2423 let mut tx = pool.begin().await.context("begin scrub_one_cursor tx")?;
2424
2425 let (_, read_ids, unread_ids) = cursor_sets(&mut tx, did, feed_url).await?;
2426 // An empty exception set has nothing to orphan, and skipping it avoids the
2427 // live-ids query entirely — the dominant cost of this pass, and the common
2428 // case for a cursor sitting at its high-water mark.
2429 if is_empty_id_set(&read_ids) && is_empty_id_set(&unread_ids) {
2430 return Ok(false);
2431 }
2432
2433 let live: std::collections::HashSet<i64> = sqlx::query_scalar::<_, i64>(
2434 "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id WHERE f.url = ?1",
2435 )
2436 .bind(feed_url)
2437 .fetch_all(&mut *tx)
2438 .await
2439 .with_context(|| format!("prune_orphan_cursor_ids: live ids for {feed_url}"))?
2440 .into_iter()
2441 .collect();
2442
2443 let new_read = filter_id_set_to_live(&read_ids, &live);
2444 let new_unread = filter_id_set_to_live(&unread_ids, &live);
2445 if new_read == read_ids && new_unread == unread_ids {
2446 return Ok(false); // nothing orphaned — leave the cursor (and its dirty flag) alone
2447 }
2448 sqlx::query(
2449 "UPDATE read_cursor SET read_ids = ?3, unread_ids = ?4, dirty = 1, updated_at = ?5 \
2450 WHERE did = ?1 AND feed_url = ?2",
2451 )
2452 .bind(did)
2453 .bind(feed_url)
2454 .bind(&new_read)
2455 .bind(&new_unread)
2456 .bind(now_rfc3339())
2457 .execute(&mut *tx)
2458 .await
2459 .with_context(|| format!("prune_orphan_cursor_ids: rewrite cursor {did}/{feed_url}"))?;
2460 tx.commit().await.context("commit scrub_one_cursor tx")?;
2461 Ok(true)
2462}
2463
2464/// Whether a stored id-set is *textually* empty — `[]` or blank.
2465///
2466/// Deliberately NOT a parse: this is a fast pre-filter, and
2467/// [`filter_id_set_to_live`] remains the authority on what a set contains. An
2468/// unparseable value returns `false` here, so it goes through the full path and
2469/// gets canonicalised to `[]` rather than being skipped — the pre-filter fails
2470/// toward doing the work, which is the safe direction.
2471fn is_empty_id_set(raw: &str) -> bool {
2472 let t = raw.trim();
2473 t.is_empty() || t == "[]"
2474}
2475
2476/// Filter a JSON id-array string down to only ids present in `live`, returning
2477/// the canonical JSON-array-of-strings form (matching [`json_id_set_toggle`]). A
2478/// malformed input yields `[]`.
2479fn filter_id_set_to_live(raw: &str, live: &std::collections::HashSet<i64>) -> String {
2480 let ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
2481 .ok()
2482 .map(|vals| {
2483 vals.into_iter()
2484 .filter_map(|v| match v {
2485 serde_json::Value::Number(n) => n.as_i64(),
2486 serde_json::Value::String(s) => s.parse::<i64>().ok(),
2487 _ => None,
2488 })
2489 .filter(|id| live.contains(id))
2490 .collect()
2491 })
2492 .unwrap_or_default();
2493 let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
2494 serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
2495}
2496
2497/// Replace the per-DID subscription projection (`sub_ref`) for `did` with
2498/// exactly `feed_ids`, in one transaction.
2499///
2500/// Called from the web layer's subscription-resolve/sync path so `sub_ref`
2501/// always mirrors the caller's *current* PDS subscription set. This is the
2502/// authority every scoped read/mutation checks against — a feed the caller no
2503/// longer subscribes to drops out of their read surface immediately.
2504pub async fn replace_sub_refs(pool: &SqlitePool, did: &str, feed_ids: &[i64]) -> Result<()> {
2505 let mut tx = pool.begin().await.context("begin replace_sub_refs tx")?;
2506 sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
2507 .bind(did)
2508 .execute(&mut *tx)
2509 .await
2510 .with_context(|| format!("clear sub_ref for {did}"))?;
2511 for &feed_id in feed_ids {
2512 sqlx::query("INSERT OR IGNORE INTO sub_ref (did, feed_id) VALUES (?1, ?2)")
2513 .bind(did)
2514 .bind(feed_id)
2515 .execute(&mut *tx)
2516 .await
2517 .with_context(|| format!("insert sub_ref {did}/{feed_id}"))?;
2518 }
2519 tx.commit().await.context("commit replace_sub_refs tx")?;
2520 Ok(())
2521}
2522
2523/// Whether `did` currently subscribes to the feed `feed_id` owns
2524/// (i.e. a `sub_ref` row exists). The authorization primitive behind every
2525/// per-DID scoped read/mutation.
2526pub async fn did_subscribes_to_entry(pool: &SqlitePool, did: &str, entry_id: i64) -> Result<bool> {
2527 let found: Option<i64> = sqlx::query_scalar(
2528 r#"
2529 SELECT 1
2530 FROM entries e
2531 JOIN sub_ref sr ON sr.feed_id = e.feed_id AND sr.did = ?1
2532 WHERE e.id = ?2
2533 "#,
2534 )
2535 .bind(did)
2536 .bind(entry_id)
2537 .fetch_optional(pool)
2538 .await
2539 .with_context(|| format!("did_subscribes_to_entry failed for {did}/{entry_id}"))?;
2540 Ok(found.is_some())
2541}
2542
2543/// The exact `(sql, bind_count)` `list_entries` runs, for a view and scope.
2544///
2545/// **One path, so a test cannot assert on something the query is free to
2546/// ignore.** A named `LIST_PROJECTION` constant was not enough: the test read
2547/// the constant while `list_entries` passed `list_query_sql` whatever it liked,
2548/// so swapping in an inline literal containing `e.content_html` still shipped
2549/// green. The test now calls this.
2550fn list_entries_sql(view: ListView, feed_ids: Option<&[i64]>) -> (String, usize) {
2551 list_query_sql(Projection::EntryList, view, feed_ids)
2552}
2553
2554/// Which columns a list query may select.
2555///
2556/// **A closed type, not a `&str`.** A named constant was not enough and neither
2557/// was a helper function: both left `list_query_sql` taking an arbitrary string,
2558/// so a call site could pass an inline literal containing `e.content_html` and
2559/// ship green — twice over, which is how this ended up as an enum. The article
2560/// body is up to 20 KB per row and the list renders 50 at a time, so reading it
2561/// is the difference between a bounded response and a megabyte per page.
2562#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2563enum Projection {
2564 /// The list view. Deliberately omits `content_html`.
2565 EntryList,
2566 Count,
2567 Ids,
2568 FeedCounts,
2569 StarredUrls,
2570}
2571
2572impl Projection {
2573 const fn columns(self) -> &'static str {
2574 match self {
2575 Projection::EntryList => {
2576 "e.id, e.feed_id, e.guid, e.url, e.title, e.published, \
2577 COALESCE(s.read, 0) AS read, COALESCE(s.starred, 0) AS starred"
2578 }
2579 Projection::Count => "COUNT(*)",
2580 Projection::Ids => "e.id",
2581 Projection::FeedCounts => "e.feed_id, COUNT(*)",
2582 Projection::StarredUrls => "e.url, e.guid",
2583 }
2584 }
2585}
2586
2587/// The shared body of every list query: the per-DID `entry_state` LEFT JOIN, the
2588/// `sub_ref` authorization predicate, the view predicate and the optional
2589/// feed-id restriction. `projection` is spliced in as the `SELECT` list.
2590///
2591/// Returns the SQL plus the number of feed-id placeholders emitted, so the
2592/// caller knows where its own `LIMIT`/`OFFSET` placeholders start. `?1` is
2593/// always the DID; feed ids are `?2..`.
2594///
2595/// **Why the callers may assert this is SQL-safe.** Only three things vary, and
2596/// none is caller data: `projection` and [`ListView::predicate`] are `&'static
2597/// str` written in this file, and the feed-id restriction contributes only a
2598/// COUNT — the ids themselves are bound, never formatted in. Every runtime value
2599/// (the DID, the ids, the limit, the offset) reaches SQLite as a bind parameter.
2600fn list_query_sql(
2601 projection: Projection,
2602 view: ListView,
2603 feed_ids: Option<&[i64]>,
2604) -> (String, usize) {
2605 let cols = projection.columns();
2606 let scoped = feed_ids.is_some();
2607 let mut sql = format!(
2608 "SELECT {cols} \
2609 FROM entries e \
2610 LEFT JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1 \
2611 WHERE {} \
2612 AND EXISTS ( \
2613 SELECT 1 FROM sub_ref sr \
2614 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id \
2615 )",
2616 view.predicate()
2617 );
2618 if scoped {
2619 // **ONE bind parameter for any scope size.**
2620 //
2621 // This used to emit one placeholder per feed id, so the SQL string and
2622 // the bind list both grew with the reader's subscription count — which
2623 // is PDS-supplied and bounded only by the 20,000-record list ceiling.
2624 //
2625 // That was reachable-broken, not merely ugly: `SQLITE_LIMIT_VARIABLE_NUMBER`
2626 // is 32766 on the bundled build, and the ids were bound TWICE per render
2627 // (the count query and the page query), so the effective ceiling was
2628 // ~16,383 feeds — below the list ceiling. Past it, `prepare` fails with
2629 // "too many SQL variables" and the reader's page 500s. Measured: 20,000
2630 // ids through `json_each` is a 108 KB bind that runs in 9.9 ms; 32,767
2631 // placeholders does not prepare at all.
2632 // The first attempt at bounding it truncated the subscription list
2633 // instead, which traded a query-shape problem for an access problem:
2634 // `sync_sub_refs` writes `sub_ref` from that list, so dropped feeds
2635 // became unreadable AND unmutatable. `json_each` removes the need to
2636 // choose — the whole set rides in as one JSON text bind.
2637 sql.push_str(" AND e.feed_id IN (SELECT value FROM json_each(?2))");
2638 }
2639 (sql, usize::from(scoped))
2640}
2641
2642/// Bind the DID and the optional feed-id restriction, in the order
2643/// [`list_query_sql`] emits them — `?1` the DID, `?2` the scope JSON when there
2644/// is one.
2645fn bind_list_scope<'q, O>(
2646 q: sqlx::query::QueryAs<'q, sqlx::Sqlite, O, sqlx::sqlite::SqliteArguments>,
2647 did: &'q str,
2648 feed_ids: Option<&[i64]>,
2649) -> sqlx::query::QueryAs<'q, sqlx::Sqlite, O, sqlx::sqlite::SqliteArguments> {
2650 let q = q.bind(did);
2651 match feed_ids {
2652 // Serialising i64s cannot fail; the fallback is an empty array, which
2653 // matches nothing — the fail-closed direction for a scope filter.
2654 Some(ids) => q.bind(serde_json::to_string(ids).unwrap_or_else(|_| "[]".to_string())),
2655 None => q,
2656 }
2657}
2658
2659/// One page of a list view, newest-published first, scoped to `did`'s
2660/// subscriptions (`sub_ref`) and optionally narrowed to `feed_ids`.
2661///
2662/// **`limit` is a required parameter, not a convenience.** This function
2663/// replaced three `SELECT e.*` queries that had no `LIMIT` at all and pulled the
2664/// article body they never used; leaving an unbounded variant next to the
2665/// bounded one would just be the same trap with a longer name. If a caller wants
2666/// "everything", it has to say how much everything is allowed to be. See
2667/// [`EntryListRow`] for what the projection deliberately omits and why.
2668///
2669/// `feed_ids = Some(&[])` means "no feeds in scope" and returns empty without
2670/// touching the database — distinct from `None`, which means "every feed this
2671/// DID subscribes to".
2672pub async fn list_entries(
2673 pool: &SqlitePool,
2674 did: &str,
2675 view: ListView,
2676 feed_ids: Option<&[i64]>,
2677 limit: i64,
2678 offset: i64,
2679) -> Result<Vec<EntryListRow>> {
2680 if feed_ids.is_some_and(<[i64]>::is_empty) || limit <= 0 {
2681 return Ok(Vec::new());
2682 }
2683 let (mut sql, n) = list_entries_sql(view, feed_ids);
2684 sql.push_str(&format!(
2685 " ORDER BY COALESCE(e.published, e.fetched_at) DESC, e.id DESC LIMIT ?{} OFFSET ?{}",
2686 n + 2,
2687 n + 3
2688 ));
2689 let q = sqlx::query_as::<_, EntryListRow>(sqlx::AssertSqlSafe(sql));
2690 let rows = bind_list_scope(q, did, feed_ids)
2691 .bind(limit)
2692 .bind(offset.max(0))
2693 .fetch_all(pool)
2694 .await
2695 .with_context(|| format!("list_entries({view:?}) failed for {did}"))?;
2696 Ok(rows)
2697}
2698
2699/// How many entries the same scope + view would return, unpaged. Used for the
2700/// "N entries" heading and to decide whether a next-page link is warranted —
2701/// both of which used to read `entries.len()` off a fully materialized list.
2702pub async fn count_entries_for_view(
2703 pool: &SqlitePool,
2704 did: &str,
2705 view: ListView,
2706 feed_ids: Option<&[i64]>,
2707) -> Result<i64> {
2708 if feed_ids.is_some_and(<[i64]>::is_empty) {
2709 return Ok(0);
2710 }
2711 let (sql, _) = list_query_sql(Projection::Count, view, feed_ids);
2712 // `query_as` over a 1-tuple keeps one binding helper for both shapes.
2713 let q = sqlx::query_as::<_, (i64,)>(sqlx::AssertSqlSafe(sql));
2714 let (n,) = bind_list_scope(q, did, feed_ids)
2715 .fetch_one(pool)
2716 .await
2717 .with_context(|| format!("count_entries_for_view({view:?}) failed for {did}"))?;
2718 Ok(n)
2719}
2720
2721/// The ordered entry ids for a scope + view — the same ordering [`list_entries`]
2722/// renders, used for the reader's prev/next links.
2723///
2724/// Ids only: this one genuinely spans the whole list rather than a page (prev/next
2725/// needs the reader's position in it), so it is the one query where row COUNT can
2726/// still be large. An id is 8 bytes against the 11.9 KB row this used to fetch,
2727/// and `limit` bounds it regardless. Past the limit, prev/next simply stops
2728/// finding neighbours — the article still opens.
2729pub async fn list_entry_ids(
2730 pool: &SqlitePool,
2731 did: &str,
2732 view: ListView,
2733 feed_ids: Option<&[i64]>,
2734 limit: i64,
2735) -> Result<Vec<i64>> {
2736 if feed_ids.is_some_and(<[i64]>::is_empty) || limit <= 0 {
2737 return Ok(Vec::new());
2738 }
2739 let (mut sql, n) = list_query_sql(Projection::Ids, view, feed_ids);
2740 sql.push_str(&format!(
2741 " ORDER BY COALESCE(e.published, e.fetched_at) DESC, e.id DESC LIMIT ?{}",
2742 n + 2
2743 ));
2744 let q = sqlx::query_as::<_, (i64,)>(sqlx::AssertSqlSafe(sql));
2745 let rows = bind_list_scope(q, did, feed_ids)
2746 .bind(limit)
2747 .fetch_all(pool)
2748 .await
2749 .with_context(|| format!("list_entry_ids({view:?}) failed for {did}"))?;
2750 Ok(rows.into_iter().map(|(id,)| id).collect())
2751}
2752
2753/// Unread counts per `feed_id` for a DID — the sidebar's per-feed badges.
2754///
2755/// Counted in SQL. The sidebar used to fetch every unread entry (bodies and all)
2756/// and count them in Rust, on every page with chrome, which is the single most
2757/// frequent instance of the projection problem [`EntryListRow`] describes.
2758pub async fn unread_counts_by_feed(
2759 pool: &SqlitePool,
2760 did: &str,
2761) -> Result<std::collections::HashMap<i64, i64>> {
2762 let (sql, _) = list_query_sql(Projection::FeedCounts, ListView::Unread, None);
2763 let rows =
2764 sqlx::query_as::<_, (i64, i64)>(sqlx::AssertSqlSafe(format!("{sql} GROUP BY e.feed_id")))
2765 .bind(did)
2766 .fetch_all(pool)
2767 .await
2768 .with_context(|| format!("unread_counts_by_feed failed for {did}"))?;
2769 Ok(rows.into_iter().collect())
2770}
2771
2772/// The `(url, guid)` identity pairs of every cached starred entry for a DID.
2773///
2774/// The starred view matches PDS saved records against these to decide which
2775/// records the cache can render itself. It must span the whole starred set, not
2776/// the visible page: a record that looks uncached gets an un-save button that
2777/// deletes the PDS RECORD rather than un-starring the entry, so narrowing this
2778/// set changes what a click destroys. Identity strings only — no bodies.
2779///
2780/// **Truncation is reported, not absorbed.** The `limit` is a memory backstop,
2781/// but hitting it violates the invariant above — and the first version had no
2782/// way to say so and no `ORDER BY`, so it silently returned an ARBITRARY subset
2783/// and every starred article outside it rendered with a record-destroying
2784/// button. `Truncated` lets the caller fail closed instead, and the ordering
2785/// makes the subset at least deterministic across renders rather than
2786/// whatever the query planner felt like returning.
2787pub enum StarredIdentities {
2788 /// The complete set for this DID.
2789 All(Vec<(Option<String>, String)>),
2790 /// `limit` was reached, so this is a partial set and MUST NOT be used to
2791 /// decide that a record is uncached.
2792 Truncated,
2793}
2794
2795pub async fn starred_identities(
2796 pool: &SqlitePool,
2797 did: &str,
2798 limit: i64,
2799) -> Result<StarredIdentities> {
2800 let (mut sql, n) = list_query_sql(Projection::StarredUrls, ListView::Starred, None);
2801 // One past the limit, so reaching it is distinguishable from landing on it
2802 // exactly. Ordered by id so the rows are stable; `url`/`guid` are not
2803 // guaranteed unique or non-NULL, and the id is both.
2804 //
2805 // The placeholder index comes from `list_query_sql` rather than being
2806 // hardcoded: it was `?2` only because this call passes `None` for the scope,
2807 // which is the kind of coupling that breaks silently when the shared builder
2808 // changes shape — as it just did.
2809 sql.push_str(&format!(" ORDER BY e.id LIMIT ?{}", n + 2));
2810 let rows = sqlx::query_as::<_, (Option<String>, String)>(sqlx::AssertSqlSafe(sql))
2811 .bind(did)
2812 .bind(limit.saturating_add(1))
2813 .fetch_all(pool)
2814 .await
2815 .with_context(|| format!("starred_identities failed for {did}"))?;
2816 if rows.len() as i64 > limit {
2817 return Ok(StarredIdentities::Truncated);
2818 }
2819 Ok(StarredIdentities::All(rows))
2820}
2821
2822/// Mark a single entry read/unread for a DID, upserting the per-DID state row
2823/// and stamping `updated_at`. Preserves any existing `starred` bit. Also
2824/// projects the change into the per-`(did, feed_url)` [`ReadCursor`] and marks
2825/// it `dirty` so the batched flusher pushes it to the PDS (see
2826/// `project_entry_into_cursor`).
2827///
2828/// AUTHORIZED per-DID: the upsert only touches an entry the caller subscribes
2829/// to (`sub_ref`). Returns `true` if a row was written, `false` if `did` does
2830/// not subscribe to the entry's feed (the web layer maps that to a 404 —
2831/// a non-subscriber can never mutate another user's state).
2832pub async fn mark_read(pool: &SqlitePool, did: &str, entry_id: i64, read: bool) -> Result<bool> {
2833 let now = now_rfc3339();
2834 let mut tx = pool.begin().await.context("begin mark_read tx")?;
2835 let res = sqlx::query(
2836 r#"
2837 INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
2838 SELECT ?1, e.id, ?3, 0, ?4
2839 FROM entries e
2840 WHERE e.id = ?2
2841 AND EXISTS (
2842 SELECT 1 FROM sub_ref sr
2843 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
2844 )
2845 ON CONFLICT (did, entry_id) DO UPDATE SET
2846 read = excluded.read,
2847 updated_at = excluded.updated_at
2848 "#,
2849 )
2850 .bind(did)
2851 .bind(entry_id)
2852 .bind(read)
2853 .bind(&now)
2854 .execute(&mut *tx)
2855 .await
2856 .with_context(|| format!("mark_read failed for {did}/{entry_id}"))?;
2857
2858 if res.rows_affected() == 0 {
2859 // Not authorized (no `sub_ref`) — nothing written, no cursor to dirty.
2860 tx.rollback().await.ok();
2861 return Ok(false);
2862 }
2863
2864 // Project the read/unread into this feed's read cursor (dirty=1) so the
2865 // flusher syncs it to the PDS. Same tx as the state write so a crash can't
2866 // leave the two out of step.
2867 project_entry_into_cursor(&mut tx, did, entry_id, read, &now).await?;
2868
2869 tx.commit().await.context("commit mark_read tx")?;
2870 Ok(true)
2871}
2872
2873/// Star/unstar a single entry for a DID (upsert, preserving `read`).
2874///
2875/// AUTHORIZED per-DID like [`mark_read`]: only touches an entry the caller
2876/// subscribes to. Returns `true` if a row was written, `false` if `did` does
2877/// not subscribe (→ 404 at the web layer).
2878pub async fn mark_starred(
2879 pool: &SqlitePool,
2880 did: &str,
2881 entry_id: i64,
2882 starred: bool,
2883) -> Result<bool> {
2884 let res = sqlx::query(
2885 r#"
2886 INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
2887 SELECT ?1, e.id, 0, ?3, ?4
2888 FROM entries e
2889 WHERE e.id = ?2
2890 AND EXISTS (
2891 SELECT 1 FROM sub_ref sr
2892 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
2893 )
2894 ON CONFLICT (did, entry_id) DO UPDATE SET
2895 starred = excluded.starred,
2896 updated_at = excluded.updated_at
2897 "#,
2898 )
2899 .bind(did)
2900 .bind(entry_id)
2901 .bind(starred)
2902 .bind(now_rfc3339())
2903 .execute(pool)
2904 .await
2905 .with_context(|| format!("mark_starred failed for {did}/{entry_id}"))?;
2906 Ok(res.rows_affected() > 0)
2907}
2908
2909/// Fold ids already covered by a high-water-mark into `read_through`, so the
2910/// exception set stops growing. Returns the new `read_through` when it advanced.
2911///
2912/// **What was wrong.** `read_through` was never COMPUTED — `project_entry_into_cursor`
2913/// only carried an existing value through, and it starts NULL, so in practice it
2914/// was always NULL. That left `read_ids` as the sole mechanism, growing one id
2915/// per article read, bounded only by `max_entries_per_feed` (2000) — while the
2916/// flusher caps the record at `ReadState::MAX_IDS` (1000) keeping the TAIL, with
2917/// no log line. Past 1000 read articles in one feed, the oldest read-state
2918/// silently stopped syncing, and those articles came back UNREAD in any other
2919/// atproto reader. The `cap` helper's own comment assumed "the exception sets
2920/// are expected to stay well under the cap in normal use"; against a 2000-entry
2921/// per-feed ceiling that does not hold.
2922///
2923/// **The rule.** `read_through` means "every entry at or before this time is
2924/// read". So it may advance only to a point with no unread entry at or before
2925/// it. That point is computed here as the newest entry timestamp STRICTLY OLDER
2926/// than the oldest unread entry — strictly, because entries can share a
2927/// timestamp, and a watermark equal to an unread entry's time would assert that
2928/// entry is read.
2929///
2930/// Once the watermark moves, every `read_ids` entry at or before it is
2931/// redundant and is dropped — that is the compaction. `unread_ids` is filtered
2932/// the same way; by construction nothing unread sits at or below the new
2933/// watermark, so it empties, but the filter is written rather than assumed so it
2934/// stays correct if that invariant ever shifts.
2935///
2936/// Timestamps compare lexicographically because every writer normalises to UTC
2937/// `...Z` at seconds precision (`feed::fmt_time`, `now_rfc3339`) — the same
2938/// assumption `poll_health` and the retention window already make.
2939pub async fn compact_cursor(
2940 pool: &SqlitePool,
2941 did: &str,
2942 feed_url: &str,
2943) -> Result<Option<String>> {
2944 let mut tx = pool.begin().await.context("begin compact_cursor tx")?;
2945 let (read_through, read_ids, unread_ids) = cursor_sets(&mut tx, did, feed_url).await?;
2946
2947 // The oldest entry on this feed that `did` has NOT read. `NULL` = nothing
2948 // unread, in which case the watermark can cover the whole feed.
2949 let oldest_unread: Option<String> = sqlx::query_scalar(
2950 r#"
2951 SELECT MIN(COALESCE(e.published, e.fetched_at))
2952 FROM entries e
2953 JOIN feeds f ON f.id = e.feed_id
2954 LEFT JOIN entry_state s ON s.entry_id = e.id AND s.did = ?1
2955 WHERE f.url = ?2 AND COALESCE(s.read, 0) = 0
2956 "#,
2957 )
2958 .bind(did)
2959 .bind(feed_url)
2960 .fetch_one(&mut *tx)
2961 .await
2962 .with_context(|| format!("compact_cursor: oldest unread for {did}/{feed_url}"))?;
2963
2964 let watermark: Option<String> = match &oldest_unread {
2965 Some(oldest) => sqlx::query_scalar(
2966 r#"
2967 SELECT MAX(COALESCE(e.published, e.fetched_at))
2968 FROM entries e JOIN feeds f ON f.id = e.feed_id
2969 WHERE f.url = ?1 AND COALESCE(e.published, e.fetched_at) < ?2
2970 "#,
2971 )
2972 .bind(feed_url)
2973 .bind(oldest)
2974 .fetch_one(&mut *tx)
2975 .await
2976 .with_context(|| format!("compact_cursor: watermark for {did}/{feed_url}"))?,
2977 None => sqlx::query_scalar(
2978 r#"
2979 SELECT MAX(COALESCE(e.published, e.fetched_at))
2980 FROM entries e JOIN feeds f ON f.id = e.feed_id
2981 WHERE f.url = ?1
2982 "#,
2983 )
2984 .bind(feed_url)
2985 .fetch_one(&mut *tx)
2986 .await
2987 .with_context(|| format!("compact_cursor: watermark for {did}/{feed_url}"))?,
2988 };
2989
2990 // Nothing to cover, or the watermark is already at least this far along.
2991 // Never move it BACKWARDS: that would re-assert articles as unread.
2992 let Some(watermark) = watermark else {
2993 return Ok(None);
2994 };
2995 if read_through
2996 .as_deref()
2997 .is_some_and(|rt| rt >= &watermark[..])
2998 {
2999 return Ok(None);
3000 }
3001
3002 let keep_above = ids_published_after(&mut tx, feed_url, &read_ids, &watermark).await?;
3003 let keep_unread =
3004 ids_published_at_or_before(&mut tx, feed_url, &unread_ids, &watermark).await?;
3005
3006 write_cursor_sets(
3007 &mut tx,
3008 did,
3009 feed_url,
3010 Some(&watermark),
3011 &keep_above,
3012 &keep_unread,
3013 &now_rfc3339(),
3014 )
3015 .await?;
3016 tx.commit().await.context("commit compact_cursor tx")?;
3017 Ok(Some(watermark))
3018}
3019
3020/// The subset of `ids` whose entries are published strictly AFTER `watermark`,
3021/// as the canonical JSON array-of-strings the cursor stores.
3022async fn ids_published_after(
3023 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3024 feed_url: &str,
3025 ids: &str,
3026 watermark: &str,
3027) -> Result<String> {
3028 let live = ids_matching_watermark(tx, feed_url, watermark, true).await?;
3029 Ok(filter_id_set_to_live(ids, &live))
3030}
3031
3032/// The subset of `ids` whose entries are published at or BEFORE `watermark`.
3033async fn ids_published_at_or_before(
3034 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3035 feed_url: &str,
3036 ids: &str,
3037 watermark: &str,
3038) -> Result<String> {
3039 let live = ids_matching_watermark(tx, feed_url, watermark, false).await?;
3040 Ok(filter_id_set_to_live(ids, &live))
3041}
3042
3043/// Entry ids on `feed_url` on one side of `watermark`. `after = true` selects
3044/// strictly newer; `false` selects at-or-older.
3045async fn ids_matching_watermark(
3046 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3047 feed_url: &str,
3048 watermark: &str,
3049 after: bool,
3050) -> Result<std::collections::HashSet<i64>> {
3051 let sql = if after {
3052 "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id \
3053 WHERE f.url = ?1 AND COALESCE(e.published, e.fetched_at) > ?2"
3054 } else {
3055 "SELECT e.id FROM entries e JOIN feeds f ON f.id = e.feed_id \
3056 WHERE f.url = ?1 AND COALESCE(e.published, e.fetched_at) <= ?2"
3057 };
3058 Ok(sqlx::query_scalar::<_, i64>(sql)
3059 .bind(feed_url)
3060 .bind(watermark)
3061 .fetch_all(&mut **tx)
3062 .await
3063 .context("compact_cursor: ids on one side of the watermark")?
3064 .into_iter()
3065 .collect())
3066}
3067
3068/// Clear `did`'s star on any cached entry matching `url` or `guid`, **ignoring
3069/// the subscription projection**. Returns the number of `entry_state` rows
3070/// changed.
3071///
3072/// This closes a desync between the two places a star lives. The starred view
3073/// matches PDS saved records against cached entries through `sub_ref`, so an
3074/// entry that is cached AND starred in a feed the reader has since UNSUBSCRIBED
3075/// from does not match: it renders as an uncached row whose button is
3076/// `POST /saved/{rkey}/delete`. That deletes the PDS record and used to leave
3077/// `entry_state.starred = 1` behind — invisible, because the starred list is
3078/// `sub_ref`-scoped too, until the reader resubscribes and the star reappears
3079/// with no record backing it.
3080///
3081/// **Why omitting `sub_ref` is safe here, when it is the per-DID isolation hook
3082/// everywhere else.** Every row this can touch is keyed by `did` and this writes
3083/// only `starred = 0`. The worst a caller can do with it is clear one of their
3084/// OWN stars — which is what they just asked for. The predicate that matters for
3085/// isolation is the `did` in the `WHERE`, and it is not optional.
3086///
3087/// Matching on `url` OR `guid` mirrors how the view decides a record is already
3088/// cached, so the removal path and the render path agree on what "the same
3089/// article" means.
3090pub async fn clear_star_by_identity(
3091 pool: &SqlitePool,
3092 did: &str,
3093 url: Option<&str>,
3094 guid: Option<&str>,
3095) -> Result<u64> {
3096 // Neither identifier present: nothing to match on. Running the statement
3097 // would compare NULL to NULL and match nothing, but returning early says so.
3098 if url.is_none_or(str::is_empty) && guid.is_none_or(str::is_empty) {
3099 return Ok(0);
3100 }
3101 let res = sqlx::query(
3102 r#"
3103 UPDATE entry_state
3104 SET starred = 0, updated_at = ?4
3105 WHERE did = ?1
3106 AND starred = 1
3107 AND entry_id IN (
3108 SELECT id FROM entries
3109 WHERE (?2 IS NOT NULL AND url = ?2)
3110 OR (?3 IS NOT NULL AND guid = ?3)
3111 )
3112 "#,
3113 )
3114 .bind(did)
3115 .bind(url.filter(|u| !u.is_empty()))
3116 .bind(guid.filter(|g| !g.is_empty()))
3117 .bind(now_rfc3339())
3118 .execute(pool)
3119 .await
3120 .with_context(|| format!("clear_star_by_identity failed for {did}"))?;
3121 Ok(res.rows_affected())
3122}
3123
3124/// Mark every entry of a feed read (or unread) for a DID in one statement —
3125/// backs the "mark-all-read (per feed)" action. Also projects the change into
3126/// the feed's per-DID [`ReadCursor`] (dirty=1) so the batched flusher syncs the
3127/// new read-state to the PDS.
3128pub async fn mark_feed_read(pool: &SqlitePool, did: &str, feed_id: i64, read: bool) -> Result<u64> {
3129 let now = now_rfc3339();
3130 let mut tx = pool.begin().await.context("begin mark_feed_read tx")?;
3131 let res = sqlx::query(
3132 r#"
3133 INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
3134 SELECT ?1, e.id, ?2, 0, ?3 FROM entries e
3135 WHERE e.feed_id = ?4
3136 AND EXISTS (
3137 SELECT 1 FROM sub_ref sr
3138 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
3139 )
3140 ON CONFLICT (did, entry_id) DO UPDATE SET
3141 read = excluded.read,
3142 updated_at = excluded.updated_at
3143 "#,
3144 )
3145 .bind(did)
3146 .bind(read)
3147 .bind(&now)
3148 .bind(feed_id)
3149 .execute(&mut *tx)
3150 .await
3151 .with_context(|| format!("mark_feed_read failed for {did}/feed {feed_id}"))?;
3152
3153 if res.rows_affected() > 0 {
3154 // Project every affected entry into this feed's read cursor. `feed_id`
3155 // maps to exactly one feed URL, so this is a single per-feed cursor —
3156 // batched, not per-article. Only runs when the caller was authorized
3157 // (some rows changed), so an unsubscribed feed leaves no cursor behind.
3158 project_feed_into_cursor(&mut tx, did, feed_id, read, &now).await?;
3159 }
3160
3161 tx.commit().await.context("commit mark_feed_read tx")?;
3162 Ok(res.rows_affected())
3163}
3164
3165// ---------------------------------------------------------------------------
3166// Read-cursor projection (wires the local read/unread mutation into the
3167// PDS-bound `read_cursor`, so the batched flusher actually pushes read-state)
3168// ---------------------------------------------------------------------------
3169
3170/// Add or remove an entry id from a JSON id-array string, returning the new JSON.
3171/// Membership is set-like (no duplicates) and order-stable (append on add). A
3172/// malformed input is treated as empty so a cosmetic parse issue never blocks a
3173/// projection.
3174fn json_id_set_toggle(raw: &str, id: i64, present: bool) -> String {
3175 let mut ids: Vec<i64> = serde_json::from_str::<Vec<serde_json::Value>>(raw)
3176 .ok()
3177 .map(|vals| {
3178 vals.into_iter()
3179 .filter_map(|v| match v {
3180 serde_json::Value::Number(n) => n.as_i64(),
3181 serde_json::Value::String(s) => s.parse::<i64>().ok(),
3182 _ => None,
3183 })
3184 .collect()
3185 })
3186 .unwrap_or_default();
3187 if present {
3188 if !ids.contains(&id) {
3189 ids.push(id);
3190 }
3191 } else {
3192 ids.retain(|&x| x != id);
3193 }
3194 // Serialize as a JSON array of strings (the shape the flusher / lexicon
3195 // expect — `community.lexicon.rss.readState.readIds` is a string array).
3196 let as_strings: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
3197 serde_json::to_string(&as_strings).unwrap_or_else(|_| "[]".to_string())
3198}
3199
3200/// The feed URL owning `feed_id`, if the row exists (cursors are keyed by URL,
3201/// not feed id — they mirror the PDS-side `readState.feedUrl`).
3202async fn feed_url_for_id_tx(
3203 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3204 feed_id: i64,
3205) -> Result<Option<String>> {
3206 let url: Option<String> = sqlx::query_scalar("SELECT url FROM feeds WHERE id = ?1")
3207 .bind(feed_id)
3208 .fetch_optional(&mut **tx)
3209 .await
3210 .with_context(|| format!("feed_url_for_id_tx failed for feed {feed_id}"))?;
3211 Ok(url)
3212}
3213
3214/// Fetch the (read_through, read_ids, unread_ids) of an existing cursor, or the
3215/// empty defaults if there is none yet.
3216async fn cursor_sets(
3217 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3218 did: &str,
3219 feed_url: &str,
3220) -> Result<(Option<String>, String, String)> {
3221 let row = sqlx::query(
3222 "SELECT read_through, read_ids, unread_ids FROM read_cursor \
3223 WHERE did = ?1 AND feed_url = ?2",
3224 )
3225 .bind(did)
3226 .bind(feed_url)
3227 .fetch_optional(&mut **tx)
3228 .await
3229 .with_context(|| format!("cursor_sets failed for {did}/{feed_url}"))?;
3230 Ok(match row {
3231 Some(r) => (
3232 r.get::<Option<String>, _>("read_through"),
3233 r.get::<String, _>("read_ids"),
3234 r.get::<String, _>("unread_ids"),
3235 ),
3236 None => (None, "[]".to_string(), "[]".to_string()),
3237 })
3238}
3239
3240/// Upsert the cursor row for `(did, feed_url)` with the given exception sets,
3241/// stamping `updated_at` and marking it `dirty` so `dirty_cursors` returns it.
3242async fn write_cursor_sets(
3243 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3244 did: &str,
3245 feed_url: &str,
3246 read_through: Option<&str>,
3247 read_ids: &str,
3248 unread_ids: &str,
3249 now: &str,
3250) -> Result<()> {
3251 sqlx::query(
3252 r#"
3253 INSERT INTO read_cursor
3254 (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
3255 VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6)
3256 ON CONFLICT (did, feed_url) DO UPDATE SET
3257 read_through = excluded.read_through,
3258 read_ids = excluded.read_ids,
3259 unread_ids = excluded.unread_ids,
3260 dirty = 1,
3261 updated_at = excluded.updated_at
3262 "#,
3263 )
3264 .bind(did)
3265 .bind(feed_url)
3266 .bind(read_through)
3267 .bind(read_ids)
3268 .bind(unread_ids)
3269 .bind(now)
3270 .execute(&mut **tx)
3271 .await
3272 .with_context(|| format!("write_cursor_sets failed for {did}/{feed_url}"))?;
3273 Ok(())
3274}
3275
3276/// Project a single entry's read/unread flip into its feed's read cursor.
3277///
3278/// The cursor mirrors `community.lexicon.rss.readState`: a `read_through`
3279/// high-water-mark plus two bounded exception sets. A per-article flip is
3280/// recorded in those sets (`read_ids` when read, `unread_ids` when unread), the
3281/// opposite set is cleared of the id, and the cursor is stamped + marked dirty.
3282/// This keeps the write batched by touching only the ONE per-feed cursor. (Note:
3283/// there is no compaction step yet that folds covered ids back into
3284/// `read_through`; the exception sets are expected to stay well under the cap.)
3285async fn project_entry_into_cursor(
3286 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3287 did: &str,
3288 entry_id: i64,
3289 read: bool,
3290 now: &str,
3291) -> Result<()> {
3292 // The entry's feed id → feed URL (the cursor key).
3293 let feed_id: Option<i64> = sqlx::query_scalar("SELECT feed_id FROM entries WHERE id = ?1")
3294 .bind(entry_id)
3295 .fetch_optional(&mut **tx)
3296 .await
3297 .with_context(|| format!("project_entry_into_cursor: feed_id for entry {entry_id}"))?;
3298 let feed_id = match feed_id {
3299 Some(f) => f,
3300 None => return Ok(()), // entry vanished mid-tx; nothing to project
3301 };
3302 let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
3303 Some(u) => u,
3304 None => return Ok(()),
3305 };
3306
3307 let (read_through, read_ids, unread_ids) = cursor_sets(tx, did, &feed_url).await?;
3308 // read=true: id joins read_ids, leaves unread_ids. read=false: the inverse.
3309 let read_ids = json_id_set_toggle(&read_ids, entry_id, read);
3310 let unread_ids = json_id_set_toggle(&unread_ids, entry_id, !read);
3311 write_cursor_sets(
3312 tx,
3313 did,
3314 &feed_url,
3315 read_through.as_deref(),
3316 &read_ids,
3317 &unread_ids,
3318 now,
3319 )
3320 .await
3321}
3322
3323/// Project a mark-all-feed-read/unread into that feed's single read cursor.
3324///
3325/// Every entry the caller subscribes to on `feed_id` is folded into the cursor
3326/// in one write: on mark-all-READ each id joins `read_ids` (and leaves
3327/// `unread_ids`); on mark-all-UNREAD the inverse. Still ONE per-feed cursor row
3328/// (batched), stamped + dirtied for the flusher.
3329async fn project_feed_into_cursor(
3330 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
3331 did: &str,
3332 feed_id: i64,
3333 read: bool,
3334 now: &str,
3335) -> Result<()> {
3336 let feed_url = match feed_url_for_id_tx(tx, feed_id).await? {
3337 Some(u) => u,
3338 None => return Ok(()),
3339 };
3340
3341 // The entry ids on this feed the caller is authorized for (subscribes to).
3342 let ids: Vec<i64> = sqlx::query_scalar(
3343 r#"
3344 SELECT e.id FROM entries e
3345 WHERE e.feed_id = ?2
3346 AND EXISTS (
3347 SELECT 1 FROM sub_ref sr
3348 WHERE sr.did = ?1 AND sr.feed_id = e.feed_id
3349 )
3350 "#,
3351 )
3352 .bind(did)
3353 .bind(feed_id)
3354 .fetch_all(&mut **tx)
3355 .await
3356 .with_context(|| format!("project_feed_into_cursor: entry ids for {did}/feed {feed_id}"))?;
3357
3358 let (read_through, mut read_ids, mut unread_ids) = cursor_sets(tx, did, &feed_url).await?;
3359 for id in ids {
3360 read_ids = json_id_set_toggle(&read_ids, id, read);
3361 unread_ids = json_id_set_toggle(&unread_ids, id, !read);
3362 }
3363 write_cursor_sets(
3364 tx,
3365 did,
3366 &feed_url,
3367 read_through.as_deref(),
3368 &read_ids,
3369 &unread_ids,
3370 now,
3371 )
3372 .await
3373}
3374
3375/// Test-only unbounded convenience wrappers over [`list_entries`].
3376///
3377/// Production code passes an explicit `limit`, because that is the whole point
3378/// of the change these replaced. Fixtures hold a handful of rows and asserting
3379/// on "the whole list" is what the tests actually mean, so they get a helper
3380/// with a stated ceiling instead of each spelling one out — and the ceiling is
3381/// high enough that a test hitting it is a broken fixture, not a truncation.
3382#[cfg(test)]
3383mod test_helpers {
3384 use super::*;
3385
3386 /// Far above any fixture; a test that reaches it has a bug of its own.
3387 const FIXTURE_MAX: i64 = 10_000;
3388
3389 pub(crate) async fn entries_for_feed(
3390 pool: &SqlitePool,
3391 did: &str,
3392 feed_id: i64,
3393 ) -> Result<Vec<EntryListRow>> {
3394 list_entries(pool, did, ListView::All, Some(&[feed_id]), FIXTURE_MAX, 0).await
3395 }
3396
3397 pub(crate) async fn get_unread_for_did(
3398 pool: &SqlitePool,
3399 did: &str,
3400 ) -> Result<Vec<EntryListRow>> {
3401 list_entries(pool, did, ListView::Unread, None, FIXTURE_MAX, 0).await
3402 }
3403
3404 pub(crate) async fn get_starred_for_did(
3405 pool: &SqlitePool,
3406 did: &str,
3407 ) -> Result<Vec<EntryListRow>> {
3408 list_entries(pool, did, ListView::Starred, None, FIXTURE_MAX, 0).await
3409 }
3410}
3411
3412#[cfg(test)]
3413pub(crate) use test_helpers::{entries_for_feed, get_starred_for_did, get_unread_for_did};
3414
3415/// Insert or update a per-`(did, feed_url)` read cursor, stamping `updated_at`.
3416/// The write path for local mark-read updates (and the seam a login-time PDS
3417/// merge would use, once that is wired).
3418pub async fn upsert_cursor(pool: &SqlitePool, cursor: &ReadCursor) -> Result<()> {
3419 sqlx::query(
3420 r#"
3421 INSERT INTO read_cursor
3422 (did, feed_url, read_through, read_ids, unread_ids, dirty, updated_at)
3423 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
3424 ON CONFLICT (did, feed_url) DO UPDATE SET
3425 read_through = excluded.read_through,
3426 read_ids = excluded.read_ids,
3427 unread_ids = excluded.unread_ids,
3428 dirty = excluded.dirty,
3429 updated_at = excluded.updated_at
3430 "#,
3431 )
3432 .bind(&cursor.did)
3433 .bind(&cursor.feed_url)
3434 .bind(&cursor.read_through)
3435 .bind(&cursor.read_ids)
3436 .bind(&cursor.unread_ids)
3437 .bind(cursor.dirty)
3438 .bind(&cursor.updated_at)
3439 .execute(pool)
3440 .await
3441 .with_context(|| {
3442 format!(
3443 "upsert_cursor failed for {}/{}",
3444 cursor.did, cursor.feed_url
3445 )
3446 })?;
3447 Ok(())
3448}
3449
3450/// Fetch a single read cursor, if present.
3451pub async fn get_cursor(
3452 pool: &SqlitePool,
3453 did: &str,
3454 feed_url: &str,
3455) -> Result<Option<ReadCursor>> {
3456 let cursor = sqlx::query_as::<_, ReadCursor>(
3457 "SELECT * FROM read_cursor WHERE did = ?1 AND feed_url = ?2",
3458 )
3459 .bind(did)
3460 .bind(feed_url)
3461 .fetch_optional(pool)
3462 .await
3463 .context("get_cursor failed")?;
3464 Ok(cursor)
3465}
3466
3467/// The flusher's hot query: every cursor with `dirty = 1` for a DID — the ones
3468/// whose read-state changed since the last batched PDS flush.
3469/// How many DIDs hold read-state that cannot currently be flushed: dirty
3470/// cursors with no OAuth session to send them with.
3471///
3472/// **The visible form of the parked state (#117).** The flusher deliberately
3473/// stops warning about these every round, and quiet-and-invisible would be a
3474/// worse bug than the noisy loop it replaces — so the count is surfaced on
3475/// `/admin/metrics`. A non-zero number is not itself an alarm: it is the normal
3476/// state of anyone signed out with unsynced reads. A number that only ever
3477/// grows is the thing to look at.
3478///
3479/// Rust-backend shaped: it asks about `oauth_session`, which is the Rust
3480/// backend's store. On the sidecar backend it over-reports, since those
3481/// sessions live in the sidecar's own database. Prod runs `rust` and the
3482/// sidecar is removed by #18.
3483pub async fn parked_readstate_dids(pool: &SqlitePool) -> Result<i64> {
3484 let row: (i64,) = sqlx::query_as(
3485 r#"
3486 SELECT COUNT(DISTINCT rc.did)
3487 FROM read_cursor rc
3488 WHERE rc.dirty = 1
3489 AND NOT EXISTS (SELECT 1 FROM oauth_session s WHERE s.sub = rc.did)
3490 "#,
3491 )
3492 .fetch_one(pool)
3493 .await
3494 .context("counting parked read-state DIDs")?;
3495 Ok(row.0)
3496}
3497
3498pub async fn dirty_cursors(pool: &SqlitePool, did: &str) -> Result<Vec<ReadCursor>> {
3499 let cursors =
3500 sqlx::query_as::<_, ReadCursor>("SELECT * FROM read_cursor WHERE did = ?1 AND dirty = 1")
3501 .bind(did)
3502 .fetch_all(pool)
3503 .await
3504 .with_context(|| format!("dirty_cursors failed for {did}"))?;
3505 Ok(cursors)
3506}
3507
3508// ---------------------------------------------------------------------------
3509// Network observations (the adoption probe's projection)
3510// ---------------------------------------------------------------------------
3511
3512/// Record one relay's observation, keyed by `(key, source)` so each relay's
3513/// number is kept separately (non-archival relays legitimately disagree).
3514///
3515/// An upsert: the table is bounded forever at (metrics × relays) rows — two
3516/// today — so this can never grow the DB. It must stay an upsert and never
3517/// become a per-DID insert.
3518///
3519/// **A truncated observation never lowers a stored count.** A truncated walk
3520/// saw only part of the network, so a smaller number is evidence about the
3521/// *walk*, not about adoption. Without the guard, one slow run that managed a
3522/// single 500-repo page would overwrite a complete 2 000 and drag the published
3523/// "at least N" down — and because `latest_network_stat` takes the max across
3524/// sources, two relays behind the same operator degrade together, so `/about`
3525/// would sit at the lower figure until a full walk succeeded again. A COMPLETE
3526/// observation always wins, even when smaller (repos genuinely can disappear);
3527/// a truncated one may only ever raise the floor — and an EQUAL count raises
3528/// nothing, so it is rejected too. That is why the guard reads `<=` and not
3529/// `<`: the strict form let a truncated walk that merely matched the stored
3530/// number rewrite the row and flip `truncated` on, degrading "2 000" to "at
3531/// least 2 000" with no change in adoption.
3532pub async fn record_network_stat(pool: &SqlitePool, stat: &NetworkStat) -> Result<()> {
3533 sqlx::query(
3534 r#"
3535 INSERT INTO network_stat (key, source, value, truncated, observed_at)
3536 VALUES (?1, ?2, ?3, ?4, ?5)
3537 ON CONFLICT (key, source) DO UPDATE SET
3538 value = excluded.value,
3539 truncated = excluded.truncated,
3540 observed_at = excluded.observed_at
3541 WHERE NOT (excluded.truncated = 1 AND excluded.value <= network_stat.value)
3542 "#,
3543 )
3544 .bind(&stat.key)
3545 .bind(&stat.source)
3546 .bind(stat.value)
3547 .bind(stat.truncated)
3548 .bind(&stat.observed_at)
3549 .execute(pool)
3550 .await
3551 .with_context(|| {
3552 format!(
3553 "record_network_stat failed for {}/{}",
3554 stat.key, stat.source
3555 )
3556 })?;
3557 Ok(())
3558}
3559
3560/// The highest observation for `key` across every relay — the number to surface
3561/// (`design/NETWORK-SPEC.md` §4.1: relays disagree; show the max). `None` when no
3562/// probe has ever succeeded.
3563pub async fn latest_network_stat(pool: &SqlitePool, key: &str) -> Result<Option<NetworkStat>> {
3564 let stat = sqlx::query_as::<_, NetworkStat>(
3565 "SELECT key, source, value, truncated, observed_at FROM network_stat \
3566 WHERE key = ?1 ORDER BY value DESC, observed_at DESC LIMIT 1",
3567 )
3568 .bind(key)
3569 .fetch_optional(pool)
3570 .await
3571 .with_context(|| format!("latest_network_stat failed for {key}"))?;
3572 Ok(stat)
3573}
3574
3575/// Mark a cursor's PDS `readState` record as CREATED after the flush that first
3576/// created it, so subsequent flushes emit an `update` instead of another
3577/// `create`. Idempotent; a no-op if the row is gone.
3578pub async fn mark_cursor_pds_created(pool: &SqlitePool, did: &str, feed_url: &str) -> Result<()> {
3579 sqlx::query("UPDATE read_cursor SET pds_created = 1 WHERE did = ?1 AND feed_url = ?2")
3580 .bind(did)
3581 .bind(feed_url)
3582 .execute(pool)
3583 .await
3584 .with_context(|| format!("mark_cursor_pds_created failed for {did}/{feed_url}"))?;
3585 Ok(())
3586}
3587
3588/// Set a cursor's `pds_created` flag to what the PDS was just observed to hold.
3589///
3590/// [`mark_cursor_pds_created`] only ever sets it, because a successful create is
3591/// the only event the flusher used to learn from. The read-state reconcile
3592/// (#241) learns from a listing, and a listing can say the record is GONE —
3593/// deleted by another client or a repo reset — so it needs the other direction
3594/// too, or every later flush sends `#update` to a key that does not exist.
3595pub async fn set_cursor_pds_created(
3596 pool: &SqlitePool,
3597 did: &str,
3598 feed_url: &str,
3599 created: bool,
3600) -> Result<()> {
3601 sqlx::query("UPDATE read_cursor SET pds_created = ?3 WHERE did = ?1 AND feed_url = ?2")
3602 .bind(did)
3603 .bind(feed_url)
3604 .bind(created)
3605 .execute(pool)
3606 .await
3607 .with_context(|| format!("set_cursor_pds_created failed for {did}/{feed_url}"))?;
3608 Ok(())
3609}
3610
3611/// Clear the `dirty` flag on a cursor after a successful PDS flush — but ONLY if
3612/// the row still carries the exact `flushed_updated_at` snapshot we flushed.
3613///
3614/// The flusher reads a cursor, sends it to the PDS (a network round-trip), then
3615/// clears `dirty`. A concurrent [`upsert_cursor`] (a fresh mark-read) can land
3616/// DURING that in-flight write, bumping `updated_at` and re-setting `dirty = 1`
3617/// for reads that were NOT in the flushed snapshot. An unconditional
3618/// `SET dirty = 0` would silently drop those reads. Guarding on the snapshot's
3619/// `updated_at` makes this a compare-and-swap: if `updated_at` changed under us,
3620/// zero rows update, the row stays dirty, and it re-flushes next round.
3621pub async fn clear_cursor_dirty(
3622 pool: &SqlitePool,
3623 did: &str,
3624 feed_url: &str,
3625 flushed_updated_at: &str,
3626) -> Result<()> {
3627 sqlx::query(
3628 "UPDATE read_cursor SET dirty = 0 \
3629 WHERE did = ?1 AND feed_url = ?2 AND updated_at = ?3",
3630 )
3631 .bind(did)
3632 .bind(feed_url)
3633 .bind(flushed_updated_at)
3634 .execute(pool)
3635 .await
3636 .context("clear_cursor_dirty failed")?;
3637 Ok(())
3638}
3639
3640// ---------------------------------------------------------------------------
3641// Closed-beta invite gate (beta_access + invite_codes)
3642// ---------------------------------------------------------------------------
3643//
3644// Ported in SHAPE from a prior Go beta-gate (RedeemCode / CreateInviteCode /
3645// code_gen) but deliberately trimmed for FeatherReader's before-public
3646// experiment: NO viral invite-budget tree, NO generation cap, NO waitlist /
3647// invite-request table, and SQLite instead of Mongo. A code is minted by an
3648// existing member (or admin), and redeeming it grants a seat while seats remain
3649// under the configured cap.
3650
3651/// Unix-epoch seconds for "now" — the integer time base for the beta tables.
3652pub(crate) fn now_unix() -> i64 {
3653 chrono::Utc::now().timestamp()
3654}
3655
3656/// The invite-code alphabet: uppercase letters + digits with the
3657/// visually-ambiguous glyphs removed (`I`, `O`, `0`, `1`) so a code read aloud
3658/// or copied by hand is unambiguous.
3659const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
3660
3661/// Human-facing prefix so a FeatherReader invite code is recognisable at a
3662/// glance.
3663const CODE_PREFIX: &str = "FEATHER-";
3664
3665/// Number of random characters after the prefix.
3666const CODE_BODY_LEN: usize = 8;
3667
3668/// Generate a random, unguessable invite code of the form `FEATHER-XXXXXXXX`.
3669///
3670/// Draws from the OS CSPRNG (`getrandom`) and maps each byte onto
3671/// `CODE_ALPHABET` via rejection sampling so the alphabet distribution is
3672/// uniform (no modulo bias). Infallible in practice; a `getrandom` failure
3673/// (no entropy source) propagates as an error rather than a weak code.
3674pub fn generate_invite_code() -> Result<String> {
3675 let n = CODE_ALPHABET.len() as u16; // 31
3676 // Largest multiple of `n` that fits in a byte; bytes at or above it are
3677 // rejected so every accepted byte maps uniformly onto the alphabet.
3678 let limit = 256 / n * n; // 256 - (256 % n)
3679 let mut out = String::with_capacity(CODE_PREFIX.len() + CODE_BODY_LEN);
3680 out.push_str(CODE_PREFIX);
3681 let mut got = 0;
3682 let mut buf = [0u8; 1];
3683 while got < CODE_BODY_LEN {
3684 getrandom::fill(&mut buf).context("getrandom failed while minting invite code")?;
3685 let b = buf[0] as u16;
3686 if b < limit {
3687 out.push(CODE_ALPHABET[(b % n) as usize] as char);
3688 got += 1;
3689 }
3690 }
3691 Ok(out)
3692}
3693
3694/// Whether a DID currently holds a beta seat.
3695pub async fn has_beta_access(pool: &SqlitePool, did: &str) -> Result<bool> {
3696 let row = sqlx::query("SELECT 1 FROM beta_access WHERE did = ?1")
3697 .bind(did)
3698 .fetch_optional(pool)
3699 .await
3700 .with_context(|| format!("has_beta_access failed for {did}"))?;
3701 Ok(row.is_some())
3702}
3703
3704/// Count the beta seats currently granted — the numerator checked against the
3705/// configured cap on redeem.
3706pub async fn count_beta_access(pool: &SqlitePool) -> Result<i64> {
3707 let row = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
3708 .fetch_one(pool)
3709 .await
3710 .context("count_beta_access failed")?;
3711 Ok(row.get::<i64, _>("n"))
3712}
3713
3714/// Count `active`, unexpired invite codes — the outstanding-but-unredeemed seats
3715/// a bot has already promised. Added to [`count_beta_access`] this is the "seats
3716/// committed" figure the bot mint path (`POST /bot/claims`) checks against the
3717/// cap, so it doesn't over-promise more claims than seats remain (the redeem-time
3718/// cap in [`redeem_code`] is the hard backstop; this avoids telling a follower
3719/// "you're in" for a seat that will be full by the time they claim it).
3720pub async fn count_active_codes(pool: &SqlitePool) -> Result<i64> {
3721 let now = now_unix();
3722 let row = sqlx::query(
3723 "SELECT COUNT(*) AS n FROM invite_codes WHERE status = 'active' AND expires_at >= ?1",
3724 )
3725 .bind(now)
3726 .fetch_one(pool)
3727 .await
3728 .context("count_active_codes failed")?;
3729 Ok(row.get::<i64, _>("n"))
3730}
3731
3732/// Grant a beta seat directly (admin / seed path — no code consumed). Idempotent
3733/// on `did` (re-granting updates the row rather than erroring).
3734pub async fn grant_access(
3735 pool: &SqlitePool,
3736 did: &str,
3737 handle: Option<&str>,
3738 granted_by: &str,
3739 invite_code_used: Option<&str>,
3740) -> Result<()> {
3741 sqlx::query(
3742 r#"
3743 INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
3744 VALUES (?1, ?2, ?3, ?4, ?5)
3745 ON CONFLICT (did) DO UPDATE SET
3746 handle = COALESCE(excluded.handle, beta_access.handle),
3747 granted_by = excluded.granted_by,
3748 invite_code_used = COALESCE(excluded.invite_code_used, beta_access.invite_code_used)
3749 "#,
3750 )
3751 .bind(did)
3752 .bind(handle)
3753 .bind(granted_by)
3754 .bind(now_unix())
3755 .bind(invite_code_used)
3756 .execute(pool)
3757 .await
3758 .with_context(|| format!("grant_access failed for {did}"))?;
3759 Ok(())
3760}
3761
3762/// Mint a new `active` invite code owned by `creator_did`, expiring `ttl_secs`
3763/// from now. Returns the generated code string. The browser/admin path leaves the
3764/// bot idempotency key (`intended_did`) NULL; see [`mint_code_for_did`] for the
3765/// bot path that records the target follower.
3766pub async fn mint_code(pool: &SqlitePool, creator_did: &str, ttl_secs: i64) -> Result<String> {
3767 mint_code_inner(pool, creator_did, ttl_secs, None).await
3768}
3769
3770/// Like [`mint_code`] but records the follower `intended_did` the code is minted
3771/// FOR, so a later `POST /bot/claims` for the same DID can return the SAME code
3772/// (see [`find_active_code_for_did`]) rather than minting a duplicate. This is the
3773/// app-side idempotency backstop that survives a bot-host state loss.
3774pub async fn mint_code_for_did(
3775 pool: &SqlitePool,
3776 creator_did: &str,
3777 ttl_secs: i64,
3778 intended_did: &str,
3779) -> Result<String> {
3780 mint_code_inner(pool, creator_did, ttl_secs, Some(intended_did)).await
3781}
3782
3783async fn mint_code_inner(
3784 pool: &SqlitePool,
3785 creator_did: &str,
3786 ttl_secs: i64,
3787 intended_did: Option<&str>,
3788) -> Result<String> {
3789 let code = generate_invite_code()?;
3790 let now = now_unix();
3791 let expires_at = now.saturating_add(ttl_secs.max(0));
3792 sqlx::query(
3793 r#"
3794 INSERT INTO invite_codes
3795 (code, creator_did, status, invitee_did, intended_did, created_at, expires_at, redeemed_at)
3796 VALUES (?1, ?2, 'active', NULL, ?3, ?4, ?5, NULL)
3797 "#,
3798 )
3799 .bind(&code)
3800 .bind(creator_did)
3801 .bind(intended_did)
3802 .bind(now)
3803 .bind(expires_at)
3804 .execute(pool)
3805 .await
3806 .with_context(|| format!("mint_code failed for creator {creator_did}"))?;
3807 Ok(code)
3808}
3809
3810/// Does this error chain represent the partial-unique-index conflict raised when
3811/// a SECOND active claim is minted for a DID that already has one
3812/// (`idx_invite_codes_intended_active`)? The web layer uses this to recover from a
3813/// lost mint race (S4): on a conflict it re-reads the winner's code instead of
3814/// 500-ing. Matches on the sqlx `Database` error's UNIQUE-constraint code (SQLite
3815/// 2067 / primary 19) AND the offending COLUMN in the message
3816/// (`invite_codes.intended_did` — SQLite names the column(s), not the index), so an
3817/// unrelated constraint violation (e.g. the `code` PRIMARY KEY) is NOT swallowed.
3818pub fn is_intended_active_conflict(err: &anyhow::Error) -> bool {
3819 for cause in err.chain() {
3820 if let Some(sqlx::Error::Database(db)) = cause.downcast_ref::<sqlx::Error>() {
3821 let msg = db.message();
3822 // SQLite reports UNIQUE violations with (primary) code 19 /
3823 // (extended) 2067; the message names the offending column(s), e.g.
3824 // "UNIQUE constraint failed: invite_codes.intended_did".
3825 let is_unique = db.code().as_deref() == Some("2067")
3826 || db.code().as_deref() == Some("19")
3827 || msg.contains("UNIQUE constraint failed");
3828 // Scope to the intended_did index specifically. Only that index and the
3829 // `code` PRIMARY KEY can raise a UNIQUE error here; the partial unique
3830 // index is the only one over `intended_did`, so the column reference
3831 // uniquely identifies it.
3832 if is_unique && msg.contains("invite_codes.intended_did") {
3833 return true;
3834 }
3835 }
3836 }
3837 false
3838}
3839
3840/// The `code` of an outstanding (`active`, unexpired) invite minted FOR the
3841/// follower `intended_did`, if one exists — the app-side idempotency lookup for
3842/// `POST /bot/claims`. `Some(code)` means "return this existing code, do NOT mint
3843/// a second"; `None` means "no live code for this DID — mint one".
3844///
3845/// S3 — this lookup ONLY returns `active`, UNEXPIRED codes; once a code passes
3846/// `expires_at` (or `expire_old_codes` flips it to `expired`) this returns `None`,
3847/// so the next `POST /bot/claims` MINTS A FRESH code for the DID. There is no
3848/// in-place "refresh" of an expired code (the partial-unique index only constrains
3849/// `active` rows, so a fresh mint after expiry is allowed). The bot then re-posts:
3850/// its record rkey is deterministic per DID, so the existing skeet is UPDATED in
3851/// place with the new claim URL (see the bot's `reconcile_stale_record`, S1) rather
3852/// than a second skeet being posted. NOTE: a bot-`delivered` follower whose link
3853/// expired UNCLAIMED is only re-minted if the bot re-processes that DID (a re-seen
3854/// follow, a `waitlisted` retry, or a bot-store reset); manual recovery is to clear
3855/// the bot's `handled` row for that DID so the next cycle re-mints + re-posts.
3856/// If several live codes somehow exist (a race), the soonest-expiring is returned.
3857pub async fn find_active_code_for_did(
3858 pool: &SqlitePool,
3859 intended_did: &str,
3860) -> Result<Option<String>> {
3861 let now = now_unix();
3862 let row = sqlx::query(
3863 "SELECT code FROM invite_codes
3864 WHERE intended_did = ?1 AND status = 'active' AND expires_at >= ?2
3865 ORDER BY expires_at ASC
3866 LIMIT 1",
3867 )
3868 .bind(intended_did)
3869 .bind(now)
3870 .fetch_optional(pool)
3871 .await
3872 .with_context(|| format!("find_active_code_for_did failed for {intended_did}"))?;
3873 Ok(row.map(|r| r.get::<String, _>("code")))
3874}
3875
3876/// Atomically redeem an invite code for `did`, granting a beta seat.
3877///
3878/// Runs entirely in one transaction so the capacity check and the seat grant
3879/// cannot race (two redeems can't both slip past a `cap - 1` count). Steps:
3880/// 1. verify the code exists, is `active`, and is not past `expires_at`;
3881/// 2. verify the current seat count is `< cap`;
3882/// 3. flip the code `active`→`redeemed` (stamping `invitee_did` + `redeemed_at`);
3883/// 4. insert the `beta_access` row.
3884///
3885/// On a policy failure returns the matching [`RedeemError`] (the tx rolls back);
3886/// a real SQLite error propagates as the outer [`anyhow::Error`].
3887pub async fn redeem_code(
3888 pool: &SqlitePool,
3889 code: &str,
3890 did: &str,
3891 handle: Option<&str>,
3892 cap: i64,
3893) -> Result<std::result::Result<(), RedeemError>> {
3894 let now = now_unix();
3895 let mut tx = pool.begin().await.context("begin redeem_code tx")?;
3896
3897 // Take the write lock at the START of the transaction. sqlx issues a plain
3898 // deferred BEGIN, so without this the capacity SELECT below runs under a read
3899 // snapshot: two concurrent redeems could both pass the gate, and the loser's
3900 // later UPDATE would fail with SQLITE_BUSY_SNAPSHOT (which busy_timeout does
3901 // NOT retry) — an opaque error instead of a clean CapacityFull. A leading
3902 // no-op write against the target row acquires the RESERVED lock immediately
3903 // (SQLite locks on any write statement, even one matching zero rows), so the
3904 // second redeem blocks on the first, then reads the post-commit seat count
3905 // and returns CapacityFull. (The cap already held via snapshot isolation;
3906 // this upgrades the failure mode from a hard error to the right one.)
3907 sqlx::query("UPDATE invite_codes SET status = status WHERE code = ?1")
3908 .bind(code)
3909 .execute(&mut *tx)
3910 .await
3911 .context("redeem_code: acquire write lock")?;
3912
3913 // 1. Look the code up.
3914 let row =
3915 sqlx::query("SELECT status, expires_at, intended_did FROM invite_codes WHERE code = ?1")
3916 .bind(code)
3917 .fetch_optional(&mut *tx)
3918 .await
3919 .context("redeem_code: lookup")?;
3920 let row = match row {
3921 Some(r) => r,
3922 None => return Ok(Err(RedeemError::NotFound)),
3923 };
3924 let status: String = row.get("status");
3925 let expires_at: i64 = row.get("expires_at");
3926 let intended_did: Option<String> = row.get("intended_did");
3927
3928 // DID-binding gate (blocker B2). A bot-minted claim link is posted PUBLICLY
3929 // with a non-confidential token, so anyone who sees a follower's reply could
3930 // redeem it with a throwaway account — defeating the follow-gate, the daily
3931 // sybil budget, and the rate limit. When the code was minted FOR a specific
3932 // follower (`intended_did IS NOT NULL`), only that DID may redeem it; anyone
3933 // else gets a `NotFound` (indistinguishable from a bad code — no oracle).
3934 // Codes with a NULL `intended_did` (admin/browser-minted) stay open, as
3935 // before — those are meant to be sharable.
3936 if let Some(bound) = intended_did.as_deref() {
3937 if bound != did {
3938 return Ok(Err(RedeemError::NotFound));
3939 }
3940 }
3941
3942 // Status gate: only an `active` code is redeemable. Anything already
3943 // redeemed/revoked is "already redeemed" from the redeemer's view; an
3944 // `expired` status (or a past expiry) is "expired".
3945 if status == "expired" || now > expires_at {
3946 return Ok(Err(RedeemError::Expired));
3947 }
3948 if status != "active" {
3949 return Ok(Err(RedeemError::AlreadyRedeemed));
3950 }
3951
3952 // 2. Capacity gate (inside the tx so it can't race a concurrent redeem).
3953 let count: i64 = sqlx::query("SELECT COUNT(*) AS n FROM beta_access")
3954 .fetch_one(&mut *tx)
3955 .await
3956 .context("redeem_code: count")?
3957 .get("n");
3958 if count >= cap {
3959 return Ok(Err(RedeemError::CapacityFull));
3960 }
3961
3962 // 3. Flip the code active→redeemed. The `status = 'active'` guard in the
3963 // WHERE makes this a compare-and-swap: if a concurrent tx already flipped it
3964 // (despite the read above), zero rows change and we treat it as redeemed.
3965 let flipped = sqlx::query(
3966 r#"
3967 UPDATE invite_codes
3968 SET status = 'redeemed', invitee_did = ?2, redeemed_at = ?3
3969 WHERE code = ?1 AND status = 'active'
3970 "#,
3971 )
3972 .bind(code)
3973 .bind(did)
3974 .bind(now)
3975 .execute(&mut *tx)
3976 .await
3977 .context("redeem_code: flip")?;
3978 if flipped.rows_affected() == 0 {
3979 return Ok(Err(RedeemError::AlreadyRedeemed));
3980 }
3981
3982 // 4. Grant the seat.
3983 sqlx::query(
3984 r#"
3985 INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
3986 VALUES (?1, ?2, ?3, ?4, ?5)
3987 ON CONFLICT (did) DO UPDATE SET
3988 handle = COALESCE(excluded.handle, beta_access.handle),
3989 invite_code_used = excluded.invite_code_used
3990 "#,
3991 )
3992 .bind(did)
3993 .bind(handle)
3994 // granted_by is the code's creator; look it up in-tx to keep provenance.
3995 .bind(
3996 sqlx::query("SELECT creator_did FROM invite_codes WHERE code = ?1")
3997 .bind(code)
3998 .fetch_one(&mut *tx)
3999 .await
4000 .context("redeem_code: creator lookup")?
4001 .get::<String, _>("creator_did"),
4002 )
4003 .bind(now)
4004 .bind(code)
4005 .execute(&mut *tx)
4006 .await
4007 .context("redeem_code: grant")?;
4008
4009 tx.commit().await.context("commit redeem_code tx")?;
4010 Ok(Ok(()))
4011}
4012
4013/// Sweep: flip every `active` code whose `expires_at` is in the past to
4014/// `expired`. Returns the number of codes expired. Called periodically by the
4015/// scheduler.
4016pub async fn expire_old_codes(pool: &SqlitePool) -> Result<u64> {
4017 let now = now_unix();
4018 let res = sqlx::query(
4019 "UPDATE invite_codes SET status = 'expired' WHERE status = 'active' AND expires_at < ?1",
4020 )
4021 .bind(now)
4022 .execute(pool)
4023 .await
4024 .context("expire_old_codes failed")?;
4025 Ok(res.rows_affected())
4026}
4027
4028/// Seed the admin-bootstrap DIDs: for each, insert a `beta_access` row
4029/// (`granted_by = 'admin'`) if one does not already exist. Idempotent — an
4030/// existing seat is left untouched. Returns how many new seats were created.
4031pub async fn ensure_seed(pool: &SqlitePool, dids: &[String]) -> Result<u64> {
4032 let mut tx = pool.begin().await.context("begin ensure_seed tx")?;
4033 let now = now_unix();
4034 let mut created = 0u64;
4035 for did in dids {
4036 let res = sqlx::query(
4037 r#"
4038 INSERT INTO beta_access (did, handle, granted_by, granted_at, invite_code_used)
4039 VALUES (?1, NULL, 'admin', ?2, NULL)
4040 ON CONFLICT (did) DO NOTHING
4041 "#,
4042 )
4043 .bind(did)
4044 .bind(now)
4045 .execute(&mut *tx)
4046 .await
4047 .with_context(|| format!("ensure_seed insert failed for {did}"))?;
4048 created += res.rows_affected();
4049 }
4050 tx.commit().await.context("commit ensure_seed tx")?;
4051 Ok(created)
4052}
4053
4054/// The row counts purged by [`purge_did_data`], for a confirmable success
4055/// message and for assertions in tests.
4056#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
4057pub struct PurgeCounts {
4058 /// `entry_state` rows removed (per-DID read/star flags).
4059 pub entry_state: u64,
4060 /// `read_cursor` rows removed (per-DID per-feed read cursors).
4061 pub read_cursor: u64,
4062 /// `sub_ref` rows removed (the DID's subscription projection).
4063 pub sub_ref: u64,
4064 /// `beta_access` rows removed (the DID's closed-beta seat: 0 or 1).
4065 pub beta_access: u64,
4066 /// `invite_codes` rows removed (codes this DID *created*).
4067 pub invite_codes: u64,
4068 /// `invite_codes` rows *scrubbed* (the code this DID *redeemed* to join —
4069 /// its `invitee_did` back-reference cleared to NULL, row kept).
4070 pub invitee_scrubbed: u64,
4071 /// `beta_access` rows *scrubbed* (seats this DID *granted* to others — the
4072 /// `granted_by` back-reference redacted to a sentinel, row kept).
4073 pub granted_by_scrubbed: u64,
4074}
4075
4076impl PurgeCounts {
4077 /// Total rows removed across every per-DID table. (Scrub counts are tracked
4078 /// separately — those rows belong to *other* DIDs and are redacted, not
4079 /// deleted — so they are excluded from the delete total.)
4080 pub fn total(&self) -> u64 {
4081 self.entry_state + self.read_cursor + self.sub_ref + self.beta_access + self.invite_codes
4082 }
4083}
4084
4085/// Sentinel written into `beta_access.granted_by` when the granting DID deletes
4086/// its data: the column is `NOT NULL`, so we redact rather than NULL it. Keeps
4087/// the grantee's seat valid while removing the departed DID's back-reference.
4088pub const REDACTED_DID: &str = "__redacted__";
4089
4090/// Delete **all** local rows owned by `did` in a single transaction: the
4091/// per-DID read/star state (`entry_state`), per-feed read cursors
4092/// (`read_cursor`), the subscription projection (`sub_ref`), the closed-beta
4093/// seat (`beta_access`), and any invite codes this DID *created*
4094/// (`invite_codes`). The shared `feeds`/`entries` cache is intentionally left
4095/// intact — it is deduped and not owned by any single DID.
4096///
4097/// This is the local half of "delete my data": the caller pairs it with a
4098/// sidecar `POST /internal/revoke` so the OAuth tokens + sidecar session rows
4099/// are dropped too. Idempotent — deleting a DID with no rows returns all-zero
4100/// counts.
4101pub async fn purge_did_data(pool: &SqlitePool, did: &str) -> Result<PurgeCounts> {
4102 let mut tx = pool.begin().await.context("begin purge_did_data tx")?;
4103
4104 let entry_state = sqlx::query("DELETE FROM entry_state WHERE did = ?1")
4105 .bind(did)
4106 .execute(&mut *tx)
4107 .await
4108 .with_context(|| format!("purge entry_state for {did}"))?
4109 .rows_affected();
4110
4111 let read_cursor = sqlx::query("DELETE FROM read_cursor WHERE did = ?1")
4112 .bind(did)
4113 .execute(&mut *tx)
4114 .await
4115 .with_context(|| format!("purge read_cursor for {did}"))?
4116 .rows_affected();
4117
4118 let sub_ref = sqlx::query("DELETE FROM sub_ref WHERE did = ?1")
4119 .bind(did)
4120 .execute(&mut *tx)
4121 .await
4122 .with_context(|| format!("purge sub_ref for {did}"))?
4123 .rows_affected();
4124
4125 let beta_access = sqlx::query("DELETE FROM beta_access WHERE did = ?1")
4126 .bind(did)
4127 .execute(&mut *tx)
4128 .await
4129 .with_context(|| format!("purge beta_access for {did}"))?
4130 .rows_affected();
4131
4132 let invite_codes = sqlx::query("DELETE FROM invite_codes WHERE creator_did = ?1")
4133 .bind(did)
4134 .execute(&mut *tx)
4135 .await
4136 .with_context(|| format!("purge invite_codes for {did}"))?
4137 .rows_affected();
4138
4139 // Scrub the DID's back-references from rows that belong to OTHER DIDs so no
4140 // per-DID residue survives the delete:
4141 // * the invite code this DID *redeemed* to join lives on the inviter's
4142 // row (`invitee_did`) — NULL it out (column is nullable).
4143 // * seats this DID *granted* to others carry `granted_by = <this did>` —
4144 // redact to a sentinel (column is NOT NULL) so the grantee keeps access
4145 // without retaining the departed DID.
4146 let invitee_scrubbed =
4147 sqlx::query("UPDATE invite_codes SET invitee_did = NULL WHERE invitee_did = ?1")
4148 .bind(did)
4149 .execute(&mut *tx)
4150 .await
4151 .with_context(|| format!("scrub invitee_did for {did}"))?
4152 .rows_affected();
4153
4154 // A departing DID may also be the TARGET of an outstanding bot claim
4155 // (`intended_did`, minted for them before they joined/left) — NULL it so no
4156 // per-DID residue survives. We ALSO expire the orphaned code in the same tx:
4157 // once `intended_did` is NULLed, an `active` row would otherwise keep counting
4158 // against the daily mint cap for its full 14-day TTL (and a re-follow would
4159 // double-count it), so `expired` it now. `redeemed`/already-`expired` rows are
4160 // untouched (the WHERE only matches `active`). (Cheap nit — purge orphan.)
4161 sqlx::query(
4162 "UPDATE invite_codes \
4163 SET intended_did = NULL, \
4164 status = CASE WHEN status = 'active' THEN 'expired' ELSE status END \
4165 WHERE intended_did = ?1",
4166 )
4167 .bind(did)
4168 .execute(&mut *tx)
4169 .await
4170 .with_context(|| format!("scrub intended_did for {did}"))?;
4171
4172 let granted_by_scrubbed =
4173 sqlx::query("UPDATE beta_access SET granted_by = ?2 WHERE granted_by = ?1")
4174 .bind(did)
4175 .bind(REDACTED_DID)
4176 .execute(&mut *tx)
4177 .await
4178 .with_context(|| format!("scrub granted_by for {did}"))?
4179 .rows_affected();
4180
4181 tx.commit().await.context("commit purge_did_data tx")?;
4182
4183 Ok(PurgeCounts {
4184 entry_state,
4185 read_cursor,
4186 sub_ref,
4187 beta_access,
4188 invite_codes,
4189 invitee_scrubbed,
4190 granted_by_scrubbed,
4191 })
4192}
4193
4194/// Aggregate poll health, for the public stats page.
4195///
4196/// **Deliberately aggregate-only.** No user counts, no error rates, no per-feed
4197/// detail: this is published to anyone, and a reader does not need to know how
4198/// many people use an instance or which feeds are failing. What it does answer
4199/// is the only question the page exists for — is the poller keeping up?
4200#[derive(Debug, Clone, PartialEq, Eq)]
4201pub struct PollHealth {
4202 /// Distinct feeds the poller is responsible for.
4203 pub feeds_tracked: i64,
4204 /// How many were polled within the last hour.
4205 pub polled_last_hour: i64,
4206 /// Feeds whose `next_poll` has passed — the backlog. A healthy instance
4207 /// clears this every tick; a growing number is the signal that the poller
4208 /// cannot keep up with the feed count.
4209 pub overdue: i64,
4210 /// Seconds since the most recent poll of any feed. `None` before the first.
4211 pub last_poll_secs_ago: Option<i64>,
4212 /// Seconds since the LEAST recently polled feed was polled — the worst
4213 /// staleness any reader is currently seeing.
4214 ///
4215 /// `None` when any feed has NEVER been polled, because that is a worse
4216 /// staleness than any finite age and reporting the finite one would make
4217 /// the page read healthiest exactly when it is least healthy.
4218 pub oldest_poll_secs_ago: Option<i64>,
4219 /// How many feeds have never been polled at all.
4220 pub never_polled: i64,
4221 /// Feeds currently in error backoff (`consecutive_errors > 0`).
4222 ///
4223 /// One of the two states that stop feeds updating, and previously visible
4224 /// nowhere: `consecutive_errors` was written by `bump_feed_errors` and read
4225 /// by nothing outside the backoff calculation — no page, no endpoint. Worse,
4226 /// a feed in backoff is NOT counted in `overdue`, because backoff is applied
4227 /// by pushing `next_poll` forward. So the one number a reader might have
4228 /// checked moved the wrong way: a feed failing every fetch made `overdue`
4229 /// look BETTER.
4230 pub in_backoff: i64,
4231 /// Of those, how many have reached `BADLY_BROKEN_ERRORS` consecutive
4232 /// failures — retried 2h40m apart rather than every 5 minutes.
4233 ///
4234 /// Not "will not recover on their own": the backoff ceiling is 24h at ten
4235 /// errors, and any of these recovers on its next successful poll. See
4236 /// `BADLY_BROKEN_ERRORS`.
4237 pub badly_broken: i64,
4238 /// Failing feeds grouped by **cause**, descending, as
4239 /// `(kind, count)` — `fetch`, `status`, `body`, `parse`.
4240 ///
4241 /// **Counts, never identities.** `/stats` is public and states that it
4242 /// reports machines rather than people: no per-feed detail, never which feed
4243 /// and never whose. A cause histogram keeps that promise and still answers
4244 /// the question `badly_broken` could not — whether sixty feeds are failing
4245 /// for sixty reasons or for one. Had this existed, #159 would have read
4246 /// `fetch: 60` on a page anyone could load, instead of costing a production
4247 /// investigation.
4248 pub failure_kinds: Vec<(String, i64)>,
4249}
4250
4251/// `consecutive_errors` at or above which a feed counts as `badly_broken`.
4252///
4253/// Chosen to mean "this is not a transient blip": `feed::backoff_for` climbs
4254/// exponentially, so by this many consecutive failures a feed is being retried
4255/// **2h40m apart** — `backoff_for(6)`.
4256///
4257/// **Not "at or near the ceiling", and not "effectively dead".** `BACKOFF_MAX`
4258/// is 24h and is first reached at *ten* errors, so a feed at this threshold is
4259/// still retried around nine times a day and recovers on its own the moment the
4260/// cause clears. Three doc comments claimed otherwise, and the claim was
4261/// load-bearing in the wrong direction.
4262///
4263/// **It says nothing about whose fault the failure is, and used to claim it
4264/// did.** This comment and the matching `/stats` copy read "almost certainly
4265/// gone rather than flaky" until 2026-09-20, when #159 found that 60-odd feeds
4266/// sat here because `guarded_get` was reading every `304 Not Modified` as a
4267/// malformed redirect. The publishers were live; the reader was broken. That
4268/// assertion is what stopped anyone looking, which is why `last_error_kind`
4269/// now exists — the row can answer the question the count never could.
4270const BADLY_BROKEN_ERRORS: i64 = 6;
4271
4272/// Compute [`PollHealth`] as of `now` (RFC3339, seconds precision — the same
4273/// format the scheduler writes, so the comparisons are lexicographic).
4274pub async fn poll_health(pool: &SqlitePool, now: &str, hour_ago: &str) -> Result<PollHealth> {
4275 // **Only what the poller sees.** `due_feeds` skips `at://` rows, so nothing
4276 // ever advances their `next_poll` or sets `last_polled`; counted here they
4277 // read as overdue and never-polled forever and force "oldest poll" to
4278 // `never` — unsupported shown as broken, on a public page, permanently.
4279 // The same predicate as the scheduler's, so the two cannot disagree.
4280 let aggregate = format!(
4281 r#"
4282 SELECT
4283 COUNT(*),
4284 COALESCE(SUM(CASE WHEN last_polled IS NOT NULL AND last_polled >= ?2 THEN 1 ELSE 0 END), 0),
4285 COALESCE(SUM(CASE WHEN next_poll IS NULL OR next_poll <= ?1 THEN 1 ELSE 0 END), 0),
4286 MAX(last_polled),
4287 -- NULL-AWARE. `MIN` skips NULLs, so an instance where most feeds
4288 -- had NEVER been polled reported the freshest of the few that had —
4289 -- the figure read healthiest in the most degraded state, which is
4290 -- the opposite of what a health page is for. A never-polled feed IS
4291 -- the worst staleness, so it wins outright.
4292 CASE WHEN SUM(CASE WHEN last_polled IS NULL THEN 1 ELSE 0 END) > 0
4293 THEN NULL ELSE MIN(last_polled) END,
4294 SUM(CASE WHEN last_polled IS NULL THEN 1 ELSE 0 END),
4295 COALESCE(SUM(CASE WHEN consecutive_errors > 0 THEN 1 ELSE 0 END), 0),
4296 COALESCE(SUM(CASE WHEN consecutive_errors >= ?3 THEN 1 ELSE 0 END), 0)
4297 FROM feeds
4298 WHERE kind IN ({POLLABLE_KINDS_SQL})
4299 "#
4300 );
4301 #[allow(clippy::type_complexity)]
4302 let row: (i64, i64, i64, Option<String>, Option<String>, i64, i64, i64) =
4303 sqlx::query_as(sqlx::AssertSqlSafe(aggregate))
4304 .bind(now)
4305 .bind(hour_ago)
4306 .bind(BADLY_BROKEN_ERRORS)
4307 .fetch_one(pool)
4308 .await
4309 .context("computing poll health")?;
4310
4311 // A second, tiny query rather than a join: the histogram groups rows the
4312 // aggregate above collapses, and one statement doing both would make the
4313 // counts above harder to read than the extra round trip is worth.
4314 //
4315 // **Every failing feed lands in a bucket, so this sums to `in_backoff`.**
4316 //
4317 // A row that predates the column is failing with no recorded cause, and it
4318 // must not be attributed to some other feed's reason — but it must not
4319 // vanish either. Filtering them out made the breakdown silently disagree
4320 // with the `Failing` figure beside it: on a migrated database that is EVERY
4321 // currently-failing feed, so the page would have read "70 failing" next to
4322 // "3 fetch" with 67 unexplained and no indication a remainder existed.
4323 //
4324 // `unknown` is a deliberate bucket rather than an omission. It cannot
4325 // collide with a real kind — `FailureKind::as_str` never returns it, and
4326 // `FailureKind::parse("unknown")` is `None`.
4327 let histogram = format!(
4328 r#"
4329 -- **`failure_kind`, not `kind`.** Aliasing this `kind` collided with
4330 -- the `feeds.kind` column added for the poller: SQLite resolved
4331 -- `GROUP BY kind` to the table column, so every failing feed collapsed
4332 -- into ONE bucket labelled from an arbitrary row — a public page
4333 -- reporting "10 fetch" for ten unrelated causes. Caught by
4334 -- `an_unrecognised_failure_kind_folds_into_unknown`.
4335 SELECT COALESCE(last_error_kind, 'unknown') AS failure_kind, COUNT(*) AS n
4336 FROM feeds
4337 WHERE consecutive_errors > 0 AND kind IN ({POLLABLE_KINDS_SQL})
4338 GROUP BY failure_kind
4339 ORDER BY n DESC, failure_kind ASC
4340 "#
4341 );
4342 let kinds: Vec<(String, i64)> = sqlx::query_as(sqlx::AssertSqlSafe(histogram))
4343 .fetch_all(pool)
4344 .await
4345 .context("computing the failure-cause histogram")?;
4346
4347 // **Close the vocabulary where it is READ.** `FailureKind::parse` promised
4348 // that a kind from a newer build would not be attributed to a cause this
4349 // one recognises — but nothing called it, so the raw column reached the
4350 // public template and an unrecognised string rendered as its own bucket.
4351 // Fold anything `parse` rejects into `unknown`, then re-aggregate and
4352 // re-order, so the histogram only ever shows the four kinds this build
4353 // knows plus the one honest bucket for what it does not.
4354 let mut folded: std::collections::BTreeMap<String, i64> = std::collections::BTreeMap::new();
4355 for (kind, n) in kinds {
4356 let key = if kind == "unknown" || crate::feed::FailureKind::parse(&kind).is_some() {
4357 kind
4358 } else {
4359 "unknown".to_string()
4360 };
4361 *folded.entry(key).or_insert(0) += n;
4362 }
4363 let mut kinds: Vec<(String, i64)> = folded.into_iter().collect();
4364 kinds.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
4365
4366 Ok(PollHealth {
4367 feeds_tracked: row.0,
4368 polled_last_hour: row.1,
4369 overdue: row.2,
4370 last_poll_secs_ago: secs_between(row.3.as_deref(), now),
4371 oldest_poll_secs_ago: secs_between(row.4.as_deref(), now),
4372 never_polled: row.5,
4373 in_backoff: row.6,
4374 badly_broken: row.7,
4375 failure_kinds: kinds,
4376 })
4377}
4378
4379/// Whole seconds from `then` to `now`, or `None` if `then` is absent or
4380/// unparseable. Never negative: a clock skew that puts a poll in the future
4381/// reads as "just now" rather than as a negative age.
4382fn secs_between(then: Option<&str>, now: &str) -> Option<i64> {
4383 let then = chrono::DateTime::parse_from_rfc3339(then?).ok()?;
4384 let now = chrono::DateTime::parse_from_rfc3339(now).ok()?;
4385 Some((now - then).num_seconds().max(0))
4386}
4387
4388#[cfg(test)]
4389mod tests {
4390 use super::*;
4391
4392 const PUB_A: &str = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3laa";
4393
4394 /// Step 2 of the 0.4.0 plan: a stored publication is pollable.
4395 #[tokio::test]
4396 async fn a_due_publication_is_handed_to_the_poller() -> Result<()> {
4397 let pool = init_url("sqlite::memory:").await?;
4398 upsert_feed(
4399 &pool,
4400 &NewFeed {
4401 url: PUB_A.into(),
4402 ..Default::default()
4403 },
4404 )
4405 .await?;
4406 let due = due_feeds(&pool, "2999-01-01T00:00:00Z", 50).await?;
4407 assert!(
4408 due.iter().any(|f| f.url == PUB_A),
4409 "a publication row is not handed to the poller"
4410 );
4411 Ok(())
4412 }
4413
4414 #[tokio::test]
4415 async fn admitting_publications_staggers_their_first_poll() -> Result<()> {
4416 let pool = init_url("sqlite::memory:").await?;
4417 for i in 0..19 {
4418 let url =
4419 format!("at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3l{i:02}");
4420 upsert_feed(
4421 &pool,
4422 &NewFeed {
4423 url,
4424 ..Default::default()
4425 },
4426 )
4427 .await?;
4428 }
4429 // Already polled, and an RSS row: neither is touched.
4430 upsert_feed(
4431 &pool,
4432 &NewFeed {
4433 url: "https://rss.example/feed.xml".into(),
4434 ..Default::default()
4435 },
4436 )
4437 .await?;
4438 let n = stagger_unscheduled(
4439 &pool,
4440 crate::feed::FeedKind::Publication,
4441 std::time::Duration::from_secs(3600),
4442 )
4443 .await?;
4444 assert_eq!(n, 19, "not every unscheduled publication was scheduled");
4445 let slots: Vec<String> = sqlx::query_scalar(
4446 "SELECT next_poll FROM feeds WHERE kind = 'publication' ORDER BY next_poll",
4447 )
4448 .fetch_all(&pool)
4449 .await?;
4450 let distinct: std::collections::BTreeSet<_> = slots.iter().collect();
4451 assert_eq!(distinct.len(), 19, "publications share slots: {slots:?}");
4452 let rss: Option<String> = sqlx::query_scalar(
4453 "SELECT next_poll FROM feeds WHERE url = 'https://rss.example/feed.xml'",
4454 )
4455 .fetch_one(&pool)
4456 .await?;
4457 assert_eq!(rss, None, "an RSS row was rescheduled");
4458 let again = stagger_unscheduled(
4459 &pool,
4460 crate::feed::FeedKind::Publication,
4461 std::time::Duration::from_secs(3600),
4462 )
4463 .await?;
4464 assert_eq!(
4465 again, 0,
4466 "a second boot re-staggered rows that already had a slot"
4467 );
4468 Ok(())
4469 }
4470
4471 /// The point of the stagger: an overdue RSS feed is not starved by a block
4472 /// of newly admitted rows.
4473 #[tokio::test]
4474 async fn admitted_rows_do_not_outrank_an_overdue_rss_feed() -> Result<()> {
4475 let pool = init_url("sqlite::memory:").await?;
4476 for i in 0..5 {
4477 let url =
4478 format!("at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3l{i:02}");
4479 upsert_feed(
4480 &pool,
4481 &NewFeed {
4482 url,
4483 ..Default::default()
4484 },
4485 )
4486 .await?;
4487 }
4488 upsert_feed(
4489 &pool,
4490 &NewFeed {
4491 url: "https://overdue.example/feed.xml".into(),
4492 next_poll: Some("2000-01-01T00:00:00Z".into()),
4493 ..Default::default()
4494 },
4495 )
4496 .await?;
4497 stagger_unscheduled(
4498 &pool,
4499 crate::feed::FeedKind::Publication,
4500 std::time::Duration::from_secs(3600),
4501 )
4502 .await?;
4503 let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
4504 let first = due_feeds(&pool, &now, 1).await?;
4505 assert_eq!(first[0].url, "https://overdue.example/feed.xml");
4506 Ok(())
4507 }
4508
4509 /// **A re-poll refreshes `published`; it never refreshes `fetched_at`.**
4510 ///
4511 /// The asymmetry is the whole reason a date must be stable. `published`
4512 /// comes back from the publisher on every poll, so a value the mapper
4513 /// recomputes — "now", say — is rewritten every hour and the row can never
4514 /// age. `fetched_at` is written once, at first insert, so an entry stored
4515 /// with no date is effectively dated when we first saw it, and that date
4516 /// does hold still. Both the per-feed cap and the retention sweep order on
4517 /// `COALESCE(published, fetched_at)`, so which of the two a row lands in
4518 /// decides whether it can ever be evicted or swept.
4519 #[tokio::test]
4520 async fn a_repoll_refreshes_published_but_never_fetched_at() -> Result<()> {
4521 let pool = init_url("sqlite::memory:").await?;
4522 let feed_id = upsert_feed(
4523 &pool,
4524 &NewFeed {
4525 url: "https://example.com/f.xml".to_string(),
4526 ..Default::default()
4527 },
4528 )
4529 .await?;
4530 let seen = |at: &str| {
4531 vec![NewEntry {
4532 guid: "g".to_string(),
4533 published: Some(at.to_string()),
4534 fetched_at: Some(at.to_string()),
4535 ..Default::default()
4536 }]
4537 };
4538 insert_entries(&pool, feed_id, &seen("2026-01-01T00:00:00Z"), 0).await?;
4539 insert_entries(&pool, feed_id, &seen("2026-09-20T00:00:00Z"), 0).await?;
4540
4541 let (published, fetched_at): (Option<String>, String) =
4542 sqlx::query_as("SELECT published, fetched_at FROM entries WHERE guid = 'g'")
4543 .fetch_one(&pool)
4544 .await?;
4545 assert_eq!(
4546 published.as_deref(),
4547 Some("2026-09-20T00:00:00Z"),
4548 "the second poll's date did not replace the first"
4549 );
4550 assert_eq!(
4551 fetched_at, "2026-01-01T00:00:00Z",
4552 "fetched_at moved, so an undated entry would never age either"
4553 );
4554 Ok(())
4555 }
4556
4557 /// A partial upsert must not erase the conditional-GET validators.
4558 ///
4559 /// `set_next_poll` supplies only `url` + `next_poll` and runs after EVERY
4560 /// poll of EVERY feed. While `upsert_feed` assigned etag/last_modified
4561 /// unconditionally, that call wrote both back to NULL, so `If-None-Match`
4562 /// was never sent, `304` was unreachable, and every feed was re-downloaded
4563 /// and re-parsed in full on every cycle. Nothing failed; it was invisible.
4564 #[tokio::test]
4565 async fn validators_survive_a_partial_upsert() -> Result<()> {
4566 let pool = init_url("sqlite::memory:").await?;
4567 let url = "https://example.com/feed.xml";
4568
4569 upsert_feed(
4570 &pool,
4571 &NewFeed {
4572 url: url.to_string(),
4573 etag: Some("\"abc123\"".to_string()),
4574 last_modified: Some("Wed, 01 Jan 2026 00:00:00 GMT".to_string()),
4575 ..Default::default()
4576 },
4577 )
4578 .await?;
4579
4580 // Exactly what `scheduler::set_next_poll` sends.
4581 upsert_feed(
4582 &pool,
4583 &NewFeed {
4584 url: url.to_string(),
4585 next_poll: Some("2026-07-12T00:00:00Z".to_string()),
4586 ..Default::default()
4587 },
4588 )
4589 .await?;
4590
4591 let feed = get_feed_by_url(&pool, url).await?.expect("feed");
4592 assert_eq!(
4593 feed.etag.as_deref(),
4594 Some("\"abc123\""),
4595 "a partial upsert erased the ETag, disabling conditional GET"
4596 );
4597 assert_eq!(
4598 feed.last_modified.as_deref(),
4599 Some("Wed, 01 Jan 2026 00:00:00 GMT"),
4600 "a partial upsert erased Last-Modified"
4601 );
4602 assert_eq!(feed.next_poll.as_deref(), Some("2026-07-12T00:00:00Z"));
4603 Ok(())
4604 }
4605
4606 /// A hard ceiling that is not strictly older than the window is IGNORED.
4607 ///
4608 /// `hard_days.max(days)` made `0` — the obvious "off" value, and the
4609 /// documented disable value for `RETENTION_DAYS` — collapse the ceiling onto
4610 /// the soft window, where the delete spares nothing. The starred and unread
4611 /// rows the window exists to protect were purged at `retention_days`.
4612 #[tokio::test]
4613 async fn a_ceiling_inside_the_window_is_ignored_not_applied() -> Result<()> {
4614 for hard in [0_i64, 1, 7, 14] {
4615 let pool = init_url("sqlite::memory:").await?;
4616 let feed_id = upsert_feed(
4617 &pool,
4618 &NewFeed {
4619 url: "https://example.com/f.xml".to_string(),
4620 ..Default::default()
4621 },
4622 )
4623 .await?;
4624 let old = (chrono::Utc::now() - chrono::Duration::days(30))
4625 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
4626 insert_entries(
4627 &pool,
4628 feed_id,
4629 &[
4630 NewEntry {
4631 guid: "starred-30d".to_string(),
4632 published: Some(old.clone()),
4633 ..Default::default()
4634 },
4635 NewEntry {
4636 guid: "unread-30d".to_string(),
4637 published: Some(old.clone()),
4638 ..Default::default()
4639 },
4640 ],
4641 0,
4642 )
4643 .await?;
4644 // Both need an explicit `entry_state` row: sparing keys off a
4645 // DELIBERATE mark, and an entry with no row at all is unclaimed
4646 // cache that the window is supposed to evict.
4647 sqlx::query(
4648 "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4649 SELECT 'did:plc:x', id, 1, 1, '2026-01-01T00:00:00Z'
4650 FROM entries WHERE guid = 'starred-30d'",
4651 )
4652 .execute(&pool)
4653 .await?;
4654 sqlx::query(
4655 "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4656 SELECT 'did:plc:x', id, 0, 0, '2026-01-01T00:00:00Z'
4657 FROM entries WHERE guid = 'unread-30d'",
4658 )
4659 .execute(&pool)
4660 .await?;
4661
4662 prune_old_entries(&pool, 14, hard, 0).await?;
4663
4664 let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries")
4665 .fetch_one(&pool)
4666 .await?;
4667 assert_eq!(
4668 left, 2,
4669 "hard_days={hard} destroyed starred/unread rows at the soft window"
4670 );
4671 }
4672 Ok(())
4673 }
4674
4675 /// Turning the rolling window off must NOT also turn the ceiling off.
4676 ///
4677 /// `prune_old_entries` used to return on `days <= 0` before the ceiling was
4678 /// even computed, so `RETENTION_DAYS=0` — advertised as "disables eviction" —
4679 /// meant no window AND no ceiling. That is the one configuration with no
4680 /// bound on the shared cache at all, and it stopped being survivable when the
4681 /// per-feed trim started sparing starred entries: nothing was left to catch
4682 /// them. The two knobs are independent now.
4683 #[tokio::test]
4684 async fn a_disabled_window_does_not_disable_the_ceiling() -> Result<()> {
4685 let pool = init_url("sqlite::memory:").await?;
4686 let feed_id = upsert_feed(
4687 &pool,
4688 &NewFeed {
4689 url: "https://example.com/f.xml".to_string(),
4690 ..Default::default()
4691 },
4692 )
4693 .await?;
4694 let age = |d: i64| {
4695 (chrono::Utc::now() - chrono::Duration::days(d))
4696 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
4697 };
4698 insert_entries(
4699 &pool,
4700 feed_id,
4701 &[
4702 NewEntry {
4703 guid: "starred-400d".to_string(),
4704 published: Some(age(400)),
4705 ..Default::default()
4706 },
4707 NewEntry {
4708 guid: "starred-30d".to_string(),
4709 published: Some(age(30)),
4710 ..Default::default()
4711 },
4712 ],
4713 0,
4714 )
4715 .await?;
4716 // Star both, so only the ceiling can remove either one — the soft
4717 // window's exception would spare them both even if it did run.
4718 sqlx::query(
4719 "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4720 SELECT 'did:plc:x', id, 1, 1, '2026-01-01T00:00:00Z' FROM entries",
4721 )
4722 .execute(&pool)
4723 .await?;
4724
4725 // No rolling window; a 180-day ceiling.
4726 let deleted = prune_old_entries(&pool, 0, 180, 0).await?;
4727
4728 assert_eq!(
4729 deleted, 1,
4730 "retention_days=0 skipped the hard ceiling, leaving the cache unbounded"
4731 );
4732 let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
4733 .fetch_all(&pool)
4734 .await?;
4735 assert_eq!(
4736 left,
4737 vec!["starred-30d".to_string()],
4738 "the ceiling removed the wrong rows with the window disabled"
4739 );
4740 Ok(())
4741 }
4742
4743 /// With BOTH knobs off, nothing is deleted — that is the documented
4744 /// "no eviction at all" configuration, and it must stay a true no-op rather
4745 /// than falling through to one of the two deletes with a degenerate cutoff.
4746 #[tokio::test]
4747 async fn both_knobs_off_deletes_nothing() -> Result<()> {
4748 let pool = init_url("sqlite::memory:").await?;
4749 let feed_id = upsert_feed(
4750 &pool,
4751 &NewFeed {
4752 url: "https://example.com/f.xml".to_string(),
4753 ..Default::default()
4754 },
4755 )
4756 .await?;
4757 insert_entries(
4758 &pool,
4759 feed_id,
4760 &[NewEntry {
4761 guid: "ancient".to_string(),
4762 published: Some(
4763 (chrono::Utc::now() - chrono::Duration::days(9999))
4764 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
4765 ),
4766 ..Default::default()
4767 }],
4768 0,
4769 )
4770 .await?;
4771
4772 assert_eq!(prune_old_entries(&pool, 0, 0, 0).await?, 0);
4773 let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries")
4774 .fetch_one(&pool)
4775 .await?;
4776 assert_eq!(left, 1);
4777 Ok(())
4778 }
4779
4780 /// Starred sparing must not remove the per-feed cap.
4781 ///
4782 /// The first version spared every starred row without limit: at cap=5 with
4783 /// 50 starred entries, 55 survived — 11x the cap, i.e. no cap at all.
4784 #[tokio::test]
4785 async fn per_feed_trim_stays_bounded_when_everything_is_starred() -> Result<()> {
4786 let pool = init_url("sqlite::memory:").await?;
4787 let feed_id = upsert_feed(
4788 &pool,
4789 &NewFeed {
4790 url: "https://example.com/f.xml".to_string(),
4791 ..Default::default()
4792 },
4793 )
4794 .await?;
4795 let entries: Vec<NewEntry> = (0..100)
4796 .map(|i| NewEntry {
4797 guid: format!("g-{i}"),
4798 published: Some(format!("2026-01-{:02}T00:00:00Z", (i % 28) + 1)),
4799 ..Default::default()
4800 })
4801 .collect();
4802 insert_entries(&pool, feed_id, &entries, 0).await?;
4803 sqlx::query(
4804 "INSERT INTO entry_state (did, entry_id, read, starred, updated_at)
4805 SELECT 'did:plc:x', id, 0, 1, '2026-01-01T00:00:00Z'
4806 FROM entries LIMIT 50",
4807 )
4808 .execute(&pool)
4809 .await?;
4810
4811 // Re-run the trim with cap = 5.
4812 insert_entries(&pool, feed_id, &[], 5).await?;
4813
4814 let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries")
4815 .fetch_one(&pool)
4816 .await?;
4817 assert!(
4818 left <= 10,
4819 "per-feed trim kept {left} rows for a cap of 5; sparing removed the bound"
4820 );
4821 Ok(())
4822 }
4823
4824 /// Init an in-memory SQLite, insert a feed + entries, read them back.
4825 #[tokio::test]
4826 async fn init_insert_readback() -> Result<()> {
4827 let pool = init_url("sqlite::memory:").await?;
4828
4829 // Insert a feed.
4830 let feed_id = upsert_feed(
4831 &pool,
4832 &NewFeed {
4833 url: "https://example.com/feed.xml".to_string(),
4834 title: Some("Example".to_string()),
4835 site_url: Some("https://example.com".to_string()),
4836 next_poll: Some("2026-07-12T00:00:00Z".to_string()),
4837 ..Default::default()
4838 },
4839 )
4840 .await?;
4841 assert!(feed_id > 0);
4842
4843 // Read the feed back by URL.
4844 let feed = get_feed_by_url(&pool, "https://example.com/feed.xml")
4845 .await?
4846 .expect("feed should exist");
4847 assert_eq!(feed.id, feed_id);
4848 assert_eq!(feed.title.as_deref(), Some("Example"));
4849 assert_eq!(feed.site_url.as_deref(), Some("https://example.com"));
4850
4851 // Upsert on the same URL updates rather than duplicating.
4852 let feed_id2 = upsert_feed(
4853 &pool,
4854 &NewFeed {
4855 url: "https://example.com/feed.xml".to_string(),
4856 title: Some("Example (renamed)".to_string()),
4857 ..Default::default()
4858 },
4859 )
4860 .await?;
4861 assert_eq!(feed_id, feed_id2, "same URL must reuse the same row");
4862
4863 // Insert two entries.
4864 let n = insert_entries(
4865 &pool,
4866 feed_id,
4867 &[
4868 NewEntry {
4869 guid: "guid-1".to_string(),
4870 url: Some("https://example.com/a".to_string()),
4871 title: Some("First".to_string()),
4872 published: Some("2026-07-10T08:00:00Z".to_string()),
4873 content_html: Some("<p>hello</p>".to_string()),
4874 ..Default::default()
4875 },
4876 NewEntry {
4877 guid: "guid-2".to_string(),
4878 url: Some("https://example.com/b".to_string()),
4879 title: Some("Second".to_string()),
4880 published: Some("2026-07-11T08:00:00Z".to_string()),
4881 ..Default::default()
4882 },
4883 ],
4884 0, // per-feed trim disabled for this test
4885 )
4886 .await?;
4887 assert_eq!(n, 2);
4888
4889 // The reader must subscribe to the feed for the scoped reads to return
4890 // its entries (per-DID isolation projection).
4891 let did = "did:plc:abc123";
4892 replace_sub_refs(&pool, did, &[feed_id]).await?;
4893
4894 // Read entries back (newest-published first).
4895 let entries = entries_for_feed(&pool, did, feed_id).await?;
4896 assert_eq!(entries.len(), 2);
4897 assert_eq!(entries[0].guid, "guid-2");
4898 assert_eq!(entries[1].guid, "guid-1");
4899 // The body is stored, but it is NOT in the list projection — that is the
4900 // point of `EntryListRow`. Read it the way the single-entry reader does.
4901 let body: Option<String> =
4902 sqlx::query_scalar("SELECT content_html FROM entries WHERE guid = 'guid-1'")
4903 .fetch_one(&pool)
4904 .await?;
4905 assert_eq!(body.as_deref(), Some("<p>hello</p>"));
4906
4907 // Re-inserting the same GUID dedups (updates in place, no new row).
4908 let n2 = insert_entries(
4909 &pool,
4910 feed_id,
4911 &[NewEntry {
4912 guid: "guid-1".to_string(),
4913 title: Some("First (edited)".to_string()),
4914 ..Default::default()
4915 }],
4916 0,
4917 )
4918 .await?;
4919 assert_eq!(n2, 1);
4920 assert_eq!(entries_for_feed(&pool, did, feed_id).await?.len(), 2);
4921
4922 // --- per-DID read state ---
4923 let e1 = entries.iter().find(|e| e.guid == "guid-1").unwrap().id;
4924
4925 // Both entries start unread.
4926 assert_eq!(get_unread_for_did(&pool, did).await?.len(), 2);
4927
4928 // Mark one read; unread count drops to 1.
4929 mark_read(&pool, did, e1, true).await?;
4930 let unread = get_unread_for_did(&pool, did).await?;
4931 assert_eq!(unread.len(), 1);
4932 assert_eq!(unread[0].guid, "guid-2");
4933
4934 // Star it; it shows in the starred list.
4935 mark_starred(&pool, did, e1, true).await?;
4936 let starred = get_starred_for_did(&pool, did).await?;
4937 assert_eq!(starred.len(), 1);
4938 assert_eq!(starred[0].id, e1);
4939
4940 // Mark-all-read clears the remaining unread.
4941 mark_feed_read(&pool, did, feed_id, true).await?;
4942 assert_eq!(get_unread_for_did(&pool, did).await?.len(), 0);
4943
4944 // --- read cursor (batched-sync bookkeeping) ---
4945 let cursor = ReadCursor {
4946 did: did.to_string(),
4947 feed_url: "https://example.com/feed.xml".to_string(),
4948 read_through: Some("2026-07-11T08:00:00Z".to_string()),
4949 read_ids: "[]".to_string(),
4950 unread_ids: "[]".to_string(),
4951 dirty: true,
4952 pds_created: false,
4953 updated_at: now_rfc3339(),
4954 };
4955 upsert_cursor(&pool, &cursor).await?;
4956
4957 let fetched = get_cursor(&pool, did, "https://example.com/feed.xml")
4958 .await?
4959 .expect("cursor should exist");
4960 assert_eq!(
4961 fetched.read_through.as_deref(),
4962 Some("2026-07-11T08:00:00Z")
4963 );
4964 assert!(fetched.dirty);
4965
4966 // The flusher sees exactly one dirty cursor.
4967 let dirty = dirty_cursors(&pool, did).await?;
4968 assert_eq!(dirty.len(), 1);
4969 let flushed_at = dirty[0].updated_at.clone();
4970
4971 // After a flush, clearing dirty (with the flushed snapshot's updated_at)
4972 // removes it from the flusher's view.
4973 clear_cursor_dirty(&pool, did, "https://example.com/feed.xml", &flushed_at).await?;
4974 assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
4975
4976 Ok(())
4977 }
4978
4979 // -----------------------------------------------------------------------
4980 // The bounded, body-free list projection.
4981 //
4982 // The three queries these replaced were `SELECT e.*` with no `LIMIT`. Both
4983 // halves of that are load-bearing on a 512 MB box: the projection dragged
4984 // an ~11.9 KB article body per row that no list surface reads, and the
4985 // missing bound let one reader's backlog decide how much a handler
4986 // allocates.
4987 // -----------------------------------------------------------------------
4988
4989 /// Seed `count` entries in one feed, each with a large body, subscribed by
4990 /// `did`. Returns the feed id.
4991 async fn seed_big_entries(pool: &SqlitePool, did: &str, count: usize) -> Result<i64> {
4992 let feed_id = upsert_feed(
4993 pool,
4994 &NewFeed {
4995 url: "https://example.com/big.xml".to_string(),
4996 ..Default::default()
4997 },
4998 )
4999 .await?;
5000 let body = "x".repeat(20_000);
5001 let entries: Vec<NewEntry> = (0..count)
5002 .map(|i| NewEntry {
5003 guid: format!("guid-{i:04}"),
5004 url: Some(format!("https://example.com/a/{i}")),
5005 title: Some(format!("Article {i}")),
5006 // Descending guid order matches descending published order, so
5007 // assertions can name the rows they expect.
5008 published: Some(format!("2026-01-{:02}T00:00:00Z", (i % 28) + 1)),
5009 content_html: Some(body.clone()),
5010 ..Default::default()
5011 })
5012 .collect();
5013 insert_entries(pool, feed_id, &entries, 0).await?;
5014 replace_sub_refs(pool, did, &[feed_id]).await?;
5015 Ok(feed_id)
5016 }
5017
5018 /// Review of #213: the ceiling only stops NEW future dates. A row stored
5019 /// with one before it, whose item has since left its feed, is never polled
5020 /// again to be corrected — so it stayed first in the list and survived the
5021 /// per-feed cap forever. Startup re-dates it.
5022 #[tokio::test]
5023 async fn a_stored_future_date_is_cleared_at_startup() -> Result<()> {
5024 let pool = init_url("sqlite::memory:").await?;
5025 let feed_id = upsert_feed(
5026 &pool,
5027 &NewFeed {
5028 url: "https://clock.example/f.xml".into(),
5029 ..Default::default()
5030 },
5031 )
5032 .await?;
5033 let tomorrow = (chrono::Utc::now() + chrono::Duration::days(1))
5034 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
5035 for (guid, published) in [
5036 ("bogus", "2999-01-01T00:00:00Z"),
5037 ("soon", tomorrow.as_str()),
5038 ] {
5039 sqlx::query(
5040 "INSERT INTO entries (feed_id, guid, published, fetched_at) \
5041 VALUES (?1, ?2, ?3, '2026-07-11T00:00:00Z')",
5042 )
5043 .bind(feed_id)
5044 .bind(guid)
5045 .bind(published)
5046 .execute(&pool)
5047 .await?;
5048 }
5049 apply_migrations(&pool).await?;
5050 let dated: Vec<(String, Option<String>)> =
5051 sqlx::query_as("SELECT guid, published FROM entries ORDER BY guid")
5052 .fetch_all(&pool)
5053 .await?;
5054 assert_eq!(
5055 dated[0],
5056 ("bogus".to_string(), None),
5057 "a 2999 date survived startup"
5058 );
5059 assert_eq!(
5060 dated[1].1.as_deref(),
5061 Some(tomorrow.as_str()),
5062 "a near-future date was cleared"
5063 );
5064 Ok(())
5065 }
5066
5067 /// **The cap, the reading list and prev/next must agree about what an undated
5068 /// entry's date IS.** They did not, and the disagreement had a direction.
5069 ///
5070 /// The per-feed keep-set and both retention sweeps order on
5071 /// `COALESCE(published, fetched_at)` — correctly, because a feed of undated
5072 /// items would otherwise trim its own freshest rows. The reading list
5073 /// ordered on bare `e.published DESC`, and in SQLite `NULL` sorts LAST under
5074 /// `DESC`. So one undated entry was simultaneously the NEWEST row in the
5075 /// feed as far as eviction was concerned, and the OLDEST row in every list
5076 /// view — parked below years of read articles where no reader would see it,
5077 /// while the cap declined to drop it to make room for something they would.
5078 ///
5079 /// `site.standard.document` makes `publishedAt` optional, so publication
5080 /// feeds reach this far more readily than RSS ever did.
5081 ///
5082 /// Both directions here: the undated row must come first, AND the two dated
5083 /// rows must stay in their own order, or "order by nothing" would pass.
5084 ///
5085 /// **On the index worry, measured on the query the app actually sends.**
5086 /// #187 flagged that a `COALESCE` in `ORDER BY` cannot use
5087 /// `idx_entries_feed_published` for ordering. The real list and prev/next
5088 /// queries (LEFT JOIN `entry_state`, EXISTS `sub_ref`) did not use it for
5089 /// ordering before this change either, and timing them at 40 feeds x 1,000
5090 /// entries showed the new ordering costs nothing on the existing index. A
5091 /// `(feed_id, published, fetched_at)` index meant to keep them covering was
5092 /// never chosen on the default prev/next query and made it ~3.8x slower,
5093 /// so it was not kept (review of #213).
5094 #[tokio::test]
5095 async fn an_undated_entry_leads_the_reading_list_as_it_leads_the_cap() -> Result<()> {
5096 let pool = init_url("sqlite::memory:").await?;
5097 let did = "did:plc:undated";
5098 let feed_id = upsert_feed(
5099 &pool,
5100 &NewFeed {
5101 url: "https://undated.example/f.xml".to_string(),
5102 ..Default::default()
5103 },
5104 )
5105 .await?;
5106 insert_entries(
5107 &pool,
5108 feed_id,
5109 &[
5110 NewEntry {
5111 guid: "dated-old".to_string(),
5112 title: Some("Old".to_string()),
5113 published: Some("2024-01-01T00:00:00Z".to_string()),
5114 ..Default::default()
5115 },
5116 NewEntry {
5117 guid: "dated-new".to_string(),
5118 title: Some("Newer".to_string()),
5119 published: Some("2025-01-01T00:00:00Z".to_string()),
5120 ..Default::default()
5121 },
5122 // No `published` at all — dated by `fetched_at`, which is now,
5123 // so it is the freshest row in the feed.
5124 NewEntry {
5125 guid: "undated".to_string(),
5126 title: Some("Undated".to_string()),
5127 ..Default::default()
5128 },
5129 ],
5130 0,
5131 )
5132 .await?;
5133 replace_sub_refs(&pool, did, &[feed_id]).await?;
5134
5135 let rows = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
5136 let order: Vec<&str> = rows.iter().map(|r| r.guid.as_str()).collect();
5137 assert_eq!(
5138 order,
5139 vec!["undated", "dated-new", "dated-old"],
5140 "the list disagrees with the cap about an undated entry's date",
5141 );
5142
5143 // `list_entry_ids` is the sequence PREV/NEXT walks — its only non-test
5144 // caller is `web::neighbors_in_scope`. Ordered differently from the
5145 // list, "next entry" would take the reader somewhere that is not the
5146 // next row on screen. (`mark_read` and `mark_all_read` are id-based and
5147 // never use this ordering; an earlier version of this comment said they
5148 // did, naming a failure that cannot happen and omitting the one that
5149 // can.)
5150 let ids = list_entry_ids(&pool, did, ListView::All, None, 100).await?;
5151 let by_guid: std::collections::HashMap<i64, &str> =
5152 rows.iter().map(|r| (r.id, r.guid.as_str())).collect();
5153 let id_order: Vec<&str> = ids.iter().filter_map(|i| by_guid.get(i).copied()).collect();
5154 assert_eq!(
5155 id_order,
5156 vec!["undated", "dated-new", "dated-old"],
5157 "the id projection orders differently from the list it projects",
5158 );
5159 Ok(())
5160 }
5161
5162 /// `limit` is honoured, and `offset` walks the same ordering without gaps or
5163 /// repeats. Against the unbounded originals the first assertion returned all
5164 /// 250 rows.
5165 #[tokio::test]
5166 async fn list_entries_is_bounded_and_pages_without_overlap() -> Result<()> {
5167 let pool = init_url("sqlite::memory:").await?;
5168 let did = "did:plc:pager";
5169 seed_big_entries(&pool, did, 250).await?;
5170
5171 let page1 = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
5172 assert_eq!(page1.len(), 100, "limit was not applied");
5173 let page2 = list_entries(&pool, did, ListView::All, None, 100, 100).await?;
5174 let page3 = list_entries(&pool, did, ListView::All, None, 100, 200).await?;
5175 assert_eq!(page3.len(), 50, "the last page should be the remainder");
5176
5177 let walked: Vec<i64> = page1
5178 .iter()
5179 .chain(&page2)
5180 .chain(&page3)
5181 .map(|e| e.id)
5182 .collect();
5183 let unique: std::collections::HashSet<i64> = walked.iter().copied().collect();
5184 assert_eq!(unique.len(), 250, "paging repeated or skipped rows");
5185
5186 // And the walk is the same order an unpaged read would produce.
5187 let whole = list_entries(&pool, did, ListView::All, None, 1_000, 0).await?;
5188 assert_eq!(
5189 walked,
5190 whole.iter().map(|e| e.id).collect::<Vec<_>>(),
5191 "paging changed the ordering"
5192 );
5193
5194 // **The tie-break is pinned, not left to the engine.** The seed gives
5195 // 250 rows only 28 distinct dates, so the order is mostly ties; with
5196 // the `id DESC` tie-break deleted, SQLite happened to return ties in a
5197 // stable order and both assertions above still held. The expected
5198 // order is computed from the seed pattern here — newest date first,
5199 // then newest id — and must match exactly.
5200 let mut expected: Vec<(i64, i64)> = whole
5201 .iter()
5202 .map(|e| {
5203 let day: i64 = e.published.as_deref().unwrap()[8..10].parse().unwrap();
5204 (day, e.id)
5205 })
5206 .collect();
5207 expected.sort_by(|a, b| b.cmp(a));
5208 assert_eq!(
5209 walked,
5210 expected.iter().map(|(_, id)| *id).collect::<Vec<_>>(),
5211 "ties are not broken by newest id"
5212 );
5213
5214 assert_eq!(
5215 count_entries_for_view(&pool, did, ListView::All, None).await?,
5216 250,
5217 "the unpaged count must survive paging"
5218 );
5219 Ok(())
5220 }
5221
5222 /// The list projection must not read `content_html`.
5223 ///
5224 /// A type-level fact — `EntryListRow` has no body field — so the test proves
5225 /// it the only way that survives a refactor: by asking SQLite what the query
5226 /// it runs actually names. `SELECT e.*` would list every column.
5227 #[tokio::test]
5228 async fn the_list_projection_does_not_name_the_body_column() -> Result<()> {
5229 let pool = init_url("sqlite::memory:").await?;
5230 let did = "did:plc:projection";
5231 seed_big_entries(&pool, did, 3).await?;
5232
5233 // **The projection the query actually runs**, not a copy re-typed here.
5234 // The earlier version passed its own literal to `list_query_sql` and
5235 // asserted on that, so adding `e.content_html` to `list_entries` left
5236 // this green.
5237 let (sql, _) = list_entries_sql(ListView::All, None);
5238 assert!(
5239 !sql.contains("content_html") && !sql.contains("e.*"),
5240 "the list query reads the article body: {sql}"
5241 );
5242
5243 // And the rows really do come back without it, which is what bounds the
5244 // per-request allocation.
5245 let rows = list_entries(&pool, did, ListView::All, None, 10, 0).await?;
5246 assert_eq!(rows.len(), 3);
5247 let widest = rows
5248 .iter()
5249 .map(|r| {
5250 r.guid.len()
5251 + r.url.as_deref().map_or(0, str::len)
5252 + r.title.as_deref().map_or(0, str::len)
5253 })
5254 .max()
5255 .unwrap_or(0);
5256 assert!(
5257 widest < 1_000,
5258 "a list row carries {widest} bytes of text; the 20,000-byte body leaked in"
5259 );
5260 Ok(())
5261 }
5262
5263 /// **A large scope must not become a large SQL statement.**
5264 ///
5265 /// The scope filter used to emit one placeholder per feed id, so the SQL
5266 /// string and the bind list both grew with a reader's subscription count —
5267 /// which comes from the PDS and is bounded only by a 20,000-record list
5268 /// ceiling. The first attempt at fixing that truncated the subscription
5269 /// list, which silently removed the reader's access to the dropped feeds
5270 /// (`sub_ref` is written from the same list). `json_each` takes the whole
5271 /// set as ONE bind, so neither trade-off is needed.
5272 #[tokio::test]
5273 async fn a_large_scope_is_one_bind_and_still_filters() -> Result<()> {
5274 let pool = init_url("sqlite::memory:").await?;
5275 let did = "did:plc:widescope";
5276
5277 // 300 feeds, one entry each; the scope names 200 of them.
5278 let mut all_ids = Vec::new();
5279 for i in 0..300 {
5280 let feed_id = upsert_feed(
5281 &pool,
5282 &NewFeed {
5283 url: format!("https://wide{i}.example/f.xml"),
5284 ..Default::default()
5285 },
5286 )
5287 .await?;
5288 insert_entries(
5289 &pool,
5290 feed_id,
5291 &[NewEntry {
5292 guid: format!("w-{i}"),
5293 ..Default::default()
5294 }],
5295 0,
5296 )
5297 .await?;
5298 all_ids.push(feed_id);
5299 }
5300 replace_sub_refs(&pool, did, &all_ids).await?;
5301
5302 let scope: Vec<i64> = all_ids.iter().copied().take(200).collect();
5303 let rows = list_entries(&pool, did, ListView::All, Some(&scope), 1_000, 0).await?;
5304 assert_eq!(rows.len(), 200, "the scope filter did not narrow correctly");
5305 let in_scope: std::collections::HashSet<i64> = scope.iter().copied().collect();
5306 assert!(
5307 rows.iter().all(|r| in_scope.contains(&r.feed_id)),
5308 "a feed outside the scope came back"
5309 );
5310 assert_eq!(
5311 count_entries_for_view(&pool, did, ListView::All, Some(&scope)).await?,
5312 200
5313 );
5314
5315 // The statement itself carries no per-id placeholders — that is the
5316 // property, and it is what stops the SQL growing with the reader.
5317 let (sql, n) = list_query_sql(Projection::Ids, ListView::All, Some(&scope));
5318 assert_eq!(n, 1, "the scope must contribute exactly one placeholder");
5319 assert!(
5320 sql.contains("json_each(?2)") && !sql.contains("?3"),
5321 "the scope is still expanded into per-id placeholders: {sql}"
5322 );
5323 Ok(())
5324 }
5325
5326 /// Scope is applied INSIDE the query, so a page is a page of rows the reader
5327 /// will see. Filtering after the `LIMIT` (what the handler used to do) made
5328 /// pages arbitrarily short for any narrowed scope.
5329 #[tokio::test]
5330 async fn a_feed_scope_narrows_the_query_not_the_page() -> Result<()> {
5331 let pool = init_url("sqlite::memory:").await?;
5332 let did = "did:plc:scope";
5333 let wanted = seed_big_entries(&pool, did, 10).await?;
5334
5335 let other = upsert_feed(
5336 &pool,
5337 &NewFeed {
5338 url: "https://other.example/f.xml".to_string(),
5339 ..Default::default()
5340 },
5341 )
5342 .await?;
5343 let noise: Vec<NewEntry> = (0..40)
5344 .map(|i| NewEntry {
5345 guid: format!("noise-{i}"),
5346 // Newer than everything in `wanted`, so an unscoped query would
5347 // fill the whole page with these.
5348 published: Some("2027-01-01T00:00:00Z".to_string()),
5349 ..Default::default()
5350 })
5351 .collect();
5352 insert_entries(&pool, other, &noise, 0).await?;
5353 replace_sub_refs(&pool, did, &[wanted, other]).await?;
5354
5355 let scoped = list_entries(&pool, did, ListView::All, Some(&[wanted]), 10, 0).await?;
5356 assert_eq!(
5357 scoped.len(),
5358 10,
5359 "the scoped page came back short — the filter ran after the LIMIT"
5360 );
5361 assert!(scoped.iter().all(|e| e.feed_id == wanted));
5362
5363 // An EMPTY scope means "no feeds in scope", not "every feed".
5364 assert!(list_entries(&pool, did, ListView::All, Some(&[]), 10, 0)
5365 .await?
5366 .is_empty());
5367 assert_eq!(
5368 count_entries_for_view(&pool, did, ListView::All, Some(&[])).await?,
5369 0
5370 );
5371 Ok(())
5372 }
5373
5374 /// The per-row `read` / `starred` bits come off the row's own join, matching
5375 /// what the separate full-set queries used to compute — including the
5376 /// "no `entry_state` row means unread" rule the views depend on.
5377 #[tokio::test]
5378 async fn list_rows_carry_their_own_read_and_star_bits() -> Result<()> {
5379 let pool = init_url("sqlite::memory:").await?;
5380 let did = "did:plc:bits";
5381 seed_big_entries(&pool, did, 3).await?;
5382 let ids: Vec<i64> = list_entries(&pool, did, ListView::All, None, 10, 0)
5383 .await?
5384 .iter()
5385 .map(|e| e.id)
5386 .collect();
5387
5388 mark_read(&pool, did, ids[0], true).await?;
5389 mark_starred(&pool, did, ids[1], true).await?;
5390
5391 let all = list_entries(&pool, did, ListView::All, None, 10, 0).await?;
5392 let by_id = |id: i64| all.iter().find(|e| e.id == id).expect("row present");
5393 assert!(by_id(ids[0]).read && !by_id(ids[0]).starred);
5394 assert!(!by_id(ids[1]).read && by_id(ids[1]).starred);
5395 // Never touched: no state row at all, which must read as unread.
5396 assert!(!by_id(ids[2]).read && !by_id(ids[2]).starred);
5397
5398 // And the view predicates agree with the bits.
5399 let unread = list_entries(&pool, did, ListView::Unread, None, 10, 0).await?;
5400 assert_eq!(unread.len(), 2);
5401 assert!(unread.iter().all(|e| !e.read));
5402 let starred = list_entries(&pool, did, ListView::Starred, None, 10, 0).await?;
5403 assert_eq!(starred.len(), 1);
5404 assert_eq!(starred[0].id, ids[1]);
5405 Ok(())
5406 }
5407
5408 /// The sidebar's per-feed unread badges, counted in SQL rather than by
5409 /// materializing every unread entry and filtering in Rust.
5410 #[tokio::test]
5411 async fn unread_counts_are_per_feed_and_exclude_read_rows() -> Result<()> {
5412 let pool = init_url("sqlite::memory:").await?;
5413 let did = "did:plc:counts";
5414 let a = seed_big_entries(&pool, did, 5).await?;
5415 let b = upsert_feed(
5416 &pool,
5417 &NewFeed {
5418 url: "https://b.example/f.xml".to_string(),
5419 ..Default::default()
5420 },
5421 )
5422 .await?;
5423 insert_entries(
5424 &pool,
5425 b,
5426 &[
5427 NewEntry {
5428 guid: "b-1".to_string(),
5429 ..Default::default()
5430 },
5431 NewEntry {
5432 guid: "b-2".to_string(),
5433 ..Default::default()
5434 },
5435 ],
5436 0,
5437 )
5438 .await?;
5439 replace_sub_refs(&pool, did, &[a, b]).await?;
5440
5441 let first_a = list_entries(&pool, did, ListView::All, Some(&[a]), 1, 0).await?[0].id;
5442 mark_read(&pool, did, first_a, true).await?;
5443
5444 let counts = unread_counts_by_feed(&pool, did).await?;
5445 assert_eq!(counts.get(&a).copied(), Some(4));
5446 assert_eq!(counts.get(&b).copied(), Some(2));
5447
5448 // A feed the DID does not subscribe to contributes nothing.
5449 replace_sub_refs(&pool, did, &[b]).await?;
5450 let counts = unread_counts_by_feed(&pool, did).await?;
5451 assert_eq!(counts.get(&a), None);
5452 assert_eq!(counts.get(&b).copied(), Some(2));
5453 Ok(())
5454 }
5455
5456 /// **Read-state compaction: the water-mark must absorb the id set.**
5457 ///
5458 /// `read_through` was never computed, so `read_ids` was the only mechanism
5459 /// and grew one id per article read against a 2000-entry per-feed ceiling —
5460 /// while the flusher truncates the record at 1000, keeping the tail. Past
5461 /// 1000 read articles in a feed, the oldest read-state stopped syncing and
5462 /// those articles came back UNREAD in every other atproto reader.
5463 #[tokio::test]
5464 async fn compaction_folds_read_ids_into_the_water_mark() -> Result<()> {
5465 let pool = init_url("sqlite::memory:").await?;
5466 let did = "did:plc:compact";
5467 let feed_url = "https://compact.example/f.xml";
5468 let feed_id = upsert_feed(
5469 &pool,
5470 &NewFeed {
5471 url: feed_url.to_string(),
5472 ..Default::default()
5473 },
5474 )
5475 .await?;
5476 // 40 entries, oldest first by published date.
5477 let entries: Vec<NewEntry> = (0..40)
5478 .map(|i| NewEntry {
5479 guid: format!("c-{i:03}"),
5480 published: Some(format!("2026-01-{:02}T00:00:00Z", i + 1)),
5481 ..Default::default()
5482 })
5483 .collect();
5484 insert_entries(&pool, feed_id, &entries, 0).await?;
5485 replace_sub_refs(&pool, did, &[feed_id]).await?;
5486
5487 let all = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
5488 // Oldest first, so the read prefix is contiguous from the start.
5489 let mut oldest_first = all.clone();
5490 oldest_first.reverse();
5491 for row in oldest_first.iter().take(30) {
5492 mark_read(&pool, did, row.id, true).await?;
5493 }
5494
5495 let before = get_cursor(&pool, did, feed_url).await?.expect("cursor");
5496 assert!(before.read_through.is_none(), "read_through starts unset");
5497 let before_ids: Vec<String> = serde_json::from_str(&before.read_ids)?;
5498 assert_eq!(before_ids.len(), 30, "every read is its own exception");
5499
5500 let watermark = compact_cursor(&pool, did, feed_url)
5501 .await?
5502 .expect("the water-mark must advance");
5503
5504 let after = get_cursor(&pool, did, feed_url).await?.expect("cursor");
5505 assert_eq!(after.read_through.as_deref(), Some(watermark.as_str()));
5506 let after_ids: Vec<String> = serde_json::from_str(&after.read_ids)?;
5507 assert!(
5508 after_ids.is_empty(),
5509 "a contiguous read prefix must fold entirely into the water-mark, left {after_ids:?}"
5510 );
5511 // The 30th entry is read and the 31st is not, so the mark sits on the
5512 // 30th — STRICTLY below the oldest unread, never equal to it.
5513 assert_eq!(watermark, "2026-01-30T00:00:00Z");
5514 assert!(after.dirty, "a rewritten cursor must be re-flushed");
5515 Ok(())
5516 }
5517
5518 /// The water-mark may never cover an unread entry, and may never move
5519 /// backwards. Both would re-assert articles as read that are not.
5520 #[tokio::test]
5521 async fn compaction_stops_below_the_oldest_unread_entry() -> Result<()> {
5522 let pool = init_url("sqlite::memory:").await?;
5523 let did = "did:plc:gap";
5524 let feed_url = "https://gap.example/f.xml";
5525 let feed_id = upsert_feed(
5526 &pool,
5527 &NewFeed {
5528 url: feed_url.to_string(),
5529 ..Default::default()
5530 },
5531 )
5532 .await?;
5533 let entries: Vec<NewEntry> = (0..10)
5534 .map(|i| NewEntry {
5535 guid: format!("g-{i:02}"),
5536 published: Some(format!("2026-02-{:02}T00:00:00Z", i + 1)),
5537 ..Default::default()
5538 })
5539 .collect();
5540 insert_entries(&pool, feed_id, &entries, 0).await?;
5541 replace_sub_refs(&pool, did, &[feed_id]).await?;
5542
5543 let mut oldest_first = list_entries(&pool, did, ListView::All, None, 100, 0).await?;
5544 oldest_first.reverse();
5545 // Read everything EXCEPT the third-oldest: a hole at 2026-02-03.
5546 for (i, row) in oldest_first.iter().enumerate() {
5547 if i != 2 {
5548 mark_read(&pool, did, row.id, true).await?;
5549 }
5550 }
5551
5552 let watermark = compact_cursor(&pool, did, feed_url)
5553 .await?
5554 .expect("advances");
5555 assert_eq!(
5556 watermark, "2026-02-02T00:00:00Z",
5557 "the water-mark jumped the unread hole"
5558 );
5559 let after = get_cursor(&pool, did, feed_url).await?.expect("cursor");
5560 let kept: Vec<String> = serde_json::from_str(&after.read_ids)?;
5561 assert_eq!(
5562 kept.len(),
5563 7,
5564 "the 7 reads ABOVE the hole must stay as explicit exceptions"
5565 );
5566 // The unread hole is above the water-mark, so it needs no unread
5567 // exception — everything above the mark is unread by default.
5568 let unread: Vec<String> = serde_json::from_str(&after.unread_ids)?;
5569 assert!(
5570 unread.is_empty(),
5571 "redundant unread exceptions survived: {unread:?}"
5572 );
5573
5574 // Idempotent, and never backwards: re-running changes nothing.
5575 assert_eq!(
5576 compact_cursor(&pool, did, feed_url).await?,
5577 None,
5578 "a second compaction moved a water-mark that was already correct"
5579 );
5580 Ok(())
5581 }
5582
5583 /// Nothing read yet, or nothing in the feed: compaction must be a no-op
5584 /// rather than inventing a water-mark that asserts the backlog is read.
5585 #[tokio::test]
5586 async fn compaction_never_invents_a_water_mark() -> Result<()> {
5587 let pool = init_url("sqlite::memory:").await?;
5588 let did = "did:plc:none";
5589 let feed_url = "https://none.example/f.xml";
5590 let feed_id = upsert_feed(
5591 &pool,
5592 &NewFeed {
5593 url: feed_url.to_string(),
5594 ..Default::default()
5595 },
5596 )
5597 .await?;
5598 replace_sub_refs(&pool, did, &[feed_id]).await?;
5599
5600 // Empty feed: no entries at all.
5601 assert_eq!(compact_cursor(&pool, did, feed_url).await?, None);
5602
5603 insert_entries(
5604 &pool,
5605 feed_id,
5606 &[
5607 NewEntry {
5608 guid: "n-1".to_string(),
5609 published: Some("2026-03-01T00:00:00Z".to_string()),
5610 ..Default::default()
5611 },
5612 NewEntry {
5613 guid: "n-2".to_string(),
5614 published: Some("2026-03-02T00:00:00Z".to_string()),
5615 ..Default::default()
5616 },
5617 ],
5618 0,
5619 )
5620 .await?;
5621
5622 // Nothing read: the OLDEST entry is unread, so there is no timestamp
5623 // strictly below it and the mark cannot move at all.
5624 assert_eq!(
5625 compact_cursor(&pool, did, feed_url).await?,
5626 None,
5627 "a water-mark appeared with nothing read — that asserts the backlog is read"
5628 );
5629 Ok(())
5630 }
5631
5632 /// **The unsave desync: clearing a star must work for an UNSUBSCRIBED feed.**
5633 ///
5634 /// That is the whole case. Every other starred path is `sub_ref`-scoped, so
5635 /// an entry that is cached AND starred in a feed the reader has since
5636 /// unsubscribed from is invisible to all of them — including the starred
5637 /// list itself. Its PDS record therefore renders as "not cached", and the
5638 /// button on that row deletes the record. If clearing the local star were
5639 /// `sub_ref`-scoped too, it would silently do nothing, and the star would
5640 /// reappear with no record behind it the moment the reader resubscribed.
5641 #[tokio::test]
5642 async fn a_star_can_be_cleared_after_unsubscribing_from_its_feed() -> Result<()> {
5643 let pool = init_url("sqlite::memory:").await?;
5644 let did = "did:plc:unsub";
5645 let feed_id = seed_big_entries(&pool, did, 3).await?;
5646 let rows = list_entries(&pool, did, ListView::All, None, 10, 0).await?;
5647 let target = rows[0].clone();
5648 mark_starred(&pool, did, target.id, true).await?;
5649 assert_eq!(get_starred_for_did(&pool, did).await?.len(), 1);
5650
5651 // Unsubscribe. The entry stays cached and stays starred, but every
5652 // sub_ref-scoped read now skips it.
5653 replace_sub_refs(&pool, did, &[]).await?;
5654 assert!(
5655 get_starred_for_did(&pool, did).await?.is_empty(),
5656 "fixture precondition: the star must be invisible to the scoped read"
5657 );
5658 assert!(
5659 matches!(
5660 starred_identities(&pool, did, 1_000).await?,
5661 StarredIdentities::All(ref v) if v.is_empty()
5662 ),
5663 "fixture precondition: the identity lookup must miss it too"
5664 );
5665 let still_starred: i64 =
5666 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND starred = 1")
5667 .bind(did)
5668 .fetch_one(&pool)
5669 .await?;
5670 assert_eq!(
5671 still_starred, 1,
5672 "the star is still there, just unreachable"
5673 );
5674
5675 // The removal path must reach it anyway.
5676 let cleared =
5677 clear_star_by_identity(&pool, did, target.url.as_deref(), Some(&target.guid)).await?;
5678 assert_eq!(cleared, 1, "the star survived the unsave");
5679 let after: i64 =
5680 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND starred = 1")
5681 .bind(did)
5682 .fetch_one(&pool)
5683 .await?;
5684 assert_eq!(after, 0);
5685
5686 // Resubscribing must NOT bring it back.
5687 replace_sub_refs(&pool, did, &[feed_id]).await?;
5688 assert!(
5689 get_starred_for_did(&pool, did).await?.is_empty(),
5690 "the star came back after resubscribing — the desync is still there"
5691 );
5692 Ok(())
5693 }
5694
5695 /// It clears only the CALLER's star, and only for the matching article.
5696 ///
5697 /// Omitting `sub_ref` is safe precisely because `did` is not optional; this
5698 /// pins that, and that a non-matching identity is a no-op rather than a
5699 /// wildcard.
5700 #[tokio::test]
5701 async fn clearing_a_star_touches_only_that_did_and_that_article() -> Result<()> {
5702 let pool = init_url("sqlite::memory:").await?;
5703 let mine = "did:plc:mine";
5704 let theirs = "did:plc:theirs";
5705 let feed_id = seed_big_entries(&pool, mine, 3).await?;
5706 replace_sub_refs(&pool, theirs, &[feed_id]).await?;
5707 let rows = list_entries(&pool, mine, ListView::All, None, 10, 0).await?;
5708
5709 for r in &rows {
5710 mark_starred(&pool, mine, r.id, true).await?;
5711 mark_starred(&pool, theirs, r.id, true).await?;
5712 }
5713
5714 let target = &rows[1];
5715 assert_eq!(
5716 clear_star_by_identity(&pool, mine, target.url.as_deref(), Some(&target.guid)).await?,
5717 1
5718 );
5719
5720 let count = |did: &'static str| {
5721 let pool = pool.clone();
5722 async move {
5723 sqlx::query_scalar::<_, i64>(
5724 "SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND starred = 1",
5725 )
5726 .bind(did)
5727 .fetch_one(&pool)
5728 .await
5729 .unwrap()
5730 }
5731 };
5732 assert_eq!(count(mine).await, 2, "it cleared more than the one article");
5733 assert_eq!(count(theirs).await, 3, "it cleared another DID's stars");
5734
5735 // An identity that matches nothing is a no-op, not a wildcard.
5736 assert_eq!(
5737 clear_star_by_identity(&pool, mine, Some("https://nope.example/x"), Some("nope"))
5738 .await?,
5739 0
5740 );
5741 assert_eq!(count(mine).await, 2);
5742 // **Clearing an already-cleared star is a no-op**, reported as one:
5743 // `web::unsave` branches on `Ok(0)` vs `Ok(n)` to decide whether a
5744 // local star was actually cleared. This used to be untested — every
5745 // article here was starred first — so `starred = 1` in the WHERE clause
5746 // could be widened to `IN (0, 1)` with the suite green, rewriting
5747 // `updated_at` on rows that changed nothing and logging clears that
5748 // never happened.
5749 let before: String = sqlx::query_scalar(
5750 "SELECT updated_at FROM entry_state WHERE did = ?1 AND entry_id = ?2",
5751 )
5752 .bind(mine)
5753 .bind(target.id)
5754 .fetch_one(&pool)
5755 .await?;
5756 assert_eq!(
5757 clear_star_by_identity(&pool, mine, target.url.as_deref(), Some(&target.guid)).await?,
5758 0,
5759 "a second clear reported rows it did not change"
5760 );
5761 let after: String = sqlx::query_scalar(
5762 "SELECT updated_at FROM entry_state WHERE did = ?1 AND entry_id = ?2",
5763 )
5764 .bind(mine)
5765 .bind(target.id)
5766 .fetch_one(&pool)
5767 .await?;
5768 assert_eq!(before, after, "a no-op clear rewrote updated_at");
5769
5770 // And neither identifier present does nothing at all.
5771 assert_eq!(clear_star_by_identity(&pool, mine, None, None).await?, 0);
5772 assert_eq!(
5773 clear_star_by_identity(&pool, mine, Some(""), Some("")).await?,
5774 0
5775 );
5776 assert_eq!(count(mine).await, 2);
5777 Ok(())
5778 }
5779
5780 /// `starred_identities` must span the WHOLE starred set, not a page.
5781 ///
5782 /// The starred view matches PDS saved records against it; a cached article
5783 /// missing from the set renders as "not cached", and that row's button
5784 /// deletes the PDS RECORD instead of un-starring the entry. Narrowing this
5785 /// set changes what a click destroys.
5786 #[tokio::test]
5787 async fn starred_identities_span_the_whole_set() -> Result<()> {
5788 let pool = init_url("sqlite::memory:").await?;
5789 let did = "did:plc:ident";
5790 seed_big_entries(&pool, did, 150).await?;
5791 for row in list_entries(&pool, did, ListView::All, None, 1_000, 0).await? {
5792 mark_starred(&pool, did, row.id, true).await?;
5793 }
5794
5795 let identities = match starred_identities(&pool, did, 20_000).await? {
5796 StarredIdentities::All(v) => v,
5797 StarredIdentities::Truncated => panic!("150 rows must not read as truncated"),
5798 };
5799 assert_eq!(
5800 identities.len(),
5801 150,
5802 "the identity set was truncated to a page"
5803 );
5804 assert!(identities
5805 .iter()
5806 .all(|(url, guid)| url.is_some() && !guid.is_empty()));
5807
5808 // **Hitting the cap must be REPORTED, not absorbed.** It used to return
5809 // an arbitrary subset with no way to tell, and every starred article
5810 // outside that subset then rendered an un-save button that deletes the
5811 // PDS record rather than un-starring the entry.
5812 assert!(
5813 matches!(
5814 starred_identities(&pool, did, 10).await?,
5815 StarredIdentities::Truncated
5816 ),
5817 "a truncated identity set reported itself as complete"
5818 );
5819 // Landing EXACTLY on the cap is complete, not truncated — the query asks
5820 // for one extra row precisely so the two are distinguishable.
5821 assert!(
5822 matches!(
5823 starred_identities(&pool, did, 150).await?,
5824 StarredIdentities::All(ref v) if v.len() == 150
5825 ),
5826 "a set exactly at the cap was misreported as truncated"
5827 );
5828 Ok(())
5829 }
5830
5831 /// Prev/next ids are bounded too, and keep the list's ordering.
5832 #[tokio::test]
5833 async fn entry_ids_are_ordered_and_capped() -> Result<()> {
5834 let pool = init_url("sqlite::memory:").await?;
5835 let did = "did:plc:ids";
5836 seed_big_entries(&pool, did, 60).await?;
5837
5838 let capped = list_entry_ids(&pool, did, ListView::All, None, 25).await?;
5839 assert_eq!(capped.len(), 25);
5840
5841 let rows = list_entries(&pool, did, ListView::All, None, 25, 0).await?;
5842 assert_eq!(
5843 capped,
5844 rows.iter().map(|e| e.id).collect::<Vec<_>>(),
5845 "the id list and the row list disagree on ordering"
5846 );
5847 Ok(())
5848 }
5849
5850 // -----------------------------------------------------------------------
5851 // Read-state PDS sync wiring: marking read/unread must project into the
5852 // per-feed `read_cursor` and mark it dirty so the batched flusher pushes it.
5853 // Before this wiring `mark_read` touched only `entry_state`; nothing dirtied
5854 // a cursor, so the flusher never synced read-state to the PDS.
5855 // -----------------------------------------------------------------------
5856
5857 #[tokio::test]
5858 async fn mark_read_dirties_the_feed_cursor() -> Result<()> {
5859 let pool = init_url("sqlite::memory:").await?;
5860 let feed_url = "https://example.com/feed.xml";
5861 let feed_id = upsert_feed(
5862 &pool,
5863 &NewFeed {
5864 url: feed_url.to_string(),
5865 title: Some("Example".to_string()),
5866 ..Default::default()
5867 },
5868 )
5869 .await?;
5870 insert_entries(
5871 &pool,
5872 feed_id,
5873 &[
5874 NewEntry {
5875 guid: "g1".to_string(),
5876 published: Some("2026-07-10T00:00:00Z".to_string()),
5877 ..Default::default()
5878 },
5879 NewEntry {
5880 guid: "g2".to_string(),
5881 published: Some("2026-07-11T00:00:00Z".to_string()),
5882 ..Default::default()
5883 },
5884 ],
5885 0,
5886 )
5887 .await?;
5888 let did = "did:plc:reader";
5889 replace_sub_refs(&pool, did, &[feed_id]).await?;
5890
5891 // No cursor exists yet.
5892 assert!(get_cursor(&pool, did, feed_url).await?.is_none());
5893 assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
5894
5895 // Mark one entry read → the feed's read_cursor row now exists, dirty=1,
5896 // and dirty_cursors returns it (the exact assertion the fix requires).
5897 let e1 = entries_for_feed(&pool, did, feed_id).await?[0].id;
5898 assert!(mark_read(&pool, did, e1, true).await?);
5899
5900 let cursor = get_cursor(&pool, did, feed_url)
5901 .await?
5902 .expect("mark_read must create the feed's read_cursor");
5903 assert!(cursor.dirty, "cursor must be dirty after mark_read");
5904 assert!(
5905 cursor.read_ids.contains(&e1.to_string()),
5906 "the read entry id must be in read_ids: {}",
5907 cursor.read_ids
5908 );
5909 let dirty = dirty_cursors(&pool, did).await?;
5910 assert_eq!(dirty.len(), 1, "flusher must see the newly dirty cursor");
5911 assert_eq!(dirty[0].feed_url, feed_url);
5912
5913 // Marking it unread again moves the id to unread_ids and keeps it dirty.
5914 assert!(mark_read(&pool, did, e1, false).await?);
5915 let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
5916 assert!(cursor.dirty);
5917 assert!(
5918 cursor.unread_ids.contains(&e1.to_string()),
5919 "unread id must be in unread_ids: {}",
5920 cursor.unread_ids
5921 );
5922 assert!(
5923 !cursor.read_ids.contains(&e1.to_string()),
5924 "id must have left read_ids: {}",
5925 cursor.read_ids
5926 );
5927
5928 // mark_feed_read dirties the one per-feed cursor too (batched, not
5929 // per-article).
5930 assert!(mark_feed_read(&pool, did, feed_id, true).await? > 0);
5931 let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
5932 assert!(cursor.dirty);
5933 assert_eq!(dirty_cursors(&pool, did).await?.len(), 1);
5934
5935 // A non-subscriber's mark_read is a no-op and dirties NO cursor.
5936 let outsider = "did:plc:outsider";
5937 assert!(!mark_read(&pool, outsider, e1, true).await?);
5938 assert_eq!(dirty_cursors(&pool, outsider).await?.len(), 0);
5939
5940 // The conditional clear only clears when updated_at matches the snapshot.
5941 let snap = dirty_cursors(&pool, did).await?[0].clone();
5942 // A stale updated_at must NOT clear (models a concurrent re-dirty).
5943 clear_cursor_dirty(&pool, did, feed_url, "1999-01-01T00:00:00Z").await?;
5944 assert_eq!(
5945 dirty_cursors(&pool, did).await?.len(),
5946 1,
5947 "stale-snapshot clear must be a no-op"
5948 );
5949 // The matching updated_at clears it.
5950 clear_cursor_dirty(&pool, did, feed_url, &snap.updated_at).await?;
5951 assert_eq!(dirty_cursors(&pool, did).await?.len(), 0);
5952
5953 Ok(())
5954 }
5955
5956 #[test]
5957 fn json_id_set_toggle_is_set_like() {
5958 // Add is idempotent, remove drops, output is a JSON string array.
5959 let s = json_id_set_toggle("[]", 5, true);
5960 assert_eq!(s, r#"["5"]"#);
5961 assert_eq!(json_id_set_toggle(&s, 5, true), r#"["5"]"#); // no dup
5962 let s = json_id_set_toggle(&s, 7, true);
5963 assert_eq!(s, r#"["5","7"]"#);
5964 let s = json_id_set_toggle(&s, 5, false);
5965 assert_eq!(s, r#"["7"]"#);
5966 // Tolerates numeric-array input and malformed input.
5967 assert_eq!(json_id_set_toggle("[1,2]", 3, true), r#"["1","2","3"]"#);
5968 assert_eq!(json_id_set_toggle("garbage", 1, true), r#"["1"]"#);
5969 }
5970
5971 // -----------------------------------------------------------------------
5972 // Per-DID isolation: the shared cache is one row per URL, but the READ
5973 // SURFACE (entries/unread/starred) and the read/star MUTATIONS are scoped
5974 // to the caller's own subscriptions (`sub_ref`). User A must never see or
5975 // mutate user B's entries.
5976 // -----------------------------------------------------------------------
5977
5978 #[tokio::test]
5979 async fn per_did_isolation_scopes_reads_and_mutations() -> Result<()> {
5980 let pool = init_url("sqlite::memory:").await?;
5981
5982 // Two feeds in the SHARED cache; A subscribes to feed_a, B to feed_b.
5983 let feed_a = upsert_feed(
5984 &pool,
5985 &NewFeed {
5986 url: "https://a.example/feed.xml".to_string(),
5987 title: Some("A".to_string()),
5988 ..Default::default()
5989 },
5990 )
5991 .await?;
5992 let feed_b = upsert_feed(
5993 &pool,
5994 &NewFeed {
5995 url: "https://b.example/feed.xml".to_string(),
5996 title: Some("B".to_string()),
5997 ..Default::default()
5998 },
5999 )
6000 .await?;
6001
6002 insert_entries(
6003 &pool,
6004 feed_a,
6005 &[NewEntry {
6006 guid: "a-1".to_string(),
6007 url: Some("https://a.example/1".to_string()),
6008 title: Some("A one".to_string()),
6009 published: Some("2026-07-10T00:00:00Z".to_string()),
6010 content_html: Some("<p>secret A body</p>".to_string()),
6011 ..Default::default()
6012 }],
6013 0,
6014 )
6015 .await?;
6016 insert_entries(
6017 &pool,
6018 feed_b,
6019 &[NewEntry {
6020 guid: "b-1".to_string(),
6021 url: Some("https://b.example/1".to_string()),
6022 title: Some("B one".to_string()),
6023 published: Some("2026-07-11T00:00:00Z".to_string()),
6024 content_html: Some("<p>secret B body</p>".to_string()),
6025 ..Default::default()
6026 }],
6027 0,
6028 )
6029 .await?;
6030
6031 let did_a = "did:plc:aaaa";
6032 let did_b = "did:plc:bbbb";
6033 replace_sub_refs(&pool, did_a, &[feed_a]).await?;
6034 replace_sub_refs(&pool, did_b, &[feed_b]).await?;
6035
6036 // The id of B's only entry (the one A must not be able to touch).
6037 let b_entry_id = entries_for_feed(&pool, did_b, feed_b).await?[0].id;
6038
6039 // --- entries_for_feed is scoped: A sees A's feed, not B's ------------
6040 assert_eq!(entries_for_feed(&pool, did_a, feed_a).await?.len(), 1);
6041 assert!(
6042 entries_for_feed(&pool, did_a, feed_b).await?.is_empty(),
6043 "A must not read entries of a feed it does not subscribe to"
6044 );
6045
6046 // --- unread list is scoped -------------------------------------------
6047 let unread_a = get_unread_for_did(&pool, did_a).await?;
6048 assert_eq!(unread_a.len(), 1);
6049 assert_eq!(unread_a[0].guid, "a-1");
6050 let unread_b = get_unread_for_did(&pool, did_b).await?;
6051 assert_eq!(unread_b.len(), 1);
6052 assert_eq!(unread_b[0].guid, "b-1");
6053
6054 // --- did_subscribes_to_entry authorizes correctly --------------------
6055 assert!(did_subscribes_to_entry(&pool, did_b, b_entry_id).await?);
6056 assert!(
6057 !did_subscribes_to_entry(&pool, did_a, b_entry_id).await?,
6058 "A does not subscribe to B's feed"
6059 );
6060
6061 // --- mark_read is authorized: A CANNOT mark B's entry ----------------
6062 assert!(
6063 !mark_read(&pool, did_a, b_entry_id, true).await?,
6064 "non-subscriber mark_read must be a no-op (→ 404), never a mutation"
6065 );
6066 // B's unread list is untouched by A's attempt.
6067 assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 1);
6068 // A subscriber CAN mark it.
6069 assert!(mark_read(&pool, did_b, b_entry_id, true).await?);
6070 assert_eq!(get_unread_for_did(&pool, did_b).await?.len(), 0);
6071
6072 // --- toggle_star is authorized the same way --------------------------
6073 assert!(
6074 !mark_starred(&pool, did_a, b_entry_id, true).await?,
6075 "non-subscriber mark_starred must be a no-op (→ 404)"
6076 );
6077 assert!(
6078 get_starred_for_did(&pool, did_a).await?.is_empty(),
6079 "A's starred list stays empty after the rejected attempt"
6080 );
6081 assert!(mark_starred(&pool, did_b, b_entry_id, true).await?);
6082 assert_eq!(get_starred_for_did(&pool, did_b).await?.len(), 1);
6083 // B's star never leaks into A's starred list.
6084 assert!(get_starred_for_did(&pool, did_a).await?.is_empty());
6085
6086 // --- feeds_for_did is scoped to the DID's OWN sub_ref ----------------
6087 // This is the PDS-unreachable fallback's projection: it must NEVER
6088 // widen a DID's surface to feeds it does not subscribe to. A sees only
6089 // feed_a; B (still subscribed to feed_b here) sees only feed_b.
6090 let a_feeds = feeds_for_did(&pool, did_a).await?;
6091 assert_eq!(a_feeds.len(), 1);
6092 assert_eq!(a_feeds[0].id, feed_a);
6093 let b_feeds = feeds_for_did(&pool, did_b).await?;
6094 assert_eq!(b_feeds.len(), 1);
6095 assert_eq!(b_feeds[0].id, feed_b);
6096
6097 // --- resync drops a feed from the surface when the sub goes away ------
6098 replace_sub_refs(&pool, did_b, &[]).await?;
6099 assert!(get_unread_for_did(&pool, did_b).await?.is_empty());
6100 assert!(get_starred_for_did(&pool, did_b).await?.is_empty());
6101 assert!(entries_for_feed(&pool, did_b, feed_b).await?.is_empty());
6102 // And the fallback projection is empty too — fail CLOSED, not open.
6103 assert!(feeds_for_did(&pool, did_b).await?.is_empty());
6104
6105 Ok(())
6106 }
6107
6108 // -----------------------------------------------------------------------
6109 // PDS-outage authorization (fail CLOSED). REGRESSION GUARD for the past
6110 // FAIL-OPEN bug (fixed in 2e53e0e): `resolve_subscriptions`' PDS/sidecar-
6111 // unreachable fallback used to synthesize a DID's `sub_ref` from EVERY
6112 // cached feed (`due_feeds(.., i64::MAX)`), granting cross-tenant read +
6113 // mutate during any outage. The fix serves the DID's OWN last-known
6114 // `sub_ref` via `feeds_for_did(did)` and NEVER widens it.
6115 //
6116 // This test replays that fixed fallback at the store layer — the seam the
6117 // web handler drives when `list_subscriptions_sorted(did) -> Err`. The
6118 // key adversarial shape is an ORPHAN cached feed (in the shared cache but
6119 // subscribed by NO ONE): the old fail-open code would have folded it into
6120 // the caller's surface. If the fail-open is reintroduced, `feeds_for_did`
6121 // would include that orphan and every assertion below flips — so this is a
6122 // real guard, not a tautology.
6123 // -----------------------------------------------------------------------
6124
6125 #[tokio::test]
6126 async fn pds_outage_fallback_fails_closed_not_open() -> Result<()> {
6127 let pool = init_url("sqlite::memory:").await?;
6128
6129 let did_a = "did:plc:aaaa";
6130
6131 // feed_a: A's own subscription (its last-known `sub_ref`; the fallback
6132 // may serve this stale but must not widen past it).
6133 let feed_a = upsert_feed(
6134 &pool,
6135 &NewFeed {
6136 url: "https://a.example/feed.xml".to_string(),
6137 title: Some("A".to_string()),
6138 ..Default::default()
6139 },
6140 )
6141 .await?;
6142 // feed_orphan: present in the SHARED cache but subscribed by NO DID.
6143 // This is exactly what the fail-open path would have leaked to A.
6144 let feed_orphan = upsert_feed(
6145 &pool,
6146 &NewFeed {
6147 url: "https://orphan.example/feed.xml".to_string(),
6148 title: Some("Orphan".to_string()),
6149 ..Default::default()
6150 },
6151 )
6152 .await?;
6153
6154 insert_entries(
6155 &pool,
6156 feed_a,
6157 &[NewEntry {
6158 guid: "a-1".to_string(),
6159 url: Some("https://a.example/1".to_string()),
6160 title: Some("A one".to_string()),
6161 published: Some("2026-07-10T00:00:00Z".to_string()),
6162 content_html: Some("<p>A body</p>".to_string()),
6163 ..Default::default()
6164 }],
6165 0,
6166 )
6167 .await?;
6168 insert_entries(
6169 &pool,
6170 feed_orphan,
6171 &[NewEntry {
6172 guid: "orphan-1".to_string(),
6173 url: Some("https://orphan.example/1".to_string()),
6174 title: Some("Orphan one".to_string()),
6175 published: Some("2026-07-11T00:00:00Z".to_string()),
6176 content_html: Some("<p>secret orphan body</p>".to_string()),
6177 ..Default::default()
6178 }],
6179 0,
6180 )
6181 .await?;
6182
6183 // A's last-known subscription set is feed_a ONLY. No `sub_ref` row ever
6184 // points any DID at feed_orphan.
6185 replace_sub_refs(&pool, did_a, &[feed_a]).await?;
6186
6187 // Grab the orphan entry id via a transient sub so we can address it,
6188 // then drop the sub — nobody subscribes to feed_orphan afterwards.
6189 replace_sub_refs(&pool, "did:plc:seed", &[feed_orphan]).await?;
6190 let orphan_entry_id = entries_for_feed(&pool, "did:plc:seed", feed_orphan).await?[0].id;
6191 replace_sub_refs(&pool, "did:plc:seed", &[]).await?;
6192
6193 // --- Replay the FIXED fallback projection ----------------------------
6194 // This is what `resolve_subscriptions` serves on the Err (outage) path:
6195 // the caller's OWN feeds, never widened. It must contain feed_a and
6196 // NEVER the orphan. (The old fail-open synthesized from every cached
6197 // feed → this vec would have held feed_orphan too.)
6198 let fallback = feeds_for_did(&pool, did_a).await?;
6199 let fallback_ids: Vec<i64> = fallback.iter().map(|f| f.id).collect();
6200 assert_eq!(
6201 fallback_ids,
6202 vec![feed_a],
6203 "outage fallback must serve ONLY A's own last-known sub_ref, \
6204 never widen to the orphan cached feed"
6205 );
6206 assert!(
6207 !fallback_ids.contains(&feed_orphan),
6208 "FAIL-OPEN regression: outage fallback leaked an unsubscribed \
6209 cached feed into A's surface"
6210 );
6211
6212 // --- With that projection in place, EVERY scoped read denies A -------
6213 assert!(
6214 !did_subscribes_to_entry(&pool, did_a, orphan_entry_id).await?,
6215 "A must not be authorized for an orphan feed's entry during an outage"
6216 );
6217 assert!(
6218 entries_for_feed(&pool, did_a, feed_orphan)
6219 .await?
6220 .is_empty(),
6221 "entries_for_feed must not expose the orphan feed to A during an outage"
6222 );
6223 // Neither the unread nor the starred list may surface the orphan entry.
6224 let unread_guids: Vec<String> = get_unread_for_did(&pool, did_a)
6225 .await?
6226 .into_iter()
6227 .map(|e| e.guid)
6228 .collect();
6229 assert!(
6230 !unread_guids.iter().any(|g| g == "orphan-1"),
6231 "orphan entry leaked into A's unread list during an outage"
6232 );
6233 assert!(
6234 get_starred_for_did(&pool, did_a).await?.is_empty(),
6235 "A has no starred entries; the orphan must not appear"
6236 );
6237
6238 // --- And EVERY scoped mutation is a no-op (→ 404 at the web layer) ---
6239 assert!(
6240 !mark_read(&pool, did_a, orphan_entry_id, true).await?,
6241 "A must not mark an orphan feed's entry read during an outage"
6242 );
6243 assert!(
6244 !mark_starred(&pool, did_a, orphan_entry_id, true).await?,
6245 "A must not star an orphan feed's entry during an outage"
6246 );
6247 assert_eq!(
6248 mark_feed_read(&pool, did_a, feed_orphan, true).await?,
6249 0,
6250 "A must not mark-all-read the orphan feed during an outage"
6251 );
6252
6253 // Nothing was written for A against the orphan entry.
6254 let es_count: i64 =
6255 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1 AND entry_id = ?2")
6256 .bind(did_a)
6257 .bind(orphan_entry_id)
6258 .fetch_one(&pool)
6259 .await?;
6260 assert_eq!(es_count, 0, "no cross-tenant mutation during the outage");
6261
6262 Ok(())
6263 }
6264
6265 // -----------------------------------------------------------------------
6266 // Closed-beta invite gate
6267 // -----------------------------------------------------------------------
6268
6269 #[test]
6270 fn code_gen_shape_and_alphabet() {
6271 for _ in 0..200 {
6272 let code = generate_invite_code().unwrap();
6273 assert!(code.starts_with("FEATHER-"), "bad prefix: {code}");
6274 let body = &code["FEATHER-".len()..];
6275 assert_eq!(body.len(), CODE_BODY_LEN, "bad body length: {code}");
6276 // Every body char must be from the ambiguity-free alphabet — in
6277 // particular NEVER I/O/0/1.
6278 for c in body.chars() {
6279 assert!(
6280 CODE_ALPHABET.contains(&(c as u8)),
6281 "char {c:?} not in alphabet ({code})"
6282 );
6283 assert!(
6284 !matches!(c, 'I' | 'O' | '0' | '1'),
6285 "ambiguous char {c:?} leaked into {code}"
6286 );
6287 }
6288 }
6289 // Two codes in a row must differ (unguessable / random).
6290 assert_ne!(
6291 generate_invite_code().unwrap(),
6292 generate_invite_code().unwrap()
6293 );
6294 }
6295
6296 #[tokio::test]
6297 async fn busy_timeout_is_applied() -> Result<()> {
6298 // Opening an on-disk DB and reading back the PRAGMA proves the pool
6299 // carries busy_timeout = 5000 ms.
6300 let dir = std::env::temp_dir().join(format!("fr-busy-{}", std::process::id()));
6301 std::fs::create_dir_all(&dir).ok();
6302 let path = dir.join("busy.db");
6303 let url = format!("sqlite://{}", path.display());
6304 let pool = init_url(&url).await?;
6305 let row = sqlx::query("PRAGMA busy_timeout").fetch_one(&pool).await?;
6306 let timeout: i64 = row.get(0);
6307 assert_eq!(timeout, 5000, "busy_timeout should be 5000 ms");
6308 pool.close().await;
6309 std::fs::remove_dir_all(&dir).ok();
6310 Ok(())
6311 }
6312
6313 #[tokio::test]
6314 async fn redeem_valid_grants_seat() -> Result<()> {
6315 let pool = init_url("sqlite::memory:").await?;
6316 let code = mint_code(&pool, "did:plc:creator", 3600).await?;
6317 assert!(!has_beta_access(&pool, "did:plc:new").await?);
6318
6319 let out = redeem_code(&pool, &code, "did:plc:new", Some("new.bsky"), 100).await?;
6320 assert_eq!(out, Ok(()));
6321 assert!(has_beta_access(&pool, "did:plc:new").await?);
6322 assert_eq!(count_beta_access(&pool).await?, 1);
6323
6324 // The code is now spent — a second redeem is AlreadyRedeemed.
6325 let again = redeem_code(&pool, &code, "did:plc:other", None, 100).await?;
6326 assert_eq!(again, Err(RedeemError::AlreadyRedeemed));
6327 Ok(())
6328 }
6329
6330 #[tokio::test]
6331 async fn redeem_not_found() -> Result<()> {
6332 let pool = init_url("sqlite::memory:").await?;
6333 let out = redeem_code(&pool, "FEATHER-NOPENOPE", "did:plc:x", None, 100).await?;
6334 assert_eq!(out, Err(RedeemError::NotFound));
6335 Ok(())
6336 }
6337
6338 /// Insert an already-expired `active` code directly (mint_code clamps a
6339 /// negative ttl to 0, so the past-expiry case is set up by hand).
6340 async fn insert_expired_code(pool: &SqlitePool, code: &str, creator: &str) -> Result<()> {
6341 let now = now_unix();
6342 sqlx::query(
6343 r#"INSERT INTO invite_codes
6344 (code, creator_did, status, invitee_did, created_at, expires_at, redeemed_at)
6345 VALUES (?1, ?2, 'active', NULL, ?3, ?4, NULL)"#,
6346 )
6347 .bind(code)
6348 .bind(creator)
6349 .bind(now - 100)
6350 .bind(now - 10) // expires_at in the past
6351 .execute(pool)
6352 .await?;
6353 Ok(())
6354 }
6355
6356 #[tokio::test]
6357 async fn redeem_expired() -> Result<()> {
6358 let pool = init_url("sqlite::memory:").await?;
6359 insert_expired_code(&pool, "FEATHER-EXPIRED0", "did:plc:creator").await?;
6360 let out = redeem_code(&pool, "FEATHER-EXPIRED0", "did:plc:new", None, 100).await?;
6361 assert_eq!(out, Err(RedeemError::Expired));
6362 // No seat granted.
6363 assert_eq!(count_beta_access(&pool).await?, 0);
6364 Ok(())
6365 }
6366
6367 #[tokio::test]
6368 async fn redeem_capacity_full() -> Result<()> {
6369 let pool = init_url("sqlite::memory:").await?;
6370 // Cap of 1, one seat already taken by an admin seed.
6371 ensure_seed(&pool, &["did:plc:admin".to_string()]).await?;
6372 assert_eq!(count_beta_access(&pool).await?, 1);
6373
6374 let code = mint_code(&pool, "did:plc:admin", 3600).await?;
6375 let out = redeem_code(&pool, &code, "did:plc:new", None, 1).await?;
6376 assert_eq!(out, Err(RedeemError::CapacityFull));
6377 // Seat NOT granted and the code NOT consumed (tx rolled back).
6378 assert!(!has_beta_access(&pool, "did:plc:new").await?);
6379 // Raising the cap lets the same code redeem.
6380 let ok = redeem_code(&pool, &code, "did:plc:new", None, 2).await?;
6381 assert_eq!(ok, Ok(()));
6382 Ok(())
6383 }
6384
6385 #[tokio::test]
6386 async fn count_active_codes_excludes_expired_and_redeemed() -> Result<()> {
6387 let pool = init_url("sqlite::memory:").await?;
6388 assert_eq!(count_active_codes(&pool).await?, 0);
6389
6390 // Two live codes.
6391 let a = mint_code(&pool, "did:plc:bot", 3600).await?;
6392 let _b = mint_code(&pool, "did:plc:bot", 3600).await?;
6393 assert_eq!(count_active_codes(&pool).await?, 2);
6394
6395 // An expired code doesn't count.
6396 insert_expired_code(&pool, "FEATHER-EXPIRED0", "did:plc:bot").await?;
6397 assert_eq!(count_active_codes(&pool).await?, 2);
6398
6399 // Redeeming one drops the active count.
6400 let out = redeem_code(&pool, &a, "did:plc:new", None, 100).await?;
6401 assert_eq!(out, Ok(()));
6402 assert_eq!(count_active_codes(&pool).await?, 1);
6403 Ok(())
6404 }
6405
6406 #[tokio::test]
6407 async fn expire_and_seed() -> Result<()> {
6408 let pool = init_url("sqlite::memory:").await?;
6409 // An already-expired code is swept to `expired`.
6410 insert_expired_code(&pool, "FEATHER-EXPIRED1", "did:plc:creator").await?;
6411 let live = mint_code(&pool, "did:plc:creator", 3600).await?;
6412 let n = expire_old_codes(&pool).await?;
6413 assert_eq!(n, 1, "exactly the past-expiry code should flip");
6414 // The live code still redeems.
6415 assert_eq!(
6416 redeem_code(&pool, &live, "did:plc:new", None, 100).await?,
6417 Ok(())
6418 );
6419
6420 // ensure_seed is idempotent.
6421 let created = ensure_seed(
6422 &pool,
6423 &["did:plc:seed1".to_string(), "did:plc:seed2".to_string()],
6424 )
6425 .await?;
6426 assert_eq!(created, 2);
6427 let created2 = ensure_seed(&pool, &["did:plc:seed1".to_string()]).await?;
6428 assert_eq!(created2, 0, "re-seeding an existing DID is a no-op");
6429 assert!(has_beta_access(&pool, "did:plc:seed1").await?);
6430 Ok(())
6431 }
6432
6433 /// **The sweep spares a REDEEMED code that is past its TTL.** The
6434 /// existing sweep test seeds one active past-expiry code and one live
6435 /// one, so the `status = 'active'` guard never excludes anything — with
6436 /// it deleted the suite stayed green. Without it the hourly sweep rewrites
6437 /// redeemed codes to `expired`, destroying the redemption the invite audit
6438 /// trail depends on and inflating the logged sweep count.
6439 #[tokio::test]
6440 async fn the_expiry_sweep_spares_redeemed_codes() -> Result<()> {
6441 let pool = init_url("sqlite::memory:").await?;
6442 let code = mint_code(&pool, "did:plc:creator", 3600).await?;
6443 assert!(redeem_code(&pool, &code, "did:plc:new", None, 100)
6444 .await?
6445 .is_ok());
6446 // Time passes: the redeemed code is now past its TTL.
6447 sqlx::query("UPDATE invite_codes SET expires_at = ?1 WHERE code = ?2")
6448 .bind(now_unix() - 10)
6449 .bind(&code)
6450 .execute(&pool)
6451 .await?;
6452 insert_expired_code(&pool, "FEATHER-EXPIRED2", "did:plc:creator").await?;
6453
6454 let n = expire_old_codes(&pool).await?;
6455 assert_eq!(n, 1, "the sweep counted the redeemed code");
6456 let status: String = sqlx::query_scalar("SELECT status FROM invite_codes WHERE code = ?1")
6457 .bind(&code)
6458 .fetch_one(&pool)
6459 .await?;
6460 assert_eq!(status, "redeemed", "the sweep rewrote a redemption");
6461 Ok(())
6462 }
6463
6464 // -----------------------------------------------------------------------
6465 // Hardening caps: per-DID sub count, global feed count, per-feed entry trim.
6466 // -----------------------------------------------------------------------
6467
6468 #[tokio::test]
6469 async fn count_helpers_track_feeds_and_subs() -> Result<()> {
6470 let pool = init_url("sqlite::memory:").await?;
6471 assert_eq!(count_feeds(&pool).await?, 0);
6472
6473 let mut ids = Vec::new();
6474 for i in 0..3 {
6475 let id = upsert_feed(
6476 &pool,
6477 &NewFeed {
6478 url: format!("https://f{i}.example/feed.xml"),
6479 ..Default::default()
6480 },
6481 )
6482 .await?;
6483 ids.push(id);
6484 }
6485 assert_eq!(count_feeds(&pool).await?, 3);
6486
6487 let did = "did:plc:capcheck";
6488 assert_eq!(count_subscriptions_for_did(&pool, did).await?, 0);
6489 replace_sub_refs(&pool, did, &ids).await?;
6490 assert_eq!(count_subscriptions_for_did(&pool, did).await?, 3);
6491 Ok(())
6492 }
6493
6494 #[tokio::test]
6495 async fn insert_entries_trims_over_cap_keeping_newest() -> Result<()> {
6496 let pool = init_url("sqlite::memory:").await?;
6497 let feed_id = upsert_feed(
6498 &pool,
6499 &NewFeed {
6500 url: "https://firehose.example/feed.xml".to_string(),
6501 ..Default::default()
6502 },
6503 )
6504 .await?;
6505
6506 // Insert 5 entries with ascending published dates, cap retained to 2.
6507 let batch: Vec<NewEntry> = (0..5)
6508 .map(|i| NewEntry {
6509 guid: format!("g-{i}"),
6510 title: Some(format!("E{i}")),
6511 published: Some(format!("2026-07-0{}T00:00:00Z", i + 1)),
6512 ..Default::default()
6513 })
6514 .collect();
6515 insert_entries(&pool, feed_id, &batch, 2).await?;
6516
6517 let did = "did:plc:trim";
6518 replace_sub_refs(&pool, did, &[feed_id]).await?;
6519 let kept = entries_for_feed(&pool, did, feed_id).await?;
6520 assert_eq!(
6521 kept.len(),
6522 2,
6523 "over-cap feed trimmed to the newest 2 entries"
6524 );
6525 // Newest first: g-4 (2026-07-05), g-3 (2026-07-04).
6526 assert_eq!(kept[0].guid, "g-4");
6527 assert_eq!(kept[1].guid, "g-3");
6528 Ok(())
6529 }
6530
6531 /// Regression: an UNDATED entry (NULL `published`) that was fetched most
6532 /// recently must NOT be evicted in favour of an older *dated* entry. The
6533 /// trim orders by `COALESCE(published, fetched_at) DESC`; under the old
6534 /// `ORDER BY published DESC` a NULL-published row sorts LAST and is dropped
6535 /// first even when it is the freshest thing in the feed.
6536 #[tokio::test]
6537 async fn insert_entries_trims_keeps_fresh_undated_over_stale_dated() -> Result<()> {
6538 let pool = init_url("sqlite::memory:").await?;
6539 let feed_id = upsert_feed(
6540 &pool,
6541 &NewFeed {
6542 url: "https://undated.example/feed.xml".to_string(),
6543 ..Default::default()
6544 },
6545 )
6546 .await?;
6547
6548 // Two OLD dated entries (fetched long ago), plus one UNDATED entry
6549 // fetched most recently. Cap = 2, so exactly one row must be evicted.
6550 let batch = vec![
6551 NewEntry {
6552 guid: "old-dated-1".to_string(),
6553 title: Some("Old A".to_string()),
6554 published: Some("2026-07-01T00:00:00Z".to_string()),
6555 fetched_at: Some("2026-07-01T00:00:00Z".to_string()),
6556 ..Default::default()
6557 },
6558 NewEntry {
6559 guid: "old-dated-2".to_string(),
6560 title: Some("Old B".to_string()),
6561 published: Some("2026-07-02T00:00:00Z".to_string()),
6562 fetched_at: Some("2026-07-02T00:00:00Z".to_string()),
6563 ..Default::default()
6564 },
6565 NewEntry {
6566 guid: "fresh-undated".to_string(),
6567 title: Some("Fresh undated".to_string()),
6568 published: None,
6569 fetched_at: Some("2026-07-11T00:00:00Z".to_string()),
6570 ..Default::default()
6571 },
6572 ];
6573 insert_entries(&pool, feed_id, &batch, 2).await?;
6574
6575 let did = "did:plc:undated";
6576 replace_sub_refs(&pool, did, &[feed_id]).await?;
6577 let kept = entries_for_feed(&pool, did, feed_id).await?;
6578 assert_eq!(kept.len(), 2, "over-cap feed trimmed to 2 entries");
6579 let guids: Vec<&str> = kept.iter().map(|e| e.guid.as_str()).collect();
6580 assert!(
6581 guids.contains(&"fresh-undated"),
6582 "the freshly-fetched undated entry must survive the trim, kept: {guids:?}"
6583 );
6584 assert!(
6585 guids.contains(&"old-dated-2"),
6586 "the newer dated entry survives; the OLDEST dated entry is the one evicted, kept: {guids:?}"
6587 );
6588 assert!(
6589 !guids.contains(&"old-dated-1"),
6590 "the oldest dated entry is the one that should be evicted, kept: {guids:?}"
6591 );
6592 Ok(())
6593 }
6594
6595 /// **It must actually GROW — the name used to be a lie.**
6596 ///
6597 /// The earlier body was three lines asserting only `before > 0`. There was
6598 /// no second measurement, so `db_size_bytes` returning a constant `1` passed.
6599 /// That matters because this number is the poller's disk watermark: a size
6600 /// that never moves means the pause never trips and the volume fills
6601 /// instead.
6602 #[tokio::test]
6603 async fn db_size_is_positive_and_grows() -> Result<()> {
6604 let pool = init_url("sqlite::memory:").await?;
6605 let before = db_size_bytes(&pool).await?;
6606 assert!(before > 0, "a schema-initialised DB has a non-zero size");
6607
6608 // Enough rows that the file must gain pages, not just fill slack.
6609 seed_big_entries(&pool, "did:plc:growth", 400).await?;
6610
6611 let after = db_size_bytes(&pool).await?;
6612 assert!(
6613 after > before,
6614 "the database grew by {} bytes after 400 seeded entries; the size is \
6615 not tracking the data, so the disk watermark can never trip",
6616 after.saturating_sub(before),
6617 );
6618 Ok(())
6619 }
6620
6621 /// `purge_did_data` removes every per-DID row the caller owns (read/star
6622 /// state, cursors, sub_ref projection, beta seat, created invite codes) —
6623 /// and touches no other DID's rows nor the shared feeds/entries cache.
6624 #[tokio::test]
6625 async fn purge_did_data_removes_only_the_callers_rows() -> Result<()> {
6626 let pool = init_url("sqlite::memory:").await?;
6627
6628 // A shared feed + entry both DIDs can subscribe to.
6629 let feed_id = upsert_feed(
6630 &pool,
6631 &NewFeed {
6632 url: "https://example.com/feed.xml".to_string(),
6633 title: Some("Example".to_string()),
6634 ..Default::default()
6635 },
6636 )
6637 .await?;
6638 insert_entries(
6639 &pool,
6640 feed_id,
6641 &[NewEntry {
6642 guid: "g-1".to_string(),
6643 url: Some("https://example.com/a".to_string()),
6644 title: Some("First".to_string()),
6645 published: Some("2026-07-10T08:00:00Z".to_string()),
6646 ..Default::default()
6647 }],
6648 0,
6649 )
6650 .await?;
6651 let entry_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'g-1'")
6652 .fetch_one(&pool)
6653 .await?;
6654
6655 let victim = "did:plc:victim";
6656 let bystander = "did:plc:bystander";
6657
6658 // Seed BOTH DIDs with a full spread of per-DID rows.
6659 for did in [victim, bystander] {
6660 replace_sub_refs(&pool, did, &[feed_id]).await?;
6661 assert!(mark_read(&pool, did, entry_id, true).await?);
6662 assert!(mark_starred(&pool, did, entry_id, true).await?);
6663 upsert_cursor(
6664 &pool,
6665 &ReadCursor {
6666 did: did.to_string(),
6667 feed_url: "https://example.com/feed.xml".to_string(),
6668 read_through: Some("2026-07-10T08:00:00Z".to_string()),
6669 read_ids: "[]".to_string(),
6670 unread_ids: "[]".to_string(),
6671 dirty: false,
6672 pds_created: false,
6673 updated_at: now_rfc3339(),
6674 },
6675 )
6676 .await?;
6677 grant_access(&pool, did, Some("h.example"), "admin", None).await?;
6678 mint_code(&pool, did, 3600).await?;
6679 }
6680
6681 // Purge only the victim.
6682 let counts = purge_did_data(&pool, victim).await?;
6683 assert_eq!(
6684 counts.entry_state, 1,
6685 "one entry_state row (read+star merge)"
6686 );
6687 assert_eq!(counts.read_cursor, 1);
6688 assert_eq!(counts.sub_ref, 1);
6689 assert_eq!(counts.beta_access, 1);
6690 assert_eq!(counts.invite_codes, 1);
6691 assert_eq!(counts.total(), 5);
6692
6693 // The victim has zero rows left in every per-DID table.
6694 let es: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE did = ?1")
6695 .bind(victim)
6696 .fetch_one(&pool)
6697 .await?;
6698 assert_eq!(es, 0, "victim still had entry_state rows");
6699 let rc: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM read_cursor WHERE did = ?1")
6700 .bind(victim)
6701 .fetch_one(&pool)
6702 .await?;
6703 assert_eq!(rc, 0, "victim still had read_cursor rows");
6704 let sr: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_ref WHERE did = ?1")
6705 .bind(victim)
6706 .fetch_one(&pool)
6707 .await?;
6708 assert_eq!(sr, 0, "victim still had sub_ref rows");
6709 assert!(
6710 !has_beta_access(&pool, victim).await?,
6711 "victim still had a beta seat"
6712 );
6713 let victim_codes: i64 =
6714 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
6715 .bind(victim)
6716 .fetch_one(&pool)
6717 .await?;
6718 assert_eq!(victim_codes, 0);
6719
6720 // The bystander is untouched.
6721 assert!(has_beta_access(&pool, bystander).await?);
6722 let bystander_subs = count_subscriptions_for_did(&pool, bystander).await?;
6723 assert_eq!(bystander_subs, 1, "bystander's sub_ref survived");
6724 let bystander_codes: i64 =
6725 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
6726 .bind(bystander)
6727 .fetch_one(&pool)
6728 .await?;
6729 assert_eq!(bystander_codes, 1);
6730
6731 // The shared cache is intact.
6732 assert_eq!(count_feeds(&pool).await?, 1);
6733
6734 // Idempotent: purging again removes nothing.
6735 let again = purge_did_data(&pool, victim).await?;
6736 assert_eq!(again.total(), 0);
6737
6738 Ok(())
6739 }
6740
6741 /// A departing DID leaves back-references on rows that belong to OTHER DIDs:
6742 /// * the invite code it *redeemed* to join (inviter's row: `invitee_did`);
6743 /// * seats it *granted* to others (`beta_access.granted_by`).
6744 /// `purge_did_data` must scrub both so no per-DID residue survives, while
6745 /// leaving those other DIDs' rows otherwise intact (their access is kept).
6746 #[tokio::test]
6747 async fn purge_did_data_scrubs_cross_did_back_references() -> Result<()> {
6748 let pool = init_url("sqlite::memory:").await?;
6749
6750 let inviter = "did:plc:inviter";
6751 let leaver = "did:plc:leaver";
6752 let friend = "did:plc:friend";
6753
6754 // inviter mints a code; leaver redeems it to join (stamps invitee_did).
6755 let inviter_code = mint_code(&pool, inviter, 3600).await?;
6756 grant_access(&pool, inviter, None, "admin", None).await?;
6757 assert_eq!(
6758 redeem_code(&pool, &inviter_code, leaver, Some("leaver.bsky"), 100).await?,
6759 Ok(())
6760 );
6761
6762 // leaver mints a code; friend redeems it (stamps friend's granted_by).
6763 let leaver_code = mint_code(&pool, leaver, 3600).await?;
6764 assert_eq!(
6765 redeem_code(&pool, &leaver_code, friend, Some("friend.bsky"), 100).await?,
6766 Ok(())
6767 );
6768
6769 // Precondition: the leaver DID is present in both back-reference columns.
6770 let invitee_before: i64 =
6771 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
6772 .bind(leaver)
6773 .fetch_one(&pool)
6774 .await?;
6775 assert_eq!(
6776 invitee_before, 1,
6777 "leaver should be an invitee before purge"
6778 );
6779 let granted_before: i64 =
6780 sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
6781 .bind(leaver)
6782 .fetch_one(&pool)
6783 .await?;
6784 assert_eq!(granted_before, 1, "leaver should be a granter before purge");
6785
6786 // Purge the leaver.
6787 let counts = purge_did_data(&pool, leaver).await?;
6788 assert_eq!(
6789 counts.invitee_scrubbed, 1,
6790 "the redeemed code's invitee_did"
6791 );
6792 assert_eq!(counts.granted_by_scrubbed, 1, "the seat leaver granted");
6793
6794 // No residue: the leaver DID appears in NEITHER back-reference column.
6795 let invitee_after: i64 =
6796 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE invitee_did = ?1")
6797 .bind(leaver)
6798 .fetch_one(&pool)
6799 .await?;
6800 assert_eq!(invitee_after, 0, "leaver survived in invitee_did");
6801 let granted_after: i64 =
6802 sqlx::query_scalar("SELECT COUNT(*) FROM beta_access WHERE granted_by = ?1")
6803 .bind(leaver)
6804 .fetch_one(&pool)
6805 .await?;
6806 assert_eq!(granted_after, 0, "leaver survived in granted_by");
6807
6808 // The other DIDs' rows are kept: the friend still has a seat (redacted
6809 // granter), and the inviter's code row still exists (invitee NULLed).
6810 assert!(
6811 has_beta_access(&pool, friend).await?,
6812 "friend's seat must survive the leaver's scrub"
6813 );
6814 let friend_granted_by: String =
6815 sqlx::query_scalar("SELECT granted_by FROM beta_access WHERE did = ?1")
6816 .bind(friend)
6817 .fetch_one(&pool)
6818 .await?;
6819 assert_eq!(friend_granted_by, REDACTED_DID);
6820 let inviter_code_rows: i64 =
6821 sqlx::query_scalar("SELECT COUNT(*) FROM invite_codes WHERE creator_did = ?1")
6822 .bind(inviter)
6823 .fetch_one(&pool)
6824 .await?;
6825 assert_eq!(inviter_code_rows, 1, "inviter's code row must survive");
6826
6827 Ok(())
6828 }
6829
6830 // -- F2: consecutive-error count drives the poll backoff -----------------
6831
6832 /// **Rows that failed only because we could not poll them are cleared.**
6833 ///
6834 /// Excluding `at://` from `due_feeds` stops NEW failures; it does nothing
6835 /// about the ones already recorded. This instance carries 19 such rows at
6836 /// 35+ consecutive errors each — accumulated entirely by our own refusal to
6837 /// fetch a scheme we had not implemented. Left alone they keep counting
6838 /// toward `in_backoff` and `badly_broken`, so a public page would report
6839 /// unsupported feeds as broken publishers forever, with no poll that could
6840 /// ever clear them since they are no longer selected.
6841 ///
6842 /// Safe to re-run because of WHAT it clears, not because the count cannot
6843 /// grow: only rows never polled successfully (`last_polled IS NULL`) — see
6844 /// `the_at_uri_error_clearing_spares_a_row_that_has_been_polled`.
6845 #[tokio::test]
6846 async fn the_migration_clears_error_counts_on_unpollable_at_uri_rows() -> Result<()> {
6847 let pool = init_url("sqlite::memory:").await?;
6848 for url in [
6849 "https://real.example/feed.xml",
6850 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab",
6851 ] {
6852 upsert_feed(
6853 &pool,
6854 &NewFeed {
6855 url: url.to_string(),
6856 ..Default::default()
6857 },
6858 )
6859 .await?;
6860 sqlx::query(
6861 "UPDATE feeds SET consecutive_errors = 35, last_error_kind = 'fetch', \
6862 last_error = 'unsupported scheme' WHERE url = ?1",
6863 )
6864 .bind(url)
6865 .execute(&pool)
6866 .await?;
6867 }
6868
6869 apply_migrations(&pool).await?;
6870
6871 let (at_errors, at_kind, at_detail): (i64, Option<String>, Option<String>) =
6872 sqlx::query_as(sqlx::AssertSqlSafe(format!(
6873 "SELECT consecutive_errors, last_error_kind, last_error FROM feeds \
6874 WHERE kind = '{}'",
6875 crate::feed::FeedKind::Unsupported.as_str()
6876 )))
6877 .fetch_one(&pool)
6878 .await?;
6879 assert_eq!(at_errors, 0, "an unpollable row kept its failure count");
6880 // A row with no errors carries no reason — the invariant
6881 // `reset_feed_errors` upholds, and the migration must too.
6882 assert_eq!(at_kind, None, "an unpollable row kept its failure kind");
6883 assert_eq!(at_detail, None, "an unpollable row kept its failure detail");
6884
6885 // A real feed's failure history is NOT touched — it is still meaningful.
6886 let http_errors: i64 = sqlx::query_scalar(
6887 "SELECT consecutive_errors FROM feeds WHERE url = 'https://real.example/feed.xml'",
6888 )
6889 .fetch_one(&pool)
6890 .await?;
6891 assert_eq!(http_errors, 35, "a real feed's history was discarded");
6892 Ok(())
6893 }
6894
6895 /// **An `at://` feed is never selected for polling.**
6896 ///
6897 /// (Since 0.4.0 these are rows of kind `unsupported`: an at-URI that is not a
6898 /// well-formed publication, which no reader can fetch.) Selecting them does not
6899 /// leave the feature dormant — it manufactures a permanent failure per row,
6900 /// which since the cause histogram is *published* as an unreachable
6901 /// publisher. This instance already carries 19 such rows, subscribed before
6902 /// the scheme was refused.
6903 ///
6904 /// They are skipped rather than failed: unsupported is not broken, and the
6905 /// difference is the whole point of recording a cause at all.
6906 #[tokio::test]
6907 async fn an_at_uri_feed_is_never_due_for_polling() -> Result<()> {
6908 let pool = init_url("sqlite::memory:").await?;
6909 for url in [
6910 "https://example.com/feed.xml",
6911 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab",
6912 "at://alice.example.com/site.standard.publication/3lab",
6913 ] {
6914 upsert_feed(
6915 &pool,
6916 &NewFeed {
6917 url: url.to_string(),
6918 ..Default::default()
6919 },
6920 )
6921 .await?;
6922 }
6923 // All three have a NULL next_poll, which sorts FIRST — so if at:// were
6924 // selectable at all it would be selected before the http feed.
6925 let due = due_feeds(&pool, "2026-09-20T00:00:00Z", 50).await?;
6926 let urls: Vec<&str> = due.iter().map(|f| f.url.as_str()).collect();
6927 assert_eq!(
6928 urls,
6929 ["https://example.com/feed.xml"],
6930 "an at:// feed was handed to the poller"
6931 );
6932 Ok(())
6933 }
6934
6935 #[tokio::test]
6936 async fn feed_error_count_bumps_and_resets() -> Result<()> {
6937 let pool = init_url("sqlite::memory:").await?;
6938 let url = "https://broken.example/feed.xml";
6939 upsert_feed(
6940 &pool,
6941 &NewFeed {
6942 url: url.to_string(),
6943 ..Default::default()
6944 },
6945 )
6946 .await?;
6947
6948 // A fresh feed starts at 0 errors.
6949 let feed = get_feed_by_url(&pool, url).await?.expect("feed exists");
6950 assert_eq!(feed.consecutive_errors, 0);
6951
6952 // N consecutive failures grow the count 1,2,3, and — fed through
6953 // `backoff_for` — the backoff grows with it (never latched at the floor).
6954 let mut last = std::time::Duration::ZERO;
6955 for expected in 1..=3 {
6956 let count = bump_feed_errors(
6957 &pool,
6958 url,
6959 crate::feed::FailureKind::Fetch,
6960 "connection refused",
6961 )
6962 .await?;
6963 assert_eq!(count, expected, "bump returns the new count");
6964 let backoff = crate::feed::backoff_for(count as u32);
6965 assert!(
6966 backoff >= last,
6967 "backoff must not shrink as errors accumulate"
6968 );
6969 last = backoff;
6970 }
6971 // Growth actually happened (2 errors backs off longer than 1).
6972 assert!(crate::feed::backoff_for(2) > crate::feed::backoff_for(1));
6973 assert_eq!(
6974 get_feed_by_url(&pool, url)
6975 .await?
6976 .unwrap()
6977 .consecutive_errors,
6978 3
6979 );
6980
6981 // A success resets the streak to 0 (back to the normal cadence).
6982 reset_feed_errors(&pool, url).await?;
6983 assert_eq!(
6984 get_feed_by_url(&pool, url)
6985 .await?
6986 .unwrap()
6987 .consecutive_errors,
6988 0
6989 );
6990 Ok(())
6991 }
6992
6993 /// **A recovered feed keeps no reason for having failed.**
6994 ///
6995 /// Added because a mutation found this untested: deleting the
6996 /// `last_error_kind = NULL, last_error = NULL` half of `reset_feed_errors`
6997 /// left the entire suite green. The histogram filters on
6998 /// `consecutive_errors > 0`, so a stale row would not inflate the public
6999 /// count — but anything reading the row directly would be handed a cause
7000 /// that stopped applying, which is the exact failure this column was added
7001 /// to end. A guarantee nothing checks is a comment.
7002 #[tokio::test]
7003 async fn a_successful_poll_clears_the_recorded_failure_reason() -> Result<()> {
7004 let pool = init_url("sqlite::memory:").await?;
7005 let url = "https://recovers.example/feed.xml";
7006 upsert_feed(
7007 &pool,
7008 &NewFeed {
7009 url: url.to_string(),
7010 ..Default::default()
7011 },
7012 )
7013 .await?;
7014
7015 bump_feed_errors(&pool, url, crate::feed::FailureKind::Fetch, "SENTINEL_WHY").await?;
7016 let failing: (Option<String>, Option<String>) =
7017 sqlx::query_as("SELECT last_error_kind, last_error FROM feeds WHERE url = ?1")
7018 .bind(url)
7019 .fetch_one(&pool)
7020 .await?;
7021 assert_eq!(
7022 failing.0.as_deref(),
7023 Some("fetch"),
7024 "the kind was not stored"
7025 );
7026 assert_eq!(
7027 failing.1.as_deref(),
7028 Some("SENTINEL_WHY"),
7029 "the detail was not stored"
7030 );
7031
7032 reset_feed_errors(&pool, url).await?;
7033 let recovered: (Option<String>, Option<String>) =
7034 sqlx::query_as("SELECT last_error_kind, last_error FROM feeds WHERE url = ?1")
7035 .bind(url)
7036 .fetch_one(&pool)
7037 .await?;
7038 assert_eq!(
7039 recovered.0, None,
7040 "a healthy feed still names a failure kind"
7041 );
7042 assert_eq!(
7043 recovered.1, None,
7044 "a healthy feed still carries error detail"
7045 );
7046 Ok(())
7047 }
7048
7049 /// **The closed vocabulary is closed where it is READ, not only written.**
7050 ///
7051 /// `FailureKind::parse` promises that a kind string from a newer build is
7052 /// not "silently attributed to a cause this one recognises" — and the
7053 /// histogram's comment leaned on it. But review found `parse` had zero
7054 /// production callers: `poll_health` handed the raw column to the public
7055 /// template, so an unrecognised string got its own bucket, rendered
7056 /// verbatim. The protection existed only as a doc comment.
7057 ///
7058 /// A row written by a future build must land in `unknown`.
7059 #[tokio::test]
7060 async fn an_unrecognised_failure_kind_folds_into_unknown() -> Result<()> {
7061 let pool = init_url("sqlite::memory:").await?;
7062 for (url, kind) in [
7063 ("https://a.example/f.xml", Some("fetch")),
7064 ("https://b.example/f.xml", Some("quota")), // a newer build's kind
7065 ("https://c.example/f.xml", None), // a legacy row
7066 ] {
7067 upsert_feed(
7068 &pool,
7069 &NewFeed {
7070 url: url.to_string(),
7071 ..Default::default()
7072 },
7073 )
7074 .await?;
7075 sqlx::query(
7076 "UPDATE feeds SET consecutive_errors = 1, last_error_kind = ?2 WHERE url = ?1",
7077 )
7078 .bind(url)
7079 .bind(kind)
7080 .execute(&pool)
7081 .await?;
7082 }
7083 let now = chrono::Utc::now();
7084 let health = poll_health(
7085 &pool,
7086 &now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
7087 &(now - chrono::Duration::hours(1)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
7088 )
7089 .await?;
7090 let mut kinds = health.failure_kinds.clone();
7091 kinds.sort();
7092 assert_eq!(
7093 kinds,
7094 vec![("fetch".to_string(), 1), ("unknown".to_string(), 2)],
7095 "an unrecognised kind reached the public histogram as its own bucket: {:?}",
7096 health.failure_kinds
7097 );
7098 Ok(())
7099 }
7100
7101 /// **The migration is exercised against a table that predates the columns.**
7102 ///
7103 /// Every other test here builds a fresh database, where `CREATE TABLE`
7104 /// already contains `last_error_kind` / `last_error` — so `ensure_column`,
7105 /// the code path that actually runs against the production volume, was
7106 /// never executed by any of them. A bad `ALTER` would have been found at
7107 /// boot, on the one machine, by crash-looping: `apply_migrations` runs
7108 /// inside `init`, and the entrypoint takes the container down when a child
7109 /// dies.
7110 ///
7111 /// Builds the OLD table shape by hand, puts a failing row in it, migrates,
7112 /// and asserts both that the columns arrive and that the pre-existing row
7113 /// survives with NULLs rather than being rewritten or dropped.
7114 #[tokio::test]
7115 async fn the_last_error_columns_migrate_onto_a_table_that_predates_them() -> Result<()> {
7116 let pool = init_url("sqlite::memory:").await?;
7117
7118 // Drop the current shape and rebuild the pre-migration one.
7119 sqlx::query("DROP TABLE feeds").execute(&pool).await?;
7120 sqlx::query(
7121 "CREATE TABLE feeds (
7122 id INTEGER PRIMARY KEY AUTOINCREMENT,
7123 url TEXT NOT NULL UNIQUE,
7124 title TEXT,
7125 site_url TEXT,
7126 etag TEXT,
7127 last_modified TEXT,
7128 last_polled TEXT,
7129 next_poll TEXT,
7130 consecutive_errors INTEGER NOT NULL DEFAULT 0
7131 )",
7132 )
7133 .execute(&pool)
7134 .await?;
7135 sqlx::query("INSERT INTO feeds (url, consecutive_errors) VALUES (?1, 7)")
7136 .bind("https://legacy.example/feed.xml")
7137 .execute(&pool)
7138 .await?;
7139
7140 apply_migrations(&pool).await?;
7141
7142 // The columns exist...
7143 let cols: Vec<String> = sqlx::query("PRAGMA table_info(feeds)")
7144 .fetch_all(&pool)
7145 .await?
7146 .iter()
7147 .map(|r| r.get::<String, _>("name"))
7148 .collect();
7149 assert!(cols.iter().any(|c| c == "last_error_kind"), "{cols:?}");
7150 assert!(cols.iter().any(|c| c == "last_error"), "{cols:?}");
7151
7152 // ...and the pre-existing row is intact, with no invented cause.
7153 let row: (i64, Option<String>, Option<String>) = sqlx::query_as(
7154 "SELECT consecutive_errors, last_error_kind, last_error FROM feeds WHERE url = ?1",
7155 )
7156 .bind("https://legacy.example/feed.xml")
7157 .fetch_one(&pool)
7158 .await?;
7159 assert_eq!(row.0, 7, "the migration disturbed an existing error count");
7160 assert_eq!(row.1, None, "a legacy row was given a cause it never had");
7161 assert_eq!(row.2, None);
7162
7163 // And it is idempotent — `init` runs this on every boot.
7164 apply_migrations(&pool).await?;
7165 Ok(())
7166 }
7167
7168 /// The stored detail is bounded — it is a remote server's text on an
7169 /// unattended path.
7170 #[tokio::test]
7171 async fn the_stored_error_detail_is_truncated() -> Result<()> {
7172 let pool = init_url("sqlite::memory:").await?;
7173 let url = "https://verbose.example/feed.xml";
7174 upsert_feed(
7175 &pool,
7176 &NewFeed {
7177 url: url.to_string(),
7178 ..Default::default()
7179 },
7180 )
7181 .await?;
7182 bump_feed_errors(
7183 &pool,
7184 url,
7185 crate::feed::FailureKind::Body,
7186 &"x".repeat(10_000),
7187 )
7188 .await?;
7189 let stored: (Option<String>,) =
7190 sqlx::query_as("SELECT last_error FROM feeds WHERE url = ?1")
7191 .bind(url)
7192 .fetch_one(&pool)
7193 .await?;
7194 assert_eq!(stored.0.unwrap().chars().count(), MAX_ERROR_DETAIL_CHARS);
7195 Ok(())
7196 }
7197
7198 // -- F3: db_size_bytes ignores freed pages and drops after reclaim -------
7199
7200 /// A new on-disk database must be created in INCREMENTAL mode.
7201 ///
7202 /// This is the whole fix for new instances: `auto_vacuum` was read by
7203 /// `reclaim` and set nowhere, so every database ran in NONE and `reclaim`
7204 /// always took its full-`VACUUM` branch — the one that cannot complete on a
7205 /// volume under the pressure that triggered the sweep. The pragma only binds
7206 /// on a database with no tables yet, so "at creation" is the load-bearing
7207 /// part, not "somewhere in init".
7208 #[tokio::test]
7209 async fn a_new_database_is_created_in_incremental_vacuum_mode() -> Result<()> {
7210 let dir = std::env::temp_dir();
7211 let path = dir.join(format!("fr-autovac-{}.db", std::process::id()));
7212 for p in [
7213 path.display().to_string(),
7214 format!("{}-wal", path.display()),
7215 format!("{}-shm", path.display()),
7216 ] {
7217 std::fs::remove_file(&p).ok();
7218 }
7219 let pool = init_url(&format!("sqlite://{}", path.display())).await?;
7220
7221 assert_eq!(
7222 auto_vacuum_mode(&pool).await?,
7223 AutoVacuum::Incremental,
7224 "a fresh database is still in the mode where reclaim needs a full VACUUM"
7225 );
7226 // And the WAL is bounded rather than growing to its high-water mark
7227 // forever.
7228 let limit: i64 = sqlx::query_scalar("PRAGMA journal_size_limit")
7229 .fetch_one(&pool)
7230 .await?;
7231 assert_eq!(
7232 limit, WAL_SIZE_LIMIT_BYTES,
7233 "journal_size_limit not applied"
7234 );
7235
7236 // Being INCREMENTAL, the migration is a no-op — which is what makes the
7237 // flag safe for an operator to run without checking first.
7238 assert_eq!(
7239 migrate_to_incremental_vacuum(&pool, None).await?,
7240 VacuumMigration::NotNeeded(AutoVacuum::Incremental)
7241 );
7242
7243 pool.close().await;
7244 for p in [
7245 path.display().to_string(),
7246 format!("{}-wal", path.display()),
7247 format!("{}-shm", path.display()),
7248 ] {
7249 std::fs::remove_file(&p).ok();
7250 }
7251 Ok(())
7252 }
7253
7254 /// The migration refuses itself when the volume cannot hold the rebuild.
7255 ///
7256 /// A full `VACUUM` writes a complete second copy, so attempting one without
7257 /// headroom burns I/O on a box that has none and finishes nothing. Refusing
7258 /// is the entire reason this is an operator step rather than something
7259 /// `reclaim` does on its own.
7260 #[tokio::test]
7261 async fn the_vacuum_migration_refuses_without_headroom() -> Result<()> {
7262 let dir = std::env::temp_dir();
7263 let path = dir.join(format!("fr-autovac-none-{}.db", std::process::id()));
7264 for p in [
7265 path.display().to_string(),
7266 format!("{}-wal", path.display()),
7267 format!("{}-shm", path.display()),
7268 ] {
7269 std::fs::remove_file(&p).ok();
7270 }
7271 // Build a database the way one that predates this change looks: create
7272 // the file in NONE mode explicitly, then populate it.
7273 let url = format!("sqlite://{}", path.display());
7274 let opts = SqliteConnectOptions::from_str(&url)?
7275 .create_if_missing(true)
7276 .foreign_keys(true)
7277 .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
7278 .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::None);
7279 let pool = SqlitePoolOptions::new()
7280 .min_connections(1)
7281 .max_connections(1)
7282 .connect_with(opts)
7283 .await?;
7284 init_schema(&pool).await?;
7285 assert_eq!(auto_vacuum_mode(&pool).await?, AutoVacuum::None);
7286
7287 // Zero free space: refused, and the mode is untouched.
7288 let refused = migrate_to_incremental_vacuum(&pool, Some(0)).await?;
7289 assert!(
7290 matches!(refused, VacuumMigration::RefusedNoHeadroom { .. }),
7291 "expected a refusal, got {refused:?}"
7292 );
7293 assert_eq!(
7294 auto_vacuum_mode(&pool).await?,
7295 AutoVacuum::None,
7296 "a refused migration must not have changed the mode"
7297 );
7298
7299 // With headroom it runs, and the database ends up INCREMENTAL — which is
7300 // what makes `reclaim` cheap from then on.
7301 let done = migrate_to_incremental_vacuum(&pool, Some(u64::MAX)).await?;
7302 let VacuumMigration::Migrated {
7303 bytes_after,
7304 file_after,
7305 ..
7306 } = done
7307 else {
7308 panic!("expected a migration, got {done:?}");
7309 };
7310 assert_eq!(auto_vacuum_mode(&pool).await?, AutoVacuum::Incremental);
7311 // The reported size must not include the WAL the VACUUM just filled. In
7312 // WAL mode a VACUUM writes the whole rebuilt database through the WAL,
7313 // so without the truncating checkpoint this reads as roughly double —
7314 // "the migration doubled my database", from the one line the command
7315 // prints.
7316 let file_after = file_after.expect("an on-disk database has a file size") as i64;
7317 assert!(
7318 bytes_after <= file_after * 2,
7319 "bytes_after ({bytes_after}) is inflated by an untruncated WAL against a \
7320 {file_after}-byte file"
7321 );
7322
7323 pool.close().await;
7324 for p in [
7325 path.display().to_string(),
7326 format!("{}-wal", path.display()),
7327 format!("{}-shm", path.display()),
7328 ] {
7329 std::fs::remove_file(&p).ok();
7330 }
7331 Ok(())
7332 }
7333
7334 /// **R6 benchmark: what the retention sweep actually costs, and what fixes it.**
7335 ///
7336 /// `#[ignore]` — builds a ~1M-row database once per shape per scale (ten
7337 /// times), so it is a measurement tool rather than a test. Run with:
7338 ///
7339 /// ```text
7340 /// cargo test --lib -- --ignored --nocapture r6_measure_retention_sweep
7341 /// ```
7342 ///
7343 /// It exists because R6 was "every delete batch re-scans `entry_state`" and
7344 /// the honest answer was "measure before changing an index". Kept so the next
7345 /// candidate index can be tried against the same fixture rather than a new
7346 /// one. Findings are recorded in `design/REVIEW-ROUND-2.md`.
7347 #[tokio::test]
7348 #[ignore]
7349 async fn r6_measure_retention_sweep() -> Result<()> {
7350 const FEEDS: i64 = 500;
7351 const PER_FEED: i64 = 2_000; // matches `max_entries_per_feed`
7352 const PINNED: i64 = 50_000; // entry_state rows a reader has touched
7353
7354 /// Build the fixture, apply `extra_indexes`, then plan and time a sweep.
7355 async fn run(
7356 label: &str,
7357 extra_indexes: &[&str],
7358 pinned: i64,
7359 old_list_form: bool,
7360 ) -> Result<()> {
7361 let dir = std::env::temp_dir();
7362 let path = dir.join(format!("fr-r6-{}-{label}.db", std::process::id()));
7363 // RAII, because every `?` between here and the end used to leak a
7364 // 1M-row fixture plus its -wal/-shm into the temp dir — six per run.
7365 struct Fixture(std::path::PathBuf);
7366 impl Fixture {
7367 fn wipe(&self) {
7368 for p in [
7369 self.0.display().to_string(),
7370 format!("{}-wal", self.0.display()),
7371 format!("{}-shm", self.0.display()),
7372 ] {
7373 std::fs::remove_file(&p).ok();
7374 }
7375 }
7376 }
7377 impl Drop for Fixture {
7378 fn drop(&mut self) {
7379 self.wipe();
7380 }
7381 }
7382 let fixture = Fixture(path.clone());
7383 fixture.wipe();
7384 let pool = init_url(&format!("sqlite://{}", path.display())).await?;
7385
7386 // Bulk-build with SQL: a million round trips would measure the
7387 // fixture, not the sweep. Recursive CTE because `generate_series` is
7388 // not compiled into the bundled SQLite.
7389 sqlx::query(
7390 "WITH RECURSIVE n(value) AS ( \
7391 SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < ?1 \
7392 ) \
7393 INSERT INTO feeds (url) \
7394 SELECT 'https://f' || value || '.example/x.xml' FROM n",
7395 )
7396 .bind(FEEDS)
7397 .execute(&pool)
7398 .await
7399 .context("seeding feeds")?;
7400
7401 // Half the entries older than the window, half inside it.
7402 sqlx::query(
7403 "WITH RECURSIVE n(value) AS ( \
7404 SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < ?1 \
7405 ) \
7406 INSERT INTO entries (feed_id, guid, title, published, fetched_at) \
7407 SELECT f.id, \
7408 'g' || f.id || '-' || s.value, \
7409 'Entry ' || s.value, \
7410 CASE WHEN s.value % 2 = 0 THEN '2020-01-01T00:00:00Z' \
7411 ELSE '2099-01-01T00:00:00Z' END, \
7412 '2026-01-01T00:00:00Z' \
7413 FROM feeds f, n s",
7414 )
7415 .bind(PER_FEED)
7416 .execute(&pool)
7417 .await?;
7418
7419 // **A REALISTIC pin distribution, which the first version did not
7420 // have.** It made every row `read=0,starred=0` or `read=1,starred=1`,
7421 // so 100% of `entry_state` matched `starred = 1 OR read = 0` — there
7422 // were no "read and not starred" rows at all, which is the commonest
7423 // state a reader leaves behind. That mattered: a PARTIAL index on the
7424 // pinned predicate then covers the whole table and cannot be
7425 // selective, so measuring one against that fixture measures nothing.
7426 //
7427 // 90% read-and-unstarred (evictable), 10% pinned, split between
7428 // starred and unread.
7429 sqlx::query(
7430 "INSERT INTO entry_state (did, entry_id, read, starred, updated_at) \
7431 SELECT 'did:plc:reader', id, \
7432 CASE WHEN id % 10 <> 0 THEN 1 \
7433 WHEN id % 20 = 0 THEN 1 ELSE 0 END, \
7434 CASE WHEN id % 10 <> 0 THEN 0 \
7435 WHEN id % 20 = 0 THEN 1 ELSE 0 END, \
7436 '2026-01-01T00:00:00Z' \
7437 FROM entries LIMIT ?1",
7438 )
7439 .bind(pinned)
7440 .execute(&pool)
7441 .await?;
7442
7443 // Space is the other half of the trade: this is a 1 GB volume with a
7444 // 768 MiB watermark, so an index that buys time and costs disk can be
7445 // a net loss.
7446 let pages_before: i64 = sqlx::query_scalar("PRAGMA page_count")
7447 .fetch_one(&pool)
7448 .await?;
7449 let page_size: i64 = sqlx::query_scalar("PRAGMA page_size")
7450 .fetch_one(&pool)
7451 .await?;
7452 for idx in extra_indexes {
7453 sqlx::query(sqlx::AssertSqlSafe((*idx).to_string()))
7454 .execute(&pool)
7455 .await
7456 .with_context(|| format!("creating {idx}"))?;
7457 }
7458 let pages_after: i64 = sqlx::query_scalar("PRAGMA page_count")
7459 .fetch_one(&pool)
7460 .await?;
7461 let index_bytes = (pages_after - pages_before) * page_size;
7462
7463 // What the index costs on the WRITE path — the poller inserts
7464 // constantly, the sweep runs once a day.
7465 let t_ins = std::time::Instant::now();
7466 sqlx::query(
7467 "WITH RECURSIVE n(value) AS ( \
7468 SELECT 1 UNION ALL SELECT value + 1 FROM n WHERE value < 10000 \
7469 ) \
7470 INSERT INTO entries (feed_id, guid, published, fetched_at) \
7471 SELECT 1, 'ins-' || value, '2099-06-01T00:00:00Z', '2026-01-01T00:00:00Z' \
7472 FROM n",
7473 )
7474 .execute(&pool)
7475 .await?;
7476 let insert_10k = t_ins.elapsed();
7477
7478 // Give the planner statistics, as a long-lived instance would have.
7479 sqlx::query("ANALYZE").execute(&pool).await?;
7480
7481 // The plan must describe the query this run actually TIMES. It used
7482 // to be hardcoded to the `NOT IN` form regardless, so four of six
7483 // runs printed a plan for a different query than the one measured —
7484 // in the artifact kept precisely to be the evidence.
7485 let planned = if old_list_form {
7486 "EXPLAIN QUERY PLAN SELECT id FROM entries \
7487 WHERE COALESCE(published, fetched_at) < '2026-06-01T00:00:00Z' \
7488 AND id NOT IN (SELECT entry_id FROM entry_state \
7489 WHERE starred = 1 OR read = 0) \
7490 LIMIT 1000"
7491 } else {
7492 "EXPLAIN QUERY PLAN SELECT e.id FROM entries e \
7493 WHERE COALESCE(e.published, e.fetched_at) < '2026-06-01T00:00:00Z' \
7494 AND NOT EXISTS (SELECT 1 FROM entry_state s \
7495 WHERE s.entry_id = e.id \
7496 AND (s.starred = 1 OR s.read = 0)) \
7497 LIMIT 1000"
7498 };
7499 let plan: Vec<String> = sqlx::query(sqlx::AssertSqlSafe(planned))
7500 .fetch_all(&pool)
7501 .await?
7502 .into_iter()
7503 .map(|r| r.get::<String, _>("detail"))
7504 .collect();
7505
7506 // ONE variant per fixture — running both against the same database
7507 // measured the second against an already-emptied table, which
7508 // reported a 0-row "win" the first time this was written.
7509 let cutoff = (chrono::Utc::now() - chrono::Duration::days(30))
7510 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7511 let t = std::time::Instant::now();
7512 let deleted = if old_list_form {
7513 // The shape `prune_old_entries` used to have: the pinned set as
7514 // an `IN` list, re-materialised on every batch.
7515 let mut n = 0u64;
7516 loop {
7517 let got = sqlx::query(
7518 "DELETE FROM entries WHERE id IN ( \
7519 SELECT id FROM entries \
7520 WHERE COALESCE(published, fetched_at) < ?1 \
7521 AND id NOT IN ( \
7522 SELECT entry_id FROM entry_state \
7523 WHERE starred = 1 OR read = 0 \
7524 ) \
7525 LIMIT 1000)",
7526 )
7527 .bind(&cutoff)
7528 .execute(&pool)
7529 .await?
7530 .rows_affected();
7531 n += got;
7532 if got == 0 {
7533 break;
7534 }
7535 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
7536 }
7537 n
7538 } else {
7539 // NOTE the arms are not identical work: this one goes through the
7540 // real `prune_old_entries`, which also runs the hard-ceiling pass
7541 // and the cursor scrub. The bias therefore runs AGAINST the
7542 // shipped form, so a win measured here is a lower bound — but the
7543 // two numbers are not a like-for-like microbenchmark.
7544 prune_old_entries(&pool, 30, 3650, 0).await?
7545 };
7546 let elapsed = t.elapsed();
7547
7548 // State the fixture's shape, so a future reader cannot mistake a
7549 // degenerate distribution for a representative one again.
7550 let matching: i64 = sqlx::query_scalar(
7551 "SELECT COUNT(*) FROM entry_state WHERE starred = 1 OR read = 0",
7552 )
7553 .fetch_one(&pool)
7554 .await?;
7555 println!("\n=== {label} (entry_state = {pinned}, pinned = {matching}) ===");
7556 println!(
7557 " index cost: {:.1} MiB on disk, 10k inserts in {insert_10k:?}",
7558 index_bytes as f64 / 1024.0 / 1024.0
7559 );
7560 for l in &plan {
7561 println!(" plan: {l}");
7562 }
7563 println!(
7564 " deleted {deleted} in {elapsed:?} ({:?}/batch)",
7565 elapsed / (deleted as u32 / PRUNE_BATCH as u32).max(1)
7566 );
7567
7568 pool.close().await;
7569 drop(fixture);
7570 Ok(())
7571 }
7572
7573 // R6's own hypothesis was that the per-batch `entry_state` scan is the
7574 // cost. Both scales are measured because that scan grows with TOTAL
7575 // users, not with the feed being swept — 50k is one active reader,
7576 // 600k is the figure the schema comment cites as realistic.
7577 const AGE_IDX: &str =
7578 "CREATE INDEX idx_entries_age ON entries(COALESCE(published, fetched_at))";
7579 // The index R6 actually asked for. Its row is the one the rejection
7580 // turns on — "changes the plan, changes the time by nothing" — and an
7581 // earlier version of this benchmark dropped it, leaving that claim
7582 // resting on prose while the artifact kept to prove it could not.
7583 const PINNED_IDX: &str = "CREATE INDEX idx_es_pinned ON entry_state(entry_id) \
7584 WHERE starred = 1 OR read = 0";
7585 for pinned in [PINNED, 600_000] {
7586 // `false` = the shipped `prune_old_entries`, whatever shape it
7587 // currently uses; `true` = the raw `NOT IN` list form it replaced,
7588 // kept so the regression stays measurable rather than remembered.
7589 run("as shipped (NOT EXISTS)", &[], pinned, false).await?;
7590 run("old NOT IN list form", &[], pinned, true).await?;
7591 run("old NOT IN + pinned index", &[PINNED_IDX], pinned, true).await?;
7592 // The row that was never measured: the pinned index against the
7593 // query that SHIPPED, rather than against the one being deleted.
7594 // Rejecting it on the strength of the latter was the error.
7595 run("as shipped + pinned index", &[PINNED_IDX], pinned, false).await?;
7596 run("as shipped + age index", &[AGE_IDX], pinned, false).await?;
7597 }
7598 Ok(())
7599 }
7600
7601 /// **The migration must not ask the pool for anything while holding a
7602 /// connection.** A single-connection pool is always saturated, so any such
7603 /// call stalls for the full acquire timeout.
7604 ///
7605 /// This has now been introduced twice — once by acquiring a connection for
7606 /// the pragma pair, and once by resolving the temp directory inside that
7607 /// block. The second was worse than a stall: `main_db_path` swallows errors
7608 /// into `None`, so it waited 30 s and then silently skipped the pragma it
7609 /// existed to set. A wall-clock assertion is crude, but it is the only thing
7610 /// that distinguishes "works" from "works after a 30-second timeout".
7611 #[tokio::test]
7612 async fn the_vacuum_migration_never_waits_on_its_own_pool() -> Result<()> {
7613 let dir = std::env::temp_dir();
7614 let path = dir.join(format!("fr-nodeadlock-{}.db", std::process::id()));
7615 for p in [
7616 path.display().to_string(),
7617 format!("{}-wal", path.display()),
7618 format!("{}-shm", path.display()),
7619 ] {
7620 std::fs::remove_file(&p).ok();
7621 }
7622 let url = format!("sqlite://{}", path.display());
7623 let opts = SqliteConnectOptions::from_str(&url)?
7624 .create_if_missing(true)
7625 .foreign_keys(true)
7626 .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
7627 .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::None);
7628 // ONE connection: any pool call made while the migration holds it will
7629 // block until the acquire timeout rather than deadlocking forever.
7630 let pool = SqlitePoolOptions::new()
7631 .min_connections(1)
7632 .max_connections(1)
7633 .connect_with(opts)
7634 .await?;
7635 init_schema(&pool).await?;
7636
7637 let t0 = std::time::Instant::now();
7638 let outcome = migrate_to_incremental_vacuum(&pool, Some(u64::MAX)).await?;
7639 let elapsed = t0.elapsed();
7640
7641 assert!(
7642 matches!(outcome, VacuumMigration::Migrated { .. }),
7643 "expected a migration, got {outcome:?}"
7644 );
7645 assert!(
7646 elapsed < std::time::Duration::from_secs(5),
7647 "the migration took {elapsed:?} on an empty database — it is waiting on \
7648 its own pool while holding a connection"
7649 );
7650
7651 pool.close().await;
7652 for p in [
7653 path.display().to_string(),
7654 format!("{}-wal", path.display()),
7655 format!("{}-shm", path.display()),
7656 ] {
7657 std::fs::remove_file(&p).ok();
7658 }
7659 Ok(())
7660 }
7661
7662 /// `reclaim` must NOT run a full VACUUM in NONE mode — the branch that used
7663 /// to be the only one that ever executed, and the one that cannot finish on
7664 /// a volume under the pressure that triggers a sweep.
7665 ///
7666 /// Observable without timing a VACUUM: a full VACUUM returns freed pages to
7667 /// the OS, so `page_count` falls. Skipping it leaves the allocation in
7668 /// place — while `db_size_bytes`, which subtracts the freelist, still drops.
7669 /// That pairing is the actual claim: the watermark does not latch even
7670 /// though the file does not shrink.
7671 #[tokio::test]
7672 async fn reclaim_does_not_full_vacuum_in_none_mode() -> Result<()> {
7673 let dir = std::env::temp_dir();
7674 let path = dir.join(format!("fr-noneclaim-{}.db", std::process::id()));
7675 for p in [
7676 path.display().to_string(),
7677 format!("{}-wal", path.display()),
7678 format!("{}-shm", path.display()),
7679 ] {
7680 std::fs::remove_file(&p).ok();
7681 }
7682 let url = format!("sqlite://{}", path.display());
7683 let opts = SqliteConnectOptions::from_str(&url)?
7684 .create_if_missing(true)
7685 .foreign_keys(true)
7686 .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
7687 .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::None);
7688 let pool = SqlitePoolOptions::new()
7689 .min_connections(1)
7690 .max_connections(1)
7691 .connect_with(opts)
7692 .await?;
7693 init_schema(&pool).await?;
7694
7695 let feed_id = upsert_feed(
7696 &pool,
7697 &NewFeed {
7698 url: "https://none.example/f.xml".to_string(),
7699 ..Default::default()
7700 },
7701 )
7702 .await?;
7703 let entries: Vec<NewEntry> = (0..1500)
7704 .map(|i| NewEntry {
7705 guid: format!("n-{i}"),
7706 content_html: Some("x".repeat(800)),
7707 ..Default::default()
7708 })
7709 .collect();
7710 insert_entries(&pool, feed_id, &entries, 0).await?;
7711 // Fold the WAL in so the "full" baseline is file pages, not WAL churn.
7712 sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
7713 .execute(&pool)
7714 .await?;
7715 let used_full = db_size_bytes(&pool).await?;
7716
7717 sqlx::query("DELETE FROM entries").execute(&pool).await?;
7718 let pages_before: i64 = sqlx::query_scalar("PRAGMA page_count")
7719 .fetch_one(&pool)
7720 .await?;
7721
7722 reclaim(&pool).await?;
7723
7724 let pages_after: i64 = sqlx::query_scalar("PRAGMA page_count")
7725 .fetch_one(&pool)
7726 .await?;
7727 assert_eq!(
7728 pages_after, pages_before,
7729 "reclaim shrank the file in NONE mode, so it ran the full VACUUM this \
7730 branch exists to avoid"
7731 );
7732 // …and the watermark still falls, which is what makes skipping safe.
7733 // `db_size_bytes` subtracts the freelist, so the delete alone lowers it
7734 // even though the file kept every page it had allocated.
7735 let used_after = db_size_bytes(&pool).await?;
7736 assert!(
7737 used_after < used_full,
7738 "used size did not fall after the delete ({used_after} !< {used_full}); \
7739 without a VACUUM the DB-size watermark would latch the poller off"
7740 );
7741
7742 pool.close().await;
7743 for p in [
7744 path.display().to_string(),
7745 format!("{}-wal", path.display()),
7746 format!("{}-shm", path.display()),
7747 ] {
7748 std::fs::remove_file(&p).ok();
7749 }
7750 Ok(())
7751 }
7752
7753 #[tokio::test]
7754 async fn db_size_drops_after_prune_and_reclaim() -> Result<()> {
7755 // On-disk DB so VACUUM has a file to shrink (in-memory has no freelist to
7756 // speak of the same way). Temp path, cleaned up at the end.
7757 let dir = std::env::temp_dir();
7758 let path = dir.join(format!("fr-reclaim-{}.db", std::process::id()));
7759 let url = format!("sqlite://{}", path.display());
7760 let pool = init_url(&url).await?;
7761
7762 let feed_id = upsert_feed(
7763 &pool,
7764 &NewFeed {
7765 url: "https://bulk.example/feed.xml".to_string(),
7766 ..Default::default()
7767 },
7768 )
7769 .await?;
7770
7771 // Insert a large batch so the file allocates real pages.
7772 let entries: Vec<NewEntry> = (0..2000)
7773 .map(|i| NewEntry {
7774 guid: format!("guid-{i}"),
7775 title: Some(format!("Entry number {i} with some padding text")),
7776 content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
7777 published: Some("2026-01-01T00:00:00Z".to_string()),
7778 ..Default::default()
7779 })
7780 .collect();
7781 insert_entries(&pool, feed_id, &entries, 0).await?;
7782 let full = db_size_bytes(&pool).await?;
7783 assert!(full > 0);
7784
7785 // Prune: delete every entry (the retention sweep's effect). This frees
7786 // pages onto the freelist but does NOT shrink the file yet.
7787 sqlx::query("DELETE FROM entries WHERE feed_id = ?1")
7788 .bind(feed_id)
7789 .execute(&pool)
7790 .await?;
7791
7792 // Because db_size_bytes subtracts freelist pages, the USED size already
7793 // reflects the delete even before the file shrinks.
7794 let after_delete = db_size_bytes(&pool).await?;
7795 assert!(
7796 after_delete < full,
7797 "used size must drop once rows are deleted (freed pages excluded): \
7798 {after_delete} !< {full}"
7799 );
7800
7801 // Reclaim returns the freed pages to the OS; used size stays low (and the
7802 // file itself shrinks). The key property F3 needs: the watermark can now
7803 // fall back below its threshold instead of latching polling off.
7804 reclaim(&pool).await?;
7805 let after_reclaim = db_size_bytes(&pool).await?;
7806 assert!(
7807 after_reclaim <= after_delete,
7808 "reclaim must not grow used size: {after_reclaim} !<= {after_delete}"
7809 );
7810 assert!(
7811 after_reclaim < full,
7812 "after prune+reclaim the DB is smaller than when full: \
7813 {after_reclaim} !< {full}"
7814 );
7815
7816 drop(pool);
7817 let _ = std::fs::remove_file(&path);
7818 let _ = std::fs::remove_file(format!("{}-wal", path.display()));
7819 let _ = std::fs::remove_file(format!("{}-shm", path.display()));
7820 Ok(())
7821 }
7822
7823 // -- F4 support: pds_created flag round-trips + flips ---------------------
7824
7825 #[tokio::test]
7826 async fn cursor_pds_created_defaults_false_and_flips() -> Result<()> {
7827 let pool = init_url("sqlite::memory:").await?;
7828 let did = "did:plc:f4";
7829 let feed_url = "https://example.com/feed.xml";
7830 upsert_cursor(
7831 &pool,
7832 &ReadCursor {
7833 did: did.to_string(),
7834 feed_url: feed_url.to_string(),
7835 read_through: None,
7836 read_ids: r#"["1"]"#.to_string(),
7837 unread_ids: "[]".to_string(),
7838 dirty: true,
7839 pds_created: false,
7840 updated_at: now_rfc3339(),
7841 },
7842 )
7843 .await?;
7844
7845 // A brand-new cursor's PDS record does NOT yet exist.
7846 let c = get_cursor(&pool, did, feed_url).await?.unwrap();
7847 assert!(!c.pds_created, "first flush must emit a create, not update");
7848
7849 // Two bystanders: the same DID on another feed, another DID on the same
7850 // feed. **The UPDATE must be scoped to exactly one row.** With its WHERE
7851 // clause deleted this test still passed — it seeded one cursor, so
7852 // "every row" and "this row" were the same row. Unscoped, every DID's
7853 // every cursor is flagged as created, their readState records are never
7854 // created, and every later flush emits `update` against nothing.
7855 for (d, f) in [
7856 (did, "https://other.example/feed.xml"),
7857 ("did:plc:other", feed_url),
7858 ] {
7859 upsert_cursor(
7860 &pool,
7861 &ReadCursor {
7862 did: d.to_string(),
7863 feed_url: f.to_string(),
7864 read_through: None,
7865 read_ids: "[]".to_string(),
7866 unread_ids: "[]".to_string(),
7867 dirty: false,
7868 pds_created: false,
7869 updated_at: now_rfc3339(),
7870 },
7871 )
7872 .await?;
7873 }
7874
7875 // After the create-flush lands, the flag flips so future flushes update.
7876 mark_cursor_pds_created(&pool, did, feed_url).await?;
7877 let c = get_cursor(&pool, did, feed_url).await?.unwrap();
7878 assert!(c.pds_created);
7879 for (d, f) in [
7880 (did, "https://other.example/feed.xml"),
7881 ("did:plc:other", feed_url),
7882 ] {
7883 let bystander = get_cursor(&pool, d, f).await?.unwrap();
7884 assert!(
7885 !bystander.pds_created,
7886 "marking ({did}, {feed_url}) also flagged ({d}, {f})"
7887 );
7888 }
7889 Ok(())
7890 }
7891
7892 // -- STORAGE HYGIENE: retention prune + orphan-id scrub -------------------
7893
7894 /// Count entries currently in the cache.
7895 async fn count_entries(pool: &SqlitePool) -> Result<i64> {
7896 Ok(sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM entries")
7897 .fetch_one(pool)
7898 .await?)
7899 }
7900
7901 /// **The rolling window and the hard ceiling do not touch a publication, and
7902 /// this is the test that says the feature works at all.**
7903 ///
7904 /// Measured on 2026-09-27 against three real publications: the newest
7905 /// document Standard.site offered was 131 days old, Annotated's 109, minus
7906 /// listens' 241. Under the 14-day window every one of them stored **zero**
7907 /// rows — a successful poll and an empty feed. So age is not the policy here;
7908 /// COUNT is (`max_entries_per_feed`), and the ceiling below is only the
7909 /// not-immortal backstop.
7910 ///
7911 /// Both directions in one test on purpose: the RSS twin must still be
7912 /// deleted, or "nothing is ever swept" would pass.
7913 #[tokio::test]
7914 async fn the_window_and_the_ceiling_spare_a_publication_but_not_an_rss_entry() -> Result<()> {
7915 let pool = init_url("sqlite::memory:").await?;
7916 let rss = upsert_feed(
7917 &pool,
7918 &NewFeed {
7919 url: "https://aged.example/feed.xml".to_string(),
7920 ..Default::default()
7921 },
7922 )
7923 .await?;
7924 let publication = upsert_feed(
7925 &pool,
7926 &NewFeed {
7927 url: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab"
7928 .to_string(),
7929 ..Default::default()
7930 },
7931 )
7932 .await?;
7933 // The kind column is what the sweep filters on, so assert the fixture
7934 // really produced two different kinds rather than trusting `FeedKind::of`.
7935 let kinds: Vec<String> = sqlx::query_scalar("SELECT kind FROM feeds ORDER BY id")
7936 .fetch_all(&pool)
7937 .await?;
7938 assert_eq!(kinds, vec!["rss".to_string(), "publication".to_string()]);
7939
7940 // A year old, and READ by somebody — so the window's own sparing rule
7941 // ("starred or unread survives") cannot be what keeps either row.
7942 let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
7943 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
7944 for feed_id in [rss, publication] {
7945 insert_entries(
7946 &pool,
7947 feed_id,
7948 &[NewEntry {
7949 guid: format!("ancient-{feed_id}"),
7950 published: Some(ancient.clone()),
7951 fetched_at: Some(ancient.clone()),
7952 ..Default::default()
7953 }],
7954 0,
7955 )
7956 .await?;
7957 }
7958 replace_sub_refs(&pool, "did:plc:reader", &[rss, publication]).await?;
7959 for id in sqlx::query_scalar::<_, i64>("SELECT id FROM entries ORDER BY id")
7960 .fetch_all(&pool)
7961 .await?
7962 {
7963 mark_read(&pool, "did:plc:reader", id, true).await?;
7964 }
7965 assert_eq!(count_entries(&pool).await?, 2);
7966
7967 // **The shipped configuration, all three knobs at their defaults.** An
7968 // earlier version of this test passed `0` for the archive ceiling, so the
7969 // combination under test was not the one any instance runs; at 3650 the
7970 // publication's year-old document is inside the ceiling and must still
7971 // survive.
7972 let deleted = prune_old_entries(&pool, 14, 180, 3_650).await?;
7973 assert_eq!(deleted, 1, "exactly one of the two should have gone");
7974 let surviving: Vec<i64> = sqlx::query_scalar("SELECT feed_id FROM entries")
7975 .fetch_all(&pool)
7976 .await?;
7977 assert_eq!(
7978 surviving,
7979 vec![publication],
7980 "the publication's year-old document was swept — under the 14-day \
7981 window that is every document a real publication has, so the feed a \
7982 reader subscribed to would be permanently empty",
7983 );
7984 Ok(())
7985 }
7986
7987 /// **"Not aged out" must not mean "immortal".**
7988 ///
7989 /// The per-feed trim is what bounds a publication, and it only runs when a
7990 /// poll stores something — so entries of a feed nobody polls any more have
7991 /// nothing else to reap them. This ceiling is that backstop, and it spares
7992 /// nothing, for the same reason the hard ceiling spares nothing: a saved
7993 /// record whose entry is gone still renders from the PDS record as a link.
7994 #[tokio::test]
7995 async fn the_archive_ceiling_reaps_a_publication_entry_past_it() -> Result<()> {
7996 let pool = init_url("sqlite::memory:").await?;
7997 // An RSS twin, to pin that this pass is SCOPED. Verified needed: dropping
7998 // the `kind NOT IN` clause from it left all 909 tests passing, and that
7999 // mutation quietly re-enables age-based eviction for RSS on an instance
8000 // whose operator set both RSS knobs to zero.
8001 let rss = upsert_feed(
8002 &pool,
8003 &NewFeed {
8004 url: "https://not-swept.example/feed.xml".to_string(),
8005 ..Default::default()
8006 },
8007 )
8008 .await?;
8009 let publication = upsert_feed(
8010 &pool,
8011 &NewFeed {
8012 url: "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab"
8013 .to_string(),
8014 ..Default::default()
8015 },
8016 )
8017 .await?;
8018 let ancient = (chrono::Utc::now() - chrono::Duration::days(400))
8019 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8020 let recent = now_rfc3339();
8021 insert_entries(
8022 &pool,
8023 publication,
8024 &[
8025 NewEntry {
8026 guid: "past-the-ceiling".into(),
8027 published: Some(ancient.clone()),
8028 fetched_at: Some(ancient),
8029 ..Default::default()
8030 },
8031 NewEntry {
8032 guid: "inside-the-ceiling".into(),
8033 published: Some(recent.clone()),
8034 fetched_at: Some(recent),
8035 ..Default::default()
8036 },
8037 ],
8038 0,
8039 )
8040 .await?;
8041 let long_ago = (chrono::Utc::now() - chrono::Duration::days(400))
8042 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8043 insert_entries(
8044 &pool,
8045 rss,
8046 &[NewEntry {
8047 guid: "rss-past-the-archive-ceiling".into(),
8048 published: Some(long_ago.clone()),
8049 fetched_at: Some(long_ago),
8050 ..Default::default()
8051 }],
8052 0,
8053 )
8054 .await?;
8055
8056 // STARRED, so this also pins that the ceiling spares nothing.
8057 replace_sub_refs(&pool, "did:plc:reader", &[rss, publication]).await?;
8058 for id in sqlx::query_scalar::<_, i64>("SELECT id FROM entries ORDER BY id")
8059 .fetch_all(&pool)
8060 .await?
8061 {
8062 mark_starred(&pool, "did:plc:reader", id, true).await?;
8063 }
8064
8065 // Rolling window and hard ceiling off: the archive ceiling is the only
8066 // thing that can delete here.
8067 let deleted = prune_old_entries(&pool, 0, 0, 365).await?;
8068 assert_eq!(
8069 deleted, 1,
8070 "the entry past the archive ceiling was not reaped"
8071 );
8072 let mut guids: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries")
8073 .fetch_all(&pool)
8074 .await?;
8075 guids.sort();
8076 assert_eq!(
8077 guids,
8078 vec![
8079 "inside-the-ceiling".to_string(),
8080 "rss-past-the-archive-ceiling".to_string(),
8081 ],
8082 "the archive ceiling must reap the publication's over-age entry and \
8083 ONLY that — an RSS entry on an instance with both RSS knobs at zero \
8084 is one the operator chose to keep",
8085 );
8086
8087 // And zero disables it, consistently with the other two knobs.
8088 assert_eq!(
8089 prune_old_entries(&pool, 0, 0, 0).await?,
8090 0,
8091 "publication_retention_days = 0 still deleted something",
8092 );
8093 Ok(())
8094 }
8095
8096 /// **A retention window too large to be a date must disable that pass, not
8097 /// kill the sweeper.**
8098 ///
8099 /// Every knob parses from a `u32` with no upper bound, and `Duration::days` /
8100 /// `DateTime - TimeDelta` both panic out of range — measured, anything past
8101 /// roughly 96 million days, and `u32::MAX` is. A unit slip (seconds or
8102 /// milliseconds typed into a days field) reaches it.
8103 ///
8104 /// The old failure was quiet: this runs in a spawned task, so tokio catches
8105 /// the panic and the sweeper stops for the life of the process, taking the
8106 /// release valve for `db_size_watermark_bytes` with it — the one thing that
8107 /// stops polling for every reader on the instance.
8108 ///
8109 /// `standard_site::ingest_floor` already answers the same input with "no
8110 /// floor", and `Config::retention_for` exists to keep the two agreeing, so
8111 /// this is also the end of a disagreement: unrepresentable meant "store
8112 /// everything" on one side and "panic" on the other.
8113 #[tokio::test]
8114 async fn an_unrepresentable_retention_window_disables_the_pass_it_belongs_to() -> Result<()> {
8115 let pool = init_url("sqlite::memory:").await?;
8116 let feed_id = upsert_feed(
8117 &pool,
8118 &NewFeed {
8119 url: "https://absurd.example/feed.xml".to_string(),
8120 ..Default::default()
8121 },
8122 )
8123 .await?;
8124 let ancient = (chrono::Utc::now() - chrono::Duration::days(1_000))
8125 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8126 insert_entries(
8127 &pool,
8128 feed_id,
8129 &[NewEntry {
8130 guid: "ancient".into(),
8131 published: Some(ancient.clone()),
8132 fetched_at: Some(ancient),
8133 ..Default::default()
8134 }],
8135 0,
8136 )
8137 .await?;
8138
8139 // Each knob in turn, since each computes its own cutoff.
8140 let absurd = u32::MAX as i64;
8141 assert_eq!(
8142 prune_old_entries(&pool, absurd, 0, 0).await?,
8143 0,
8144 "an absurd rolling window deleted something",
8145 );
8146 assert_eq!(
8147 prune_old_entries(&pool, 0, absurd, 0).await?,
8148 0,
8149 "an absurd hard ceiling deleted something",
8150 );
8151 assert_eq!(
8152 prune_old_entries(&pool, 0, 0, absurd).await?,
8153 0,
8154 "an absurd archive ceiling deleted something",
8155 );
8156 assert_eq!(
8157 count_entries(&pool).await?,
8158 1,
8159 "the entry went away under a window that cannot even be expressed",
8160 );
8161
8162 // And the sweep still works for the same knobs at a sane value — a
8163 // function that returned early on every input would satisfy the above.
8164 assert_eq!(
8165 prune_old_entries(&pool, 30, 0, 0).await?,
8166 1,
8167 "a 30-day window did not delete a 1000-day-old entry",
8168 );
8169 Ok(())
8170 }
8171
8172 /// The SQL list and the Rust slice are asserted equal, for the same reason
8173 /// [`POLLABLE_KINDS_SQL`] is: a literal here and a slice there is the drift
8174 /// the `kind` column was introduced to end.
8175 #[test]
8176 fn the_sql_aged_kind_list_matches_the_rust_one() {
8177 let expected = crate::feed::FeedKind::AGED
8178 .iter()
8179 .map(|k| format!("'{}'", k.as_str()))
8180 .collect::<Vec<_>>()
8181 .join(", ");
8182 assert_eq!(AGED_KINDS_SQL, expected);
8183 }
8184
8185 #[tokio::test]
8186 async fn prune_old_entries_deletes_only_old_and_cascades_entry_state() -> Result<()> {
8187 let pool = init_url("sqlite::memory:").await?;
8188 let feed_id = upsert_feed(
8189 &pool,
8190 &NewFeed {
8191 url: "https://ret.example/feed.xml".to_string(),
8192 ..Default::default()
8193 },
8194 )
8195 .await?;
8196
8197 let recent = now_rfc3339();
8198 let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
8199 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8200
8201 // One fresh (published now), one ancient (published a year ago), and one
8202 // UNDATED-but-freshly-fetched (published NULL, fetched_at now) — the last
8203 // must survive because COALESCE falls back to fetched_at, not to "old".
8204 insert_entries(
8205 &pool,
8206 feed_id,
8207 &[
8208 NewEntry {
8209 guid: "fresh".into(),
8210 published: Some(recent.clone()),
8211 fetched_at: Some(recent.clone()),
8212 ..Default::default()
8213 },
8214 NewEntry {
8215 guid: "ancient".into(),
8216 published: Some(ancient.clone()),
8217 fetched_at: Some(ancient.clone()),
8218 ..Default::default()
8219 },
8220 NewEntry {
8221 guid: "undated-fresh".into(),
8222 published: None,
8223 fetched_at: Some(recent.clone()),
8224 ..Default::default()
8225 },
8226 ],
8227 0,
8228 )
8229 .await?;
8230 assert_eq!(count_entries(&pool).await?, 3);
8231 // Subscribe so mark_read is authorized to write an entry_state row.
8232 replace_sub_refs(&pool, "did:plc:reader", &[feed_id]).await?;
8233
8234 // Give the ancient entry an entry_state row so we can prove the FK cascade.
8235 let ancient_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'ancient'")
8236 .fetch_one(&pool)
8237 .await?;
8238 let wrote = mark_read(&pool, "did:plc:reader", ancient_id, true).await?;
8239 assert!(wrote, "mark_read must write with a sub_ref in place");
8240 let state_before: i64 =
8241 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
8242 .bind(ancient_id)
8243 .fetch_one(&pool)
8244 .await?;
8245 assert_eq!(state_before, 1);
8246
8247 // Prune at a 90-day window: only the ancient entry is old.
8248 let deleted = prune_old_entries(&pool, 90, 3650, 0).await?;
8249 assert_eq!(deleted, 1, "only the year-old entry should be pruned");
8250 assert_eq!(
8251 count_entries(&pool).await?,
8252 2,
8253 "fresh + undated-fresh survive"
8254 );
8255
8256 // The surviving guids are exactly the two fresh ones.
8257 let surviving: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
8258 .fetch_all(&pool)
8259 .await?;
8260 assert_eq!(surviving, vec!["fresh", "undated-fresh"]);
8261
8262 // entry_state for the deleted entry cascaded away via the FK.
8263 let state_after: i64 =
8264 sqlx::query_scalar("SELECT COUNT(*) FROM entry_state WHERE entry_id = ?1")
8265 .bind(ancient_id)
8266 .fetch_one(&pool)
8267 .await?;
8268 assert_eq!(state_after, 0, "entry_state must cascade on entry delete");
8269
8270 // days == 0 disables the rolling WINDOW. The 3650-day ceiling still runs
8271 // (see `a_disabled_window_does_not_disable_the_ceiling`); it deletes
8272 // nothing here because both survivors are fresh.
8273 assert_eq!(prune_old_entries(&pool, 0, 3650, 0).await?, 0);
8274 assert_eq!(count_entries(&pool).await?, 2);
8275 Ok(())
8276 }
8277
8278 #[tokio::test]
8279 async fn prune_removes_orphan_ids_from_read_cursor() -> Result<()> {
8280 let pool = init_url("sqlite::memory:").await?;
8281 let did = "did:plc:reader";
8282 let feed_url = "https://orphan.example/feed.xml";
8283 let feed_id = upsert_feed(
8284 &pool,
8285 &NewFeed {
8286 url: feed_url.to_string(),
8287 ..Default::default()
8288 },
8289 )
8290 .await?;
8291 // Caller subscribes so mark-read is authorized to project into the cursor.
8292 replace_sub_refs(&pool, did, &[feed_id]).await?;
8293
8294 let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
8295 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8296 let recent = now_rfc3339();
8297 insert_entries(
8298 &pool,
8299 feed_id,
8300 &[
8301 NewEntry {
8302 guid: "old".into(),
8303 published: Some(ancient.clone()),
8304 fetched_at: Some(ancient.clone()),
8305 ..Default::default()
8306 },
8307 NewEntry {
8308 guid: "new".into(),
8309 published: Some(recent.clone()),
8310 fetched_at: Some(recent.clone()),
8311 ..Default::default()
8312 },
8313 ],
8314 0,
8315 )
8316 .await?;
8317 let old_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'old'")
8318 .fetch_one(&pool)
8319 .await?;
8320 let new_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'new'")
8321 .fetch_one(&pool)
8322 .await?;
8323
8324 // Mark BOTH read — the cursor's read_ids now references both entry ids.
8325 mark_read(&pool, did, old_id, true).await?;
8326 mark_read(&pool, did, new_id, true).await?;
8327 let before = get_cursor(&pool, did, feed_url).await?.unwrap();
8328 let ids_before: Vec<String> = serde_json::from_str(&before.read_ids)?;
8329 assert!(ids_before.contains(&old_id.to_string()));
8330 assert!(ids_before.contains(&new_id.to_string()));
8331
8332 // Prune the old entry — its id must be scrubbed from the cursor's id-set.
8333 let deleted = prune_old_entries(&pool, 90, 3650, 0).await?;
8334 assert_eq!(deleted, 1);
8335 let after = get_cursor(&pool, did, feed_url).await?.unwrap();
8336 let ids_after: Vec<String> = serde_json::from_str(&after.read_ids)?;
8337 assert_eq!(
8338 ids_after,
8339 vec![new_id.to_string()],
8340 "orphaned (deleted) entry id must be removed; live id kept"
8341 );
8342 // The scrub re-dirties the cursor so the flusher resyncs the PDS record.
8343 assert!(
8344 after.dirty,
8345 "cursor must be marked dirty after orphan scrub"
8346 );
8347 Ok(())
8348 }
8349
8350 #[tokio::test]
8351 async fn insert_entries_trim_scrubs_orphan_cursor_ids() -> Result<()> {
8352 // The per-feed max_entries trim path must ALSO scrub orphaned cursor ids.
8353 let pool = init_url("sqlite::memory:").await?;
8354 let did = "did:plc:reader";
8355 let feed_url = "https://trim.example/feed.xml";
8356 let feed_id = upsert_feed(
8357 &pool,
8358 &NewFeed {
8359 url: feed_url.to_string(),
8360 ..Default::default()
8361 },
8362 )
8363 .await?;
8364 replace_sub_refs(&pool, did, &[feed_id]).await?;
8365
8366 // Two entries, cap of 2 for now (no trim yet).
8367 insert_entries(
8368 &pool,
8369 feed_id,
8370 &[
8371 NewEntry {
8372 guid: "a".into(),
8373 published: Some("2026-01-01T00:00:00Z".into()),
8374 ..Default::default()
8375 },
8376 NewEntry {
8377 guid: "b".into(),
8378 published: Some("2026-01-02T00:00:00Z".into()),
8379 ..Default::default()
8380 },
8381 ],
8382 2,
8383 )
8384 .await?;
8385 let a_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'a'")
8386 .fetch_one(&pool)
8387 .await?;
8388 mark_read(&pool, did, a_id, true).await?;
8389
8390 // Insert a newer entry with cap=1 → the oldest ('a') is trimmed away.
8391 insert_entries(
8392 &pool,
8393 feed_id,
8394 &[NewEntry {
8395 guid: "c".into(),
8396 published: Some("2026-01-03T00:00:00Z".into()),
8397 ..Default::default()
8398 }],
8399 1,
8400 )
8401 .await?;
8402 // 'a' is gone.
8403 let a_still: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entries WHERE guid = 'a'")
8404 .fetch_one(&pool)
8405 .await?;
8406 assert_eq!(a_still, 0, "oldest entry trimmed by the per-feed cap");
8407
8408 // The cursor no longer references the trimmed id.
8409 let cursor = get_cursor(&pool, did, feed_url).await?.unwrap();
8410 let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
8411 assert!(
8412 !ids.contains(&a_id.to_string()),
8413 "trimmed entry id must be scrubbed from the cursor"
8414 );
8415 Ok(())
8416 }
8417
8418 /// A sweep spanning several batches must still delete everything.
8419 ///
8420 /// The batching exists to make the write-lock hold interruptible, not to
8421 /// make the sweep partial — so the obvious way to get it wrong is an
8422 /// off-by-one that leaves a batch behind, or a loop that exits on the first
8423 /// short batch instead of the first empty one.
8424 #[tokio::test]
8425 async fn a_sweep_larger_than_one_batch_still_drains() -> Result<()> {
8426 let pool = init_url("sqlite::memory:").await?;
8427 let feed_id = upsert_feed(
8428 &pool,
8429 &NewFeed {
8430 url: "https://bulk.example/f.xml".to_string(),
8431 ..Default::default()
8432 },
8433 )
8434 .await?;
8435 let old = (chrono::Utc::now() - chrono::Duration::days(400))
8436 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8437 // Deliberately not a multiple of PRUNE_BATCH, so the final batch is
8438 // short and the loop has to keep going to the empty one.
8439 let count = (PRUNE_BATCH * 2 + 137) as usize;
8440 let entries: Vec<NewEntry> = (0..count)
8441 .map(|i| NewEntry {
8442 guid: format!("bulk-{i}"),
8443 published: Some(old.clone()),
8444 ..Default::default()
8445 })
8446 .collect();
8447 insert_entries(&pool, feed_id, &entries, 0).await?;
8448 assert_eq!(count_entries(&pool).await? as usize, count);
8449
8450 let deleted = prune_old_entries(&pool, 30, 180, 0).await?;
8451 assert_eq!(deleted as usize, count, "the sweep left rows behind");
8452 assert_eq!(count_entries(&pool).await?, 0);
8453 Ok(())
8454 }
8455
8456 /// What a sweep driven by a lock test actually did.
8457 ///
8458 /// `Contended` is NOT a failure. `SQLITE_BUSY` on the pruner is an outcome
8459 /// production expects and handles — `scheduler.rs` logs it and the next tick
8460 /// retries — so a test that treats it as a regression is stricter than the
8461 /// system it guards, and fails for a reason its own assertions are not
8462 /// about. See #146.
8463 enum SweepOutcome {
8464 Completed(u64),
8465 Contended,
8466 }
8467
8468 /// True for the `SQLITE_BUSY` FAMILY anywhere in the chain.
8469 ///
8470 /// Matched on the DRIVER CODE, not on the message text: "database is
8471 /// locked" is a string another error could plausibly carry, and this
8472 /// decides whether a test failure is suppressed.
8473 ///
8474 /// **Masked to the primary code.** sqlx-sqlite's `code()` returns
8475 /// `sqlite3_extended_errcode` verbatim, so comparing it to `"5"` matches
8476 /// only bare `SQLITE_BUSY` and treats the WAL variants as hard failures:
8477 /// `BUSY_RECOVERY` (261), `BUSY_SNAPSHOT` (517), `BUSY_TIMEOUT` (773).
8478 /// This database runs in WAL mode and `store.rs` already documents hitting
8479 /// `SQLITE_BUSY_SNAPSHOT`, so that gap is not hypothetical — the narrowing
8480 /// would have rejected the very class this tolerance exists for.
8481 ///
8482 /// `& 0xFF` is how SQLite defines the relationship: the low byte of an
8483 /// extended code IS the primary code.
8484 fn is_sqlite_busy(err: &anyhow::Error) -> bool {
8485 err.chain().any(|e| {
8486 e.downcast_ref::<sqlx::Error>().is_some_and(|e| match e {
8487 sqlx::Error::Database(db) => db
8488 .code()
8489 .and_then(|c| c.parse::<i32>().ok())
8490 .is_some_and(is_busy_code),
8491 _ => false,
8492 })
8493 })
8494 }
8495
8496 /// The classification, split out so the WAL variants are TESTABLE.
8497 ///
8498 /// A `BUSY_SNAPSHOT` cannot be produced on demand in a test, so without
8499 /// this the claim that 261/517/773 are tolerated would be a comment and
8500 /// nothing else. The wiring — that `is_sqlite_busy` consults this at all —
8501 /// is pinned separately by `a_busy_sweep_is_reported_as_contended_not_as_a_failure`,
8502 /// which drives a real `SQLITE_BUSY` end to end.
8503 fn is_busy_code(code: i32) -> bool {
8504 code & 0xFF == 5
8505 }
8506
8507 /// **The whole `SQLITE_BUSY` family, and nothing else.**
8508 #[test]
8509 fn busy_codes_cover_the_wal_variants() {
8510 for code in [
8511 5, // SQLITE_BUSY
8512 261, // SQLITE_BUSY_RECOVERY
8513 517, // SQLITE_BUSY_SNAPSHOT
8514 773, // SQLITE_BUSY_TIMEOUT
8515 ] {
8516 assert!(
8517 is_busy_code(code),
8518 "{code} is in the BUSY family but would be treated as a hard failure"
8519 );
8520 }
8521 for code in [
8522 0, // SQLITE_OK
8523 1, // SQLITE_ERROR
8524 6, // SQLITE_LOCKED — adjacent, and deliberately NOT tolerated
8525 262, // SQLITE_LOCKED_SHAREDCACHE
8526 11, // SQLITE_CORRUPT
8527 ] {
8528 assert!(
8529 !is_busy_code(code),
8530 "{code} is not contention, but would be swallowed as though it were"
8531 );
8532 }
8533 }
8534
8535 /// Run the batched delete, separating "the write lock was contended" from
8536 /// "the loop misbehaved". Only the second is this test's subject.
8537 async fn sweep_tolerating_busy(
8538 pool: &SqlitePool,
8539 select_ids: &str,
8540 cutoff: &str,
8541 label: &str,
8542 ) -> Result<SweepOutcome> {
8543 match delete_in_batches(pool, select_ids, cutoff, label).await {
8544 Ok(n) => Ok(SweepOutcome::Completed(n)),
8545 // Contended, not broken. Narrowed to SQLITE_BUSY on purpose: every
8546 // other error still fails the caller, so this is not a blanket
8547 // `let _ =` that would delete the test while keeping its name.
8548 Err(err) if is_sqlite_busy(&err) => Ok(SweepOutcome::Contended),
8549 Err(err) => Err(err),
8550 }
8551 }
8552
8553 /// **A sweep that loses the write lock is inconclusive, not a failure.**
8554 ///
8555 /// CI hit this on `main` at `d05a716`: the sweeper took `SQLITE_BUSY` and
8556 /// the test reported a regression, on a tree whose only changes were two
8557 /// version strings and a changelog.
8558 ///
8559 /// Forced deterministically rather than waiting for a contended runner — it
8560 /// did not reproduce in 48 local runs — by holding a write transaction open
8561 /// and giving the sweep a `busy_timeout` short enough to give up at once.
8562 #[tokio::test]
8563 async fn a_busy_sweep_is_reported_as_contended_not_as_a_failure() -> Result<()> {
8564 struct TempDb(std::path::PathBuf);
8565 impl Drop for TempDb {
8566 fn drop(&mut self) {
8567 for suffix in ["", "-wal", "-shm"] {
8568 std::fs::remove_file(format!("{}{suffix}", self.0.display())).ok();
8569 }
8570 }
8571 }
8572 let path = std::env::temp_dir().join(format!("fr-busysweep-{}.db", std::process::id()));
8573 drop(TempDb(path.clone()));
8574 let _tmp = TempDb(path.clone());
8575 let url = format!("sqlite://{}", path.display());
8576 let pool = init_url(&url).await?;
8577
8578 let feed_id = upsert_feed(
8579 &pool,
8580 &NewFeed {
8581 url: "https://busy.example/f.xml".to_string(),
8582 ..Default::default()
8583 },
8584 )
8585 .await?;
8586 let old = (chrono::Utc::now() - chrono::Duration::days(400))
8587 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8588 let entries: Vec<NewEntry> = (0..4)
8589 .map(|i| NewEntry {
8590 guid: format!("busy-{i}"),
8591 url: Some(format!("https://busy.example/{i}")),
8592 title: Some(format!("e{i}")),
8593 published: Some(old.clone()),
8594 ..Default::default()
8595 })
8596 .collect();
8597 insert_entries(&pool, feed_id, &entries, 1_000).await?;
8598
8599 // A sweep pool that gives up on a contended write immediately.
8600 let sweep_pool = SqlitePoolOptions::new()
8601 .max_connections(1)
8602 .connect_with(
8603 url.parse::<sqlx::sqlite::SqliteConnectOptions>()?
8604 .busy_timeout(std::time::Duration::from_millis(2)),
8605 )
8606 .await?;
8607
8608 // Hold the write lock for the duration of the sweep below.
8609 let mut blocker = pool.acquire().await?;
8610 sqlx::query("BEGIN IMMEDIATE")
8611 .execute(&mut *blocker)
8612 .await?;
8613
8614 let cutoff = (chrono::Utc::now() - chrono::Duration::days(180))
8615 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8616 let outcome = sweep_tolerating_busy(
8617 &sweep_pool,
8618 "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1",
8619 &cutoff,
8620 "busy-sweep-test",
8621 )
8622 .await;
8623
8624 sqlx::query("ROLLBACK").execute(&mut *blocker).await.ok();
8625
8626 match outcome {
8627 Ok(SweepOutcome::Contended) => Ok(()),
8628 Ok(SweepOutcome::Completed(n)) => panic!(
8629 "the sweep completed ({n} rows) while the write lock was held — \
8630 the fixture is not actually contending, so this test proves nothing"
8631 ),
8632 Err(err) => panic!(
8633 "a contended sweep was reported as a failure rather than as \
8634 inconclusive; production logs this and retries on the next \
8635 tick (scheduler.rs): {err:#}"
8636 ),
8637 }
8638 }
8639
8640 /// **A sweep error that is NOT `SQLITE_BUSY` must still fail.**
8641 ///
8642 /// `sweep_tolerating_busy` claims to narrow its tolerance to contention.
8643 /// Without this, that claim is unenforced: widening the arm to `Err(_) =>
8644 /// Contended` swallows every sweep error — a malformed query, a missing
8645 /// table, a corrupt file — and the whole suite stays green. Measured, not
8646 /// assumed: that mutation passed 733 tests before this test existed.
8647 #[tokio::test]
8648 async fn a_non_busy_sweep_error_still_fails() -> Result<()> {
8649 let pool = init_url("sqlite::memory:").await?;
8650 // A table that does not exist: SQLITE_ERROR (1), not SQLITE_BUSY (5).
8651 let outcome = sweep_tolerating_busy(
8652 &pool,
8653 "SELECT id FROM no_such_table WHERE created < ?1",
8654 "2026-01-01T00:00:00Z",
8655 "bad-query-test",
8656 )
8657 .await;
8658
8659 match outcome {
8660 Err(err) => {
8661 assert!(
8662 !is_sqlite_busy(&err),
8663 "fixture drifted: this must be a non-BUSY error, got {err:#}"
8664 );
8665 Ok(())
8666 }
8667 Ok(SweepOutcome::Contended) => panic!(
8668 "a malformed sweep was reported as lock contention — the \
8669 tolerance is a blanket error swallow, not a narrowing"
8670 ),
8671 Ok(SweepOutcome::Completed(n)) => {
8672 panic!("a sweep over a missing table reported {n} rows deleted")
8673 }
8674 }
8675 }
8676
8677 /// **An UNCONTENDED sweep must report `Completed`.**
8678 ///
8679 /// This exists to stop the `Contended` arm above becoming a way to never
8680 /// run the hand-off assertions. Make `sweep_tolerating_busy` return
8681 /// `Contended` unconditionally and the sweep-lock test still passes — it
8682 /// just silently stops testing anything. This one fails instead.
8683 ///
8684 /// That is the difference between tolerating a real contention loss and
8685 /// deleting a test while keeping its name.
8686 #[tokio::test]
8687 async fn a_sweep_with_no_contention_completes() -> Result<()> {
8688 struct TempDb(std::path::PathBuf);
8689 impl Drop for TempDb {
8690 fn drop(&mut self) {
8691 for suffix in ["", "-wal", "-shm"] {
8692 std::fs::remove_file(format!("{}{suffix}", self.0.display())).ok();
8693 }
8694 }
8695 }
8696 let path = std::env::temp_dir().join(format!("fr-calmsweep-{}.db", std::process::id()));
8697 drop(TempDb(path.clone()));
8698 let _tmp = TempDb(path.clone());
8699 let pool = init_url(&format!("sqlite://{}", path.display())).await?;
8700
8701 let feed_id = upsert_feed(
8702 &pool,
8703 &NewFeed {
8704 url: "https://calm.example/f.xml".to_string(),
8705 ..Default::default()
8706 },
8707 )
8708 .await?;
8709 let old = (chrono::Utc::now() - chrono::Duration::days(400))
8710 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8711 let entries: Vec<NewEntry> = (0..3)
8712 .map(|i| NewEntry {
8713 guid: format!("calm-{i}"),
8714 published: Some(old.clone()),
8715 ..Default::default()
8716 })
8717 .collect();
8718 insert_entries(&pool, feed_id, &entries, 1_000).await?;
8719
8720 let cutoff = (chrono::Utc::now() - chrono::Duration::days(180))
8721 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8722 match sweep_tolerating_busy(
8723 &pool,
8724 "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1",
8725 &cutoff,
8726 "calm-sweep-test",
8727 )
8728 .await?
8729 {
8730 SweepOutcome::Completed(n) => {
8731 assert_eq!(n, 3, "the uncontended sweep did not delete the fixture");
8732 Ok(())
8733 }
8734 SweepOutcome::Contended => panic!(
8735 "nothing was holding the write lock, yet the sweep reported \
8736 contention — every test that skips on `Contended` is now \
8737 skipping unconditionally"
8738 ),
8739 }
8740 }
8741
8742 /// **The sweep must not lock other writers out for its duration.**
8743 ///
8744 /// The whole sweep used to be one transaction — both deletes plus a global
8745 /// cursor scrub that loads every `read_cursor` row and then issues a
8746 /// per-cursor live-ids query. SQLite is single-writer with a 5 s
8747 /// `busy_timeout`, so every mark-read, login write and cursor flush failed
8748 /// for that whole span.
8749 ///
8750 /// On-disk (WAL) because the in-memory pool is deliberately
8751 /// single-connection, which would make a concurrency test meaningless.
8752 ///
8753 /// **The writer runs on its own pool with a short `busy_timeout`, and the
8754 /// runtime is multi-thread.** Both are load-bearing — a 5 s `busy_timeout`
8755 /// on a shared runtime is what made this test flake on CI. See the comment
8756 /// on the writer pool and the `attempts` assertion.
8757 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8758 async fn a_writer_gets_through_while_the_sweep_runs() -> Result<()> {
8759 // **Cleanup on EVERY exit, including a panicking assertion.**
8760 //
8761 // The three `remove_file` calls used to sit after the assertions, so any
8762 // failure leaked the database and its `-wal`/`-shm` — 2.7–12.8 MB a time,
8763 // and this test is deliberately the one most likely to fail. Worse, setup
8764 // removed only the `.db`, so a recycled PID paired a fresh database with a
8765 // stale WAL. A guard drops on the unwind path too and takes all three.
8766 struct TempDb(std::path::PathBuf);
8767 impl Drop for TempDb {
8768 fn drop(&mut self) {
8769 for suffix in ["", "-wal", "-shm"] {
8770 std::fs::remove_file(format!("{}{suffix}", self.0.display())).ok();
8771 }
8772 }
8773 }
8774 let dir = std::env::temp_dir();
8775 let path = dir.join(format!("fr-sweeplock-{}.db", std::process::id()));
8776 // Drops the previous run's leftovers, WAL and all, before opening.
8777 drop(TempDb(path.clone()));
8778 let _tmp = TempDb(path.clone());
8779 let url = format!("sqlite://{}", path.display());
8780 let pool = init_url(&url).await?;
8781
8782 let feed_id = upsert_feed(
8783 &pool,
8784 &NewFeed {
8785 url: "https://lock.example/f.xml".to_string(),
8786 ..Default::default()
8787 },
8788 )
8789 .await?;
8790 // **The fixture is DERIVED from the batch count, not described by it.**
8791 //
8792 // Every assertion below reasons about "ten hand-off windows". That was
8793 // prose — a `const BATCHES: u32 = 10` sitting next to a `PRUNE_BATCH *
8794 // 10` fixture with nothing tying them together. Editing the fixture
8795 // alone to `PRUNE_BATCH * 4` left the floor still demanding ten
8796 // hand-offs' worth of time from a four-batch loop, and correct code was
8797 // accused of not handing the lock over at all (1 run in 6). Now the
8798 // compiler carries the coupling.
8799 const BATCHES: i64 = 10;
8800 // **The 50% ceiling below is only safe because BATCHES is large.**
8801 //
8802 // `max_refused_run / attempts` is bounded by roughly `1 / BATCHES` only
8803 // because the fixture opens that many hand-off windows. Shrink it and
8804 // correct code walks into the ceiling: measured with production code
8805 // untouched and the per-batch hold grown 10x, `BATCHES = 4` gives ratios
8806 // of 0.21–0.35 and `BATCHES = 2` gives 0.45–0.56, **failing 3 runs in
8807 // 5**. The comment above invites editing this fixture; this stops that
8808 // edit from silently turning the assertion against the code it guards.
8809 const _: () = assert!(
8810 BATCHES >= 5,
8811 "the 50% ceiling assumes ~1/BATCHES; below 5 batches correct code false-fails",
8812 );
8813 let old = (chrono::Utc::now() - chrono::Duration::days(400))
8814 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8815 let entries: Vec<NewEntry> = (0..(PRUNE_BATCH * BATCHES) as usize)
8816 .map(|i| NewEntry {
8817 guid: format!("lock-{i}"),
8818 published: Some(old.clone()),
8819 ..Default::default()
8820 })
8821 .collect();
8822 insert_entries(&pool, feed_id, &entries, 0).await?;
8823
8824 // **The writer gets its OWN pool, with a SHORT `busy_timeout`.**
8825 //
8826 // This is the fix for the CI flake described on the `attempts` assertion
8827 // below, and it is two separate changes.
8828 //
8829 // *Its own pool*, so the only thing that can block a write is SQLite's
8830 // write lock — the thing under test. Sharing the 5-connection pool with
8831 // the sweep meant a write could also stall waiting to ACQUIRE a pooled
8832 // connection the sweep was holding, which is a confounder that looks
8833 // identical from the outside.
8834 //
8835 // *A short `busy_timeout`*, so a contended write FAILS FAST and the loop
8836 // takes another shot. At the production 5 s, SQLite's busy handler backs
8837 // off internally — 1, 2, 5, 10, 25, 50, 100 ms and up — all inside a
8838 // single `execute()`. The writer therefore gets ONE attempt per blocked
8839 // write, and once the ladder reaches 100 ms it sleeps straight past the
8840 // `PRUNE_BATCH_HANDOFF` windows `delete_in_batches` opens. Failing fast
8841 // turns one low-probability attempt into hundreds of independent ones:
8842 // measured 54 attempts at 5 ms, 517 at 2 ms, over the same sweep.
8843 const WRITER_BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(2);
8844 let writer_pool = SqlitePoolOptions::new()
8845 .min_connections(1)
8846 .max_connections(1)
8847 .connect_with(
8848 SqliteConnectOptions::from_str(&url)?
8849 .foreign_keys(true)
8850 .busy_timeout(WRITER_BUSY_TIMEOUT)
8851 .log_statements(tracing::log::LevelFilter::Debug),
8852 )
8853 .await?;
8854
8855 let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
8856 let writer_done = std::sync::Arc::clone(&done);
8857 let writer = tokio::spawn(async move {
8858 // `(when the attempt STARTED, whether it landed)`.
8859 //
8860 // The ORDER is what the assertions read, not the timestamps: they
8861 // count consecutive failures. The instants serve only to select the
8862 // attempts made inside the measured window — the writer is spawned
8863 // before `t0`, so a plain counter would fold in attempts that can
8864 // never appear in `during`.
8865 //
8866 // (An earlier version of this comment, left behind by the switch away
8867 // from elapsed time, said completions were recorded and that "the
8868 // timestamps are the load-bearing part". Neither is true now.)
8869 let mut outcomes: Vec<(std::time::Instant, bool)> = Vec::new();
8870 // Kept for the failure message: if the writes are failing for a
8871 // reason that is NOT lock contention, nothing lands and the test
8872 // fails — this is what says why. Timestamped so the test can drop it
8873 // when it describes an attempt OUTSIDE the measured window; the
8874 // writer starts before `t0`, so the very first error is usually from
8875 // an attempt the assertions never look at.
8876 let mut first_err: Option<(std::time::Instant, String)> = None;
8877 while !writer_done.load(std::sync::atomic::Ordering::Relaxed) {
8878 let started = std::time::Instant::now();
8879 match grant_access(
8880 &writer_pool,
8881 &format!("did:plc:writer{}", outcomes.len()),
8882 None,
8883 "sweep-test",
8884 None,
8885 )
8886 .await
8887 {
8888 Ok(()) => outcomes.push((started, true)),
8889 // Expected: the sweep holds the write lock right now.
8890 // Retrying is the entire point, so this is counted, not
8891 // fatal. A `?` here would abort the writer on the first
8892 // contended write and destroy the measurement.
8893 Err(err) => {
8894 outcomes.push((started, false));
8895 if first_err.is_none() {
8896 first_err = Some((started, format!("{err:#}")));
8897 }
8898 }
8899 }
8900 tokio::task::yield_now().await;
8901 }
8902 writer_pool.close().await;
8903 (outcomes, first_err)
8904 });
8905
8906 // **Drive `delete_in_batches` directly, not `prune_old_entries`.**
8907 //
8908 // The subject is the batched delete loop and whether it hands the write
8909 // lock over between batches. `prune_old_entries` wraps it in work that
8910 // is not that — two delete passes plus `prune_orphan_cursor_ids` — so
8911 // timing the whole call measures a window in which the lock was never
8912 // meant to be held throughout, and writes landing outside the loop
8913 // count as though the loop had handed the lock over.
8914 //
8915 // A correction to what this comment first claimed. It said the cursor
8916 // scrub was a tail that "grows with the number of rows deleted", and
8917 // that this explained a `42 of 358` measurement. **That is false, and
8918 // measured to be false**: this fixture creates no `read_cursor` rows at
8919 // all, so the scrub does one `SELECT` over an empty table and loops zero
8920 // times — 0.16–2 ms, 0.03–0.5% of the window, at any fixture size. It
8921 // cannot explain anything. Narrowing the window is still right, for the
8922 // reason above; the mechanism originally given for it was not real.
8923 //
8924 // The consequence worth stating: because the fixture has no cursors, the
8925 // old form never covered the scrub's locking either — it only appeared
8926 // to. Nothing here regressed. `prune_orphan_cursor_ids` holding the lock
8927 // across a whole pass is a real production invariant (see its own doc)
8928 // and remains untested; that needs a test with actual cursors, not this
8929 // one.
8930 let hard_cutoff = (chrono::Utc::now() - chrono::Duration::days(180))
8931 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
8932 let t0 = std::time::Instant::now();
8933 let outcome = sweep_tolerating_busy(
8934 &pool,
8935 "SELECT id FROM entries WHERE COALESCE(published, fetched_at) < ?1",
8936 &hard_cutoff,
8937 "sweep-lock-test",
8938 )
8939 .await?;
8940 let sweep = t0.elapsed();
8941
8942 // **Teardown happens on BOTH paths, before the outcome is inspected.**
8943 //
8944 // The contended arm below used to carry its own copy of these two lines.
8945 // A probe proved that arm is never reached by the suite — a `panic!` in
8946 // it failed nothing — so it was five lines of unexercised teardown that
8947 // would run for the first time on a contended CI runner, which is
8948 // exactly when it has to work. Hoisting leaves the arm with nothing that
8949 // can be wrong.
8950 done.store(true, std::sync::atomic::Ordering::Relaxed);
8951 let (outcomes, first_err) = writer.await?;
8952
8953 let deleted = match outcome {
8954 SweepOutcome::Completed(n) => n,
8955 // **Inconclusive, not a regression.** The sweeper lost the write
8956 // lock, which says nothing about whether it hands the lock over
8957 // between batches — the property below. Production logs this and
8958 // retries on the next tick (`scheduler.rs`), so a test that failed
8959 // here would be stricter than the system it guards. Observed on CI
8960 // at `d05a716`, on a tree with no `.rs` change at all.
8961 //
8962 // `a_sweep_with_no_contention_completes` is what stops this arm
8963 // becoming a way to never run the assertions.
8964 SweepOutcome::Contended => {
8965 eprintln!(
8966 "sweep-lock test INCONCLUSIVE: the sweeper took SQLITE_BUSY; \
8967 the hand-off assertions did not run"
8968 );
8969 return Ok(());
8970 }
8971 };
8972 let sweep_end = t0 + sweep;
8973 // Attempts actually made inside the measured window, in order.
8974 let inside: Vec<bool> = outcomes
8975 .iter()
8976 .filter(|(t, _)| *t >= t0 && *t < sweep_end)
8977 .map(|(_, ok)| *ok)
8978 .collect();
8979 let attempts = inside.len();
8980 let during = inside.iter().filter(|ok| **ok).count();
8981 // **The longest unbroken run of REFUSED attempts.**
8982 //
8983 // Counted in attempts, not elapsed time — see the note on the assertion
8984 // for why that distinction is the whole point.
8985 let max_refused_run = {
8986 let (mut worst, mut run) = (0usize, 0usize);
8987 for ok in &inside {
8988 run = if *ok { 0 } else { run + 1 };
8989 worst = worst.max(run);
8990 }
8991 worst
8992 };
8993 let why = first_err
8994 .filter(|(t, _)| *t >= t0 && *t < sweep_end)
8995 .map(|(_, e)| format!(" (first in-window write error: {e})"))
8996 .unwrap_or_default();
8997
8998 assert_eq!(deleted as usize, entries.len());
8999 // **The sweep has to BE batched before anything downstream means
9000 // anything, and this floor is derived, not calibrated.**
9001 //
9002 // The fixture is `PRUNE_BATCH * 10` rows, all older than the hard
9003 // ceiling, so the hard-ceiling delete drains them in ten full batches
9004 // and stands down `PRUNE_BATCH_HANDOFF` after each. A genuinely batched
9005 // sweep therefore cannot finish in under `10 * PRUNE_BATCH_HANDOFF` on
9006 // any machine, however fast its disk — the sleeps are a floor the
9007 // hardware cannot undercut, and the deletes themselves only add to it.
9008 //
9009 // A loop that has LOST its batching is faster, not slower: measured at
9010 // 56 ms with the `LIMIT` dropped, against 305 ms batched. That is why
9011 // this fires before the two assertions below — without it, removing the
9012 // batching starves the writer of attempts and gets reported as "invalid
9013 // measurement", blaming the test for the defect it just detected.
9014 //
9015 // **Partial coverage, measured rather than asserted.** Two mutations
9016 // that keep the loop looking roughly batched are caught only sometimes:
9017 //
9018 // `LIMIT` dropped (no batching at all) 3-4 runs in 5-6, MOSTLY by
9019 // the refusal assertion below,
9020 // not by this floor
9021 // `PRUNE_BATCH_HANDOFF` sleep removed 1 run in 5-6, by this floor
9022 //
9023 // (An earlier version attributed both to this floor. Re-measured: of
9024 // four catches of the `LIMIT` mutation in six runs, three panicked at
9025 // the refusal assertion and one here.)
9026 //
9027 // Both were caught more often — 5/5 and 3/5 — by the wall-clock form
9028 // this replaced. That is a real coverage loss and it was taken on
9029 // purpose: the wall-clock form FALSE-FAILED correct code, which is a
9030 // worse defect than missing a deliberate deletion of a commented line.
9031 // See the note on the assertion below for the measurement.
9032 //
9033 // Nothing here is tuned to make those two reliable. Doing so means
9034 // thresholding a rate, which is what this test has now been wrong about
9035 // three separate times.
9036 let handoff_floor = PRUNE_BATCH_HANDOFF * BATCHES as u32;
9037 assert!(
9038 sweep > handoff_floor,
9039 "the delete loop finished in {sweep:?}, under the {handoff_floor:?} that \
9040 {BATCHES} batches of `PRUNE_BATCH_HANDOFF` alone would take — it is not \
9041 handing the write lock over between batches at all"
9042 );
9043 // **Assert a RATIO OF TWO DURATIONS THAT SCALE TOGETHER.**
9044 //
9045 // Three thresholds have now failed here, each for the same reason: they
9046 // compared something machine-scaled against something fixed.
9047 //
9048 // `worst * 3 < sweep` — broke when the sweep got FASTER (the
9049 // `NOT EXISTS` rewrite, 1.49x) and tightened a
9050 // threshold calibrated against the slow version.
9051 // `wrote >= 10` — a raw count is writes-per-unit-time, so it
9052 // measured the runner. Flaked on CI at 4 writes.
9053 // `during * 2 >=` — a success FRACTION, which I claimed was
9054 // `attempts` scale-free. It is not, and this is the
9055 // important one, because the argument sounds
9056 // right. Successes come from the FIXED
9057 // `BATCHES * PRUNE_BATCH_HANDOFF` of open
9058 // window divided by write latency; failures
9059 // come from the machine-scaled lock hold
9060 // divided by the FIXED `WRITER_BUSY_TIMEOUT`.
9061 // Slow the machine by k and the fraction decays
9062 // as roughly 1/(1 + k²c) — quadratically,
9063 // toward failure. Measured with production code
9064 // fully correct and only the per-batch hold
9065 // grown 10x: **188/949 (19.8%) and 383/1028
9066 // (37.3%), two false failures in three runs**,
9067 // at loop durations of 3.4 s. CPU saturation
9068 // cannot find this — it slows writer and
9069 // sweeper together, which is the wrong axis.
9070 //
9071 // `max_gap * 2 <` — the longest WALL-CLOCK stretch with no write
9072 // `sweep` landing, against the loop's duration. Both
9073 // sides scale with the machine, which fixed the
9074 // fraction's problem and introduced a new one:
9075 // a gap opens when the writer is DESCHEDULED
9076 // just as surely as when the lock is held.
9077 // Observed under 4x CPU saturation, full suite:
9078 // `went 319.95ms of 609.83ms` — while **622 of
9079 // 626 attempts landed**. The lock was fine; the
9080 // writer task simply did not run for 320 ms.
9081 //
9082 // So count REFUSALS, not time. The longest unbroken run of `SQLITE_BUSY`
9083 // against the number of attempts made:
9084 //
9085 // handed over : the lock is free for `PRUNE_BATCH_HANDOFF` after every
9086 // batch, so the longest refused run is bounded by about
9087 // one batch's worth of attempts.
9088 // held across : every attempt in the window is refused — 100%.
9089 //
9090 // **The ~10% this comment used to quote for the handed-over case is not
9091 // what the shipped configuration produces.** Measured here: 0.001–0.05,
9092 // and in roughly a quarter of runs the writer is refused ZERO times
9093 // (`max_refused_run == 0`, every attempt landing), so the assertion is
9094 // vacuously true and certifies the hand-off by never observing one. That
9095 // is a weak test, not a wrong one — but it is worth knowing that the
9096 // enormous margin comes from the writer rarely colliding at all, not
9097 // from a measured 10%. 10% is what appears only once the per-batch hold
9098 // dominates the hand-off (`PRUNE_BATCH` x10 gives 0.115–0.143).
9099 //
9100 // This is immune to descheduling in a way no wall-clock measure can be:
9101 // a starved writer makes no attempts, so it contributes to neither side
9102 // of the ratio. Machine speed still cancels, because both sides are
9103 // counts of the same attempts. Re-checked against the failure above:
9104 // 622 of 626 landing means a refused run of at most 4, nowhere near the
9105 // 313 it would take to trip.
9106 //
9107 // **Detection is near all-or-nothing, and that is a known limit rather
9108 // than an oversight.** Holding one transaction across only the FIRST
9109 // HALF of the batches — production code otherwise correct — is not
9110 // caught at all:
9111 //
9112 // batches held in one tx (of 10) runs failing
9113 // 5 0 of 6 (ratios 0.05-0.27)
9114 // 7 2 of 6
9115 // 9 5 of 5
9116 // 10 22 of 22
9117 //
9118 // The ratio systematically UNDERSTATES the wall-clock fraction the lock
9119 // was held, because a refused attempt costs ~2 ms and leaves the sweeper
9120 // running uncontended, while a successful write actively blocks it and
9121 // stretches the loop. So attempts pile up during free time. The
9122 // "10% vs 100%" framing above describes the endpoints, not the curve.
9123 //
9124 // Closing that would mean measuring the wall-clock SPAN of a refusal run
9125 // rather than its length — which is most of the way back to `max_gap`,
9126 // the form that false-failed correct code on a descheduled writer. Given
9127 // this assertion has now been wrong four times in a row, and the current
9128 // one has zero false failures across 134 runs in six environments while
9129 // catching the real defect 22/22, a fifth redesign to catch a
9130 // half-transaction — a mutation no plausible edit produces — is not a
9131 // trade worth making. Stated here so the next reader knows the gap is
9132 // chosen, not missed.
9133 //
9134 // Measured, with the apparatus verified before each run:
9135 //
9136 // correct, 1x / 10x per-batch hold passes
9137 // one tx across batches, 1x CAUGHT — refused 128 of 129
9138 // one tx across batches, 10x CAUGHT — refused 1841 of 1843
9139 // `LIMIT` dropped caught 3 runs in 5 (by the floor)
9140 // hand-off sleep removed caught 1 run in 5 (by the floor)
9141 //
9142 // The last two were 5/5 and 3/5 under the wall-clock form. Losing that
9143 // is the price of not false-failing correct code, and it is the right
9144 // way round: the named defect is now caught by two orders of magnitude,
9145 // and the mutations that got weaker are deliberate deletions of lines
9146 // that carry their own explanation.
9147 assert!(
9148 attempts >= 20,
9149 "the writer only got {attempts} attempts inside a {sweep:?} delete loop \
9150 — too few for the ratio below to mean anything. That is USUALLY an \
9151 invalid measurement rather than a held lock, but note that a loop \
9152 holding the lock throughout is itself one cause of a starved writer, \
9153 so check {during} (landed) before concluding the test is at \
9154 fault{why}"
9155 );
9156 assert!(
9157 max_refused_run * 2 < attempts,
9158 "the delete loop refused {max_refused_run} consecutive write attempts out \
9159 of {attempts} ({during} landed) — a loop that hands the write lock over \
9160 between batches refuses at most about one batch's worth in a row; one \
9161 that holds the lock across them refuses nearly every attempt it sees{why}"
9162 );
9163
9164 pool.close().await;
9165 // `_tmp` removes the database, WAL and shm as it drops — on this path
9166 // and on the unwind from any assertion above.
9167 Ok(())
9168 }
9169
9170 /// **The sparing predicate must quantify over ALL DIDs, not just one.**
9171 ///
9172 /// `entry_state`'s primary key is `(did, entry_id)`, so several readers can
9173 /// hold rows on the same shared entry. The window spares an entry when ANY of
9174 /// them has starred it or left it unread — one person's star protects the
9175 /// cached copy everyone reads.
9176 ///
9177 /// This is the ONLY case where `id NOT IN (…)` and the correlated
9178 /// `NOT EXISTS` that replaced it could diverge, and it had no test. Every
9179 /// other retention test writes one `entry_state` row per entry under a single
9180 /// DID, where the two forms are trivially identical — so the claim that the
9181 /// suite made the equivalence executable was false when it was written. It is
9182 /// true now.
9183 #[tokio::test]
9184 async fn sparing_honours_every_did_not_just_one() -> Result<()> {
9185 let pool = init_url("sqlite::memory:").await?;
9186 let feed_id = upsert_feed(
9187 &pool,
9188 &NewFeed {
9189 url: "https://shared.example/f.xml".to_string(),
9190 ..Default::default()
9191 },
9192 )
9193 .await?;
9194 let old = (chrono::Utc::now() - chrono::Duration::days(400))
9195 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
9196 let guids = [
9197 "nobody-touched", // no state row at all -> evicted
9198 "both-read-unstarred", // two DIDs, both read+unstarred -> evicted
9199 "one-starred", // A read+unstarred, B starred -> SPARED by B
9200 "one-unread", // A read+unstarred, B unread -> SPARED by B
9201 ];
9202 let entries: Vec<NewEntry> = guids
9203 .iter()
9204 .map(|g| NewEntry {
9205 guid: (*g).to_string(),
9206 published: Some(old.clone()),
9207 ..Default::default()
9208 })
9209 .collect();
9210 insert_entries(&pool, feed_id, &entries, 0).await?;
9211
9212 let id_of = |g: &'static str| {
9213 let pool = pool.clone();
9214 async move {
9215 sqlx::query_scalar::<_, i64>("SELECT id FROM entries WHERE guid = ?1")
9216 .bind(g)
9217 .fetch_one(&pool)
9218 .await
9219 .unwrap()
9220 }
9221 };
9222 // (did, entry, read, starred)
9223 let rows: [(&str, &'static str, i64, i64); 6] = [
9224 ("did:plc:a", "both-read-unstarred", 1, 0),
9225 ("did:plc:b", "both-read-unstarred", 1, 0),
9226 ("did:plc:a", "one-starred", 1, 0),
9227 ("did:plc:b", "one-starred", 1, 1),
9228 ("did:plc:a", "one-unread", 1, 0),
9229 ("did:plc:b", "one-unread", 0, 0),
9230 ];
9231 for (did, guid, read, starred) in rows {
9232 let id = id_of(guid).await;
9233 sqlx::query(
9234 "INSERT INTO entry_state (did, entry_id, read, starred, updated_at) \
9235 VALUES (?1, ?2, ?3, ?4, '2026-01-01T00:00:00Z')",
9236 )
9237 .bind(did)
9238 .bind(id)
9239 .bind(read)
9240 .bind(starred)
9241 .execute(&pool)
9242 .await?;
9243 }
9244
9245 // Window only — no ceiling, so nothing is swept for age alone.
9246 let deleted = prune_old_entries(&pool, 30, 0, 0).await?;
9247 assert_eq!(
9248 deleted, 2,
9249 "expected the untouched and the all-read entries to go"
9250 );
9251
9252 let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
9253 .fetch_all(&pool)
9254 .await?;
9255 assert_eq!(
9256 left,
9257 vec!["one-starred".to_string(), "one-unread".to_string()],
9258 "a second reader's star or unread mark must spare the SHARED entry"
9259 );
9260 Ok(())
9261 }
9262
9263 /// **A mark-read landing during the scrub must not be overwritten.**
9264 ///
9265 /// Moving the scrub out of the sweep's transaction removed a multi-minute
9266 /// write-lock hold and introduced a lost update in its place: the id-sets
9267 /// were read into a snapshot up front and written back unguarded, so a
9268 /// `mark_read` arriving mid-pass had its id silently dropped — and the
9269 /// rewrite set `dirty = 1`, so the flusher pushed the truncated set to the
9270 /// PDS as authoritative. Local `entry_state` still said read, so the loss was
9271 /// invisible here and visible only in every other atproto client.
9272 ///
9273 /// **⚠️ THIS TEST DOES NOT PROVE THAT, AND THE NAME NO LONGER CLAIMS IT.**
9274 ///
9275 /// The mark-read below lands BEFORE the scrub is called, not during it — so
9276 /// a snapshot-then-write implementation taking its snapshot at the top of
9277 /// `prune_orphan_cursor_ids` would see it too, and pass. The discriminator
9278 /// does not discriminate; what is actually pinned is the ordinary outcome:
9279 /// orphaned ids go, live ids stay.
9280 ///
9281 /// What the lost-update shape is really prevented by is a TYPE fact, not
9282 /// this test: `scrub_one_cursor(pool, did, feed_url)` is handed no id-sets,
9283 /// so it cannot write back anything but what it read itself, and
9284 /// re-introducing the bug means changing its signature.
9285 ///
9286 /// Proving it by test needs a real interleave — hold the write lock on a
9287 /// second connection, let the scrub block on it, commit a `mark_read`, then
9288 /// release — which needs a file-backed database and, without a hook inside
9289 /// the pass, a sleep to be sure the key snapshot has already run. A sleep is
9290 /// how this suite gets flaky in CI, and a flaky test is worse than an honest
9291 /// one, so it is left undone and written down instead.
9292 #[tokio::test]
9293 async fn the_cursor_scrub_drops_orphans_and_keeps_live_ids() -> Result<()> {
9294 let pool = init_url("sqlite::memory:").await?;
9295 let did = "did:plc:race";
9296 let feed_url = "https://race.example/f.xml";
9297 let feed_id = upsert_feed(
9298 &pool,
9299 &NewFeed {
9300 url: feed_url.to_string(),
9301 ..Default::default()
9302 },
9303 )
9304 .await?;
9305 insert_entries(
9306 &pool,
9307 feed_id,
9308 &[
9309 NewEntry {
9310 guid: "live".to_string(),
9311 ..Default::default()
9312 },
9313 NewEntry {
9314 guid: "doomed".to_string(),
9315 ..Default::default()
9316 },
9317 ],
9318 0,
9319 )
9320 .await?;
9321 replace_sub_refs(&pool, did, &[feed_id]).await?;
9322 let live_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'live'")
9323 .fetch_one(&pool)
9324 .await?;
9325 let doomed_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'doomed'")
9326 .fetch_one(&pool)
9327 .await?;
9328
9329 // A cursor holding only the id that is about to be deleted.
9330 upsert_cursor(
9331 &pool,
9332 &ReadCursor {
9333 did: did.to_string(),
9334 feed_url: feed_url.to_string(),
9335 read_through: None,
9336 read_ids: format!("[\"{doomed_id}\"]"),
9337 unread_ids: "[]".to_string(),
9338 dirty: false,
9339 pds_created: false,
9340 updated_at: now_rfc3339(),
9341 },
9342 )
9343 .await?;
9344 sqlx::query("DELETE FROM entries WHERE guid = 'doomed'")
9345 .execute(&pool)
9346 .await?;
9347
9348 // A reader marks the surviving entry read. NOTE this lands before the
9349 // scrub, not during it — see the caveat on this test. It is here because
9350 // the live id must survive the pass, not because it catches the race.
9351 mark_read(&pool, did, live_id, true).await?;
9352
9353 assert_eq!(prune_orphan_cursor_ids(&pool, None).await?, 1);
9354
9355 let cursor = get_cursor(&pool, did, feed_url).await?.expect("cursor");
9356 let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
9357 assert_eq!(
9358 ids,
9359 vec![live_id.to_string()],
9360 "the scrub dropped a live id"
9361 );
9362 assert!(
9363 !ids.contains(&doomed_id.to_string()),
9364 "the orphaned id survived the scrub"
9365 );
9366 Ok(())
9367 }
9368
9369 /// The cursor scrub still happens — it just no longer rides inside the
9370 /// delete transaction. Moving it out is only safe because it is idempotent;
9371 /// this pins that it still runs at all, which is the thing a "move it out"
9372 /// refactor can silently drop.
9373 #[tokio::test]
9374 async fn the_sweep_still_scrubs_orphaned_cursor_ids() -> Result<()> {
9375 let pool = init_url("sqlite::memory:").await?;
9376 let did = "did:plc:scrub";
9377 let feed_url = "https://scrub.example/f.xml";
9378 let feed_id = upsert_feed(
9379 &pool,
9380 &NewFeed {
9381 url: feed_url.to_string(),
9382 ..Default::default()
9383 },
9384 )
9385 .await?;
9386 let old = (chrono::Utc::now() - chrono::Duration::days(400))
9387 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
9388 insert_entries(
9389 &pool,
9390 feed_id,
9391 &[NewEntry {
9392 guid: "doomed".to_string(),
9393 published: Some(old),
9394 ..Default::default()
9395 }],
9396 0,
9397 )
9398 .await?;
9399 let doomed = entries_for_feed(&pool, did, feed_id).await;
9400 // `entries_for_feed` is sub_ref-scoped; read the id directly instead.
9401 drop(doomed);
9402 let doomed_id: i64 = sqlx::query_scalar("SELECT id FROM entries WHERE guid = 'doomed'")
9403 .fetch_one(&pool)
9404 .await?;
9405
9406 upsert_cursor(
9407 &pool,
9408 &ReadCursor {
9409 did: did.to_string(),
9410 feed_url: feed_url.to_string(),
9411 read_through: None,
9412 read_ids: format!("[\"{doomed_id}\"]"),
9413 unread_ids: "[]".to_string(),
9414 dirty: false,
9415 pds_created: false,
9416 updated_at: now_rfc3339(),
9417 },
9418 )
9419 .await?;
9420
9421 assert_eq!(prune_old_entries(&pool, 30, 180, 0).await?, 1);
9422
9423 let cursor = get_cursor(&pool, did, feed_url).await?.expect("cursor");
9424 let ids: Vec<String> = serde_json::from_str(&cursor.read_ids)?;
9425 assert!(
9426 ids.is_empty(),
9427 "the deleted entry's id survived in the cursor: {ids:?}"
9428 );
9429 assert!(cursor.dirty, "a rewritten cursor must be re-flushed");
9430 Ok(())
9431 }
9432
9433 #[tokio::test]
9434 async fn prune_and_reclaim_drops_db_size() -> Result<()> {
9435 // On-disk DB so VACUUM has a file to shrink.
9436 let dir = std::env::temp_dir();
9437 let path = dir.join(format!("fr-prune-{}.db", std::process::id()));
9438 let url = format!("sqlite://{}", path.display());
9439 let pool = init_url(&url).await?;
9440
9441 let feed_id = upsert_feed(
9442 &pool,
9443 &NewFeed {
9444 url: "https://bulk.example/feed.xml".to_string(),
9445 ..Default::default()
9446 },
9447 )
9448 .await?;
9449 let ancient = (chrono::Utc::now() - chrono::Duration::days(365))
9450 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
9451 let entries: Vec<NewEntry> = (0..2000)
9452 .map(|i| NewEntry {
9453 guid: format!("guid-{i}"),
9454 content_html: Some("<p>".to_string() + &"x".repeat(400) + "</p>"),
9455 published: Some(ancient.clone()),
9456 fetched_at: Some(ancient.clone()),
9457 ..Default::default()
9458 })
9459 .collect();
9460 insert_entries(&pool, feed_id, &entries, 0).await?;
9461 let full = db_size_bytes(&pool).await?;
9462 assert!(full > 0);
9463
9464 // A retention sweep prunes every (year-old) entry, then reclaim shrinks.
9465 let deleted = prune_old_entries(&pool, 90, 3650, 0).await?;
9466 assert_eq!(deleted, 2000);
9467 reclaim(&pool).await?;
9468 let after = db_size_bytes(&pool).await?;
9469 assert!(
9470 after < full,
9471 "prune + reclaim must shrink db_size_bytes: {after} !< {full}"
9472 );
9473
9474 drop(pool);
9475 let _ = std::fs::remove_file(&path);
9476 let _ = std::fs::remove_file(format!("{}-wal", path.display()));
9477 let _ = std::fs::remove_file(format!("{}-shm", path.display()));
9478 Ok(())
9479 }
9480
9481 // ---- B1: an existing PRE-0.2.2 invite_codes table (no intended_did) must
9482 // migrate cleanly, not crash-loop boot. ----------------------------------
9483
9484 #[tokio::test]
9485 async fn migrates_pre_intended_did_invite_codes_table() -> Result<()> {
9486 // Build an on-disk DB whose `invite_codes` table has the OLD 0.2.1 shape
9487 // (NO `intended_did` column, and therefore no `intended_did` index), then
9488 // run init_schema/migrations against it — this is exactly the existing-prod
9489 // volume that blocker B1 crash-looped (the SCHEMA's `CREATE INDEX ...
9490 // (intended_did, ...)` fired before the ALTER TABLE added the column).
9491 let dir = std::env::temp_dir();
9492 let path = dir.join(format!("fr-b1-{}.db", std::process::id()));
9493 let url = format!("sqlite://{}", path.display());
9494
9495 // Open a raw pool WITHOUT init_schema and hand-build the old table shape.
9496 let opts = SqliteConnectOptions::from_str(&url)?
9497 .create_if_missing(true)
9498 .foreign_keys(true);
9499 let pool = SqlitePoolOptions::new()
9500 .min_connections(1)
9501 .max_connections(1)
9502 .connect_with(opts)
9503 .await?;
9504 sqlx::query(
9505 r#"CREATE TABLE invite_codes (
9506 code TEXT PRIMARY KEY,
9507 creator_did TEXT NOT NULL,
9508 status TEXT NOT NULL,
9509 invitee_did TEXT,
9510 created_at INTEGER NOT NULL,
9511 expires_at INTEGER NOT NULL,
9512 redeemed_at INTEGER
9513 );"#,
9514 )
9515 .execute(&pool)
9516 .await?;
9517 // Seed a legacy active code so the migration runs against real data.
9518 sqlx::query(
9519 "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
9520 VALUES ('FEATHER-LEGACY00', 'did:plc:old', 'active', 1, 9999999999)",
9521 )
9522 .execute(&pool)
9523 .await?;
9524
9525 // The column is genuinely absent to start with (pre-condition of B1).
9526 let cols: Vec<String> = sqlx::query("PRAGMA table_info(invite_codes)")
9527 .fetch_all(&pool)
9528 .await?
9529 .iter()
9530 .map(|r| r.get::<String, _>("name"))
9531 .collect();
9532 assert!(
9533 !cols.iter().any(|c| c == "intended_did"),
9534 "pre-condition: legacy table must lack intended_did"
9535 );
9536
9537 // THE FIX: init_schema must succeed (not error with "no such column").
9538 init_schema(&pool)
9539 .await
9540 .expect("init_schema on a pre-0.2.2 invite_codes table must not crash");
9541
9542 // Post-condition: the column now exists, both indexes were created, and the
9543 // legacy row is intact.
9544 let cols: Vec<String> = sqlx::query("PRAGMA table_info(invite_codes)")
9545 .fetch_all(&pool)
9546 .await?
9547 .iter()
9548 .map(|r| r.get::<String, _>("name"))
9549 .collect();
9550 assert!(cols.iter().any(|c| c == "intended_did"));
9551 let idx: Vec<String> = sqlx::query(
9552 "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='invite_codes'",
9553 )
9554 .fetch_all(&pool)
9555 .await?
9556 .iter()
9557 .map(|r| r.get::<String, _>("name"))
9558 .collect();
9559 assert!(idx.iter().any(|n| n == "idx_invite_codes_intended"));
9560 assert!(idx.iter().any(|n| n == "idx_invite_codes_intended_active"));
9561
9562 // Idempotent: running it again is a no-op, not an error.
9563 init_schema(&pool)
9564 .await
9565 .expect("re-running init_schema must be idempotent");
9566
9567 // **The OAuth tables must exist too.** They live in this database, and
9568 // creating them only when the Rust backend is selected would make the
9569 // first request after a cutover flip fail with "no such table" -- at the
9570 // one moment nobody wants to find out a migration was missed. They are
9571 // empty and harmless while the sidecar is serving.
9572 let tables: Vec<String> =
9573 sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table'")
9574 .fetch_all(&pool)
9575 .await
9576 .unwrap();
9577 for table in ["oauth_state", "oauth_session", "oauth_nonce"] {
9578 assert!(
9579 tables.iter().any(|t| t == table),
9580 "{table} is missing, so the rust backend would fail on its first request: {tables:?}"
9581 );
9582 }
9583
9584 // The legacy code still redeems (NULL intended_did → open, as before).
9585 let out = redeem_code(&pool, "FEATHER-LEGACY00", "did:plc:new", None, 100).await?;
9586 assert_eq!(out, Ok(()));
9587
9588 drop(pool);
9589 let _ = std::fs::remove_file(&path);
9590 let _ = std::fs::remove_file(format!("{}-wal", path.display()));
9591 let _ = std::fs::remove_file(format!("{}-shm", path.display()));
9592 Ok(())
9593 }
9594
9595 // ---- 0.3.9: the schema a RELEASED binary left behind must upgrade. ------
9596 //
9597 // B1 above hand-built the old shape of ONE table, so it could only catch the
9598 // mistake it was written for. 0.3.9 made the same mistake on `feeds` — an
9599 // index in the base SCHEMA on `kind`, a column only `apply_migrations` adds
9600 // — and crash-looped production on its first boot, while every test here
9601 // passed, because every other test starts from an empty file. These start
9602 // from the schema a released binary actually created (dumped, not
9603 // transcribed), so they cover every table at once: v0.3.8, the release
9604 // before the bug, and v0.2.0, the oldest and furthest-migrated shape.
9605
9606 /// A fresh in-memory pool on ONE connection that never expires. The bug
9607 /// class is DDL order, which does not depend on a file, and a file named by
9608 /// pid leaks on a failed run and then fails the next run whose pid matches,
9609 /// at the fixture's first CREATE TABLE, before it tests anything.
9610 async fn upgrade_test_pool() -> Result<SqlitePool> {
9611 let opts = SqliteConnectOptions::from_str("sqlite::memory:")?.foreign_keys(true);
9612 Ok(SqlitePoolOptions::new()
9613 .min_connections(1)
9614 .max_connections(1)
9615 .idle_timeout(None)
9616 .max_lifetime(None)
9617 .connect_with(opts)
9618 .await?)
9619 }
9620
9621 /// Every table's columns (with type, NOT NULL, default and pk) and every
9622 /// index (with uniqueness, partiality and its columns in order), as one
9623 /// comparable set. Column ORDER is left out on purpose: `ALTER TABLE ADD
9624 /// COLUMN` appends, so a migrated table legitimately orders differently
9625 /// from a fresh one.
9626 async fn schema_shape(pool: &SqlitePool) -> Result<std::collections::BTreeSet<String>> {
9627 let mut shape = std::collections::BTreeSet::new();
9628 let tables: Vec<String> = sqlx::query_scalar(
9629 "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
9630 )
9631 .fetch_all(pool)
9632 .await?;
9633 for t in tables {
9634 for r in sqlx::query(
9635 r#"SELECT name, type, "notnull", dflt_value, pk FROM pragma_table_info(?)"#,
9636 )
9637 .bind(&t)
9638 .fetch_all(pool)
9639 .await?
9640 {
9641 shape.insert(format!(
9642 "column {t}.{} {} notnull={} default={:?} pk={}",
9643 r.get::<String, _>("name"),
9644 r.get::<String, _>("type"),
9645 r.get::<i64, _>("notnull"),
9646 r.get::<Option<String>, _>("dflt_value"),
9647 r.get::<i64, _>("pk"),
9648 ));
9649 }
9650 for r in sqlx::query(r#"SELECT name, "unique", partial FROM pragma_index_list(?)"#)
9651 .bind(&t)
9652 .fetch_all(pool)
9653 .await?
9654 {
9655 let name: String = r.get("name");
9656 let cols: Vec<String> =
9657 sqlx::query_scalar("SELECT name FROM pragma_index_info(?) ORDER BY seqno")
9658 .bind(&name)
9659 .fetch_all(pool)
9660 .await?;
9661 shape.insert(format!(
9662 "index {t}.{name} unique={} partial={} ({})",
9663 r.get::<i64, _>("unique"),
9664 r.get::<i64, _>("partial"),
9665 cols.join(", "),
9666 ));
9667 }
9668 }
9669 Ok(shape)
9670 }
9671
9672 /// Load `fixture`, seed rows the way an old binary inserted them, run the
9673 /// current `init_schema`, and require the result to be indistinguishable
9674 /// in shape from a fresh database, with `kind` back-filled correctly.
9675 async fn assert_upgrades_from(version: &str, fixture: &'static str) -> Result<()> {
9676 let pool = upgrade_test_pool().await?;
9677 sqlx::raw_sql(fixture).execute(&pool).await?;
9678
9679 let has_kind = |pool: SqlitePool| async move {
9680 Ok::<_, anyhow::Error>(
9681 sqlx::query_scalar::<_, i64>(
9682 "SELECT count(*) FROM pragma_table_info('feeds') WHERE name = 'kind'",
9683 )
9684 .fetch_one(&pool)
9685 .await?
9686 == 1,
9687 )
9688 };
9689 assert!(
9690 !has_kind(pool.clone()).await?,
9691 "pre-condition: a {version} feeds table has no kind column"
9692 );
9693
9694 // One row of each kind, inserted the way the old binary did: without `kind`.
9695 let publication = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
9696 for u in ["https://example.com/feed.xml", publication] {
9697 sqlx::query("INSERT INTO feeds (url) VALUES (?)")
9698 .bind(u)
9699 .execute(&pool)
9700 .await?;
9701 }
9702
9703 init_schema(&pool)
9704 .await
9705 .unwrap_or_else(|e| panic!("init_schema must upgrade a {version} database: {e:#}"));
9706
9707 let kinds: Vec<(String, String)> =
9708 sqlx::query_as("SELECT url, kind FROM feeds ORDER BY id")
9709 .fetch_all(&pool)
9710 .await?;
9711 assert_eq!(
9712 kinds,
9713 vec![
9714 (
9715 "https://example.com/feed.xml".to_string(),
9716 "rss".to_string()
9717 ),
9718 (publication.to_string(), "publication".to_string()),
9719 ],
9720 "{version}: existing rows are back-filled from their URL"
9721 );
9722 // What it indexes, not only its name: an `idx_feeds_kind` on the wrong
9723 // column passed a name check. (The shape comparison below also covers
9724 // this; this one names the bug that shipped.)
9725 let indexed: Vec<String> = sqlx::query_scalar(
9726 "SELECT name FROM pragma_index_info('idx_feeds_kind') ORDER BY seqno",
9727 )
9728 .fetch_all(&pool)
9729 .await?;
9730 assert_eq!(
9731 indexed,
9732 vec!["kind".to_string()],
9733 "{version}: idx_feeds_kind exists, on feeds(kind), after the column"
9734 );
9735
9736 // The general check: anything a fresh database has that the upgraded
9737 // one lacks, or the reverse, is a migration gap.
9738 let fresh = upgrade_test_pool().await?;
9739 init_schema(&fresh).await?;
9740 let (want, got) = (schema_shape(&fresh).await?, schema_shape(&pool).await?);
9741 assert!(
9742 want == got,
9743 "{version}: upgraded schema differs from a fresh one\n missing: {:#?}\n extra: {:#?}",
9744 want.difference(&got).collect::<Vec<_>>(),
9745 got.difference(&want).collect::<Vec<_>>(),
9746 );
9747
9748 // And a second boot over the upgraded database is a no-op, not an error.
9749 init_schema(&pool)
9750 .await
9751 .unwrap_or_else(|e| panic!("{version}: re-running init_schema failed: {e:#}"));
9752 Ok(())
9753 }
9754
9755 #[tokio::test]
9756 async fn a_v0_3_8_database_upgrades_to_the_current_schema() -> Result<()> {
9757 assert_upgrades_from(
9758 "v0.3.8",
9759 include_str!("../tests/fixtures/schema-v0.3.8.sql"),
9760 )
9761 .await
9762 }
9763
9764 #[tokio::test]
9765 async fn a_v0_2_0_database_upgrades_to_the_current_schema() -> Result<()> {
9766 assert_upgrades_from(
9767 "v0.2.0",
9768 include_str!("../tests/fixtures/schema-v0.2.0.sql"),
9769 )
9770 .await
9771 }
9772
9773 // ---- B2: a code minted FOR a specific DID is redeemable ONLY by that DID. --
9774
9775 #[tokio::test]
9776 async fn redeem_enforces_intended_did_binding() -> Result<()> {
9777 let pool = init_url("sqlite::memory:").await?;
9778 // Mint a claim FOR did:plc:A (the follower the bot posted the link to).
9779 let code = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:A").await?;
9780
9781 // A DIFFERENT DID (a throwaway that stole the public link) is refused as if
9782 // the code didn't exist — no seat granted, code still active.
9783 let stolen = redeem_code(&pool, &code, "did:plc:B", Some("thief.bsky"), 100).await?;
9784 assert_eq!(stolen, Err(RedeemError::NotFound));
9785 assert!(!has_beta_access(&pool, "did:plc:B").await?);
9786 assert_eq!(count_active_codes(&pool).await?, 1, "code must stay active");
9787
9788 // The INTENDED DID redeems successfully.
9789 let ok = redeem_code(&pool, &code, "did:plc:A", Some("alice.bsky"), 100).await?;
9790 assert_eq!(ok, Ok(()));
9791 assert!(has_beta_access(&pool, "did:plc:A").await?);
9792
9793 // A NULL-intended (admin/browser) code stays open to anyone (unchanged).
9794 let open = mint_code(&pool, "did:plc:admin", 3600).await?;
9795 let anyone = redeem_code(&pool, &open, "did:plc:C", None, 100).await?;
9796 assert_eq!(anyone, Ok(()));
9797 assert!(has_beta_access(&pool, "did:plc:C").await?);
9798 Ok(())
9799 }
9800
9801 // ---- S4: at most one ACTIVE code per intended DID; a concurrent second mint
9802 // hits the partial-unique index, and is_intended_active_conflict recognises it.
9803
9804 #[tokio::test]
9805 async fn intended_active_partial_unique_index_blocks_double_mint() -> Result<()> {
9806 let pool = init_url("sqlite::memory:").await?;
9807 // First mint for the DID succeeds.
9808 mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup").await?;
9809 // A SECOND active mint for the SAME DID violates the partial unique index.
9810 let err = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup")
9811 .await
9812 .expect_err("second active mint for the same DID must fail the unique index");
9813 assert!(
9814 is_intended_active_conflict(&err),
9815 "the conflict must be recognised so the web layer can recover: {err:?}"
9816 );
9817 // Still exactly one active code for the DID.
9818 assert!(find_active_code_for_did(&pool, "did:plc:dup")
9819 .await?
9820 .is_some());
9821
9822 // Once the first code is redeemed (no longer active), a fresh mint for the
9823 // DID is allowed again (partial index only constrains active rows).
9824 let existing = find_active_code_for_did(&pool, "did:plc:dup")
9825 .await?
9826 .unwrap();
9827 redeem_code(&pool, &existing, "did:plc:dup", None, 100).await??;
9828 mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:dup")
9829 .await
9830 .expect("a new mint is allowed after the prior one is redeemed");
9831
9832 // And the conflict helper does NOT fire on an unrelated error (a PRIMARY KEY
9833 // clash on `code`, i.e. a different constraint).
9834 sqlx::query(
9835 "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
9836 VALUES ('FEATHER-DUPEKEY0', 'did:x', 'active', 1, 9999999999)",
9837 )
9838 .execute(&pool)
9839 .await?;
9840 let pk_err = sqlx::query(
9841 "INSERT INTO invite_codes (code, creator_did, status, created_at, expires_at) \
9842 VALUES ('FEATHER-DUPEKEY0', 'did:x', 'active', 1, 9999999999)",
9843 )
9844 .execute(&pool)
9845 .await
9846 .expect_err("duplicate PRIMARY KEY must error");
9847 let as_anyhow = anyhow::Error::new(pk_err);
9848 assert!(
9849 !is_intended_active_conflict(&as_anyhow),
9850 "a non-intended-index conflict must NOT be mistaken for the recover-able one"
9851 );
9852 Ok(())
9853 }
9854
9855 #[tokio::test]
9856 async fn purge_expires_orphaned_active_intended_code() -> Result<()> {
9857 // Cheap nit: purging a DID that is the TARGET of an active claim must both
9858 // NULL intended_did AND expire the (now orphaned) active code, so it stops
9859 // counting against the mint cap for its full TTL.
9860 let pool = init_url("sqlite::memory:").await?;
9861 let code = mint_code_for_did(&pool, "did:bot:fr", 3600, "did:plc:leaver").await?;
9862 assert_eq!(count_active_codes(&pool).await?, 1);
9863
9864 purge_did_data(&pool, "did:plc:leaver").await?;
9865
9866 // The code is no longer active (expired), so it no longer counts.
9867 assert_eq!(
9868 count_active_codes(&pool).await?,
9869 0,
9870 "orphaned code must be expired by purge, not left active"
9871 );
9872 // And intended_did was scrubbed.
9873 let intended: Option<String> =
9874 sqlx::query("SELECT intended_did FROM invite_codes WHERE code = ?1")
9875 .bind(&code)
9876 .fetch_one(&pool)
9877 .await?
9878 .get("intended_did");
9879 assert!(intended.is_none(), "intended_did must be NULLed");
9880 Ok(())
9881 }
9882
9883 /// A `(key, source)` observation upserts in place: two writes for the same
9884 /// relay leave ONE row, carrying the newer value.
9885 #[tokio::test]
9886 async fn network_stat_upserts_per_source() -> Result<()> {
9887 let pool = init_url("sqlite::memory:").await?;
9888 let mut stat = NetworkStat {
9889 key: ADOPTION_STAT_KEY.to_string(),
9890 source: "https://relay1.us-west.bsky.network".to_string(),
9891 value: 1,
9892 truncated: false,
9893 observed_at: "2026-08-12T00:00:00Z".to_string(),
9894 };
9895 record_network_stat(&pool, &stat).await?;
9896 stat.value = 4;
9897 stat.observed_at = "2026-08-13T00:00:00Z".to_string();
9898 record_network_stat(&pool, &stat).await?;
9899
9900 let rows: i64 = sqlx::query("SELECT COUNT(*) AS n FROM network_stat")
9901 .fetch_one(&pool)
9902 .await?
9903 .get("n");
9904 assert_eq!(rows, 1, "the same relay must update, not duplicate");
9905 let latest = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9906 .await?
9907 .expect("a stat");
9908 assert_eq!(latest.value, 4);
9909 assert_eq!(latest.observed_at, "2026-08-13T00:00:00Z");
9910 Ok(())
9911 }
9912
9913 /// **Regression (v0.2.9 review).** Once a slow walk can return a PARTIAL
9914 /// count, a plain upsert lets it overwrite a complete, larger one — moving
9915 /// the published "at least N" DOWN because a relay was slow, not because
9916 /// adoption fell. A truncated observation may only ever raise the floor.
9917 #[tokio::test]
9918 async fn a_truncated_observation_never_lowers_a_stored_count() -> Result<()> {
9919 let pool = init_url("sqlite::memory:").await?;
9920 let mut stat = NetworkStat {
9921 key: ADOPTION_STAT_KEY.to_string(),
9922 source: "https://relay1.us-west.bsky.network".to_string(),
9923 value: 2000,
9924 truncated: false,
9925 observed_at: "2026-08-13T00:00:00Z".to_string(),
9926 };
9927 record_network_stat(&pool, &stat).await?;
9928
9929 // A budget-truncated walk that only got one page in.
9930 stat.value = 500;
9931 stat.truncated = true;
9932 stat.observed_at = "2026-08-14T00:00:00Z".to_string();
9933 record_network_stat(&pool, &stat).await?;
9934
9935 let kept = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9936 .await?
9937 .expect("a stat");
9938 assert_eq!(kept.value, 2000, "a partial walk must not lower the count");
9939 assert!(!kept.truncated, "and must not mark the kept row truncated");
9940 assert_eq!(kept.observed_at, "2026-08-13T00:00:00Z");
9941
9942 // A truncated observation that RAISES the floor is still accepted...
9943 stat.value = 3000;
9944 record_network_stat(&pool, &stat).await?;
9945 assert_eq!(
9946 latest_network_stat(&pool, ADOPTION_STAT_KEY)
9947 .await?
9948 .expect("a stat")
9949 .value,
9950 3000
9951 );
9952
9953 // ...and a COMPLETE observation wins even when it is smaller, because
9954 // repos genuinely can go away and a full walk is authoritative.
9955 stat.value = 42;
9956 stat.truncated = false;
9957 record_network_stat(&pool, &stat).await?;
9958 assert_eq!(
9959 latest_network_stat(&pool, ADOPTION_STAT_KEY)
9960 .await?
9961 .expect("a stat")
9962 .value,
9963 42,
9964 "a complete walk is authoritative even when it shrinks"
9965 );
9966
9967 // An EQUAL-valued truncated observation must not downgrade the row
9968 // either: it proves nothing the stored complete count did not already
9969 // prove, but flipping `truncated` would silently degrade /about from
9970 // "42" to "at least 42" with no change in actual adoption. The strict
9971 // `<` in the guard let exactly this through — the equal case is the one
9972 // the two assertions above cannot reach, because both move the value.
9973 stat.truncated = true;
9974 stat.observed_at = "2026-08-15T00:00:00Z".to_string();
9975 record_network_stat(&pool, &stat).await?;
9976 let kept = latest_network_stat(&pool, ADOPTION_STAT_KEY)
9977 .await?
9978 .expect("a stat");
9979 assert_eq!(kept.value, 42);
9980 assert!(
9981 !kept.truncated,
9982 "an equal truncated observation must not mark the kept row truncated"
9983 );
9984 assert_eq!(
9985 kept.observed_at, "2026-08-14T00:00:00Z",
9986 "the rejected observation must not have rewritten the row at all"
9987 );
9988 Ok(())
9989 }
9990
9991 /// Relays disagree by design (non-archival indexes); the max is surfaced.
9992 #[tokio::test]
9993 async fn latest_network_stat_picks_the_max_across_sources() -> Result<()> {
9994 let pool = init_url("sqlite::memory:").await?;
9995 for (source, value, truncated) in [
9996 ("https://relay1.us-west.bsky.network", 2i64, false),
9997 ("https://relay1.us-east.bsky.network", 40i64, true),
9998 ] {
9999 record_network_stat(
10000 &pool,
10001 &NetworkStat {
10002 key: ADOPTION_STAT_KEY.to_string(),
10003 source: source.to_string(),
10004 value,
10005 truncated,
10006 observed_at: "2026-08-13T00:00:00Z".to_string(),
10007 },
10008 )
10009 .await?;
10010 }
10011 let latest = latest_network_stat(&pool, ADOPTION_STAT_KEY)
10012 .await?
10013 .expect("a stat");
10014 assert_eq!(latest.value, 40);
10015 assert_eq!(latest.source, "https://relay1.us-east.bsky.network");
10016 // `truncated` round-trips as a bool.
10017 assert!(latest.truncated);
10018 Ok(())
10019 }
10020
10021 #[tokio::test]
10022 async fn latest_network_stat_is_none_on_an_empty_table() -> Result<()> {
10023 let pool = init_url("sqlite::memory:").await?;
10024 assert!(latest_network_stat(&pool, ADOPTION_STAT_KEY)
10025 .await?
10026 .is_none());
10027 Ok(())
10028 }
10029
10030 /// **The lookup is keyed.** Every existing network-stat test writes only
10031 /// `ADOPTION_STAT_KEY`, so the `WHERE key = ?1` never discriminated; with
10032 /// it widened to `OR 1=1` the suite stayed green. The public `/stats`
10033 /// page asks for the adoption count, and unkeyed it would render the
10034 /// largest value of ANY stat as the network size.
10035 #[tokio::test]
10036 async fn latest_network_stat_ignores_other_keys() -> Result<()> {
10037 let pool = init_url("sqlite::memory:").await?;
10038 for (key, source, value) in [
10039 (ADOPTION_STAT_KEY, "https://relay1.example", 40),
10040 ("some.other.metric", "https://relay1.example", 9_999),
10041 ] {
10042 record_network_stat(
10043 &pool,
10044 &NetworkStat {
10045 key: key.to_string(),
10046 source: source.to_string(),
10047 value,
10048 truncated: false,
10049 observed_at: now_rfc3339(),
10050 },
10051 )
10052 .await?;
10053 }
10054 let latest = latest_network_stat(&pool, ADOPTION_STAT_KEY)
10055 .await?
10056 .expect("the adoption stat was recorded");
10057 assert_eq!(
10058 latest.value, 40,
10059 "another key's value was returned as the adoption count"
10060 );
10061 Ok(())
10062 }
10063
10064 // ── poll health (the public stats page) ─────────────────────────────────
10065
10066 /// Seed a feed row **through the real writer**, so its `kind` is whatever
10067 /// production would store.
10068 ///
10069 /// This used to be a raw `INSERT`, which took the `kind` column's
10070 /// `DEFAULT 'rss'`. That is correct for an http(s) URL and silently wrong
10071 /// for an `at://` one — the helper claimed to seed a row the poller skips
10072 /// while seeding one it selects.
10073 async fn feed_polled(
10074 pool: &SqlitePool,
10075 url: &str,
10076 last_polled: Option<&str>,
10077 next_poll: Option<&str>,
10078 ) {
10079 upsert_feed(
10080 pool,
10081 &NewFeed {
10082 url: url.to_string(),
10083 last_polled: last_polled.map(str::to_string),
10084 next_poll: next_poll.map(str::to_string),
10085 ..Default::default()
10086 },
10087 )
10088 .await
10089 .unwrap();
10090 }
10091
10092 /// The numbers on the public page must describe the poller's actual state.
10093 #[tokio::test]
10094 async fn poll_health_counts_tracked_recent_and_overdue() -> anyhow::Result<()> {
10095 let pool = init_url("sqlite::memory:").await?;
10096 let now = "2026-01-01T12:00:00Z";
10097 let hour_ago = "2026-01-01T11:00:00Z";
10098
10099 // Polled 10 minutes ago, due in 50 minutes: healthy.
10100 feed_polled(
10101 &pool,
10102 "https://a.example/f",
10103 Some("2026-01-01T11:50:00Z"),
10104 Some("2026-01-01T12:50:00Z"),
10105 )
10106 .await;
10107 // Polled 3 hours ago and overdue: the backlog case.
10108 feed_polled(
10109 &pool,
10110 "https://b.example/f",
10111 Some("2026-01-01T09:00:00Z"),
10112 Some("2026-01-01T10:00:00Z"),
10113 )
10114 .await;
10115 // Never polled: counts as overdue (next_poll IS NULL), and must not
10116 // corrupt the "oldest poll" figure with a NULL.
10117 feed_polled(&pool, "https://c.example/f", None, None).await;
10118
10119 let h = poll_health(&pool, now, hour_ago).await?;
10120 assert_eq!(h.feeds_tracked, 3);
10121 assert_eq!(
10122 h.polled_last_hour, 1,
10123 "only the 11:50 poll is within the hour"
10124 );
10125 assert_eq!(h.overdue, 2, "the stale feed and the never-polled one");
10126 assert_eq!(
10127 h.last_poll_secs_ago,
10128 Some(600),
10129 "most recent poll was 10 minutes ago"
10130 );
10131 // **A never-polled feed IS the worst staleness.**
10132 //
10133 // This originally asserted `Some(10_800)` — the oldest FINITE age — and
10134 // in doing so pinned a defect: `MIN` skips NULLs, so the page reported
10135 // "3h ago" while a quarter of the feeds had never been fetched at all.
10136 // The figure read healthiest in the most degraded state, which is the
10137 // opposite of what a health page is for.
10138 assert_eq!(
10139 h.oldest_poll_secs_ago, None,
10140 "a never-polled feed must outrank any finite age"
10141 );
10142 assert_eq!(h.never_polled, 1);
10143
10144 // With every feed polled, the finite worst case is reported again.
10145 sqlx::query("UPDATE feeds SET last_polled = ?1 WHERE last_polled IS NULL")
10146 .bind("2026-01-01T09:00:00Z")
10147 .execute(&pool)
10148 .await?;
10149 let h = poll_health(&pool, now, hour_ago).await?;
10150 assert_eq!(h.never_polled, 0);
10151 assert_eq!(h.oldest_poll_secs_ago, Some(10_800));
10152 Ok(())
10153 }
10154
10155 /// **`/stats` measures the poller, so it counts only what the poller sees.**
10156 ///
10157 /// `due_feeds` skips `at://` rows; nothing ever advances their `next_poll`
10158 /// or sets `last_polled`. Counted, they read as overdue and never-polled
10159 /// forever, and force "oldest poll" to `never` — the same "unsupported
10160 /// shown as broken" the exclusion exists to end, moved to different rows on
10161 /// a public page. The same predicate decides both queries so they cannot
10162 /// drift.
10163 #[tokio::test]
10164 async fn poll_health_ignores_unpollable_at_uri_rows() -> anyhow::Result<()> {
10165 let pool = init_url("sqlite::memory:").await?;
10166 let now = "2026-01-01T12:00:00Z";
10167 let hour_ago = "2026-01-01T11:00:00Z";
10168 feed_polled(
10169 &pool,
10170 "https://a.example/f",
10171 Some("2026-01-01T11:50:00Z"),
10172 Some("2026-01-01T12:50:00Z"),
10173 )
10174 .await;
10175 // Never polled, never due: the shape every at:// row has.
10176 feed_polled(
10177 &pool,
10178 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab",
10179 None,
10180 None,
10181 )
10182 .await;
10183
10184 let h = poll_health(&pool, now, hour_ago).await?;
10185 assert_eq!(
10186 h.feeds_tracked, 1,
10187 "an unpollable row was counted as tracked"
10188 );
10189 assert_eq!(h.overdue, 0, "an unpollable row was counted as overdue");
10190 assert_eq!(
10191 h.never_polled, 0,
10192 "an unpollable row was counted as never polled"
10193 );
10194 assert_eq!(
10195 h.oldest_poll_secs_ago,
10196 Some(600),
10197 "an unpollable row forced the oldest poll to `never`"
10198 );
10199 assert_eq!(h.polled_last_hour, 1);
10200 Ok(())
10201 }
10202
10203 /// **The admin's failing-feeds list is the poller's too.** `failing_feeds`
10204 /// feeds `/admin/metrics`; it was not given the exclusion both `/stats`
10205 /// queries got. An `at://` row that carries errors — from a rollback to a
10206 /// build that polled them, say — would then sit at the top of the one page
10207 /// an operator uses to diagnose "unsupported shown as broken", with no
10208 /// poll ever coming to clear it and the one-shot migration already spent.
10209 #[tokio::test]
10210 async fn failing_feeds_ignores_unpollable_at_uri_rows() -> anyhow::Result<()> {
10211 let pool = init_url("sqlite::memory:").await?;
10212 for url in [
10213 "https://broken.example/feed.xml",
10214 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab",
10215 ] {
10216 upsert_feed(
10217 &pool,
10218 &NewFeed {
10219 url: url.to_string(),
10220 ..Default::default()
10221 },
10222 )
10223 .await?;
10224 bump_feed_errors(&pool, url, crate::feed::FailureKind::Fetch, "down").await?;
10225 }
10226 let failing = failing_feeds(&pool, 10).await?;
10227 let urls: Vec<&str> = failing.iter().map(|f| f.url.as_str()).collect();
10228 assert_eq!(
10229 urls,
10230 vec!["https://broken.example/feed.xml"],
10231 "an unpollable row was listed as a failing feed"
10232 );
10233 Ok(())
10234 }
10235
10236 /// **The clearing is idempotent by predicate, not by stamp.** It touches
10237 /// only rows that have never been polled successfully: `bump_feed_errors`
10238 /// never sets `last_polled`, both success paths do. So a row a wired
10239 /// reader has fetched once keeps its later failures across restarts, and
10240 /// a row that only ever failed under our own refusal is cleared at every
10241 /// boot — including after a rollback to a build that polled it. No
10242 /// version stamp, nothing for a test to rewind.
10243 #[tokio::test]
10244 async fn the_at_uri_error_clearing_spares_a_row_that_has_been_polled() -> anyhow::Result<()> {
10245 let pool = init_url("sqlite::memory:").await?;
10246 let polled = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/polled";
10247 let never = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/never";
10248 for url in [polled, never] {
10249 upsert_feed(
10250 &pool,
10251 &NewFeed {
10252 url: url.to_string(),
10253 ..Default::default()
10254 },
10255 )
10256 .await?;
10257 bump_feed_errors(&pool, url, crate::feed::FailureKind::Fetch, "down").await?;
10258 }
10259 // A wired reader fetched this one once, then it started failing.
10260 sqlx::query("UPDATE feeds SET last_polled = '2026-01-01T00:00:00Z' WHERE url = ?1")
10261 .bind(polled)
10262 .execute(&pool)
10263 .await?;
10264
10265 for boot in 1..=2 {
10266 apply_migrations(&pool).await?;
10267 let mut errors = std::collections::HashMap::new();
10268 for url in [polled, never] {
10269 let n: i64 =
10270 sqlx::query_scalar("SELECT consecutive_errors FROM feeds WHERE url = ?1")
10271 .bind(url)
10272 .fetch_one(&pool)
10273 .await?;
10274 errors.insert(url, n);
10275 }
10276 assert_eq!(
10277 errors[polled], 1,
10278 "boot {boot} wiped a polled row's failure"
10279 );
10280 assert_eq!(
10281 errors[never], 0,
10282 "boot {boot} left a never-polled row failing"
10283 );
10284 }
10285 Ok(())
10286 }
10287
10288 /// **The SQL kind list and the Rust one are the same list.** A literal in
10289 /// SQL and a slice in Rust is the drift the column exists to end; wiring
10290 /// the standard.site reader changes both, and this is what makes
10291 /// forgetting one a failure rather than a silently dormant feature.
10292 #[test]
10293 fn the_sql_kind_list_matches_the_rust_one() {
10294 let expected = crate::feed::FeedKind::POLLABLE
10295 .iter()
10296 .map(|k| format!("'{}'", k.as_str()))
10297 .collect::<Vec<_>>()
10298 .join(", ");
10299 assert_eq!(POLLABLE_KINDS_SQL, expected);
10300 }
10301
10302 /// **A feed's kind is recorded at insert, not re-derived from its URL.**
10303 ///
10304 /// "Can the poller fetch this?" was a substring predicate spliced into
10305 /// four statements, and a review found a fifth reader that had drifted
10306 /// from it. A column the writers set cannot drift: the Rust side decides
10307 /// once, SQL reads a value.
10308 #[tokio::test]
10309 async fn a_feed_row_records_its_kind_at_insert() -> anyhow::Result<()> {
10310 let pool = init_url("sqlite::memory:").await?;
10311 for (url, want) in [
10312 ("https://real.example/feed.xml", crate::feed::FeedKind::Rss),
10313 ("http://real.example/feed.xml", crate::feed::FeedKind::Rss),
10314 (
10315 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
10316 crate::feed::FeedKind::Publication,
10317 ),
10318 ] {
10319 upsert_feed(
10320 &pool,
10321 &NewFeed {
10322 url: url.to_string(),
10323 ..Default::default()
10324 },
10325 )
10326 .await?;
10327 let got: String = sqlx::query_scalar("SELECT kind FROM feeds WHERE url = ?1")
10328 .bind(url)
10329 .fetch_one(&pool)
10330 .await?;
10331 assert_eq!(got, want.as_str(), "wrong kind recorded for {url}");
10332 }
10333 Ok(())
10334 }
10335
10336 /// **A row written before the column existed is back-filled from its URL.**
10337 /// That back-fill is the LAST use of the string predicate; every reader
10338 /// keys on `kind` afterwards.
10339 #[tokio::test]
10340 async fn the_migration_backfills_kind_from_the_url() -> anyhow::Result<()> {
10341 let pool = init_url("sqlite::memory:").await?;
10342 // A table that predates the column, with both shapes in it.
10343 sqlx::query("DROP TABLE feeds").execute(&pool).await?;
10344 sqlx::query(
10345 "CREATE TABLE feeds (
10346 id INTEGER PRIMARY KEY AUTOINCREMENT,
10347 url TEXT NOT NULL UNIQUE,
10348 title TEXT, site_url TEXT, etag TEXT, last_modified TEXT,
10349 last_polled TEXT, next_poll TEXT,
10350 consecutive_errors INTEGER NOT NULL DEFAULT 0,
10351 last_error_kind TEXT, last_error TEXT
10352 )",
10353 )
10354 .execute(&pool)
10355 .await?;
10356 for url in [
10357 "https://real.example/feed.xml",
10358 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab",
10359 "At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lac",
10360 ] {
10361 sqlx::query("INSERT INTO feeds (url) VALUES (?1)")
10362 .bind(url)
10363 .execute(&pool)
10364 .await?;
10365 }
10366
10367 apply_migrations(&pool).await?;
10368
10369 let kinds: Vec<(String, String)> =
10370 sqlx::query_as("SELECT url, kind FROM feeds ORDER BY url")
10371 .fetch_all(&pool)
10372 .await?;
10373 let by_url: std::collections::HashMap<_, _> = kinds.into_iter().collect();
10374 assert_eq!(by_url["https://real.example/feed.xml"], "rss");
10375 assert_eq!(
10376 by_url["at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab"],
10377 "publication"
10378 );
10379 // Recognised as an at-URI (not `rss`), like every other guard does —
10380 // and, since 0.4.0 polls publications, classed `unsupported`: storage
10381 // refuses this spelling (#183), so polling it would fail every tick.
10382 assert_eq!(
10383 by_url["At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lac"],
10384 "unsupported",
10385 "the back-fill must recognise a non-canonical spelling, and not poll it"
10386 );
10387 Ok(())
10388 }
10389
10390 /// **`feeds.kind` is derived from the URL, so it has to be re-derivable.**
10391 ///
10392 /// The back-fill translated one direction only — a row the Rust side would
10393 /// call `rss` was never touched — which is correct for a one-time migration
10394 /// and wrong for a column that has to survive the rule changing. A kind that
10395 /// disagrees with its own URL is currently permanent: nothing re-reads it.
10396 #[tokio::test]
10397 async fn the_back_fill_corrects_a_kind_that_disagrees_with_the_url() -> anyhow::Result<()> {
10398 let pool = init_url("sqlite::memory:").await?;
10399 let at = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
10400 for (url, wrong) in [
10401 ("https://real.example/feed.xml", "publication"),
10402 (at, "rss"),
10403 ] {
10404 sqlx::query("INSERT INTO feeds (url, kind) VALUES (?1, ?2)")
10405 .bind(url)
10406 .bind(wrong)
10407 .execute(&pool)
10408 .await?;
10409 }
10410
10411 apply_migrations(&pool).await?;
10412
10413 let by_url: std::collections::HashMap<String, String> =
10414 sqlx::query_as("SELECT url, kind FROM feeds")
10415 .fetch_all(&pool)
10416 .await?
10417 .into_iter()
10418 .collect();
10419 assert_eq!(
10420 by_url["https://real.example/feed.xml"], "rss",
10421 "an http feed marked as a publication stayed one, and nothing polls it"
10422 );
10423 assert_eq!(by_url[at], "publication", "the at:// direction regressed");
10424 Ok(())
10425 }
10426
10427 /// **Taking a row out of the poller orphans its poll state, so clear it.**
10428 ///
10429 /// `last_polled` is set here on purpose: the migration's other cleanup step
10430 /// only clears rows we never polled, so a row that HAS been polled proves
10431 /// this reset is the one doing the work. An error count left on a row the
10432 /// scheduler will never select again is hidden from `/stats`, which filters
10433 /// on kind — and if a later rule change readmits the row, it resumes at a
10434 /// backoff earned under a classification that no longer applies.
10435 #[tokio::test]
10436 async fn a_row_taken_out_of_the_poller_loses_the_poll_state_it_cannot_use() -> anyhow::Result<()>
10437 {
10438 let pool = init_url("sqlite::memory:").await?;
10439 let at = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab";
10440 sqlx::query(
10441 "INSERT INTO feeds (url, kind, consecutive_errors, last_error_kind, last_error, \
10442 next_poll, last_polled) \
10443 VALUES (?1, 'rss', 7, 'fetch', 'connection refused', ?2, ?3)",
10444 )
10445 .bind(at)
10446 .bind("2026-09-10T00:00:00Z")
10447 .bind("2026-09-01T00:00:00Z")
10448 .execute(&pool)
10449 .await?;
10450
10451 apply_migrations(&pool).await?;
10452
10453 let (kind, errors, error_kind, error, next_poll): (
10454 String,
10455 i64,
10456 Option<String>,
10457 Option<String>,
10458 Option<String>,
10459 ) = sqlx::query_as(
10460 "SELECT kind, consecutive_errors, last_error_kind, last_error, next_poll \
10461 FROM feeds WHERE url = ?1",
10462 )
10463 .bind(at)
10464 .fetch_one(&pool)
10465 .await?;
10466 assert_eq!(kind, "unsupported", "the row was not reclassified at all");
10467 assert_eq!(
10468 (errors, error_kind, error, next_poll),
10469 (0, None, None, None),
10470 "a row the scheduler will never select again kept its backoff and failure history"
10471 );
10472 Ok(())
10473 }
10474
10475 /// **A row we cannot read must not stop the process from starting.**
10476 ///
10477 /// This runs on the boot path. Refusing to start is a strictly worse
10478 /// outcome than declining to have an opinion about one row, and it is a
10479 /// failure mode the SQL predicate this replaced did not have: it evaluated
10480 /// a non-text `url` happily and returned false.
10481 #[tokio::test]
10482 async fn an_unreadable_feeds_row_does_not_stop_the_boot() -> anyhow::Result<()> {
10483 let pool = init_url("sqlite::memory:").await?;
10484 sqlx::query("INSERT INTO feeds (url, kind) VALUES (X'ff41', 'rss')")
10485 .execute(&pool)
10486 .await?;
10487 sqlx::query("INSERT INTO feeds (url, kind) VALUES (?1, 'publication')")
10488 .bind("https://real.example/feed.xml")
10489 .execute(&pool)
10490 .await?;
10491
10492 apply_migrations(&pool).await?;
10493
10494 let corrected: String = sqlx::query_scalar("SELECT kind FROM feeds WHERE url = ?1")
10495 .bind("https://real.example/feed.xml")
10496 .fetch_one(&pool)
10497 .await?;
10498 assert_eq!(
10499 corrected, "rss",
10500 "one unreadable row aborted the pass before the readable ones were corrected"
10501 );
10502 let untouched: String =
10503 sqlx::query_scalar("SELECT kind FROM feeds WHERE typeof(url) = 'blob'")
10504 .fetch_one(&pool)
10505 .await?;
10506 assert_eq!(
10507 untouched, "rss",
10508 "a row we declined to classify was classified anyway"
10509 );
10510 Ok(())
10511 }
10512
10513 /// Re-subscribing must re-derive the kind, not preserve whatever is there.
10514 ///
10515 /// `upsert_feed` binds `FeedKind::of` on the way in, but its conflict clause
10516 /// never carried `kind`, so the value a row was first written with is the
10517 /// value it keeps. Harmless while the rule is fixed; the rule is about to
10518 /// change.
10519 #[tokio::test]
10520 async fn a_re_upsert_re_derives_the_kind() -> anyhow::Result<()> {
10521 let pool = init_url("sqlite::memory:").await?;
10522 let url = "https://real.example/feed.xml";
10523 let feed = NewFeed {
10524 url: url.to_string(),
10525 ..Default::default()
10526 };
10527 upsert_feed(&pool, &feed).await?;
10528 sqlx::query("UPDATE feeds SET kind = 'publication' WHERE url = ?1")
10529 .bind(url)
10530 .execute(&pool)
10531 .await?;
10532
10533 upsert_feed(&pool, &feed).await?;
10534
10535 let kind: String = sqlx::query_scalar("SELECT kind FROM feeds WHERE url = ?1")
10536 .bind(url)
10537 .fetch_one(&pool)
10538 .await?;
10539 assert_eq!(
10540 kind, "rss",
10541 "a second subscription to the same URL kept the stale classification"
10542 );
10543 Ok(())
10544 }
10545
10546 /// **The readers key on `kind`, not on the URL.** A row whose kind says
10547 /// publication is unpollable even if its URL looks ordinary — which is
10548 /// what makes the column, rather than the string, the source of truth.
10549 #[tokio::test]
10550 async fn the_poller_and_the_pages_key_on_kind() -> anyhow::Result<()> {
10551 let pool = init_url("sqlite::memory:").await?;
10552 upsert_feed(
10553 &pool,
10554 &NewFeed {
10555 url: "https://looks-ordinary.example/feed.xml".to_string(),
10556 ..Default::default()
10557 },
10558 )
10559 .await?;
10560 // Force the kind independently of the URL: only the column should matter.
10561 // `unsupported` because it is the kind no poller reads (publications
10562 // are pollable since 0.4.0).
10563 sqlx::query("UPDATE feeds SET kind = 'unsupported' WHERE url LIKE 'https://looks%'")
10564 .execute(&pool)
10565 .await?;
10566
10567 let due = due_feeds(&pool, "2026-01-01T12:00:00Z", 10).await?;
10568 assert!(due.is_empty(), "due_feeds read the URL, not the kind");
10569 assert_eq!(
10570 unpollable_feeds(&pool).await?,
10571 1,
10572 "unpollable_feeds read the URL"
10573 );
10574
10575 let h = poll_health(&pool, "2026-01-01T12:00:00Z", "2026-01-01T11:00:00Z").await?;
10576 assert_eq!(h.feeds_tracked, 0, "poll_health read the URL, not the kind");
10577 Ok(())
10578 }
10579
10580 /// **SQL and Rust agree on what an at-URI is — case-insensitively.**
10581 ///
10582 /// This test used to pin the opposite, and pinned a bug. It asserted that a
10583 /// mixed-case `At://` row IS handed to the poller, reasoning that the Rust
10584 /// guards use a case-sensitive `strip_prefix` so "every other check treats
10585 /// it as a plain URL". They do not: URL schemes are case-insensitive, so
10586 /// `Url::parse` folds `At://` to scheme `at`, which `net::check_scheme`
10587 /// refuses — and the DID form does not parse at all. Such a row can only
10588 /// fail, every tick, forever, and be published in the `fetch` bucket as an
10589 /// unreachable publisher. That is the exact conflation the exclusion exists
10590 /// to end.
10591 ///
10592 /// Recognition is case-insensitive on both sides now. Storing one is still
10593 /// refused: `feeds.url` is UNIQUE, so two spellings of one publication are
10594 /// two rows — the same rule the canonical-handle check applies.
10595 #[tokio::test]
10596 async fn a_mixed_case_at_uri_is_unpollable_on_both_sides() -> anyhow::Result<()> {
10597 let pool = init_url("sqlite::memory:").await?;
10598 let odd = "At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
10599 assert!(
10600 !crate::feed::is_storable_feed_url(odd, true),
10601 "a non-canonical spelling must not be storable"
10602 );
10603 upsert_feed(
10604 &pool,
10605 &NewFeed {
10606 url: odd.to_string(),
10607 ..Default::default()
10608 },
10609 )
10610 .await?;
10611 let due = due_feeds(&pool, "2026-01-01T12:00:00Z", 10).await?;
10612 assert!(
10613 due.is_empty(),
10614 "a row nothing can fetch was handed to the poller: {:?}",
10615 due.iter().map(|f| &f.url).collect::<Vec<_>>()
10616 );
10617
10618 // And the boot-time clearing reaches it, so a legacy row that already
10619 // accrued errors stops counting as a broken publisher.
10620 bump_feed_errors(&pool, odd, crate::feed::FailureKind::Fetch, "refused").await?;
10621 apply_migrations(&pool).await?;
10622 let n: i64 = sqlx::query_scalar("SELECT consecutive_errors FROM feeds WHERE url = ?1")
10623 .bind(odd)
10624 .fetch_one(&pool)
10625 .await?;
10626 assert_eq!(n, 0, "the clearing skipped a mixed-case at-URI row");
10627 Ok(())
10628 }
10629
10630 /// **The global feeds ceiling counts every row, including unpollable ones
10631 /// — deliberately, and visibly.**
10632 ///
10633 /// `count_feeds` is a fifth reader of "is this an at-URI" that does NOT use
10634 /// the unpollable kinds, and that is the right call: the ceiling bounds
10635 /// STORAGE on a small box, and an unpollable row occupies a row. What was
10636 /// wrong is that the capacity it consumed appeared on no surface — `/stats`
10637 /// measures the poller and excludes them, so an operator could be at the
10638 /// cap while every page said otherwise. `unpollable_feeds` is what
10639 /// `/admin/metrics` renders to close that gap.
10640 #[tokio::test]
10641 async fn the_ceiling_counts_unpollable_rows_and_they_are_countable() -> anyhow::Result<()> {
10642 let pool = init_url("sqlite::memory:").await?;
10643 for url in [
10644 "https://real.example/feed.xml",
10645 "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/app.bsky.feed.post/3lab",
10646 "At://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lac",
10647 ] {
10648 upsert_feed(
10649 &pool,
10650 &NewFeed {
10651 url: url.to_string(),
10652 ..Default::default()
10653 },
10654 )
10655 .await?;
10656 }
10657 assert_eq!(
10658 count_feeds(&pool).await?,
10659 3,
10660 "the ceiling must bound storage, so every row counts"
10661 );
10662 assert_eq!(
10663 unpollable_feeds(&pool).await?,
10664 2,
10665 "both at-URI spellings are unpollable and must be countable"
10666 );
10667 Ok(())
10668 }
10669
10670 /// A fresh instance has no polls yet. The page must say so rather than
10671 /// rendering a zero that reads as "polled just now".
10672 #[tokio::test]
10673 async fn poll_health_on_an_empty_instance_reports_no_polls() -> anyhow::Result<()> {
10674 let pool = init_url("sqlite::memory:").await?;
10675 let h = poll_health(&pool, "2026-01-01T12:00:00Z", "2026-01-01T11:00:00Z").await?;
10676 assert_eq!(h.feeds_tracked, 0);
10677 assert_eq!(h.last_poll_secs_ago, None);
10678 assert_eq!(h.oldest_poll_secs_ago, None);
10679 Ok(())
10680 }
10681
10682 /// A poll timestamped in the future — clock skew, or a restored backup —
10683 /// reads as "just now", never as a negative age.
10684 #[tokio::test]
10685 async fn a_future_poll_timestamp_does_not_go_negative() -> anyhow::Result<()> {
10686 let pool = init_url("sqlite::memory:").await?;
10687 feed_polled(
10688 &pool,
10689 "https://a.example/f",
10690 Some("2026-01-01T13:00:00Z"),
10691 None,
10692 )
10693 .await;
10694 let h = poll_health(&pool, "2026-01-01T12:00:00Z", "2026-01-01T11:00:00Z").await?;
10695 assert_eq!(h.last_poll_secs_ago, Some(0));
10696 Ok(())
10697 }
10698
10699 // ── retention is a CACHE policy, not a data-retention policy ────────────
10700
10701 async fn aged_entry(pool: &SqlitePool, url: &str, days_old: i64) -> i64 {
10702 let when = (chrono::Utc::now() - chrono::Duration::days(days_old))
10703 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
10704 sqlx::query("INSERT INTO feeds (url) VALUES (?1) ON CONFLICT(url) DO NOTHING")
10705 .bind("https://f.example/feed")
10706 .execute(pool)
10707 .await
10708 .unwrap();
10709 let feed_id: i64 = sqlx::query_scalar("SELECT id FROM feeds WHERE url = ?1")
10710 .bind("https://f.example/feed")
10711 .fetch_one(pool)
10712 .await
10713 .unwrap();
10714 sqlx::query("INSERT INTO entries (feed_id, guid, url, title, published, fetched_at) VALUES (?1,?2,?3,'t',?4,?4)")
10715 .bind(feed_id).bind(url).bind(url).bind(&when)
10716 .execute(pool).await.unwrap();
10717 sqlx::query_scalar("SELECT id FROM entries WHERE guid = ?1")
10718 .bind(url)
10719 .fetch_one(pool)
10720 .await
10721 .unwrap()
10722 }
10723
10724 async fn mark(pool: &SqlitePool, entry_id: i64, read: i64, starred: i64) {
10725 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')")
10726 .bind(entry_id).bind(read).bind(starred)
10727 .execute(pool).await.unwrap();
10728 }
10729
10730 /// **A STARRED article is never evicted, however old.**
10731 ///
10732 /// The starred view joins `entries`, and `entry_state` cascades on delete,
10733 /// so pruning a starred entry removed it from the starred list entirely —
10734 /// and the content is not recoverable, because a feed serves only its last
10735 /// few dozen items. The PDS keeps the saved RECORD; it has never held the
10736 /// article.
10737 #[tokio::test]
10738 async fn retention_keeps_starred_and_unread_entries() -> anyhow::Result<()> {
10739 let pool = init_url("sqlite::memory:").await?;
10740 let old_read = aged_entry(&pool, "old-read", 30).await;
10741 let old_starred = aged_entry(&pool, "old-starred", 30).await;
10742 let old_unread = aged_entry(&pool, "old-unread", 30).await;
10743 let recent_read = aged_entry(&pool, "recent-read", 1).await;
10744 mark(&pool, old_read, 1, 0).await;
10745 mark(&pool, old_starred, 1, 1).await; // read AND starred
10746 mark(&pool, old_unread, 0, 0).await;
10747 mark(&pool, recent_read, 1, 0).await;
10748
10749 let deleted = prune_old_entries(&pool, 14, 3650, 0).await?;
10750 assert_eq!(deleted, 1, "only the old, read, unstarred entry should go");
10751
10752 let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
10753 .fetch_all(&pool)
10754 .await?;
10755 assert_eq!(left, vec!["old-starred", "old-unread", "recent-read"]);
10756 Ok(())
10757 }
10758
10759 /// An entry nobody has interacted with at all — no `entry_state` row — is
10760 /// still evicted once it ages out. Otherwise the cache never shrinks, since
10761 /// most entries are never opened.
10762 #[tokio::test]
10763 async fn retention_evicts_entries_with_no_reader_state() -> anyhow::Result<()> {
10764 let pool = init_url("sqlite::memory:").await?;
10765 aged_entry(&pool, "untouched-old", 30).await;
10766 aged_entry(&pool, "untouched-new", 1).await;
10767 assert_eq!(prune_old_entries(&pool, 14, 3650, 0).await?, 1);
10768 Ok(())
10769 }
10770
10771 /// **A recently-polled feed is NOT made due again.**
10772 ///
10773 /// `due_feeds` treats NULL as due immediately, so an unbounded nudge from a
10774 /// page handler turned every reload of the starred view into another poll of
10775 /// those feeds — outbound amplification against third-party origins, and one
10776 /// reader monopolising a poll budget that is shared and already the binding
10777 /// constraint on user count.
10778 #[tokio::test]
10779 async fn a_recently_polled_feed_is_not_nudged_again() -> anyhow::Result<()> {
10780 let pool = init_url("sqlite::memory:").await?;
10781 let recent = "2026-01-01T11:59:00Z";
10782 let stale_before = "2026-01-01T11:00:00Z"; // one hour before "now"
10783
10784 sqlx::query("INSERT INTO feeds (url, last_polled, next_poll) VALUES (?1, ?2, ?3)")
10785 .bind("https://fresh.example/f")
10786 .bind(recent)
10787 .bind("2026-01-01T12:59:00Z")
10788 .execute(&pool)
10789 .await?;
10790 // Polled long ago: this one SHOULD be nudged.
10791 sqlx::query("INSERT INTO feeds (url, last_polled, next_poll) VALUES (?1, ?2, ?3)")
10792 .bind("https://stale.example/f")
10793 .bind("2026-01-01T06:00:00Z")
10794 .bind("2026-01-01T07:00:00Z")
10795 .execute(&pool)
10796 .await?;
10797
10798 mark_feed_due(&pool, "https://fresh.example/f", stale_before).await?;
10799 mark_feed_due(&pool, "https://stale.example/f", stale_before).await?;
10800
10801 let fresh: Option<String> =
10802 sqlx::query_scalar("SELECT next_poll FROM feeds WHERE url = 'https://fresh.example/f'")
10803 .fetch_one(&pool)
10804 .await?;
10805 let stale: Option<String> =
10806 sqlx::query_scalar("SELECT next_poll FROM feeds WHERE url = 'https://stale.example/f'")
10807 .fetch_one(&pool)
10808 .await?;
10809
10810 assert!(
10811 fresh.is_some(),
10812 "a feed polled a minute ago was made due again — a reload loop is an \
10813 amplification vector"
10814 );
10815 assert!(stale.is_none(), "a long-unpolled feed should be nudged");
10816 Ok(())
10817 }
10818
10819 /// A feed that has never been polled is always nudgeable — there is no
10820 /// recent fetch to argue it would be wasted.
10821 #[tokio::test]
10822 async fn a_never_polled_feed_is_nudged() -> anyhow::Result<()> {
10823 let pool = init_url("sqlite::memory:").await?;
10824 sqlx::query("INSERT INTO feeds (url, last_polled, next_poll) VALUES (?1, NULL, ?2)")
10825 .bind("https://new.example/f")
10826 .bind("2026-01-01T12:59:00Z")
10827 .execute(&pool)
10828 .await?;
10829 mark_feed_due(&pool, "https://new.example/f", "2026-01-01T11:00:00Z").await?;
10830 let next: Option<String> =
10831 sqlx::query_scalar("SELECT next_poll FROM feeds WHERE url = 'https://new.example/f'")
10832 .fetch_one(&pool)
10833 .await?;
10834 assert!(next.is_none());
10835 Ok(())
10836 }
10837
10838 /// **The hard ceiling is the bound that sparing would otherwise remove.**
10839 ///
10840 /// "Mark unread" is a one-click control and `entries` is shared across every
10841 /// reader, so an unbounded `read = 0` exception lets one person pin rows
10842 /// permanently — and since the poller stops entirely above
10843 /// `db_size_watermark_bytes` with this DELETE as its only release valve,
10844 /// those pins could stop polling for everyone.
10845 #[tokio::test]
10846 async fn the_hard_ceiling_evicts_even_starred_and_unread() -> anyhow::Result<()> {
10847 let pool = init_url("sqlite::memory:").await?;
10848 let ancient_starred = aged_entry(&pool, "ancient-starred", 400).await;
10849 let ancient_unread = aged_entry(&pool, "ancient-unread", 400).await;
10850 let recent_starred = aged_entry(&pool, "recent-starred", 30).await;
10851 mark(&pool, ancient_starred, 1, 1).await;
10852 mark(&pool, ancient_unread, 0, 0).await;
10853 mark(&pool, recent_starred, 1, 1).await;
10854
10855 // 14-day soft window, 180-day hard ceiling.
10856 prune_old_entries(&pool, 14, 180, 0).await?;
10857
10858 let left: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries ORDER BY guid")
10859 .fetch_all(&pool)
10860 .await?;
10861 assert_eq!(
10862 left,
10863 vec!["recent-starred"],
10864 "past the ceiling nothing is pinned — otherwise one reader can stall the poller \
10865 for every reader"
10866 );
10867 Ok(())
10868 }
10869
10870 /// The per-feed trim spares starred entries too. It was fixed in the
10871 /// retention sweep and NOT here, which left the documented guarantee false —
10872 /// and this path runs on every poll of every feed rather than daily.
10873 #[tokio::test]
10874 async fn the_per_feed_trim_spares_starred_entries() -> anyhow::Result<()> {
10875 let pool = init_url("sqlite::memory:").await?;
10876 let old_starred = aged_entry(&pool, "old-starred", 5).await;
10877 mark(&pool, old_starred, 1, 1).await;
10878 for i in 0..5 {
10879 aged_entry(&pool, &format!("filler-{i}"), 1).await;
10880 }
10881 let feed_id: i64 = sqlx::query_scalar("SELECT id FROM feeds LIMIT 1")
10882 .fetch_one(&pool)
10883 .await?;
10884
10885 // Trim hard enough that the older starred entry would be cut. The trim
10886 // runs inside `insert_entries`, so drive it the way production does.
10887 insert_entries(&pool, feed_id, &[], 2).await?;
10888
10889 let left: Vec<String> =
10890 sqlx::query_scalar("SELECT guid FROM entries WHERE guid = 'old-starred'")
10891 .fetch_all(&pool)
10892 .await?;
10893 assert_eq!(
10894 left,
10895 vec!["old-starred"],
10896 "the per-feed trim evicted a starred entry"
10897 );
10898 Ok(())
10899 }
10900
10901 /// **When more entries are starred than the cap, the NEWEST starred ones
10902 /// are spared.** The sparing subquery orders by date and takes `cap`; the
10903 /// existing tests seed one starred row (fewer than the cap, so the order
10904 /// never chooses) or assert only a count. With `DESC` flipped to `ASC` the
10905 /// suite stayed green — and in production the trim would spare the OLDEST
10906 /// starred articles and evict the newest, on every poll of every feed.
10907 #[tokio::test]
10908 async fn the_trim_spares_the_newest_starred_entries_when_over_cap() -> anyhow::Result<()> {
10909 let pool = init_url("sqlite::memory:").await?;
10910 // Five starred entries, one per day, cap of two: only the two newest
10911 // may survive.
10912 let mut ids = Vec::new();
10913 for days_old in 1..=5 {
10914 let id = aged_entry(&pool, &format!("starred-{days_old}"), days_old).await;
10915 mark(&pool, id, 1, 1).await;
10916 ids.push((days_old, id));
10917 }
10918 let feed_id: i64 = sqlx::query_scalar("SELECT feed_id FROM entries WHERE id = ?1")
10919 .bind(ids[0].1)
10920 .fetch_one(&pool)
10921 .await?;
10922 insert_entries(&pool, feed_id, &[], 2).await?;
10923
10924 let mut survivors: Vec<String> = sqlx::query_scalar("SELECT guid FROM entries")
10925 .fetch_all(&pool)
10926 .await?;
10927 survivors.sort();
10928 assert_eq!(
10929 survivors,
10930 vec!["starred-1".to_string(), "starred-2".to_string()],
10931 "the trim spared the wrong starred entries"
10932 );
10933 Ok(())
10934 }
10935}