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:
@@ -64,6 +64,15 @@ pub struct WebCfg {
|
||||
pub struct StoreCfg {
|
||||
#[serde(default = "default_db_path")]
|
||||
pub path: String,
|
||||
/// How many days of event history to keep. `0` keeps everything.
|
||||
///
|
||||
/// The store sits on a game host, and protocol 2 gave it a catalogue that produces real volume
|
||||
/// — every death, every chat line, every connect. The *permanent* record is the website's:
|
||||
/// per-wipe rollups in the module's own tables (R12). So this bounds the sidecar's copy, and
|
||||
/// the default is generous enough that nobody needs to think about it and small enough that a
|
||||
/// busy month is not a wipe-day outage.
|
||||
#[serde(default = "default_retain_days")]
|
||||
pub retain_days: i64,
|
||||
}
|
||||
|
||||
/// A loaded configuration plus what loading it *did* — an installer re-running the binary needs to
|
||||
@@ -89,6 +98,9 @@ fn default_web_bind() -> String {
|
||||
fn default_db_path() -> String {
|
||||
"rust-link.db".into()
|
||||
}
|
||||
fn default_retain_days() -> i64 {
|
||||
14
|
||||
}
|
||||
|
||||
impl Default for GameCfg {
|
||||
fn default() -> Self {
|
||||
@@ -110,6 +122,7 @@ impl Default for StoreCfg {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
path: default_db_path(),
|
||||
retain_days: default_retain_days(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,6 +202,15 @@ impl Config {
|
||||
if let Ok(v) = env::var("RUSTLINK_DB_PATH") {
|
||||
self.store.path = v;
|
||||
}
|
||||
if let Ok(v) = env::var("RUSTLINK_RETAIN_DAYS") {
|
||||
// A malformed value is ignored rather than fatal: this reaches the process as a panel
|
||||
// variable somebody typed (R22), and refusing to start over a stray character would
|
||||
// take the bridge down for a setting that has a perfectly good default.
|
||||
match v.trim().parse::<i64>() {
|
||||
Ok(days) if days >= 0 => self.store.retain_days = days,
|
||||
_ => tracing::warn!(value = %v, "ignoring an unreadable RUSTLINK_RETAIN_DAYS"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves `[store].path` against the config file's directory (see the module docs). Absolute
|
||||
@@ -373,6 +395,13 @@ auth_token = "{token}"
|
||||
# Relative paths resolve against the directory holding THIS FILE, not the
|
||||
# working directory of whatever started the process.
|
||||
path = "rust-link.db"
|
||||
|
||||
# How many days of event history to keep. 0 keeps everything.
|
||||
#
|
||||
# The permanent record is the website's — it holds per-wipe rollups that survive
|
||||
# a wipe. This database is the recent copy the site reads to catch up, and it
|
||||
# lives on the game host, so it is bounded.
|
||||
retain_days = 14
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user