Back off a failing feed exponentially, up to a day (#99)
A feed that failed was tried again on its usual schedule however long it had been failing: gizmodo's 404, pelgrane's 403, daily-quests' 503 and toddstashwick's redirect loop every hour, each a request to a site that had said no, a warning and scan time. A failing feed now waits as long as it has been failing, from error_since to its last check, never less than its usual interval and never more than a day: 1h, 1h, 2h, 4h, 8h, 16h, then daily on an hourly schedule. No new column: error_since already marks the run's start and the first success clears it. A forced refresh skips the due check, so it still tries at once. The feed list's next check follows the backoff. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- A feed that keeps failing is checked less and less often, waiting as long as it has been failing, up to once a day; it goes back to its schedule as soon as it works. Refreshing it still checks it at once.
|
||||||
- A scan's trace shows the time a feed spends on its artwork and in the database after the fetch.
|
- A scan's trace shows the time a feed spends on its artwork and in the database after the fetch.
|
||||||
- The JSON log carries each line's `trace_id` and `span_id`, logs each scan event once instead of
|
- The JSON log carries each line's `trace_id` and `span_id`, logs each scan event once instead of
|
||||||
twice, names a failure's kind in `error.type` (and its HTTP status in
|
twice, names a failure's kind in `error.type` (and its HTTP status in
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ media_types = ["audio", "video"]
|
|||||||
database as described above; `download_dir`, `socket` and `organize` stay in config.toml.
|
database as described above; `download_dir`, `socket` and `organize` stay in config.toml.
|
||||||
|
|
||||||
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
|
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
|
||||||
be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI.
|
be polled *less* often, and a per-feed `schedule` overrides both. A feed that keeps failing
|
||||||
|
waits as long as it has been failing before the next try, up to a day, and is back on schedule
|
||||||
|
after its first success. Admin-only from the UI.
|
||||||
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
|
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
|
||||||
* **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB
|
* **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB
|
||||||
pad. Kept items are never deleted, and a file only counts as read once every subscriber has
|
pad. Kept items are never deleted, and a file only counts as read once every subscriber has
|
||||||
|
|||||||
@@ -415,6 +415,8 @@ pub struct HttpState {
|
|||||||
pub last_modified: Option<String>,
|
pub last_modified: Option<String>,
|
||||||
pub last_checked: Option<i64>,
|
pub last_checked: Option<i64>,
|
||||||
pub ttl_mins: Option<u64>,
|
pub ttl_mins: Option<u64>,
|
||||||
|
/// When the feed's current run of failures began, for backing off (`due_after`).
|
||||||
|
pub error_since: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
@@ -427,6 +429,7 @@ impl Db {
|
|||||||
last_modified: f.last_modified,
|
last_modified: f.last_modified,
|
||||||
last_checked: f.last_checked,
|
last_checked: f.last_checked,
|
||||||
ttl_mins: f.ttl_mins.map(|t| t.max(0) as u64),
|
ttl_mins: f.ttl_mins.map(|t| t.max(0) as u64),
|
||||||
|
error_since: f.error_since,
|
||||||
})
|
})
|
||||||
.unwrap_or_default())
|
.unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/main.rs
45
src/main.rs
@@ -981,7 +981,7 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !force && let Some(last) = state.last_checked {
|
if !force && let Some(last) = state.last_checked {
|
||||||
let due = last + due_after(&cfg, feed_cfg, state.ttl_mins) as i64;
|
let due = last + due_after(&cfg, feed_cfg, state.ttl_mins, state.error_since, last) as i64;
|
||||||
if due > db::now() {
|
if due > db::now() {
|
||||||
ctx.out.emit(Event::FeedSkip {
|
ctx.out.emit(Event::FeedSkip {
|
||||||
feed: id.clone(),
|
feed: id.clone(),
|
||||||
@@ -1165,13 +1165,34 @@ async fn retire_stranded(ctx: &Ctx) -> Result<usize> {
|
|||||||
///
|
///
|
||||||
/// A per-feed schedule is an explicit instruction and wins outright. Without one, the
|
/// A per-feed schedule is an explicit instruction and wins outright. Without one, the
|
||||||
/// global schedule applies, but the feed's own <ttl> raises it when the publisher asks to
|
/// global schedule applies, but the feed's own <ttl> raises it when the publisher asks to
|
||||||
/// be polled less often.
|
/// be polled less often. A feed that is failing backs off (`backoff`), from `error_since`, when
|
||||||
pub fn due_after(cfg: &config::Config, feed: &config::Feed, ttl_mins: Option<u64>) -> u64 {
|
/// its run of failures began, to `last_checked`, when it last failed.
|
||||||
|
pub fn due_after(
|
||||||
|
cfg: &config::Config,
|
||||||
|
feed: &config::Feed,
|
||||||
|
ttl_mins: Option<u64>,
|
||||||
|
error_since: Option<i64>,
|
||||||
|
last_checked: i64,
|
||||||
|
) -> u64 {
|
||||||
let mins = match feed.schedule.as_deref().and_then(config::parse_interval) {
|
let mins = match feed.schedule.as_deref().and_then(config::parse_interval) {
|
||||||
Some(explicit) => explicit,
|
Some(explicit) => explicit,
|
||||||
None => ttl_mins.unwrap_or(0).max(cfg.general.interval()),
|
None => ttl_mins.unwrap_or(0).max(cfg.general.interval()),
|
||||||
};
|
};
|
||||||
mins * 60
|
let failing_for = error_since.map(|since| (last_checked - since).max(0) as u64);
|
||||||
|
backoff(mins * 60, failing_for)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A failing feed waits as long as it has been failing, so the wait doubles with each failure
|
||||||
|
/// (1h, 1h, 2h, 4h, ... on an hourly schedule), never less than its usual interval and, past that,
|
||||||
|
/// never more than a day. Retried hourly, a feed dead for good cost a request, a warning and scan
|
||||||
|
/// time every hour (#99). The first success clears error_since, and with it the backoff; a
|
||||||
|
/// refresh someone asks for is not held back by it.
|
||||||
|
fn backoff(usual: u64, failing_for: Option<u64>) -> u64 {
|
||||||
|
const CEILING: u64 = 86_400;
|
||||||
|
match failing_for {
|
||||||
|
Some(f) => usual.max(f.min(CEILING)),
|
||||||
|
None => usual,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -1836,6 +1857,22 @@ fn duration(secs: u64) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failing_feed_backs_off_doubling_up_to_a_day() {
|
||||||
|
let hour = 3600;
|
||||||
|
assert_eq!(backoff(hour, None), hour);
|
||||||
|
// Failing since 0: each check waits as long as the failure has lasted so far.
|
||||||
|
let (mut at, mut waits) = (0, vec![]);
|
||||||
|
for _ in 0..8 {
|
||||||
|
let w = backoff(hour, Some(at));
|
||||||
|
waits.push(w / hour);
|
||||||
|
at += w;
|
||||||
|
}
|
||||||
|
assert_eq!(waits, [1, 1, 2, 4, 8, 16, 24, 24]);
|
||||||
|
// A weekly schedule is longer than the ceiling and stays as it is.
|
||||||
|
assert_eq!(backoff(7 * 86_400, Some(30 * 86_400)), 7 * 86_400);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn the_first_start_moves_the_configuration_in_and_trims_the_file() {
|
async fn the_first_start_moves_the_configuration_in_and_trims_the_file() {
|
||||||
let dir = std::env::temp_dir().join(format!("ipx-assemble-{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("ipx-assemble-{}", std::process::id()));
|
||||||
|
|||||||
@@ -790,11 +790,11 @@ async fn feeds(
|
|||||||
.schedule
|
.schedule
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(crate::config::parse_interval),
|
.and_then(crate::config::parse_interval),
|
||||||
every_mins: crate::due_after(&cfg, feed, ttl_mins) / 60,
|
every_mins: crate::due_after(&cfg, feed, ttl_mins, None, 0) / 60,
|
||||||
last_checked: s.last_checked,
|
last_checked: s.last_checked,
|
||||||
next_check: s
|
next_check: s
|
||||||
.last_checked
|
.last_checked
|
||||||
.map(|t| t + crate::due_after(&cfg, feed, ttl_mins) as i64),
|
.map(|t| t + crate::due_after(&cfg, feed, ttl_mins, s.error_since, t) as i64),
|
||||||
failing: s
|
failing: s
|
||||||
.error_since
|
.error_since
|
||||||
.filter(|since| crate::db::now() - since >= FLAG_AFTER_SECS)
|
.filter(|since| crate::db::now() - since >= FLAG_AFTER_SECS)
|
||||||
|
|||||||
Reference in New Issue
Block a user