Add settings-configurable cap on logs kept in memory (ticket 026)

Settings.max_logs (default None = unlimited) is pushed to the engine
via a new EngineMessage::SetMaxLogs, enforced on every ingest and
whenever the setting changes at runtime (no restart required).
Eviction drops the oldest (lowest-sequence) logs; sequence numbering
isn't renumbered and buckets already derived from an evicted log are
left in place. Exposed via PATCH /api/settings (max_logs: number |
null) and a NavMenu numeric field (blank = unlimited).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 15:12:12 +01:00
co-authored by Claude Sonnet 5
parent aaa5596030
commit 1d0f324c43
8 changed files with 261 additions and 16 deletions
+99 -10
View File
@@ -444,21 +444,37 @@ async fn get_settings(State(state): State<ApiState>) -> impl IntoResponse {
}
/// `PATCH /api/settings` — updates process-wide settings. Body:
/// `{ "color_scheme": "light" | "dark" | "system" }` (omit the field, or
/// send `null`, to leave it unchanged). Persists to `settings_path` and
/// returns the updated settings as `SettingsDto`.
/// `{ "color_scheme": "light" | "dark" | "system", "max_logs": number |
/// null }` (omit a field to leave it unchanged; `max_logs: null` clears the
/// cap back to unlimited). Persists to `settings_path`, pushes a changed
/// `max_logs` to the engine so it takes effect on the next ingest without a
/// restart, and returns the updated settings as `SettingsDto`.
async fn update_settings(
State(state): State<ApiState>,
Json(body): Json<UpdateSettingsRequest>,
) -> impl IntoResponse {
let mut settings = state.settings.lock().unwrap();
if let Some(color_scheme) = body.color_scheme {
settings.color_scheme = color_scheme;
}
match settings.save(&state.settings_path) {
Ok(()) => Json(SettingsDto::from(&*settings)).into_response(),
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
let max_logs_changed = body.max_logs.is_some();
let new_max_logs = {
let mut settings = state.settings.lock().unwrap();
if let Some(color_scheme) = body.color_scheme {
settings.color_scheme = color_scheme;
}
if let Some(max_logs) = body.max_logs {
settings.max_logs = max_logs;
}
match settings.save(&state.settings_path) {
Ok(()) => (),
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
}
(SettingsDto::from(&*settings), settings.max_logs)
};
if max_logs_changed {
let _ = state
.engine
.send(EngineMessage::SetMaxLogs(new_max_logs.1))
.await;
}
Json(new_max_logs.0).into_response()
}
/// Maps a `ProfileError` to a response status/message: `NotFound` -> 404,
@@ -1126,4 +1142,77 @@ mod tests {
assert_eq!(dashboards[0].panels.len(), 1);
assert_eq!(dashboards[0].panels[0].promql, "requests");
}
async fn ingest_lines(engine: &EngineHandle, start_sequence: u64, texts: &[&str]) {
for (offset, text) in texts.iter().enumerate() {
let sequence = start_sequence + offset as u64;
engine
.send(EngineMessage::Ingest(IngestedLine {
sequence,
timestamp: Timestamp::from_millis(sequence as i64 * 1000),
text: text.to_string(),
}))
.await
.unwrap();
}
}
#[tokio::test]
async fn setting_max_logs_evicts_oldest_on_next_ingest() {
let (app, engine, _settings, _dir) = test_app();
ingest_lines(&engine, 0, &["a", "b", "c"]).await;
let (status, body) = get_json(&app, "/api/logs").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.as_array().unwrap().len(), 3);
let (status, body) = send_json(&app, "PATCH", "/api/settings", json!({ "max_logs": 2 })).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["max_logs"], 2);
// Existing over-cap logs are evicted as soon as the setting change
// reaches the engine, not just on the next ingest.
let (_, body) = get_json(&app, "/api/logs").await;
assert_eq!(body.as_array().unwrap().len(), 2);
ingest_lines(&engine, 3, &["d"]).await;
let (_, body) = get_json(&app, "/api/logs").await;
let texts: Vec<_> = body
.as_array()
.unwrap()
.iter()
.map(|entry| entry["text"].as_str().unwrap())
.collect();
assert_eq!(texts, vec!["c", "d"]);
}
#[tokio::test]
async fn max_logs_defaults_to_unlimited() {
let (app, engine, _settings, _dir) = test_app();
let (_, body) = get_json(&app, "/api/settings").await;
assert_eq!(body["max_logs"], Value::Null);
ingest_lines(&engine, 0, &["a", "b", "c", "d", "e"]).await;
let (_, body) = get_json(&app, "/api/logs").await;
assert_eq!(body.as_array().unwrap().len(), 5);
}
#[tokio::test]
async fn max_logs_clears_back_to_unlimited() {
let (app, engine, _settings, _dir) = test_app();
send_json(&app, "PATCH", "/api/settings", json!({ "max_logs": 1 })).await;
ingest_lines(&engine, 0, &["a", "b"]).await;
let (_, body) = get_json(&app, "/api/logs").await;
assert_eq!(body.as_array().unwrap().len(), 1);
let (status, body) = send_json(&app, "PATCH", "/api/settings", json!({ "max_logs": null })).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["max_logs"], Value::Null);
ingest_lines(&engine, 2, &["c", "d"]).await;
let (_, body) = get_json(&app, "/api/logs").await;
assert_eq!(body.as_array().unwrap().len(), 3);
}
}