Files
zeekay 29f0202e06 perf(datastoremetrics): write N batches in two inserts, not 2N
The metrics path costs INSERTS, not bytes, and nothing in it was shaped
for that. Every insert into the samples table fans out through the 5m and
30m rollup views — one insert becomes three parts — and the ZAP wire
carries one batch per RESOURCE, so insert volume tracks the number of
objects a fleet observes rather than the amount of data it produces.

Measured on hanzo-k8s 2026-08-08: ~518 writes a minute against a merge
pool already pinned 32/32 on a node at load average 43. event.metric_30m
is AggregatingMergeTree on 30-minute buckets, so merges re-aggregate the
same rows endlessly and its parts reached merge level 21,310 while
metric/series sat near 50. It crossed the part ceiling and ClickHouse
answered code 252 to EVERY metric write for an hour — app telemetry that
had been landing for months, not just the new collectors. A k8s_cluster
receiver, which models each object as its own resource, would have been
~16,000 inserts a minute.

Rows are cheap to concatenate; statements are not. WriteMetricsMany
builds each batch exactly as WriteMetrics would — same resource
attributes, same fingerprints — and lands the lot in ONE time_series and
ONE samples statement. A nil or empty batch is skipped rather than
refused, so one dead sender cannot discard a window of everyone else's
metrics.

Additive: nothing calls it yet. The caller change is cloud buffering what
arrives in a window instead of writing per batch.

buildRowsMany is split out so the contract is testable without a
datastore connection, and TestBuildRowsManyPreservesPerBatchIdentity is
mutation-proven — reuse one batch's resource for all and it fails on the
env leaking staging -> prod, which is the exact hazard of coalescing.
2026-08-08 18:57:17 -07:00

346 lines
11 KiB
Go

// Copyright (C) 2025-2026, Hanzo AI Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
package datastoremetrics
import (
"strings"
"testing"
"github.com/hanzoai/o11y/pkg/zapmetricreceiver"
)
func f64(v float64) *float64 { return &v }
func u64(v uint64) *uint64 { return &v }
// refFNV is an independent, byte-stream expression of FNV-1a (the ground-truth
// definition) used to cross-check the fingerprint primitive without importing
// the forked internal package.
func refFNV(seed uint64, data []byte) uint64 {
const prime = 1099511628211
h := seed
for _, b := range data {
h ^= uint64(b)
h *= prime
}
return h
}
func TestFingerprintMatchesFNVReference(t *testing.T) {
attrs := map[string]string{"b": "2", "a": "1"}
// sorted key order: a,b — stream is key 0xFF value 0xFF per pair.
stream := []byte("a")
stream = append(stream, 255)
stream = append(stream, []byte("1")...)
stream = append(stream, 255)
stream = append(stream, []byte("b")...)
stream = append(stream, 255)
stream = append(stream, []byte("2")...)
stream = append(stream, 255)
got := fingerprint(initialOffset, attrs)
want := refFNV(initialOffset, stream)
if got != want {
t.Fatalf("fingerprint=%d want=%d", got, want)
}
// hashWithName appends __name__ 0xFF name (no trailing separator).
name := "http_requests"
nameStream := append([]byte("__name__"), 255)
nameStream = append(nameStream, []byte(name)...)
if hashWithName(got, name) != refFNV(got, nameStream) {
t.Fatalf("hashWithName mismatch")
}
}
func TestFingerprintOffsetPassthroughAndOrderIndependent(t *testing.T) {
if fingerprint(initialOffset, map[string]string{}) != initialOffset {
t.Fatalf("empty attrs must pass the offset through unchanged")
}
a := fingerprint(initialOffset, map[string]string{"x": "1", "y": "2", "z": "3"})
b := fingerprint(initialOffset, map[string]string{"z": "3", "y": "2", "x": "1"})
if a != b {
t.Fatalf("fingerprint must be independent of map order: %d != %d", a, b)
}
if fingerprint(initialOffset, map[string]string{"x": "1"}) == fingerprint(initialOffset, map[string]string{"x": "2"}) {
t.Fatalf("distinct values must hash differently")
}
}
func indexTS(rows []tsRow) map[uint64]tsRow {
m := make(map[uint64]tsRow, len(rows))
for _, r := range rows {
m[r.fingerprint] = r
}
return m
}
func TestBuildRowsCounter(t *testing.T) {
batch := &zapmetricreceiver.MetricBatch{
AppName: "gateway",
Version: "1.2.3",
Resource: map[string]string{"deployment.environment": "prod"},
TimestampNs: 1_700_000_000_000 * 1e6,
Families: []zapmetricreceiver.MetricFamily{{
Name: "http_requests_total", Help: "total reqs", Type: "counter",
Metrics: []zapmetricreceiver.Metric{{
Labels: map[string]string{"method": "GET"}, Value: f64(42),
}},
}},
}
ts, samples := buildRows(batch)
if len(samples) != 1 || len(ts) != 1 {
t.Fatalf("counter: got %d samples %d ts, want 1/1", len(samples), len(ts))
}
s := samples[0]
if s.metricName != "http_requests_total" || s.value != 42 || s.temporality != temporalityCumulative {
t.Fatalf("counter sample wrong: %+v", s)
}
if s.env != "prod" {
t.Fatalf("env must come from deployment.environment: %q", s.env)
}
tr := ts[0]
if tr.fingerprint != s.fingerprint {
t.Fatalf("join key broken: ts fp %d != sample fp %d", tr.fingerprint, s.fingerprint)
}
if tr.typ != typeSum || !tr.isMonotonic {
t.Fatalf("counter must be monotonic Sum: %+v", tr)
}
if tr.resourceAttrs["service.name"] != "gateway" || tr.resourceAttrs["service.version"] != "1.2.3" {
t.Fatalf("AppName/Version must lift into service.*: %+v", tr.resourceAttrs)
}
for _, sub := range []string{`"__name__":"http_requests_total"`, `"method":"GET"`, `"service.name":"gateway"`, `"__temporality__":"Cumulative"`} {
if !strings.Contains(tr.labels, sub) {
t.Fatalf("labels %q missing %q", tr.labels, sub)
}
}
}
func TestBuildRowsGauge(t *testing.T) {
batch := &zapmetricreceiver.MetricBatch{
TimestampNs: 1_700_000_000_000 * 1e6,
Families: []zapmetricreceiver.MetricFamily{{
Name: "queue_depth", Type: "gauge",
Metrics: []zapmetricreceiver.Metric{{Value: f64(7)}},
}},
}
ts, samples := buildRows(batch)
if len(samples) != 1 || samples[0].temporality != temporalityUnspecified {
t.Fatalf("gauge temporality must be Unspecified: %+v", samples)
}
if ts[0].typ != typeGauge || ts[0].isMonotonic {
t.Fatalf("gauge must be non-monotonic Gauge: %+v", ts[0])
}
}
func TestBuildRowsHistogram(t *testing.T) {
batch := &zapmetricreceiver.MetricBatch{
TimestampNs: 1_700_000_000_000 * 1e6,
Families: []zapmetricreceiver.MetricFamily{{
Name: "req_duration", Type: "histogram",
Metrics: []zapmetricreceiver.Metric{{
Labels: map[string]string{"route": "/v1/chat"},
SampleCount: u64(10), SampleSum: f64(3.5),
Buckets: []zapmetricreceiver.Bucket{
{UpperBound: 0.1, CumulativeCount: 3},
{UpperBound: 0.5, CumulativeCount: 8},
},
}},
}},
}
ts, samples := buildRows(batch)
tsByFP := indexTS(ts)
// Expect .count, .sum, and 3 .bucket samples (0.1, 0.5, +Inf).
var count, sum int
les := map[string]float64{} // le -> sample value
for _, s := range samples {
tr := tsByFP[s.fingerprint]
switch s.metricName {
case "req_duration.count":
count++
if s.value != 10 {
t.Fatalf(".count value=%v want 10", s.value)
}
if tr.unit != unitCount || tr.typ != typeSum {
t.Fatalf(".count series wrong: %+v", tr)
}
case "req_duration.sum":
sum++
if s.value != 3.5 {
t.Fatalf(".sum value=%v want 3.5", s.value)
}
case "req_duration.bucket":
if tr.typ != typeHistogram {
t.Fatalf(".bucket type must be Histogram: %+v", tr)
}
les[tr.attrs["le"]] = s.value
default:
t.Fatalf("unexpected series %q", s.metricName)
}
}
if count != 1 || sum != 1 {
t.Fatalf("want one .count and one .sum, got %d/%d", count, sum)
}
if les["0.1"] != 3 || les["0.5"] != 8 || les["+Inf"] != 10 {
t.Fatalf("cumulative bucket values wrong: %+v", les)
}
// Every bucket le must produce a distinct fingerprint.
seen := map[uint64]bool{}
for _, s := range samples {
if s.metricName == "req_duration.bucket" {
if seen[s.fingerprint] {
t.Fatalf("duplicate bucket fingerprint %d", s.fingerprint)
}
seen[s.fingerprint] = true
}
}
}
func TestBuildRowsSummary(t *testing.T) {
batch := &zapmetricreceiver.MetricBatch{
TimestampNs: 1_700_000_000_000 * 1e6,
Families: []zapmetricreceiver.MetricFamily{{
Name: "rpc_latency", Type: "summary",
Metrics: []zapmetricreceiver.Metric{{
SampleCount: u64(5), SampleSum: f64(1.2),
Quantiles: []zapmetricreceiver.Quantile{
{Quantile: 0.5, Value: 0.2},
{Quantile: 0.99, Value: 0.9},
},
}},
}},
}
ts, samples := buildRows(batch)
tsByFP := indexTS(ts)
quantiles := map[string]float64{}
for _, s := range samples {
tr := tsByFP[s.fingerprint]
if s.metricName == "rpc_latency.quantile" {
if tr.typ != typeSummary {
t.Fatalf(".quantile type must be Summary: %+v", tr)
}
quantiles[tr.attrs["quantile"]] = s.value
}
}
if quantiles["0.5"] != 0.2 || quantiles["0.99"] != 0.9 {
t.Fatalf("quantile samples wrong: %+v", quantiles)
}
}
func TestTimeSeriesHourFloor(t *testing.T) {
// 1_700_000_123_456 ms is mid-hour; series unix_milli must floor to the hour.
batch := &zapmetricreceiver.MetricBatch{
TimestampNs: 1_700_000_123_456 * 1e6,
Families: []zapmetricreceiver.MetricFamily{{
Name: "c", Type: "counter",
Metrics: []zapmetricreceiver.Metric{{Value: f64(1)}},
}},
}
ts, samples := buildRows(batch)
if samples[0].unixMilli != 1_700_000_123_456 {
t.Fatalf("sample keeps exact ms: %d", samples[0].unixMilli)
}
if ts[0].unixMilli != 1_700_000_123_456/3600000*3600000 {
t.Fatalf("series must floor to hour: %d", ts[0].unixMilli)
}
}
func TestNilValueSkipped(t *testing.T) {
batch := &zapmetricreceiver.MetricBatch{
TimestampNs: 1,
Families: []zapmetricreceiver.MetricFamily{{
Name: "c", Type: "counter",
Metrics: []zapmetricreceiver.Metric{{Value: nil}},
}},
}
ts, samples := buildRows(batch)
if len(ts) != 0 || len(samples) != 0 {
t.Fatalf("nil counter value must be skipped, got %d/%d", len(ts), len(samples))
}
}
func TestDeterministicAcrossCalls(t *testing.T) {
mk := func() *zapmetricreceiver.MetricBatch {
return &zapmetricreceiver.MetricBatch{
AppName: "svc", TimestampNs: 1_700_000_000_000 * 1e6,
Families: []zapmetricreceiver.MetricFamily{{
Name: "m", Type: "counter",
Metrics: []zapmetricreceiver.Metric{{
Labels: map[string]string{"a": "1", "b": "2", "c": "3"}, Value: f64(1),
}},
}},
}
}
_, s1 := buildRows(mk())
_, s2 := buildRows(mk())
if s1[0].fingerprint != s2[0].fingerprint {
t.Fatalf("fingerprints must be stable across calls: %d != %d", s1[0].fingerprint, s2[0].fingerprint)
}
}
// TestBuildRowsManyPreservesPerBatchIdentity is the batched-insert contract:
// N batches must yield exactly the rows the N separate writes would, so the
// only thing that changes is the number of INSERT statements.
//
// That ratio is why WriteMetricsMany exists. Each insert into the samples table
// fans out through the 5m and 30m rollup views — one insert, three parts — and
// the ZAP wire carries one batch per RESOURCE, so insert volume tracks the
// number of objects observed rather than the amount of data. Measured
// 2026-08-08 on hanzo-k8s: ~518 writes a minute drove event.metric_30m past its
// part ceiling and ClickHouse answered code 252 to EVERY metric write, killing
// app telemetry that had been landing for months.
func TestBuildRowsManyPreservesPerBatchIdentity(t *testing.T) {
mk := func(env, metric string, v float64) *zapmetricreceiver.MetricBatch {
return &zapmetricreceiver.MetricBatch{
AppName: "gateway",
Resource: map[string]string{"deployment.environment": env},
TimestampNs: 1_700_000_000_000 * 1e6,
Families: []zapmetricreceiver.MetricFamily{{
Name: metric, Type: "counter",
Metrics: []zapmetricreceiver.Metric{{Value: f64(v)}},
}},
}
}
batches := []*zapmetricreceiver.MetricBatch{
mk("prod", "a_total", 1),
nil, // a dead sender must not discard the window
mk("staging", "b_total", 2),
{Families: nil}, // an empty batch, likewise
mk("prod", "c_total", 3),
}
w := &Writer{nowMilli: func() int64 { return 1_700_000_000_000 }}
ts, samples := w.buildRowsMany(batches)
if len(samples) != 3 || len(ts) != 3 {
t.Fatalf("got %d samples %d ts, want 3/3 (nil and empty batches skipped)", len(samples), len(ts))
}
// Identical to building each batch alone — same rows, same fingerprints.
var wantTS []tsRow
var wantSamples []sampleRow
for _, b := range []*zapmetricreceiver.MetricBatch{batches[0], batches[2], batches[4]} {
t2, s2 := buildRows(b)
wantTS = append(wantTS, t2...)
wantSamples = append(wantSamples, s2...)
}
for i := range samples {
if samples[i] != wantSamples[i] {
t.Fatalf("sample %d differs from the single-batch build: got %+v want %+v", i, samples[i], wantSamples[i])
}
}
for i := range ts {
if ts[i].fingerprint != wantTS[i].fingerprint || ts[i].env != wantTS[i].env {
t.Fatalf("ts %d differs: got fp=%d env=%q, want fp=%d env=%q",
i, ts[i].fingerprint, ts[i].env, wantTS[i].fingerprint, wantTS[i].env)
}
}
// Per-batch resource attributes must survive the merge — the whole risk of
// coalescing is one batch's env leaking onto another's rows.
if samples[0].env != "prod" || samples[1].env != "staging" || samples[2].env != "prod" {
t.Fatalf("env leaked across batches: %q %q %q", samples[0].env, samples[1].env, samples[2].env)
}
}