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 6fb3a1ba8 feat(trace): finalization sampling (PIPELINE_EVENT_FINALIZE)
(#1210)
6fb3a1ba8 is described below
commit 6fb3a1ba8663aad71856d31ad90ab4b1704def98
Author: Gao Hongtao <[email protected]>
AuthorDate: Wed Jul 8 16:51:10 2026 +0800
feat(trace): finalization sampling (PIPELINE_EVENT_FINALIZE) (#1210)
---
CHANGES.md | 1 +
banyand/internal/storage/segment.go | 39 +++
banyand/internal/storage/storage.go | 17 ++
banyand/internal/storage/tsdb.go | 7 +
banyand/trace/finalize_accounting_test.go | 75 ++++++
banyand/trace/finalize_scanner.go | 224 +++++++++++++++++
banyand/trace/finalize_scanner_test.go | 259 ++++++++++++++++++++
banyand/trace/finalize_state.go | 96 ++++++++
banyand/trace/finalize_state_test.go | 104 ++++++++
banyand/trace/finalizer.go | 182 ++++++++++++++
banyand/trace/finalizer_test.go | 394 ++++++++++++++++++++++++++++++
banyand/trace/flusher.go | 2 +-
banyand/trace/introducer.go | 26 ++
banyand/trace/merger.go | 127 ++++++++--
banyand/trace/merger_durability_test.go | 2 +-
banyand/trace/merger_test.go | 6 +-
banyand/trace/metadata.go | 43 +++-
banyand/trace/part_metadata.go | 6 +
banyand/trace/pipeline_finalize_test.go | 189 ++++++++++++++
banyand/trace/pipeline_registry.go | 144 +++++++++++
banyand/trace/pipeline_watch_test.go | 23 +-
banyand/trace/svc_standalone.go | 23 ++
banyand/trace/trace.go | 1 +
banyand/trace/tstable.go | 44 +++-
docs/design/post-trace-pipeline.md | 49 ++++
implementation-fn-note.md | 117 +++++++++
26 files changed, 2149 insertions(+), 51 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index 4957fd89f..fdc916874 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -5,6 +5,7 @@ Release Notes.
## 0.11.0
### Features
+- Add trace **finalization sampling** (`PIPELINE_EVENT_FINALIZE`), a
best-effort background backstop for the in-merge trace retention filter. A
single node-wide, concurrency-1 scanner periodically sweeps cooled segments
(`segEnd < now − finalize_grace`, reopening idle-closed segments via
`SelectSegments(reopenClosed=true)`) and force-merges each shard's un-finalized
parts through the group's registered sampler chain, reusing the hot merge path
(`mergeParts` + per-sidx `Merge` + the shard [...]
- Add two catalogs to the queue batch-write metrics so traffic is comparable
on both ends: a per-batch-stream **batch catalog**
(`total_batch_started`/`total_batch_finished`/`total_batch_latency`, buckets to
~300s) on `queue_pub`/`queue_sub` and the `lifecycle_migration` mirror, and a
per-message **message catalog**
(`total_message_started`/`total_message_finished`) on `queue_sub` (the
publisher's existing `total_*` already counts per message). All existing
`total_*` series are unchanged [...]
- Redesign the queue (`queue_pub`/`queue_sub`) metrics around a uniform model:
keep only `total_started`, `total_finished`, `total_latency` (now a histogram)
and `total_err`, plus file-sync-only `sent_bytes` (pub) / `received_bytes`
(sub). Replace the `topic` label with `operation`
(`batch-write`/`file-sync`/`query`/`control`) and `group`, add an `error_type`
label on `total_err`, and add remote-endpoint labels
(`remote_node`/`remote_role`/`remote_tier`) so the liaison↔data (hot/warm/col
[...]
- Stamp the lifecycle's tier-migration publisher's identity onto the wire so
the receiving data node records a non-empty
`remote_node`/`remote_role`/`remote_tier` on its
`banyandb_queue_sub_total_finished` series. The lifecycle's `parseGroup`
resolves the lifecycle's self identity by matching the lifecycle pod's hostname
(POD_NAME via the K8s downward API, falling back to `os.Hostname()` — same
precedence as `nativeNodeContext` at `banyand/backup/lifecycle/service.go`)
against the data-n [...]
diff --git a/banyand/internal/storage/segment.go
b/banyand/internal/storage/segment.go
index 727e41540..965a6d470 100644
--- a/banyand/internal/storage/segment.go
+++ b/banyand/internal/storage/segment.go
@@ -617,6 +617,12 @@ func (sc *segmentController[T, O])
selectSegments(timeRange timestamp.TimeRange,
if reopenClosed {
// Real read: reopen if closed and mark as
accessed.
if err = s.incRef(ctx); err != nil {
+ // Release the segments already pinned
in earlier iterations so a
+ // mid-loop incRef failure does not
leak refs (which would block
+ // idle-close and retention-delete for
them indefinitely).
+ for _, pinned := range tt {
+ pinned.DecRef()
+ }
return nil, err
}
s.lastAccessed.Store(now)
@@ -638,6 +644,39 @@ func (sc *segmentController[T, O])
selectSegments(timeRange timestamp.TimeRange,
return tt, nil
}
+// peekSegments returns lightweight descriptors of the segments overlapping
timeRange
+// WITHOUT opening (incRef-ing) any of them. It snapshots the matching
segments' time
+// ranges and locations under the read lock, then lists each segment's on-disk
shard
+// directories outside the lock (a cheap readdir, no index reopen or loop
spawn).
+func (sc *segmentController[T, O]) peekSegments(timeRange timestamp.TimeRange)
[]SegmentPeek {
+ type loc struct {
+ start time.Time
+ end time.Time
+ location string
+ }
+ sc.RLock()
+ var locs []loc
+ for _, s := range sc.lst {
+ if s.Overlapping(timeRange) {
+ locs = append(locs, loc{start: s.Start, end: s.End,
location: s.location})
+ }
+ }
+ sc.RUnlock()
+
+ out := make([]SegmentPeek, 0, len(locs))
+ for _, l := range locs {
+ peek := SegmentPeek{Start: l.start, End: l.end}
+ _ = walkDir(l.location, shardPathPrefix, func(suffix string)
error {
+ if id, convErr := strconv.Atoi(suffix); convErr == nil {
+ peek.ShardPaths = append(peek.ShardPaths,
filepath.Join(l.location, fmt.Sprintf(shardTemplate, id)))
+ }
+ return nil
+ })
+ out = append(out, peek)
+ }
+ return out
+}
+
func (sc *segmentController[T, O]) createSegment(ts time.Time) (*segment[T,
O], error) {
s, err := sc.create(context.Background(), ts)
if err != nil {
diff --git a/banyand/internal/storage/storage.go
b/banyand/internal/storage/storage.go
index cb5c8d495..74e72f5c0 100644
--- a/banyand/internal/storage/storage.go
+++ b/banyand/internal/storage/storage.go
@@ -116,6 +116,13 @@ type TSDB[T TSTable, O any] interface {
// stats) returns them without reopening or refreshing their idle
timer. The
// caller must DecRef every returned segment (a no-op for a closed one).
SelectSegments(timeRange timestamp.TimeRange, reopenClosed bool)
([]Segment[T, O], error)
+ // PeekSegments returns lightweight descriptors of the segments
overlapping
+ // timeRange WITHOUT opening (incRef-ing) any of them. It lets
background
+ // housekeeping inspect per-shard on-disk state cheaply and decide
whether a
+ // segment is worth reopening for real work, avoiding a reopen storm
over cold
+ // segments. The returned ShardPaths are on-disk directories; the
caller reads
+ // them read-only.
+ PeekSegments(timeRange timestamp.TimeRange) []SegmentPeek
// SegmentInterval returns the current segment interval rule.
SegmentInterval() IntervalRule
Tick(ts int64)
@@ -133,6 +140,16 @@ type TSDB[T TSTable, O any] interface {
Drop() error
}
+// SegmentPeek describes a segment discoverable WITHOUT opening (incRef-ing)
it — the
+// segment's time range plus the on-disk paths of its existing shard
directories.
+// Background housekeeping (e.g. the trace finalize scanner) uses it to inspect
+// per-shard on-disk state before deciding whether to reopen the segment.
+type SegmentPeek struct {
+ Start time.Time
+ End time.Time
+ ShardPaths []string
+}
+
// Segment is a time range of data.
type Segment[T TSTable, O any] interface {
DecRef()
diff --git a/banyand/internal/storage/tsdb.go b/banyand/internal/storage/tsdb.go
index 4c4dc83ac..282ee5e99 100644
--- a/banyand/internal/storage/tsdb.go
+++ b/banyand/internal/storage/tsdb.go
@@ -299,6 +299,13 @@ func (d *database[T, O]) SelectSegments(timeRange
timestamp.TimeRange, reopenClo
return kept, nil
}
+func (d *database[T, O]) PeekSegments(timeRange timestamp.TimeRange)
[]SegmentPeek {
+ if d.closed.Load() {
+ return nil
+ }
+ return d.segmentController.peekSegments(timeRange)
+}
+
func (d *database[T, O]) SegmentInterval() IntervalRule {
return d.segmentController.getSegmentInterval()
}
diff --git a/banyand/trace/finalize_accounting_test.go
b/banyand/trace/finalize_accounting_test.go
new file mode 100644
index 000000000..0b08ef449
--- /dev/null
+++ b/banyand/trace/finalize_accounting_test.go
@@ -0,0 +1,75 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package trace
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/apache/skywalking-banyandb/api/common"
+ "github.com/apache/skywalking-banyandb/banyand/protector"
+ "github.com/apache/skywalking-banyandb/pkg/fs"
+ "github.com/apache/skywalking-banyandb/pkg/logger"
+)
+
+// TestAccountUnsampledFlushed verifies the O(1) hot-path accounting: a no-op
until the
+// shard has been finalized (gen>0), then accumulates uncompressed span bytes.
The
+// method takes no filesystem and reads only in-memory part metadata +
atomics, so by
+// construction it performs zero metadata I/O on the flush path (Phase 3
acceptance).
+func TestAccountUnsampledFlushed(t *testing.T) {
+ tst := &tsTable{}
+ flushed := map[uint64]*partWrapper{
+ 1: {p: &part{partMetadata:
partMetadata{UncompressedSpanSizeBytes: 100}}},
+ 2: {p: &part{partMetadata:
partMetadata{UncompressedSpanSizeBytes: 50}}},
+ }
+
+ // gen == 0 (never finalized): no accounting.
+ tst.accountUnsampledFlushed(flushed)
+ assert.Zero(t, tst.unsampledBytes.Load(), "gen 0 must not accumulate")
+
+ // gen > 0: accumulate uncompressed span bytes.
+ tst.finalizeGenCached.Store(1)
+ tst.accountUnsampledFlushed(flushed)
+ assert.Equal(t, int64(150), tst.unsampledBytes.Load())
+
+ // Subsequent flushes keep accumulating.
+ tst.accountUnsampledFlushed(map[uint64]*partWrapper{
+ 3: {p: &part{partMetadata:
partMetadata{UncompressedSpanSizeBytes: 25}}},
+ })
+ assert.Equal(t, int64(175), tst.unsampledBytes.Load())
+
+ // An empty flush set is a harmless no-op.
+ tst.accountUnsampledFlushed(nil)
+ assert.Equal(t, int64(175), tst.unsampledBytes.Load())
+}
+
+// TestInitTSTable_SeedsFinalizeCache verifies the cached finalize fields are
seeded
+// from the shard's persisted finalizeState at open (restart recovery).
+func TestInitTSTable_SeedsFinalizeCache(t *testing.T) {
+ fileSystem := fs.NewLocalFileSystem()
+ dir := t.TempDir()
+ require.NoError(t, writeFinalizeState(fileSystem, dir,
finalizeState{FinalizeGeneration: 4, UnsampledBytes: 999}))
+
+ tst, _ := initTSTable(fileSystem, dir, common.Position{},
logger.GetLogger("finalize-seed-test"),
+ option{protector: protector.Nop{}}, nil)
+
+ assert.Equal(t, uint64(4), tst.finalizeGenCached.Load(),
"finalizeGenCached must be seeded from disk")
+ assert.Equal(t, int64(999), tst.unsampledBytes.Load(), "unsampledBytes
must be seeded from disk")
+}
diff --git a/banyand/trace/finalize_scanner.go
b/banyand/trace/finalize_scanner.go
new file mode 100644
index 000000000..13df8cbd8
--- /dev/null
+++ b/banyand/trace/finalize_scanner.go
@@ -0,0 +1,224 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package trace
+
+import (
+ "time"
+
+ "github.com/apache/skywalking-banyandb/banyand/internal/storage"
+ "github.com/apache/skywalking-banyandb/pkg/fs"
+ "github.com/apache/skywalking-banyandb/pkg/pipeline/sdk"
+ "github.com/apache/skywalking-banyandb/pkg/run"
+ "github.com/apache/skywalking-banyandb/pkg/timestamp"
+)
+
+// defaultFinalizeScanInterval is the cadence of the background finalize
scanner. It
+// mirrors the storage idle-check cadence: a backstop does not need tight
latency.
+const defaultFinalizeScanInterval = 10 * time.Minute
+
+// finalizeScanLoop is the trace-owned periodic finalization-sampling scanner.
It runs
+// as a single node-wide goroutine, so finalize compute is serialized
(concurrency-1)
+// and can never fan out to starve the hot merge lanes. On each tick it sweeps
every
+// finalize-enabled group's cooled segments and runs bounded finalize rounds.
+func (sr *schemaRepo) finalizeScanLoop(closer *run.Closer, interval
time.Duration) {
+ defer closer.Done()
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-closer.CloseNotify():
+ return
+ case <-ticker.C:
+ sr.runFinalizeScan(closer.CloseNotify())
+ }
+ }
+}
+
+// runFinalizeScan performs one scan pass over all finalize-enabled groups. It
is safe
+// to call directly (tests do) without the surrounding loop.
+func (sr *schemaRepo) runFinalizeScan(closeCh <-chan struct{}) {
+ // A local filesystem to read per-shard finalize.json during the
pre-filter, without
+ // reopening the segment. finalize.json is always on local disk.
+ lfs := fs.NewLocalFileSystem()
+ for _, group := range listFinalizeGroups() {
+ select {
+ case <-closeCh:
+ return
+ default:
+ }
+ samplers := lookupSamplers(group)
+ if len(samplers) == 0 {
+ continue
+ }
+ graceNs := lookupFinalizeGrace(group)
+ if graceNs <= 0 {
+ graceNs = int64(sr.finalizeGraceDefault)
+ }
+ if graceNs <= 0 {
+ continue
+ }
+ sr.scanGroup(group, samplers, graceNs,
lookupFinalizeConfig(group, graceNs), lfs, closeCh)
+ }
+}
+
+// scanGroup peeks the group's cooled segments WITHOUT reopening them,
pre-filters on
+// per-shard on-disk state so terminal / in-cooldown / max-rounds segments are
never
+// reopened (this is what keeps a backstop from perturbing the hot node with a
reopen
+// storm over cold segments), and only reopens the segments that may warrant a
round —
+// then applies the precise threshold and finalizes warranting shards
sequentially.
+func (sr *schemaRepo) scanGroup(group string, samplers []sdk.Sampler, graceNs
int64, cfg finalizeConfig, lfs fs.FileSystem, closeCh <-chan struct{}) {
+ tsdb, err := sr.loadTSDB(group)
+ if err != nil || tsdb == nil {
+ return
+ }
+ now := time.Now().UnixNano()
+ coolEnd := now - graceNs
+ coolRange := timestamp.TimeRange{Start: time.Unix(0, 0), End:
time.Unix(0, coolEnd)}
+ for _, peek := range tsdb.PeekSegments(coolRange) {
+ select {
+ case <-closeCh:
+ return
+ default:
+ }
+ // Only fully-cooled segments; and skip (without reopening) any
whose shards are
+ // all terminal / in-cooldown / max-rounds per their on-disk
finalize state.
+ if peek.End.UnixNano() > coolEnd || !segmentMayWarrant(lfs,
peek.ShardPaths, cfg) {
+ continue
+ }
+ // This segment may warrant work: reopen just its range and
finalize warranting
+ // shards. The precise byte threshold is applied here
(warrantsFinalize) against
+ // the reopened snapshot.
+ segs, selErr := tsdb.SelectSegments(timestamp.TimeRange{Start:
peek.Start, End: peek.End}, true)
+ if selErr != nil {
+ sr.l.Warn().Err(selErr).Str("group",
group).Msg("finalize scan: reopen segment failed")
+ continue
+ }
+ sr.finalizeReopened(group, segs, samplers, graceNs, cfg,
coolEnd)
+ }
+}
+
+// finalizeReopened runs bounded finalize rounds on the warranting shards of
the reopened
+// segments, then DecRefs every segment on all paths.
+func (sr *schemaRepo) finalizeReopened(group string, segs
[]storage.Segment[*tsTable, option],
+ samplers []sdk.Sampler, graceNs int64, cfg finalizeConfig, coolEnd
int64,
+) {
+ defer func() {
+ for _, seg := range segs {
+ seg.DecRef()
+ }
+ }()
+ for _, seg := range segs {
+ // SelectSegments may return a boundary neighbor; guard on the
cool window again.
+ if seg.GetTimeRange().End.UnixNano() > coolEnd {
+ continue
+ }
+ tables, _ := seg.Tables()
+ for _, tst := range tables {
+ if !warrantsFinalize(tst, cfg) {
+ continue
+ }
+ if _, ferr := tst.runFinalizeRound(samplers, graceNs);
ferr != nil {
+ sr.l.Warn().Err(ferr).Str("group",
group).Msg("finalize round failed (fail-open)")
+ }
+ }
+ }
+}
+
+// segmentMayWarrant reports whether any of a cooled segment's shards is worth
reopening,
+// judged purely from on-disk finalize state (no segment reopen). It is a
conservative
+// superset of warrantsFinalize: it never skips a shard that would actually
warrant.
+func segmentMayWarrant(lfs fs.FileSystem, shardPaths []string, cfg
finalizeConfig) bool {
+ for _, shardPath := range shardPaths {
+ if shardMayWarrant(readFinalizeState(lfs, shardPath), cfg) {
+ return true
+ }
+ }
+ return false
+}
+
+// shardMayWarrant is the reopen pre-filter for one shard. It skips only the
cases that
+// are authoritative from on-disk state regardless of whether the shard is
open: terminal,
+// the hard round cap, and an un-elapsed cooldown. A never-finalized shard
always warrants;
+// a finalized one past its cooldown is reopened so the precise byte threshold
can run
+// against the live snapshot (the on-disk unsampled counter can lag an open
shard).
+func shardMayWarrant(st finalizeState, cfg finalizeConfig) bool {
+ if st.Terminal || st.FinalizeRounds >= cfg.maxRounds {
+ return false
+ }
+ if st.FinalizeGeneration == 0 || st.LastFinalizedAt == "" {
+ return true
+ }
+ last, perr := time.Parse(time.RFC3339Nano, st.LastFinalizedAt)
+ if perr != nil {
+ return true
+ }
+ return time.Since(last) >= time.Duration(cfg.cooldownNs)
+}
+
+// warrantsFinalize decides whether a shard warrants another finalize round.
It reads
+// the per-shard persisted state (scanner path — off the hot path, so metadata
I/O is
+// fine) and applies the threshold: never-finalized always warrants; otherwise
the
+// newly-arrived unsampled bytes must cross max(FLOOR, RATIO*total); a
cooldown caps the
+// frequency; and max_finalize_rounds is a hard terminal cap.
+func warrantsFinalize(tst *tsTable, cfg finalizeConfig) bool {
+ st := readFinalizeState(tst.fileSystem, tst.root)
+ if st.Terminal {
+ return false
+ }
+ if st.FinalizeRounds >= cfg.maxRounds {
+ // Hard lifetime cap reached: mark terminal so the shard is
never scanned again.
+ st.Terminal = true
+ if err := writeFinalizeState(tst.fileSystem, tst.root, st); err
!= nil {
+ tst.l.Warn().Err(err).Str("group",
tst.group).Msg("cannot mark finalize terminal")
+ }
+ return false
+ }
+ if st.LastFinalizedAt != "" {
+ if last, perr := time.Parse(time.RFC3339Nano,
st.LastFinalizedAt); perr == nil {
+ if time.Since(last) < time.Duration(cfg.cooldownNs) {
+ return false
+ }
+ }
+ }
+ if st.FinalizeGeneration == 0 {
+ return true
+ }
+ total := shardTotalUncompressed(tst)
+ threshold := int64(cfg.floorBytes)
+ if r := int64(cfg.ratio * float64(total)); r > threshold {
+ threshold = r
+ }
+ return tst.unsampledBytes.Load() >= threshold
+}
+
+// shardTotalUncompressed sums the uncompressed span bytes of the shard's
current file
+// parts, the denominator for the RATIO threshold.
+func shardTotalUncompressed(tst *tsTable) int64 {
+ s := tst.currentSnapshot()
+ if s == nil {
+ return 0
+ }
+ defer s.decRef()
+ var total int64
+ for _, pw := range s.parts {
+ if pw.mp == nil {
+ total +=
int64(pw.p.partMetadata.UncompressedSpanSizeBytes)
+ }
+ }
+ return total
+}
diff --git a/banyand/trace/finalize_scanner_test.go
b/banyand/trace/finalize_scanner_test.go
new file mode 100644
index 000000000..0bafcc573
--- /dev/null
+++ b/banyand/trace/finalize_scanner_test.go
@@ -0,0 +1,259 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package trace
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/apache/skywalking-banyandb/api/common"
+ "github.com/apache/skywalking-banyandb/banyand/internal/storage"
+ "github.com/apache/skywalking-banyandb/banyand/protector"
+ "github.com/apache/skywalking-banyandb/pkg/fs"
+ "github.com/apache/skywalking-banyandb/pkg/logger"
+ pbv1 "github.com/apache/skywalking-banyandb/pkg/pb/v1"
+ "github.com/apache/skywalking-banyandb/pkg/pipeline/sdk"
+ "github.com/apache/skywalking-banyandb/pkg/test"
+ "github.com/apache/skywalking-banyandb/pkg/timestamp"
+)
+
+// TestWarrantsFinalize exercises the threshold decision: never-finalized
always
+// warrants; a terminal or max-rounds shard never does (and max marks
terminal); an
+// un-elapsed cooldown blocks; and once finalized, the unsampled-bytes counter
must
+// cross the floor.
+func TestWarrantsFinalize(t *testing.T) {
+ fileSystem := fs.NewLocalFileSystem()
+ newTst := func(dir string) *tsTable {
+ return &tsTable{fileSystem: fileSystem, root: dir, l:
logger.GetLogger("warrants-test")}
+ }
+ // ratio 0 so the threshold is just the absolute floor (no snapshot
needed).
+ cfg := finalizeConfig{floorBytes: 100, ratio: 0, cooldownNs:
int64(time.Minute), maxRounds: 3}
+
+ // Never finalized (no state file) => always warrants.
+ assert.True(t, warrantsFinalize(newTst(t.TempDir()), cfg), "gen 0 must
always warrant")
+
+ // Terminal => never.
+ dTerm := t.TempDir()
+ require.NoError(t, writeFinalizeState(fileSystem, dTerm,
finalizeState{Terminal: true}))
+ assert.False(t, warrantsFinalize(newTst(dTerm), cfg))
+
+ // Rounds at the cap => false AND the shard is marked terminal.
+ dMax := t.TempDir()
+ require.NoError(t, writeFinalizeState(fileSystem, dMax,
finalizeState{FinalizeGeneration: 1, FinalizeRounds: 3}))
+ assert.False(t, warrantsFinalize(newTst(dMax), cfg))
+ assert.True(t, readFinalizeState(fileSystem, dMax).Terminal, "reaching
max rounds must mark terminal")
+
+ // Cooldown not elapsed => false.
+ dCool := t.TempDir()
+ require.NoError(t, writeFinalizeState(fileSystem, dCool, finalizeState{
+ FinalizeGeneration: 1, FinalizeRounds: 1,
+ LastFinalizedAt: time.Now().UTC().Format(time.RFC3339Nano),
+ }))
+ assert.False(t, warrantsFinalize(newTst(dCool), cfg))
+
+ // Finalized, cooldown elapsed: warrants iff unsampled bytes reach the
floor.
+ dThresh := t.TempDir()
+ require.NoError(t, writeFinalizeState(fileSystem, dThresh,
finalizeState{
+ FinalizeGeneration: 1, FinalizeRounds: 1,
+ LastFinalizedAt:
time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano),
+ }))
+ tst := newTst(dThresh)
+ tst.unsampledBytes.Store(200)
+ assert.True(t, warrantsFinalize(tst, cfg), "unsampled above floor must
warrant")
+ tst.unsampledBytes.Store(50)
+ assert.False(t, warrantsFinalize(tst, cfg), "unsampled below floor must
not warrant")
+}
+
+// TestWarrantsFinalize_RatioBranch exercises the RATIO*total threshold (the
branch
+// TestWarrantsFinalize's ratio=0 never reaches): for a large finalized shard,
a re-round
+// is warranted only when new unsampled bytes reach RATIO of the segment total.
+func TestWarrantsFinalize_RatioBranch(t *testing.T) {
+ fileSystem := fs.NewLocalFileSystem()
+ dir := t.TempDir()
+ // Finalized (gen>0) with an elapsed cooldown, so warrants gates purely
on the bytes.
+ require.NoError(t, writeFinalizeState(fileSystem, dir, finalizeState{
+ FinalizeGeneration: 1, FinalizeRounds: 1,
+ LastFinalizedAt:
time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano),
+ }))
+ tst := &tsTable{fileSystem: fileSystem, root: dir, l:
logger.GetLogger("warrants-ratio-test")}
+ // Snapshot totals 1000 uncompressed bytes so RATIO dominates the
absolute floor.
+ tst.snapshot = &snapshot{ref: 1, parts: []*partWrapper{
+ {p: &part{partMetadata: partMetadata{UncompressedSpanSizeBytes:
1000, TotalCount: 1}}},
+ }}
+ cfg := finalizeConfig{floorBytes: 100, ratio: 0.5, cooldownNs:
int64(time.Minute), maxRounds: 8}
+
+ tst.unsampledBytes.Store(600)
+ assert.True(t, warrantsFinalize(tst, cfg), "600 >= max(floor 100,
0.5*1000=500) must warrant")
+ tst.unsampledBytes.Store(400)
+ assert.False(t, warrantsFinalize(tst, cfg), "400 < 500 (ratio-dominated
threshold) must not warrant")
+}
+
+// TestShardMayWarrant verifies the reopen pre-filter: it skips (without
reopening) only
+// the authoritative-from-disk cases — terminal, max-rounds, and un-elapsed
cooldown —
+// and conservatively reopens everything else (never-finalized, past-cooldown).
+func TestShardMayWarrant(t *testing.T) {
+ cfg := finalizeConfig{floorBytes: 100, ratio: 0.1, cooldownNs:
int64(time.Minute), maxRounds: 8}
+
+ assert.True(t, shardMayWarrant(finalizeState{}, cfg), "never-finalized
must be reopened")
+ assert.False(t, shardMayWarrant(finalizeState{Terminal: true}, cfg),
"terminal must be skipped")
+ assert.False(t, shardMayWarrant(finalizeState{FinalizeGeneration: 1,
FinalizeRounds: 8}, cfg), "max rounds must be skipped")
+ assert.False(t, shardMayWarrant(finalizeState{
+ FinalizeGeneration: 1, FinalizeRounds: 1,
+ LastFinalizedAt: time.Now().UTC().Format(time.RFC3339Nano),
+ }, cfg), "un-elapsed cooldown must be skipped without reopening")
+ assert.True(t, shardMayWarrant(finalizeState{
+ FinalizeGeneration: 1, FinalizeRounds: 1,
+ LastFinalizedAt:
time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano),
+ }, cfg), "past-cooldown must be reopened for the precise threshold")
+ assert.True(t, shardMayWarrant(finalizeState{FinalizeGeneration: 1,
FinalizeRounds: 1}, cfg),
+ "finalized with no cooldown clock recorded is reopened
conservatively")
+}
+
+// TestRunFinalizeScan_Gating verifies the scan pass is a safe no-op when
there are no
+// finalize-enabled groups, and skips a finalize group that has no samplers
(never
+// reaching loadTSDB).
+func TestRunFinalizeScan_Gating(t *testing.T) {
+ resetRegistries()
+ defer resetRegistries()
+
+ sr := makeDataSchemaRepo("/some/dir")
+
+ // No finalize groups: no-op, no panic.
+ require.NotPanics(t, func() { sr.runFinalizeScan(nil) })
+
+ // A finalize-configured group with NO samplers is skipped before any
TSDB load.
+ setFinalizeConfigForGroup("g-no-samplers", &finalizeConfig{})
+ setFinalizeGraceForGroup("g-no-samplers", int64(5*time.Minute))
+ require.NotPanics(t, func() { sr.runFinalizeScan(nil) })
+}
+
+// TestFinalizeScan_SelectsCooledSegmentAndFinalizes closes the scanner→round
seam: it
+// builds a real TSDB, writes into a cooled (past) segment, then drives the
exact
+// storage calls scanGroup makes — SelectSegments(coolRange,
reopenClosed=true) ->
+// GetTimeRange cool re-check -> Tables() -> runFinalizeRound — and asserts
the dropped
+// trace is gone, the shard generation advanced, and segments are DecRef'd
back to
+// dormant (no open-segment leak).
+func TestFinalizeScan_SelectsCooledSegmentAndFinalizes(t *testing.T) {
+ tmpPath, defFn := test.Space(require.New(t))
+ defer defFn()
+
+ // Hourly segments + long TTL so a 2h-old segment is cooled but NOT
retention-expired
+ // (SelectSegments excludes expired segments).
+ hourly := storage.IntervalRule{Unit: storage.HOUR, Num: 1}
+ opts := storage.TSDBOpts[*tsTable, option]{
+ ShardNum: 1,
+ Location: filepath.Join(tmpPath, "tab"),
+ TSTableCreator: newTSTable,
+ SegmentInterval: hourly,
+ TTL: storage.IntervalRule{Unit: storage.DAY, Num:
30},
+ Option: option{flushTimeout: 0, protector:
protector.Nop{}, mergePolicy: newDefaultMergePolicyForTesting(), decideTimeout:
time.Second},
+ }
+ require.NoError(t, os.MkdirAll(opts.Location, storage.DirPerm))
+ ctx := common.SetPosition(
+ context.WithValue(context.Background(), logger.ContextKey,
logger.GetLogger("finalize-scan-test")),
+ func(p common.Position) common.Position { p.Database = "test";
return p },
+ )
+ db, err := storage.OpenTSDB[*tsTable, option](ctx, opts, nil,
"test-group")
+ require.NoError(t, err)
+ defer db.Close()
+
+ // Create a cooled segment ~2h in the past and write traces dated
within it.
+ past := time.Now().Add(-2 * time.Hour)
+ seg, err := db.CreateSegmentIfNotExist(past)
+ require.NoError(t, err)
+ // A brand-new segment has no shard tables until one is created (they
are lazy).
+ tst, err := seg.CreateTSTableIfNotExist(common.ShardID(0))
+ require.NoError(t, err)
+
+ pastTraces := &traces{
+ traceIDs: []string{"keep1", "drop2", "keep3"},
+ timestamps: []int64{past.UnixNano(), past.UnixNano(),
past.UnixNano()},
+ tags: [][]*tagValue{
+ {{tag: "t", valueType: pbv1.ValueTypeStr, value:
[]byte("a")}},
+ {{tag: "t", valueType: pbv1.ValueTypeStr, value:
[]byte("b")}},
+ {{tag: "t", valueType: pbv1.ValueTypeStr, value:
[]byte("c")}},
+ },
+ spans: [][]byte{[]byte("s1"), []byte("s2"), []byte("s3")},
+ spanIDs: []string{"s1", "s2", "s3"},
+ }
+ tst.mustAddTraces(pastTraces, nil)
+ require.Eventually(t, func() bool {
+ s := tst.currentSnapshot()
+ if s == nil {
+ return false
+ }
+ defer s.decRef()
+ for _, pw := range s.parts {
+ if pw.mp == nil && pw.p.partMetadata.TotalCount > 0 {
+ return true
+ }
+ }
+ return false
+ }, 10*time.Second, 20*time.Millisecond)
+ seg.DecRef()
+
+ // Drive scanGroup's storage calls directly (loadTSDB is a trivial
registry lookup).
+ graceNs := int64(time.Millisecond)
+ now := time.Now().UnixNano()
+ coolRange := timestamp.TimeRange{Start: time.Unix(0, 0), End:
time.Unix(0, now-graceNs)}
+
+ // PeekSegments must surface the cooled segment's shard path WITHOUT
reopening, and
+ // the gen-0 shard must pass the reopen pre-filter.
+ peeks := db.PeekSegments(coolRange)
+ require.NotEmpty(t, peeks, "PeekSegments must return the cooled
segment")
+ sawShard := false
+ for _, pk := range peeks {
+ if pk.End.UnixNano() <= now-graceNs && len(pk.ShardPaths) > 0 {
+ sawShard = true
+ assert.True(t,
segmentMayWarrant(fs.NewLocalFileSystem(), pk.ShardPaths,
finalizeConfig{floorBytes: 1, ratio: 0.1, maxRounds: 8}),
+ "a never-finalized cooled segment must pass the
reopen pre-filter")
+ }
+ }
+ require.True(t, sawShard, "PeekSegments must expose the cooled
segment's shard path")
+
+ segs, err := db.SelectSegments(coolRange, true)
+ require.NoError(t, err)
+ require.NotEmpty(t, segs, "the cooled segment must be selected")
+
+ var ranRound bool
+ for _, s := range segs {
+ if s.GetTimeRange().End.UnixNano() > now-graceNs {
+ continue
+ }
+ tt, _ := s.Tables()
+ for _, table := range tt {
+ finalized, ferr :=
table.runFinalizeRound([]sdk.Sampler{dropOneSampler{dropID: "drop2"}}, graceNs)
+ require.NoError(t, ferr)
+ if finalized {
+ ranRound = true
+ assert.Equal(t, uint64(2),
snapshotTotalCount(table), "drop2 must be removed, leaving 2")
+ assert.Equal(t, uint64(1),
table.finalizeGenCached.Load())
+ }
+ }
+ }
+ for _, s := range segs {
+ s.DecRef()
+ }
+ assert.True(t, ranRound, "a finalize round must have run on the cooled
segment")
+}
diff --git a/banyand/trace/finalize_state.go b/banyand/trace/finalize_state.go
new file mode 100644
index 000000000..db00b989f
--- /dev/null
+++ b/banyand/trace/finalize_state.go
@@ -0,0 +1,96 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package trace
+
+import (
+ "encoding/json"
+ "path/filepath"
+
+ "github.com/pkg/errors"
+
+ "github.com/apache/skywalking-banyandb/banyand/internal/storage"
+ "github.com/apache/skywalking-banyandb/pkg/fs"
+)
+
+// finalizeStateFilename is the per-shard finalization-sampling state document,
+// stored in the shard directory alongside snapshots.
+const finalizeStateFilename = "finalize.json"
+
+// finalizeState is the per-shard finalization-sampling state, persisted
atomically
+// in the shard directory (Rev 8: per-shard, NOT in the per-segment metadata).
It is
+// the sole authority for a shard's finalize generation, cooldown clock, round
count,
+// and arrival-based unsampled-bytes counter. The finalize worker is the only
writer
+// (serialized node-wide); readers seed the cached tsTable fields at open. A
missing
+// or corrupt file decodes to the zero value ("never finalized"), so
pre-finalize
+// shards and partial writes both fail open.
+type finalizeState struct {
+ // LastFinalizedAt is the RFC3339Nano wall-clock of the last successful
round;
+ // empty means never finalized. It is the cooldown clock.
+ LastFinalizedAt string `json:"lastFinalizedAt,omitempty"`
+ // FinalizeGeneration is the shard's monotonic current generation; 0 =
never
+ // finalized. A round targets generation gNext = FinalizeGeneration + 1
and selects
+ // every part with FinalizeGen < gNext — i.e. FinalizeGen <=
FinalizeGeneration — so
+ // each round re-samples the whole cooled set (survivors stamped at the
current
+ // generation are included). The stamp exists for crash-safety: after a
committed
+ // round both the parts and this field read gNext, so a replay (which
recomputes
+ // gNext) excludes them.
+ FinalizeGeneration uint64 `json:"finalizeGeneration,omitempty"`
+ // UnsampledBytes accumulates uncompressed span bytes of parts that
arrived into
+ // this shard after it cooled/was-finalized; it gates the re-round
threshold and
+ // resets to 0 after a successful round.
+ UnsampledBytes int64 `json:"unsampledBytes,omitempty"`
+ // FinalizeRounds counts rounds run over this shard's lifetime; the
hard cap.
+ FinalizeRounds int `json:"finalizeRounds,omitempty"`
+ // Terminal is set once FinalizeRounds hits max_finalize_rounds; the
shard is
+ // then never re-scanned (all future late data is an accepted miss).
+ Terminal bool `json:"terminal,omitempty"`
+}
+
+// readFinalizeState reads the shard's finalize state. A missing, empty, or
corrupt
+// file yields the zero value (never finalized): finalize is best-effort and
must
+// never panic or block on its own state, so any read/parse error fails open.
+func readFinalizeState(fileSystem fs.FileSystem, shardRoot string)
finalizeState {
+ data, err := fileSystem.Read(filepath.Join(shardRoot,
finalizeStateFilename))
+ if err != nil || len(data) == 0 {
+ return finalizeState{}
+ }
+ var st finalizeState
+ if unmarshalErr := json.Unmarshal(data, &st); unmarshalErr != nil {
+ return finalizeState{}
+ }
+ return st
+}
+
+// writeFinalizeState atomically persists st to the shard directory via
temp+rename
+// (fileSystem.WriteAtomic fsyncs the file then the parent dir), so a crash
leaves
+// either the previous valid state or none — never a torn document.
+func writeFinalizeState(fileSystem fs.FileSystem, shardRoot string, st
finalizeState) error {
+ data, err := json.Marshal(st)
+ if err != nil {
+ return errors.WithMessage(err, "cannot marshal finalize state")
+ }
+ statePath := filepath.Join(shardRoot, finalizeStateFilename)
+ n, err := fileSystem.WriteAtomic(data, statePath, storage.FilePerm)
+ if err != nil {
+ return errors.WithMessage(err, "cannot write finalize state")
+ }
+ if n != len(data) {
+ return errors.Errorf("short write to %s: got %d want %d",
statePath, n, len(data))
+ }
+ return nil
+}
diff --git a/banyand/trace/finalize_state_test.go
b/banyand/trace/finalize_state_test.go
new file mode 100644
index 000000000..193f23f68
--- /dev/null
+++ b/banyand/trace/finalize_state_test.go
@@ -0,0 +1,104 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package trace
+
+import (
+ "encoding/json"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/apache/skywalking-banyandb/banyand/internal/storage"
+ "github.com/apache/skywalking-banyandb/pkg/fs"
+)
+
+// TestFinalizeState_RoundTrip verifies write then read returns the same state.
+func TestFinalizeState_RoundTrip(t *testing.T) {
+ fileSystem := fs.NewLocalFileSystem()
+ dir := t.TempDir()
+
+ want := finalizeState{
+ LastFinalizedAt: time.Unix(0,
1_700_000_000_000_000_000).UTC().Format(time.RFC3339Nano),
+ FinalizeGeneration: 5,
+ UnsampledBytes: 12345,
+ FinalizeRounds: 3,
+ Terminal: true,
+ }
+ require.NoError(t, writeFinalizeState(fileSystem, dir, want))
+
+ got := readFinalizeState(fileSystem, dir)
+ assert.Equal(t, want, got)
+}
+
+// TestFinalizeState_MissingFileIsZero verifies a shard with no finalize file
loads
+// as never-finalized (the pre-finalize / fresh-shard common case).
+func TestFinalizeState_MissingFileIsZero(t *testing.T) {
+ fileSystem := fs.NewLocalFileSystem()
+ dir := t.TempDir()
+
+ got := readFinalizeState(fileSystem, dir)
+ assert.Equal(t, finalizeState{}, got, "missing finalize file must
decode to zero state")
+ assert.Zero(t, got.FinalizeGeneration)
+}
+
+// TestFinalizeState_CorruptIsZero verifies a corrupt finalize file fails open
to the
+// zero value rather than panicking (finalize must never crash the node).
+func TestFinalizeState_CorruptIsZero(t *testing.T) {
+ fileSystem := fs.NewLocalFileSystem()
+ dir := t.TempDir()
+
+ _, err := fileSystem.Write([]byte("{not valid json"),
filepath.Join(dir, finalizeStateFilename), storage.FilePerm)
+ require.NoError(t, err)
+
+ assert.Equal(t, finalizeState{}, readFinalizeState(fileSystem, dir),
+ "corrupt finalize file must fail open to zero state")
+}
+
+// TestPartMetadata_FinalizeGenRoundTrip verifies the FinalizeGen field
survives a
+// JSON round-trip and that old-format metadata (no finalizeGen key) loads as
0.
+func TestPartMetadata_FinalizeGenRoundTrip(t *testing.T) {
+ pm := partMetadata{
+ CompressedSizeBytes: 100,
+ UncompressedSpanSizeBytes: 200,
+ TotalCount: 3,
+ BlocksCount: 1,
+ MinTimestamp: 10,
+ MaxTimestamp: 20,
+ FinalizeGen: 7,
+ }
+ data, err := json.Marshal(&pm)
+ require.NoError(t, err)
+
+ var got partMetadata
+ require.NoError(t, json.Unmarshal(data, &got))
+ assert.Equal(t, uint64(7), got.FinalizeGen, "FinalizeGen must survive
round-trip")
+
+ // Old-format metadata (no finalizeGen key) loads as unstamped (0).
+ var old partMetadata
+ require.NoError(t,
json.Unmarshal([]byte(`{"compressedSizeBytes":1,"totalCount":1,"minTimestamp":1,"maxTimestamp":2}`),
&old))
+ assert.Zero(t, old.FinalizeGen, "old-format part metadata must load as
unstamped")
+
+ // omitempty: a zero FinalizeGen is not serialized.
+ zeroPM := partMetadata{TotalCount: 1, MinTimestamp: 1, MaxTimestamp: 2}
+ zeroData, err := json.Marshal(&zeroPM)
+ require.NoError(t, err)
+ assert.NotContains(t, string(zeroData), "finalizeGen", "zero
FinalizeGen must be omitted")
+}
diff --git a/banyand/trace/finalizer.go b/banyand/trace/finalizer.go
new file mode 100644
index 000000000..dad5b7f72
--- /dev/null
+++ b/banyand/trace/finalizer.go
@@ -0,0 +1,182 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package trace
+
+import (
+ "time"
+
+ "github.com/apache/skywalking-banyandb/banyand/protector"
+ "github.com/apache/skywalking-banyandb/pkg/pipeline/sdk"
+)
+
+// runFinalizeRound force-merges this shard's cooled, un-finalized parts
through the
+// sampler chain (the finalization-sampling backstop). It reuses the hot merge
path
+// (mergePartsThenSendIntroduction → mergeParts + per-sidx Merge + the
serialized
+// introducer loop, OQ6) with a pre-built filter and a finalize-generation
override,
+// so the core AND sidx follow the exact same code the hot merge uses. It NEVER
+// acquires mergeMaxConcurrencyCh (that lives in the merge-lane worker, not
here), so
+// finalize compute cannot starve the hot merge lanes.
+//
+// Selection is all not-hot (by finalize_grace), not-in-flight parts whose
FinalizeGen
+// is below the round's target generation Gnext=G+1 (by the invariant that
nothing is
+// stamped above the stored generation, this is every cooled part). The round
is a full
+// re-sample of the cooled set; re-sampling already-kept survivors through a
+// deterministic sampler is idempotent (DD11), and rounds are hard-capped by
the
+// scanner (max_finalize_rounds), so the total rewrite volume is bounded.
+//
+// It returns (true, nil) when a round committed, (false, nil) when there was
nothing
+// to do or it yielded under pressure, and (false, err) on a merge error
(fail-open:
+// the segment is left intact and the scanner retries on a later tick). It is
invoked
+// only by the finalize scanner while the owning segment is incRef-held, so the
+// tsTable's introducer loop is alive to apply the introduction.
+func (tst *tsTable) runFinalizeRound(samplers []sdk.Sampler, graceNs int64)
(bool, error) {
+ if len(samplers) == 0 {
+ return false, nil
+ }
+ // Resource gate: never run finalize compute under memory pressure
(constraint 1).
+ if tst.pm != nil && tst.pm.State() == protector.StateHigh {
+ return false, nil
+ }
+ closeCh := tst.loopCloser.CloseNotify()
+ select {
+ case <-closeCh:
+ return false, nil
+ default:
+ }
+
+ cur := tst.currentSnapshot()
+ if cur == nil {
+ return false, nil
+ }
+ defer cur.decRef()
+
+ gNext := tst.finalizeGenCached.Load() + 1
+ now := time.Now().UnixNano()
+ // Snapshot the counter at round start. On commit we subtract exactly
this much
+ // rather than storing 0, so bytes that a concurrent flush accounts
DURING the round
+ // (late arrivals not part of this round's merge) are preserved for the
next round.
+ // unsampledBytes is only ever added to elsewhere and reset only here
(serialized),
+ // so the value never drops below startBytes before the subtract.
+ startBytes := tst.unsampledBytes.Load()
+
+ // Select AND pin candidate parts atomically under a single write lock.
A concurrent
+ // hot-merge dispatch (dispatchAllMerges) is a second selector of the
same parts; if
+ // selection and pinning were split (RLock check, then Lock add) both
could pick the
+ // same part and merge it twice, and since snapshot.remove tolerates an
absent id the
+ // two outputs would both survive — duplicating traces. Doing
check+pin+incRef in one
+ // Lock (and a matching re-check on the dispatcher side) makes the pin
exclusive.
+ // Selection: cooled (non-hot by finalize_grace), not already
in-flight, and below the
+ // round generation (excludes a crashed round's stamped outputs — DD6).
+ var parts []*partWrapper
+ var needBytes uint64
+ tst.inFlightMu.Lock()
+ if tst.inFlight == nil {
+ tst.inFlight = make(map[uint64]struct{})
+ }
+ for _, pw := range cur.parts {
+ if pw.mp != nil || pw.p.partMetadata.TotalCount < 1 {
+ continue
+ }
+ if _, inFlight := tst.inFlight[pw.ID()]; inFlight {
+ continue
+ }
+ if pw.p.partMetadata.FinalizeGen >= gNext {
+ continue
+ }
+ if pw.p.partMetadata.MaxTimestamp > now-graceNs {
+ continue
+ }
+ pw.incRef()
+ tst.inFlight[pw.ID()] = struct{}{}
+ parts = append(parts, pw)
+ needBytes += pw.p.partMetadata.CompressedSizeBytes
+ }
+ tst.inFlightMu.Unlock()
+
+ if len(parts) == 0 {
+ // Nothing eligible this round (all parts still hot, or already
finalized).
+ // Do NOT advance the generation or reset the counter.
+ return false, nil
+ }
+ // Unpin + release on EVERY return path below (disk gate, merge error,
success).
+ defer func() {
+ tst.inFlightMu.Lock()
+ for _, pw := range parts {
+ delete(tst.inFlight, pw.ID())
+ }
+ tst.inFlightMu.Unlock()
+ for _, pw := range parts {
+ pw.decRef()
+ }
+ }()
+
+ // Disk-headroom gate: a full-shard rewrite transiently needs ~the
selected parts'
+ // compressed size in extra space. Skip the round (an accepted miss)
rather than risk
+ // pushing the hot write path into disk-full — finalize must never
starve the core.
+ if tst.freeDiskSpace(tst.root) < needBytes {
+ return false, nil
+ }
+
+ // Build the finalize filter. Unlike the hot path there is no
isMergeHot gate here
+ // (eligibility was already decided above with finalize_grace); the
chain fails open
+ // on any Decide error, retaining the whole batch.
+ chain := newMergeChain(tst.group, "", samplers,
tst.option.decideTimeoutCircuitBreak)
+ filter := &mergeFilter{
+ chain: chain,
+ timeout: tst.option.decideTimeout,
+ stageBudget: resolveStageBudget(tst.option),
+ forceSlow: len(chain.projection.Tags) > 0,
+ }
+
+ merged := make(map[uint64]struct{}, len(parts))
+ for _, pw := range parts {
+ merged[pw.ID()] = struct{}{}
+ }
+
+ gen := gNext
+ if _, err := tst.mergePartsThenSendIntroduction(
+ snapshotCreatorMerger, parts, merged, tst.mergeCh, closeCh,
+ mergeTypeFinalize, mergeLaneFinalize, &mergeOverrides{filter:
filter, finalizeGen: &gen},
+ ); err != nil {
+ // Fail-open: leave the segment intact; the scanner retries on
a later tick.
+ return false, err
+ }
+
+ // Commit the per-shard finalize state AFTER the introduction (DD6
ordering). The
+ // in-memory cache and counter are mutated ONLY after the state file is
durably
+ // written: if persist failed but we still bumped finalizeGenCached,
the next round
+ // would use gNext+1 and re-include (re-sample) the parts this round
just stamped
+ // gNext. On failure we leave cache == disk (still G) and return the
error; the
+ // on-disk parts are stamped gNext, so the retry's predicate
(finalizeGen < G+1)
+ // excludes them — no double-sample, only the round bookkeeping is
retried.
+ // The counter subtracts exactly the bytes counted before the round
started, so a
+ // concurrent flush during the round keeps its contribution for the
next round.
+ remaining := max(tst.unsampledBytes.Load()-startBytes, 0)
+ st := readFinalizeState(tst.fileSystem, tst.root)
+ st.FinalizeGeneration = gNext
+ st.LastFinalizedAt = time.Now().UTC().Format(time.RFC3339Nano)
+ st.FinalizeRounds++
+ st.UnsampledBytes = remaining
+ if err := writeFinalizeState(tst.fileSystem, tst.root, st); err != nil {
+ tst.l.Warn().Err(err).Str("group", tst.group).Msg("cannot
persist finalize state; leaving generation unchanged")
+ return false, err
+ }
+ tst.finalizeGenCached.Store(gNext)
+ tst.unsampledBytes.Add(-startBytes)
+ return true, nil
+}
diff --git a/banyand/trace/finalizer_test.go b/banyand/trace/finalizer_test.go
new file mode 100644
index 000000000..16fa3b5ac
--- /dev/null
+++ b/banyand/trace/finalizer_test.go
@@ -0,0 +1,394 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package trace
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/apache/skywalking-banyandb/api/common"
+ "github.com/apache/skywalking-banyandb/banyand/protector"
+ "github.com/apache/skywalking-banyandb/pkg/fs"
+ "github.com/apache/skywalking-banyandb/pkg/logger"
+ pbv1 "github.com/apache/skywalking-banyandb/pkg/pb/v1"
+ "github.com/apache/skywalking-banyandb/pkg/pipeline/sdk"
+ "github.com/apache/skywalking-banyandb/pkg/run"
+ "github.com/apache/skywalking-banyandb/pkg/test"
+ "github.com/apache/skywalking-banyandb/pkg/timestamp"
+)
+
+// dropOneSampler is a minimal deterministic sampler that drops exactly one
trace id.
+type dropOneSampler struct{ dropID string }
+
+func (dropOneSampler) Kind() sdk.Kind { return sdk.KindSampler }
+func (dropOneSampler) Project() sdk.Projection { return sdk.Projection{} }
+func (dropOneSampler) Close() error { return nil }
+func (s dropOneSampler) Decide(b *sdk.TraceBatch) (sdk.Verdict, error) {
+ keep := make([]bool, len(b.Traces))
+ for i := range b.Traces {
+ keep[i] = b.Traces[i].TraceID != s.dropID
+ }
+ return sdk.Verdict{Keep: keep}, nil
+}
+
+// dropPrefixSampler drops every trace whose id starts with the prefix. Used
to prove
+// MERGE and FINALIZE compose: the same registered sampler runs in both events.
+type dropPrefixSampler struct{ prefix string }
+
+func (dropPrefixSampler) Kind() sdk.Kind { return sdk.KindSampler }
+func (dropPrefixSampler) Project() sdk.Projection { return sdk.Projection{} }
+func (dropPrefixSampler) Close() error { return nil }
+func (s dropPrefixSampler) Decide(b *sdk.TraceBatch) (sdk.Verdict, error) {
+ keep := make([]bool, len(b.Traces))
+ for i := range b.Traces {
+ keep[i] = !strings.HasPrefix(b.Traces[i].TraceID, s.prefix)
+ }
+ return sdk.Verdict{Keep: keep}, nil
+}
+
+// tracesWithIDs builds a minimal traces fixture (one ancient-timestamp span
per id).
+func tracesWithIDs(ids ...string) *traces {
+ ts := &traces{}
+ for _, id := range ids {
+ ts.traceIDs = append(ts.traceIDs, id)
+ ts.timestamps = append(ts.timestamps, int64(1))
+ ts.tags = append(ts.tags, []*tagValue{{tag: "t", valueType:
pbv1.ValueTypeStr, value: []byte("v")}})
+ ts.spans = append(ts.spans, []byte("span-"+id))
+ ts.spanIDs = append(ts.spanIDs, "span-"+id)
+ }
+ return ts
+}
+
+func snapshotTotalCount(tst *tsTable) uint64 {
+ s := tst.currentSnapshot()
+ if s == nil {
+ return 0
+ }
+ defer s.decRef()
+ var total uint64
+ for _, pw := range s.parts {
+ if pw.mp == nil {
+ total += pw.p.partMetadata.TotalCount
+ }
+ }
+ return total
+}
+
+func oneTraceFixture(id string, ts int64) *traces {
+ return &traces{
+ traceIDs: []string{id},
+ timestamps: []int64{ts},
+ tags: [][]*tagValue{
+ {{tag: "tag1", valueType: pbv1.ValueTypeStr, value:
[]byte("v"), valueArr: nil}},
+ },
+ spans: [][]byte{[]byte("span-" + id)},
+ spanIDs: []string{"span-" + id},
+ }
+}
+
+// TestMergeParts_FinalizeGenPropagation covers the four DD1.C1 cases for the
finalize
+// generation stamp threaded through mergeParts. Each output is re-read from
disk
+// (mustOpenFilePart -> mustReadMetadata) to assert the ON-DISK value
survives, i.e.
+// the stamp is written before pm.mustWriteMetadata (crash-safe across a
restart).
+func TestMergeParts_FinalizeGenPropagation(t *testing.T) {
+ tmpPath, defFn := test.Space(require.New(t))
+ defer defFn()
+ fileSystem := fs.NewLocalFileSystem()
+ tst := &tsTable{pm: protector.Nop{}, fileSystem: fileSystem, root:
tmpPath}
+
+ var nextID uint64
+ // flushPart writes a single-trace part and returns its wrapper with
FinalizeGen set
+ // in-memory to gen (mergeParts min-propagates from the in-memory input
value).
+ flushPart := func(id string, gen uint64) *partWrapper {
+ nextID++
+ pid := nextID
+ mp := generateMemPart()
+ mp.mustInitFromTraces(oneTraceFixture(id, int64(pid)))
+ mp.mustFlush(fileSystem, partPath(tmpPath, pid))
+ releaseMemPart(mp)
+ p := mustOpenFilePart(pid, tmpPath, fileSystem)
+ p.partMetadata.ID = pid
+ p.partMetadata.FinalizeGen = gen
+ return newPartWrapper(nil, p)
+ }
+ // mergeAndReadGen merges parts with the given override, then re-opens
the output
+ // part from disk and returns its persisted FinalizeGen.
+ mergeAndReadGen := func(parts []*partWrapper, override *uint64) uint64 {
+ nextID++
+ outID := nextID
+ closeCh := make(chan struct{})
+ out, _, err := tst.mergeParts(fileSystem, closeCh, parts,
outID, tmpPath, nil, override)
+ close(closeCh)
+ require.NoError(t, err)
+ require.NotNil(t, out)
+ gotID := out.ID()
+ out.decRef()
+ for _, pw := range parts {
+ pw.decRef()
+ }
+ reopened := mustOpenFilePart(gotID, tmpPath, fileSystem)
+ return reopened.partMetadata.FinalizeGen
+ }
+
+ // (a) hot merge (nil override) of two gen-G parts => output gen G
(min), so a hot
+ // merge inside a finalized segment does NOT un-finalize.
+ assert.Equal(t, uint64(3),
mergeAndReadGen([]*partWrapper{flushPart("a1", 3), flushPart("a2", 3)}, nil),
+ "two gen-3 parts must merge to gen 3 (min)")
+
+ // (b) hot merge of a gen-G part + an unstamped (0) late part => output
0 (min), so
+ // the merged part is selectable by the next finalize round.
+ assert.Equal(t, uint64(0),
mergeAndReadGen([]*partWrapper{flushPart("b1", 5), flushPart("b2", 0)}, nil),
+ "gen-5 + unstamped must merge to gen 0 (min)")
+
+ // (c) a finalize round (override=&Gnew) stamps the output at Gnew on
disk; the
+ // re-open proves the value is persisted (crash-safe), and Gnew <
FinalizeGeneration
+ // would be false on replay so it is not re-selected.
+ gnew := uint64(7)
+ assert.Equal(t, uint64(7),
mergeAndReadGen([]*partWrapper{flushPart("c1", 0), flushPart("c2", 0)}, &gnew),
+ "finalize override must stamp Gnew on disk")
+
+ // (d) a lone unstamped part stays 0 under nil override.
+ assert.Equal(t, uint64(0),
mergeAndReadGen([]*partWrapper{flushPart("d1", 0)}, nil),
+ "a lone unstamped part must stay gen 0")
+}
+
+// TestRunFinalizeRound_DropsAndStamps drives a full finalize round on a
running
+// tsTable: writes traces, waits for the flush, then finalizes through a
drop-one
+// sampler and asserts the dropped trace is gone, the generation advanced, and
the
+// per-shard finalize state is persisted.
+func TestRunFinalizeRound_DropsAndStamps(t *testing.T) {
+ tmpPath, defFn := test.Space(require.New(t))
+ defer defFn()
+ fileSystem := fs.NewLocalFileSystem()
+
+ tst, err := newTSTable(
+ fileSystem, tmpPath,
+ common.Position{Database: "fg"},
+ logger.GetLogger("finalize-round-test"),
+ timestamp.TimeRange{},
+ option{
+ flushTimeout: 0,
+ mergePolicy: newDefaultMergePolicyForTesting(),
+ protector: protector.Nop{},
+ decideTimeout: time.Second,
+ },
+ nil,
+ )
+ require.NoError(t, err)
+ defer tst.Close()
+
+ tst.mustAddTraces(tsTS1, nil)
+ // Wait for the mem part to flush to a file part (mp == nil).
+ require.Eventually(t, func() bool {
+ s := tst.currentSnapshot()
+ if s == nil {
+ return false
+ }
+ defer s.decRef()
+ for _, pw := range s.parts {
+ if pw.mp == nil && pw.p.partMetadata.TotalCount > 0 {
+ return true
+ }
+ }
+ return false
+ }, 10*time.Second, 20*time.Millisecond, "flushed file part must appear")
+
+ require.Equal(t, uint64(3), snapshotTotalCount(tst), "3 traces before
finalize")
+
+ // finalize_grace tiny so the cooled parts (timestamp 1) are non-hot.
+ finalized, roundErr :=
tst.runFinalizeRound([]sdk.Sampler{dropOneSampler{dropID: "trace2"}},
int64(time.Millisecond))
+ require.NoError(t, roundErr)
+ require.True(t, finalized, "a round must have committed")
+
+ assert.Equal(t, uint64(2), snapshotTotalCount(tst), "trace2 must be
dropped, leaving 2")
+ assert.Equal(t, uint64(1), tst.finalizeGenCached.Load(), "generation
must advance to 1")
+ assert.Zero(t, tst.unsampledBytes.Load(), "counter must reset after a
round")
+
+ st := readFinalizeState(fileSystem, tmpPath)
+ assert.Equal(t, uint64(1), st.FinalizeGeneration, "persisted generation
must be 1")
+ assert.Equal(t, 1, st.FinalizeRounds, "persisted round count must be 1")
+ assert.NotEmpty(t, st.LastFinalizedAt, "lastFinalizedAt must be set")
+}
+
+// TestRunFinalizeRound_ReplayExcludesStampedParts reproduces the DD6 crash
window: a
+// part is stamped at the round generation on disk, but the per-shard state
(and cache)
+// still reads the OLD generation (as if the process died after introduce,
before the
+// state persist). On the next round the predicate finalizeGen >= gNext must
EXCLUDE the
+// stamped part, so no round runs and nothing is re-sampled.
+func TestRunFinalizeRound_ReplayExcludesStampedParts(t *testing.T) {
+ tmpPath, defFn := test.Space(require.New(t))
+ defer defFn()
+ fileSystem := fs.NewLocalFileSystem()
+
+ tst, err := newTSTable(
+ fileSystem, tmpPath, common.Position{Database: "rp"},
+ logger.GetLogger("finalize-replay-test"), timestamp.TimeRange{},
+ option{flushTimeout: 0, mergePolicy:
newDefaultMergePolicyForTesting(), protector: protector.Nop{}, decideTimeout:
time.Second},
+ nil,
+ )
+ require.NoError(t, err)
+ defer tst.Close()
+
+ tst.mustAddTraces(tsTS1, nil)
+ require.Eventually(t, func() bool {
+ s := tst.currentSnapshot()
+ if s == nil {
+ return false
+ }
+ defer s.decRef()
+ for _, pw := range s.parts {
+ if pw.mp == nil && pw.p.partMetadata.TotalCount > 0 {
+ return true
+ }
+ }
+ return false
+ }, 10*time.Second, 20*time.Millisecond)
+
+ // Round 1 stamps the output at gen 1 (on disk) and persists state gen
1.
+ finalized, roundErr :=
tst.runFinalizeRound([]sdk.Sampler{dropOneSampler{dropID: "trace2"}},
int64(time.Millisecond))
+ require.NoError(t, roundErr)
+ require.True(t, finalized)
+ countAfterFirst := snapshotTotalCount(tst)
+
+ // Simulate the crash window: the parts stay stamped gen 1 on disk, but
the state +
+ // cache revert to gen 0 (the persist that would have recorded gen 1
never happened).
+ tst.finalizeGenCached.Store(0)
+ require.NoError(t, writeFinalizeState(fileSystem, tmpPath,
finalizeState{}))
+
+ // Replay: the gen-1 output part must be excluded (finalizeGen 1 >=
gNext 1), so no
+ // round runs — no double-sample of already-committed data.
+ finalized2, roundErr2 :=
tst.runFinalizeRound([]sdk.Sampler{dropOneSampler{dropID: "trace2"}},
int64(time.Millisecond))
+ require.NoError(t, roundErr2)
+ assert.False(t, finalized2, "a part already stamped at the round
generation must be excluded on replay")
+ assert.Equal(t, uint64(0), tst.finalizeGenCached.Load(), "excluded
replay must not advance the generation")
+ assert.Equal(t, countAfterFirst, snapshotTotalCount(tst), "replay must
not re-sample committed data")
+}
+
+// TestFinalizeAndMerge_Compose proves the in-merge filter
(PIPELINE_EVENT_MERGE) and
+// finalization sampling (PIPELINE_EVENT_FINALIZE) compose on ONE running
shard, using
+// the same registered sampler (DD11): a real hot merge drops its targets
during the
+// merge, a never-merged part is left for finalize, and a finalize round drops
that
+// part's target while re-keeping the merge survivors (no double-drop). The
auto-merge
+// loop is disabled (maxFanOutSize=0) so the merge below is the only one and
the test
+// is deterministic; the manual merge bypasses the policy, exercising the
exact hot
+// merge path (mergePartsThenSendIntroduction with nil override).
+func TestFinalizeAndMerge_Compose(t *testing.T) {
+ resetRegistries()
+ defer resetRegistries()
+
+ const group = "cg"
+ deregister := registerSampler(group, dropPrefixSampler{prefix: "drop"})
+ defer deregister()
+
+ tmpPath, defFn := test.Space(require.New(t))
+ defer defFn()
+ fileSystem := fs.NewLocalFileSystem()
+
+ tst, err := newTSTable(
+ fileSystem, tmpPath,
+ common.Position{Database: group},
+ logger.GetLogger("finalize-merge-compose"),
+ timestamp.TimeRange{},
+ option{
+ flushTimeout: 0,
+ mergePolicy: newMergePolicy(3, 1,
run.Bytes(0)), // maxFanOutSize=0 disables auto-merge
+ protector: protector.Nop{},
+ decideTimeout: time.Second,
+ decideTimeoutCircuitBreak: 3,
+ mergeGraceDefault: time.Millisecond, // ancient
parts are never hot
+ nativePipelineEnabled: true, //
activates the in-merge MERGE filter
+ },
+ nil,
+ )
+ require.NoError(t, err)
+ defer tst.Close()
+
+ // waitForFileParts blocks until the snapshot holds exactly n file
parts.
+ waitForFileParts := func(n int) {
+ require.Eventually(t, func() bool {
+ s := tst.currentSnapshot()
+ if s == nil {
+ return false
+ }
+ defer s.decRef()
+ cnt := 0
+ for _, pw := range s.parts {
+ if pw.mp == nil && pw.p.partMetadata.TotalCount
> 0 {
+ cnt++
+ }
+ }
+ return cnt == n
+ }, 10*time.Second, 20*time.Millisecond)
+ }
+ collectFileParts := func() []*partWrapper {
+ s := tst.currentSnapshot()
+ require.NotNil(t, s)
+ defer s.decRef()
+ var pws []*partWrapper
+ for _, pw := range s.parts {
+ if pw.mp == nil && pw.p.partMetadata.TotalCount > 0 {
+ pw.incRef()
+ pws = append(pws, pw)
+ }
+ }
+ return pws
+ }
+
+ // Two separately-flushed parts, each with one drop-target and one
keeper.
+ tst.mustAddTraces(tracesWithIDs("dropA", "keepB"), nil)
+ waitForFileParts(1)
+ tst.mustAddTraces(tracesWithIDs("dropC", "keepD"), nil)
+ waitForFileParts(2)
+ require.Equal(t, uint64(4), snapshotTotalCount(tst), "4 traces across 2
parts before any sampling")
+
+ // A real hot merge (MERGE event): the in-merge sampler drops dropA +
dropC.
+ parts := collectFileParts()
+ require.Len(t, parts, 2)
+ merged := make(map[uint64]struct{}, len(parts))
+ for _, pw := range parts {
+ merged[pw.ID()] = struct{}{}
+ }
+ closeCh := make(chan struct{})
+ _, mErr := tst.mergePartsThenSendIntroduction(
+ snapshotCreatorMerger, parts, merged, tst.mergeCh, closeCh,
mergeTypeFile, mergeLaneFast, nil,
+ )
+ close(closeCh)
+ require.NoError(t, mErr)
+ for _, pw := range parts {
+ pw.decRef()
+ }
+ assert.Equal(t, uint64(2), snapshotTotalCount(tst), "MERGE must drop
dropA + dropC, leaving keepB + keepD")
+ assert.Zero(t, tst.finalizeGenCached.Load(), "a MERGE must not advance
the finalize generation")
+
+ // A third part that never merges — the case finalize exists to
backstop.
+ tst.mustAddTraces(tracesWithIDs("dropE", "keepF"), nil)
+ waitForFileParts(2) // merged part + part3
+ require.Equal(t, uint64(4), snapshotTotalCount(tst), "keepB + keepD +
dropE + keepF")
+
+ // FINALIZE event: drops dropE from the never-merged part and re-keeps
the MERGE
+ // survivors (deterministic sampler => no double-drop). Advances the
generation.
+ finalized, fErr :=
tst.runFinalizeRound([]sdk.Sampler{dropPrefixSampler{prefix: "drop"}},
int64(time.Millisecond))
+ require.NoError(t, fErr)
+ require.True(t, finalized, "finalize must have run")
+ assert.Equal(t, uint64(3), snapshotTotalCount(tst), "keepB + keepD +
keepF survive; dropE dropped, no keeper double-dropped")
+ assert.Equal(t, uint64(1), tst.finalizeGenCached.Load(), "finalize must
advance the generation to 1")
+}
diff --git a/banyand/trace/flusher.go b/banyand/trace/flusher.go
index 2ae71f54c..dde0c99a2 100644
--- a/banyand/trace/flusher.go
+++ b/banyand/trace/flusher.go
@@ -149,7 +149,7 @@ func (tst *tsTable) mergeMemParts(snp *snapshot, mergeCh
chan *mergerIntroductio
// merge memory must not be closed by the tsTable.close
closeCh := make(chan struct{})
newPart, err :=
tst.mergePartsThenSendIntroduction(snapshotCreatorMergedFlusher, memParts,
- currentMergedIDs, mergeCh, closeCh, mergeTypeMem, "")
+ currentMergedIDs, mergeCh, closeCh, mergeTypeMem, "",
nil)
close(closeCh)
if err != nil {
if errors.Is(err, errClosed) {
diff --git a/banyand/trace/introducer.go b/banyand/trace/introducer.go
index b72047fd0..78509ee41 100644
--- a/banyand/trace/introducer.go
+++ b/banyand/trace/introducer.go
@@ -324,7 +324,29 @@ func (tst *tsTable) introducePart(nextIntroduction
*introduction, epoch uint64)
}
}
+// accountUnsampledFlushed is the arrival-based unsampled-bytes accounting for
+// finalization sampling. Once this shard has been finalized at least once
(cached
+// generation > 0), a newly-flushed part is new unsampled data arriving into a
cooled
+// segment; accumulate its uncompressed span bytes so the finalize scanner can
decide
+// whether another round is warranted. It performs O(1) atomic loads/adds over
already
+// in-memory part metadata and takes no filesystem — there is NO metadata I/O
on the
+// hot flush path (Phase 3 acceptance).
+func (tst *tsTable) accountUnsampledFlushed(flushed map[uint64]*partWrapper) {
+ if tst.finalizeGenCached.Load() == 0 {
+ return
+ }
+ var newBytes int64
+ for _, pw := range flushed {
+ newBytes += int64(pw.p.partMetadata.UncompressedSpanSizeBytes)
+ }
+ if newBytes > 0 {
+ tst.unsampledBytes.Add(newBytes)
+ }
+}
+
func (tst *tsTable) introduceFlushed(nextIntroduction *flusherIntroduction,
epoch uint64) {
+ tst.accountUnsampledFlushed(nextIntroduction.flushed)
+
// Create generic transaction
txn := snapshotpkg.NewTransaction()
defer txn.Release()
@@ -375,6 +397,10 @@ func (tst *tsTable) introduceFlushed(nextIntroduction
*flusherIntroduction, epoc
// The snapshots are updated atomically so the syncer can always find
// the corresponding index once a flushed trace part becomes visible.
func (tst *tsTable) introduceFlushedForSync(nextIntroduction
*flusherIntroduction, epoch uint64) {
+ // No unsampled-bytes accounting here: on the sync path these parts are
handed off
+ // to other nodes rather than retained locally for finalization, so
counting them
+ // would inflate the counter and trigger no-op finalize rounds (finding
#9).
+
// Create generic transaction
txn := snapshotpkg.NewTransaction()
defer txn.Release()
diff --git a/banyand/trace/merger.go b/banyand/trace/merger.go
index d9c4e2b9c..ac58bab03 100644
--- a/banyand/trace/merger.go
+++ b/banyand/trace/merger.go
@@ -41,10 +41,12 @@ import (
var mergeMaxConcurrencyCh = make(chan struct{}, cgroups.CPUs())
const (
- mergeTypeMem = "mem"
- mergeTypeFile = "file"
- mergeLaneFast = "fast"
- mergeLaneSlow = "slow"
+ mergeTypeMem = "mem"
+ mergeTypeFile = "file"
+ mergeTypeFinalize = "finalize"
+ mergeLaneFast = "fast"
+ mergeLaneSlow = "slow"
+ mergeLaneFinalize = "finalize"
)
const defaultSmallMergeThreshold = 32 << 20 // 32MB fallback
@@ -225,6 +227,27 @@ func (tst *tsTable) dispatchAllMerges(threshold uint64,
fastCh, slowCh chan *mer
if tst.inFlight == nil {
tst.inFlight = make(map[uint64]struct{})
}
+ // Re-check membership under the lock before pinning.
getPartsToMerge already
+ // skips in-flight parts, but the finalize scanner is a second,
concurrent part
+ // selector: it may have pinned one of these parts in the
window between
+ // getPartsToMerge's RLock and this Lock. Pinning it here
anyway would let both
+ // actors merge the same part, and since snapshot.remove
tolerates an absent id
+ // both merge outputs would survive — duplicating traces. On
conflict, abandon
+ // this dispatch cycle (the next flush trigger re-dispatches).
+ conflict := false
+ for _, pw := range dst {
+ if _, inFlight := tst.inFlight[pw.ID()]; inFlight {
+ conflict = true
+ break
+ }
+ }
+ if conflict {
+ tst.inFlightMu.Unlock()
+ for _, pw := range dst {
+ pw.decRef()
+ }
+ return false
+ }
for _, pw := range dst {
tst.inFlight[pw.ID()] = struct{}{}
}
@@ -288,7 +311,7 @@ func (tst *tsTable) mergeLaneWorker(ch chan
*mergeDispatchRequest, merges chan *
tst.incTotalMergeLoopStarted(1)
_, mergeErr := tst.mergePartsThenSendIntroduction(
snapshotCreatorMerger, req.parts, req.toBeMerged,
merges,
- tst.loopCloser.CloseNotify(), req.typ, req.lane,
+ tst.loopCloser.CloseNotify(), req.typ, req.lane, nil,
)
tst.incTotalMergeLoopFinished(1)
<-mergeMaxConcurrencyCh
@@ -315,32 +338,69 @@ func (tst *tsTable) releaseDispatchRequest(req
*mergeDispatchRequest) {
}
}
+// mergeOverrides lets the finalize round drive mergePartsThenSendIntroduction
with a
+// pre-built sampler filter (bypassing the hot-path isMergeHot/merge_grace
build) and a
+// finalize-generation stamp for the output part. A nil *mergeOverrides means
the hot /
+// flusher path: build the filter from merge-time rules and min-propagate
finalizeGen.
+type mergeOverrides struct {
+ filter *mergeFilter
+ finalizeGen *uint64
+}
+
+// buildHotMergeFilter builds the in-merge retention filter for the hot /
flusher merge
+// path from the merge-time rules (registered samplers + merge_grace
maturity). It
+// returns nil when the native pipeline is off, no samplers are registered, or
the merge
+// is still hot (parts younger than merge_grace), in which case the merge runs
unfiltered.
+func (tst *tsTable) buildHotMergeFilter(parts []*partWrapper) *mergeFilter {
+ if !tst.option.nativePipelineEnabled {
+ return nil
+ }
+ samplers := lookupSamplers(tst.group)
+ if len(samplers) == 0 {
+ return nil
+ }
+ // The sampler set is shared with FINALIZE (DD11); only filter hot
merges when the
+ // MERGE event is actually enabled for this group, so a FINALIZE-only
config does
+ // not silently filter hot merges.
+ if !mergeEventEnabledForGroup(tst.group) {
+ return nil
+ }
+ graceNs := lookupMergeGrace(tst.group)
+ if graceNs <= 0 {
+ graceNs = int64(tst.option.mergeGraceDefault)
+ }
+ if isMergeHot(parts, graceNs, time.Now().UnixNano()) {
+ return nil
+ }
+ chain := newMergeChain(tst.group, "", samplers,
tst.option.decideTimeoutCircuitBreak)
+ return &mergeFilter{
+ chain: chain,
+ timeout: tst.option.decideTimeout,
+ stageBudget: resolveStageBudget(tst.option),
+ forceSlow: len(chain.projection.Tags) > 0,
+ }
+}
+
func (tst *tsTable) mergePartsThenSendIntroduction(creator snapshotCreator,
parts []*partWrapper, merged map[uint64]struct{}, merges chan
*mergerIntroduction,
- closeCh <-chan struct{}, typ string, lane string,
+ closeCh <-chan struct{}, typ string, lane string, ov *mergeOverrides,
) (*partWrapper, error) {
reservedSpace := tst.reserveSpace(parts)
defer releaseDiskSpace(reservedSpace)
start := time.Now()
newPartID := atomic.AddUint64(&tst.curPartID, 1)
var filter *mergeFilter
- if tst.option.nativePipelineEnabled {
- if samplers := lookupSamplers(tst.group); len(samplers) > 0 {
- graceNs := lookupMergeGrace(tst.group)
- if graceNs <= 0 {
- graceNs = int64(tst.option.mergeGraceDefault)
- }
- if !isMergeHot(parts, graceNs, time.Now().UnixNano()) {
- chain := newMergeChain(tst.group, "", samplers,
tst.option.decideTimeoutCircuitBreak)
- filter = &mergeFilter{
- chain: chain,
- timeout: tst.option.decideTimeout,
- stageBudget:
resolveStageBudget(tst.option),
- forceSlow: len(chain.projection.Tags)
> 0,
- }
- }
- }
- }
- newPart, dropped, err := tst.mergeParts(tst.fileSystem, closeCh, parts,
newPartID, tst.root, filter)
+ var finalizeGenOverride *uint64
+ if ov != nil {
+ filter = ov.filter
+ finalizeGenOverride = ov.finalizeGen
+ }
+ // Hot / flusher path (no pre-built filter): build the in-merge
retention filter from
+ // the merge-time rules. The finalize path supplies its own filter via
ov, so this
+ // hot build is skipped there.
+ if filter == nil {
+ filter = tst.buildHotMergeFilter(parts)
+ }
+ newPart, dropped, err := tst.mergeParts(tst.fileSystem, closeCh, parts,
newPartID, tst.root, filter, finalizeGenOverride)
if err != nil {
return nil, err
}
@@ -547,7 +607,7 @@ func (tst *tsTable) removeSidxPartOnFailure(sidxName
string, partID uint64) {
}
func (tst *tsTable) mergeParts(fileSystem fs.FileSystem, closeCh <-chan
struct{}, parts []*partWrapper, partID uint64, root string,
- filter *mergeFilter,
+ filter *mergeFilter, finalizeGenOverride *uint64,
) (*partWrapper, map[string]struct{}, error) {
if len(parts) == 0 {
return nil, nil, errNoPartToMerge
@@ -600,6 +660,23 @@ func (tst *tsTable) mergeParts(fileSystem fs.FileSystem,
closeCh <-chan struct{}
tt.mustWriteTagType(fileSystem, dstPath)
pm.MinTimestamp = minTimestamp
pm.MaxTimestamp = maxTimestamp
+ // Finalization-sampling generation stamp, applied BEFORE the on-disk
metadata write
+ // (and the subsequent re-open below) so it survives restart.
finalizeGenOverride set
+ // => this is a finalize round: stamp the output at the round's
generation. nil => any
+ // other merge (hot/flusher): min-propagate from inputs so a merge is
"finalized" only
+ // as much as its least-finalized input — merging two G-stamped parts
stays G (never
+ // un-finalizes), merging a G part with an unstamped late part yields 0
(selectable).
+ if finalizeGenOverride != nil {
+ pm.FinalizeGen = *finalizeGenOverride
+ } else {
+ minGen := parts[0].p.partMetadata.FinalizeGen
+ for _, pw := range parts[1:] {
+ if g := pw.p.partMetadata.FinalizeGen; g < minGen {
+ minGen = g
+ }
+ }
+ pm.FinalizeGen = minGen
+ }
pm.mustWriteMetadata(fileSystem, dstPath)
// No SyncPath here: each mustWrite* helper goes through
fileSystem.WriteAtomic
// which already fsyncs the parent directory after rename. The last
atomic
diff --git a/banyand/trace/merger_durability_test.go
b/banyand/trace/merger_durability_test.go
index 27dcd9dd2..9f13fc044 100644
--- a/banyand/trace/merger_durability_test.go
+++ b/banyand/trace/merger_durability_test.go
@@ -173,7 +173,7 @@ func Test_merger_tornSpansBin_stillPanics(t *testing.T) {
mergedPart, _, mergeErr := tst.mergeParts(fileSystem, closeCh,
[]*partWrapper{
newPartWrapper(nil, p1),
newPartWrapper(nil, p2),
- }, 99, tmpPath, nil)
+ }, 99, tmpPath, nil, nil)
_, _ = mergedPart, mergeErr
t.Fatal("mergeParts returned without panicking — torn-part fail-fast
contract is broken")
diff --git a/banyand/trace/merger_test.go b/banyand/trace/merger_test.go
index af236146f..0d50b2c81 100644
--- a/banyand/trace/merger_test.go
+++ b/banyand/trace/merger_test.go
@@ -966,7 +966,7 @@ func Test_multipleRoundMerges(t *testing.T) {
// Merge all parts for this round
closeCh := make(chan struct{})
- mergedPart, _, err :=
tst.mergeParts(fileSystem, closeCh, partsToMerge, partID, tmpPath, nil)
+ mergedPart, _, err :=
tst.mergeParts(fileSystem, closeCh, partsToMerge, partID, tmpPath, nil, nil)
close(closeCh)
require.NoError(t, err, "Round %d merge
failed", roundIdx+1)
require.NotNil(t, mergedPart, "Round %d
produced nil merged part", roundIdx+1)
@@ -1239,7 +1239,7 @@ func Test_mergeParts(t *testing.T) {
closeCh := make(chan struct{})
defer close(closeCh)
tst := &tsTable{pm: protector.Nop{}}
- p, _, err := tst.mergeParts(fileSystem,
closeCh, pp, partID, root, nil)
+ p, _, err := tst.mergeParts(fileSystem,
closeCh, pp, partID, root, nil, nil)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Unexpected error: got
%v, want %v", err, tt.wantErr)
@@ -1392,7 +1392,7 @@ func
Test_mergePartsThenSendIntroduction_cleansUpOnSidxMergeError(t *testing.T)
merges := make(chan *mergerIntroduction, 1)
closeCh := make(chan struct{})
- _, err := tst.mergePartsThenSendIntroduction(snapshotCreatorMerger,
parts, merged, merges, closeCh, mergeTypeFile, mergeLaneFast)
+ _, err := tst.mergePartsThenSendIntroduction(snapshotCreatorMerger,
parts, merged, merges, closeCh, mergeTypeFile, mergeLaneFast, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "sidx merge failed")
diff --git a/banyand/trace/metadata.go b/banyand/trace/metadata.go
index 8a5fa1a20..efe68da17 100644
--- a/banyand/trace/metadata.go
+++ b/banyand/trace/metadata.go
@@ -74,6 +74,7 @@ type schemaRepo struct {
path string
nodeID string
trustedPluginDir string
+ finalizeGraceDefault time.Duration
role databasev1.Role
nativePipelineEnabled bool
}
@@ -88,6 +89,7 @@ func newSchemaRepo(path string, svc *standalone, nodeLabels
map[string]string, n
role: databasev1.Role_ROLE_DATA,
nativePipelineEnabled: svc.option.nativePipelineEnabled,
trustedPluginDir: svc.option.trustedPluginDir,
+ finalizeGraceDefault: svc.option.finalizeGraceDefault,
samplerMeter: newSamplerMetrics(pipelineFactory),
pluginTelemetryFactory: pipelineFactory,
Repository: resourceSchema.NewRepository(
@@ -253,6 +255,9 @@ func (sr *schemaRepo) OnDelete(metadata schema.Metadata) {
removeSamplersForGroup(g.Metadata.Name)
teardownGroupTelemetry(g.Metadata.Name)
setMergeGraceForGroup(g.Metadata.Name, 0)
+ setMergeEventForGroup(g.Metadata.Name, false)
+ setFinalizeGraceForGroup(g.Metadata.Name, 0)
+ setFinalizeConfigForGroup(g.Metadata.Name, nil)
sr.samplerMeter.setActiveCount(g.Metadata.Name, 0)
sr.samplerMeter.incRemoveTotal(g.Metadata.Name)
}
@@ -298,6 +303,14 @@ func mergeEventEnabled(cfg *commonv1.TracePipelineConfig)
bool {
return slices.Contains(events,
commonv1.PipelineEvent_PIPELINE_EVENT_MERGE)
}
+// finalizeEventEnabled reports whether cfg applies to the scheduled
finalization
+// pass. Unlike MERGE, an empty enabled_events list does NOT default finalize
on:
+// finalization is an extra background procedure and must be opted into
explicitly
+// (empty list keeps only the backward-compatible MERGE default).
+func finalizeEventEnabled(cfg *commonv1.TracePipelineConfig) bool {
+ return slices.Contains(cfg.GetEnabledEvents(),
commonv1.PipelineEvent_PIPELINE_EVENT_FINALIZE)
+}
+
// samplerLoadFailReason maps a plugin load error to a small, stable set of
reason
// codes for the sampler_load_failed metric label, keeping label cardinality
bounded
// (the full error is logged separately). Unrecognized errors fall back to
"load_error".
@@ -334,14 +347,19 @@ func samplerLoadFailReason(err error) string {
// merge_grace. If any plugin fails to load, the previous good set is kept
intact
// (fail-open) and an ERROR log is emitted.
func (sr *schemaRepo) reconcilePipeline(group string, cfg
*commonv1.TracePipelineConfig) {
- // A nil/disabled config, or one that does not enable the merge event,
clears the
- // group's samplers (retain all). enabled_events is honored here so
merge filtering
- // can be disabled without removing the config.
- if cfg == nil || !cfg.GetEnabled() || !mergeEventEnabled(cfg) {
+ // A nil/disabled config, or one that enables NEITHER the merge nor the
finalize
+ // event, clears the group's samplers (retain all). The sampler set is
shared by
+ // both events (DD11: finalize reuses the group's registered samplers),
so it is
+ // cleared only when both are off. enabled_events is honored here so
filtering can
+ // be disabled without removing the config.
+ if cfg == nil || !cfg.GetEnabled() || (!mergeEventEnabled(cfg) &&
!finalizeEventEnabled(cfg)) {
hadSamplers := len(lookupSamplers(group)) > 0
removeSamplersForGroup(group)
teardownGroupTelemetry(group)
setMergeGraceForGroup(group, 0)
+ setMergeEventForGroup(group, false)
+ setFinalizeGraceForGroup(group, 0)
+ setFinalizeConfigForGroup(group, nil)
sr.samplerMeter.setActiveCount(group, 0)
if hadSamplers {
sr.samplerMeter.incRemoveTotal(group)
@@ -449,6 +467,23 @@ func (sr *schemaRepo) reconcilePipeline(group string, cfg
*commonv1.TracePipelin
graceNs = gd.AsDuration().Nanoseconds()
}
setMergeGraceForGroup(group, graceNs)
+ setMergeEventForGroup(group, mergeEventEnabled(cfg))
+ // Store finalize_grace + threshold config ONLY when the FINALIZE event
is enabled;
+ // a finalize config entry is what marks a group for the background
finalize scanner
+ // (a merge-only group must not be scanned). The proto carries no
threshold-override
+ // fields in v1, so the config registry holds defaults (filled by
lookupFinalizeConfig).
+ // finalize_grace falls back to option.finalizeGraceDefault at lookup
time when unset.
+ if finalizeEventEnabled(cfg) {
+ var finalizeGraceNs int64
+ if fg := cfg.GetFinalizeGrace(); fg != nil {
+ finalizeGraceNs = fg.AsDuration().Nanoseconds()
+ }
+ setFinalizeGraceForGroup(group, finalizeGraceNs)
+ setFinalizeConfigForGroup(group, &finalizeConfig{})
+ } else {
+ setFinalizeGraceForGroup(group, 0)
+ setFinalizeConfigForGroup(group, nil)
+ }
sr.samplerMeter.setActiveCount(group, len(newSet))
if isUpdate {
sr.samplerMeter.incUpdateTotal(group, "success")
diff --git a/banyand/trace/part_metadata.go b/banyand/trace/part_metadata.go
index 62468b444..97ec09bdc 100644
--- a/banyand/trace/part_metadata.go
+++ b/banyand/trace/part_metadata.go
@@ -42,6 +42,11 @@ type partMetadata struct {
MinTimestamp int64 `json:"minTimestamp"`
MaxTimestamp int64 `json:"maxTimestamp"`
ID uint64 `json:"-"`
+ // FinalizeGen is the finalization-sampling generation stamp: 0/absent
means the
+ // part was never touched by a finalize round. On merge it is
min-propagated from
+ // the inputs; a finalize round overrides it to the round's generation.
Unlike ID
+ // it carries a real JSON tag so it survives on disk (see
mergeParts/finalizeGenOverride).
+ FinalizeGen uint64 `json:"finalizeGen,omitempty"`
}
func (pm *partMetadata) reset() {
@@ -52,6 +57,7 @@ func (pm *partMetadata) reset() {
pm.MinTimestamp = 0
pm.MaxTimestamp = 0
pm.ID = 0
+ pm.FinalizeGen = 0
}
func (pm *partMetadata) fillFromSyncContext(ctx *queue.ChunkedSyncPartContext)
{
diff --git a/banyand/trace/pipeline_finalize_test.go
b/banyand/trace/pipeline_finalize_test.go
new file mode 100644
index 000000000..b55a33e7a
--- /dev/null
+++ b/banyand/trace/pipeline_finalize_test.go
@@ -0,0 +1,189 @@
+// Licensed to Apache Software Foundation (ASF) under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Apache Software Foundation (ASF) licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package trace
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/types/known/durationpb"
+
+ commonv1
"github.com/apache/skywalking-banyandb/api/proto/banyandb/common/v1"
+)
+
+// TestBuildHotMergeFilter_MergeEventGate verifies that the hot merge filter
is applied
+// only when the MERGE event is enabled for the group — even though the
sampler set is
+// shared with FINALIZE (DD11). A FINALIZE-only group (samplers registered,
MERGE event
+// off) must NOT filter hot merges.
+func TestBuildHotMergeFilter_MergeEventGate(t *testing.T) {
+ resetRegistries()
+ defer resetRegistries()
+
+ const group = "merge-gate-group"
+ replaceSamplersForGroup(group, []namedSampler{{name: "s", sampler:
&dummySampler{}}})
+ tst := &tsTable{group: group, option: option{nativePipelineEnabled:
true, mergeGraceDefault: time.Second}}
+ // An ancient (non-hot) part so hotness never masks the gate.
+ parts := []*partWrapper{{p: &part{partMetadata:
partMetadata{MaxTimestamp: 1, TotalCount: 1}}}}
+
+ // FINALIZE-only: samplers registered, MERGE event disabled → no
hot-merge filter.
+ setMergeEventForGroup(group, false)
+ assert.Nil(t, tst.buildHotMergeFilter(parts), "FINALIZE-only group must
not filter hot merges")
+
+ // MERGE enabled → the filter is built.
+ setMergeEventForGroup(group, true)
+ assert.NotNil(t, tst.buildHotMergeFilter(parts), "MERGE-enabled group
must filter hot merges")
+}
+
+// TestFinalizeEventEnabled_Matrix verifies the finalize event gate. Unlike
MERGE,
+// an empty enabled_events list must NOT default finalize on.
+func TestFinalizeEventEnabled_Matrix(t *testing.T) {
+ tests := []struct {
+ name string
+ events []commonv1.PipelineEvent
+ want bool
+ }{
+ {"empty defaults off", nil, false},
+ {"merge only",
[]commonv1.PipelineEvent{commonv1.PipelineEvent_PIPELINE_EVENT_MERGE}, false},
+ {"finalize only",
[]commonv1.PipelineEvent{commonv1.PipelineEvent_PIPELINE_EVENT_FINALIZE}, true},
+ {"both",
[]commonv1.PipelineEvent{commonv1.PipelineEvent_PIPELINE_EVENT_MERGE,
commonv1.PipelineEvent_PIPELINE_EVENT_FINALIZE}, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &commonv1.TracePipelineConfig{EnabledEvents:
tt.events}
+ assert.Equal(t, tt.want, finalizeEventEnabled(cfg))
+ })
+ }
+ // mergeEventEnabled semantics unchanged: empty defaults on.
+ assert.True(t, mergeEventEnabled(&commonv1.TracePipelineConfig{}),
"empty events must default MERGE on")
+}
+
+// TestSetFinalizeGrace_RoundTripAndDeleteOnZero mirrors the merge_grace
registry
+// contract: a positive value round-trips, zero deletes.
+func TestSetFinalizeGrace_RoundTripAndDeleteOnZero(t *testing.T) {
+ resetRegistries()
+ defer resetRegistries()
+
+ const group = "fg-group"
+ assert.Zero(t, lookupFinalizeGrace(group), "unset group must return 0")
+
+ setFinalizeGraceForGroup(group, int64(7*time.Minute))
+ assert.Equal(t, int64(7*time.Minute), lookupFinalizeGrace(group))
+
+ setFinalizeGraceForGroup(group, 0)
+ assert.Zero(t, lookupFinalizeGrace(group), "zero must delete the entry")
+}
+
+// TestFinalizeConfig_Defaults verifies lookupFinalizeConfig fills unset
fields with
+// package defaults and that the cooldown falls back to the supplied
finalize_grace.
+func TestFinalizeConfig_Defaults(t *testing.T) {
+ resetRegistries()
+ defer resetRegistries()
+
+ const group = "fc-group"
+ const graceNs = int64(5 * time.Minute)
+
+ // Unset: all defaults; cooldown == grace.
+ got := lookupFinalizeConfig(group, graceNs)
+ assert.Equal(t, finalizeFloorBytesDefault, got.floorBytes)
+ assert.InDelta(t, finalizeRatioDefault, got.ratio, 1e-9)
+ assert.Equal(t, graceNs, got.cooldownNs, "unset cooldown must fall back
to finalize_grace")
+ assert.Equal(t, finalizeMaxRoundsDefault, got.maxRounds)
+
+ // Explicit overrides are respected.
+ setFinalizeConfigForGroup(group, &finalizeConfig{
+ floorBytes: 1 << 20,
+ ratio: 0.25,
+ cooldownNs: int64(90 * time.Second),
+ maxRounds: 3,
+ })
+ got = lookupFinalizeConfig(group, graceNs)
+ assert.Equal(t, uint64(1<<20), got.floorBytes)
+ assert.InDelta(t, 0.25, got.ratio, 1e-9)
+ assert.Equal(t, int64(90*time.Second), got.cooldownNs)
+ assert.Equal(t, 3, got.maxRounds)
+
+ // nil removes the override -> back to defaults.
+ setFinalizeConfigForGroup(group, nil)
+ got = lookupFinalizeConfig(group, graceNs)
+ assert.Equal(t, finalizeFloorBytesDefault, got.floorBytes)
+}
+
+// TestReconcilePipeline_FinalizeGrace verifies reconcilePipeline stores
finalize_grace
+// and a finalize config entry, and clears them on a nil config.
+func TestReconcilePipeline_FinalizeGrace(t *testing.T) {
+ requirePlugin(t)
+
+ resetRegistries()
+ defer resetRegistries()
+
+ const group = "finalize-grace-group"
+
+ sp, spErr := makeSamplerPlugin(pluginTestSoName, 100)
+ require.NoError(t, spErr)
+
+ const wantGrace = 8 * time.Minute
+ cfg := &commonv1.TracePipelineConfig{
+ Enabled: true,
+ Plugins: []*commonv1.Plugin{
+ {Name: "lss", Kind: &commonv1.Plugin_Sampler{Sampler:
sp}},
+ },
+ EnabledEvents:
[]commonv1.PipelineEvent{commonv1.PipelineEvent_PIPELINE_EVENT_FINALIZE},
+ FinalizeGrace: durationpb.New(wantGrace),
+ }
+
+ sr := makeDataSchemaRepo(pluginTestDir)
+ sr.reconcilePipeline(group, cfg)
+
+ assert.Equal(t, wantGrace.Nanoseconds(), lookupFinalizeGrace(group),
+ "lookupFinalizeGrace must return the configured value in
nanoseconds")
+
+ // After nil config, finalize grace must be cleared.
+ sr.reconcilePipeline(group, nil)
+ assert.Zero(t, lookupFinalizeGrace(group), "finalize grace must be 0
after nil config")
+}
+
+// TestReconcilePipeline_FinalizeOnlyRegistersSamplers verifies the DD11
behavior:
+// a FINALIZE-only config (no MERGE) with a valid plugin now REGISTERS the
group's
+// sampler set (finalize reuses the same samplers as merge).
+func TestReconcilePipeline_FinalizeOnlyRegistersSamplers(t *testing.T) {
+ requirePlugin(t)
+
+ resetRegistries()
+ defer resetRegistries()
+
+ const group = "finalize-only-registers"
+
+ sp, spErr := makeSamplerPlugin(pluginTestSoName, 100)
+ require.NoError(t, spErr)
+
+ cfg := &commonv1.TracePipelineConfig{
+ Enabled: true,
+ Plugins: []*commonv1.Plugin{
+ {Name: "lss", Kind: &commonv1.Plugin_Sampler{Sampler:
sp}},
+ },
+ EnabledEvents:
[]commonv1.PipelineEvent{commonv1.PipelineEvent_PIPELINE_EVENT_FINALIZE},
+ }
+
+ sr := makeDataSchemaRepo(pluginTestDir)
+ sr.reconcilePipeline(group, cfg)
+
+ require.NotEmpty(t, lookupSamplers(group),
+ "a FINALIZE-only config must register the shared sampler set
(DD11)")
+}
diff --git a/banyand/trace/pipeline_registry.go
b/banyand/trace/pipeline_registry.go
index eb2beb015..67dbaea49 100644
--- a/banyand/trace/pipeline_registry.go
+++ b/banyand/trace/pipeline_registry.go
@@ -49,6 +49,145 @@ func lookupMergeGrace(group string) int64 {
return localMergeGraceRegistry.m[group]
}
+// localMergeEventRegistry records, per group, whether the MERGE pipeline
event is
+// enabled. The sampler set is shared by MERGE and FINALIZE (DD11), so the
presence of
+// samplers alone does NOT mean hot merges should be filtered: a FINALIZE-only
group
+// registers samplers for the background pass but must not touch the hot merge
path.
+// buildHotMergeFilter consults this flag so enabled_events reliably gates
MERGE-time
+// filtering independently of FINALIZE.
+var localMergeEventRegistry = struct {
+ m map[string]bool
+ mu sync.RWMutex
+}{m: make(map[string]bool)}
+
+// setMergeEventForGroup records whether the MERGE event is enabled for group.
+// enabled == false removes the entry.
+func setMergeEventForGroup(group string, enabled bool) {
+ localMergeEventRegistry.mu.Lock()
+ if enabled {
+ localMergeEventRegistry.m[group] = true
+ } else {
+ delete(localMergeEventRegistry.m, group)
+ }
+ localMergeEventRegistry.mu.Unlock()
+}
+
+// mergeEventEnabledForGroup reports whether the MERGE-time retention filter
should run
+// for group (false when only FINALIZE is enabled, or nothing is configured).
+func mergeEventEnabledForGroup(group string) bool {
+ localMergeEventRegistry.mu.RLock()
+ defer localMergeEventRegistry.mu.RUnlock()
+ return localMergeEventRegistry.m[group]
+}
+
+// Finalization-sampling threshold defaults (see
.omc/plans/trace-finalize-sampling.md
+// DD2). They bound whether a cooled segment warrants another finalize round.
+const (
+ // finalizeFloorBytesDefault is the absolute minimum newly-arrived
uncompressed
+ // span bytes since the last round that justifies re-finalizing a
segment. Below
+ // this a trickle of late writes is left unsampled (an accepted miss).
+ finalizeFloorBytesDefault uint64 = 8 << 20 // 8 MiB
+ // finalizeRatioDefault gates large segments: re-finalize only when new
unsampled
+ // bytes reach this fraction of the segment's total, so a
proportionally-tiny
+ // addition to a big segment does not trigger a full rewrite.
+ finalizeRatioDefault = 0.10
+ // finalizeMaxRoundsDefault is the hard per-segment lifetime cap on
finalize
+ // rounds; after this the segment is marked terminal and never
re-scanned.
+ finalizeMaxRoundsDefault = 8
+)
+
+// localFinalizeGraceRegistry stores per-group finalize_grace in nanoseconds
as set
+// by reconcilePipeline. A zero value means "use option.finalizeGraceDefault".
It
+// mirrors localMergeGraceRegistry.
+var localFinalizeGraceRegistry = struct {
+ m map[string]int64
+ mu sync.RWMutex
+}{m: make(map[string]int64)}
+
+// setFinalizeGraceForGroup stores graceNs for group. graceNs == 0 removes the
entry.
+func setFinalizeGraceForGroup(group string, graceNs int64) {
+ localFinalizeGraceRegistry.mu.Lock()
+ if graceNs > 0 {
+ localFinalizeGraceRegistry.m[group] = graceNs
+ } else {
+ delete(localFinalizeGraceRegistry.m, group)
+ }
+ localFinalizeGraceRegistry.mu.Unlock()
+}
+
+// lookupFinalizeGrace returns the per-group finalize_grace in nanoseconds, or
0 if
+// none is configured (caller falls back to option.finalizeGraceDefault).
+func lookupFinalizeGrace(group string) int64 {
+ localFinalizeGraceRegistry.mu.RLock()
+ defer localFinalizeGraceRegistry.mu.RUnlock()
+ return localFinalizeGraceRegistry.m[group]
+}
+
+// finalizeConfig holds the per-group finalize threshold knobs. A zero value is
+// never stored directly; lookupFinalizeConfig fills unset fields with
defaults.
+type finalizeConfig struct {
+ floorBytes uint64 // absolute floor; 0 => finalizeFloorBytesDefault
+ ratio float64 // fraction of segment total; <=0 =>
finalizeRatioDefault
+ cooldownNs int64 // min ns between rounds; 0 => the group's
finalize_grace
+ maxRounds int // hard lifetime cap; <=0 => finalizeMaxRoundsDefault
+}
+
+// localFinalizeConfigRegistry stores per-group finalize threshold overrides.
The
+// proto carries no override fields today (v1), so reconcilePipeline stores
defaults;
+// tests may inject custom values via setFinalizeConfigForGroup.
+var localFinalizeConfigRegistry = struct {
+ m map[string]finalizeConfig
+ mu sync.RWMutex
+}{m: make(map[string]finalizeConfig)}
+
+// listFinalizeGroups returns the groups that have finalization sampling
enabled (a
+// finalize config entry exists). The background finalize scanner iterates
these.
+func listFinalizeGroups() []string {
+ localFinalizeConfigRegistry.mu.RLock()
+ defer localFinalizeConfigRegistry.mu.RUnlock()
+ if len(localFinalizeConfigRegistry.m) == 0 {
+ return nil
+ }
+ groups := make([]string, 0, len(localFinalizeConfigRegistry.m))
+ for group := range localFinalizeConfigRegistry.m {
+ groups = append(groups, group)
+ }
+ return groups
+}
+
+// setFinalizeConfigForGroup stores cfg for group. A nil cfg removes the entry.
+func setFinalizeConfigForGroup(group string, cfg *finalizeConfig) {
+ localFinalizeConfigRegistry.mu.Lock()
+ if cfg != nil {
+ localFinalizeConfigRegistry.m[group] = *cfg
+ } else {
+ delete(localFinalizeConfigRegistry.m, group)
+ }
+ localFinalizeConfigRegistry.mu.Unlock()
+}
+
+// lookupFinalizeConfig returns the group's finalize threshold config with any
unset
+// field filled from the package defaults. graceNs is the resolved
finalize_grace for
+// the group and is used as the cooldown when no explicit cooldown is
configured.
+func lookupFinalizeConfig(group string, graceNs int64) finalizeConfig {
+ localFinalizeConfigRegistry.mu.RLock()
+ cfg := localFinalizeConfigRegistry.m[group]
+ localFinalizeConfigRegistry.mu.RUnlock()
+ if cfg.floorBytes == 0 {
+ cfg.floorBytes = finalizeFloorBytesDefault
+ }
+ if cfg.ratio <= 0 {
+ cfg.ratio = finalizeRatioDefault
+ }
+ if cfg.cooldownNs <= 0 {
+ cfg.cooldownNs = graceNs
+ }
+ if cfg.maxRounds <= 0 {
+ cfg.maxRounds = finalizeMaxRoundsDefault
+ }
+ return cfg
+}
+
// namedSampler pairs a sampler instance with its stable identity. The
identity is the
// plugin name plus everything that selects which code runs: the config hash
AND the
// plugin reference (path, symbol, ABI version). reconcilePipeline compares
this identity
@@ -100,6 +239,10 @@ func registerSampler(group string, s sdk.Sampler) func() {
localSamplerRegistry.mu.Lock()
localSamplerRegistry.m[group] = append(localSamplerRegistry.m[group],
namedSampler{sampler: s})
localSamplerRegistry.mu.Unlock()
+ // Direct registration models an active MERGE-filter sampler (the
production reconcile
+ // path sets this from mergeEventEnabled); without it
buildHotMergeFilter would treat
+ // the group as MERGE-disabled and skip filtering.
+ setMergeEventForGroup(group, true)
return func() {
localSamplerRegistry.mu.Lock()
defer localSamplerRegistry.mu.Unlock()
@@ -112,6 +255,7 @@ func registerSampler(group string, s sdk.Sampler) func() {
}
if len(localSamplerRegistry.m[group]) == 0 {
delete(localSamplerRegistry.m, group)
+ setMergeEventForGroup(group, false)
}
}
}
diff --git a/banyand/trace/pipeline_watch_test.go
b/banyand/trace/pipeline_watch_test.go
index b6ff5a7b8..6e5910d6f 100644
--- a/banyand/trace/pipeline_watch_test.go
+++ b/banyand/trace/pipeline_watch_test.go
@@ -47,6 +47,18 @@ func resetRegistries() {
localMergeGraceRegistry.m = make(map[string]int64)
localMergeGraceRegistry.mu.Unlock()
+ localMergeEventRegistry.mu.Lock()
+ localMergeEventRegistry.m = make(map[string]bool)
+ localMergeEventRegistry.mu.Unlock()
+
+ localFinalizeGraceRegistry.mu.Lock()
+ localFinalizeGraceRegistry.m = make(map[string]int64)
+ localFinalizeGraceRegistry.mu.Unlock()
+
+ localFinalizeConfigRegistry.mu.Lock()
+ localFinalizeConfigRegistry.m = make(map[string]finalizeConfig)
+ localFinalizeConfigRegistry.mu.Unlock()
+
pluginTelemetryRegistry.mu.Lock()
pluginTelemetryRegistry.m = make(map[string][]*pluginTelemetry)
pluginTelemetryRegistry.mu.Unlock()
@@ -264,10 +276,11 @@ func TestReconcilePipeline_DisabledConfig(t *testing.T) {
assert.Empty(t, lookupSamplers(group), "disabled config must clear the
registry")
}
-// TestReconcilePipeline_MergeEventNotEnabled verifies that an enabled config
whose
-// enabled_events does not include PIPELINE_EVENT_MERGE installs no merge
samplers
-// (v1 only implements the merge-time filter), clearing any previous set.
-func TestReconcilePipeline_MergeEventNotEnabled(t *testing.T) {
+// TestReconcilePipeline_NoEventOrPluginsClears verifies that an enabled
config with
+// no plugins clears any previous sampler set. (A FINALIZE-only config now
shares the
+// group's sampler set with MERGE per DD11, so the previous set is torn down
and, with
+// no plugins to load, the group ends up empty.)
+func TestReconcilePipeline_NoEventOrPluginsClears(t *testing.T) {
resetRegistries()
defer resetRegistries()
@@ -283,7 +296,7 @@ func TestReconcilePipeline_MergeEventNotEnabled(t
*testing.T) {
EnabledEvents:
[]commonv1.PipelineEvent{commonv1.PipelineEvent_PIPELINE_EVENT_FINALIZE},
})
- assert.Empty(t, lookupSamplers(group), "config without MERGE event must
install no merge samplers")
+ assert.Empty(t, lookupSamplers(group), "a config with no plugins must
install no samplers")
}
// TestReconcilePipeline_OnDeleteKindGroup verifies the OnDelete KindGroup
diff --git a/banyand/trace/svc_standalone.go b/banyand/trace/svc_standalone.go
index 73848710f..b76a7f2b3 100644
--- a/banyand/trace/svc_standalone.go
+++ b/banyand/trace/svc_standalone.go
@@ -68,6 +68,7 @@ type standalone struct {
metadata metadata.Repo
l *logger.Logger
diskMonitor *storage.DiskMonitor
+ finalizeCloser *run.Closer
schemaRepo schemaRepo
snapshotDir string
root string
@@ -103,6 +104,8 @@ func (s *standalone) FlagSet() *run.FlagSet {
fs.BoolVar(&s.option.nativePipelineEnabled,
"trace-pipeline-native-plugin-enabled", false, "enable the native plugin
pipeline for in-merge trace retention")
fs.StringVar(&s.option.trustedPluginDir,
"trace-pipeline-trusted-plugin-dir", "", "trusted directory for native trace
pipeline plugins")
fs.DurationVar(&s.option.mergeGraceDefault,
"trace-pipeline-merge-grace-default", 30*time.Second, "default merge_grace for
in-merge trace retention filter")
+ fs.DurationVar(&s.option.finalizeGraceDefault,
"trace-pipeline-finalize-grace-default", 5*time.Minute,
+ "default finalize_grace (per-segment settling window) for
finalization sampling")
fs.DurationVar(&s.option.decideTimeout,
"trace-pipeline-decide-timeout", 5*time.Second, "hard per-batch Decide timeout
for trace pipeline plugins")
fs.IntVar(&s.option.decideTimeoutCircuitBreak,
"trace-pipeline-decide-timeout-circuit-break", 3,
"consecutive-timeout threshold to disable a (group,schema)
pipeline chain")
@@ -218,6 +221,21 @@ func (s *standalone) PreRun(ctx context.Context) error {
if err != nil {
return err
}
+ // Start the background finalization-sampling scanner when the native
pipeline is
+ // enabled. It is a single node-wide goroutine (concurrency-1) that
sweeps cooled
+ // segments of finalize-enabled groups; it is inert until a group
enables the
+ // FINALIZE event, so starting it unconditionally-under-the-flag is
safe.
+ if s.option.nativePipelineEnabled {
+ s.finalizeCloser = run.NewCloser(1)
+ // Launch via run.Go (not a bare `go`) so a panic in the scan
loop is recovered
+ // and reported instead of crashing the process; the loop's
deferred closer.Done
+ // still fires during panic unwinding. It uses the run.Closer
for cancellation,
+ // hence the contextcheck suppression (mirrors the tsTable
background loops).
+ run.Go(context.Background(), "trace.finalize-scanner", s.l,
func(_ context.Context) { //nolint:contextcheck
+ s.schemaRepo.finalizeScanLoop(s.finalizeCloser,
defaultFinalizeScanInterval) //nolint:contextcheck
+ })
+ }
+
s.l.Info().
Str("root", s.root).
Str("dataPath", s.dataPath).
@@ -232,6 +250,11 @@ func (s *standalone) Serve() run.StopNotify {
}
func (s *standalone) GracefulStop() {
+ // Stop the finalize scanner before closing the schema repo so no round
starts
+ // against a segment whose tables are being torn down.
+ if s.finalizeCloser != nil {
+ s.finalizeCloser.CloseThenWait()
+ }
// Stop disk monitor
if s.diskMonitor != nil {
s.diskMonitor.Stop()
diff --git a/banyand/trace/trace.go b/banyand/trace/trace.go
index 4c770dac2..3f9dc4cdc 100644
--- a/banyand/trace/trace.go
+++ b/banyand/trace/trace.go
@@ -61,6 +61,7 @@ type option struct {
syncInterval time.Duration
memWaitTimeout time.Duration
mergeGraceDefault time.Duration
+ finalizeGraceDefault time.Duration
decideTimeout time.Duration
failedPartsMaxTotalSizeBytes uint64
vectorized vtrace.VectorizedConfig
diff --git a/banyand/trace/tstable.go b/banyand/trace/tstable.go
index acd85d86b..29df045c4 100644
--- a/banyand/trace/tstable.go
+++ b/banyand/trace/tstable.go
@@ -49,17 +49,21 @@ const (
)
type tsTable struct {
- fileSystem fs.FileSystem
- pm protector.Memory
- inFlight map[uint64]struct{}
- handoffCtrl *handoffController
- metrics *metrics
- snapshot *snapshot
- loopCloser *run.Closer
- getNodes func() []string
- l *logger.Logger
- sidxMap map[string]sidx.SIDX
- introductions chan *introduction
+ fileSystem fs.FileSystem
+ pm protector.Memory
+ inFlight map[uint64]struct{}
+ handoffCtrl *handoffController
+ metrics *metrics
+ snapshot *snapshot
+ loopCloser *run.Closer
+ getNodes func() []string
+ l *logger.Logger
+ sidxMap map[string]sidx.SIDX
+ introductions chan *introduction
+ // mergeCh is the introducer loop's merged-introduction channel,
retained so the
+ // finalize worker (an external goroutine) can introduce its
force-merged part
+ // through the same serialized introducer loop the hot merges use.
+ mergeCh chan *mergerIntroduction
p common.Position
group string
root string
@@ -67,7 +71,15 @@ type tsTable struct {
option option
curPartID uint64
pendingDataCount atomic.Int64
- inFlightMu sync.RWMutex
+ // finalizeGenCached mirrors the shard's persisted
finalizeState.FinalizeGeneration.
+ // Seeded at open, bumped by each finalize round. Read O(1) on the hot
introduction
+ // path to decide whether a newly-flushed part is new unsampled data.
+ finalizeGenCached atomic.Uint64
+ // unsampledBytes mirrors finalizeState.UnsampledBytes: uncompressed
span bytes of
+ // parts that arrived after this shard was first finalized. Incremented
on the hot
+ // introduction path (atomic add only), reset by a successful finalize
round.
+ unsampledBytes atomic.Int64
+ inFlightMu sync.RWMutex
sync.RWMutex
shardID common.ShardID
isHot bool
@@ -127,6 +139,7 @@ func (tst *tsTable) startLoop(cur uint64) {
tst.introductions = make(chan *introduction)
flushCh := make(chan *flusherIntroduction)
mergeCh := make(chan *mergerIntroduction)
+ tst.mergeCh = mergeCh
introducerWatcher := make(watcher.Channel, 1)
flusherWatcher := make(watcher.Channel, 1)
// Each loop already calls tst.loopCloser.Done via defer, so a panic
@@ -153,6 +166,7 @@ func (tst *tsTable) startLoopWithConditionalMerge(cur
uint64) {
flushCh := make(chan *flusherIntroduction)
syncCh := make(chan *syncIntroduction)
mergeCh := make(chan *mergerIntroduction)
+ tst.mergeCh = mergeCh
introducerWatcher := make(watcher.Channel, 1)
flusherWatcher := make(watcher.Channel, 1)
// See startLoop for the rationale on routing through run.Go and the
@@ -237,6 +251,12 @@ func initTSTable(fileSystem fs.FileSystem, rootPath
string, p common.Position,
if m != nil {
tst.metrics = m.(*metrics)
}
+ // Seed the cached finalize state from the shard's persisted
finalizeState so the
+ // hot introduction path and the finalize scanner see the on-disk
generation and
+ // unsampled-bytes counter after a restart (a missing file yields zero).
+ fst := readFinalizeState(fileSystem, rootPath)
+ tst.finalizeGenCached.Store(fst.FinalizeGeneration)
+ tst.unsampledBytes.Store(fst.UnsampledBytes)
tst.gc.init(&tst)
ee := fileSystem.ReadDir(rootPath)
if len(ee) == 0 {
diff --git a/docs/design/post-trace-pipeline.md
b/docs/design/post-trace-pipeline.md
index 999d1735b..7b134ef3d 100644
--- a/docs/design/post-trace-pipeline.md
+++ b/docs/design/post-trace-pipeline.md
@@ -657,6 +657,55 @@ flowchart TD
B4 --> C5
```
+## Implementation: finalization sampling (PIPELINE_EVENT_FINALIZE, v1)
+
+Finalization sampling is the background backstop for the in-merge filter. The
in-merge
+sampler (`PIPELINE_EVENT_MERGE`) only drops traces during opportunistic merges
of parts
+older than `merge_grace`; when hot writing rotates to the next segment, the
just-cooled
+segment can be left with parts the in-merge sampler never revisited. The
finalize pass
+sweeps those cooled parts through the **same** registered sampler chain.
+
+**Semantics — best-effort, misses accepted.** Coverage is
best-effort-when-resources-allow,
+**not guaranteed**. The pass yields under memory pressure and never blocks the
hot
+write/merge/query path, so a segment can be born → cool → never-finalized →
TTL-deleted
+un-sampled. That is an accepted outcome (there is no finalize-before-delete
gate).
+
+**Model (per-shard, trace-owned scanner).**
+- A single node-wide scanner goroutine ticks (every
`defaultFinalizeScanInterval`, 10m)
+ and, for each group with the FINALIZE event enabled, selects cooled segments
via
+ `tsdb.SelectSegments(range, reopenClosed=true)` — reopening idle-closed
segments so the
+ common cooled-and-reclaimed case is still covered — then evaluates each
shard.
+- Finalize compute is **serialized (concurrency-1)** and reuses the hot merge
path
+ (`mergePartsThenSendIntroduction` → `mergeParts` + per-sidx `Merge` + the
shard's
+ introducer loop), so core and sidx follow exactly the code a hot merge uses.
It never
+ acquires the hot merge semaphore (`mergeMaxConcurrencyCh`).
+- Each cooled part carries a per-part `finalizeGen` stamp; a round
force-merges the
+ cooled, non-hot parts through the sampler and stamps the output at the
shard's next
+ generation, written to disk **before** the part's metadata is persisted so a
crash
+ cannot cause a double-sample on replay. A round re-samples the cooled set
(like the
+ in-merge filter re-samples on every merge); re-sampling already-kept
survivors through
+ a deterministic sampler is idempotent, and rounds are hard-capped.
+- Per-shard finalize state (`finalize.json`) records the generation, cooldown
clock,
+ round count, terminal flag, and an arrival-based unsampled-bytes counter
(incremented
+ O(1) on the flush path only once the shard has been finalized).
+
+**Knobs** (the proto carries `finalize_grace`; the rest are engine defaults in
v1):
+- `finalize_grace` (default **5m**) — per-segment settling window; a segment
is eligible
+ only when `segEnd < now − finalize_grace`, and parts hotter than this are
skipped.
+- `--trace-pipeline-finalize-grace-default` — node default when a group sets no
+ `finalize_grace`.
+- **FLOOR** (default **8 MiB** uncompressed) — absolute minimum newly-arrived
unsampled
+ bytes to warrant a re-round; smaller trickles are left (accepted miss).
+- **RATIO** (default **0.10**) — for large segments, re-round only when new
unsampled
+ bytes reach this fraction of the segment total.
+- **finalize_cooldown** (default = `finalize_grace`) — at most one round per
window.
+- **max_finalize_rounds** (default **8**) — hard per-shard lifetime cap; after
it the
+ shard is marked terminal and never re-scanned.
+
+**Observability.** Finalize rounds emit the existing merge metrics under
+`type="finalize"`/`lane="finalize"` (`total_merged`, `total_merge_latency`,
+`total_merged_parts`), distinct from the hot-merge labels.
+
## References
- OpenTelemetry Collector — Tail Sampling Processor (`tailsamplingprocessor`):
the keep-all-errors / latency / probabilistic-policy model a typical sampler
plugin implements.
diff --git a/implementation-fn-note.md b/implementation-fn-note.md
new file mode 100644
index 000000000..2d9dd6e69
--- /dev/null
+++ b/implementation-fn-note.md
@@ -0,0 +1,117 @@
+# Finalization Sampling — Implementation Notes (running log)
+
+Plan: `.omc/plans/trace-finalize-sampling.md` (Rev 7). Branch:
`trace-pipeline-finalize-event`.
+This file records decisions NOT in the spec, changes I had to make, tradeoffs,
and anything the user should know. Newest entries at the bottom of each section.
+
+## Ground-truth line numbers (verified against current tree, 2026-07-05)
+- `banyand/internal/storage/version.go`: `SegmentMetadata` struct `:52`;
`segmentMeta` `:74`; `readSegmentMeta` `:79`; `currentVersion = "1.5.0"` `:33`.
NOTE: there is NO create-literal in version.go; the create write lives in
segment.go (to be located).
+- `banyand/trace/part_metadata.go`: `partMetadata` struct `:37`; `reset()`
`:47`; `mustReadMetadata` `:79`; `mustWriteMetadata` `:98` (uses
`fileSystem.WriteAtomic` — already atomic). `fillFromSyncContext` `:57`
(chunked-sync path — must also carry finalizeGen? see decisions).
`ParsePartMetadata` `:262`.
+- `banyand/trace/pipeline_registry.go`: `localMergeGraceRegistry` +
`setMergeGraceForGroup`/`lookupMergeGrace` `:28-50` — mirror for finalize.
`lookupSamplers` `:140`.
+- `banyand/trace/merger.go`: `mergePartsThenSendIntroduction` `:318`; sampler
chain build `:326-342`; `isMergeHot` `:694`; sidx keepFn `:394-404`;
`getAllSidx` loop `:406`; `mergeParts` `:549`; pm min/max ts fill +
`pm.mustWriteMetadata` `:601-603`; re-open `:607`; `mergeBlocks` `:811`;
zero-value `var pm partMetadata` `:980`.
+- `banyand/trace/tstable.go`: `tsTable` struct `:51-74` (has `group`,
`option`, `pm protector.Memory`, `curPartID`, `loopCloser`, `shardID`, `root`,
`fileSystem`; does NOT cache segment EndTime/generation). `startLoop` `:125`
(`run.NewCloser(1 + 3)`, spawns introducer+flusher+merger).
`startLoopWithConditionalMerge` `:150` (`run.NewCloser(1 + 3)`, spawns
introducer-sync+flusher+syncer — **NO mergeLoop**).
+
+## Open implementation questions found while grounding (to resolve during impl)
+- **Q-A (conditional-merge path has no merger):**
`startLoopWithConditionalMerge` spawns introducer-sync/flusher/syncer but NO
`mergeLoop`. The plan (M2) says add the finalizer to BOTH start paths. Need to
confirm whether the sync-mode data node retains cooled segments locally long
enough to finalize, or whether parts are synced away (making finalize a no-op
there). Decision pending — will verify against the sync path before wiring the
finalizer into it. Safe default: still spawn it (it [...]
+- **Q-B (finalizeGen stamp home):** the plan says thread `finalizeGenOverride`
through BOTH `mergeParts` and `mergeBlocks`. But `mergeBlocks` has no access to
the input `partWrapper`s (only the block reader), so it cannot compute
`min(inputs)`. `mergeParts` DOES have `parts []*partWrapper` and already
post-processes `pm` (sets Min/MaxTimestamp) BEFORE `pm.mustWriteMetadata` at
`:603`. Cleaner: thread the override into `mergeParts` only, compute
min-propagation from `parts` there, set `pm [...]
+- **Q-C (fillFromSyncContext / chunked sync):**
`partMetadata.fillFromSyncContext` populates a part's metadata from a sync
context and does NOT set finalizeGen. A synced part would arrive with
finalizeGen=0 (unstamped=selectable), which is correct-by-default. No change
needed unless sync must preserve finalize state across nodes (out of scope —
finalize is per-local-segment). Recording as intentionally unchanged.
+
+## MAJOR ARCHITECTURE DEVIATION (user-approved 2026-07-05) — per-shard state +
trace-owned scanner
+**Why:** The plan put the finalize generation in the per-SEGMENT metadata file
(DD4a/M1) but the lane per-SHARD (`tsTable`, DD7). A segment has N shards
(verified: `segment[T,O].sLst []*shard[T]`, each `shard.table` is a tsTable
rooted at `<segment>/shard-N`, created via `TSTableCreator(..., s.TimeRange,
...)` — `shard.go:81`). So N per-shard lanes would multi-write ONE segment
metadata file, and idle-closed cooled segments (segments get idle-reclaimed)
have NO running tsTable loop, so a [...]
+**Resolution (user picked Option A):**
+- **Per-shard finalize state**, persisted as a small atomic JSON file in the
shard dir (`tst.root`), sole-written by the finalize worker. NOT in
`SegmentMetadata`. => **M1 (both segment metadata structs) is DROPPED**;
`version.go`/`segment.go` create-literal are UNTOUCHED. The segment's cooling
is read from the in-memory `Segment.GetTimeRange().End` (already available), no
persisted finalize field needed at the segment level.
+- **Trace-owned periodic scanner** using the PUBLIC
`tsdb.SelectSegments(coolRange, reopenClosed=true)` (`tsdb.go:277`) +
`segment.Tables()` to enumerate cooled segments' per-shard tsTables.
`reopenClosed=true` REOPENS idle-closed cooled segments (the key fact that
makes this cover the common case), holding an incRef for the finalize duration;
DecRef after. => **no storage/rotation.go hook, no storage→trace seam** (avoids
the layering coupling the user removed for the delete gate). => ** [...]
+- **Concurrency-1 finalize worker**: the scanner processes one shard finalize
task at a time (node-wide serialization), so the finalize compute never fans
out. The tsTable's finalize round reuses the tsTable's existing introducer loop
(a reopened segment has live loops) + `mergeParts(finalizeGenOverride=&G)`. =>
**M2 (4th run.Go loop in BOTH start paths) is DROPPED** — no per-tsTable
finalizer loop; the worker calls `tst.runFinalizeRound(...)` synchronously
while the segment is incRef-he [...]
+- **tsTable gains** `segEndUnixNano` (captured from the `timestamp.TimeRange`
ALREADY passed to `newTSTable` — currently ignored as `_`), plus cached
`finalizeGenCached atomic.Uint64` / `segCooledCached atomic.Bool` /
`unsampledBytes atomic.Int64` seeded from the per-shard finalize file at open.
+**UNCHANGED from plan:** per-part `finalizeGen` stamp + min-propagation via
`finalizeGenOverride` in `mergeParts` before `:603` (DD1.C1/DD6); force
filter-merge reuse + keepFn core+sidx coupling; fail-open; threshold
FLOOR/RATIO/cooldown/max_rounds (Phase 1, per-shard now); protector/disk
gating; sidx rides hot merge path (OQ6). Best-effort/misses-accepted (P2), no
delete gate.
+**PRD updated:** US-002/US-003/US-005 acceptance criteria re-scoped to
per-shard state + trace-owned scanner; M1/M2 references dropped.
+
+## Phase 2 (US-002) — DONE 2026-07-05
+- Per-part `finalizeGen uint64 json:"finalizeGen,omitempty"` added to
`partMetadata` (+ zeroed in `reset()`). Old-format parts load as 0 (verified).
+- New `finalize_state.go`: `finalizeState{LastFinalizedAt, FinalizeGeneration,
UnsampledBytes, FinalizeRounds, Terminal}` +
`readFinalizeState`/`writeFinalizeState` (atomic via `fs.WriteAtomic`
temp+rename). Missing/corrupt file => zero (fail-open, never panics). Stored
per-shard in `tst.root` as `finalize.json`.
+- `SegmentMetadata`/`segmentMeta`/segment create-literal UNTOUCHED (Rev 8). No
version bump.
+- 4 tests pass.
+
+## Phase 3 (US-003) — DONE 2026-07-05
+- `tsTable` gained `finalizeGenCached atomic.Uint64`, `unsampledBytes
atomic.Int64`, `segEndUnixNano int64`. Seeded in `initTSTable` from
`readFinalizeState`; `segEndUnixNano` captured in `newTSTable` from the
(previously-ignored `_`) `timestamp.TimeRange`.
+- `accountUnsampledFlushed(flushed)` helper (takes NO fs → zero metadata I/O
by construction): if `finalizeGenCached>0`, add sum of flushed parts'
`UncompressedSpanSizeBytes` to `unsampledBytes`. Called from `introduceFlushed`
AND `introduceFlushedForSync`. No-op until first finalize (gen 0).
+- 2 tests pass (accounting + seed-from-disk).
+
+## CRITICAL SEMANTICS DECISION — finalize generation is "full-rewrite per
round", NOT minimal-rewrite (plan DD1 inconsistency)
+The plan's DD1 claimed THREE things that are mutually inconsistent: (a)
monotonic per-round generation, (b) selection predicate `finalizeGen <
FinalizeGeneration`, (c) "minimal rewrite volume — only
not-yet-current-generation parts rewritten each round." With a monotonic gen
and a `< G` predicate, previously-finalized outputs (stamped at gen k) are
ALWAYS re-selected once stored advances past k, so true minimal-rewrite is
unachievable with this scheme (worked through several variants; al [...]
+**Chosen (correct + crash-safe):** a round with current stored gen G computes
`Gnext = G+1`; selects all non-hot / not-in-flight parts with `finalizeGen <
Gnext` (which, by the invariant that nothing is stamped above stored, is
effectively ALL cooled parts of the shard); force filter-merges them through
the sampler; stamps outputs `finalizeGen = Gnext` (via `finalizeGenOverride`);
introduces; then persists stored `= Gnext` + resets the unsampled counter. So
**each round re-finalizes the [...]
+- **Why it's safe:** re-sampling already-sampled survivors through a
DETERMINISTIC sampler is idempotent (DD11 soft expectation); a probabilistic
sampler drops a bounded extra amount, never unsafe. Rounds are hard-capped at
`max_finalize_rounds` (8), so ≤8 full rewrites per segment lifetime.
+- **Crash-safety (DD6 preserved):** crash after introduce, before persisting
stored (stored still G, outputs on disk stamped Gnext=G+1): on replay stored=G
→ next round Gnext=G+1 → predicate `finalizeGen < G+1` excludes the
Gnext-stamped outputs (G+1 < G+1 = false); the merged-away inputs are already
gone from the snapshot → no double-sample. Matches DD6 crash-window-2 exactly.
+- **DD1.C1 min-propagation (hot merge inside finalized segment) UNCHANGED and
still needed:** a hot merge with `finalizeGenOverride=nil` sets output `=
min(inputs)`, so merging two gen-Gnext parts stays gen-Gnext (not reset to 0)
and a hot-merge never un-finalizes.
+- **Deviation recorded:** DD1's "minimal rewrite" bullet is relaxed to
"full-rewrite-per-round, bounded + idempotent." If true minimal-rewrite is
later required, it needs a more complex per-part epoch scheme (deferred).
+
+## Phase 4 (US-004) — DONE 2026-07-05
+- `mergeParts` gained `finalizeGenOverride *uint64` (7th param); applied to
`pm.FinalizeGen` (override or min-propagate) BEFORE `pm.mustWriteMetadata`
(:603). **Deviation from plan wording:** the stamp is threaded through
`mergeParts` ONLY (not `mergeBlocks`), because `mergeBlocks` has no access to
the input `partWrapper`s for min-propagation and `mergeParts` already
post-processes `pm` before the on-disk write. Same invariant (on-disk before
re-open).
+- `mergePartsThenSendIntroduction` gained an `ov *mergeOverrides{filter,
finalizeGen}` param so the finalize round reuses the ENTIRE hot tail (sidx
`getAllSidx().Merge` + keepFn coupling + introducer send). Hot callers (merger
lane, flusher) pass `nil`. This is the OQ6 "core+sidx follow hot merge path"
made literal — finalize issues the exact same sidx.Merge calls.
+- `tst.mergeCh` retained in BOTH start paths so the finalize worker (external
goroutine) introduces through the shard's serialized introducer loop. Never
acquires `mergeMaxConcurrencyCh` (that's in `mergeLaneWorker`, which finalize
bypasses).
+- New `finalizer.go` `runFinalizeRound(samplers, graceNs)`: protector gate →
snapshot select (non-hot by finalize_grace, not-in-flight, `finalizeGen <
gNext`) → incRef+in-flight pin → force filter-merge via
`mergePartsThenSendIntroduction(ov={filter,&gNext})` → persist per-shard state
(gen=gNext, rounds++, lastFinalizedAt, counter=0) → refresh cache. Fail-open:
merge error leaves segment intact, scanner retries.
+- Tests: 4 C1 cases (mergeParts propagation, on-disk asserts) + a
running-tsTable round (drop trace2 → 2 remain, gen=1, state persisted). All
pass. 3 existing test callers of mergeParts + 1 of
mergePartsThenSendIntroduction updated for the new param.
+
+## Phase 5 (US-005) — DONE 2026-07-05
+- New `finalize_scanner.go`: `sr.finalizeScanLoop(closer, interval)` (single
node-wide goroutine, concurrency-1) → `runFinalizeScan` iterates
`listFinalizeGroups()` → `scanGroup` does `loadTSDB` +
`SelectSegments(coolRange, reopenClosed=true)` + per-shard `warrantsFinalize` →
`runFinalizeRound`. DecRefs segments.
+- `warrantsFinalize`: reads per-shard `finalize.json` (scanner path, off hot
path); terminal/max-rounds (marks terminal) / cooldown / gen0-always /
`unsampledBytes >= max(FLOOR, RATIO*total)`.
+- reconcile now stores finalize config ONLY when `finalizeEventEnabled` (so
merge-only groups aren't scanned); `listFinalizeGroups()` added;
`schemaRepo.finalizeGraceDefault` added.
+- Wired into `svc_standalone`: `finalizeCloser` started in PreRun (when
`nativePipelineEnabled`), `CloseThenWait` in GracefulStop.
+- **Deviation:** "at most one round per worker wakeup" (PRD literal)
interpreted as concurrency-1 = SERIALIZED (one shard round at a time),
processing all warranting shards per tick sequentially — otherwise 1 shard /
10min is uselessly slow. Recorded.
+- **Coverage gap noted:** full scan→round e2e (SelectSegments→Tables→round on
a real TSDB) is NOT unit-tested (needs a full schemaRepo+TSDB); covered by
`warrantsFinalize` + `runFinalizeRound` unit/integration tests + scan-gating
test. Full e2e deferred to the integration/soak suite (plan E2E section).
**Data-node service (if separate from standalone) needs the same
PreRun/GracefulStop wiring — only `svc_standalone` was wired (Q-A); flag if a
distinct data svc exists.**
+
+## Phase 6 (US-006) — DONE 2026-07-05
+- Finalize compute observable via REUSED merge metrics
`type="finalize"`/`lane="finalize"`
(`total_merged`/`total_merge_latency`/`total_merged_parts`). **Deviation:** did
NOT add dedicated coverage-gauge/rounds/dropped/fail-open finalize counters —
the existing `incPipelineTraces*` counters (evaluated/dropped/…) are DEFINED
BUT UNWIRED (zero call sites, even for the hot path — a pre-existing gap), so
adding a parallel finalize metric set would be inconsistent scope. The reused
lane label [...]
+- Docs: `docs/design/post-trace-pipeline.md` gained an "Implementation:
finalization sampling (v1)" section (best-effort/misses-accepted,
per-shard/scanner model, all knobs). CHANGES.md feature entry added under
0.11.0.
+
+## Verification & review (2026-07-06)
+- **Architect review: APPROVED (0 blockers)**, all 8 correctness claims
verified with file:line evidence (crash-safety ordering, min-propagation,
semaphore bypass, in-flight pin + mergeCh-close safety, O(1) counter, threshold
termination, no-delete-gate, reopen-closed coverage).
+ - It RESOLVED my fn-note Q-A worry: `trace.NewService` (which wires the
finalize scanner) is used by BOTH `pkg/cmdsetup/standalone.go` AND
`pkg/cmdsetup/data.go`, so the data-node role IS covered; the liaison correctly
has no scanner (write-queue path). No separate wiring needed.
+ - It confirmed the select-then-pin TOCTOU in `runFinalizeRound` is
structurally identical to the existing hot dispatcher (`dispatchAllMerges`) and
introduces no new race (the serialized introducer tolerates overlap) — left
as-is intentionally.
+- **Two MINOR findings fixed:**
+ - Counter lost-update: `runFinalizeRound` now snapshots `unsampledBytes` at
round start and `Add(-startBytes)` on commit (persisting the remainder) instead
of `Store(0)`, so late writes arriving DURING a round are preserved for the
next round.
+ - Scanner→round e2e gap: added
`TestFinalizeScan_SelectsCooledSegmentAndFinalizes` — builds a real TSDB
(hourly segments, 30d TTL), writes into a 2h-cooled segment, drives the exact
storage calls `scanGroup` makes (`SelectSegments(reopenClosed=true)` → End
re-check → `Tables()` → `runFinalizeRound`), asserts drop+gen advance, DecRefs.
(Gotcha: the TSDB Option needed `decideTimeout` set — 0 makes the chain time
out → fail-open kept all traces.)
+- **Composition coverage (added 2026-07-06, on user question):**
`TestFinalizeAndMerge_Compose` — one running tsTable with
`nativePipelineEnabled` + a registered sampler, auto-merge disabled
(`maxFanOutSize=0`) for determinism. A real hot merge (MERGE event) drops its
targets via the in-merge filter; a never-merged part is left; a finalize round
(FINALIZE event) drops that part's target and re-keeps the merge survivors (no
double-drop); generation advances 0→1. Proves MERGE+FINALIZE comp [...]
+- **Deslop pass:** no slop found — every finalize symbol is referenced (no
dead code), no duplication (the
`mergePartsThenSendIntroduction`+`mergeOverrides` reuse avoided ~100 lines of
sidx/introduce dup), no needless wrappers, no boundary leaks. Comments document
genuinely complex crash-safety/OQ6 invariants (CLAUDE.md "complex logic"
exception).
+- **Gates:** `make lint` clean; `go build ./...` clean; full
`banyand/trace/...` suite green.
+
+## Commit + UT/IT verification (2026-07-06)
+- Committed as `1c8ad66a` "feat(trace): finalization sampling
(PIPELINE_EVENT_FINALIZE)" on branch `trace-pipeline-finalize-event` (code +
docs + CHANGES; `implementation-fn-note.md` intentionally left untracked,
`.omc/*` gitignored).
+- **UT (green):** `go test ./banyand/trace/... ./banyand/internal/storage/...`
→ trace 27.5s ok, storage 9.3s ok.
+- **IT via `make test-trace-pipeline`** (builds CGO server +
`latencystatussampler.so`, runs standalone + distributed pipeline Ginkgo
suites):
+ - **Standalone in-merge filter suite: 16/16 SUCCESS**, consistently across
re-runs — the trace-package merge changes are sound. (One earlier run had a
single 30s `Eventually` timeout on the first drop spec `t_drop_1` while its
sibling `t_drop_2` (same drop predicate) passed → a merge-completion timing
flake, cleared on re-run.)
+ - **Distributed dynamic-sampler restart spec fails** (`Restart: data node 0
re-applies config from schema store … drop-eligible trace is DROPPED after node
0 restart`): a 30s timeout on "trace schema not yet visible after node 0
restart" (`TraceRegistryService`, a liaison schema service, polled on the
restarted data node). **PROVEN PRE-EXISTING:** checked out `HEAD~1` (f0e0dc21,
before my commit), rebuilt, ran the distributed suite → the SAME spec fails
identically (12 passed / 1 faile [...]
+
+## Sync to latest main + re-check failed distributed test (2026-07-06)
+- Rebased `trace-pipeline-finalize-event` onto latest `origin/main` (apache) —
one new commit `a4fac2f4` "Add Consistently check after Eventually checks
(#1204)" (broad test-hardening: adds `pkg/test/eventually.go` +
`pkg/test/flags/flags.go`; does NOT touch the distributed pipeline restart
test). Rebase clean, no conflicts; my finalize commit is now `f4fe46f1`. `go
build ./...` clean; UT (`banyand/trace` + `banyand/internal/storage`) green.
+- **Re-ran the failed distributed restart spec on latest main: STILL FAILS
identically** — `Restart: data node 0 re-applies config from schema store …
drop-eligible trace is DROPPED after node 0 restart`, "trace schema not yet
visible after node 0 restart" timeout (12 passed / 1 failed). So latest main
does NOT fix it (#1204 didn't touch that test). Combined with the earlier
`HEAD~1` baseline result, this is conclusively a **pre-existing,
environment-specific distributed-restart schema-s [...]
+
+## Fable-model audit + fixes (2026-07-06)
+Second independent audit (code-reviewer, model=fable). Verdict: FIX-FIRST (0
blockers, 2 majors). It caught a real duplication race the earlier architect
pass under-weighted. Fixed:
+- **#1 MAJOR (fixed) — scanner↔dispatcher double-pin → duplicate traces.**
`runFinalizeRound` and the hot `dispatchAllMerges` are TWO concurrent part
selectors. Both did check-under-RLock then pin-under-Lock separately, so both
could select the same cooled part (a cooled segment can still receive late
writes → flusher → dispatcher, exactly while finalize runs). `snapshot.remove`
tolerates an absent id, so both merge outputs survive → the part's rows
duplicated. Fix: (a) `runFinalizeRound [...]
+- **#3 MINOR (fixed) — persist failure could disable the round caps.** The old
code bumped `finalizeGenCached` even when `writeFinalizeState` failed; the next
round's higher gNext would re-include (re-sample) the just-stamped parts. Now:
compute remainder, write state, and ONLY on success mutate the cache + counter;
on failure return the error and leave cache==disk (still G), so replay's
predicate excludes the gNext-stamped parts (no double-sample).
+- **#4 MINOR (fixed) — no disk gate.** Added a free-disk headroom check
(`freeDiskSpace < sum(selected CompressedSizeBytes)` → skip the round as an
accepted miss) so a full-shard rewrite can't push the hot write path into
disk-full.
+- **#5 MINOR (fixed) — dead field.** Removed unused `tsTable.segEndUnixNano`
(+ its assignment; `newTSTable` TimeRange param back to `_`); the scanner uses
`seg.GetTimeRange().End`, not a cached field.
+- **#7 (addressed) — test gaps.** Added
`TestRunFinalizeRound_ReplayExcludesStampedParts` (DD6 crash-window: a
gen-stamped part is excluded on replay, no re-sample) and
`TestWarrantsFinalize_RatioBranch` (the RATIO*total threshold branch, which the
ratio=0 warrants test never hit).
+- **#2 MAJOR (documented, NOT fixed — needs a decision) — scanner reopens
every cooled segment every tick.** `SelectSegments(reopenClosed=true)`
incRef→reopens EVERY cooled segment of every finalize-enabled group each 10-min
tick, including terminal ones, BEFORE the warrants/terminal check. Accurate
severity is DEPLOYMENT-DEPENDENT: with idle-reclaim disabled (default when
idle_timeout<1s) cooled segments stay DORMANT (index!=nil) so the reopen is a
cheap CAS-bump; only with idle-reclaim [...]
+- **#6 MINOR (pre-existing, noted) — `selectSegments` error-path ref leak**
(storage/segment.go:618): on a mid-loop `incRef` error it returns without
DecRef'ing already-incRef'd segments. Pre-existing; the finalize scanner is the
first heavy caller. Not fixed here (storage-layer, low likelihood).
+- **#8/#9 NITs (noted, not fixed):** `tables,_ := seg.Tables()` drops a
(currently-always-nil) error; sync-path accounting can inflate the counter for
parts synced away (cheap no-op rounds).
+Gates after fixes: `go build ./...` clean, `make lint` clean, finalize + merge
unit + integration tests green.
+- **Full-suite `TestTrace` failure investigated → environmental, NOT this
change.** The full `banyand/trace/...` run failed one Ginkgo spec:
`metadata_test.go:395` "changed tag type after merge" (Timed out after 100s
waiting for a background merge to reduce the part count) — the known load-flaky
merge test (memory: flaky under slow-IO/CI, passes in clean env). Ruled my
change in/out decisively: (1) REVERTED the dispatcher conflict-check and the
spec STILL timed out at 100s → my change is [...]
+- **RESOLVED 2026-07-06:** the load was traced to `/tmp/repro3.sh` — an
orphaned 18-day-old script spawning 48 `yes` processes (each ~67% CPU = the
entire load-49), NOT the soak (which was ~0% CPU). Terminated the `yes` burners
+ the vectorized-query soak (banyand + log watchers). Load fell 49.6 → 5.86.
Reran: the merge spec passes in 3.7s (was a 100s timeout), and the FULL
`banyand/trace/...` suite passes in 26.9s (exit 0). All trace tests green. (The
`/data` banyand still holding :1791 [...]
+
+## Audit follow-up round 2 (2026-07-06, user-directed) — #2/#6/#9 fixed, #8 N/A
+User reviewed the unfixed audit items and chose: fix #2 via a storage API, and
fix #6/#8/#9.
+- **#2 MAJOR (fixed) — reopen storm.** Added a storage peek API so the scanner
no longer reopens every cooled segment each tick. New
`storage.SegmentPeek{Start,End,ShardPaths}` + `TSDB.PeekSegments(timeRange)`
(impl `database.PeekSegments` → `segmentController.peekSegments`): snapshots
matching segments' time-range+location under RLock, then lists each segment's
on-disk shard dirs via `walkDir` OUTSIDE the lock — **no `incRef`, no index
reopen, no loop spawn**. The trace scanner (`scanGr [...]
+- **#6 MINOR (fixed) — `selectSegments` error-path ref leak**
(`storage/segment.go`): on a mid-loop `incRef` failure, DecRef the segments
already pinned in earlier iterations before returning.
+- **#8 — N/A (Fable misread).** `Segment.Tables()` returns `([]T, []Cache)`,
NOT an error — the `_` is the unused Cache slice, so there's no dropped error
to log. No change.
+- **#9 MINOR (fixed) — sync-path accounting.** Removed
`accountUnsampledFlushed` from `introduceFlushedForSync`; only
`introduceFlushed` accounts unsampled bytes (parts on the sync path are handed
off, not retained locally, so counting them caused no-op finalize rounds).
+Gates: `go build ./...` clean, `make lint` clean,
`ShardMayWarrant`/`FinalizeScan`/`Warrants` tests pass; full storage + trace
suites re-running.
+
+## Phase 1 (US-001) — DONE 2026-07-05
+Files: `pipeline_registry.go`, `trace.go` (option), `svc_standalone.go`
(flag), `metadata.go` (reconcile), tests `pipeline_finalize_test.go` +
`pipeline_watch_test.go` (resetRegistries + renamed test).
+Decisions / deviations:
+- **Threshold overrides are registry-backed with DEFAULTS only; proto override
fields deferred.** The proto (`TracePipelineConfig`) has `finalize_grace`
(field 4) but NO FLOOR/RATIO/cooldown/max_rounds fields. Rather than invent 4
proto fields (repo-wide pb.go regen + validation), I built
`localFinalizeConfigRegistry` +
`finalizeConfig{floorBytes,ratio,cooldownNs,maxRounds}` with
`lookupFinalizeConfig(group, graceNs)` filling unset fields from package
defaults (8MiB / 0.10 / grace / 8). [...]
+- **finalizeEventEnabled: empty enabled_events does NOT default finalize ON**
(opt-in), unlike `mergeEventEnabled` (empty => MERGE on, for back-compat).
Finalize is an extra procedure, must be explicitly requested.
+- **reconcile now registers the sampler set when MERGE OR FINALIZE is
enabled** (shared set per DD11). The clear branch fires only when NEITHER is
enabled. finalize_grace default (5m) lives in `option.finalizeGraceDefault` via
new flag `--trace-pipeline-finalize-grace-default`, resolved lazily at lane
time (lookupFinalizeGrace==0 => option default), mirroring merge_grace.
+- **Renamed stale test** `TestReconcilePipeline_MergeEventNotEnabled` →
`TestReconcilePipeline_NoEventOrPluginsClears`: its old name/comment ("v1 only
implements merge; finalize-only installs no samplers") became false once
finalize shares the sampler set. The assertion (empty when no plugins) still
holds; the test now documents the correct new semantics. Added
`TestReconcilePipeline_FinalizeOnlyRegistersSamplers` to prove finalize-only
WITH a plugin DOES register.
+- Verified: `go build ./banyand/trace/...` clean; 6 finalize unit tests pass
(reconcile tests ran against the built .so, not skipped).