ADR 0020: The event log - events stop being a view on run history¶
- Status: Accepted
- Date: 2026-10-13
- Backlog: #59 (a dedicated event log), the "Before 1.0.0-rc1" item in BACKLOG.md's roadmap. Carries out ADR 0002 §3-§5. Unblocks #60 (change data capture) and webhook delivery.
Context¶
Live events are rows of execution_history. queryapigate events tails that table, and a run's history row id is its
event's SSE id:. ADR 0002 kept it that way in 0.13 and wrote down what that costs:
- History policy decides what is an event. Runs
QUERYAPIGATE_HISTORY_SAMPLE_RATEsamples out never become events. How far back a client can resume depends onQUERYAPIGATE_HISTORY_LIMIT(per version) andQUERYAPIGATE_HISTORY_ADHOC_LIMIT, not on time: a busy query trims its own replay window to minutes. - SQLite reuses an id when the newest run is deleted before the next is recorded, and a client that saw the deleted run can miss the one that took its id.
- There is nowhere to put an event that isn't a run - a data change (#60), a key revoked, a query published.
Live events stayed experimental in 0.30 (ADR 0017) for one reason: the payload was meant to gain type and source
when this log shipped. Two more things turned up reading the code for this ADR:
- A resume further back than 1,000 events loses the rest. The replay sends the oldest 1,000 after
Last-Event-ID, then switches to live, and everything between the two is skipped without a word. - Erasing a caller (ADR 0016) would miss the new table unless it's told about it.
Decision¶
1. An events table¶
| Column | |
|---|---|
id |
INTEGER PRIMARY KEY AUTOINCREMENT on SQLite, BIGSERIAL on PostgreSQL: never reused, on either |
type |
query.run - the only type this release writes |
source |
where it came from: for a run, the front door its history entry names (rest, mcp, export); null for a run that names none - a queryapigate export to a local file, or an older run, copied in at the upgrade, recorded before history kept it |
occurred_at |
YYYY-MM-DD HH:MM:SS, server time, like every other timestamp in the store |
key_name, connection_name, query_name, status |
what access checks and subscription filters read, without parsing the payload |
payload_json |
the event's data: exactly as sent |
Schema 16. Additive: a 0.32 process still running ignores the table.
2. Every run is an event, written with its history row¶
A run's event goes into the same batch transaction as its history row (history.py), and the NOTIFY that wakes the
events server is unchanged. Two things change from history's rules:
- Sampling no longer applies to events. A run sampled out of history is still queued, as an event only. The sample rate now reduces history, which is what it was for; the event log is bounded by its own retention instead.
- A run whose saved query was deleted before its batch was written loses its history row, as now, but keeps its event: the run happened, and a client may already be waiting for it.
Back-pressure is unchanged: when the queue is full, a run is dropped from both. Runs the examples seed into history are not live traffic and are not events.
3. Retention by time¶
QUERYAPIGATE_EVENTS_RETENTION_HOURS, default 24, a whole number of hours. Each process's history writer sweeps
older events every ten minutes, in chunks; on PostgreSQL one instance at a time, under its own advisory lock, as the
history sweep does. A client can resume from anywhere in that window. Beyond it, it gets what a trimmed id always got:
the stream from the oldest event still kept.
24 hours covers a phone left overnight or a dashboard over a weekend's first day. One row per run is small, and history at its default keeps far fewer runs than a busy day's events, so the default is a day and not a week. Lower it on SQLite under heavy traffic; raise it on PostgreSQL if clients sleep for longer.
4. The upgrade continues the sequence (ADR 0002 §3)¶
The schema-16 step:
- Copies the runs still in history from the retention window into
events, keeping each one's id - its history row id, which is the event id a client already holds. - Seeds the id sequence above
MAX(execution_history.rowid)(sqlite_sequenceon SQLite,setvalon PostgreSQL), so the first new event's id is above every id the old server could have sent.
A client holding an id from before the upgrade resumes after it: the runs it missed are replayed from the copy, nothing twice. The step runs only while the sequence has never been used, so it's safe to re-run, and does nothing on a new store.
queryapigate migrate-to-postgres copies events with its ids and carries the sequence across, from
sqlite_sequence, not from MAX(id), so an id handed out and since swept away is never handed out again.
5. The payload, settled¶
{"type": "query.run", "source": "rest", "occurred_at": "2026-10-13 12:00:00", "kind": "saved",
"query": "monthly_revenue", "version": 3, "connection_name": "warehouse",
"entry": {"executed_at": "2026-10-13 12:00:00", "status": "success", "rows": 42, "key_name": "mobile-alice", ...}}
typeandsourcesay what happened and where, for every event type to come.query.runcovers both a saved query's run and ad-hoc SQL: what differs iskind,savedoradhoc, andquery/versionarenullfor an ad-hoc run.kind,queryandversionare the namesGET /api/v1/historyalready uses for the same things.entryis the run's history entry, as before.- Breaking, for an experimental feature:
type: "execution"and"adhoc_execution"and thefilenamefield are gone. The Console's Home is updated in the same release.GET /eventson the main server sends the same payload.
Versioning. No version field. Once live events graduate, the payload is under the compatibility promise like any
response: fields and event types may be added, none removed or renamed before 2.0. A client ignores a type it
doesn't know. A version field would be a promise to serve two shapes at once, which nothing here needs.
CloudEvents isn't adopted now. id, type, source and occurred_at map onto its required attributes one to
one, so a CloudEvents rendering can be added later (an Accept value or a parameter) without changing this one.
6. Subscription filters¶
?type=, ?kind=, ?query=, ?connection=, ?status= (success or error) on both /events endpoints. Each
takes one value, and the filters combine with AND. They narrow what the key may already see and never widen it: the
admin sees every event, any other key only its own, and then the filters apply. A replay applies them too. A
malformed status or kind is refused with 400 invalid_filter, as GET /api/v1/history refuses one. A filter
naming something that doesn't exist matches nothing; it isn't an error, since a query may be created after the
stream opens.
7. A resume replays everything it may¶
The replay pages through every event after Last-Event-ID the key may see, 1,000 at a time, until it reaches the
live stream, instead of stopping at the first 1,000. Live events queue up meanwhile, as now; a client too slow to
take a long replay is disconnected as too slow and resumes from where it got to.
8. /metrics on the events server¶
The events server answers GET /metrics, behind QUERYAPIGATE_METRICS_TOKEN as the main server's does:
queryapigate_events_streams- open streamsqueryapigate_events_delivered_total- events taken off the log and queued to clientsqueryapigate_events_replayed_total- events sent on a resumequeryapigate_events_slow_disconnects_total- clients cut off for falling behindqueryapigate_events_lag_seconds- a histogram ofoccurred_atto delivery.occurred_athas one-second precision, so read it in seconds, not milliseconds.
9. Still experimental for this release¶
The payload changed in this release, and ADR 0017's terms ask a graduate to stay unchanged for a release. Live events graduate in the next release if nothing here has to change. The event-id rule (ADR 0002 §2) is already outside the experiment.
Alternatives considered¶
- Keep
type: "execution"andfilename, addingsourcebeside them. Nothing breaks, buttypewould then name a run, not a kind of event. A data change (#60) would need a second field for that, and 1.0 would freeze the muddle. The payload is experimental so that this could be fixed once, before the freeze. - One type per kind of run (
query.run,sql.run). A subscriber that wants every run would need two filters, and the history API already calls this differencekind. - Don't copy recent history on upgrade; just seed the sequence. ADR 0002 allowed it: an id from before the upgrade then resumes with nothing replayed. It's simpler, but a client that disconnected just before the upgrade would lose the runs in between. The copy is bounded by the retention window.
- Respect the sample rate for events too. It halves the writes a sampled server makes, but a client watching its own requests would see some of them arrive and others not, depending on chance. Sampling is for keeping history small; the event log has retention for that.
- A setting to turn the event log off. Writing an event costs one more insert in a batch that's already open. It doesn't seem worth a setting until someone measures it as a problem.
- Retention by count, like history. Count-based retention is what made the replay window depend on how busy a query is.
Consequences¶
- One more row per run, in the same transaction as its history row. On SQLite at high traffic, the default 24 hours is the setting to look at.
- Live events no longer depend on history's sample rate, limit or retention. History settings stop appearing in the live events documentation.
forget(ADR 0016) erases a caller's events with their runs and counts them in its report, and the data-flow inventory lists the table.- Rolling upgrades: an instance still on 0.32 writes runs to history only, so its runs aren't events until it's
upgraded. Upgrade
queryapigate eventswith the instances: a 0.32 events server keeps tailing history, and its ids are no longer the store's event ids. -
60 and webhook delivery have a table to write to and read from.¶