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:
parent
30d9e1d8e6
commit
6f0a72b246
5 changed files with 208 additions and 17 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue