From 0de0fb4332e2b5f94e64301151d9c6cbfe06a01c Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 23 Oct 2026 15:13:02 +0000 Subject: [PATCH] Bound icon picks per person, and keep people's Signals apart (#340 #343) #340: POST /api/signals/icon spends the server's TYPESAFE_KEY for anyone signed in, whatever signals_on_server says, and is not tied to a Signal, so a loop on it ran up the bill and, through the shared pace and a 429's back-off, held back everyone's judging. Each person now has 20 picks an hour; past that it is a 429 the page shows as a toast. #343: on the server's key, calls() put everyone's Signals on an item in one call (#275), so one person's free-text description was part of the question that decided another's verdicts. Each person's Signals on an item are now a call of their own. The saving #275 made is lost only where two people have Signals on the same item. Co-Authored-By: Claude Opus 5.5 --- crates/ipx/CHANGELOG.md | 2 ++ crates/ipx/src/signals.rs | 39 +++++++++++------------------------ crates/ipx/src/web.rs | 43 +++++++++++++++++++++++++++++++++++++++ docs/signals.md | 4 ++-- 4 files changed, 59 insertions(+), 29 deletions(-) diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index 2bab34e..8ead064 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -40,6 +40,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Only someone subscribed to a feed, or an admin, may mark it private. Anyone signed in could keep anyone's feed, or a whole OPML's, out of the directory, and only an admin could undo it (#333). - An OPML upload may add up to 2,000 feeds; one with more is refused and adds none. Importing tens of thousands held up every change to the feed list for minutes and left them all in it (#338). - Password checks stay a few at a time when the person signing in gives up before the answer. Each sign-in that hung up let another start, so a flood of them used every core (#324). +- Jev picks at most 20 icons an hour for each person, on the server's key; past that the page says to choose one by hand. Anyone signed in could have it pick without end, on the server's bill, holding back everyone's Signals (#340). +- Each person's Signals on an item are judged in a call of their own, on the server's key too. One person's description was in the question that decided another's verdicts (#343). ## [1.4.0] - 2026-10-19 diff --git a/crates/ipx/src/signals.rs b/crates/ipx/src/signals.rs index df83294..4e058e8 100644 --- a/crates/ipx/src/signals.rs +++ b/crates/ipx/src/signals.rs @@ -94,7 +94,7 @@ async fn pass(ctx: &Ctx) -> Result { hits = tracing::field::Empty ); async { - let groups = calls(&items, server.is_some()); + let groups = calls(&items); let paid = if server.is_some() { "server" } else { "own" }; tracing::Span::current().record("calls", groups.len()); let refused = std::sync::Mutex::new(HashSet::new()); @@ -184,23 +184,11 @@ async fn pass(ctx: &Ctx) -> Result { .await } -/// The calls a batch makes, each one item and the people asking about it. On the server's key -/// everyone's Signals on an item go in one call, its text sent once (#275); on people's own keys -/// each pays for their own. `unjudged` puts one item's rows together. -fn calls(items: &[Unjudged], share: bool) -> Vec> { - let mut out: Vec> = Vec::new(); - for item in items { - match out.last_mut() { - Some(g) - if share - && (g[0].feed_id.as_str(), g[0].guid.as_str()) == (item.feed_id.as_str(), item.guid.as_str()) => - { - g.push(item) - } - _ => out.push(vec![item]), - } - } - out +/// The calls a batch makes: one an item and person, with all that person's Signals on it. On the +/// server's key everyone's Signals on an item went in one call, its text sent once (#275), and +/// then one person's description was in the question that decided another's verdicts (#343). +fn calls(items: &[Unjudged]) -> Vec> { + items.iter().map(|item| vec![item]).collect() } /// A call TypeSafe answered with an error, and what it said: its body says why, where the status @@ -590,7 +578,7 @@ mod tests { } #[test] - fn on_the_servers_key_one_item_is_one_call_for_everyone() { + fn one_persons_signals_never_share_a_call_with_anothers() { let row = |user_id, guid: &str| Unjudged { user_id, user_name: format!("u{user_id}"), @@ -603,14 +591,11 @@ mod tests { signals: vec![Ask { id: user_id * 10, name: "s".into(), keywords: vec![], description: None }], }; let items = [row(1, "x"), row(2, "x"), row(1, "y")]; - let shape = |share| { - calls(&items, share).iter().map(|g| g.iter().map(|i| i.user_id).collect::>()).collect::>() - }; - assert_eq!(shape(true), [vec![1, 2], vec![1]], "x asked once, for both"); - assert_eq!(shape(false), [vec![1], vec![2], vec![1]], "each on their own key"); - let both: Vec<&Ask> = calls(&items, true)[0].iter().flat_map(|i| &i.signals).collect(); - let r = request(&items[0], &both); - assert!(r["questions"]["signal_10"].is_object() && r["questions"]["signal_20"].is_object()); + let shape: Vec> = calls(&items).iter().map(|g| g.iter().map(|i| i.user_id).collect()).collect(); + assert_eq!(shape, [vec![1], vec![2], vec![1]], "x is asked for each of them apart (#343)"); + let mine: Vec<&Ask> = calls(&items)[0].iter().flat_map(|i| &i.signals).collect(); + let r = request(&items[0], &mine); + assert!(r["questions"]["signal_10"].is_object() && r["questions"].get("signal_20").is_none()); } #[test] diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index f0298c1..ab2b329 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -498,6 +498,30 @@ struct IconAsk { candidates: Vec<(String, String)>, } +/// How many icons one person may have Jev pick in an hour. Each is a call on the server's key, and +/// the route is not tied to a Signal, so a loop on it ran up the server's bill and held back +/// everyone's Signals behind the shared pace (#340). A Signal is named a few times at most. +const ICON_PICKS_AN_HOUR: u32 = 20; +static ICON_PICKS: std::sync::LazyLock>> = + std::sync::LazyLock::new(Default::default); + +/// Counts a pick against the person's hour, which starts at their first pick in it. +fn icon_pick_allowed( + picks: &mut std::collections::HashMap, + user: i64, + now: std::time::Instant, +) -> bool { + let (start, used) = picks.entry(user).or_insert((now, 0)); + if now.duration_since(*start) >= std::time::Duration::from_secs(3600) { + (*start, *used) = (now, 0); + } + if *used >= ICON_PICKS_AN_HOUR { + return false; + } + *used += 1; + true +} + /// Jev's pick of an icon for a Signal, on the server's TypeSafe key (#263). Answers `{ "icon": ... }`, /// or 404 when the server has no key. async fn pick_signal_icon( @@ -508,6 +532,9 @@ async fn pick_signal_icon( let Some(key) = crate::signals::typesafe_key() else { return Err(ApiError::not_found("picking an icon is not set up on this server")); }; + if !icon_pick_allowed(&mut ICON_PICKS.lock().unwrap(), user.id, std::time::Instant::now()) { + return Err(ApiError::too_many("enough icons picked for one hour; choose one by hand")); + } let name = signal_name(&body.name)?; let words = clean_words(body.keywords)?; let description = signal_description(body.description)?; @@ -1250,6 +1277,10 @@ impl ApiError { fn bad_gateway(msg: impl Into) -> Self { Self { error: anyhow::Error::msg(msg.into()), status: StatusCode::BAD_GATEWAY } } + + fn too_many(msg: impl Into) -> Self { + Self { error: anyhow::Error::msg(msg.into()), status: StatusCode::TOO_MANY_REQUESTS } + } } impl IntoResponse for ApiError { @@ -1265,6 +1296,18 @@ impl IntoResponse for ApiError { mod tests { use super::*; + #[test] + fn icon_picks_are_counted_by_the_hour_and_the_person() { + let mut picks = std::collections::HashMap::new(); + let t = std::time::Instant::now(); + for _ in 0..ICON_PICKS_AN_HOUR { + assert!(icon_pick_allowed(&mut picks, 1, t)); + } + assert!(!icon_pick_allowed(&mut picks, 1, t), "one too many (#340)"); + assert!(icon_pick_allowed(&mut picks, 2, t), "someone else has their own hour"); + assert!(icon_pick_allowed(&mut picks, 1, t + std::time::Duration::from_secs(3600)), "the next hour"); + } + #[test] fn a_change_sent_from_another_site_is_known() { let h = |pairs: &[(&str, &str)]| { diff --git a/docs/signals.md b/docs/signals.md index 3dfba5f..d309796 100644 --- a/docs/signals.md +++ b/docs/signals.md @@ -92,8 +92,8 @@ The recent half reads the index `entries_recent`, on each feed's items by date, is asked by; without it each wake read all of `entries`, 605 MB, 566 ms to find nothing (#274). Rows for one item and one person are grouped, so an item is one call whatever the number of -Signals. On the server's key, everyone's Signals on the same item go in one call too, its text -sent once (#275). Up to ten calls are in flight at once (`IN_FLIGHT`): one at a time, about 150 ms +Signals. Two people's Signals never share a call, even on the server's key, where they did until +#343: one person's description was then in the question that decided the other's verdicts. Up to ten calls are in flight at once (`IN_FLIGHT`): one at a time, about 150 ms each, a month of items took twenty minutes. Every call to Jev, Signals', icons' and key checks', takes a turn (`pace`, #277): TypeSafe's default limits are 80 requests and 100,000 input tokens a second, and ipx keeps to 60 and 80,000, each call holding the line for a 60th of a second or its