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 std::collections::HashSet;
use feather_reader::lexicon::nsid;
use feather_reader::network::RelayClient;
use feather_reader::readstate::{flush_did, fnv1a_64};
use feather_reader::store::{self, Feed, Pool};
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);
const ADOPTION_STARTUP_DELAY: Duration = Duration::from_secs(5 * 60);
const POLLER_STARTUP_DELAY: Duration = Duration::from_secs(30);
const PENDING_SWEEP_STARTUP_DELAY: Duration = Duration::from_secs(45);
const CODE_SWEEP_STARTUP_DELAY: Duration = Duration::from_secs(60);
const RETENTION_STARTUP_DELAY: Duration = Duration::from_secs(90);
const PUBLICATION_POLLER_STARTUP_DELAY: Duration = Duration::from_secs(75);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Loop {
Poller,
PublicationPoller,
PendingSweep,
CodeSweep,
Retention,
Adoption,
}
impl Loop {
const ALL: [Loop; 6] = [
Loop::Poller,
Loop::PublicationPoller,
Loop::PendingSweep,
Loop::CodeSweep,
Loop::Retention,
Loop::Adoption,
];
fn spawn_at(
self,
state: AppState,
shutdown: watch::Receiver<()>,
startup: Duration,
) -> tokio::task::JoinHandle<()> {
match self {
Loop::Poller => tokio::spawn(run_poller(state, shutdown, startup)),
Loop::PublicationPoller => {
tokio::spawn(run_publication_poller(state, shutdown, startup))
}
Loop::PendingSweep => tokio::spawn(run_pending_sweeper(state, shutdown, startup)),
Loop::CodeSweep => tokio::spawn(run_code_sweeper(state, shutdown, startup)),
Loop::Retention => tokio::spawn(run_retention_sweeper(state, shutdown, startup)),
Loop::Adoption => tokio::spawn(run_adoption_probe(state, shutdown, startup)),
}
}
const fn startup_offset(self) -> Duration {
match self {
Loop::Poller => POLLER_STARTUP_DELAY,
Loop::PublicationPoller => PUBLICATION_POLLER_STARTUP_DELAY,
Loop::PendingSweep => PENDING_SWEEP_STARTUP_DELAY,
Loop::CodeSweep => CODE_SWEEP_STARTUP_DELAY,
Loop::Retention => RETENTION_STARTUP_DELAY,
Loop::Adoption => ADOPTION_STARTUP_DELAY,
}
}
}
#[cfg(test)]
const fn poller_for(kind: feed::FeedKind) -> Option<Loop> {
match kind {
feed::FeedKind::Rss => Some(Loop::Poller),
feed::FeedKind::Publication => Some(Loop::PublicationPoller),
feed::FeedKind::Unsupported => None,
}
}
const STARTUP_DELAY_ENV: &str = "FEATHERREADER_STARTUP_DELAY_SECS";
fn startup_plan() -> Vec<(Loop, Duration)> {
startup_plan_from(std::env::var(STARTUP_DELAY_ENV).ok())
}
fn startup_plan_from(raw: Option<String>) -> Vec<(Loop, Duration)> {
let mut plan = Vec::new();
for_each_loop(|l| plan.push((l, offset_from(l, raw.clone()))));
plan
}
fn offset_from(which: Loop, raw: Option<String>) -> Duration {
startup_delay_from(which.startup_offset(), raw)
}
fn startup_delay_from(default: Duration, raw: Option<String>) -> Duration {
match raw.as_deref().map(str::trim) {
Some(v) => match v.parse::<u64>() {
Ok(secs) => {
let requested = Duration::from_secs(secs);
if requested > default {
info!(
requested_secs = secs,
effective_secs = default.as_secs(),
"FEATHERREADER_STARTUP_DELAY_SECS is a ceiling and can only \
SHORTEN a startup delay; using the built-in value"
);
return default;
}
requested
}
Err(_) => {
warn!(
value = v,
"FEATHERREADER_STARTUP_DELAY_SECS is not a number; ignoring it"
);
default
}
},
None => default,
}
}
fn delayed_interval(delay: Duration, period: Duration) -> tokio::time::Interval {
let mut ticker = tokio::time::interval_at(tokio::time::Instant::now() + delay, period);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
ticker
}
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)");
state.runtime_health.set_schedulers_enabled(false);
return Vec::new();
}
state.runtime_health.set_schedulers_enabled(true);
info!(
"spawning background schedulers (RSS poller + publication poller + sweepers + adoption probe + read-state flusher)"
);
let mut handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
for (l, startup) in startup_plan() {
handles.push(l.spawn_at(state.clone(), shutdown.clone(), startup));
}
handles.push({
let state = state.clone();
let shutdown = shutdown.clone();
tokio::spawn(async move { run_metrics_flusher(state, shutdown).await })
});
handles.push(tokio::spawn(
async move { run_flusher(state, shutdown).await },
));
handles
}
fn for_each_loop(mut f: impl FnMut(Loop)) {
for l in Loop::ALL {
f(l);
}
}
async fn shutdown_fired(rx: &mut watch::Receiver<()>) {
let _ = rx.changed().await;
}
pub async fn run_poller(state: AppState, mut shutdown: watch::Receiver<()>, startup: Duration) {
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 = delayed_interval(startup, tick);
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, &shutdown).await
{
error!(%err, "poll scheduler: tick failed");
}
state.runtime_health.poll_tick_completed(Utc::now().timestamp());
}
}
}
}
async fn over_watermark(state: &AppState) -> bool {
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)"
);
state.runtime_health.set_watermark(true);
return true;
}
Ok(_) => state.runtime_health.set_watermark(false),
Err(err) => warn!(%err, "could not read DB size for watermark check; polling anyway"),
}
}
false
}
async fn poll_due_once(
state: &AppState,
client: &reqwest::Client,
limiter: &Arc<Semaphore>,
batch: i64,
stagger: Duration,
shutdown: &watch::Receiver<()>,
) -> anyhow::Result<()> {
if over_watermark(state).await {
return Ok(());
}
let now = now_rfc3339();
let due = store::due_feeds_of_kind(&state.db, &now, feed::FeedKind::Rss, 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());
let mut abandoned = 0usize;
for feed in due {
if shutdown.has_changed().unwrap_or(true) {
abandoned += 1;
continue;
}
let pool = state.db.clone();
let client = client.clone();
let default_interval = state.config.poll_interval;
let config = Arc::clone(&state.config);
let permit = match Arc::clone(limiter).acquire_owned().await {
Ok(p) => p,
Err(_) => break, };
handles.push(tokio::spawn(async move {
let _permit = permit; poll_and_reschedule(&pool, &client, &feed, default_interval, &config).await;
}));
if !stagger.is_zero() {
tokio::time::sleep(stagger).await;
}
}
if abandoned > 0 {
info!(
abandoned,
"poll scheduler: shutdown requested mid-round; these feeds were not \
launched and stay due"
);
}
for h in handles {
if let Err(err) = h.await {
warn!(%err, "poll scheduler: a feed poll task panicked");
}
}
Ok(())
}
pub async fn run_publication_poller(
state: AppState,
mut shutdown: watch::Receiver<()>,
startup: Duration,
) {
let client = match feed::build_client() {
Ok(c) => c,
Err(err) => {
error!(%err, "publication poller: failed to build HTTP client; it will not run");
return;
}
};
match store::stagger_unscheduled(
&state.db,
feed::FeedKind::Publication,
state.config.poll_interval,
)
.await
{
Ok(0) => {}
Ok(n) => info!(
scheduled = n,
"staggered the first poll of never-polled publications"
),
Err(err) => {
warn!(%err, "could not stagger never-polled publications; they will all be due at once")
}
}
let tick = env_duration_secs("FEATHERREADER_POLL_TICK_SECS", DEFAULT_POLL_TICK);
let mut ticker = delayed_interval(startup, tick);
loop {
tokio::select! {
_ = shutdown_fired(&mut shutdown) => {
info!("publication poller: shutdown signal received, stopping");
break;
}
_ = ticker.tick() => {
if let Err(err) = poll_publications_once(&state, &client, &shutdown).await {
error!(%err, "publication poller: pass failed");
}
}
}
}
}
async fn poll_publications_once(
state: &AppState,
client: &reqwest::Client,
shutdown: &watch::Receiver<()>,
) -> anyhow::Result<()> {
poll_publications_with(state, shutdown, |feeds, interval| async move {
poll_group_and_reschedule(&state.db, client, &feeds, interval, &state.config).await;
})
.await
}
const MAX_PUBLICATION_GROUP: usize = 16;
async fn poll_publications_with<F, Fut>(
state: &AppState,
shutdown: &watch::Receiver<()>,
mut poll: F,
) -> anyhow::Result<()>
where
F: FnMut(Vec<Feed>, Duration) -> Fut,
Fut: std::future::Future<Output = ()>,
{
let mut read = std::collections::HashSet::new();
let repo_of =
|f: &Feed| feather_reader::standard_site::AtUri::parse(&f.url).map(|u| u.authority);
loop {
if shutdown.has_changed().unwrap_or(true) || over_watermark(state).await {
return Ok(());
}
let now = now_rfc3339();
let due: Vec<Feed> = store::due_feeds_of_kind(
&state.db,
&now,
feed::FeedKind::Publication,
i64::try_from(read.len() + 1_000).unwrap_or(i64::MAX),
)
.await?
.into_iter()
.filter(|f| !read.contains(&f.id))
.collect();
let Some(head) = due.first() else {
return Ok(());
};
let repo = repo_of(head);
let group: Vec<Feed> = due
.iter()
.filter(|f| repo.is_some() && repo_of(f) == repo)
.take(MAX_PUBLICATION_GROUP)
.cloned()
.collect();
let group = if group.is_empty() {
vec![head.clone()]
} else {
group
};
read.extend(group.iter().map(|f| f.id));
poll(group, state.config.poll_interval).await;
}
}
async fn poll_group_and_reschedule(
pool: &Pool,
client: &reqwest::Client,
feeds: &[Feed],
default_interval: Duration,
config: &feather_reader::config::Config,
) {
for f in feeds {
if let Err(err) = store::set_next_poll(pool, &f.url, cadence_for(f, default_interval)).await
{
warn!(feed = %f.url, %err, "failed to lease next_poll before fetching");
}
}
let outcomes = feed::poll_publication_group(pool, client, config, feeds).await;
for (f, outcome) in feeds.iter().zip(outcomes) {
match outcome {
Ok(o) => feed::settle_poll(pool, &f.url, &o, cadence_for(f, default_interval)).await,
Err(err) => {
error!(feed = %f.url, %err, "polled: store error");
if let Err(err) =
store::set_next_poll(pool, &f.url, cadence_for(f, default_interval)).await
{
error!(feed = %f.url, %err, "failed to persist next_poll");
}
}
}
}
}
async fn poll_and_reschedule(
pool: &Pool,
client: &reqwest::Client,
feed: &Feed,
default_interval: Duration,
config: &feather_reader::config::Config,
) {
poll_and_reschedule_with(pool, feed, default_interval, |pool, feed| {
feed::poll_feed_by_kind(pool, client, config, feed)
})
.await;
}
async fn poll_and_reschedule_with<'a, F, Fut>(
pool: &'a Pool,
feed: &'a Feed,
default_interval: Duration,
poll: F,
) where
F: FnOnce(&'a Pool, &'a Feed) -> Fut,
Fut: std::future::Future<Output = anyhow::Result<PollOutcome>>,
{
if let Err(err) =
store::set_next_poll(pool, &feed.url, cadence_for(feed, default_interval)).await
{
warn!(feed = %feed.url, %err, "failed to lease next_poll before fetching; \
a crash during this poll would re-select this feed first");
}
let outcome = poll(pool, feed).await;
match &outcome {
Ok(o) => feed::settle_poll(pool, &feed.url, o, cadence_for(feed, default_interval)).await,
Err(err) => {
error!(feed = %feed.url, %err, "polled: store error");
if let Err(err) =
store::set_next_poll(pool, &feed.url, cadence_for(feed, default_interval)).await
{
error!(feed = %feed.url, %err, "failed to persist next_poll");
}
}
}
}
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<()>,
startup: Duration,
) {
let period = env_duration_secs("FEATHERREADER_CODE_SWEEP_SECS", DEFAULT_CODE_SWEEP);
info!(?period, "invite-code TTL sweeper started");
let mut ticker = delayed_interval(startup, period);
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<()>,
startup: Duration,
) {
let days = state.config.retention_days as i64;
let hard_days = state.config.retention_hard_days as i64;
let publication_days = state.config.publication_retention_days as i64;
if days <= 0 && hard_days <= 0 && publication_days <= 0 {
info!(
"retention sweeper: retention_days=0, retention_hard_days=0 and \
publication_retention_days=0, retention disabled entirely (no rolling \
window, NO ceiling — the shared cache is unbounded in this \
configuration)"
);
return;
}
let period = env_duration_secs(
"FEATHERREADER_RETENTION_SWEEP_SECS",
DEFAULT_RETENTION_SWEEP,
);
info!(
retention_days = days,
retention_hard_days = hard_days,
publication_retention_days = publication_days,
?period,
"retention sweeper started"
);
let mut ticker = delayed_interval(startup, period);
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, hard_days, publication_days).await {
Ok(0) => debug!("retention sweeper: nothing past the retention window"),
Ok(n) => {
info!(
pruned = n,
retention_days = days,
retention_hard_days = hard_days,
publication_retention_days = publication_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_adoption_probe(
state: AppState,
mut shutdown: watch::Receiver<()>,
startup: Duration,
) {
let period = state.config.adoption_interval;
if period.is_zero() {
info!("adoption probe: disabled (FEATHERREADER_ADOPTION_INTERVAL_SECS=0)");
return;
}
for bad in &state.config.relay_host_errors {
warn!(
entry = %bad,
"adoption probe: ignoring unusable FEATHERREADER_RELAY_HOSTS entry"
);
}
if state.config.relay_hosts.is_empty() {
info!("adoption probe: no relay hosts configured, probe disabled");
return;
}
let client = match RelayClient::new(state.http.clone(), &state.config.relay_hosts) {
Ok(client) => client,
Err(err) => {
warn!(%err, "adoption probe: unusable FEATHERREADER_RELAY_HOSTS, probe disabled");
return;
}
};
let period = jittered(period, &state.config.public_url);
info!(
?period,
relays = client.hosts().len(),
"adoption probe started"
);
let mut ticker = tokio::time::interval_at(tokio::time::Instant::now() + startup, period);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = shutdown_fired(&mut shutdown) => {
info!("adoption probe: shutdown signal received, stopping");
break;
}
_ = ticker.tick() => probe_adoption_once(&state, &client).await,
}
}
}
async fn probe_adoption_once(state: &AppState, client: &RelayClient) {
let report = client.count_repos_with_collection(nsid::SUBSCRIPTION).await;
for failure in &report.failures {
warn!(
host = %failure.host,
reason = %failure.reason,
"adoption probe: relay query failed; keeping previous observation"
);
}
for obs in &report.observations {
info!(
key = store::ADOPTION_STAT_KEY,
source = %obs.source,
repos = obs.repos,
truncated = obs.truncated,
"adoption probe: observed"
);
let stat = store::NetworkStat {
key: store::ADOPTION_STAT_KEY.to_string(),
source: obs.source.clone(),
value: obs.repos as i64,
truncated: obs.truncated,
observed_at: obs.observed_at.clone(),
};
if let Err(err) = store::record_network_stat(&state.db, &stat).await {
warn!(%err, source = %obs.source, "adoption probe: failed to persist observation");
}
}
if report.disagrees() {
let counts: Vec<(String, u64)> = report
.observations
.iter()
.map(|o| (o.source.clone(), o.repos))
.collect();
info!(
?counts,
"adoption probe: relays disagree; the max is surfaced"
);
}
}
fn jittered(period: Duration, seed: &str) -> Duration {
let basis = (fnv1a_64(seed.as_bytes()) % 201) as i64 - 100;
let secs = period.as_secs_f64() * (1.0 + basis as f64 / 1000.0);
Duration::from_secs_f64(secs.max(1.0))
}
const PENDING_SWEEP_SECS: u64 = 900;
const NONCE_MAX_AGE_SECS: i64 = 24 * 60 * 60;
pub async fn run_pending_sweeper(
state: AppState,
mut shutdown: watch::Receiver<()>,
startup: Duration,
) {
let period = Duration::from_secs(PENDING_SWEEP_SECS);
info!(?period, "pending-login sweeper started");
let mut ticker = delayed_interval(startup, period);
loop {
tokio::select! {
_ = shutdown_fired(&mut shutdown) => {
info!("pending-login sweeper: shutdown signal received, stopping");
break;
}
_ = ticker.tick() => {
let now = Utc::now().timestamp();
match feather_reader::oauth::store::sweep_expired_pending(&state.db, now).await {
Ok(0) => debug!("pending-login sweeper: nothing to expire"),
Ok(n) => info!(swept = n, "pending-login sweeper: removed abandoned logins"),
Err(err) => error!(%err, "pending-login sweeper: sweep failed"),
}
match feather_reader::oauth::store::sweep_stale_nonces(
&state.db,
now - NONCE_MAX_AGE_SECS,
)
.await
{
Ok(0) => debug!("nonce sweeper: nothing stale"),
Ok(n) => info!(swept = n, "nonce sweeper: removed stale DPoP nonces"),
Err(err) => error!(%err, "nonce sweeper: sweep failed"),
}
}
}
}
}
const METRICS_FLUSH_SECS: u64 = 30;
pub async fn run_metrics_flusher(state: AppState, mut shutdown: watch::Receiver<()>) {
let period = Duration::from_secs(METRICS_FLUSH_SECS);
info!(?period, "repo-timing flusher started");
let mut ticker = tokio::time::interval(period);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = ticker.tick() => flush_metrics_once(&state).await,
_ = shutdown.changed() => {
flush_metrics_once(&state).await;
info!("repo-timing flusher stopped (final flush done)");
return;
}
}
}
}
async fn flush_metrics_once(state: &AppState) {
if let Err(err) =
feather_reader::metrics::flush(&state.metrics, &state.db, Utc::now().timestamp()).await
{
warn!(%err, "could not persist repo timings");
}
}
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 parked: HashSet<String> = HashSet::new();
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, &mut parked).await {
error!(%err, "read-state flusher: final flush failed");
}
break;
}
_ = ticker.tick() => {
if let Err(err) = flush_all_dirty(&state, &mut parked).await {
error!(%err, "read-state flusher: flush round failed");
}
}
}
}
}
async fn flush_all_dirty(state: &AppState, parked: &mut HashSet<String>) -> 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 {
match state.repo().has_session(&did).await {
Ok(false) => {
if parked.insert(did.clone()) {
info!(
%did,
"read-state flusher: no OAuth session; parking this DID's \
read-state until it signs in again"
);
}
continue;
}
Ok(true) => {
parked.remove(&did);
}
Err(err) => {
warn!(%did, %err, "read-state flusher: session check failed; attempting anyway");
}
}
if let Err(err) = flush_did(state, &did).await {
warn!(%did, %err, "read-state flusher: DID flush failed; will retry");
}
}
Ok(())
}
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::*;
use feather_reader::store::ReadCursor;
async fn due_feed(url: &str) -> (Pool, Feed) {
let pool = store::init_url("sqlite::memory:").await.unwrap();
store::upsert_feed(
&pool,
&store::NewFeed {
url: url.to_string(),
..Default::default()
},
)
.await
.unwrap();
let feed = store::get_feed_by_url(&pool, url).await.unwrap().unwrap();
assert!(
feed.next_poll.is_none(),
"fixture must start due (NULL next_poll sorts FIRST in due_feeds)"
);
(pool, feed)
}
#[tokio::test]
async fn next_poll_moves_before_the_fetch_is_invoked() {
let url = "https://killer.example/feed.xml";
let (pool, feed) = due_feed(url).await;
let seen_at_fetch = std::sync::Arc::new(std::sync::Mutex::new(None::<Option<String>>));
let probe = std::sync::Arc::clone(&seen_at_fetch);
poll_and_reschedule_with(&pool, &feed, Duration::from_secs(3600), |pool, feed| {
let probe = std::sync::Arc::clone(&probe);
let url = feed.url.clone();
async move {
let row = store::get_feed_by_url(pool, &url).await.unwrap().unwrap();
*probe.lock().unwrap() = Some(row.next_poll);
Err(anyhow::anyhow!("the fetch killed the process"))
}
})
.await;
let at_fetch = seen_at_fetch.lock().unwrap().clone().expect("fetch ran");
let at_fetch = at_fetch.expect(
"next_poll was still NULL when the fetch began: a crash here re-selects \
this feed FIRST on every restart, forever",
);
assert!(
at_fetch > now_rfc3339(),
"next_poll was leased to {at_fetch}, which is not in the future"
);
}
#[tokio::test]
async fn a_returning_poll_overwrites_the_lease() {
let url = "https://slow.example/feed.xml";
let (pool, feed) = due_feed(url).await;
poll_and_reschedule_with(&pool, &feed, Duration::from_secs(86_400), |_, _| async {
Ok(PollOutcome::Failed {
backoff: Duration::from_secs(300),
kind: feather_reader::feed::FailureKind::Fetch,
detail: "test".to_string(),
})
})
.await;
let after = store::get_feed_by_url(&pool, url)
.await
.unwrap()
.unwrap()
.next_poll
.expect("next_poll must be set after a poll");
let after_dt = chrono::DateTime::parse_from_rfc3339(&after)
.expect("next_poll is RFC3339")
.with_timezone(&chrono::Utc);
let delay = (after_dt - chrono::Utc::now()).num_seconds();
assert!(
(240..=360).contains(&delay),
"a first failure must land on its 5-minute backoff, not the lease: next_poll={after} ({delay}s out)"
);
}
#[test]
fn the_startup_delay_override_is_a_ceiling() {
let d = POLLER_STARTUP_DELAY;
let at = |v: &str| startup_delay_from(d, Some(v.to_string()));
assert_eq!(at("0"), Duration::ZERO);
assert_eq!(at("5"), Duration::from_secs(5));
assert_eq!(at(" 5 "), Duration::from_secs(5), "surrounding space");
assert_eq!(at("99999"), d, "the override lengthened the wait");
assert_eq!(at("not-a-number"), d);
assert_eq!(at(""), d);
assert_eq!(startup_delay_from(d, None), d);
}
#[test]
fn the_startup_delays_are_distinct() {
let offsets: Vec<Duration> = Loop::ALL.iter().map(|l| l.startup_offset()).collect();
let unique: std::collections::HashSet<Duration> = offsets.iter().copied().collect();
assert_eq!(
unique.len(),
Loop::ALL.len(),
"two loops share a startup delay: {offsets:?}",
);
assert!(
offsets.iter().all(|d| *d > Duration::ZERO),
"a loop still fires immediately at boot: {offsets:?}",
);
}
#[test]
fn every_loop_is_visited_exactly_once() {
let mut seen = Vec::new();
for_each_loop(|l| seen.push(l));
assert_eq!(
seen,
Loop::ALL.to_vec(),
"the iteration `spawn` uses does not visit every loop exactly once, \
in registry order",
);
}
#[test]
fn the_startup_override_env_key_is_the_documented_one() {
assert_eq!(STARTUP_DELAY_ENV, "FEATHERREADER_STARTUP_DELAY_SECS");
}
#[test]
fn the_registry_lists_each_loop_exactly_once() {
let unique: std::collections::HashSet<Loop> = Loop::ALL.iter().copied().collect();
assert_eq!(
unique.len(),
Loop::ALL.len(),
"a loop is listed twice in the registry and would be started twice",
);
}
#[tokio::test]
async fn spawn_starts_every_registered_loop() {
assert!(
schedulers_enabled(),
"FEATHERREADER_DISABLE_SCHEDULER is set in this test process, so \
`spawn` returns no handles and this test cannot measure anything",
);
let state = rust_state().await;
let (tx, rx) = watch::channel(());
let handles = spawn(state, rx);
assert_eq!(
handles.len(),
Loop::ALL.len() + 2,
"`spawn` started {} tasks for {} registered loops + 2 unoffset ones \
— it is not starting one task per registry entry",
handles.len(),
Loop::ALL.len(),
);
drop(tx);
for h in handles {
let _ = h.await;
}
}
#[test]
fn the_startup_ceiling_applies_to_every_loop() {
for l in Loop::ALL {
assert_eq!(
offset_from(l, Some("0".into())),
Duration::ZERO,
"{l:?} ignored the startup-delay ceiling",
);
}
}
const DOCUMENTED_OFFSETS: [(Loop, u64); 6] = [
(Loop::Poller, 30),
(Loop::PublicationPoller, 75),
(Loop::PendingSweep, 45),
(Loop::CodeSweep, 60),
(Loop::Retention, 90),
(Loop::Adoption, 300),
];
#[test]
fn every_loop_is_on_its_documented_offset() {
let listed: std::collections::HashSet<Loop> =
DOCUMENTED_OFFSETS.iter().map(|(l, _)| *l).collect();
let registered: std::collections::HashSet<Loop> = Loop::ALL.iter().copied().collect();
assert_eq!(
listed, registered,
"this table and the registry do not cover the same loops",
);
for (l, secs) in DOCUMENTED_OFFSETS {
assert_eq!(
l.startup_offset(),
Duration::from_secs(secs),
"{l:?} is not on its documented {secs}s offset",
);
}
}
#[test]
fn the_startup_plan_hands_each_loop_its_own_offset() {
let expected: Vec<(Loop, Duration)> = DOCUMENTED_OFFSETS
.iter()
.map(|(l, secs)| (*l, Duration::from_secs(*secs)))
.collect();
assert_eq!(
startup_plan_from(None),
expected,
"the plan `spawn` starts from does not pair every loop with its own \
documented offset, in registry order",
);
}
#[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);
}
#[test]
fn jitter_stays_within_ten_percent_and_is_seed_stable() {
let period = Duration::from_secs(86_400);
let seeds = [
"https://feather-reader.com",
"http://localhost:8080",
"https://reader.example.org",
];
for seed in seeds {
let j = jittered(period, seed);
assert!(
j >= Duration::from_secs(77_760) && j <= Duration::from_secs(95_040),
"{seed}: {j:?} escaped ±10% of a day"
);
assert_eq!(j, jittered(period, seed));
}
assert_ne!(jittered(period, seeds[0]), jittered(period, seeds[1]));
}
#[test]
fn jitter_never_returns_a_sub_second_period() {
assert!(jittered(Duration::from_secs(1), "x") >= Duration::from_secs(1));
assert!(jittered(Duration::from_millis(1), "x") >= Duration::from_secs(1));
}
async fn state_with_due(urls: &[&str]) -> AppState {
let state = rust_state().await;
for url in urls {
store::upsert_feed(
&state.db,
&store::NewFeed {
url: url.to_string(),
..Default::default()
},
)
.await
.unwrap();
}
state
}
const PUB_A: &str = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lab";
const PUB_B: &str = "at://did:plc:ohutz6x5acjmpuulp3x7wxxc/site.standard.publication/3lac";
const PUB_C: &str = "at://did:plc:anotherrepoaaaaaaaaaaaaa/site.standard.publication/3lad";
#[tokio::test]
async fn the_rss_tick_never_selects_a_publication() {
let state = state_with_due(&[PUB_A]).await;
let client = feed::build_client().unwrap();
let (_tx, shutdown) = watch::channel(());
poll_due_once(
&state,
&client,
&Arc::new(Semaphore::new(4)),
50,
Duration::ZERO,
&shutdown,
)
.await
.unwrap();
let row = store::get_feed_by_url(&state.db, PUB_A)
.await
.unwrap()
.unwrap();
assert_eq!(row.next_poll, None, "the RSS tick touched a publication");
}
#[tokio::test]
async fn a_publication_pass_reads_each_due_row_once_and_stops() {
let mut state = state_with_due(&[PUB_A, PUB_B]).await;
let mut config = (*state.config).clone();
config.oauth.plc_directory = "http://plc.nowhere.invalid".into();
state.config = Arc::new(config);
let client = feed::build_client().unwrap();
let (_tx, shutdown) = watch::channel(());
tokio::time::timeout(
Duration::from_secs(30),
poll_publications_once(&state, &client, &shutdown),
)
.await
.expect("the pass did not stop")
.unwrap();
for url in [PUB_A, PUB_B] {
let row = store::get_feed_by_url(&state.db, url)
.await
.unwrap()
.unwrap();
assert!(row.next_poll.is_some(), "{url} was not read");
assert_eq!(row.consecutive_errors, 1, "{url} was read other than once");
}
}
#[tokio::test]
async fn a_publication_pass_reads_nothing_after_shutdown() {
let state = state_with_due(&[PUB_A]).await;
let client = feed::build_client().unwrap();
let (tx, shutdown) = watch::channel(());
tx.send(()).unwrap();
poll_publications_once(&state, &client, &shutdown)
.await
.unwrap();
let row = store::get_feed_by_url(&state.db, PUB_A)
.await
.unwrap()
.unwrap();
assert_eq!(
row.next_poll, None,
"a row the pass never read was deferred"
);
}
async fn with_config(
mut state: AppState,
f: impl FnOnce(&mut feather_reader::config::Config),
) -> AppState {
let mut config = (*state.config).clone();
f(&mut config);
state.config = Arc::new(config);
state
}
#[tokio::test]
async fn a_publication_pass_respects_the_watermark() {
let state = with_config(state_with_due(&[PUB_A]).await, |c| {
c.db_size_watermark_bytes = 1;
c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
})
.await;
let client = feed::build_client().unwrap();
let (_tx, shutdown) = watch::channel(());
poll_publications_once(&state, &client, &shutdown)
.await
.unwrap();
let row = store::get_feed_by_url(&state.db, PUB_A)
.await
.unwrap()
.unwrap();
assert_eq!(
row.next_poll, None,
"a publication was read over the watermark"
);
}
#[tokio::test]
async fn a_stuck_row_does_not_starve_the_rest() {
let state = state_with_due(&[PUB_A, PUB_C]).await;
let (_tx, shutdown) = watch::channel(());
let mut seen = Vec::new();
poll_publications_with(&state, &shutdown, |feeds, _| {
seen.extend(feeds.iter().map(|f| f.url.clone()));
async {}
})
.await
.unwrap();
seen.sort();
let mut want = vec![PUB_A.to_string(), PUB_C.to_string()];
want.sort();
assert_eq!(seen, want, "every due row, once each");
}
#[tokio::test]
async fn a_pass_reschedules_with_the_configured_interval() {
let state = with_config(state_with_due(&[PUB_A]).await, |c| {
c.poll_interval = Duration::from_secs(1234);
})
.await;
let (_tx, shutdown) = watch::channel(());
let mut intervals = Vec::new();
poll_publications_with(&state, &shutdown, |feeds, interval| {
intervals.push(interval);
let db = state.db.clone();
async move {
for f in feeds {
store::set_next_poll(&db, &f.url, interval).await.unwrap();
}
}
})
.await
.unwrap();
assert_eq!(intervals, vec![Duration::from_secs(1234)]);
}
#[tokio::test]
async fn the_watermark_is_checked_before_every_read() {
let state = state_with_due(&[PUB_A, PUB_C]).await;
let size = store::db_size_bytes(&state.db).await.unwrap();
let state = with_config(state, |c| c.db_size_watermark_bytes = size + 64 * 1024).await;
let (_tx, shutdown) = watch::channel(());
let mut reads = 0;
poll_publications_with(&state, &shutdown, |feeds, interval| {
reads += 1;
let db = state.db.clone();
async move {
sqlx::query("CREATE TABLE IF NOT EXISTS ballast (b BLOB)")
.execute(&db)
.await
.unwrap();
sqlx::query("INSERT INTO ballast VALUES (zeroblob(1048576))")
.execute(&db)
.await
.unwrap();
for f in feeds {
store::set_next_poll(&db, &f.url, interval).await.unwrap();
}
}
})
.await
.unwrap();
assert_eq!(reads, 1, "a second repo was read over the watermark");
}
#[tokio::test]
async fn publications_in_one_repo_are_read_together() {
let state = state_with_due(&[PUB_A, PUB_B, PUB_C]).await;
let (_tx, shutdown) = watch::channel(());
let mut calls: Vec<Vec<String>> = Vec::new();
poll_publications_with(&state, &shutdown, |feeds, _| {
let mut urls: Vec<String> = feeds.iter().map(|f| f.url.clone()).collect();
urls.sort();
calls.push(urls);
async {}
})
.await
.unwrap();
calls.sort();
let mut want = vec![
vec![PUB_A.to_string(), PUB_B.to_string()],
vec![PUB_C.to_string()],
];
want.sort();
assert_eq!(calls, want, "one read per repo");
}
#[tokio::test]
async fn more_than_a_thousand_stuck_rows_are_all_read() {
let alphabet: Vec<char> = "abcdefghijklmnopqrstuvwxyz234567".chars().collect();
let did = |mut n: usize| {
let mut s = String::new();
for _ in 0..24 {
s.push(alphabet[n % 32]);
n /= 32;
}
format!("did:plc:{s}")
};
let urls: Vec<String> = (0..1005)
.map(|i| format!("at://{}/site.standard.publication/3lab", did(i)))
.collect();
let refs: Vec<&str> = urls.iter().map(String::as_str).collect();
let state = state_with_due(&refs).await;
let (_tx, shutdown) = watch::channel(());
let mut read = 0usize;
poll_publications_with(&state, &shutdown, |feeds, _| {
read += feeds.len();
async {}
})
.await
.unwrap();
assert_eq!(read, 1005, "rows past the selection window were skipped");
}
#[tokio::test]
async fn the_rss_tick_respects_the_watermark() {
let rss = "https://rss.example/feed.xml";
let state = with_config(state_with_due(&[rss]).await, |c| {
c.db_size_watermark_bytes = 1
})
.await;
let client = feed::build_client().unwrap();
let (_tx, shutdown) = watch::channel(());
poll_due_once(
&state,
&client,
&Arc::new(Semaphore::new(4)),
50,
Duration::ZERO,
&shutdown,
)
.await
.unwrap();
let row = store::get_feed_by_url(&state.db, rss)
.await
.unwrap()
.unwrap();
assert_eq!(
row.next_poll, None,
"an RSS feed was polled over the watermark"
);
}
#[tokio::test]
async fn a_pass_never_reads_the_same_row_twice() {
let state = with_config(state_with_due(&[PUB_A]).await, |c| {
c.oauth.plc_directory = "http://plc.nowhere.invalid".into();
})
.await;
sqlx::query(
"CREATE TRIGGER no_lease BEFORE UPDATE OF next_poll ON feeds \
WHEN NEW.kind = 'publication' BEGIN SELECT RAISE(ABORT, 'disk full'); END",
)
.execute(&state.db)
.await
.unwrap();
let client = feed::build_client().unwrap();
let (_tx, shutdown) = watch::channel(());
tokio::time::timeout(
Duration::from_secs(10),
poll_publications_once(&state, &client, &shutdown),
)
.await
.expect("the pass kept re-reading a row it could not reschedule")
.unwrap();
let row = store::get_feed_by_url(&state.db, PUB_A)
.await
.unwrap()
.unwrap();
assert_eq!(
row.consecutive_errors, 1,
"read {} times in one pass",
row.consecutive_errors
);
}
#[test]
fn every_pollable_kind_has_a_poller() {
for kind in feed::FeedKind::POLLABLE {
let poller = poller_for(*kind);
assert!(
poller.is_some_and(|l| Loop::ALL.contains(&l)),
"{kind:?} has no running poller"
);
}
assert_eq!(poller_for(feed::FeedKind::Unsupported), None);
}
async fn rust_state() -> AppState {
let db = store::init_url("sqlite::memory:").await.unwrap();
AppState::new(
feather_reader::config::Config {
repo_backend: feather_reader::metrics::Backend::Rust,
oauth: feather_reader::config::OauthConfig {
key_path: std::env::temp_dir().join(format!(
"fr-sched-oauth-key-{}-{:p}.json",
std::process::id(),
&db as *const _
)),
encryption_key: Some("a".repeat(43)),
..feather_reader::config::OauthConfig::default()
},
..feather_reader::config::Config::default()
},
db,
)
.unwrap()
}
async fn dirty_cursor_for(state: &AppState, did: &str) {
store::upsert_cursor(
&state.db,
&ReadCursor {
did: did.to_string(),
feed_url: "https://example.com/feed.xml".into(),
read_through: None,
read_ids: "[\"1\"]".into(),
unread_ids: "[]".into(),
dirty: true,
pds_created: false,
updated_at: now_rfc3339(),
},
)
.await
.unwrap();
}
#[tokio::test]
async fn a_did_with_no_session_is_parked_not_retried() {
let state = rust_state().await;
let did = "did:plc:orphanedreadstate00000000";
dirty_cursor_for(&state, did).await;
let mut parked = HashSet::new();
const ROUNDS: usize = 5;
for round in 1..=ROUNDS {
flush_all_dirty(&state, &mut parked)
.await
.expect("a parked DID must not abort the sweep");
assert_eq!(
store::dirty_cursors(&state.db, did).await.unwrap().len(),
1,
"round {round}: the parked cursor was cleared — the reads are now lost",
);
}
let err = state
.metrics
.snapshot()
.into_iter()
.find(|r| r.op == "flush_read_states")
.map(|r| r.stats.err_count)
.unwrap_or(0);
assert_eq!(err, 0, "a parked DID was counted as {err} flush failures");
assert_eq!(parked.len(), 1, "the DID should be recorded as parked once");
}
#[tokio::test]
async fn a_parked_cursor_is_retried_once_the_user_signs_in_again() {
let state = rust_state().await;
let did = "did:plc:ewvi7nxzyoun6zhxrhs64oiz";
dirty_cursor_for(&state, did).await;
let mut parked = HashSet::new();
flush_all_dirty(&state, &mut parked).await.unwrap();
assert!(
parked.contains(did),
"precondition: parked while signed out"
);
assert_eq!(
state
.metrics
.snapshot()
.into_iter()
.find(|r| r.op == "flush_read_states")
.map(|r| r.stats.ok_count + r.stats.err_count)
.unwrap_or(0),
0,
"precondition: no flush was attempted while parked",
);
let runtime = state.oauth.as_deref().expect("oauth runtime");
feather_reader::oauth::store::put_session(
&state.db,
&runtime.codec,
&feather_reader::oauth::store::OAuthSession {
sub: did.into(),
issuer: "https://auth.invalid".into(),
aud: "https://pds.invalid".into(),
dpop_key_jwk: feather_reader::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(Utc::now().timestamp() + 3600),
},
)
.await
.unwrap();
flush_all_dirty(&state, &mut parked).await.unwrap();
assert!(
!parked.contains(did),
"the DID is still marked parked after regaining a session",
);
let attempts = state
.metrics
.snapshot()
.into_iter()
.find(|r| r.op == "flush_read_states")
.map(|r| r.stats.ok_count + r.stats.err_count)
.unwrap_or(0);
assert_eq!(
attempts, 1,
"the parked read-state was never re-attempted after sign-in",
);
assert_eq!(
store::dirty_cursors(&state.db, did).await.unwrap().len(),
1,
"the unflushed cursor must remain dirty after a failed attempt",
);
}
}