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)]| {