Skip to main content

feather_reader/
readstate.rs

1//! Read-state flushing — turning dirty local cursors into `readState` records
2//! in the user's own PDS.
3//!
4//! **In the LIB, not the binary's `scheduler`, because two callers need it.**
5//! The background flusher is one. Sign-out is the other: it must flush before
6//! revoking the session, or the reads are stranded with nothing able to send
7//! them (#117). `scheduler` keeps the *scheduling* — the interval loop and the
8//! DID selection; this module owns the domain logic.
9
10use std::collections::BTreeMap;
11
12use tracing::{info, warn};
13
14use crate::lexicon::ReadState;
15use crate::store::{self, ReadCursor};
16use crate::AppState;
17
18/// Flush a single DID's dirty cursors in one batched `applyWrites`, then clear
19/// the `dirty` flag for the cursors that were included.
20pub async fn flush_did(state: &AppState, did: &str) -> anyhow::Result<()> {
21    let cursors = store::dirty_cursors(&state.db, did).await?;
22    if cursors.is_empty() {
23        return Ok(());
24    }
25
26    // Build (rkey, ReadState) pairs, deduping on rkey so two rows that hash to
27    // the same feed-key don't produce two ops in one batch (applyWrites rejects
28    // duplicate writes to the same key). Deterministic order for stable batches.
29    let mut batch: BTreeMap<String, (ReadState, ReadCursor)> = BTreeMap::new();
30    for cursor in cursors {
31        // **Compact before capping.**
32        //
33        // `read_ids` grows one id per article read and is bounded only by
34        // `max_entries_per_feed` (2000), while `cap` below truncates the record
35        // at `ReadState::MAX_IDS` (1000) keeping the TAIL. Past 1000 read
36        // articles in one feed the oldest read-state silently stopped syncing,
37        // and those articles came back UNREAD in every other atproto reader —
38        // the one thing the shared lexicon exists to prevent.
39        //
40        // `store::compact_cursor` folds the covered ids into the `read_through`
41        // high-water-mark, which is the field that exists for exactly this and
42        // was never being computed. Done here rather than on every mark-read
43        // because this is the moment the size actually matters, and it is per
44        // dirty cursor per flush rather than per click.
45        let cursor = compact_if_large(state, did, cursor).await;
46        let rkey = read_state_rkey(&cursor.feed_url);
47        let record = read_state_record(&cursor);
48        batch.insert(rkey, (record, cursor));
49    }
50
51    // Each op carries whether its PDS record already exists: a not-yet-created
52    // cursor becomes an applyWrites#create (not an #update, which would error and,
53    // since applyWrites is atomic-per-repo, drop the whole DID batch on a feed's
54    // first flush). All create + update ops ride ONE batch.
55    let ops: Vec<(String, ReadState, bool)> = batch
56        .iter()
57        .map(|(rkey, (record, cursor))| (rkey.clone(), record.clone(), cursor.pds_created))
58        .collect();
59
60    // ONE applyWrites round-trip for all of this DID's dirty feeds.
61    state.repo().flush_read_states(did, &ops).await?;
62
63    // Success — for each flushed cursor: mark its PDS record as created (so future
64    // flushes emit an update), then clear `dirty` but ONLY if its `updated_at`
65    // still matches the snapshot we just flushed. A mark-read that landed DURING
66    // the in-flight PDS write bumped `updated_at` and re-dirtied the row; the
67    // conditional clear leaves that row dirty so its new reads re-flush next
68    // round instead of being silently dropped.
69    let flushed = ops.len();
70    for (_rkey, (_record, cursor)) in batch {
71        // Flip the created flag first: the record now exists in the PDS regardless
72        // of whether the dirty-clear below is a no-op due to a concurrent bump.
73        if !cursor.pds_created {
74            if let Err(err) = store::mark_cursor_pds_created(&state.db, did, &cursor.feed_url).await
75            {
76                warn!(%did, feed = %cursor.feed_url, %err, "failed to mark cursor pds_created");
77            }
78        }
79        if let Err(err) =
80            store::clear_cursor_dirty(&state.db, did, &cursor.feed_url, &cursor.updated_at).await
81        {
82            // The PDS write already landed; a failure to clear the local flag
83            // just means we harmlessly re-flush this cursor next round.
84            warn!(%did, feed = %cursor.feed_url, %err, "failed to clear cursor dirty flag");
85        }
86    }
87
88    info!(%did, feeds = flushed, "read-state flusher: flushed dirty cursors");
89    Ok(())
90}
91
92/// `read_ids` length at which a cursor is compacted before flushing.
93///
94/// Half of [`ReadState::MAX_IDS`], so compaction happens well before the cap
95/// truncates anything, and the common cursor — a handful of ids — never pays for
96/// the two extra queries.
97const COMPACT_READ_IDS_THRESHOLD: usize = ReadState::MAX_IDS / 2;
98
99/// Fold covered ids into the `read_through` water-mark when the exception set has
100/// grown enough to matter, and return the rewritten cursor.
101///
102/// On ANY failure this returns the cursor it was given. Flushing an uncompacted
103/// cursor is the behaviour that shipped for months — a compaction problem must
104/// not become a read-state-sync problem.
105async fn compact_if_large(state: &AppState, did: &str, cursor: ReadCursor) -> ReadCursor {
106    if parse_id_array(&cursor.read_ids).len() < COMPACT_READ_IDS_THRESHOLD {
107        return cursor;
108    }
109    match store::compact_cursor(&state.db, did, &cursor.feed_url).await {
110        Ok(Some(watermark)) => {
111            // Re-read: `compact_cursor` rewrote the row, and the flusher's
112            // conditional dirty-clear compares `updated_at` against the version
113            // it flushed. Carrying the pre-compaction snapshot forward would
114            // clear a flag for a row that has since changed.
115            match store::get_cursor(&state.db, did, &cursor.feed_url).await {
116                Ok(Some(fresh)) => {
117                    info!(
118                        %did,
119                        feed = %cursor.feed_url,
120                        %watermark,
121                        before = parse_id_array(&cursor.read_ids).len(),
122                        after = parse_id_array(&fresh.read_ids).len(),
123                        "read-state compacted into readThrough"
124                    );
125                    fresh
126                }
127                Ok(None) => cursor,
128                Err(err) => {
129                    warn!(%err, %did, feed = %cursor.feed_url, "could not re-read a compacted cursor");
130                    cursor
131                }
132            }
133        }
134        Ok(None) => cursor,
135        Err(err) => {
136            warn!(%err, %did, feed = %cursor.feed_url, "read-state compaction failed; flushing uncompacted");
137            cursor
138        }
139    }
140}
141
142/// Turn a local [`ReadCursor`] row into the PDS [`ReadState`] lexicon record.
143///
144/// The store keeps `read_ids` / `unread_ids` as JSON arrays of ids; the lexicon
145/// wants string arrays. `read_through` is optional both locally AND in the
146/// record: when the cursor has no local high-water-mark we pass `None` so the
147/// record OMITS `readThrough` entirely. This is the conservative behaviour —
148/// `readThrough` is a "everything seen/published `<=` this is read" water-mark,
149/// so synthesizing a flush-time (`≈ now`) value for a cursor that has none would
150/// assert the whole unread backlog is read. With `None` only the explicit
151/// `read_ids` mark entries read. Both id-sets are capped at [`ReadState::MAX_IDS`]
152/// to respect the lexicon bound.
153fn read_state_record(cursor: &ReadCursor) -> ReadState {
154    let read_ids = parse_id_array(&cursor.read_ids);
155    let unread_ids = parse_id_array(&cursor.unread_ids);
156
157    // Do NOT synthesize a water-mark from `updated_at`: an unset local
158    // `read_through` means "no high-water-mark", which the record represents by
159    // omitting `readThrough` (None), not by back-dating it to flush time.
160    let mut record = ReadState::new(
161        &cursor.feed_url,
162        cursor.read_through.clone(),
163        &cursor.updated_at,
164    );
165    record.read_ids = cap(read_ids, ReadState::MAX_IDS);
166    record.unread_ids = cap(unread_ids, ReadState::MAX_IDS);
167    record
168}
169
170/// Parse a stored JSON id-array into `Vec<String>`, tolerating both string and
171/// numeric ids (the store keeps entry ids). A malformed/empty value yields an
172/// empty set rather than an error — read-state must never fail to flush over a
173/// cosmetic parse issue.
174fn parse_id_array(raw: &str) -> Vec<String> {
175    if raw.trim().is_empty() {
176        return Vec::new();
177    }
178    match serde_json::from_str::<Vec<serde_json::Value>>(raw) {
179        Ok(vals) => vals
180            .into_iter()
181            .map(|v| match v {
182                serde_json::Value::String(s) => s,
183                other => other.to_string(),
184            })
185            .collect(),
186        Err(err) => {
187            warn!(%err, raw, "read-state flusher: unparseable id array; treating as empty");
188            Vec::new()
189        }
190    }
191}
192
193/// Truncate a set to `max`, keeping the most recent (tail) ids — the lexicon's
194/// hard cap, and the LAST line of defence rather than the only one.
195///
196/// This used to be the only one, under the stated assumption that "the exception
197/// sets are expected to stay well under the cap in normal use". Against a 2000
198/// entry per-feed ceiling and one id per article read, that did not hold: past
199/// 1000 read articles in a feed this silently dropped the oldest read-state, and
200/// those articles came back UNREAD in every other atproto reader.
201///
202/// [`compact_if_large`] now folds covered ids into `read_through` before a cursor
203/// gets here, so reaching this truncation means compaction could not advance the
204/// water-mark — which happens only when the feed's oldest entry is genuinely
205/// unread. Losing the tail is still wrong in that case, but it is now a rare
206/// shape rather than the ordinary consequence of reading a busy feed.
207fn cap(mut ids: Vec<String>, max: usize) -> Vec<String> {
208    if ids.len() > max {
209        let drop = ids.len() - max;
210        warn!(
211            dropped = drop,
212            kept = max,
213            "read-state id set exceeded the lexicon cap even after compaction; \
214             the oldest marks will not sync"
215        );
216        ids.drain(0..drop);
217    }
218    ids
219}
220
221/// Derive the deterministic, stable rkey for a feed's read-state record from its
222/// URL, so there is exactly **one record per feed** (a fixed key, not a fresh tid
223/// per flush).
224///
225/// atproto record keys must match `[A-Za-z0-9._~:-]{1,512}` (and not be `.`/`..`).
226/// A lowercase-hex FNV-1a-64 digest of the feed URL satisfies that, is stable
227/// across restarts and instances, and collides only on genuine hash collision
228/// (astronomically unlikely at feed scale; the flusher additionally dedups by
229/// rkey within a batch as a belt-and-braces guard).
230///
231/// The stable rkey is what makes create-then-update work: a feed's FIRST flush
232/// emits an `applyWrites#create` at this key (tracked by `read_cursor.pds_created`)
233/// and every subsequent flush an `#update` at the same key, so there is exactly
234/// one record per feed and the first flush never fails on a missing record.
235pub fn read_state_rkey(feed_url: &str) -> String {
236    format!("rs-{:016x}", fnv1a_64(feed_url.as_bytes()))
237}
238
239/// FNV-1a 64-bit — a tiny, dependency-free stable hash for the feed-key.
240///
241/// `pub` because `scheduler::jittered` seeds its per-feed jitter from the same
242/// hash. Two unrelated uses of one generic utility; exported rather than copied,
243/// since two drifting implementations of a stable key hash would be worse than
244/// the slightly odd home.
245pub fn fnv1a_64(bytes: &[u8]) -> u64 {
246    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
247    const PRIME: u64 = 0x0000_0100_0000_01b3;
248    let mut hash = OFFSET;
249    for &b in bytes {
250        hash ^= b as u64;
251        hash = hash.wrapping_mul(PRIME);
252    }
253    hash
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn rkey_is_stable_and_valid() {
262        let a = read_state_rkey("https://example.com/feed.xml");
263        let b = read_state_rkey("https://example.com/feed.xml");
264        assert_eq!(a, b, "rkey must be deterministic");
265        assert_ne!(a, read_state_rkey("https://other.example/feed.xml"));
266        // Valid atproto rkey: charset, length and the reserved names, as the
267        // one shared rule states them.
268        assert!(crate::atproto::is_valid_rkey(&a), "{a:?}");
269    }
270    #[test]
271    fn parse_id_array_tolerates_shapes() {
272        assert_eq!(parse_id_array(""), Vec::<String>::new());
273        assert_eq!(parse_id_array("[]"), Vec::<String>::new());
274        assert_eq!(parse_id_array(r#"["a","b"]"#), vec!["a", "b"]);
275        assert_eq!(parse_id_array("[1,2,3]"), vec!["1", "2", "3"]);
276        assert_eq!(parse_id_array("not json"), Vec::<String>::new());
277    }
278    #[test]
279    fn cap_keeps_tail_within_bound() {
280        let ids: Vec<String> = (0..10).map(|i| i.to_string()).collect();
281        let capped = cap(ids, 3);
282        assert_eq!(capped, vec!["7", "8", "9"]);
283    }
284    /// **The cap is APPLIED, not just correct.** `cap` had its own unit test
285    /// and every record-building test used 1–3 ids, so `read_state_record`
286    /// could stop calling it with the suite green — and the flusher would
287    /// publish id arrays past the lexicon's bound, the PDS would reject the
288    /// atomic batch, and every feed's read-state would stop syncing.
289    #[test]
290    fn read_state_record_applies_the_id_cap() {
291        let ids: Vec<String> = (0..ReadState::MAX_IDS + 5).map(|i| i.to_string()).collect();
292        let json = serde_json::to_string(&ids).unwrap();
293        let cursor = crate::store::ReadCursor {
294            did: "did:plc:x".into(),
295            feed_url: "https://example.com/feed.xml".into(),
296            read_through: None,
297            read_ids: json.clone(),
298            unread_ids: json,
299            dirty: true,
300            pds_created: false,
301            updated_at: "2026-07-12T00:00:00Z".into(),
302        };
303        let rec = read_state_record(&cursor);
304        assert_eq!(
305            rec.read_ids.len(),
306            ReadState::MAX_IDS,
307            "read_ids not capped"
308        );
309        assert_eq!(
310            rec.unread_ids.len(),
311            ReadState::MAX_IDS,
312            "unread_ids not capped"
313        );
314    }
315
316    #[test]
317    fn record_maps_cursor_fields() {
318        let cursor = ReadCursor {
319            did: "did:plc:abc".into(),
320            feed_url: "https://example.com/feed.xml".into(),
321            read_through: Some("2026-07-12T00:00:00Z".into()),
322            read_ids: r#"["10","11"]"#.into(),
323            unread_ids: "[]".into(),
324            dirty: true,
325            pds_created: false,
326            updated_at: "2026-07-12T01:00:00Z".into(),
327        };
328        let rec = read_state_record(&cursor);
329        assert_eq!(rec.feed_url, "https://example.com/feed.xml");
330        assert_eq!(rec.read_through.as_deref(), Some("2026-07-12T00:00:00Z"));
331        assert_eq!(rec.read_ids, vec!["10", "11"]);
332        assert!(rec.unread_ids.is_empty());
333        assert_eq!(rec.updated_at, "2026-07-12T01:00:00Z");
334    }
335    #[test]
336    fn read_through_omitted_when_local_unset() {
337        // A cursor with no local high-water-mark must NOT synthesize one from
338        // `updated_at` (≈ now) — doing so would mark the whole backlog read. The
339        // record omits `readThrough` (None) so only explicit read_ids apply.
340        let cursor = ReadCursor {
341            did: "did:plc:abc".into(),
342            feed_url: "https://example.com/feed.xml".into(),
343            read_through: None,
344            read_ids: r#"["42"]"#.into(),
345            unread_ids: "[]".into(),
346            dirty: true,
347            pds_created: false,
348            updated_at: "2026-07-12T01:00:00Z".into(),
349        };
350        let rec = read_state_record(&cursor);
351        assert_eq!(
352            rec.read_through, None,
353            "no local water-mark => readThrough absent (backlog not implicitly read)"
354        );
355        // The explicit read_ids still carry through.
356        assert_eq!(rec.read_ids, vec!["42"]);
357        // Serialized form must not carry a readThrough field at all.
358        let json = serde_json::to_value(&rec).expect("serialize");
359        assert!(json.get("readThrough").is_none());
360    }
361    #[test]
362    fn read_through_present_when_local_high_water_mark_exists() {
363        // A real high-water-mark IS written through unchanged.
364        let cursor = ReadCursor {
365            did: "did:plc:abc".into(),
366            feed_url: "https://example.com/feed.xml".into(),
367            read_through: Some("2026-07-11T00:00:00Z".into()),
368            read_ids: "[]".into(),
369            unread_ids: "[]".into(),
370            dirty: true,
371            pds_created: false,
372            updated_at: "2026-07-12T01:00:00Z".into(),
373        };
374        let rec = read_state_record(&cursor);
375        assert_eq!(rec.read_through.as_deref(), Some("2026-07-11T00:00:00Z"));
376    }
377    #[test]
378    fn flush_with_only_read_ids_sets_no_read_through() {
379        // The core F1 guarantee: a flush whose cursor carries only explicit
380        // read_ids (and no water-mark) emits a record WITHOUT readThrough, so the
381        // user's PDS never asserts the backlog is read.
382        let cursor = ReadCursor {
383            did: "did:plc:abc".into(),
384            feed_url: "https://example.com/feed.xml".into(),
385            read_through: None,
386            read_ids: r#"["100","101","102"]"#.into(),
387            unread_ids: "[]".into(),
388            dirty: true,
389            pds_created: false,
390            updated_at: "2026-07-12T02:00:00Z".into(),
391        };
392        let rec = read_state_record(&cursor);
393        assert_eq!(rec.read_through, None);
394        assert_eq!(rec.read_ids, vec!["100", "101", "102"]);
395        let json = serde_json::to_value(&rec).expect("serialize");
396        assert!(json.get("readThrough").is_none());
397        assert_eq!(json["readIds"], serde_json::json!(["100", "101", "102"]));
398    }
399}