You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by ha...@apache.org on 2026/08/10 11:31:04 UTC
(skywalking-banyandb) branch main updated: perf(tracesampler): halve realistic sampler decision cost via direct scalar reads and early entry rejection (#1274)
This is an automated email from the ASF dual-hosted git repository.
hanahmily pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git
The following commit(s) were added to refs/heads/main by this push:
new 706096952 perf(tracesampler): halve realistic sampler decision cost via direct scalar reads and early entry rejection (#1274)
706096952 is described below
commit 7060969527ae1025714937c7159ba38111eac1ac
Author: Gao Hongtao <ha...@gmail.com>
AuthorDate: Mon Aug 10 19:30:59 2026 +0800
perf(tracesampler): halve realistic sampler decision cost via direct scalar reads and early entry rejection (#1274)
---
CHANGES.md | 2 +
.../trace-pipeline-merge-optimization-plan.md | 10 +
.../skywalking/internal/tracesampler/sampler.go | 123 +++++++++-
.../internal/tracesampler/sampler_test.go | 262 +++++++++++++++++++++
4 files changed, 390 insertions(+), 7 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index 6eb42e265..beb090ba2 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -179,6 +179,8 @@ Release Notes.
- Bump Go and ui/mcp dependencies to clear Dependabot advisories: `golang.org/x/net` v0.52.0→v0.56.0 (CVE-2026-25680), `opencontainers/runc` v1.3.3→v1.3.6 (CVE-2026-41579). Refresh mcp/ui lockfiles and license attribution.
- Defer the sw-trace-sampler tag-array decode to first use: 60% per-trace cost reduction on the realistic-mix path (`duration+err+tag` 2,710 → 1,015 ns/trace, 2,380 → 664 allocs/op on the SkyWalking schema).
- Alias decoded entry strings via `pkg/convert.BytesToString`: 408 → 1 alloc/op on SkyWalking tag paths and 1,536 → 1 on Zipkin tag paths (the realistic 3-row × 8-entry trace); 24-40% ns/trace reduction on tag-using paths. Also fixes a pre-existing nil-deref in `keepTrace` when the array column is absent.
+- Reject non-matching tag entries in the sampler's rule matcher before the string comparison. Most entries belong to some other tag, so the common outcome is rejection; a length and first-byte check now precedes `strings.HasPrefix`, and rules naming the array column itself take a branch with no prefix handling at all. Tag-rule decisions drop a further 18% and full-scenario decisions 12-27%.
+- Read the trace sampler's scalar tag columns without materializing the SDK's decoded value. `sdk.Value` is 112 bytes and `TagColumn.At` returns it by value, so reading the duration, start-time and error columns per row cost more in `runtime.duffcopy` than in decoding. The sampler now reads those 8-byte cells directly, gated narrowly on the column's exact type so a malformed cell in a wrong-typed column still fails open with the same error. Duration-envelope decisions drop 80% (`duration [...]
- Decode escape-free trace tag rows without copying them. The sw/zipkin trace sampler copied every tag row before decoding, because the var-array decode rewrites its input in place and the engine hands the same batch to every link of a sampler chain. That copy is only needed for a row that actually contains an escape, so rows without one now decode straight from the block with the entries aliasing it — safe because `assembleTraceBlock` already deep-copies each value into block-owned stor [...]
- Precompute each tag rule's `"tagKey="` entry prefix at admission instead of rebuilding it on every `matchEntries` call (once per rule per trace).
- Add an escape-free fast path to `vararray.UnmarshalVarArray`, the var-array decoder shared by sidx, trace query, index filtering, the dump tool and the sampler SDK. An entry containing no escape byte is located with two `bytes.IndexByte` scans instead of a per-byte loop, dropping that case from O(len) to O(1) (-91% at a 256-byte payload) and leaving the buffer untouched. An entry that does carry an escape resumes the in-place loop at the first escape rather than restarting, since the b [...]
diff --git a/docs/design/trace-pipeline-merge-optimization-plan.md b/docs/design/trace-pipeline-merge-optimization-plan.md
index 3fa8f49f0..c5c5c22b9 100644
--- a/docs/design/trace-pipeline-merge-optimization-plan.md
+++ b/docs/design/trace-pipeline-merge-optimization-plan.md
@@ -432,6 +432,16 @@ On the array paths the remaining cost is the delimiter scan itself plus the stri
decode overhead that used to dominate here was removed by the escape-free path (`tagRulesOnly` 68.4 → 24.4 µs per
3000-trace batch, -64%).
+**Why the remaining gap is not closable inside the plugin.** Go's `internal` rule makes the two halves mutually
+unreachable: `plugins/skywalking/internal/tracesampler` is importable only from `plugins/skywalking/**`, and the fixture
+that holds the seed (`banyand/internal/benchmark/tracefixture`, plus the catalog parser in `.../sourcecatalog`) only
+from `banyand/**`. The framework side sidesteps this because `EvaluateSampler` takes the sampler as an `sdk.Sampler`
+and reads `pluginPath` only for a checksum — it never imports the plugin. A plugin-local benchmark has no such escape.
+Closing this therefore needs either a committed golden fixture under the plugin's `testdata/` (exported once from the
+real capture, at the cost of drifting from the seed) or making the loader reachable outside `banyand/internal` (a
+framework change). Both are out of scope for the plugin-local workstream, so the item stays open deliberately rather
+than being closed on synthetic data.
+
**Remaining gap (why this phase is not closed).** The hard invariant requires plugins to be measured "against
representative complete-trace batches from the mature seed". These benchmarks build their batches synthetically in
`benchBatch`/`benchEntries` — realistic in SHAPE (production tag keys, both first-party configs, complete traces) but
diff --git a/plugins/skywalking/internal/tracesampler/sampler.go b/plugins/skywalking/internal/tracesampler/sampler.go
index cf70a7cd4..447ba8370 100644
--- a/plugins/skywalking/internal/tracesampler/sampler.go
+++ b/plugins/skywalking/internal/tracesampler/sampler.go
@@ -676,6 +676,39 @@ var errNoDurationEnvelope = errors.New("no row carries both a start-time and a d
// from DurationTag (scaled to ns by DurationTagNanosPerUnit). This is the true
// trace duration — it catches traces slow only through sequential segments —
// and reads two cheap tag columns, never the span bodies.
+// scalarInt64 reads one cell of an 8-byte-encoded column WITHOUT materializing
+// the sdk.Value that TagColumn.At returns. That value is 112 bytes and is
+// returned by value, so reading a column per row through At cost more in
+// runtime.duffcopy than in decoding: 54.4% of the duration path, 37.5% of the
+// dedicated-error path, and 22.3% of the full realistic scenario.
+//
+// It reproduces sdk.DecodeTagValue's contract exactly, including the error text,
+// because a malformed cell must keep failing open with the same message the
+// engine would have logged:
+//
+// nil cell -> (0, false, nil) — null, the caller skips the row
+// non-8-byte cell -> (0, false, error) — malformed
+//
+// Callers must have checked col.ValueType themselves; see the narrow-gating note
+// at each call site for why the type check cannot simply be hoisted.
+//
+// PRECONDITION: row must be in range. Unlike TagColumn.At this does NOT
+// bounds-check — an out-of-range row panics rather than returning an error. Both
+// callers guarantee it, one by clamping to the shorter of the two columns and
+// the other by ranging over the column it reads; TestHasSlowTrace_RaggedColumns
+// pins the clamp, because losing it would turn a malformed block into a panic
+// inside a merge rather than a fail-open keep.
+func scalarInt64(col *sdk.TagColumn, row int, typeName string) (int64, bool, error) {
+ raw := col.Values[row]
+ if raw == nil {
+ return 0, false, nil
+ }
+ if len(raw) != 8 {
+ return 0, false, fmt.Errorf("%s: expected 8 bytes, got %d", typeName, len(raw))
+ }
+ return convert.BytesToInt64(raw), true, nil
+}
+
func (s *Sampler) hasSlowTrace(b *sdk.TraceBlock) (bool, error) {
startCol := b.Tag(s.startTimeTag)
durCol := b.Tag(s.durationTag)
@@ -686,9 +719,41 @@ func (s *Sampler) hasSlowTrace(b *sdk.TraceBlock) (bool, error) {
if len(startCol.Values) < rows {
rows = len(startCol.Values)
}
+ // The fast path is gated NARROWLY on both columns carrying exactly the type
+ // this rule accepts, rather than hoisting the per-row type checks below out
+ // of the loop. The checks look hoistable — ValueType is a property of the
+ // COLUMN, not the row — but a wrong-typed column can still hold a malformed
+ // cell that DecodeTagValue rejects on length (Float64 and Timestamp are both
+ // 8-byte-checked), and that error currently propagates. Skipping such a row
+ // on type alone would swallow it. Anything not matching both expected types
+ // keeps the original At-based loop, so every edge case stays bit-identical.
+ fastScalars := startCol.ValueType == valuetype.ValueTypeTimestamp && durCol.ValueType == valuetype.ValueTypeInt64
var minStart, maxEnd int64
haveStart, haveEnd := false, false
for row := 0; row < rows; row++ {
+ if fastScalars {
+ start, startOK, startErr := scalarInt64(startCol, row, "timestamp")
+ if startErr != nil {
+ return false, startErr
+ }
+ dur, durOK, durErr := scalarInt64(durCol, row, "int64")
+ if durErr != nil {
+ return false, durErr
+ }
+ if !startOK {
+ continue
+ }
+ if !haveStart || start < minStart {
+ minStart, haveStart = start, true
+ }
+ if !durOK {
+ continue
+ }
+ if end := start + dur*s.durationTagNanosPerUnit; !haveEnd || end > maxEnd {
+ maxEnd, haveEnd = end, true
+ }
+ continue
+ }
sv, sErr := startCol.At(row)
if sErr != nil {
return false, sErr
@@ -1037,6 +1102,36 @@ func (s *Sampler) hasErrorColumn(b *sdk.TraceBlock) (bool, error) {
if col == nil {
return false, errNoErrorColumn
}
+ // Both fast paths are gated on the column's exact type, for the same reason
+ // as hasSlowTrace: only a type this rule actually reads may skip At.
+ switch col.ValueType {
+ case valuetype.ValueTypeInt64:
+ for row := range col.Values {
+ v, ok, err := scalarInt64(col, row, "int64")
+ if err != nil {
+ return false, err
+ }
+ if ok && v != 0 {
+ return true, nil
+ }
+ }
+ return false, nil
+ case valuetype.ValueTypeStr:
+ // DecodeTagValue would allocate a string per row via string(raw) purely
+ // to compare it against two literals. Aliasing the bytes compares the
+ // same characters with no allocation; the strings never outlive the loop.
+ for row := range col.Values {
+ raw := col.Values[row]
+ if raw == nil {
+ continue
+ }
+ if str := convert.BytesToString(raw); str == "true" || str == "1" {
+ return true, nil
+ }
+ }
+ return false, nil
+ default:
+ }
for row := range col.Values {
v, err := col.At(row)
if err != nil {
@@ -1072,18 +1167,32 @@ func (s *Sampler) hasErrorColumn(b *sdk.TraceBlock) (bool, error) {
// two cannot drift apart.
func matchEntries(entries []string, r *rule) bool {
prefix := r.prefix
+ if prefix == "" {
+ // A rule naming the array column itself matches the whole entry, so there
+ // is no prefix to strip and no bare-key case distinct from it.
+ for _, entry := range entries {
+ if r.Exists || matchValue(r, entry) {
+ return true
+ }
+ }
+ return false
+ }
+ // Most entries belong to some other tag, so the common outcome is rejection.
+ // Reject on length and first byte before HasPrefix's memequal call. The bare-key
+ // test must stay ahead of that guard: prefix is TagKey+"=", so a bare key is one
+ // byte too short and the length check would skip it.
+ first, plen := prefix[0], len(prefix)
for _, entry := range entries {
if r.Exists && entry == r.TagKey {
return true
}
- candidate := entry
- if prefix != "" {
- if !strings.HasPrefix(entry, prefix) {
- continue
- }
- candidate = entry[len(prefix):]
+ if len(entry) < plen || entry[0] != first {
+ continue
+ }
+ if !strings.HasPrefix(entry, prefix) {
+ continue
}
- if r.Exists || matchValue(r, candidate) {
+ if r.Exists || matchValue(r, entry[plen:]) {
return true
}
}
diff --git a/plugins/skywalking/internal/tracesampler/sampler_test.go b/plugins/skywalking/internal/tracesampler/sampler_test.go
index e89766061..18ebd5671 100644
--- a/plugins/skywalking/internal/tracesampler/sampler_test.go
+++ b/plugins/skywalking/internal/tracesampler/sampler_test.go
@@ -20,11 +20,13 @@
package tracesampler
import (
+ "fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/apache/skywalking-banyandb/pkg/convert"
"github.com/apache/skywalking-banyandb/pkg/encoding/vararray"
"github.com/apache/skywalking-banyandb/pkg/pb/v1/valuetype"
"github.com/apache/skywalking-banyandb/pkg/pipeline/sdk"
@@ -914,3 +916,263 @@ func TestDecide_MissingTagArrayColumn(t *testing.T) {
assert.Equal(t, []bool{false, false}, verdict.Keep,
"missing/empty tag arrays must not crash and must drop (no rule matches)")
}
+
+// TestScalarInt64_MatchesDecodeTagValue is the oracle for the fast scalar reader.
+// scalarInt64 exists to avoid materializing sdk.Value, so it must agree with the
+// SDK decode it replaces on every cell shape — value, null-ness, AND error text,
+// since a malformed cell fails open and the message is what gets logged.
+func TestScalarInt64_MatchesDecodeTagValue(t *testing.T) {
+ cells := [][]byte{
+ nil,
+ {},
+ {0x01},
+ make([]byte, 7),
+ convert.Int64ToBytes(0),
+ convert.Int64ToBytes(1),
+ convert.Int64ToBytes(-1),
+ convert.Int64ToBytes(1 << 62),
+ make([]byte, 9),
+ }
+ for _, vt := range []struct {
+ typeName string
+ valueTyp valuetype.ValueType
+ }{
+ {"int64", valuetype.ValueTypeInt64},
+ {"timestamp", valuetype.ValueTypeTimestamp},
+ } {
+ for i, cell := range cells {
+ t.Run(fmt.Sprintf("%s/cell=%d", vt.typeName, i), func(t *testing.T) {
+ col := &sdk.TagColumn{Name: "c", ValueType: vt.valueTyp, Values: [][]byte{cell}}
+
+ wantVal, wantErr := col.At(0)
+ gotVal, gotOK, gotErr := scalarInt64(col, 0, vt.typeName)
+
+ if wantErr != nil {
+ require.Error(t, gotErr, "SDK rejected this cell, so the fast reader must too")
+ assert.Equal(t, wantErr.Error(), gotErr.Error(), "error text must match: it is logged verbatim")
+ return
+ }
+ require.NoError(t, gotErr)
+ assert.Equal(t, !wantVal.IsNull(), gotOK, "null-ness must match")
+ if !wantVal.IsNull() {
+ assert.Equal(t, wantVal.Int64(), gotVal)
+ }
+ })
+ }
+ }
+}
+
+// TestHasSlowTrace_TypeMismatchKeepsSlowPath pins the narrow gating. The per-row
+// type checks look hoistable — ValueType is a column property — but a wrong-typed
+// column can still hold a cell that DecodeTagValue rejects on length, and that
+// error must keep propagating rather than being skipped as "wrong type".
+func TestHasSlowTrace_TypeMismatchKeepsSlowPath(t *testing.T) {
+ s, err := New([]byte(`{"durationThresholdMs":500}`), segmentSchema)
+ require.NoError(t, err)
+ sampler := s.(*Sampler)
+
+ block := func(startType, durType valuetype.ValueType, startCell, durCell []byte) *sdk.TraceBlock {
+ return &sdk.TraceBlock{
+ TraceID: "t",
+ Tags: []sdk.TagColumn{
+ {Name: "start_time", ValueType: startType, Values: [][]byte{startCell}},
+ {Name: "latency", ValueType: durType, Values: [][]byte{durCell}},
+ },
+ }
+ }
+ good := convert.Int64ToBytes(1)
+
+ t.Run("malformed cell in a float-typed start column still errors", func(t *testing.T) {
+ _, slowErr := sampler.hasSlowTrace(block(valuetype.ValueTypeFloat64, valuetype.ValueTypeInt64, []byte{0x01}, good))
+ require.Error(t, slowErr, "a length-checked wrong type must not be silently skipped")
+ assert.Contains(t, slowErr.Error(), "float64: expected 8 bytes")
+ })
+
+ t.Run("well-formed float start column is a can't-tell, not slow", func(t *testing.T) {
+ _, slowErr := sampler.hasSlowTrace(block(valuetype.ValueTypeFloat64, valuetype.ValueTypeInt64, make([]byte, 8), good))
+ assert.ErrorIs(t, slowErr, errNoDurationEnvelope, "a non-timestamp start carries no unit, so it cannot bound the envelope")
+ })
+
+ t.Run("str-typed duration column bounds only the left edge", func(t *testing.T) {
+ _, slowErr := sampler.hasSlowTrace(block(valuetype.ValueTypeTimestamp, valuetype.ValueTypeStr, good, []byte("nope")))
+ assert.ErrorIs(t, slowErr, errNoDurationEnvelope, "a start with no usable duration leaves the right edge unknown")
+ })
+}
+
+// TestHasErrorColumn_TypeCoverage covers all three branches the error-column
+// reader now has: the two fast paths for the types that actually carry an error
+// signal, and the At-based fallback for every other type.
+//
+// The fallback matters precisely because it is the one that can still surface a
+// decode error: Float64 and Timestamp are 8-byte-checked by DecodeTagValue, so a
+// malformed cell in such a column must keep failing open rather than being
+// skipped as "no error signal this schema understands".
+func TestHasErrorColumn_TypeCoverage(t *testing.T) {
+ s, err := New([]byte(`{"keepErrors":true,"healthySampleRate":0}`), segmentSchema)
+ require.NoError(t, err)
+ sampler := s.(*Sampler)
+
+ block := func(vt valuetype.ValueType, cells ...[]byte) *sdk.TraceBlock {
+ return &sdk.TraceBlock{
+ TraceID: "t",
+ Tags: []sdk.TagColumn{{Name: "is_error", ValueType: vt, Values: cells}},
+ }
+ }
+
+ t.Run("int64 fast path", func(t *testing.T) {
+ hit, hitErr := sampler.hasErrorColumn(block(valuetype.ValueTypeInt64, convert.Int64ToBytes(0), convert.Int64ToBytes(1)))
+ require.NoError(t, hitErr)
+ assert.True(t, hit, "a non-zero int64 is an error")
+
+ none, noneErr := sampler.hasErrorColumn(block(valuetype.ValueTypeInt64, convert.Int64ToBytes(0), nil))
+ require.NoError(t, noneErr)
+ assert.False(t, none, "zero and null carry no error signal")
+ })
+
+ t.Run("str fast path", func(t *testing.T) {
+ for _, truthy := range []string{"true", "1"} {
+ hit, hitErr := sampler.hasErrorColumn(block(valuetype.ValueTypeStr, []byte(truthy)))
+ require.NoError(t, hitErr)
+ assert.True(t, hit, "%q is an error", truthy)
+ }
+ for _, falsy := range []string{"false", "0", "", "TRUE"} {
+ none, noneErr := sampler.hasErrorColumn(block(valuetype.ValueTypeStr, []byte(falsy)))
+ require.NoError(t, noneErr)
+ assert.False(t, none, "%q is not an error — the comparison is exact", falsy)
+ }
+ none, noneErr := sampler.hasErrorColumn(block(valuetype.ValueTypeStr, nil))
+ require.NoError(t, noneErr)
+ assert.False(t, none, "a null cell carries no error signal")
+ })
+
+ t.Run("fallback propagates a decode error", func(t *testing.T) {
+ _, errColErr := sampler.hasErrorColumn(block(valuetype.ValueTypeFloat64, []byte{0x01}))
+ require.Error(t, errColErr, "a malformed cell in a length-checked column must not be skipped as an unknown type")
+ assert.Contains(t, errColErr.Error(), "float64: expected 8 bytes")
+ })
+
+ t.Run("fallback reports no signal for a well-formed unknown type", func(t *testing.T) {
+ hit, hitErr := sampler.hasErrorColumn(block(valuetype.ValueTypeFloat64, make([]byte, 8)))
+ require.NoError(t, hitErr)
+ assert.False(t, hit, "no other type carries an error signal this schema understands")
+ })
+
+ t.Run("decode error in the fallback fails open through Decide", func(t *testing.T) {
+ verdict, decideErr := sampler.Decide(&sdk.TraceBatch{Traces: []sdk.TraceBlock{*block(valuetype.ValueTypeFloat64, []byte{0x01})}})
+ require.NoError(t, decideErr)
+ assert.Equal(t, []bool{true}, verdict.Keep, "a decode error must fail open (keep), never drop")
+ })
+}
+
+// TestHasSlowTrace_NullCells pins how null cells shape the duration envelope on
+// the fast scalar path. Both skips are load-bearing in a way that decides whether
+// a trace is KEPT or DROPPED:
+//
+// - a null start must not contribute to minStart, or the envelope is measured
+// from zero and every trace looks slow enough to keep;
+// - a null duration must leave the right edge unknown, which is a can't-tell
+// that fails open — treating it as a zero-length end instead reports "not
+// slow" and drops the trace.
+func TestHasSlowTrace_NullCells(t *testing.T) {
+ s, err := New([]byte(`{"durationThresholdMs":500}`), segmentSchema)
+ require.NoError(t, err)
+ sampler := s.(*Sampler)
+
+ // segmentSchema scales latency by 1e6 (milliseconds -> nanoseconds).
+ const sixHundredMillisInNanos = 600 * 1_000_000
+ rows := func(starts, durations [][]byte) *sdk.TraceBlock {
+ return &sdk.TraceBlock{
+ TraceID: "t",
+ Tags: []sdk.TagColumn{
+ {Name: "start_time", ValueType: valuetype.ValueTypeTimestamp, Values: starts},
+ {Name: "latency", ValueType: valuetype.ValueTypeInt64, Values: durations},
+ },
+ }
+ }
+
+ t.Run("null start does not drag minStart to zero", func(t *testing.T) {
+ // One null row and one real row 600ms in. Counting the null row's start as
+ // 0 would measure a 600ms envelope and call the trace slow.
+ slow, slowErr := sampler.hasSlowTrace(rows(
+ [][]byte{nil, convert.Int64ToBytes(sixHundredMillisInNanos)},
+ [][]byte{nil, convert.Int64ToBytes(1)},
+ ))
+ require.NoError(t, slowErr)
+ assert.False(t, slow, "the envelope spans only the rows that carry a start, so it is 1ms, not 600ms")
+ })
+
+ t.Run("null duration leaves the right edge unknown", func(t *testing.T) {
+ _, slowErr := sampler.hasSlowTrace(rows(
+ [][]byte{convert.Int64ToBytes(sixHundredMillisInNanos)},
+ [][]byte{nil},
+ ))
+ assert.ErrorIs(t, slowErr, errNoDurationEnvelope,
+ "a start with no duration cannot bound the end; that is a can't-tell, which fails open")
+ })
+
+ t.Run("a start without duration still bounds the left edge", func(t *testing.T) {
+ // Row 0 carries only a start, 600ms before row 1's start+duration. The
+ // envelope must span from row 0's start, making the trace slow.
+ slow, slowErr := sampler.hasSlowTrace(rows(
+ [][]byte{convert.Int64ToBytes(0), convert.Int64ToBytes(sixHundredMillisInNanos)},
+ [][]byte{nil, convert.Int64ToBytes(1)},
+ ))
+ require.NoError(t, slowErr)
+ assert.True(t, slow, "min start comes from the duration-less row, so the envelope is ~601ms")
+ })
+}
+
+// TestHasSlowTrace_RaggedColumns pins the bounds discipline that the fast scalar
+// reader depends on. scalarInt64 does not bounds-check — that is the point, since
+// TagColumn.At's check is part of what it avoids — so hasSlowTrace must never
+// index past the shorter of the two columns.
+//
+// A ragged block is malformed input, not a programming error, so the outcome has
+// to be a verdict rather than a panic: it must fail open like any other
+// can't-tell. Losing the clamp would turn that into a panic inside a merge.
+func TestHasSlowTrace_RaggedColumns(t *testing.T) {
+ s, err := New([]byte(`{"durationThresholdMs":500}`), segmentSchema)
+ require.NoError(t, err)
+ sampler := s.(*Sampler)
+
+ ragged := func(starts, durations [][]byte) *sdk.TraceBlock {
+ return &sdk.TraceBlock{
+ TraceID: "ragged",
+ Tags: []sdk.TagColumn{
+ {Name: "start_time", ValueType: valuetype.ValueTypeTimestamp, Values: starts},
+ {Name: "latency", ValueType: valuetype.ValueTypeInt64, Values: durations},
+ },
+ }
+ }
+ three := [][]byte{convert.Int64ToBytes(1), convert.Int64ToBytes(2), convert.Int64ToBytes(3)}
+ one := [][]byte{convert.Int64ToBytes(1)}
+
+ for _, tc := range []struct {
+ name string
+ starts [][]byte
+ durations [][]byte
+ }{
+ {"more starts than durations", three, one},
+ {"more durations than starts", one, three},
+ {"no durations at all", three, nil},
+ {"no starts at all", nil, three},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.NotPanics(t, func() {
+ _, _ = sampler.hasSlowTrace(ragged(tc.starts, tc.durations))
+ }, "a ragged block must produce a verdict, not an index panic")
+ })
+ }
+
+ // Through the public entry point: clamping to zero rows leaves the envelope
+ // unmeasurable, which is a can't-tell and must fail open.
+ noOverlap, decideErr := sampler.Decide(&sdk.TraceBatch{Traces: []sdk.TraceBlock{*ragged(three, nil)}})
+ require.NoError(t, decideErr)
+ assert.Equal(t, []bool{true}, noOverlap.Keep, "no row carries both edges, so the envelope is unmeasurable and fails open")
+
+ // Where the clamp still leaves a complete row, the verdict is real rather than
+ // fail-open: that row's envelope is 1ms, far below the 500ms threshold.
+ measurable, measurableErr := sampler.Decide(&sdk.TraceBatch{Traces: []sdk.TraceBlock{*ragged(three, one)}})
+ require.NoError(t, measurableErr)
+ assert.Equal(t, []bool{false}, measurable.Keep, "a clamped-but-complete row still yields a genuine not-slow verdict")
+}