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 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 15:13:02 +00:00
parent 79b496bdfb
commit 0de0fb4332
4 changed files with 59 additions and 29 deletions

View File

@@ -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). - 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). - 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). - 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 ## [1.4.0] - 2026-10-19

View File

@@ -94,7 +94,7 @@ async fn pass(ctx: &Ctx) -> Result<bool> {
hits = tracing::field::Empty hits = tracing::field::Empty
); );
async { async {
let groups = calls(&items, server.is_some()); let groups = calls(&items);
let paid = if server.is_some() { "server" } else { "own" }; let paid = if server.is_some() { "server" } else { "own" };
tracing::Span::current().record("calls", groups.len()); tracing::Span::current().record("calls", groups.len());
let refused = std::sync::Mutex::new(HashSet::new()); let refused = std::sync::Mutex::new(HashSet::new());
@@ -184,23 +184,11 @@ async fn pass(ctx: &Ctx) -> Result<bool> {
.await .await
} }
/// The calls a batch makes, each one item and the people asking about it. On the server's key /// The calls a batch makes: one an item and person, with all that person's Signals on it. On the
/// everyone's Signals on an item go in one call, its text sent once (#275); on people's own keys /// server's key everyone's Signals on an item went in one call, its text sent once (#275), and
/// each pays for their own. `unjudged` puts one item's rows together. /// then one person's description was in the question that decided another's verdicts (#343).
fn calls(items: &[Unjudged], share: bool) -> Vec<Vec<&Unjudged>> { fn calls(items: &[Unjudged]) -> Vec<Vec<&Unjudged>> {
let mut out: Vec<Vec<&Unjudged>> = Vec::new(); items.iter().map(|item| vec![item]).collect()
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
} }
/// A call TypeSafe answered with an error, and what it said: its body says why, where the status /// 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] #[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 { let row = |user_id, guid: &str| Unjudged {
user_id, user_id,
user_name: format!("u{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 }], 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 items = [row(1, "x"), row(2, "x"), row(1, "y")];
let shape = |share| { let shape: Vec<Vec<i64>> = calls(&items).iter().map(|g| g.iter().map(|i| i.user_id).collect()).collect();
calls(&items, share).iter().map(|g| g.iter().map(|i| i.user_id).collect::<Vec<_>>()).collect::<Vec<_>>() 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();
assert_eq!(shape(true), [vec![1, 2], vec![1]], "x asked once, for both"); let r = request(&items[0], &mine);
assert_eq!(shape(false), [vec![1], vec![2], vec![1]], "each on their own key"); assert!(r["questions"]["signal_10"].is_object() && r["questions"].get("signal_20").is_none());
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());
} }
#[test] #[test]

View File

@@ -498,6 +498,30 @@ struct IconAsk {
candidates: Vec<(String, String)>, 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::Mutex<std::collections::HashMap<i64, (std::time::Instant, u32)>>> =
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<i64, (std::time::Instant, u32)>,
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": ... }`, /// 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. /// or 404 when the server has no key.
async fn pick_signal_icon( async fn pick_signal_icon(
@@ -508,6 +532,9 @@ async fn pick_signal_icon(
let Some(key) = crate::signals::typesafe_key() else { let Some(key) = crate::signals::typesafe_key() else {
return Err(ApiError::not_found("picking an icon is not set up on this server")); 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 name = signal_name(&body.name)?;
let words = clean_words(body.keywords)?; let words = clean_words(body.keywords)?;
let description = signal_description(body.description)?; let description = signal_description(body.description)?;
@@ -1250,6 +1277,10 @@ impl ApiError {
fn bad_gateway(msg: impl Into<String>) -> Self { fn bad_gateway(msg: impl Into<String>) -> Self {
Self { error: anyhow::Error::msg(msg.into()), status: StatusCode::BAD_GATEWAY } Self { error: anyhow::Error::msg(msg.into()), status: StatusCode::BAD_GATEWAY }
} }
fn too_many(msg: impl Into<String>) -> Self {
Self { error: anyhow::Error::msg(msg.into()), status: StatusCode::TOO_MANY_REQUESTS }
}
} }
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
@@ -1265,6 +1296,18 @@ impl IntoResponse for ApiError {
mod tests { mod tests {
use super::*; 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] #[test]
fn a_change_sent_from_another_site_is_known() { fn a_change_sent_from_another_site_is_known() {
let h = |pairs: &[(&str, &str)]| { let h = |pairs: &[(&str, &str)]| {

View File

@@ -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). 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 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 Signals. Two people's Signals never share a call, even on the server's key, where they did until
sent once (#275). Up to ten calls are in flight at once (`IN_FLIGHT`): one at a time, about 150 ms #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', 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 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 second, and ipx keeps to 60 and 80,000, each call holding the line for a 60th of a second or its