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")
+}