Events belong here, gauges do not
Functional Requirements — the events plane
The events plane is what one did: the runs it wrapped, the phases inside them,
and the lines they printed, stored in this box's own ClickHouse and written
through this box's own hcapi. Structural map: docs/architecture_platform.md
(§The event path, §ClickHouse — the events). This file is the behavior
contract, written from outside with no access to the implementation; every
requirement is a check a change can pass or fail.
The plane's shape follows the estate's measurement of what a time-series store
is for (~/greyandfast/garage/notes/research/prometheus-for-snc): ClickHouse is
right for events and runs — heterogeneous rows with their own identity,
queried by group and by time — and wrong for gauges, which are a fixed
series set sampled on a clock. So the plane stores facts and lifecycle rows, and
the fleet questions are GROUP BY over time, never a metric path. A new metric
does not belong here; a new kind of event does.
The tables
Three tables, three questions.
eventsis what happened. One row per eventoneemitted, with the project, the environment, the host, the run (and its parent, when a run was launched by another), the microsecond timestamp, the kind, the name, a monotonic per-runseq, a string-to-string attribute map, and free-form tags. Queryable for 180 days fromts.runsis what is happening now. One row per run, carrying the host, pid, tag, argv,started_at,ended_at(null while open),exit_code,signal,outcome,attempts, anduptime_ms. The table is aReplacingMergeTreeversioned onended_at, so a run posted open and later posted closed collapses to one row and the closed version wins. No TTL: the lifecycle row outlives the log lines it produced.logsis what it said. One row per captured line: the run, the microsecond timestamp, the stream (stdoutorstderr), and the line. Queryable for 30 days fromts.
Retention is expressed as a table TTL and enforced by ClickHouse dropping whole
parts; a query at the boundary therefore sees only rows inside the window and
must not assume a row exists at now - 180d (or now - 30d for logs). Every
read that spans the boundary filters on ts explicitly — the TTL is the
guarantee of eventual eviction, never a promise that the edge row survives.
The event JSON
one writes exactly this object per journal line, one line per event, and the
flusher posts batches of them unchanged. v is the schema version, always 1
for this revision.
{
"v": 1,
"project_id": "uuid",
"env": "string",
"host": "string",
"run_id": "uuid",
"parent_run_id": "uuid or null",
"ts": "RFC3339 with microseconds",
"kind": "string",
"name": "string",
"seq": 0,
"attrs": {"key": "value"},
"tags": ["string"]
}
kind is the coarse class and name is the specific event. The closed set of
kind values is lifecycle, phase, heartbeat, log, meta; name is a
free lowercase snake string within that class. A consumer that needs the event's
position in one run's story orders by seq, which is per-run monotonic from 0.
kind |
name values |
|---|---|
lifecycle |
start, spawn, exit, signal, crash, stop |
phase |
pull, weights, load, warmup, profile, serving, crashed, stopped |
heartbeat |
tick |
log |
stdout, stderr |
meta |
the tag, the argv, the resolved manifest |
A name outside its kind's class is a client bug, not a new class: kind is what
the store partitions and groups on, so it is a closed set and a change to it is
a schema change. Adding a new phase is not — that is a new name under
phase.
attrs carries only strings — a number, a duration, or a boolean is written as
its string form so the map needs no schema negotiation. v is not a column: it
is a version gate, consumed at the door and not stored.
runs and logs are not carried on this wire. runs is projected from the
lifecycle/start and lifecycle/exit events, and logs from the log
events' line attribute — the one binary emits one JSON stream and hcapi fans
it into three tables.
The ingest endpoint
POST /api/one/eventsaccepts a JSON array of event objects. A batch is scored as a batch: the body is one array, and the array is the unit of accept or reject.On success it returns
202 Acceptedwith a body carrying the count accepted, e.g.{"accepted": 42}.202, not200: the write to ClickHouse may follow the response, and the flusher only needs to know the batch was taken.A batch whose any element's
vis not1is rejected with400and the count of offending elements. A batch is never partially accepted on a version mismatch — a mixed batch is a client bug and fails whole.The endpoint is idempotent by
(run_id, seq). Re-posting a batch that was already applied does not duplicate a row. The mechanism is two-part, and both parts are required:- the writer derives an
insert_deduplication_tokenper batch — a digest of the batch's ordered(run_id, seq)pairs — and every table in the cell setsnon_replicated_deduplication_window = 1000, so ClickHouse discards a second insert carrying a token it has already applied; runsis additionally aReplacingMergeTreekeyed on(project_id, run_id), so even a batch that slipped past the window overwrites the existing run row instead of adding a second one.
The token is the idempotency statement; the replacing engine is the backstop for the run table. A change that removes either half fails this requirement.
- the writer derives an
Failure behaviour
The collector is never the run's problem (ONEF-102: one performs no network
call on the child's hot path).
- When ClickHouse is down, hcapi buffers to disk at
$XDG_STATE_HOME/one/hcapi-spool/in the same JSON lines it would have posted, and drains the spool oldest-first when the store returns. - The spool is capped at 256 MB. On overflow the drop order is fixed:
log lines are dropped first, oldest first; lifecycle rows are kept —
every
lifecycleevent and the wholerunsprojection survive even when the log tail that explains them is gone. A full spool degrades the record's detail and never its skeleton. - A rejected batch is retried, not dropped, with the same token — which is what makes the retry safe.
oneitself never blocks on any of this. A slow or dead collector is invisible to the child command; the queue drops oldest non-lifecycle batches and the exit code is the child's.
The cap and the drop order live here as the contract; the spool path and the implementation are hcapi's own.
Checks
A change passes the plane when all of these hold:
SHOW TABLES FROM onefrontierlistsevents,runs,logs,runs_latest,host_activity,failed_runs.SHOW CREATE TABLE onefrontier.eventsshowsPARTITION BY toYYYYMM(ts),ORDER BY (project_id, ts, run_id), and a 180-day TTL onts.SHOW CREATE TABLE onefrontier.logsshows a 30-day TTL onts.SHOW CREATE TABLE onefrontier.runsshows aReplacingMergeTreewhose version derives fromended_atand no TTL.bin/demo-load.shtwice in a row leaves the same row counts (720 events, 60 runs, 360 logs), andruns_latestreads 60.SELECT * FROM onefrontier.host_activity LIMIT 1returns a host, an hour, a kind, an event count and a run count.POST /api/one/eventswithv: 2returns 400; withv: 1returns 202 and the accepted count.- Re-posting a batch returns 202 and leaves the row counts unchanged.