RFC-0004: The Event History Store #159
Closed
kn4oqw-clint
announced in
RFCs
Replies: 1 comment
|
This has already been implemented. |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Summary
Every event the daemon learns — the ones already flowing through the hub (
internal/hub/hub.go) — is persisted to a separate SQLite database,events.db, by a batched hub subscriber. A newGET /api/historyendpoint serves that persistent record so any browser renders the same last-heard, networks, and event log regardless of when it connected or whether the daemon has restarted. The in-memory 200-event hub backlog stops being the source of dashboard history and reverts to what it is good at: the live-reconnect tail and the LCD renderer's warm start. Retention is an operator setting (default 7 days), pruned nightly, and lives in a new Station Settings tab that a future callsign-beacon feature will share.Motivation
Today the dashboard's history is a fiction of the browser tab. The hub keeps a bounded in-memory ring (
backlogSize = 200,internal/hub/hub.go:29) that the SSE handler (cmd/waypointd/main.go,func (s *server) events) replays on connect;ui/static/app.jsthen buildsstate.lastheard,state.networks, and the event-log table entirely in client JS from that stream. The consequences (#68):waypointd→ all history is gone; the ring is memory.This is also the standing shape of founding requirement #9 — persistent last-heard with per-station history — whose acceptance ("HA MQTT discovery picks up hotspot status entities with zero YAML") sits on top of a durable event record that does not exist yet. This RFC builds that record. The Home-Assistant-facing MQTT-discovery topic scheme is deliberately out of scope here and follows as its own change: it is a publisher that reads the same store, not part of the persistence contract.
The design constraint that shapes everything below: the target hardware is a Pi Zero W / Pi 3 booting off an SD card. The persistence layer must not write per-event fsyncs (SD wear) and must not add a second long-running daemon (memory) — which is exactly why the incumbent answer of "stand up InfluxDB" is the wrong tool here (see Alternatives).
Design
A separate
events.db, not a table in the config storeEvent history is high-churn and its retention is independent of configuration. Putting it in
config.dbwould serialize every event insert against config writes on that database's single writer connection (store.gosetsSetMaxOpenConns(1)), and would tangle a 7-day pruning window into the database whose entire value proposition (RFC-0001) is that it is small, authoritative, and never churned. So the events store is its own SQLite file,events.db, a sibling ofconfig.db:internal/events, withOpen(path)mirroringstore.Open(internal/store/store.go:30): pure-Gomodernc.org/sqlitedriver (CGO-free armv6 cross-compile),journal_mode=WAL,busy_timeout=5000, and its ownmeta(schema_version)row so it migrates on its own cadence, independent of the config schema.store.go:synchronous=NORMAL. Under WAL,NORMALfsyncs at checkpoint rather than on every commit — a durability/wear trade the config store does not take (a lost config write is unacceptable; a lost last-second last-heard event on a power-cut is not). This is the knob that satisfies Bug: Last-heard / event history is per-browser-session only #68's "no per-event fsync" acceptance item.-events-store(default/home/pi-star/waypoint/events.db, theconfig.dbsibling), opened inmain()next tostore.Open, handle held on theserverstruct, closed on shutdown.The precedent for a subsystem owning tables of its own is already in the tree — the auth subsystem keeps its credential/session tables via
store.DB()(internal/auth/store.go:45). We go one step further and give events their own file rather than sharing the config connection, because the churn and retention arguments above do not apply to auth's tiny, rarely-written tables.Schema
hub.Event's fields (internal/hub/hub.go:16), so persistence is a straight projection and the history endpoint re-emits the identical wire shape the SSE stream and client already speak — no second event schema to keep in sync.WHERE ts_ms >= ?) and the retention prune are integer comparisons on an indexed column; the API still speaks RFC-3339 (hub.Event.Timeis atime.Time, marshaled as it is today) — the millis representation is an internal storage detail.idx_events_sourceis the per-station-history index Last-heard database + Home-Assistant-friendly MQTT topics #9 asks for: "who was this node hearing, newest first" isWHERE source = ? ORDER BY ts_ms DESC.Persistence subscriber (batched, off the publish lock)
Hub.Publishholdsh.muwhile it fans out to subscribers (internal/hub/hub.go), so persistence must never do a synchronous DB write inside that path. Instead the events package registers as an ordinary hub subscriber —hub.Subscribe(), the same seam the SSE handler and LCD renderer use — and runs a writer goroutine:INSERTbatch). Batching plus WAL plussynchronous=NORMALis what keeps SD writes to a trickle under sustained traffic.Started in
main()near the demo/mqtt producer wiring, with the daemon context so it stops cleanly on shutdown (final flush on context cancel).Retention & the nightly prune
Retention is an operator preference, not a build constant, so it is a store setting — an ordinary
config.Modelsection, read from the config store the same way the YSF hostlist refresher readsUpperHostfiles(main.go,ysfhosts.Runcallback):history→type History struct { RetentionDays int },DefaultHistory()={ RetentionDays: 7 }, wired intoModel.sections(),View, andbackfillDefaults(so a store seeded before this section gets the 7-day default, exactly asdisplay/p25/etc. were backfilled).RetentionDays == 0means keep forever (prune disabled) — a deliberate escape hatch for an operator who wants a permanent log and has the disk for it. Negative is rejected at save.Runloops inmain()) reads the currenthistory.retention_dayseach night and runsDELETE FROM events WHERE ts_ms < ?for the cutoff, then lets WAL checkpoint reclaim space. Bounded DB size under sustained traffic is thereby a tested property, not a hope (Bug: Last-heard / event history is per-browser-session only #68 acceptance).History API
New route
GET /api/history, registered innewMuxalongside/api/events. Because the auth gate is default-deny and passes every route through once a session authenticates (internal/auth/handlers.go,gateClaimed), the endpoint is behind the session wall with no gate change — same posture as the SSE stream.since(RFC-3339 or unix-ms; events at/after this time),type(filter one event type),limit(default 500, hard cap ~5000 so one request can never scan the whole retention window).hub.Event, newest-first, identical wire shape to the SSEdata:frames — so the client feeds history rows through the same reducer it already uses for live events.SSE and hub, after this change
The hub's in-memory backlog stays — it still serves two real needs: the live-reconnect tail (a browser that drops and reconnects catches the handful of events it missed) and the LCD renderer's warm start (
startLCDreplays the backlog so the panel opens showing current state,main.go). What changes is that the backlog is no longer the dashboard's history:GET /api/eventsbecomes a pure live tail — it stops replaying the backlog to browser clients. (Safe: the LCD callshub.Subscribe()directly, not through the SSE HTTP handler, so its warm start is unaffected.)ui/static/app.jsgainsloadHistory()—fetch("/api/history?limit=500"), replay the rows oldest→newest through the existinghandle()reducer to seedstate.lastheard/state.networks/ the event-log table — called beforeconnect(). History paints first; the SSE stream then live-tails on top.ts+source, or asincehandshake).Station Settings tab
Retention gets a home in the UI, and that home is deliberately built to hold more than retention. A new Station Settings tab (
TABSentry inui/static/settings.js, following thelcdtab's shape) surfaceshistory.retention_daystoday. A tab may span more than one store section — the General tab already spansgeneral+modem— so the future callsign-beacon feature lands here as a siblingbeaconsection under the same tab without disturbing the retention field. The tab writes through the existingPUT /api/config/{section}merge path (config.SetSection), so it inherits RFC-0001's isolation guarantee for free.The persistence contract (test harness)
CI enforces these as release-blocking properties, in the RFC-0001 style (pure functions, property-based, table-driven where it fits):
hub.Events published through the persistence subscriber, every event read back via theHistoryquery is field-for-field equal to what was published (modulo the ms-truncation of sub-millisecond time), in newest-first order. No event type is silently dropped.Openon the same file) ⇒Historyreturns them. This is the Bug: Last-heard / event history is per-browser-session only #68 "survives waypointd restart / host reboot" acceptance as an automated test.RetentionDays == 0prunes nothing; DB row count is bounded by a synthetic sustained-traffic fixture after prune (Bug: Last-heard / event history is per-browser-session only #68 "DB size bounded" / "prune verified").journal_mode=WAL+synchronous=NORMALand writes are batched in transactions — asserted at the DSN/PRAGMA level and by an insert-count-vs-transaction-count check (Bug: Last-heard / event history is per-browser-session only #68 "batched WAL writes confirmed").{since, type, limit}→ asserts thesinceboundary is inclusive-at, thetypefilter is exact,limitcaps the row count, and the response wire shape is byte-compatible with an SSE frame'shub.EventJSON.historysection round-trips through Save/Load,backfillDefaultsfills the 7-day default into a store seeded without it, a negativeretention_daysis rejected at save, and editinghistoryleaves every other section byte-identical (RFC-0001 property 2, isolation).Alternatives considered
config.db(reuse the config store viastore.DB()). Rejected: it serializes high-churn event inserts against config writes on the single writer connection, and couples a 7-day pruning/vacuum cycle to the database whose whole value is being small and never churned. A separate file isolates both the lock and the retention lifecycle. (Auth shares the config DB, but its tables are tiny and rarely written — the churn argument does not apply there.)Hub.Publish(synchronous persistence). Rejected:Publishholds the hub mutex while fanning out; a DB write there would put disk latency on the path every producer and subscriber shares. A batched subscriber decouples disk latency from the bus entirely.config.db'snow()). Rejected for the events table: range scans and prune run on every query and every night; integer-millis on an indexed column is the right storage type. The API boundary still speaks RFC-3339, so nothing downstream sees the difference.Open questions
/api/historysnapshot and the SSE attach. Tighten with a client-side dedupe (ts_ms+source+type), or asincehandshake where the SSE stream replays from the last-seen id? Leaning client-side dedupe for alpha — simplest, and duplicates are visually harmless in an event log that is already keyed by time.idx_events_sourceindex makes per-station queries cheap, but the HA-facing side of Last-heard database + Home-Assistant-friendly MQTT topics #9 may want a materializedlast_heard(source → latest event)view for zero-scan status entities. Build it as a derived table maintained by the same subscriber, or compute on read? Deferred to the Last-heard database + Home-Assistant-friendly MQTT topics #9 MQTT-discovery follow-up, which is the consumer that decides.-demomode the synthetic generator will persist synthetic events intoevents.dblike any producer. Acceptable (demo is always labeled), but should demo mode use an in-memory / throwaway events store so a demo run doesn't accrete a persistent synthetic history? Leaning yes — open:memory:(or a temp path) for the events store when-demois set, mirroring how demo traffic is already walled off elsewhere.Migrated from
docs/rfcs/0004-event-history-store.md; the drafting history is in the git log.All reactions