Drop the spent DeadAir clean-up and keep not-due skips off live updates (#196, #197)

The clean-up of DeadAir's old copies probed for its table on every open, and on
Postgres each failed probe is an ERROR in the server's log: 120 an hour from the
healthcheck alone. Production ran it already and is the only database.

A scan sent a not-due skip for each of ~1,535 feeds into a broadcast of 1,024,
so a page slow to read lagged and dropped events: 443 counted in five hours.
Nothing listening uses them; the terminal and the log still get them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 20:12:22 +00:00
parent 0d930e75ee
commit 12fadbd0f8
4 changed files with 22 additions and 43 deletions

View File

@@ -99,22 +99,6 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
let sql = "ALTER TABLE deadair_prefs ADD COLUMN pause_ms BIGINT";
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
// DeadAir made a shortened copy of each file until it played the original through a
// playlist (#181). The copies go with their table, and the files are looked at again, which
// takes seconds each. Found only while the table is there, so nothing is written after.
let copies = Statement::from_string(orm.get_database_backend(), "SELECT path FROM deadair WHERE path IS NOT NULL");
if let Ok(rows) = orm.query_all_raw(copies).await {
for r in rows {
// Only DeadAir's own files, `name.deadair.mp3`: anything else would be someone's original.
if let Ok(path) = r.try_get::<String>("", "path")
&& path.contains(".deadair.")
{
let _ = std::fs::remove_file(&path);
}
}
// IF EXISTS: the healthcheck's `ipx status` may open the database at the same moment.
orm.execute_unprepared("DROP TABLE IF EXISTS deadair").await.context("dropping DeadAir's copies")?;
}
Ok(())
}
@@ -2489,29 +2473,6 @@ pub fn now() -> i64 {
mod tests {
use super::*;
#[tokio::test]
async fn deadair_drops_its_old_copies_once() {
let db = Db::memory().await.unwrap();
let dir = std::env::temp_dir().join(format!("ipx-deadair-old-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (orig, copy) = (dir.join("a.mp3"), dir.join("a.deadair.mp3"));
std::fs::write(&orig, "original").unwrap();
std::fs::write(&copy, "copy").unwrap();
db.orm.execute_unprepared("CREATE TABLE deadair (enclosure_id BIGINT PRIMARY KEY, path TEXT)").await.unwrap();
for (id, path) in [(1, &copy), (2, &orig)] {
db.orm
.execute_unprepared(&format!("INSERT INTO deadair VALUES ({id}, '{}')", path.display()))
.await
.unwrap();
}
create_missing(&db.orm).await.unwrap();
assert!(!copy.exists(), "the copy goes");
assert!(orig.exists(), "a path that is not DeadAir's own is never removed");
assert!(db.orm.execute_unprepared("SELECT 1 FROM deadair").await.is_err(), "and the table");
create_missing(&db.orm).await.unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[tokio::test]
async fn deadair_looks_at_a_file_once_per_download_and_counts_an_episode_once() {
let db = Db::memory().await.unwrap();

View File

@@ -144,7 +144,12 @@ impl Emitter {
// line per feed per tick for "not due yet" would push everything worth reading
// out of the buffer within a few minutes.
log_event(&e, self.tx.is_some());
if let Some(tx) = &self.tx {
// A feed not due changes nothing a listener shows, and a scan has one for nearly every
// feed: 1,500 at once overflowed the channel's 1,024 for a page slow to read (#197).
let routine = matches!(&e, Event::FeedSkip { reason, .. } if reason.starts_with("not due"));
if let Some(tx) = &self.tx
&& !routine
{
// An error here only means nobody is listening yet.
let _ = tx.send(e.clone());
}
@@ -362,6 +367,17 @@ pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
mod tests {
use super::*;
#[test]
fn a_feed_not_due_stays_off_the_live_updates() {
let (tx, mut rx) = broadcast::channel(4);
let out = Emitter::socket(tx, false);
for i in 0..10 {
out.emit(Event::FeedSkip { feed: format!("f{i}"), reason: "not due for 5m".into() });
}
out.emit(Event::FeedSkip { feed: "g".into(), reason: "not modified".into() });
assert!(matches!(rx.try_recv(), Ok(Event::FeedSkip { feed, .. }) if feed == "g"), "nothing missed");
}
#[test]
fn a_feed_that_moved_says_so_on_the_wire_and_in_words() {
let e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() };

View File

@@ -1970,15 +1970,15 @@ async fn events(
Sse::new(futures_util::StreamExt::flatten(stream)).keep_alive(axum::response::sse::KeepAlive::default())
}
/// The feed an event changed what the list shows of: its counts, error or last check. Not the
/// routine skip of a feed not due, dozens a minute that change nothing.
/// The feed an event changed what the list shows of: its counts, error or last check. The
/// routine skip of a feed not due never reaches here (`Emitter::emit`).
fn changed_feed(ev: &Event) -> Option<&str> {
match ev {
Event::FeedDone { feed, .. }
| Event::FeedError { feed, .. }
| Event::FeedMoved { feed, .. }
| Event::DownloadDone { feed, .. } => Some(feed),
Event::FeedSkip { feed, reason } if !reason.starts_with("not due") => Some(feed),
Event::FeedSkip { feed, .. } => Some(feed),
_ => None,
}
}