Compressed answers, a directory twice as patient, fewer whole re-reads (#239, #242, #243, #244)

Measured on 2026-10-15: the feed list and item lists went out uncompressed at over 100 KB; ipx
read 2.6 GB of feed bodies a day and the directory 3.9 GB.

- ipx-web compresses with zstd or gzip, leaving out /api/events, which a compressor would hold
  back, and /media (#239).
- A body identical to the last one (SHA-256 in feeds.body_hash) is taken as a 304, unless
  something is missing a read would fill or the check is forced; and a feed without a schedule
  of its own whose body has been the same waits a twelfth of that, up to four hours, beside the
  failure backoff (#242). 35 Patreon show feeds sent ~300 KB each hourly, about 250 MB a day.
- The directory checks each feed every twelve hours, not six (#243).
- reqwest accepts brotli and zstd too (#244).

#240 (cache the feed list's counts) is left open: at production's size on the test Postgres
that count takes 12 ms and All Subscriptions 32 ms, against 366 and 300 ms in production, so
the cause is production's database, not the query (#241).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 13:54:00 +00:00
parent dbfe7918ac
commit 8f19d88fc9
17 changed files with 221 additions and 18 deletions

View File

@@ -95,6 +95,11 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
}
if orm.execute_unprepared("SELECT body_hash FROM feeds LIMIT 0").await.is_err() {
for sql in ["ALTER TABLE feeds ADD COLUMN body_hash TEXT", "ALTER TABLE feeds ADD COLUMN same_since BIGINT"] {
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
}
if orm.execute_unprepared("SELECT pause_ms FROM deadair_prefs LIMIT 0").await.is_err() {
let sql = "ALTER TABLE deadair_prefs ADD COLUMN pause_ms BIGINT";
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
@@ -294,6 +299,8 @@ pub struct FeedSummary {
pub last_error: Option<String>,
/// When this run of failures began; see the `error_since` column.
pub error_since: Option<i64>,
/// Since when the feed has sent the same body (#242).
pub same_since: Option<i64>,
pub entries: i64,
pub downloaded: i64,
}
@@ -499,6 +506,7 @@ impl Db {
last_error: f.last_error,
orphaned: f.orphaned,
error_since: f.error_since,
same_since: f.same_since,
category: f.category,
},
ttl_mins: f.ttl_mins.map(|t| t.max(0) as u64),
@@ -530,6 +538,9 @@ pub struct HttpState {
pub ttl_mins: Option<u64>,
/// When the feed's current run of failures began, for backing off (`due_after`).
pub error_since: Option<i64>,
/// The last body's hash, and since when it has been that body (#242).
pub body_hash: Option<String>,
pub same_since: Option<i64>,
}
impl From<feeds::Model> for HttpState {
@@ -540,6 +551,8 @@ impl From<feeds::Model> for HttpState {
last_checked: f.last_checked,
ttl_mins: f.ttl_mins.map(|t| t.max(0) as u64),
error_since: f.error_since,
body_hash: f.body_hash,
same_since: f.same_since,
}
}
}
@@ -606,6 +619,16 @@ impl Db {
}
/// 304, or any other poll that produced no new data: only the clock moves.
/// A body read whole that was not the last one: its hash, and that it changed now (#242).
pub async fn set_body(&self, feed_id: &str, hash: &str) -> Result<()> {
self.exec(
"UPDATE feeds SET body_hash = $2, same_since = $3 WHERE id = $1",
vec![feed_id.into(), hash.into(), now().into()],
)
.await?;
Ok(())
}
pub async fn touch_feed(&self, feed_id: &str, url: &str) -> Result<()> {
self.exec(
"INSERT INTO feeds (id, url, last_checked) VALUES ($1, $2, $3)
@@ -2581,6 +2604,23 @@ mod tests {
assert_eq!(db.http_state("f").await.unwrap().etag.as_deref(), Some("\"b\""));
}
#[tokio::test]
async fn a_database_from_before_body_hashes_gains_the_columns() {
let db = Db::memory().await.unwrap();
db.record_feed("f", "https://x/f", Some("F"), None, None, None, None, None, None).await.unwrap();
db.set_body("f", "abc").await.unwrap();
assert_eq!(db.http_state("f").await.unwrap().body_hash.as_deref(), Some("abc"));
// A database from before #242.
for sql in ["ALTER TABLE feeds DROP COLUMN body_hash", "ALTER TABLE feeds DROP COLUMN same_since"] {
db.exec_for_test(sql).await.unwrap();
}
create_missing(&db.orm).await.unwrap();
let st = db.http_state("f").await.unwrap();
assert_eq!((st.body_hash, st.same_since), (None, None), "every feed is read whole once more");
db.set_body("f", "def").await.unwrap();
assert_eq!(db.http_state("f").await.unwrap().body_hash.as_deref(), Some("def"));
}
#[tokio::test]
async fn every_sort_column_runs_and_orders_both_ways() {
let db = Db::memory().await.unwrap();