Skip to content

Commit e255916

Browse files
committed
fix(sink): make label output deterministic, thread context, and join batch errors
Prometheus serialized labels in map-iteration order, breaking the documented stable output and golden assertions; sort the keys. The statsd Aggregator dropped the caller context before the registry transform, losing deadline and trace propagation; thread it through metricFor. The reservoir non-batch dispatch kept only the last error; join them so no per-payload failure is lost. Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent cfa8809 commit e255916

7 files changed

Lines changed: 390 additions & 13 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package prometheus_test
4+
5+
import (
6+
"context"
7+
"net/http"
8+
"testing"
9+
10+
csink "github.com/stablekernel/crucible/sink"
11+
prom "github.com/stablekernel/crucible/sink/prometheus"
12+
)
13+
14+
// TestPushMetrics_MultiLabelDeterministicOrder verifies that a metric with
15+
// several labels serializes its labels in a stable, sorted order regardless of
16+
// map iteration order. Serializing the same metric many times must produce
17+
// byte-identical output.
18+
func TestPushMetrics_MultiLabelDeterministicOrder(t *testing.T) {
19+
t.Parallel()
20+
21+
metrics := []prom.Metric{
22+
{
23+
Name: "http_requests_total",
24+
Type: prom.TypeCounter,
25+
Value: "7",
26+
Labels: map[string]string{
27+
"method": "GET",
28+
"status": "200",
29+
"region": "us-east-1",
30+
"app": "api",
31+
},
32+
},
33+
}
34+
35+
const want = `http_requests_total{app="api",method="GET",region="us-east-1",status="200"} 7`
36+
37+
var first string
38+
for i := 0; i < 50; i++ {
39+
fd := &fakeDoer{status: http.StatusOK}
40+
reg := prom.NewRegistry()
41+
csink.Register(reg, func(_ context.Context, _ deployFinished) csink.Op[prom.Doer] {
42+
return prom.PushMetrics("http://gw", "job", metrics)
43+
})
44+
outlet := prom.New(fd, reg)
45+
if err := outlet.Sink(context.Background(), deployFinished{Env: "prod"}); err != nil {
46+
t.Fatalf("Sink() error = %v", err)
47+
}
48+
if i == 0 {
49+
first = fd.body
50+
if !contains(first, want) {
51+
t.Fatalf("body = %q, want it to contain %q", first, want)
52+
}
53+
continue
54+
}
55+
if fd.body != first {
56+
t.Fatalf("label order is not deterministic:\n run 0: %q\n run %d: %q", first, i, fd.body)
57+
}
58+
}
59+
}
60+
61+
func contains(haystack, needle string) bool {
62+
for i := 0; i+len(needle) <= len(haystack); i++ {
63+
if haystack[i:i+len(needle)] == needle {
64+
return true
65+
}
66+
}
67+
return false
68+
}

sink/prometheus/prometheus.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"fmt"
2121
"io"
2222
"net/http"
23+
"sort"
2324
"strings"
2425

2526
csink "github.com/stablekernel/crucible/sink"
@@ -106,14 +107,18 @@ func marshalMetrics(metrics []Metric) string {
106107
b.WriteString(m.Name)
107108
if len(m.Labels) > 0 {
108109
b.WriteByte('{')
109-
first := true
110-
// Iterate in a stable order for deterministic output.
111-
for k, v := range m.Labels {
112-
if !first {
110+
// Sort the label keys so the serialized output is deterministic
111+
// regardless of map iteration order.
112+
keys := make([]string, 0, len(m.Labels))
113+
for k := range m.Labels {
114+
keys = append(keys, k)
115+
}
116+
sort.Strings(keys)
117+
for i, k := range keys {
118+
if i > 0 {
113119
b.WriteByte(',')
114120
}
115-
fmt.Fprintf(&b, `%s="%s"`, k, v)
116-
first = false
121+
fmt.Fprintf(&b, `%s="%s"`, k, m.Labels[k])
117122
}
118123
b.WriteByte('}')
119124
}

sink/reservoir.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package sink
44

55
import (
66
"context"
7+
"errors"
78
"sync"
89
"time"
910

@@ -81,8 +82,9 @@ type reservoir struct {
8182
flushLatency telemetry.Histogram
8283
dropped telemetry.Counter
8384

84-
mu sync.Mutex
85-
buf []any
85+
mu sync.Mutex
86+
buf []any
87+
closed bool // set by Shutdown; Sink passes through afterward
8688

8789
cancel context.CancelFunc
8890
wg sync.WaitGroup
@@ -121,8 +123,15 @@ func Reservoir(inner Outlet, opts ...ReservoirOption) Outlet {
121123

122124
// Sink buffers payload, flushing synchronously if the buffer reaches the batch
123125
// size. An over-cap payload is dropped and counted, never blocking the caller.
126+
// After Shutdown the background loop is gone, so a payload would otherwise be
127+
// stranded in the buffer; once closed, Sink dispatches each payload synchronously
128+
// to inner instead of buffering it.
124129
func (r *reservoir) Sink(ctx context.Context, payload any) error {
125130
r.mu.Lock()
131+
if r.closed {
132+
r.mu.Unlock()
133+
return r.dispatch(ctx, []any{payload})
134+
}
126135
if r.maxBuf > 0 && len(r.buf) >= r.maxBuf {
127136
r.mu.Unlock()
128137
r.dropped.Add(ctx, 1)
@@ -158,6 +167,9 @@ func (r *reservoir) Shutdown(ctx context.Context) error {
158167
r.cancel()
159168
}
160169
r.wg.Wait()
170+
r.mu.Lock()
171+
r.closed = true
172+
r.mu.Unlock()
161173
})
162174
return r.Flush(ctx)
163175
}
@@ -183,11 +195,13 @@ func (r *reservoir) dispatch(ctx context.Context, batch []any) error {
183195
if b, ok := r.inner.(BatchOutlet); ok {
184196
err = b.SinkBatch(ctx, batch)
185197
} else {
198+
var errs []error
186199
for _, p := range batch {
187200
if e := r.inner.Sink(ctx, p); e != nil {
188-
err = e
201+
errs = append(errs, e)
189202
}
190203
}
204+
err = errors.Join(errs...)
191205
}
192206
r.flushLatency.Record(ctx, float64(r.now().Sub(start).Milliseconds()))
193207
return err

sink/reservoir_errors_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package sink
4+
5+
import (
6+
"context"
7+
"errors"
8+
"sync"
9+
"testing"
10+
)
11+
12+
// erroringOutlet fails its first failN Sink calls with a distinct error each,
13+
// then succeeds. It is not a BatchOutlet, so the reservoir takes the per-payload
14+
// dispatch path where errors must be joined.
15+
type erroringOutlet struct {
16+
mu sync.Mutex
17+
errs []error
18+
called int
19+
}
20+
21+
func (o *erroringOutlet) Sink(_ context.Context, _ any) error {
22+
o.mu.Lock()
23+
defer o.mu.Unlock()
24+
i := o.called
25+
o.called++
26+
if i < len(o.errs) {
27+
return o.errs[i]
28+
}
29+
return nil
30+
}
31+
32+
// TestReservoirNonBatchJoinsAllErrors verifies the per-payload dispatch path
33+
// joins every error rather than keeping only the last one.
34+
func TestReservoirNonBatchJoinsAllErrors(t *testing.T) {
35+
t.Parallel()
36+
37+
err1 := errors.New("first failed")
38+
err2 := errors.New("second failed")
39+
out := &erroringOutlet{errs: []error{err1, err2}}
40+
r := Reservoir(out, WithBatchSize(0), WithBatchInterval(0)).(*reservoir)
41+
42+
_ = r.Sink(context.Background(), "a")
43+
_ = r.Sink(context.Background(), "b")
44+
err := r.Flush(context.Background())
45+
if err == nil {
46+
t.Fatal("Flush() = nil, want a joined error")
47+
}
48+
if !errors.Is(err, err1) {
49+
t.Errorf("joined error does not contain the first error: %v", err)
50+
}
51+
if !errors.Is(err, err2) {
52+
t.Errorf("joined error does not contain the second error: %v", err)
53+
}
54+
}
55+
56+
// TestReservoirSinkAfterShutdownPassesThrough verifies a payload sunk after
57+
// Shutdown is dispatched synchronously to inner rather than stranded in the
58+
// buffer with no loop left to flush it.
59+
func TestReservoirSinkAfterShutdownPassesThrough(t *testing.T) {
60+
t.Parallel()
61+
62+
bucket := NewBucket()
63+
r := Reservoir(bucket, WithBatchSize(100), WithBatchInterval(0)).(*reservoir)
64+
if err := r.Shutdown(context.Background()); err != nil {
65+
t.Fatalf("Shutdown() error = %v", err)
66+
}
67+
68+
if err := r.Sink(context.Background(), "late"); err != nil {
69+
t.Fatalf("Sink() after Shutdown error = %v", err)
70+
}
71+
all := bucket.All()
72+
if len(all) != 1 || all[0] != "late" {
73+
t.Fatalf("inner has %v, want the post-shutdown payload delivered immediately", all)
74+
}
75+
}
76+
77+
// TestReservoirSinkAfterShutdownSurfacesError verifies the pass-through path
78+
// still returns inner's error to the caller.
79+
func TestReservoirSinkAfterShutdownSurfacesError(t *testing.T) {
80+
t.Parallel()
81+
82+
boom := errors.New("inner down")
83+
out := &erroringOutlet{errs: []error{boom}}
84+
r := Reservoir(out, WithBatchSize(100), WithBatchInterval(0)).(*reservoir)
85+
if err := r.Shutdown(context.Background()); err != nil {
86+
t.Fatalf("Shutdown() error = %v", err)
87+
}
88+
if err := r.Sink(context.Background(), "late"); !errors.Is(err, boom) {
89+
t.Fatalf("Sink() after Shutdown = %v, want %v", err, boom)
90+
}
91+
}

sink/statsd/options_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package statsd_test
4+
5+
import (
6+
"context"
7+
"errors"
8+
"testing"
9+
"time"
10+
11+
csink "github.com/stablekernel/crucible/sink"
12+
statsdsink "github.com/stablekernel/crucible/sink/statsd"
13+
)
14+
15+
// TestWithName_AppearsInFlushError verifies WithName overrides the outlet name
16+
// carried on the *sink.Error returned from a failing flush.
17+
func TestWithName_AppearsInFlushError(t *testing.T) {
18+
t.Parallel()
19+
20+
boom := errors.New("emit failed")
21+
fc := &fakeClient{err: boom}
22+
agg := statsdsink.NewAggregator(fc,
23+
statsdsink.WithName("custom-statsd"),
24+
statsdsink.WithInterval(0), // no background loop; Flush is the only emit
25+
)
26+
27+
if err := agg.Sink(context.Background(), statsdsink.Metric{Type: statsdsink.TypeCount, Name: "n", Int: 1, Rate: 1}); err != nil {
28+
t.Fatalf("Sink() error = %v", err)
29+
}
30+
f, ok := agg.(csink.Flusher)
31+
if !ok {
32+
t.Fatal("Aggregator does not implement sink.Flusher")
33+
}
34+
err := f.Flush(context.Background())
35+
if !errors.Is(err, boom) {
36+
t.Fatalf("Flush() = %v, want to wrap %v", err, boom)
37+
}
38+
var se *csink.Error
39+
if !errors.As(err, &se) || se.Outlet != "custom-statsd" {
40+
t.Fatalf("error = %+v, want Outlet=custom-statsd", se)
41+
}
42+
}
43+
44+
// TestWithName_EmptyIgnored verifies an empty name leaves the default in place.
45+
func TestWithName_EmptyIgnored(t *testing.T) {
46+
t.Parallel()
47+
48+
boom := errors.New("emit failed")
49+
fc := &fakeClient{err: boom}
50+
agg := statsdsink.NewAggregator(fc, statsdsink.WithName(""), statsdsink.WithInterval(0))
51+
_ = agg.Sink(context.Background(), statsdsink.Metric{Type: statsdsink.TypeCount, Name: "n", Int: 1, Rate: 1})
52+
53+
err := agg.(csink.Flusher).Flush(context.Background())
54+
var se *csink.Error
55+
if !errors.As(err, &se) || se.Outlet != "statsd" {
56+
t.Fatalf("error = %+v, want default Outlet=statsd", se)
57+
}
58+
}
59+
60+
// TestWithClock_NonNilAcceptedNilIgnored verifies WithClock installs a non-nil
61+
// clock and ignores a nil one. The aggregator stays usable in both cases.
62+
func TestWithClock_NonNilAcceptedNilIgnored(t *testing.T) {
63+
t.Parallel()
64+
65+
fixed := time.Unix(42, 0)
66+
clock := func() time.Time { return fixed }
67+
68+
for _, tc := range []struct {
69+
name string
70+
now func() time.Time
71+
}{
72+
{"non-nil clock", clock},
73+
{"nil clock falls back to default", nil},
74+
} {
75+
tc := tc
76+
t.Run(tc.name, func(t *testing.T) {
77+
t.Parallel()
78+
fc := &fakeClient{}
79+
agg := statsdsink.NewAggregator(fc, statsdsink.WithClock(tc.now), statsdsink.WithInterval(0))
80+
if err := agg.Sink(context.Background(), statsdsink.Metric{Type: statsdsink.TypeGauge, Name: "g", Value: 1, Rate: 1}); err != nil {
81+
t.Fatalf("Sink() error = %v", err)
82+
}
83+
if err := agg.(csink.Flusher).Flush(context.Background()); err != nil {
84+
t.Fatalf("Flush() error = %v", err)
85+
}
86+
if got := len(fc.snapshot()); got != 1 {
87+
t.Fatalf("emitted %d metrics, want 1", got)
88+
}
89+
})
90+
}
91+
}
92+
93+
// TestDial_ReturnsUsableClient verifies Dial constructs a Client over a UDP
94+
// address without requiring a live StatsD server. The datadog SDK resolves the
95+
// address lazily, so a valid host:port yields a ready client.
96+
func TestDial_ReturnsUsableClient(t *testing.T) {
97+
t.Parallel()
98+
99+
c, err := statsdsink.Dial("127.0.0.1:8125")
100+
if err != nil {
101+
t.Fatalf("Dial() error = %v", err)
102+
}
103+
if c == nil {
104+
t.Fatal("Dial() returned a nil Client")
105+
}
106+
if err := c.Count("dial.test", 1, nil, 1); err != nil {
107+
t.Fatalf("Count() on dialed client error = %v", err)
108+
}
109+
}

sink/statsd/statsd.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,8 @@ func NewAggregator(client Client, opts ...AggregatorOption) csink.Outlet {
250250
// directly; any other payload is resolved through the registry installed with
251251
// WithRegistry, and an unregistered type returns sink.ErrUnregistered. The
252252
// first Sink starts the background flush loop when an interval is configured.
253-
func (a *Aggregator) Sink(_ context.Context, payload any) error {
254-
m, ok := a.metricFor(payload)
253+
func (a *Aggregator) Sink(ctx context.Context, payload any) error {
254+
m, ok := a.metricFor(ctx, payload)
255255
if !ok {
256256
return csink.ErrUnregistered
257257
}
@@ -263,7 +263,9 @@ func (a *Aggregator) Sink(_ context.Context, payload any) error {
263263
}
264264

265265
// metricFor resolves payload to a Metric, either directly or via the registry.
266-
func (a *Aggregator) metricFor(payload any) (Metric, bool) {
266+
// The caller's context is threaded into the registry transformer so deadline
267+
// and trace propagation survive the resolution step.
268+
func (a *Aggregator) metricFor(ctx context.Context, payload any) (Metric, bool) {
267269
if m, ok := payload.(Metric); ok {
268270
return m, true
269271
}
@@ -274,7 +276,7 @@ func (a *Aggregator) metricFor(payload any) (Metric, bool) {
274276
if !ok {
275277
return Metric{}, false
276278
}
277-
return transform(context.Background(), payload), true
279+
return transform(ctx, payload), true
278280
}
279281

280282
// Flush swaps the live window for a fresh one and emits the captured window to

0 commit comments

Comments
 (0)