Watch
0
0
Fork
You've already forked hyperhive
0

web ui: bulk mark-done shares one deadline and takes at most 200 ids

This commit is contained in:
damocles 2026-11-11 21:18:46 +01:00 • committed by mara
commit 93d9c56e2b

View file

@ -9,7 +9,7 @@ use axum::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::future::Future; use std::future::Future;
use super::{AppState, error_response}; use super::{AppState, SOCKET_FETCH_TIMEOUT, error_response};
pub(super) async fn post_cancel_turn(State(state): State<AppState>) -> Response { pub(super) async fn post_cancel_turn(State(state): State<AppState>) -> Response {
super::cancel_turn(&state.bus, &state.interrupted, "operator: /cancel"); super::cancel_turn(&state.bus, &state.interrupted, "operator: /cancel");
@ -52,6 +52,9 @@ pub(super) async fn post_new_session(State(state): State<AppState>) -> Response
(axum::http::StatusCode::OK, "ok").into_response() (axum::http::StatusCode::OK, "ok").into_response()
} }
/// The most ids one mark-done request may carry.
const MAX_MARK_DONE_IDS: usize = 200;
#[derive(Deserialize)] #[derive(Deserialize)]
pub(super) struct MarkTodosDoneForm { pub(super) struct MarkTodosDoneForm {
/// Comma-separated todo ids. Same "one field, JS joins the checked /// Comma-separated todo ids. Same "one field, JS joins the checked
@ -71,7 +74,10 @@ pub(super) struct MarkTodosDoneForm {
/// single-id path); a request with no ids or where every id fails to parse /// single-id path); a request with no ids or where every id fails to parse
/// is rejected as a client error rather than silently acking nothing. An id /// is rejected as a client error rather than silently acking nothing. An id
/// whose store call errors or gets no answer fails the request with a 500 /// whose store call errors or gets no answer fails the request with a 500
/// naming how many failed. /// naming how many failed. More than [`MAX_MARK_DONE_IDS`] ids is a client
/// error, and the calls share one [`SOCKET_FETCH_TIMEOUT`]: ids the store has
/// not answered by then count as unanswered, so a stalled store gets its 500
/// after about 3 seconds like the other handlers.
pub(super) async fn post_mark_todos_done(Form(form): Form<MarkTodosDoneForm>) -> Response { pub(super) async fn post_mark_todos_done(Form(form): Form<MarkTodosDoneForm>) -> Response {
let ids: Vec<i64> = form let ids: Vec<i64> = form
.ids .ids
@ -81,7 +87,13 @@ pub(super) async fn post_mark_todos_done(Form(form): Form<MarkTodosDoneForm>) ->
if ids.is_empty() { if ids.is_empty() {
return error_response(StatusCode::BAD_REQUEST, "mark-done: no todo ids selected"); return error_response(StatusCode::BAD_REQUEST, "mark-done: no todo ids selected");
} }
mark_ids_done(ids, |id| async move { if ids.len() > MAX_MARK_DONE_IDS {
return error_response(
StatusCode::BAD_REQUEST,
&format!("mark-done: at most {MAX_MARK_DONE_IDS} todo ids per request"),
);
}
mark_ids_done(ids, SOCKET_FETCH_TIMEOUT, |id| async move {
crate::todo_server::dial(&hive_agent_sock::Request::MarkTodoDone { id }).await crate::todo_server::dial(&hive_agent_sock::Request::MarkTodoDone { id }).await
}) })
.await .await
@ -90,14 +102,20 @@ pub(super) async fn post_mark_todos_done(Form(form): Form<MarkTodosDoneForm>) ->
/// The handler body behind `dial`, which answers one id's `MarkTodoDone` /// The handler body behind `dial`, which answers one id's `MarkTodoDone`
/// (`None` when the store doesn't answer). Tests inject it so they never /// (`None` when the store doesn't answer). Tests inject it so they never
/// reach the live in-agent socket. /// reach the live in-agent socket.
async fn mark_ids_done<F, Fut>(ids: Vec<i64>, dial: F) -> Response async fn mark_ids_done<F, Fut>(ids: Vec<i64>, within: std::time::Duration, dial: F) -> Response
where where
F: Fn(i64) -> Fut, F: Fn(i64) -> Fut,
Fut: Future<Output = Option<hive_agent_sock::Response>>, Fut: Future<Output = Option<hive_agent_sock::Response>>,
{ {
let deadline = tokio::time::Instant::now() + within;
let mut answers = Vec::with_capacity(ids.len()); let mut answers = Vec::with_capacity(ids.len());
for id in ids { for id in ids {
answers.push(dial(id).await); // Past the deadline every remaining id is an unanswered one.
answers.push(
tokio::time::timeout_at(deadline, dial(id))
.await
.unwrap_or(None),
);
} }
match settle_mark_done(answers) { match settle_mark_done(answers) {
Ok(acked) => axum::Json(MarkTodosDoneBody { acked }).into_response(), Ok(acked) => axum::Json(MarkTodosDoneBody { acked }).into_response(),
@ -156,7 +174,10 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn mark_done_handler_answers_500_with_the_message_when_the_store_does_not_answer() { async fn mark_done_handler_answers_500_with_the_message_when_the_store_does_not_answer() {
let response = mark_ids_done(vec![1, 2], |_| async { None }).await; let response = mark_ids_done(vec![1, 2], std::time::Duration::from_secs(3), |_| async {
None
})
.await;
assert_eq!( assert_eq!(
response.status(), response.status(),
axum::http::StatusCode::INTERNAL_SERVER_ERROR axum::http::StatusCode::INTERNAL_SERVER_ERROR
@ -168,7 +189,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn mark_done_handler_answers_500_with_the_store_error() { async fn mark_done_handler_answers_500_with_the_store_error() {
let response = mark_ids_done(vec![7], |_| async { let response = mark_ids_done(vec![7], std::time::Duration::from_secs(3), |_| async {
Some(hive_agent_sock::Response::Err { Some(hive_agent_sock::Response::Err {
message: "database is locked".to_owned(), message: "database is locked".to_owned(),
}) })
@ -183,7 +204,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn mark_done_handler_answers_200_with_the_acked_total() { async fn mark_done_handler_answers_200_with_the_acked_total() {
let response = mark_ids_done(vec![1, 2], |_| async { let response = mark_ids_done(vec![1, 2], std::time::Duration::from_secs(3), |_| async {
Some(hive_agent_sock::Response::Acked { count: 1 }) Some(hive_agent_sock::Response::Acked { count: 1 })
}) })
.await; .await;
@ -191,6 +212,38 @@ mod tests {
assert_eq!(body_text(response).await, r#"{"acked":2}"#); assert_eq!(body_text(response).await, r#"{"acked":2}"#);
} }
#[tokio::test(start_paused = true)]
async fn a_stalled_store_gets_one_deadline_for_all_ids_not_one_per_id() {
let started = tokio::time::Instant::now();
let response = mark_ids_done(
(1..=50).collect(),
std::time::Duration::from_secs(3),
|_| async {
tokio::time::sleep(std::time::Duration::from_hours(1)).await;
None
},
)
.await;
assert_eq!(
response.status(),
axum::http::StatusCode::INTERNAL_SERVER_ERROR
);
assert!(started.elapsed() < std::time::Duration::from_secs(4));
assert!(body_text(response).await.contains("50 of 50 failed"));
}
#[tokio::test]
async fn more_ids_than_the_cap_are_a_client_error() {
let ids = (0..=super::MAX_MARK_DONE_IDS)
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(",");
let response =
super::post_mark_todos_done(axum::Form(super::MarkTodosDoneForm { ids })).await;
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
assert!(body_text(response).await.contains("at most 200"));
}
#[test] #[test]
fn mark_done_with_no_answer_is_an_error() { fn mark_done_with_no_answer_is_an_error() {
let err = settle_mark_done(vec![None]).unwrap_err(); let err = settle_mark_done(vec![None]).unwrap_err();