use std::collections::{BTreeMap, HashSet};
use tracing::{info, warn};
use crate::atproto::AtProtoError;
use crate::lexicon::ReadState;
use crate::store::{self, ReadCursor};
use crate::AppState;
pub async fn flush_did(state: &AppState, did: &str) -> anyhow::Result<()> {
let cursors = store::dirty_cursors(&state.db, did).await?;
if cursors.is_empty() {
return Ok(());
}
let mut batch: Batch = BTreeMap::new();
for cursor in cursors {
let cursor = compact_if_large(state, did, cursor).await;
let rkey = read_state_rkey(&cursor.feed_url);
let record = read_state_record(&cursor);
batch.insert(rkey, (record, cursor));
}
let ops = batch_ops(&batch);
if let Err(err) = state.repo().flush_read_states(did, &ops).await {
settle_landed(state, did, &mut batch, &err).await;
if !may_be_existence_mismatch(&err) {
return Err(err);
}
let corrected = reconcile_pds_created(state, did, &mut batch).await;
match corrected {
Ok(0) => return Err(err),
Ok(fixed) => {
info!(%did, fixed, "read-state flusher: pds_created disagreed with the PDS; reconciled, retrying once");
}
Err(list_err) => {
warn!(%did, err = %list_err, "read-state flusher: could not list readState records to reconcile");
return Err(err);
}
}
let ops = batch_ops(&batch);
if let Err(retry_err) = state.repo().flush_read_states(did, &ops).await {
settle_landed(state, did, &mut batch, &retry_err).await;
let context =
format!("read-state flush failed again after reconciling pds_created: {retry_err}");
return Err(retry_err.context(context));
}
}
let flushed = batch.len();
for (_rkey, (_record, cursor)) in batch {
settle(state, did, &cursor).await;
}
info!(%did, feeds = flushed, "read-state flusher: flushed dirty cursors");
Ok(())
}
async fn settle(state: &AppState, did: &str, cursor: &ReadCursor) {
if !cursor.pds_created {
if let Err(err) = store::mark_cursor_pds_created(&state.db, did, &cursor.feed_url).await {
warn!(%did, feed = %cursor.feed_url, %err, "failed to mark cursor pds_created");
}
}
if let Err(err) =
store::clear_cursor_dirty(&state.db, did, &cursor.feed_url, &cursor.updated_at).await
{
warn!(%did, feed = %cursor.feed_url, %err, "failed to clear cursor dirty flag");
}
}
async fn settle_landed(state: &AppState, did: &str, batch: &mut Batch, err: &anyhow::Error) {
let landed = crate::atproto::ApplyWritesIncomplete::of(err).map_or(0, |p| p.landed);
let keys: Vec<String> = batch.keys().take(landed).cloned().collect();
for key in keys {
if let Some((_record, cursor)) = batch.remove(&key) {
settle(state, did, &cursor).await;
}
}
if landed > 0 {
info!(%did, landed, "read-state flusher: a split flush failed part-way; settled what landed");
}
}
type Batch = BTreeMap<String, (ReadState, ReadCursor)>;
fn batch_ops(batch: &Batch) -> Vec<(String, ReadState, bool)> {
batch
.iter()
.map(|(rkey, (record, cursor))| (rkey.clone(), record.clone(), cursor.pds_created))
.collect()
}
fn may_be_existence_mismatch(err: &anyhow::Error) -> bool {
let wrapped = crate::atproto::ApplyWritesIncomplete::of(err).map(|p| p.cause().chain());
err.chain()
.chain(wrapped.into_iter().flatten())
.any(|cause| match cause.downcast_ref::<AtProtoError>() {
Some(AtProtoError::Xrpc { status, error, .. }) => match status.as_u16() {
500 => error == "InternalServerError",
400 => matches!(
error.as_str(),
"InvalidRequest" | "InvalidSwap" | "RecordNotFound"
),
409 => true,
_ => false,
},
_ => false,
})
}
async fn reconcile_pds_created(
state: &AppState,
did: &str,
batch: &mut Batch,
) -> anyhow::Result<usize> {
let existing: HashSet<String> = state
.repo()
.list_all_records(did, crate::lexicon::nsid::READ_STATE)
.await?
.iter()
.filter_map(|record| record.rkey().map(str::to_string))
.collect();
let mut fixed = 0;
for (rkey, (_record, cursor)) in batch.iter_mut() {
let exists = existing.contains(rkey);
if cursor.pds_created == exists {
continue;
}
cursor.pds_created = exists;
fixed += 1;
if let Err(err) =
store::set_cursor_pds_created(&state.db, did, &cursor.feed_url, exists).await
{
warn!(%did, feed = %cursor.feed_url, %err, "failed to record reconciled pds_created");
}
}
Ok(fixed)
}
const COMPACT_READ_IDS_THRESHOLD: usize = ReadState::MAX_IDS / 2;
async fn compact_if_large(state: &AppState, did: &str, cursor: ReadCursor) -> ReadCursor {
if parse_id_array(&cursor.read_ids).len() < COMPACT_READ_IDS_THRESHOLD {
return cursor;
}
match store::compact_cursor(&state.db, did, &cursor.feed_url).await {
Ok(Some(watermark)) => {
match store::get_cursor(&state.db, did, &cursor.feed_url).await {
Ok(Some(fresh)) => {
info!(
%did,
feed = %cursor.feed_url,
%watermark,
before = parse_id_array(&cursor.read_ids).len(),
after = parse_id_array(&fresh.read_ids).len(),
"read-state compacted into readThrough"
);
fresh
}
Ok(None) => cursor,
Err(err) => {
warn!(%err, %did, feed = %cursor.feed_url, "could not re-read a compacted cursor");
cursor
}
}
}
Ok(None) => cursor,
Err(err) => {
warn!(%err, %did, feed = %cursor.feed_url, "read-state compaction failed; flushing uncompacted");
cursor
}
}
}
fn read_state_record(cursor: &ReadCursor) -> ReadState {
let read_ids = parse_id_array(&cursor.read_ids);
let unread_ids = parse_id_array(&cursor.unread_ids);
let mut record = ReadState::new(
&cursor.feed_url,
cursor.read_through.clone(),
&cursor.updated_at,
);
record.read_ids = cap(read_ids, ReadState::MAX_IDS);
record.unread_ids = cap(unread_ids, ReadState::MAX_IDS);
record
}
fn parse_id_array(raw: &str) -> Vec<String> {
if raw.trim().is_empty() {
return Vec::new();
}
match serde_json::from_str::<Vec<serde_json::Value>>(raw) {
Ok(vals) => vals
.into_iter()
.map(|v| match v {
serde_json::Value::String(s) => s,
other => other.to_string(),
})
.collect(),
Err(err) => {
warn!(%err, raw, "read-state flusher: unparseable id array; treating as empty");
Vec::new()
}
}
}
fn cap(mut ids: Vec<String>, max: usize) -> Vec<String> {
if ids.len() > max {
let drop = ids.len() - max;
warn!(
dropped = drop,
kept = max,
"read-state id set exceeded the lexicon cap even after compaction; \
the oldest marks will not sync"
);
ids.drain(0..drop);
}
ids
}
pub fn read_state_rkey(feed_url: &str) -> String {
format!("rs-{:016x}", fnv1a_64(feed_url.as_bytes()))
}
pub fn fnv1a_64(bytes: &[u8]) -> u64 {
const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = OFFSET;
for &b in bytes {
hash ^= b as u64;
hash = hash.wrapping_mul(PRIME);
}
hash
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
#[test]
fn rkey_is_stable_and_valid() {
let a = read_state_rkey("https://example.com/feed.xml");
let b = read_state_rkey("https://example.com/feed.xml");
assert_eq!(a, b, "rkey must be deterministic");
assert_ne!(a, read_state_rkey("https://other.example/feed.xml"));
assert!(crate::atproto::is_valid_rkey(&a), "{a:?}");
}
#[test]
fn parse_id_array_tolerates_shapes() {
assert_eq!(parse_id_array(""), Vec::<String>::new());
assert_eq!(parse_id_array("[]"), Vec::<String>::new());
assert_eq!(parse_id_array(r#"["a","b"]"#), vec!["a", "b"]);
assert_eq!(parse_id_array("[1,2,3]"), vec!["1", "2", "3"]);
assert_eq!(parse_id_array("not json"), Vec::<String>::new());
}
#[test]
fn cap_keeps_tail_within_bound() {
let ids: Vec<String> = (0..10).map(|i| i.to_string()).collect();
let capped = cap(ids, 3);
assert_eq!(capped, vec!["7", "8", "9"]);
}
#[test]
fn read_state_record_applies_the_id_cap() {
let ids: Vec<String> = (0..ReadState::MAX_IDS + 5).map(|i| i.to_string()).collect();
let json = serde_json::to_string(&ids).unwrap();
let cursor = crate::store::ReadCursor {
did: "did:plc:x".into(),
feed_url: "https://example.com/feed.xml".into(),
read_through: None,
read_ids: json.clone(),
unread_ids: json,
dirty: true,
pds_created: false,
updated_at: "2026-07-12T00:00:00Z".into(),
};
let rec = read_state_record(&cursor);
assert_eq!(
rec.read_ids.len(),
ReadState::MAX_IDS,
"read_ids not capped"
);
assert_eq!(
rec.unread_ids.len(),
ReadState::MAX_IDS,
"unread_ids not capped"
);
}
#[test]
fn record_maps_cursor_fields() {
let cursor = ReadCursor {
did: "did:plc:abc".into(),
feed_url: "https://example.com/feed.xml".into(),
read_through: Some("2026-07-12T00:00:00Z".into()),
read_ids: r#"["10","11"]"#.into(),
unread_ids: "[]".into(),
dirty: true,
pds_created: false,
updated_at: "2026-07-12T01:00:00Z".into(),
};
let rec = read_state_record(&cursor);
assert_eq!(rec.feed_url, "https://example.com/feed.xml");
assert_eq!(rec.read_through.as_deref(), Some("2026-07-12T00:00:00Z"));
assert_eq!(rec.read_ids, vec!["10", "11"]);
assert!(rec.unread_ids.is_empty());
assert_eq!(rec.updated_at, "2026-07-12T01:00:00Z");
}
#[test]
fn read_through_omitted_when_local_unset() {
let cursor = ReadCursor {
did: "did:plc:abc".into(),
feed_url: "https://example.com/feed.xml".into(),
read_through: None,
read_ids: r#"["42"]"#.into(),
unread_ids: "[]".into(),
dirty: true,
pds_created: false,
updated_at: "2026-07-12T01:00:00Z".into(),
};
let rec = read_state_record(&cursor);
assert_eq!(
rec.read_through, None,
"no local water-mark => readThrough absent (backlog not implicitly read)"
);
assert_eq!(rec.read_ids, vec!["42"]);
let json = serde_json::to_value(&rec).expect("serialize");
assert!(json.get("readThrough").is_none());
}
#[test]
fn read_through_present_when_local_high_water_mark_exists() {
let cursor = ReadCursor {
did: "did:plc:abc".into(),
feed_url: "https://example.com/feed.xml".into(),
read_through: Some("2026-07-11T00:00:00Z".into()),
read_ids: "[]".into(),
unread_ids: "[]".into(),
dirty: true,
pds_created: false,
updated_at: "2026-07-12T01:00:00Z".into(),
};
let rec = read_state_record(&cursor);
assert_eq!(rec.read_through.as_deref(), Some("2026-07-11T00:00:00Z"));
}
#[test]
fn flush_with_only_read_ids_sets_no_read_through() {
let cursor = ReadCursor {
did: "did:plc:abc".into(),
feed_url: "https://example.com/feed.xml".into(),
read_through: None,
read_ids: r#"["100","101","102"]"#.into(),
unread_ids: "[]".into(),
dirty: true,
pds_created: false,
updated_at: "2026-07-12T02:00:00Z".into(),
};
let rec = read_state_record(&cursor);
assert_eq!(rec.read_through, None);
assert_eq!(rec.read_ids, vec!["100", "101", "102"]);
let json = serde_json::to_value(&rec).expect("serialize");
assert!(json.get("readThrough").is_none());
assert_eq!(json["readIds"], serde_json::json!(["100", "101", "102"]));
}
use std::sync::{Arc, Mutex};
use crate::metrics::Backend;
pub(crate) const DID: &str = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
#[derive(Clone, Copy, Debug)]
struct Fail {
status: u16,
error: &'static str,
}
const MISMATCH: Fail = Fail {
status: 500,
error: "InternalServerError",
};
#[derive(Default)]
pub(crate) struct FakeRepo {
pub(crate) records: BTreeMap<String, serde_json::Value>,
pub(crate) apply_calls: usize,
pub(crate) list_walks: usize,
always_fail: Option<Fail>,
partial_then_503: Option<usize>,
list_fails: bool,
pub(crate) drop_call: Option<usize>,
}
const HANG_UP: Fail = Fail {
status: 0,
error: "connection dropped",
};
impl FakeRepo {
fn op(w: &serde_json::Value) -> (String, String, serde_json::Value) {
let action = w["action"].as_str().map(str::to_string).unwrap_or_else(|| {
w["$type"]
.as_str()
.and_then(|t| t.rsplit('#').next())
.unwrap_or_default()
.to_string()
});
assert_eq!(w["collection"], crate::lexicon::nsid::READ_STATE);
let rkey = w["rkey"].as_str().expect("readState ops carry an rkey");
(action, rkey.to_string(), w["value"].clone())
}
fn apply(&mut self, writes: &[serde_json::Value]) -> Result<(), Fail> {
self.apply_calls += 1;
if self.drop_call == Some(self.apply_calls) {
return Err(HANG_UP);
}
if writes.len() > crate::atproto::APPLY_WRITES_MAX_OPS {
return Err(Fail {
status: 400,
error: "InvalidRequest",
});
}
if let Some(fail) = self.always_fail {
return Err(fail);
}
if let Some(landed) = self.partial_then_503.take() {
for w in writes.iter().take(landed) {
let (_, rkey, value) = Self::op(w);
self.records.insert(rkey, value);
}
return Err(Fail {
status: 503,
error: "PartitionUnavailable",
});
}
for w in writes {
let (action, rkey, _) = Self::op(w);
let exists = self.records.contains_key(&rkey);
if (action == "create" && exists) || (action == "update" && !exists) {
return Err(MISMATCH);
}
}
for w in writes {
let (_, rkey, value) = Self::op(w);
self.records.insert(rkey, value);
}
Ok(())
}
fn page(&mut self, limit: Option<usize>, cursor: Option<&str>) -> serde_json::Value {
if cursor.is_none() {
self.list_walks += 1;
}
let limit = limit.unwrap_or(50);
let after: Vec<_> = self
.records
.iter()
.filter(|(rkey, _)| cursor.is_none_or(|c| rkey.as_str() > c))
.collect();
let page: Vec<_> = after.iter().take(limit).collect();
let records: Vec<serde_json::Value> = page
.iter()
.map(|(rkey, value)| {
serde_json::json!({
"uri": format!("at://{DID}/{}/{rkey}", crate::lexicon::nsid::READ_STATE),
"cid": "bafyreigh2akiscaildc",
"value": value,
})
})
.collect();
let mut body = serde_json::json!({ "records": records });
if after.len() > limit {
body["cursor"] = serde_json::json!(page.last().unwrap().0);
}
body
}
}
fn hang_up() -> axum::response::Response {
std::panic::resume_unwind(Box::new("fake PDS hung up"))
}
async fn serve_fake(fake: Arc<Mutex<FakeRepo>>) -> (String, String) {
use axum::http::StatusCode;
use axum::response::IntoResponse as _;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let port = addr.port();
let host = format!("pds-{port}.readstate.test");
crate::net::test_host_override(&host, addr);
let app = axum::Router::new().fallback(move |req: axum::extract::Request| {
let fake = Arc::clone(&fake);
async move {
let (parts, body) = req.into_parts();
let raw = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let query: std::collections::HashMap<String, String> =
url::form_urlencoded::parse(parts.uri.query().unwrap_or("").as_bytes())
.into_owned()
.collect();
let reply = |status: u16, body: serde_json::Value| {
(StatusCode::from_u16(status).unwrap(), axum::Json(body)).into_response()
};
let mut fake = fake.lock().unwrap();
match parts.uri.path() {
"/internal/repo" => {
let req: serde_json::Value = serde_json::from_slice(&raw).unwrap();
match req["action"].as_str() {
Some("list") => {
let page = fake.page(
req["limit"].as_u64().map(|l| l as usize),
req["cursor"].as_str(),
);
if fake.list_fails {
return reply(
503,
serde_json::json!({ "ok": false, "error": "PartitionUnavailable", "status": 503 }),
);
}
reply(200, serde_json::json!({ "ok": true, "data": page }))
}
Some("applyWrites") => {
match fake.apply(req["writes"].as_array().unwrap()) {
Err(f) if f.status == HANG_UP.status => {
drop(fake);
hang_up()
}
Ok(()) => {
reply(200, serde_json::json!({ "ok": true, "data": {} }))
}
Err(f) => reply(
f.status,
serde_json::json!({
"ok": false,
"error": f.error,
"message": "Internal Server Error",
"status": f.status,
}),
),
}
}
other => panic!("unexpected sidecar action {other:?}"),
}
}
"/xrpc/com.atproto.repo.listRecords" => {
let page = fake.page(
query.get("limit").and_then(|l| l.parse().ok()),
query.get("cursor").map(String::as_str),
);
if fake.list_fails {
return reply(503, serde_json::json!({ "error": "PartitionUnavailable" }));
}
reply(200, page)
}
"/xrpc/com.atproto.repo.applyWrites" => {
let req: serde_json::Value = serde_json::from_slice(&raw).unwrap();
match fake.apply(req["writes"].as_array().unwrap()) {
Err(f) if f.status == HANG_UP.status => {
drop(fake);
hang_up()
}
Ok(()) => reply(200, serde_json::json!({ "results": [] })),
Err(f) => reply(
f.status,
serde_json::json!({
"error": f.error,
"message": "Internal Server Error",
}),
),
}
}
other => panic!("unexpected request to {other}"),
}
}
});
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(format!("http://{addr}"), format!("http://{host}:{port}"))
}
pub(crate) async fn state_on(backend: Backend, fake: &Arc<Mutex<FakeRepo>>) -> AppState {
let (sidecar, aud) = serve_fake(Arc::clone(fake)).await;
let db = store::init_url("sqlite::memory:").await.unwrap();
let state = AppState::new(
crate::config::Config {
repo_backend: backend,
public_url: "http://localhost:8080".into(),
sidecar: crate::config::SidecarConfig {
public_url: sidecar.clone(),
internal_url: sidecar,
internal_secret: "test-secret".into(),
},
oauth: crate::config::OauthConfig {
key_path: std::env::temp_dir().join(format!(
"fr-readstate-oauth-key-{}-{:p}.json",
std::process::id(),
&db as *const _
)),
encryption_key: Some("a".repeat(43)),
..crate::config::OauthConfig::default()
},
..crate::config::Config::default()
},
db,
)
.unwrap();
if backend == Backend::Rust {
let runtime = state.oauth.as_deref().expect("oauth runtime");
crate::oauth::store::put_session(
&state.db,
&runtime.codec,
&crate::oauth::store::OAuthSession {
sub: DID.into(),
issuer: "https://auth.invalid".into(),
aud,
dpop_key_jwk: crate::oauth::keys::SigningKey::generate("session-dpop")
.to_jwk_json()
.unwrap(),
access_token: "at".into(),
refresh_token: "rt".into(),
token_type: "DPoP".into(),
granted_scope: "atproto".into(),
expires_at: Some(store::now_unix() + 3600),
},
)
.await
.unwrap();
}
state
}
pub(crate) fn feed(i: usize) -> String {
format!("https://f{i}.example/feed.xml")
}
pub(crate) async fn mark_read(state: &AppState, i: usize, id: &str) {
static TICK: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let tick = TICK.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
store::upsert_cursor(
&state.db,
&ReadCursor {
did: DID.into(),
feed_url: feed(i),
read_through: None,
read_ids: format!("[\"{id}\"]"),
unread_ids: "[]".into(),
dirty: true,
pds_created: false,
updated_at: format!(
"2026-10-04T{:02}:{:02}:{:02}Z",
tick / 3600 % 24,
tick / 60 % 60,
tick % 60
),
},
)
.await
.unwrap();
}
fn existing(fake: &Arc<Mutex<FakeRepo>>, i: usize) {
let record = ReadState::new(feed(i), None, "2026-01-01T00:00:00Z");
fake.lock().unwrap().records.insert(
read_state_rkey(&feed(i)),
serde_json::to_value(record).unwrap(),
);
}
pub(crate) async fn cursor(state: &AppState, i: usize) -> ReadCursor {
store::get_cursor(&state.db, DID, &feed(i))
.await
.unwrap()
.expect("cursor row")
}
fn read_ids_on_pds(fake: &Arc<Mutex<FakeRepo>>, i: usize) -> serde_json::Value {
fake.lock().unwrap().records[&read_state_rkey(&feed(i))]["readIds"].clone()
}
const BACKENDS: [Backend; 2] = [Backend::Sidecar, Backend::Rust];
#[tokio::test]
async fn a_lost_create_response_converges_on_the_next_flush() {
for backend in BACKENDS {
let fake = Arc::new(Mutex::new(FakeRepo::default()));
let state = state_on(backend, &fake).await;
existing(&fake, 1);
mark_read(&state, 1, "42").await;
flush_did(&state, DID)
.await
.unwrap_or_else(|e| panic!("{backend:?}: the flush stayed wedged: {e:#}"));
let c = cursor(&state, 1).await;
assert!(c.pds_created, "{backend:?}: pds_created was not corrected");
assert!(!c.dirty, "{backend:?}: the cursor is still dirty");
assert_eq!(
read_ids_on_pds(&fake, 1),
serde_json::json!(["42"]),
"{backend:?}"
);
{
let f = fake.lock().unwrap();
assert_eq!(
f.list_walks, 1,
"{backend:?}: expected exactly one reconcile"
);
assert_eq!(
f.apply_calls, 2,
"{backend:?}: the failed batch, then the retry"
);
}
mark_read(&state, 1, "43").await;
flush_did(&state, DID).await.expect("steady-state flush");
let f = fake.lock().unwrap();
assert_eq!(f.list_walks, 1, "{backend:?}: a healthy flush reconciled");
assert_eq!(f.apply_calls, 3, "{backend:?}");
}
}
#[tokio::test]
async fn a_fresh_database_converges_against_existing_records() {
for backend in BACKENDS {
let fake = Arc::new(Mutex::new(FakeRepo::default()));
let state = state_on(backend, &fake).await;
for i in 0..150 {
existing(&fake, i);
}
for i in [3, 77, 149] {
mark_read(&state, i, "7").await;
}
flush_did(&state, DID)
.await
.unwrap_or_else(|e| panic!("{backend:?}: {e:#}"));
for i in [3, 77, 149] {
let c = cursor(&state, i).await;
assert!(
c.pds_created && !c.dirty,
"{backend:?}: feed {i} did not converge"
);
assert_eq!(
read_ids_on_pds(&fake, i),
serde_json::json!(["7"]),
"{backend:?}"
);
}
let f = fake.lock().unwrap();
assert_eq!(f.list_walks, 1, "{backend:?}");
assert_eq!(f.apply_calls, 2, "{backend:?}");
assert_eq!(
f.records.len(),
150,
"{backend:?}: a record was duplicated or lost"
);
}
}
#[tokio::test]
async fn a_deleted_record_converges_by_creating_it() {
for backend in BACKENDS {
let fake = Arc::new(Mutex::new(FakeRepo::default()));
let state = state_on(backend, &fake).await;
mark_read(&state, 1, "9").await;
store::mark_cursor_pds_created(&state.db, DID, &feed(1))
.await
.unwrap();
flush_did(&state, DID)
.await
.unwrap_or_else(|e| panic!("{backend:?}: {e:#}"));
let c = cursor(&state, 1).await;
assert!(c.pds_created && !c.dirty, "{backend:?}");
assert_eq!(
read_ids_on_pds(&fake, 1),
serde_json::json!(["9"]),
"{backend:?}"
);
let f = fake.lock().unwrap();
assert_eq!((f.list_walks, f.apply_calls), (1, 2), "{backend:?}");
}
}
#[tokio::test]
async fn a_partially_landed_batch_converges() {
for backend in BACKENDS {
let fake = Arc::new(Mutex::new(FakeRepo::default()));
let state = state_on(backend, &fake).await;
for i in 1..=4 {
mark_read(&state, i, "5").await;
}
store::mark_cursor_pds_created(&state.db, DID, &feed(4))
.await
.unwrap();
fake.lock().unwrap().partial_then_503 = Some(2);
flush_did(&state, DID)
.await
.expect_err("the 503 must surface as a failure");
{
let f = fake.lock().unwrap();
assert_eq!(
f.list_walks, 0,
"{backend:?}: a 503 is not a mismatch and must not reconcile"
);
assert_eq!(f.records.len(), 2, "{backend:?}: fixture");
}
flush_did(&state, DID)
.await
.unwrap_or_else(|e| panic!("{backend:?}: {e:#}"));
for i in 1..=4 {
let c = cursor(&state, i).await;
assert!(c.pds_created && !c.dirty, "{backend:?}: feed {i}");
assert_eq!(
read_ids_on_pds(&fake, i),
serde_json::json!(["5"]),
"{backend:?}"
);
}
let f = fake.lock().unwrap();
assert_eq!(f.list_walks, 1, "{backend:?}");
assert_eq!(f.apply_calls, 3, "{backend:?}: 503, mismatch, retry");
}
}
#[tokio::test]
async fn an_unrelated_failure_is_returned_without_a_reconcile() {
for backend in BACKENDS {
for fail in [
Fail {
status: 503,
error: "PartitionUnavailable",
},
Fail {
status: 502,
error: "UpstreamFailure",
},
Fail {
status: 401,
error: "AuthRequired",
},
Fail {
status: 429,
error: "RateLimitExceeded",
},
] {
let fake = Arc::new(Mutex::new(FakeRepo::default()));
let state = state_on(backend, &fake).await;
existing(&fake, 1);
mark_read(&state, 1, "1").await;
fake.lock().unwrap().always_fail = Some(fail);
flush_did(&state, DID)
.await
.expect_err("the failure must be returned");
let c = cursor(&state, 1).await;
assert!(c.dirty, "{backend:?} {fail:?}: the reads were dropped");
assert!(!c.pds_created, "{backend:?} {fail:?}: the flag moved");
let f = fake.lock().unwrap();
assert_eq!(
f.list_walks, 0,
"{backend:?} {fail:?}: reconciled an unrelated failure"
);
assert_eq!(
f.apply_calls, 1,
"{backend:?} {fail:?}: retried an unrelated failure"
);
}
}
}
#[tokio::test]
async fn a_flush_reconciles_at_most_once() {
for backend in BACKENDS {
let fake = Arc::new(Mutex::new(FakeRepo::default()));
let state = state_on(backend, &fake).await;
existing(&fake, 1);
mark_read(&state, 1, "1").await;
fake.lock().unwrap().always_fail = Some(MISMATCH);
let err = flush_did(&state, DID)
.await
.expect_err("a retry that fails again is a failure");
let shown = err.to_string();
assert!(
shown.contains("after reconciling")
&& shown.contains("500")
&& shown.contains("InternalServerError"),
"{backend:?}: the logged line lost the PDS's reason: {shown}"
);
let c = cursor(&state, 1).await;
assert!(c.dirty, "{backend:?}: the reads were dropped");
assert!(
c.pds_created,
"{backend:?}: the truth the listing found was not kept"
);
let f = fake.lock().unwrap();
assert_eq!(f.list_walks, 1, "{backend:?}: reconciled more than once");
assert_eq!(f.apply_calls, 2, "{backend:?}: retried more than once");
}
}
#[test]
fn only_a_structured_conflict_shaped_rejection_may_be_a_mismatch() {
let xrpc = |status: u16, error: &str| -> anyhow::Error {
AtProtoError::Xrpc {
status: reqwest::StatusCode::from_u16(status).unwrap(),
error: error.into(),
message: None,
}
.into()
};
for (status, error) in [
(500, "InternalServerError"),
(400, "InvalidRequest"),
(400, "InvalidSwap"),
(400, "RecordNotFound"),
(409, "Conflict"),
] {
assert!(
may_be_existence_mismatch(&xrpc(status, error)),
"{status} {error} should reconcile"
);
assert!(
may_be_existence_mismatch(&xrpc(status, error).context("applyWrites failed")),
"{status} {error} behind a context should reconcile"
);
}
for (status, error) in [
(500, "Unknown"),
(400, "ExpiredToken"),
(401, "AuthRequired"),
(403, "CollectionNotAllowed"),
(404, "SessionNotFound"),
(429, "RateLimitExceeded"),
(502, "UpstreamFailure"),
(503, "StoreUnavailable"),
(504, "UpstreamTimeout"),
] {
assert!(
!may_be_existence_mismatch(&xrpc(status, error)),
"{status} {error} must not reconcile"
);
}
assert!(!may_be_existence_mismatch(&anyhow::anyhow!(
"applyWrites failed: status 500 (InternalServerError)"
)));
}
#[tokio::test]
async fn a_reconcile_that_cannot_list_returns_the_original_error() {
for backend in BACKENDS {
let fake = Arc::new(Mutex::new(FakeRepo::default()));
let state = state_on(backend, &fake).await;
existing(&fake, 1);
mark_read(&state, 1, "1").await;
fake.lock().unwrap().list_fails = true;
let err = flush_did(&state, DID)
.await
.expect_err("an unreconciled mismatch is still a failure");
assert!(
may_be_existence_mismatch(&err),
"{backend:?}: returned the listing's error, not the write's: {err:#}"
);
let c = cursor(&state, 1).await;
assert!(c.dirty && !c.pds_created, "{backend:?}");
let f = fake.lock().unwrap();
assert_eq!((f.list_walks, f.apply_calls), (1, 1), "{backend:?}");
}
}
#[tokio::test]
async fn a_500_with_no_mismatch_is_not_retried() {
for backend in BACKENDS {
let fake = Arc::new(Mutex::new(FakeRepo::default()));
let state = state_on(backend, &fake).await;
mark_read(&state, 1, "1").await;
fake.lock().unwrap().always_fail = Some(MISMATCH);
flush_did(&state, DID)
.await
.expect_err("the 500 must be returned");
let c = cursor(&state, 1).await;
assert!(c.dirty && !c.pds_created, "{backend:?}");
let f = fake.lock().unwrap();
assert_eq!((f.list_walks, f.apply_calls), (1, 1), "{backend:?}");
}
}
pub(crate) fn send_order(n: usize) -> Vec<usize> {
let mut order: Vec<usize> = (0..n).collect();
order.sort_by_key(|&i| read_state_rkey(&feed(i)));
order
}
#[tokio::test]
async fn a_split_flush_keeps_what_landed_when_a_later_call_fails() {
for backend in BACKENDS {
let fake = Arc::new(Mutex::new(FakeRepo::default()));
let state = state_on(backend, &fake).await;
for i in 0..250 {
mark_read(&state, i, "1").await;
}
fake.lock().unwrap().drop_call = Some(2);
let err = flush_did(&state, DID)
.await
.expect_err("the second call failed");
assert!(
err.to_string().contains("200 of 250 writes had landed"),
"{backend:?}: the logged line lost the progress: {err}"
);
let order = send_order(250);
let (landed, rest) = order.split_at(crate::atproto::APPLY_WRITES_MAX_OPS);
for &i in landed {
let c = cursor(&state, i).await;
assert!(
c.pds_created && !c.dirty,
"{backend:?}: feed {i} landed in call 1 but was not settled"
);
}
for &i in rest {
let c = cursor(&state, i).await;
assert!(
c.dirty && !c.pds_created,
"{backend:?}: feed {i} never landed"
);
}
{
let f = fake.lock().unwrap();
assert_eq!(f.records.len(), 200, "{backend:?}: fixture");
assert_eq!((f.list_walks, f.apply_calls), (0, 2), "{backend:?}");
}
mark_read(&state, landed[0], "2").await;
flush_did(&state, DID)
.await
.unwrap_or_else(|e| panic!("{backend:?}: the remainder did not flush: {e:#}"));
for i in 0..250 {
let c = cursor(&state, i).await;
assert!(c.pds_created && !c.dirty, "{backend:?}: feed {i}");
}
assert_eq!(
read_ids_on_pds(&fake, landed[0]),
serde_json::json!(["2"]),
"{backend:?}"
);
let f = fake.lock().unwrap();
assert_eq!(f.records.len(), 250, "{backend:?}");
assert_eq!(
(f.list_walks, f.apply_calls),
(0, 3),
"{backend:?}: 51 writes are one call, and nothing needed listing"
);
}
}
#[tokio::test]
async fn a_mismatch_in_a_later_call_reconciles_and_retries_only_the_rest() {
for backend in BACKENDS {
let fake = Arc::new(Mutex::new(FakeRepo::default()));
let state = state_on(backend, &fake).await;
let order = send_order(250);
for &i in &order[240..] {
existing(&fake, i);
}
for i in 0..250 {
mark_read(&state, i, "3").await;
}
flush_did(&state, DID)
.await
.unwrap_or_else(|e| panic!("{backend:?}: did not converge: {e:#}"));
for i in 0..250 {
let c = cursor(&state, i).await;
assert!(c.pds_created && !c.dirty, "{backend:?}: feed {i}");
assert_eq!(
read_ids_on_pds(&fake, i),
serde_json::json!(["3"]),
"{backend:?}: feed {i}"
);
}
let f = fake.lock().unwrap();
assert_eq!(f.records.len(), 250, "{backend:?}");
assert_eq!(f.list_walks, 1, "{backend:?}: expected one reconcile");
assert_eq!(
f.apply_calls, 3,
"{backend:?}: call 1, call 2 refused, then one retry of the 50 left"
);
}
}
#[tokio::test]
async fn a_mismatch_is_recognised_inside_a_chunked_write_error() {
let op = crate::atproto::WriteOp::Delete {
collection: crate::lexicon::nsid::READ_STATE.into(),
rkey: "rs-0".into(),
};
let mismatch = || AtProtoError::Xrpc {
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
error: "InternalServerError".into(),
message: None,
};
let bare = crate::atproto::apply_writes_chunked(std::slice::from_ref(&op), |_| async {
Err(mismatch().into())
})
.await
.unwrap_err();
assert!(
crate::atproto::ApplyWritesIncomplete::of(&bare).is_some(),
"fixture: not wrapped"
);
assert!(may_be_existence_mismatch(&bare), "sidecar shape: {bare:#}");
let wrapped = crate::atproto::apply_writes_chunked(std::slice::from_ref(&op), |_| async {
Err(anyhow::Error::new(mismatch()).context("applyWrites failed"))
})
.await
.unwrap_err();
assert!(
may_be_existence_mismatch(&wrapped),
"rust shape: {wrapped:#}"
);
let transport =
crate::atproto::apply_writes_chunked(std::slice::from_ref(&op), |_| async {
Err(anyhow::anyhow!("connection reset"))
})
.await
.unwrap_err();
assert!(!may_be_existence_mismatch(&transport));
}
#[tokio::test]
async fn a_retry_that_part_lands_settles_what_it_landed() {
for backend in BACKENDS {
let fake = Arc::new(Mutex::new(FakeRepo::default()));
let state = state_on(backend, &fake).await;
let order = send_order(250);
for &i in &order[..10] {
existing(&fake, i);
}
for i in 0..250 {
mark_read(&state, i, "4").await;
}
fake.lock().unwrap().drop_call = Some(3);
let err = flush_did(&state, DID)
.await
.expect_err("the retry's second call failed");
assert!(
err.to_string().contains("after reconciling"),
"{backend:?}: {err}"
);
let (landed, rest) = order.split_at(crate::atproto::APPLY_WRITES_MAX_OPS);
for &i in landed {
let c = cursor(&state, i).await;
assert!(
c.pds_created && !c.dirty,
"{backend:?}: feed {i} landed in the retry but was not settled"
);
}
for &i in rest {
let c = cursor(&state, i).await;
assert!(c.dirty && !c.pds_created, "{backend:?}: feed {i}");
}
let f = fake.lock().unwrap();
assert_eq!((f.list_walks, f.apply_calls), (1, 3), "{backend:?}");
}
}
}