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).
- 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

View File

@@ -94,7 +94,7 @@ async fn pass(ctx: &Ctx) -> Result<bool> {
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<bool> {
.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<Vec<&Unjudged>> {
let mut out: Vec<Vec<&Unjudged>> = 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<Vec<&Unjudged>> {
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::<Vec<_>>()).collect::<Vec<_>>()
};
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<Vec<i64>> = 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]

View File

@@ -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::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": ... }`,
/// 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<String>) -> Self {
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 {
@@ -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)]| {

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).
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