Trace Merge Drop-Set Bounding
Status
This document specifies a bound on the merge-wide dropped-trace-ID collector introduced by Phase 6 of the merge optimization plan, which was unbounded in the number of dropped traces per merge.
Implemented, except the validation. The mechanism (DS-1, DS-1b), the budget resolver (DS-2), the ceiling seam (DS-3), observability (DS-4), activation (DS-5), and the production instruments of section 6 are in the tree. The ceiling is therefore live on every merge.
The workload validation has not run. It rides the Phase 10 SkyWalking acceptance workload as two runs: one at
default settings, which measures how much headroom real traffic has against the ceiling, and one at
--allowed-bytes=256MiB, which lowers the ceiling enough for real traffic to reach it and so is the only run that
exercises the capped path. Until both produce reports the ceiling is active with no workload-level evidence behind it,
and the memory bound rests on unit-level residency checks. See the execution plan for the gate and for why it replaced
the containerised controlled run.
The bound is applied to the sampling decision, not to the pruning predicate. When a merge reaches its drop-set ceiling it stops dropping traces and retains the remainder. Secondary-index pruning therefore stays exact for every drop that is performed, the secondary index remains exactly the set of retained traces, and no query-layer change is required. Phase 6’s hard invariant — “Secondary-index lookup remains exact. No probabilistic structure can authorize a deletion.” — is preserved verbatim.
Every merge is treated identically. One budget function, one constant, no lane parameter. A finalize round is one
more merge: same ceiling, one round, no continuation mechanism, no watermark, and no change to the finalize round
machinery, its persisted state, or its scheduling. banyand/trace/finalize_state.go is not touched;
banyand/trace/finalize_scanner.go gains telemetry only, and its decisions are unchanged (section 7).
The accepted cost is bounded deletion: a capped finalize round leaves traces undeleted, and they are only revisited by the existing re-round path, which is capped at eight rounds per shard for its lifetime. Section 5 states the numbers. Finalize I/O does not change, because the round count does not change.
Four features that would each lift part of this limit are specified and deferred in section 9: bloom-filter pruning, continuation rounds, the sampling watermark, and a per-lane finalize ceiling. None is a prerequisite; each is an independent later choice, and section 9 records what must be true before it can be taken.
1. Problem
droppedTraceIDs (banyand/trace/drop_set.go) holds every trace ID the core merge dropped, for the whole duration of
one merge, so that each sibling secondary-index merge can prune the same IDs (banyand/trace/merger.go:843-856):
ids []string— ascending, deduplicated, one entry per dropped trace.slots []uint64— an open-addressed index built lazily on first lookup, sized to the next power of two at or above2N, so between2Nand4Nslots of eight bytes.
It is released only after every secondary-index merge has published (banyand/trace/merger.go:826).
maxPooledDroppedTraceIDBytes = 4 MiB (banyand/trace/drop_set.go:26) bounds only what is returned to the reuse pool;
it is not a limit on a live merge, as Phase 6 already recorded.
Nothing bounds N. The pipeline’s other working sets are budgeted — stageBudgetFromLimit gives each merge
memLimit/(4 × CPUs) for staged bytes so the aggregate stays near memLimit/4, plus a per-trace budget and a
trace-count cap (banyand/trace/merger.go:194-243). The drop set sits outside that accounting: staging is bounded per
batch and reset at every flush, while the drop set accumulates across all batches of the merge.
1.1 Measured residency
Measured on the real structure with 42-byte service-prefixed IDs, each body allocated per entry as
blockMetadata.unmarshal does (banyand/trace/block_metadata.go:170). Reserved heap is HeapSys — what the container
sees.
| Dropped traces | Live heap | ids |
slots |
ID bodies | Live before index | Reserved heap |
|---|---|---|---|---|---|---|
| 100,000 | 7.6 MiB | 1.8 | 2.0 | 3.8 | 5.6 MiB | 19 MiB |
| 1,000,000 | 71.1 MiB | 16.9 | 16.0 | 38.1 | 55.1 MiB | 163 MiB |
| 4,000,000 | 281.3 MiB | 64.7 | 64.0 | 152.6 | 217.3 MiB | 615 MiB |
| 16,000,000 | 1,113.2 MiB | 246.8 | 256.0 | 610.4 | 857.2 MiB | 2,270 MiB |
Roughly 73 to 80 bytes per entry live. Reserved heap is close to twice live heap because ids grows by append doubling
and slots is a single contiguous allocation — 256 MB at sixteen million entries. Reserved heap, not live heap,
terminates a container.
1.2 Trigger
The exposure is the ordinary finalize path, not a rare large hot merge:
runFinalizeRoundselects every cooled part in the shard into one merge (banyand/trace/finalizer.go:92-119). The only quantity gate is disk headroom (:141).warrantsFinalizereturns true unconditionally whenFinalizeGeneration == 0(banyand/trace/finalize_scanner.go:211), so a shard’s first finalize round rewrites its entire segment in one merge with one drop set.- The scanner ticks every ten minutes (
banyand/trace/finalize_scanner.go:32) over cooled segments only, so that first round has no natural chunking.
At a ninety percent drop rate a shard holding twenty million traces produces an eighteen million entry drop set: about 1.3 GiB live, 2.6 GiB reserved, inside a process also serving queries and hot merges.
Hot merges are milder, bounded by the merge policy and the lane threshold, but up to CPUs/2 + 1 run concurrently
(banyand/trace/merger.go:266-283), each with its own set, alongside the node-wide serialized finalize round.
The protector is consulted once at round start (banyand/trace/finalizer.go:58) and never again as the set grows.
2. Goals and non-goals
Goals.
- A hard, resource-derived ceiling on drop-set residency per merge and in aggregate.
- Secondary-index contents remain exactly the retained-trace set. No orphan entries, no missing entries.
- No change to any query path, plan, or operator.
- No change to the finalize round machinery, its persisted state, or its scheduling.
- Byte-identical behaviour below the ceiling.
- The deletion lost at the ceiling is bounded and observable.
Non-goals.
- Recovering the deletion lost at the ceiling. Section 9 lists four ways to do that later; none is in scope.
- Bounding the finalize round’s part selection.
- Changing the fragment guard, the staging budgets, or the plugin decision path.
- Reducing the cost of the exact lookup selected by Phase 6.
3. Design
3.1 Retain instead of drop at the ceiling
The ceiling changes what the sampler is allowed to act on, never how the secondary index is pruned. Once a merge’s drop set is full, every subsequent proposed drop becomes a retention:
- The trace is written to the output part.
- It is counted as retained, under a distinct counter — retained-by-ceiling, not retained-by-verdict.
- The fragment guard is not consulted for it. The guard exists to confirm drops; a trace that will be retained needs no confirmation, and skipping it saves the guard’s bloom probes.
Because recording and dropping stop at the same instant, the drop set stays complete with respect to the drops actually performed. That is the whole correctness argument, and it is why nothing downstream changes:
| Consumer | Change |
|---|---|
keepEncoded predicate (banyand/trace/drop_set.go:84) |
none |
filterBlockPointer (banyand/internal/sidx/merge.go:255) |
none |
Guard revalidation gate (banyand/trace/merger.go:926) |
none — the set is never released, so dropped.len() stays truthful |
Output part traceIDFilter |
none |
| Finalize state, scanner, scheduling | none |
| Query plan, engine, limit pushdown | none |
| Phase 6 hard invariant | none |
The state is a single one-way flag plus the existing set. No release-mid-merge semantics, no count-versus-length divergence.
type dropTracker struct {
exact *droppedTraceIDs // never released early; lives to SIDX publication as today
maxIDs int // derived from the resolved budget; 0 means unlimited
full bool // one-way within a merge
}
3.2 Where the ceiling is enforced
One seam, in resolveStagedDrops (banyand/trace/merger.go:1945-1996), before either recordDroppedTraceID call site
and before the guard:
for traceIdx, traceID := range assembledBatch.vectors.traceIDs {
if keepMask[traceIdx] {
// ... existing retained accounting ...
continue
}
if !tracker.canAccept() {
// Ceiling reached: retain rather than drop, and do not enter the guard
// for a trace that will be kept.
keepMask[traceIdx] = true
// ... retained accounting + retained-by-ceiling counter ...
continue
}
if filter.guard == nil {
recordDroppedTraceID(...)
continue
}
// ... existing guard path, unchanged ...
}
flushStaged (banyand/trace/merger.go:2002-2035) needs no change: it already writes every trace whose mask entry is
true. The ceiling may be reached mid-batch; per-trace mask flipping handles that with no batch-boundary special case.
3.3 Cap predicate
Cap in bytes, accounted exactly. The pool’s sizing function already computes most of it
(banyand/trace/drop_set.go:35-37). The index will be built, so the projection is not optional:
liveBytes(N) = cap(ids) * sizeof(string) + bodyBytes
projectedIndex(N) = nextPow2(max(2, 2N)) * 8 // exactly what buildIndex will allocate
maxIDs is derived once, from the budget and one entry’s price, so the steady-state cost is one integer comparison per
proposed drop rather than a byte computation. Because nextPow2 is discontinuous, price the slot share at the worst-case
ratio of four slots per entry:
| Layout | Header + slack | Body | Slots (worst case) | Bytes/entry at a 42-byte ID |
|---|---|---|---|---|
Today ([]string) |
20 | allocClass(len) |
32 | 100 |
Arena + uint32 offsets (§3.5) |
5 | len |
32 | 81 |
The body term is derived from an observed ID length, not fixed. A constant priced for the 42-byte
service-prefixed ID of §1.1 would let a deployment with longer IDs overshoot the budget proportionally — about 1.4x at 96
bytes, 3x at 256 — so the ceiling would not be a ceiling. Trace-ID length is uniform within a shard, so the first
recorded ID prices the whole merge; allocClass rounds it up to the allocator’s size-class stride (16 bytes below 256,
32 above) because a ceiling must never under-count residency. At 42 bytes this yields exactly 100, so deriving the price
generalizes the fixed constant rather than re-tuning it, and the ceilings in §3.4 are unchanged for that case.
The derivation re-runs if a longer ID ever appears, which can only lower maxIDs. Uniformity is therefore an expectation
that makes one sample sufficient, not an assumption the memory bound rests on.
3.4 One budget for every merge
Hot lane, slow lane, and finalize round all resolve the same ceiling from the same function. There is no per-lane budget and no special case for finalize.
const dropSetAggregateDivisor = 16
const defaultDropSetBudget = 16 << 20 // protector disabled; mirrors stageBudgetFromLimit's limit==0 fallback
const minimumDropSetBudget = 1 << 20
// resolveDropSetBudget returns the per-merge drop-set ceiling. It mirrors
// stageBudgetFromLimit: divide by CPUs() so the aggregate across concurrent merges
// stays near memLimit/dropSetAggregateDivisor. Finalize is not special-cased — it is
// one more merge and is charged the same. See §5 for what that costs it and §9 for
// the deferred alternative.
func resolveDropSetBudget(opt option) uint64 {
if testDropSetBudgetOverride > 0 { return testDropSetBudgetOverride }
limit := protectorLimit(opt)
if limit == 0 { return defaultDropSetBudget }
return max(limit/(dropSetAggregateDivisor*uint64(max(1, cgroups.CPUs()))), minimumDropSetBudget)
}
Resulting ceilings, at 100 bytes per entry today and 81 with section 3.5:
| memLimit | CPUs | Budget | Traces per merge | Aggregate worst case |
|---|---|---|---|---|
| 512 MiB | 4 | 8 MiB | 84 k / 103 k | 32 MiB (6.3%) |
| 4 GiB | 8 | 32 MiB | 335 k / 414 k | 192 MiB (4.7%) |
| 16 GiB | 16 | 64 MiB | 671 k / 828 k | 640 MiB (3.9%) |
Aggregate assumes CPUs/2 + 2 merges — the hot lanes plus the finalize round — all simultaneously at the ceiling. That
is a bound, not an expectation. Below four CPUs the CPUs divisor is smaller than CPUs/2 + 2, so the aggregate can
exceed memLimit/16 by up to 2.5x on a one- or two-CPU node; the absolute figures there are small, and the existing
stageBudgetFromLimit has the same property by the same convention.
Two deliberate departures from stageBudgetFromLimit:
- A 1 MiB floor, not
defaultStageBudgetFloor’s 16 MiB. Some floor is needed, or a divided budget on a small node collapses to a ceiling too small for sampling to make useful progress. But a generous floor is exactly what breaks the aggregate bound above, so it is set two orders of magnitude lower than staging’s. It binds only whenlimit < 16 MiB × CPUs, where the aggregate stays a small share of any plausible limit (128 MiB / 16 CPUs: 10 MiB, under 8 %). On implausibly small limits the floor does dominate the bound (32 MiB / 16 CPUs: 31 %); that is accepted, and pinned by a test rather than left to chance. - No independent entry cap. Staging needs
defaultMaxStagedTraceCountbecause its per-trace byte estimate is coarse; here the byte accounting is exact. The omission must be commented as deliberate.
3.5 Arena-backed IDs (separable)
The ceiling is deletion capacity, so bytes per entry converts directly into traces deleted per merge. Replacing
ids []string with a byte arena plus uint32 offsets removes both the sixteen-byte string headers and the size-class
rounding, raising the ceiling by about twenty-four percent.
It does not change the lookup shape. keepEncoded compares convert.StringToBytes(dropped.ids[storedIdx-1]) against
the probe; with an arena it compares arena[offs[i]:offs[i+1]]. Same single slot probe, same single bytes.Equal, no
extra indirection. The Phase 6 index and its exact-confirmation step are untouched.
It also makes bodyBytes exactly len(arena), so the cap predicate stops depending on a size-class estimate.
Swapping the index for binary search over ids remains out of scope; see section 10.
3.6 Dynamic trigger
The static ceiling does not cover pressure arriving from elsewhere in the process. The switch to retain-only fires on either condition:
liveBytes(N) + projectedIndex(N+1) > budget— the static ceiling.pm.State() == protector.StateHigh, sampled every 4,096 recorded drops — dynamic pressure, regardless of the ceiling.
The second closes the gap in section 1.2. Its failure mode is benign: it retains traces, so a spurious trigger costs deletion, never correctness.
4. Consequence: which traces survive
Traces are proposed for dropping in core-merge order, which is ascending trace ID. So the traces a capped merge spares are the lexicographically largest IDs in that merge. SkyWalking trace IDs are service-prefixed, so within one capped round the spared set is correlated with service name rather than being a uniform sample.
A later round’s ascending walk encounters the previous survivors and deletes the next maxIDs of them, so the bias
erodes as long as rounds keep happening. It becomes permanent when rounds stop — see section 5.2. The bias must be
documented in operator docs, because “sampling retained more traces for services late in the alphabet” is otherwise an
inexplicable observation.
5. Cost: bounded deletion
5.1 Hot merges: benign
A capped hot merge’s output has FinalizeGen min-propagated from its inputs (banyand/trace/merger.go:1168-1176), so
the spared traces stay selectable by later hot merges and by finalize. Nothing is lost, only deferred.
5.2 Finalize: bounded by the existing round budget
A finalize round’s output is stamped gNext and the next round selects on FinalizeGen < gNext+1
(banyand/trace/finalizer.go:74,108,1166-1167, and the field comment at banyand/trace/finalize_state.go:45-52), so a
capped round’s output is eligible for a later round. No new mechanism is added to exploit that; the existing gates
apply unchanged:
warrantsFinalizerequiresunsampledBytes >= max(floorBytes, ratio × total)onceFinalizeGeneration != 0(banyand/trace/finalize_scanner.go:211-219), and the counter was reset by the round that just ran (banyand/trace/finalizer.go:193-204), so a later round waits on new ingest rather than on the leftover work.cooldownNspaces rounds.finalizeMaxRoundsDefault = 8(banyand/trace/pipeline_registry.go:96) is a hard per-shard lifetime cap, after which the shard is markedTerminaland never scanned again (banyand/trace/finalize_scanner.go:196-203).
So total deletion for a shard’s lifetime is bounded by eight rounds times the ceiling:
| memLimit | CPUs | Ceiling per round | Lifetime deletion bound (8 rounds) |
|---|---|---|---|
| 512 MiB | 4 | 84 k / 103 k | 670 k / 824 k |
| 4 GiB | 8 | 335 k / 414 k | 2.7 M / 3.3 M |
| 16 GiB | 16 | 671 k / 828 k | 5.4 M / 6.6 M |
A shard with eighteen million droppable traces at 4 GiB and eight CPUs therefore leaves roughly fifteen million permanently undeleted. This is the accepted cost of the design. It is a deliberate trade: memory safety and a minimal, self-contained change, against deletion throughput on large shards. Section 9’s deferred features are the engineering levers.
The operator lever is the protector limit, not the machine’s memory. The ceiling scales linearly with
protector.GetLimit(), which is --allowed-bytes when set and otherwise a valid cgroup memory limit
(banyand/protector/protector.go:245-269). A node with neither — no --allowed-bytes and no readable or finite cgroup
limit, which is the bare-metal default — leaves the protector disabled, and resolveDropSetBudget then returns the flat
defaultDropSetBudget regardless of how much RAM the machine has: about 168,000 traces per merge and 1.3 million per
shard lifetime, whether the box has 8 GiB or 512 GiB.
That is the same shape as stageBudgetFromLimit’s flat limit == 0 fallback, so it is consistent with how the rest of
the merge pipeline treats an unmeasurable node. The consequence differs, though, and is worth stating rather than
inferring: a flat staging budget only slows a merge, while a flat drop-set ceiling bounds deletion permanently. On such
a node, adding RAM does nothing for sampling; setting --allowed-bytes (or running under a cgroup limit) is what raises
the ceiling. Deriving a budget from physical RAM instead was rejected: it would add a memory-discovery path that the
rest of the codebase deliberately routes through the protector, and a node running with no memory limit at all has no
budget for staging either.
The failure is invisible without section 6’s counter, which is why that counter and its warning log are part of this specification rather than a follow-up.
5.3 Finalize I/O is unchanged
Round count does not change, and a round rewrites the same parts it does today. A capped round writes more bytes than an uncapped one, because it retains traces it would otherwise have removed — but that is strictly less work than the unbounded status quo does, and there is no additional rewrite. This design adds no finalize write amplification.
6. Observability
Without this, the design’s cost is invisible: a shard quietly under-deletes and nothing says so. With no continuation mechanism, observability is the only thing standing between a capped shard and a silent one.
On banyand_trace_tst_, per merge:
| Instrument | Labels | Answers |
|---|---|---|
pipeline_traces_retained_by_ceiling (counter) |
group,seg,shard |
How much deletion was lost, and where. Distinct from retained-by-verdict; both also increment pipeline_traces_retained |
pipeline_merges_ceiling_reached (counter) |
group,seg,shard,lane |
Which lane is capping — finalize is the one that matters |
pipeline_drop_set_budget_bytes (gauge) |
group |
The resolved ceiling: the denominator for the two above |
pipeline_drop_set_entries (histogram) |
group,seg,shard,lane |
How close merges are running to that ceiling |
The histogram is emitted whether or not a merge caps, and that is the point: the two counters only fire once
deletion has already been lost, so on their own they are a lagging indicator. Watching the upper buckets approach
pipeline_drop_set_budget_bytes / dropSetBytesPerEntry is what gives an operator warning before the ceiling bites. A
zero budget emits nothing rather than a zero gauge, so “no ceiling” never reads as “a zero-byte ceiling”.
The shard dimension is carried because the deletion bound is per shard (§5.2); without it a dashboard shows that a group
is under-deleting but not where. These series are deleted per tsTable in deleteMetrics, so cardinality is bounded by
live segments rather than growing with rotation.
On banyand_trace_pipeline_, per shard, published by the scan pre-filter (§7):
| Instrument | Labels | Answers |
|---|---|---|
finalize_rounds (gauge) |
group,shard |
How much of the shard’s lifetime round budget is spent |
finalize_terminal (gauge) |
group,shard |
Whether the loss is now permanent — this shard can never delete another trace |
- The resolved budget, the
fullflag, and the retained-by-ceiling count in the benchmark event, next toStagingHardLimit(banyand/trace/merger.go:712-716). - A warning log when a finalize round hits the ceiling, naming the group, shard, and count. This is the one condition an operator can act on, by raising the protector limit (§5.2 — not by adding RAM, if the protector is disabled).
- All three must describe the merge output that was actually published. A capped attempt that the fragment guard rejects is retried without sampling, and the retry’s output drops nothing; reporting a ceiling for it would send an operator after a merge that never hit one.
Nothing is added to the persisted finalize state; a capped round is visible through metrics and logs only.
7. Required changes
| Site | Change |
|---|---|
banyand/trace/drop_set.go:28-59 |
Add dropTracker with canAccept, exact liveBytes/projectedIndex. Optionally arena-back the IDs (§3.5). |
banyand/trace/merger.go:1945-1996 |
Ceiling check before the guard and before both record sites; flip keepMask; new counter. |
banyand/trace/merger.go:2050-2066 |
traceEvaluationStager holds the tracker instead of a bare *droppedTraceIDs. |
banyand/trace/merger.go:194-243 |
Add resolveDropSetBudget and testDropSetBudgetOverride. One function, no lane parameter. |
banyand/trace/merger.go:638-650 |
Hot filter carries the budget. |
banyand/trace/finalizer.go:156-168 |
Finalize filter carries the same budget. Nothing else in the finalize path changes. |
banyand/trace/merger.go:712-716 |
Emit budget, capped flag, retained-by-ceiling count. |
banyand/trace/metrics.go:318-334 |
Retained-by-ceiling and capped-merge counters. |
docs/design/post-trace-pipeline.md:630-632 |
Note that a merge may stop dropping at its ceiling; the pruning predicate and lockstep publication are unchanged. |
docs/design/trace-pipeline-merge-optimization-plan.md Phase 6 |
Record that the collector is now ceiling-bounded; the phase’s exactness invariant is unchanged. |
Not changed: keepEncoded, filterBlockPointer, the SIDX merge signature, the output part’s traceIDFilter,
banyand/trace/finalize_state.go, every query path.
banyand/trace/finalize_scanner.go gains telemetry only: the scan pre-filter publishes each shard’s finalize state
(section 6) before deciding, because it is the only place a terminal shard is still visited. Selection, generation
stamping, cooldown, and the round cap are untouched, and the pre-filter returns exactly what it returned before — pinned
by TestSegmentMayWarrantObservedMatchesUnobserved.
8. Rejected: prune with the retained-ID bloom filter
Recorded here rather than in section 9 because the reason is a query-layer finding that outlives this design.
The alternative was to release the exact set at the ceiling and prune with the output part’s retained-ID bloom filter
(banyand/trace/block_writer.go:236,283, read back at banyand/trace/merger.go:1181), which is free — it is built,
persisted, and re-read today. A bloom has no false negatives, so a negative proves non-membership and the deletion stays
exact; a false positive can only retain an entry for a dropped trace, at about 0.042 percent
(pkg/filter/bloom_filter.go:29-34). That would have bounded memory with no deletion loss at all.
The reasoning is sound but the conclusion drawn from it — that a surviving entry for a dropped trace is query-invisible — is false on the default query engine:
- The pushed-down limit is
limit + offset, the user-visible limit (pkg/query/logical/trace/trace_analyzer.go:95). - It reaches Phase 1 as
maxRowsand caps the stream of secondary-index-derived distinct trace IDs before any core scan (banyand/trace/query_vectorized.go:361-388,pkg/query/vectorized/trace/limit.go:50-66,limit_carry.go:56-88). - The core scan runs afterwards (
banyand/trace/query_vectorized.go:398-431). A trace with no spans yields nothing, and nothing backfills: the distributed plan sendsLimit = maxTraceSizeto data nodes (pkg/query/logical/trace/trace_plan_distributed.go:135-136) and the liaison only trims. - The vectorized engine is enabled by default (
pkg/query/vectorized/trace/config.go:49-55).
So an orphan entry inside the top-N window consumes a result slot and the query silently returns fewer traces than the
limit. Tag filters make this more likely, not less: they are pushed into the secondary index
(pkg/query/logical/trace/trace_plan_tag_filter.go:135-152) and a surviving element keeps all its tag columns
(banyand/internal/sidx/merge.go:252-283), so an orphan can match and enter the window. The row path tolerates it
(banyand/trace/query.go:366-370 skips zero-cursor traces and keeps pulling) but it is not the default.
A projection served entirely from secondary-index tags would turn this from a short count into wrong rows. No such path
exists today — Phase 1 carries only trace ID, key, and part ID (banyand/trace/streaming_pipeline.go:40-46,
pkg/query/vectorized/trace/limit_carry.go:56-88) and result tags come only from the core block scan
(banyand/trace/query_vectorized.go:246-289) — but it is an obvious future optimization, and a memory design whose
correctness depends on nobody adding it is not safe.
Note the corollary, which is why this section matters even though the bloom is rejected: fail-open-on-ceiling — “stop pruning, keep every entry” — is not an acceptable fallback either. It produces orphans for one hundred percent of post-ceiling drops where the bloom produced them for 0.042 percent. Retaining the traces is the only fallback that keeps the index exact.
9. Deferred features
Each of these lifts part of section 5.2’s deletion bound. None is a prerequisite for this design, none is required for it to be correct, and they are independent of one another. Listed in the order their prerequisites are likely to be met.
9.1 Per-lane finalize ceiling
Buys. About CPUs times more deletion per finalize round — at 4 GiB / 8 CPUs, 256 MiB and ~2.7 million traces per
round instead of 32 MiB and ~335,000, so the lifetime bound rises from ~2.7 million to ~21 million traces per shard.
Equally memory-safe, since the finalize scanner is concurrency-1 node-wide
(banyand/trace/finalize_scanner.go:34-37).
Costs. A second resolver and a second call site, so two budget constants that can drift apart, and a test table that has to cover both.
Prerequisite. Evidence that the lifetime bound is actually being hit — the retained-by-ceiling counter from section 6 being non-zero on real shards.
Why not now. The whole point of one budget is that finalize is not special. This is the cheapest way to change that decision if the data says it was wrong, which is why it is listed first.
9.2 Continuation rounds
Buys. Removes the ingest gate, so leftover work clears itself. A capped round would persist a pending flag,
warrantsFinalize and shardMayWarrant would both honour it (bypassing the unsampledBytes threshold but not the
cooldown), and a separate bounded ContinuationRounds counter would keep FinalizeRounds free for ingest-driven work.
Costs. New persisted finalize state, changes in two scanner predicates that must move together, a new bound to choose, and a new crash-ordering case to verify against the DD6 argument. It also multiplies finalize rewrite volume by roughly the number of extra rounds, since every round rewrites the whole shard whatever it deletes — the reason section 5.3’s “I/O is unchanged” holds only while continuation is absent.
Prerequisite. 9.1 first, because raising the ceiling is strictly cheaper per unit of deletion recovered than adding rounds.
9.3 Sampling watermark
Buys. Only useful with 9.2. Without it, every continuation round re-runs the sampler over all surviving traces,
re-deciding traces earlier rounds already retained by verdict, so plugin work scales with rounds times survivors. A
watermark — the last trace ID a capped round actually decided — lets the next round skip evaluation at or below it,
which retains those traces, exactly the verdict they already had. Round count becomes ceil(droppable / maxIDs),
provable rather than hoped for.
Costs. One more persisted field, and one sharp edge: the watermark must be set from the last trace decided, never the last trace read, or traces that were staged but never evaluated would be exempted from sampling forever.
Prerequisite. 9.2.
9.4 Bloom-filter pruning
Buys. The only option that removes the deletion bound entirely rather than raising it: memory bounded with no retention loss at all.
Costs and prerequisite. Section 8. It requires making the Phase-1 limit refillable — over-fetch and backfill after the core-scan join, or apply the cap post-join — which is a query-layer change worth doing on its own merits and is the hard prerequisite here. Until then this option is unavailable, not merely unattractive.
10. Other rejected alternatives
- Unbounded exact set (status quo). Residency scales with shard size, which is unbounded, and reserved heap is twice live heap. Rejected.
- Swapping the index for binary search over the ascending
ids. Saves sixteen to thirty-two bytes per entry, butkeepEncodedruns once per secondary-index element and elements outnumber traces. Phase 6 measured the sorted slice at 2.3 to 3.6 times slower than the selected compact hash (banyand/trace/drop_set_test.go:73-86). Rejected. - Spilling the drop set to disk with on-disk verification. Bounded, exact, and it would preserve full deletion — but on a heavy-drop merge most probes are true positives, so most lookups hit disk. Rejected on cost.
- Chunking the finalize round’s part selection. Would bound
Nby construction with no deletion loss. Rejected on effectiveness, not complexity: whole-shard selection is what puts every fragment of a trace in the same merge set, so chunking would leave cross-chunk fragmented traces permanently guard-deferred (banyand/trace/merger.go:1965-1995) at a rate set by fragmentation, which is worse and less predictable than the bound in section 5.2.
11. Invariants
Preserved unchanged:
- Secondary-index lookup remains exact. No probabilistic structure authorizes a deletion. (Phase 6, verbatim.)
- All physical blocks for one trace ID are assembled into exactly one sampler decision.
- The fragment guard pins and validates conjunction parts before any drop.
- Core and secondary-index outputs are introduced atomically.
- Timeout, panic, malformed-verdict, and plugin-error paths fail open.
- The pipeline-disabled path performs no drop-set work.
- Finalize round selection, generation stamping, persisted state, cooldown, and round cap are exactly as before.
Introduced:
- The drop set is complete with respect to the drops performed. Recording and dropping stop at the same instant.
- A secondary index contains exactly the entries of its part’s retained traces — no orphans, none missing.
- Drop-set residency never exceeds the resolved budget, and
fullis one-way within a merge. - The ceiling only ever converts a drop into a retention. It never changes a retention into a drop, never evaluates a partial trace, and never bypasses the guard for a trace that is dropped.
- A capped merge’s spared traces remain eligible for a later merge or round. For hot merges this follows from
FinalizeGenmin-propagation; for finalize, from the unchanged generation predicate — subject to the round bound in section 5.2, which is a bound on deletion, not a correctness claim.
12. Risks
- Silent under-deletion. The headline risk, and the one with no mitigation inside this design. A shard can reach its lifetime round cap with millions of undeleted traces and look healthy. Mitigation: section 6’s counter and warning log are not optional, and section 9.1 is the cheapest response if the counter says the bound is being hit.
- A protector-disabled node gets the worst ceiling and the least obvious lever. Section 5.2: the flat fallback
applies regardless of RAM, and neither validation run can observe it: both execute under a limit, and the
capped-path run enables the protector explicitly via
--allowed-bytes. Mitigation: documented in section 5.2 and atresolveDropSetBudget; the counter still fires, so the condition is detectable even though the validation run will not surface it. - Retention grows storage. Traces retained by ceiling occupy disk that the sampler intended to reclaim. The
finalize disk-headroom gate (
banyand/trace/finalizer.go:141) still protects the write path, but a shard whose sampling is ceiling-bound will not shrink as an operator expects. Belongs in operator documentation next to the counter. - Ordering bias. Section 4. Mitigation: documentation.
- Guard probe interaction. Skipping the guard for ceiling-retained traces changes how many bloom probes a merge performs, so guard-probe metrics shift on capped merges. Expected, not a defect; assert it rather than be surprised.
- Pool churn. Sets above
maxPooledDroppedTraceIDBytes(4 MiB) are not pooled, and every ceiling in section 3.4 exceeds it, so a capped merge’s set is garbage rather than reused. Pre-existing behaviour; measure before changing the pool bound.
13. Execution
The ticket-level breakdown, with per-ticket boundaries, tests, and rollback, is in trace-drop-set-bounding-plan.md. In outline: the mechanism lands inert (production budget zero, which the tracker treats as unlimited), and a single activation ticket turns it on and is the rollback boundary for the series.
14. Open questions
dropSetAggregateDivisor = 16. It sets both the memory bound and, through section 5.2, the lifetime deletion bound. Settle before activation, because the exit-gate measurements are stated against it.- Whether
maxRoundsshould become operator-tunable. It is per-group infinalizeConfig(banyand/trace/pipeline_registry.go:132) but the v1 proto carries no override field (banyand/trace/pipeline_registry.go:135-137), so today the only lever on section 5.2’s bound is memory. Not required by this design; worth deciding separately.