use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use chrono::{SecondsFormat, Utc};
use sqlx::Row;
use tokio::sync::{watch, Semaphore};
use tokio::time::{interval, MissedTickBehavior};
use tracing::{debug, error, info, warn};
use feather_reader::feed::{self, PollOutcome};
use feather_reader::lexicon::ReadState;
use feather_reader::store::{self, Feed, Pool, ReadCursor};
use feather_reader::AppState;
const DEFAULT_POLL_TICK: Duration = Duration::from_secs(60);
const DEFAULT_POLL_BATCH: i64 = 50;
const DEFAULT_POLL_CONCURRENCY: usize = 4;
const DEFAULT_POLL_STAGGER: Duration = Duration::from_millis(250);
const DEFAULT_FLUSH_DEBOUNCE: Duration = Duration::from_secs(60);
const DEFAULT_CODE_SWEEP: Duration = Duration::from_secs(3600);
const DEFAULT_RETENTION_SWEEP: Duration = Duration::from_secs(24 * 60 * 60);
fn env_duration_secs(key: &str, default: Duration) -> Duration {
match std::env::var(key)
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
{
Some(secs) if secs > 0 => Duration::from_secs(secs),
_ => default,
}
}
fn env_scalar<T: std::str::FromStr>(key: &str, default: T) -> T {
std::env::var(key)
.ok()
.and_then(|v| v.trim().parse::<T>().ok())
.unwrap_or(default)
}
pub fn schedulers_enabled() -> bool {
match std::env::var("FEATHERREADER_DISABLE_SCHEDULER") {
Ok(v) => !matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
),
Err(_) => true,
}
}
pub fn spawn(state: AppState, shutdown: watch::Receiver<()>) -> Vec<tokio::task::JoinHandle<()>> {
if !schedulers_enabled() {
info!("background schedulers disabled (FEATHERREADER_DISABLE_SCHEDULER)");
return Vec::new();
}
info!("spawning background schedulers (poller + read-state flusher)");
let poller = {
let state = state.clone();
let shutdown = shutdown.clone();
tokio::spawn(async move { run_poller(state, shutdown).await })
};
let sweeper = {
let state = state.clone();
let shutdown = shutdown.clone();
tokio::spawn(async move { run_code_sweeper(state, shutdown).await })
};
let retention = {
let state = state.clone();
let shutdown = shutdown.clone();
tokio::spawn(async move { run_retention_sweeper(state, shutdown).await })
};
let flusher = tokio::spawn(async move { run_flusher(state, shutdown).await });
vec![poller, sweeper, retention, flusher]
}
async fn shutdown_fired(rx: &mut watch::Receiver<()>) {
let _ = rx.changed().await;
}
pub async fn run_poller(state: AppState, mut shutdown: watch::Receiver<()>) {
let tick = env_duration_secs("FEATHERREADER_POLL_TICK_SECS", DEFAULT_POLL_TICK);
let batch = env_scalar::<i64>("FEATHERREADER_POLL_BATCH", DEFAULT_POLL_BATCH).max(1);
let concurrency =
env_scalar::<usize>("FEATHERREADER_POLL_CONCURRENCY", DEFAULT_POLL_CONCURRENCY).max(1);
let stagger = std::env::var("FEATHERREADER_POLL_STAGGER_MS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or(DEFAULT_POLL_STAGGER);
info!(
?tick,
batch,
concurrency,
?stagger,
default_interval = ?state.config.poll_interval,
"poll scheduler started"
);
let client = match feed::build_client() {
Ok(c) => c,
Err(err) => {
error!(%err, "poll scheduler: failed to build HTTP client; poller will not run");
return;
}
};
let limiter = Arc::new(Semaphore::new(concurrency));
let mut ticker = interval(tick);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = shutdown_fired(&mut shutdown) => {
info!("poll scheduler: shutdown signal received, stopping");
break;
}
_ = ticker.tick() => {
if let Err(err) = poll_due_once(&state, &client, &limiter, batch, stagger).await {
error!(%err, "poll scheduler: tick failed");
}
}
}
}
}
async fn poll_due_once(
state: &AppState,
client: &reqwest::Client,
limiter: &Arc<Semaphore>,
batch: i64,
stagger: Duration,
) -> anyhow::Result<()> {
let watermark = state.config.db_size_watermark_bytes;
if watermark > 0 {
match store::db_size_bytes(&state.db).await {
Ok(size) if size >= watermark => {
warn!(
db_size_bytes = size,
watermark_bytes = watermark,
"DB size at/above watermark: pausing new polling until it drops (retention/prune)"
);
return Ok(());
}
Ok(_) => {}
Err(err) => warn!(%err, "could not read DB size for watermark check; polling anyway"),
}
}
let now = now_rfc3339();
let due = store::due_feeds(&state.db, &now, batch).await?;
if due.is_empty() {
debug!("poll scheduler: no feeds due");
return Ok(());
}
info!(count = due.len(), "poll scheduler: polling due feeds");
let mut handles = Vec::with_capacity(due.len());
for feed in due {
let permit = match Arc::clone(limiter).acquire_owned().await {
Ok(p) => p,
Err(_) => break, };
let pool = state.db.clone();
let client = client.clone();
let default_interval = state.config.poll_interval;
let max_entries_per_feed = state.config.max_entries_per_feed;
handles.push(tokio::spawn(async move {
let _permit = permit; poll_and_reschedule(
&pool,
&client,
&feed,
default_interval,
max_entries_per_feed,
)
.await;
}));
if !stagger.is_zero() {
tokio::time::sleep(stagger).await;
}
}
for h in handles {
if let Err(err) = h.await {
warn!(%err, "poll scheduler: a feed poll task panicked");
}
}
Ok(())
}
async fn poll_and_reschedule(
pool: &Pool,
client: &reqwest::Client,
feed: &Feed,
default_interval: Duration,
max_entries_per_feed: i64,
) {
let next_delay = match feed::poll_feed(pool, client, feed, max_entries_per_feed).await {
Ok(PollOutcome::Updated { new_entries }) => {
debug!(feed = %feed.url, new_entries, "polled: updated");
if let Err(err) = store::reset_feed_errors(pool, &feed.url).await {
warn!(feed = %feed.url, %err, "failed to reset feed error count");
}
cadence_for(feed, default_interval)
}
Ok(PollOutcome::NotModified) => {
debug!(feed = %feed.url, "polled: not modified");
if let Err(err) = store::reset_feed_errors(pool, &feed.url).await {
warn!(feed = %feed.url, %err, "failed to reset feed error count");
}
cadence_for(feed, default_interval)
}
Ok(PollOutcome::Failed { backoff }) => {
let backoff = match store::bump_feed_errors(pool, &feed.url).await {
Ok(count) => feed::backoff_for(count.max(1) as u32),
Err(err) => {
warn!(feed = %feed.url, %err, "failed to bump feed error count; using floor backoff");
backoff
}
};
warn!(feed = %feed.url, ?backoff, "polled: failed, backing off");
backoff
}
Err(err) => {
error!(feed = %feed.url, %err, "polled: store error");
cadence_for(feed, default_interval)
}
};
if let Err(err) = set_next_poll(pool, &feed.url, next_delay).await {
error!(feed = %feed.url, %err, "failed to persist next_poll");
}
}
async fn set_next_poll(pool: &Pool, url: &str, delay: Duration) -> anyhow::Result<()> {
let next = Utc::now()
+ chrono::Duration::from_std(delay).unwrap_or_else(|_| chrono::Duration::hours(1));
let next_poll = next.to_rfc3339_opts(SecondsFormat::Secs, true);
let nf = store::NewFeed {
url: url.to_string(),
next_poll: Some(next_poll),
..Default::default()
};
store::upsert_feed(pool, &nf).await.map(|_| ())
}
fn cadence_for(feed: &Feed, default_interval: Duration) -> Duration {
let hint: Option<&str> = feed_fetch_hint(feed);
match hint {
Some(h) => cadence_from_hint(h, default_interval),
None => default_interval,
}
}
fn feed_fetch_hint(_feed: &Feed) -> Option<&str> {
None
}
fn cadence_from_hint(hint: &str, default_interval: Duration) -> Duration {
match hint.trim().to_ascii_lowercase().as_str() {
"realtime" => Duration::from_secs(5 * 60),
"hourly" => Duration::from_secs(60 * 60),
"daily" => Duration::from_secs(24 * 60 * 60),
"weekly" => Duration::from_secs(7 * 24 * 60 * 60),
_ => default_interval,
}
}
pub async fn run_code_sweeper(state: AppState, mut shutdown: watch::Receiver<()>) {
let period = env_duration_secs("FEATHERREADER_CODE_SWEEP_SECS", DEFAULT_CODE_SWEEP);
info!(?period, "invite-code TTL sweeper started");
let mut ticker = interval(period);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = shutdown_fired(&mut shutdown) => {
info!("invite-code TTL sweeper: shutdown signal received, stopping");
break;
}
_ = ticker.tick() => {
match store::expire_old_codes(&state.db).await {
Ok(0) => debug!("invite-code TTL sweeper: nothing to expire"),
Ok(n) => info!(expired = n, "invite-code TTL sweeper: expired codes"),
Err(err) => error!(%err, "invite-code TTL sweeper: sweep failed"),
}
}
}
}
}
pub async fn run_retention_sweeper(state: AppState, mut shutdown: watch::Receiver<()>) {
let days = state.config.retention_days as i64;
if days <= 0 {
info!("retention sweeper: retention_days=0, retention disabled (no rolling window)");
return;
}
let period = env_duration_secs(
"FEATHERREADER_RETENTION_SWEEP_SECS",
DEFAULT_RETENTION_SWEEP,
);
info!(retention_days = days, ?period, "retention sweeper started");
let mut ticker = interval(period);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = shutdown_fired(&mut shutdown) => {
info!("retention sweeper: shutdown signal received, stopping");
break;
}
_ = ticker.tick() => {
match store::prune_old_entries(&state.db, days).await {
Ok(0) => debug!("retention sweeper: nothing past the retention window"),
Ok(n) => {
info!(pruned = n, retention_days = days, "retention sweeper: pruned old entries");
if let Err(err) = store::reclaim(&state.db).await {
warn!(%err, "retention sweeper: reclaim after prune failed");
}
}
Err(err) => error!(%err, "retention sweeper: prune failed"),
}
}
}
}
}
pub async fn run_flusher(state: AppState, mut shutdown: watch::Receiver<()>) {
let debounce = env_duration_secs("FEATHERREADER_FLUSH_DEBOUNCE_SECS", DEFAULT_FLUSH_DEBOUNCE);
info!(?debounce, "read-state flusher started");
let mut ticker = interval(debounce);
ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
ticker.tick().await;
loop {
tokio::select! {
_ = shutdown_fired(&mut shutdown) => {
info!("read-state flusher: shutdown signal received, final flush");
if let Err(err) = flush_all_dirty(&state).await {
error!(%err, "read-state flusher: final flush failed");
}
break;
}
_ = ticker.tick() => {
if let Err(err) = flush_all_dirty(&state).await {
error!(%err, "read-state flusher: flush round failed");
}
}
}
}
}
async fn flush_all_dirty(state: &AppState) -> anyhow::Result<()> {
let dids = dids_with_dirty_cursors(&state.db).await?;
if dids.is_empty() {
debug!("read-state flusher: nothing dirty");
return Ok(());
}
debug!(
dids = dids.len(),
"read-state flusher: flushing dirty cursors"
);
for did in dids {
if let Err(err) = flush_did(state, &did).await {
warn!(%did, %err, "read-state flusher: DID flush failed; will retry");
}
}
Ok(())
}
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: BTreeMap<String, (ReadState, ReadCursor)> = BTreeMap::new();
for cursor in cursors {
let rkey = read_state_rkey(&cursor.feed_url);
let record = read_state_record(&cursor);
batch.insert(rkey, (record, cursor));
}
let ops: Vec<(String, ReadState, bool)> = batch
.iter()
.map(|(rkey, (record, cursor))| (rkey.clone(), record.clone(), cursor.pds_created))
.collect();
state.sidecar.flush_read_states(did, &ops).await?;
let flushed = ops.len();
for (_rkey, (_record, cursor)) in batch {
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");
}
}
info!(%did, feeds = flushed, "read-state flusher: flushed dirty cursors");
Ok(())
}
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;
ids.drain(0..drop);
}
ids
}
pub fn read_state_rkey(feed_url: &str) -> String {
format!("rs-{:016x}", fnv1a_64(feed_url.as_bytes()))
}
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
}
async fn dids_with_dirty_cursors(pool: &Pool) -> anyhow::Result<Vec<String>> {
let rows = sqlx::query("SELECT DISTINCT did FROM read_cursor WHERE dirty = 1")
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| r.get::<String, _>("did"))
.collect())
}
fn now_rfc3339() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
}
#[cfg(test)]
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!(a.len() <= 512 && !a.is_empty());
assert!(a
.bytes()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'.' | b'_' | b'~' | b':')));
assert!(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 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"]));
}
#[test]
fn cadence_from_hint_maps_known_values() {
let d = Duration::from_secs(3600);
assert_eq!(cadence_from_hint("hourly", d), Duration::from_secs(3600));
assert_eq!(cadence_from_hint("daily", d), Duration::from_secs(86_400));
assert_eq!(cadence_from_hint("weekly", d), Duration::from_secs(604_800));
assert_eq!(cadence_from_hint("realtime", d), Duration::from_secs(300));
assert_eq!(cadence_from_hint("bogus", d), d);
}
}