platform database. Migrations
db/migrations/00015_api_metrics.sql (tables, histogram functions),
00016_api_security.sql (row-level security, rights, partition functions,
initial partitions) and 00026_api_audit_fixes.sql (rollup hour locks,
series_state.baseline, the coarse_buckets warning kind; functional audit
A-2, A-5 and A-12). Queries: db/queries/writer/api.sql (package
internal/db/writerq) and db/queries/jobs/api.sql (package
internal/db/jobsq); regenerate with make sqlc. The histogram layout and
its Go side live in internal/histo.
Histogram contract (plan 4.11)
- Canonical bounds in seconds:
0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10(histo.Bounds). 15 buckets, stored asbigint[15]. Bucket 1 (SQL, 1-based) is[0, 0.005], bucket i is(bound[i-1], bound[i]], bucket 15 is the open bucket above 10 s. histo.Rebucket(bounds, counts)moves an OTLP explicit-bucket histogram onto the layout: each source bucket goes whole to the canonical bucket that contains its upper bound.exactis true only when every canonical bound is also a source bound (the canonical layout or a finer one that contains it). Otherwise the row is stored withlossy = trueand the writer counts alossy_histogramwarning. Re-bucketing is linear, so the delta of two re-bucketed cumulative points equals the re-bucketed delta.histo.Coarse(bounds, counts): true when at least half of the requests sit in source buckets that contain 3 or more canonical bounds strictly inside them (histo.CoarseSpan). Their requests could lie anywhere in 4 or more canonical buckets but all land in the one of the upper bound, so a percentile can be wrong by a factor of 10 or more (a seconds metric with an SDK’s millisecond default bounds puts every request under 5 s into(2.5, 5]). The writer counts such requests ascoarse_bucketsand the console says the percentiles are not reliable (audit A-12).api.histogram_quantile(q, counts)andhisto.Quantile(q, counts): linear interpolation inside the bucket that holds rankq * total; the first non-empty bucket that reaches the rank wins, soq = 0gives the lower bound of the first bucket with data. Open bucket: SQL returns'Infinity', Go returns+Inf(histo.IsOpen); show it as>10 s, never as a number. Empty histogram: SQLnull, Gohisto.ErrEmpty. Errors: q outside [0, 1] or NaN (22023), not 15 buckets (2202E), a negative or null bucket (22023), total above int64 (22003). The two are tested to agree bit for bit on random histograms (internal/histo).api.add_buckets(a, b): element-wise sum; different shapes (2202E), null elements (22004) and bigint overflow (22003) are errors.api.sum_buckets(bigint[]): aggregate overadd_buckets, null inputs are skipped. Percentiles over a range areapi.histogram_quantile(q, api.sum_buckets(counts)); percentiles are never averaged.
Metric tables
api.metrics_minute (partitioned by day), api.metrics_hourly
(partitioned by month), api.metrics_daily (not partitioned, kept forever)
have the same shape:
Primary key:
(tenant_id, bucket_start, service, method, route, status_code, source). Absent attributes use '' and 0 instead of null so
that the key stays unique. error.type is not a dimension. Metric and span
rows are never merged; queries choose per tenant and service (plan 4.5).
Span-derived points carry the start of the UTC minute their spans ended in
and no start time (docs/reference/collector.md, “For phase 9”); bucket them
by that time.
The writer checks every text value before the batch (checkText in
internal/metricswriter/decode.go): valid UTF-8, no control character (NUL
is refused by PostgreSQL), and the byte limits above. A message that breaks
one goes to API_DLQ alone, before anything of the batch is written.
State and bookkeeping tables
series_hash and resource_hash are 32 bytes (SHA-256). Start and point
times are unix nanoseconds (bigint), because timestamptz keeps only
microseconds and two replicas started within the same microsecond would
merge. series_hash is the caller’s hash of tenant, resource hash, metric
name and attributes; the start time is a separate key column, so replicas
with equal attributes and different start times are separate streams, and
GetSeriesStates returns every start time of a series.
api.rollup_pending is how the rollup finds “every hour touched since the
last run”: the writer marks the UTC hour of each minute row in the same
transaction (MarkRollupPending), the rollup deletes the marks it takes
(ClaimRollupHours) and recomputes those hours and their days in the same
transaction.
The mark alone does not make that safe. insert ... on conflict do nothing
takes no lock on a mark that is already committed, so a rollup could claim
it and recompute the hour while a writer transaction that had just added
minute rows to it was still open; those rows were invisible to the rollup
and, with the mark gone, never reached the hourly and daily rows (audit
A-2). Hour locks close that gap. Before its first minute row, the writer
takes pg_advisory_xact_lock_shared(api.rollup_lock_key(tenant, hour)) for
every hour it writes (LockRollupHour, in sorted order; writers share the
lock). For every claimed hour the rollup first tries the exclusive lock
without waiting (TryLockRollupHour); when a writer holds it, the rollup
puts the mark back (RestoreRollupMark) and leaves the hour to a later run.
A writer that starts after the rollup took the lock waits for its commit,
so its minutes come after the recomputation and its mark survives. The
rollup therefore never recomputes an hour while a writer that writes into
it is open. A hash collision between two hours only makes the rollup skip
an hour until its next run.
Behind the locks the rollup runs a consistency check once per run
(MarkDriftedHours): every hour of the last 24 (apirollup.DriftWindow)
that has minute rows, whose hourly rows disagree with them on the request
count and that no mark covers is marked again, recomputed in the same run,
logged as an error and counted in jobs_api_rollup_drifted_hours_total.
With the locks it should find nothing. Hours without minute rows are never
marked, because a purge that keeps aggregates deletes minutes on purpose.
ingest_warnings counts requests, not exports (audit A-8): a cumulative
series re-exported with no new request adds nothing. lossy_histogram,
coarse_buckets and span_derived count the requests of such points,
secret_in_path the requests of points whose resource had a seed redacted,
cardinality_overflow the requests relabelled {other} plus one per
cumulative point refused by the stream cap (its new requests cannot be known
without its state).
Partitions
- Names:
api.metrics_minute_pYYYYMMDD(one UTC day),api.metrics_hourly_pYYYYMM(one UTC month). - Created and dropped only through security definer functions owned by
platform_owner, executable byjobs:api.create_minute_partition(day),api.create_hourly_partition(month)(first day of the month),api.drop_minute_partition(day),api.drop_hourly_partition(month)(each returns false when there was nothing to do),api.minute_partitions()andapi.hourly_partitions()(days and months that exist, read from the partition bounds). Only the owner of a partitioned table may add or remove partitions, which is why these are functions. - Every partition gets forced row-level security and the same
cp_tenantpolicy as the parent (internal/db.CheckPolicyCoveragechecks partitions), and no right for any application role. All access goes through the parent, where the parent’s rights and policies apply. - The migration creates minute partitions from 7 days back to 7 days ahead and hourly partitions from the month 31 days back to 3 months ahead, both in UTC at migration time. The partition job keeps that window.
- No default partition. A row outside every partition fails with SQLSTATE 23514 (“no partition of relation … found for row”); the writer must treat that as a permanent error for the point (time far outside the window), not retry it forever.
Poison messages
A value PostgreSQL refuses fails the whole transaction, and a batch holds messages of many tenants. Three layers keep one message from stalling the queue (audit A-1):- The collector produces only storable values:
routenormrule set 2 turns a segment that decodes to invalid UTF-8 or a control character into{invalid}and bounds the route at 512 bytes (/{long}tail), andtenantguardcutsservice.nameon a rune boundary and replaces invalid bytes (docs/reference/collector.md). - The writer validates every value of a message before the batch (route,
service, method, message id) and moves a message that fails to
API_DLQalone. - When the database still refuses a batch with an SQL error outside the
transient classes (08, 40, 53, 55, 57, 58, XX), the writer applies the
batch again one message per transaction, in queue order. The good
messages commit. A message refused with a data error (classes 22 and 23)
goes to
API_DLQat once. Any other refusal is retried alone with the usual backoff and dead-lettered after 5 deliveries (about 2.5 minutes), but only if other messages committed within the last 10 minutes, so a database fault that fails everything is never mistaken for poison.TestMetricsWriterPoisonMessages(build tagmetricswriterit) covers all three.
- Dropping a partition takes an access exclusive lock on the parent for a moment, so the retention job runs it in its own short transaction.
- 24-month minimum for hourly data: enforced by the retention job’s
configuration (
API_HOURLY_RETENTION_MONTHS), not by the drop function.
Rights (plan 4.7)
Every table has forced row-level security with exactly one
cp_tenant
policy keyed on cp.current_tenant_id(); cp_operator has a select policy
on hourly and daily only; metrics_writer and jobs have policies over all
rows of the tables they use. cp_system has no table right in api; it has
schema usage only to call api.purge_tenant (migration 00017). Functions:
add_buckets for all four roles; sum_buckets and histogram_quantile for
cp_tenant, cp_operator, jobs; the partition functions for jobs only.
Queries
writerq (metrics_writer; run with db.WithRole(ctx, db.RoleMetricsWriter, ...) after natsx.CheckFence):
jobsq (jobs; run with db.WithRole(ctx, db.RoleJobs, ...) after
natsx.CheckFence):
The rollup sums floats in time order (
sum(sum order by bucket_start)), so
running it twice over the same minutes gives the same bits. Delete and
insert are two statements because one statement cannot both delete a row and
insert a row with the same key.
Jobs tasks
Both retention keys are required.
API_HOURLY_RETENTION_MONTHS below 24 is
refused at startup by config.LoadAPIRetention and by apiretention.New;
API_MINUTE_RETENTION_DAYS must be 8 or more (config.MinMinuteRetentionDays):
the writer accepts points as old as API_METRICS keeps messages (7 days), and
a backlog after an outage must still find their partitions (audit A-14). jobs capacity measures the
tables (docs/reference/capacity.md).
Tenant purge
00017_api_purge.sql adds api.purge_tenant(tenant, keep_aggregates), a
security definer function and the only api right of cp_system. The
lifecycle purger api (lifecycle.APIPurger) calls it in the reconciler’s
cp_system transaction (PurgeAPITenant in db/queries/system/api_purge.sql).
It refuses a tenant whose cp.tenants.status is not removed. It always
deletes metrics_minute, series_state, resource_seen, route_registry
and rollup_pending rows; metrics_hourly, metrics_daily and
ingest_warnings unless keep_aggregates. ingest_dedup rows stay until
retention removes them, so a message still queued on API_METRICS cannot be
applied again. Because every api table forces row-level security, the
function’s owner has delete and select policies on those tables and a select
policy on cp.tenants limited to removed tenants.
Writer state bounds
The route and service caps bound the rows a tenant gets per day. They do not bound the writer’s state: every process restart is a new resource, and every distinct route, status orerror.type of a cumulative SDK is its own stream
with a series_state row. metricswriter.MaxSeriesPerTenant (100,000) caps
the streams per tenant (audit A-6). A new cumulative stream beyond it is
refused, counted as a rejected point and as cardinality_overflow, and
logged; known streams and delta points carry on. Streams idle for 7 days
are pruned, which frees room. The caps cannot be applied before the stream
identity instead: merging two cumulative streams into one would interleave
two counters and turn every difference into a reset.
First-point rule and long export intervals
The first cumulative point of a process the writer has never seen counts in full when the process is younger thanMETRICSWRITER_FIRST_POINT_THRESHOLD
(120 s), and otherwise becomes a baseline: its counts are history from
before the writer followed it. A process that exports every 5 minutes is
300 s old at its first export, so that export becomes a baseline as well.
The stream remembers it (series_state.baseline), and its second point
decides from the cadence: when the first point’s age was at most twice the
gap to the second, the process had only just started and the second point
counts in full (outcome catch_up), so the first interval is not lost; it
lands one interval late, in the second point’s minute. A process that had
run longer keeps its baseline (audit A-5). A process that exports once and
exits with an interval above the threshold is still lost; keep the
interval at 60 s or less, as docs/onboarding/api/troubleshooting.md says.