A snapshot's subscriber counts are bounded above (#342)
Store::snapshot clamped a count only from below, and LISTING sums each feed's counts across instances, cast to BIGINT. Two instances sending i64::MAX for a feed overflowed it (Postgres: bigint out of range; SQLite: integer overflow), and list() and popular() failed for every feed: the public pages 503, /v1/feeds and /v1/popular 500, until an admin dropped the instance. Counts are now clamped to a million. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- HTTPS connections no longer trust certificates from emSign's distrusted intermediates, and certificate checks use constant-time comparisons: rustls-webpki, webpki-roots and ctutils are updated (#351).
|
- HTTPS connections no longer trust certificates from emSign's distrusted intermediates, and certificate checks use constant-time comparisons: rustls-webpki, webpki-roots and ctutils are updated (#351).
|
||||||
- The public pages read the list from the database at most once a minute, however many people ask. Each page asked for the whole catalogue, so a flood of requests with query strings of their own reached the database every time (#341).
|
- The public pages read the list from the database at most once a minute, however many people ask. Each page asked for the whole catalogue, so a flood of requests with query strings of their own reached the database every time (#341).
|
||||||
- The directory no longer fetches feeds at internal addresses (loopback, the LAN, Docker's networks), named in a snapshot or reached by a redirect, unless directory.toml's `fetch_internal` lists them. It fetched them, and stored what answered as the feed's title and description (#329).
|
- The directory no longer fetches feeds at internal addresses (loopback, the LAN, Docker's networks), named in a snapshot or reached by a redirect, unless directory.toml's `fetch_internal` lists them. It fetched them, and stored what answered as the feed's title and description (#329).
|
||||||
|
- A server's count of a feed's subscribers is taken up to a million. Two servers sending the largest number there is made the sum overflow, and every listing and page failed until one was dropped (#342).
|
||||||
|
|
||||||
## [1.3.0] - 2026-10-19
|
## [1.3.0] - 2026-10-19
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ pub const ITEMS: usize = 20;
|
|||||||
/// How long a feed's failure goes unexplained: a site down for an hour is not called gone (ipx#143).
|
/// How long a feed's failure goes unexplained: a site down for an hour is not called gone (ipx#143).
|
||||||
const FLAG_AFTER_SECS: i64 = 24 * 3600;
|
const FLAG_AFTER_SECS: i64 = 24 * 3600;
|
||||||
|
|
||||||
|
/// The most subscribers one instance may say a feed has.
|
||||||
|
pub const MAX_SUBSCRIBERS: i64 = 1_000_000;
|
||||||
|
|
||||||
pub struct Store {
|
pub struct Store {
|
||||||
db: DatabaseConnection,
|
db: DatabaseConnection,
|
||||||
}
|
}
|
||||||
@@ -221,7 +224,9 @@ impl Store {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// The same feed twice in one snapshot counts once, at its larger number.
|
// The same feed twice in one snapshot counts once, at its larger number.
|
||||||
let n = e.subscribers.max(0);
|
// Bounded above as well: two instances' i64::MAX summed past BIGINT, and every listing
|
||||||
|
// and page failed until the instance was dropped (#342). No server has a million.
|
||||||
|
let n = e.subscribers.clamp(0, MAX_SUBSCRIBERS);
|
||||||
let path = e.category.as_deref().map(str::trim).filter(|c| !c.is_empty()).map(ipx_feed::category_path);
|
let path = e.category.as_deref().map(str::trim).filter(|c| !c.is_empty()).map(ipx_feed::category_path);
|
||||||
let slot = keep.entry(id).or_insert((url, n, path.clone()));
|
let slot = keep.entry(id).or_insert((url, n, path.clone()));
|
||||||
slot.1 = slot.1.max(n);
|
slot.1 = slot.1.max(n);
|
||||||
@@ -548,6 +553,17 @@ pub(crate) mod tests {
|
|||||||
assert!(normalise("not a url").is_none());
|
assert!(normalise("not a url").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn no_count_an_instance_sends_breaks_the_listing() {
|
||||||
|
let s = Store::scratch().await;
|
||||||
|
s.snapshot("a", &[e("https://x.test/a.xml", i64::MAX)]).await.unwrap();
|
||||||
|
s.snapshot("b", &[e("https://x.test/a.xml", i64::MAX)]).await.unwrap();
|
||||||
|
fetch_all(&s).await;
|
||||||
|
assert_eq!(s.list().await.unwrap()[0].subscribers, 2 * MAX_SUBSCRIBERS, "summed, not overflowed (#342)");
|
||||||
|
assert_eq!(s.popular(10).await.unwrap()[0].subscribers, 2 * MAX_SUBSCRIBERS);
|
||||||
|
assert!(s.instances().await.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn snapshots_replace_what_an_instance_sent_and_add_up_across_instances() {
|
async fn snapshots_replace_what_an_instance_sent_and_add_up_across_instances() {
|
||||||
let s = Store::scratch().await;
|
let s = Store::scratch().await;
|
||||||
|
|||||||
Reference in New Issue
Block a user