Watch
0
0
Fork
You've already forked hyperhive
0

review classifier: recover from a stored record it can't decode

The controller keeps the classifier's newest runs as one JSON array and
its settings as one JSON object, each under one KV key. A document this
build could not decode, as after a rollback past a build that added an
outcome or a settings field, failed every read and every write until
someone deleted the key by hand. A classifier newer than its controller
refused every run request for the missing `model`.

The run store now decodes the array one record at a time: a record that
does not decode is dropped with a WARN naming the bucket, key, index and
error, and a document that is not an array reads as no runs. Listing
keeps working, and the next recorded run writes back only the records
that decoded.

The stored settings ignore a field this build does not know; one that
still does not decode reads as the defaults with a WARN. The PUT body
keeps `deny_unknown_fields`. A PUT now always writes, so a PUT of the
defaults also replaces an undecodable document.

A run request without `model` runs on haiku, the swarm-wide setting's
default, now one constant in swarm-queue-client.

`schedules::read` gains a sibling, `read_with`, taking the decoder.

Closes #5162
This commit is contained in:
atlas 2026-10-13 02:24:26 +02:00 • committed by mara
commit 6f0a72b246
5 changed files with 208 additions and 17 deletions

View file

@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize};
use swarm_queue_client::agent_inference::Effort;
use utoipa::ToSchema;
use crate::schedules::{queue, read, refused, write};
use crate::schedules::{queue, read_with, refused, write};
use crate::{AppState, error_problem, wanted_error_status};
/// The bucket holding the settings. No other service holds a grant on it.
@ -59,7 +59,7 @@ pub(crate) struct Settings {
impl Default for Settings {
fn default() -> Self {
Self {
model: "haiku".to_owned(),
model: swarm_queue_client::review_classifier::DEFAULT_MODEL.to_owned(),
effort: None,
max_concurrent_reviews: 2,
}
@ -90,13 +90,40 @@ pub(crate) trait SettingsSource {
impl SettingsSource for async_nats::Client {
async fn settings(&self) -> anyhow::Result<Settings> {
let (settings, _) = read::<Settings>(&open(self).await?, KEY)
let (settings, _) = stored(&open(self).await?)
.await
.context("reading the review classifier's settings")?;
Ok(settings)
}
}
/// The stored settings, with the revision to write them back against.
async fn stored(store: &Store) -> anyhow::Result<(Settings, Option<u64>)> {
read_with(store, KEY, |bytes| Ok(decode(bytes))).await
}
/// `bytes`, the stored document, as settings. Unlike a `PUT` body, a field
/// this build does not know, such as one a newer build added, is ignored;
/// a document that still does not decode is [`Settings::default`], with a
/// warning, until the next `PUT` replaces it. The known fields are those
/// [`Settings::default`] encodes, which is every field.
fn decode(bytes: &[u8]) -> Settings {
let decoded = serde_json::from_slice(bytes).and_then(|mut value: serde_json::Value| {
let known = serde_json::to_value(Settings::default())?;
if let (Some(fields), Some(known)) = (value.as_object_mut(), known.as_object()) {
fields.retain(|name, _| known.contains_key(name));
}
serde_json::from_value(value)
});
decoded.unwrap_or_else(|e| {
tracing::warn!(
bucket = BUCKET, key = KEY, error = %e,
"the review classifier settings are not decodable; using the defaults"
);
Settings::default()
})
}
/// Open [`BUCKET`], creating it if nothing has yet.
async fn open(client: &async_nats::Client) -> anyhow::Result<Store> {
let js = async_nats::jetstream::new(client.clone());
@ -185,13 +212,10 @@ pub(crate) async fn put_settings(
let client = queue(&state).map_err(refused)?;
let store = open(&client).await.map_err(|e| queue_problem(&e))?;
for _ in 0..MAX_ATTEMPTS {
let (current, revision) = read::<Settings>(&store, KEY)
let (_, revision) = stored(&store).await.map_err(|e| queue_problem(&e))?;
if write(&store, KEY, &value, revision)
.await
.map_err(|e| queue_problem(&e))?;
if current == value
|| write(&store, KEY, &value, revision)
.await
.map_err(|e| queue_problem(&e))?
.map_err(|e| queue_problem(&e))?
{
state.reviews.resize(value.max_concurrent_reviews);
tracing::info!(%operator, ?value, "set the review classifier's settings");
@ -314,6 +338,56 @@ mod tests {
.expect("a request")
}
/// A stored field this build does not know is ignored, and a document
/// that still does not decode reads as the defaults; either way the GET
/// and a run's read keep working, and a PUT, even of the defaults,
/// replaces it with one that decodes.
#[tokio::test]
async fn an_undecodable_document_reads_as_the_defaults_until_a_put() {
let nats = TestNats::start();
let (app, _) = app(&nats).await;
let client = nats.client().await;
let store = open(&client).await.expect("the bucket");
let defaults = serde_json::to_value(Settings::default()).unwrap();
let newer = serde_json::json!({
"model": "sonnet", "effort": "high", "max_concurrent_reviews": 3, "later": true,
});
store
.put(KEY, newer.to_string().into())
.await
.expect("written");
let (status, read, _) = send(&app, get()).await;
assert_eq!(status, StatusCode::OK, "{read}");
assert_eq!(
read,
serde_json::json!({"model": "sonnet", "effort": "high", "max_concurrent_reviews": 3})
);
store
.put(
KEY,
br#"{"model":"sonnet","effort":"later"}"#.to_vec().into(),
)
.await
.expect("written");
let (status, read, _) = send(&app, get()).await;
assert_eq!(status, StatusCode::OK, "{read}");
assert_eq!(read, defaults);
assert_eq!(
client.settings().await.expect("readable"),
Settings::default()
);
let (status, stored, _) = send(&app, put(&defaults.to_string())).await;
assert_eq!(status, StatusCode::OK, "{stored}");
let entry = store.get(KEY).await.expect("readable").expect("a document");
assert_eq!(
serde_json::from_slice::<Settings>(&entry).expect("decodes strictly"),
Settings::default()
);
}
#[tokio::test]
async fn a_put_is_what_the_get_and_the_next_run_read() {
let nats = TestNats::start();