feat(sidecar): protocol 2 — file by type, a cursor feed, and bounded history
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m14s
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m14s
The sidecar now files a frame by its `type` and never by its `kind`. That is the dumb-forwarder property made structural: `event` is appended to history, `snapshot` replaces the board of its kind, `reply` is routed by `reqId`, `control` is broadcast and kept nowhere. Ten new event kinds are no change here at all, which is the whole point when the thing that grows fastest is the catalogue. A frame whose `type` this build does not know is dropped and counted, never guessed at. Defaulting an absent one to `event` would file a BOARD as history — the presence board appended a few thousand times, which nothing reports. The count is on `/health` as `untyped_frames`, because the failure it diagnoses (a plugin and a sidecar on different protocol versions, which the game link has no handshake to catch) otherwise presents as a website showing nothing while the game is plainly up. It caught exactly that within three seconds of first running, against a protocol 1 plugin still live on a retired rig. `boards` generalises protocol 1's single `server_state` row, and a database made by protocol 1 is migrated in place: the two indexed columns are added by a guarded `ALTER`, and the old board is carried across. Without that carry-over an upgraded sidecar answers `204` until the game next connects, and the website reads that as "never heard from" — losing a server it has rendered for weeks at the exact moment somebody upgraded the bridge. `GET /feed` is the ingest cursor: oldest first, strictly after an id, with `lastId` and `more`. It is a separate route rather than a flag on `/events` because one route with two orderings serves the other one to every caller that forgets the parameter — and for the ingesting caller that means advancing its cursor past rows it never read. Omitting `since` asks where the END is; `since=0` is the other question entirely, and the two must not be separated by whether somebody typed a parameter. `[store].retain_days` (default 14) prunes events hourly. Boards are never pruned: history grows and the present does not, and a pruned board is a server that has never connected. The repository also had no CI. `pr-checks.yml` runs the fmt, clippy and test gates phases 1 and 3 have both been running by hand — a guard nothing invokes is a guard whose state nobody knows. 44 tests pass, clippy clean at `-D warnings`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
//! route that fails when the game is down — which is the honest answer to "what is it doing *right
|
||||
//! now*".
|
||||
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -57,6 +57,8 @@ pub struct AppState {
|
||||
pub started: Instant,
|
||||
/// Epoch ms of the last line received from the plugin, 0 if none yet.
|
||||
pub last_event: Arc<AtomicI64>,
|
||||
/// Frames this build could not file, because they carried no usable `type`. See `/health`.
|
||||
pub untyped: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
@@ -64,8 +66,14 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
.route(WS_PATH, get(ws_upgrade))
|
||||
// The board: the last `server.hello`. Store-backed, so it answers while the game is off.
|
||||
.route("/server", get(server_board))
|
||||
// Event history, newest first, optionally filtered by kind.
|
||||
// Every board at once — what a consumer reads on connect to know the present before it
|
||||
// starts following the story.
|
||||
.route("/boards", get(boards))
|
||||
// Event history, newest first, optionally filtered by kind and wipe. For a human.
|
||||
.route("/events", get(events))
|
||||
// The ingest cursor: oldest first, strictly after an id. For a consumer that must not miss
|
||||
// a row. Deliberately a second route rather than a flag on the first — see `feed`.
|
||||
.route("/feed", get(feed))
|
||||
// Live: a correlated round trip to the plugin. Fails when the game is down, by design.
|
||||
.route("/status", get(status))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), gate));
|
||||
@@ -95,6 +103,8 @@ async fn health(State(st): State<AppState>) -> impl IntoResponse {
|
||||
|
||||
let status = if plugin && db_ok { "ok" } else { "degraded" };
|
||||
|
||||
let untyped = st.untyped.load(Ordering::Relaxed);
|
||||
|
||||
Json(json!({
|
||||
"status": status,
|
||||
"protocol": PROTOCOL_VERSION,
|
||||
@@ -102,6 +112,10 @@ async fn health(State(st): State<AppState>) -> impl IntoResponse {
|
||||
"database": if db_ok { "ok" } else { "error" },
|
||||
"uptime": format_uptime(st.started.elapsed()),
|
||||
"last_event": iso_ms(last_ms),
|
||||
// Non-zero means the plugin is speaking a protocol this build cannot file. It is reported
|
||||
// here rather than only in the log because the symptom an operator sees — a website that
|
||||
// shows nothing while the game is plainly up — names nothing at all.
|
||||
"untyped_frames": untyped,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -133,7 +147,7 @@ fn iso_ms(ms: i64) -> Option<String> {
|
||||
/// reports nothing" are different answers, and a client that cannot tell them apart renders a
|
||||
/// server that does not exist.
|
||||
async fn server_board(State(st): State<AppState>) -> Response {
|
||||
match st.store.server_state().await {
|
||||
match st.store.board(crate::store::SERVER_BOARD).await {
|
||||
Ok(Some(v)) => Json(v).into_response(),
|
||||
Ok(None) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => {
|
||||
@@ -143,16 +157,30 @@ async fn server_board(State(st): State<AppState>) -> Response {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every board, keyed by kind. `200` with an empty object when the game has never connected —
|
||||
/// unlike `/server`, which answers `204`, because "no boards yet" is a complete answer to "give me
|
||||
/// all of them" and an empty map renders correctly where a `204` has to be special-cased.
|
||||
async fn boards(State(st): State<AppState>) -> Response {
|
||||
match st.store.all_boards().await {
|
||||
Ok(map) => Json(json!({ "boards": map })).into_response(),
|
||||
Err(e) => {
|
||||
warn!(error = %e, "board read failed");
|
||||
store_error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EventsQuery {
|
||||
kind: Option<String>,
|
||||
wipe: Option<String>,
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
async fn events(State(st): State<AppState>, Query(q): Query<EventsQuery>) -> Response {
|
||||
match st
|
||||
.store
|
||||
.recent(q.kind.as_deref(), q.limit.unwrap_or(50))
|
||||
.recent(q.kind.as_deref(), q.wipe.as_deref(), q.limit.unwrap_or(50))
|
||||
.await
|
||||
{
|
||||
Ok(rows) => Json(json!({ "events": rows })).into_response(),
|
||||
@@ -163,6 +191,55 @@ async fn events(State(st): State<AppState>, Query(q): Query<EventsQuery>) -> Res
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FeedQuery {
|
||||
since: Option<i64>,
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
/// The ingest cursor: everything after `since`, oldest first.
|
||||
///
|
||||
/// **Omitting `since` asks where the end is** — it answers with no rows and the current `lastId`,
|
||||
/// which is what a consumer with no cursor of its own needs. `since=0` is the other question, and
|
||||
/// the one nobody should ask by accident: replay everything retained. A module installed today
|
||||
/// against a sidecar that has been running a month wants what happens next, not a fortnight of
|
||||
/// deaths it has no rollups for, and the difference between those two intentions must not be the
|
||||
/// difference between typing a parameter and forgetting it.
|
||||
///
|
||||
/// The response always carries `lastId`, so a caller advances without inspecting the rows, and
|
||||
/// `more`, so one that has fallen behind comes straight back rather than waiting out its poll
|
||||
/// interval.
|
||||
async fn feed(State(st): State<AppState>, Query(q): Query<FeedQuery>) -> Response {
|
||||
let since = match q.since {
|
||||
Some(n) => n.max(0),
|
||||
None => match st.store.last_event_id().await {
|
||||
Ok(id) => {
|
||||
return Json(json!({ "items": [], "lastId": id, "more": false })).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "feed tail read failed");
|
||||
return store_error();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
match st.store.feed(since, q.limit.unwrap_or(200)).await {
|
||||
Ok((items, more)) => {
|
||||
// The cursor a caller should send next. When the page is empty that is the cursor it
|
||||
// sent — never 0, which would silently replay the whole retained history on the next
|
||||
// poll of a quiet server.
|
||||
let last_id = items.last().map(|i| i.id).unwrap_or(since);
|
||||
let rows: Vec<Value> = items.iter().map(|i| i.to_json()).collect();
|
||||
|
||||
Json(json!({ "items": rows, "lastId": last_id, "more": more })).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "feed read failed");
|
||||
store_error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn store_error() -> Response {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
|
||||
Reference in New Issue
Block a user