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}