Part 3 of 5 of the tag aggregation and time bucketing design, shipped in apache/skywalking-banyandb#1360.
Design references: §6 Semantics · §7.3 COUNT_DISTINCT on a single node · §7.4 COUNT_DISTINCT across data nodes · §7.6 Memory safety · §12 Delivery order (stages 4, 4a, 5)
Depends on: #14088 (API foundation + tag binding).
This is the highest-stakes issue in the series — a mis-evaluated push-down condition does not crash, it silently returns a plausible-looking wrong count. Test it hardest.
Scope
Standalone
pkg/query/aggregation/distinct.go: Distinct interface (In/Val/Reset), NewDistinct(), keyed by the existing appendKeyComponent encoder (already handles every type the §6 matrix admits).
- New
aggSlot variant; §6 matrix's COUNT_DISTINCT column (string/int/bytes accepted, array/timestamp rejected).
- Per-value memory charge:
Consume reserves on each newly-added value and refunds in Close; also fixes reduce.go:133's entrySize=0 so the liaison-side reduce isn't silently unbudgeted.
NewMap/NewReduce keep returning errUnknownFunc for COUNT_DISTINCT — this is what makes a COUNT_DISTINCT TopNAggregation rule fail loudly instead of mis-aggregating; needs a pinning test, not new guard code.
Distributed push-down
- Analyzer condition:
routingTags ⊆ groupByTags ∪ {target} (routing key = the measure's sharding_key if configured, else its entity) — reject otherwise, with an error naming the target, the entity/routing key, and the two ways out.
- Reject any request resolving to more than one lifecycle stage, bucketed or not (expand
DefaultStages before counting — never silently drop a stage).
- Reject ambiguous bare tag names (same name in two families) — require the family qualifier.
- Map phase: add the shard-id column to
keyIndices so each emitted partial is exactly one shard's contribution — a precondition fix, since today's partial is per-node, not per-shard.
- Reduce: prepend stage to the dedup key —
(stage, shard, group_key) — since hot/warm nodes can both report the same shard id with different, disjoint data; needs a stage field added to the partial wire schema.
distributedAggFunc maps COUNT_DISTINCT to the existing SUM reducer (no new wire type, no new Partial shape).
Parallel, non-gating investigation (§7.4 callout)
- The
replicas=1 experiment: compare distributed SUM against a raw scan at replicas=1 vs. replicas=0. Its outcome decides only whether the shard-in-keyIndices fix above is also a live correctness fix for existing SUM/COUNT/MEAN (→ a CHANGES.md entry and possibly a patch release) — not whether to build the fix, which ships in this issue regardless.
Out of scope
Test / Definition of done
- New package tests in
pkg/query/aggregation/distinct_test.go (zero tests exist there today): set semantics, reset, duplicate insertion doesn't grow the count.
COUNT_DISTINCT over string/int/bytes targets; null values excluded; budget exhaustion fails with the existing "aggregation memory budget exceeded" message.
- Off-cadence disagreement fixture: one entity, two points in one bucket (
09:01:00, 09:01:34 on a 1-minute measure) — assert COUNT reports 2 and COUNT_DISTINCT reports 1.
- Accept/reject table driven by
routingTags, not entity tags.
- Sharding-key counterexample: entity
{e}, sharding key {s}, the same e under two s values, COUNT_DISTINCT(e) ungrouped on s — must reject (an entity-only rule wrongly accepts this and returns 2 for one distinct value).
- Multi-stage rejection, including
DefaultStages expansion; paired with the positive case — the same target in two disjoint buckets of one stage must count once per bucket and must not be rejected.
- Ambiguous tag name resolves by family qualifier; an unqualified reference fails naming both families.
- Rejection is asserted at analyze time, before any node is contacted; error text is checked (it is the entire remediation path for the caller).
- Replica dedup: the same
(stage, shard, group) fed twice doesn't double-count; two partials differing only in shard do add.
- Standalone vs. distributed agreement;
replicas=0 vs. replicas=1 agreement (the permanent regression form of the parallel investigation above).
- The investigation's written finding, plus a
CHANGES.md entry if replicas=1 divergence reproduces.
Part 3 of 5 of the tag aggregation and time bucketing design, shipped in apache/skywalking-banyandb#1360.
Design references: §6 Semantics · §7.3 COUNT_DISTINCT on a single node · §7.4 COUNT_DISTINCT across data nodes · §7.6 Memory safety · §12 Delivery order (stages 4, 4a, 5)
Depends on: #14088 (API foundation + tag binding).
This is the highest-stakes issue in the series — a mis-evaluated push-down condition does not crash, it silently returns a plausible-looking wrong count. Test it hardest.
Scope
Standalone
pkg/query/aggregation/distinct.go:Distinctinterface (In/Val/Reset),NewDistinct(), keyed by the existingappendKeyComponentencoder (already handles every type the §6 matrix admits).aggSlotvariant; §6 matrix'sCOUNT_DISTINCTcolumn (string/int/bytes accepted, array/timestamp rejected).Consumereserves on each newly-addedvalue and refunds inClose; also fixesreduce.go:133'sentrySize=0so the liaison-side reduce isn't silently unbudgeted.NewMap/NewReducekeep returningerrUnknownFuncforCOUNT_DISTINCT— this is what makes aCOUNT_DISTINCTTopNAggregationrule fail loudly instead of mis-aggregating; needs a pinning test, not new guard code.Distributed push-down
routingTags ⊆ groupByTags ∪ {target}(routing key = the measure'ssharding_keyif configured, else its entity) — reject otherwise, with an error naming the target, the entity/routing key, and the two ways out.DefaultStagesbefore counting — never silently drop a stage).keyIndicesso each emitted partial is exactly one shard's contribution — a precondition fix, since today's partial is per-node, not per-shard.(stage, shard, group_key)— since hot/warm nodes can both report the same shard id with different, disjoint data; needs a stage field added to the partial wire schema.distributedAggFuncmapsCOUNT_DISTINCTto the existing SUM reducer (no new wire type, no newPartialshape).Parallel, non-gating investigation (§7.4 callout)
replicas=1experiment: compare distributedSUMagainst a raw scan atreplicas=1vs.replicas=0. Its outcome decides only whether the shard-in-keyIndicesfix above is also a live correctness fix for existingSUM/COUNT/MEAN(→ aCHANGES.mdentry and possibly a patch release) — not whether to build the fix, which ships in this issue regardless.Out of scope
Test / Definition of done
pkg/query/aggregation/distinct_test.go(zero tests exist there today): set semantics, reset, duplicate insertion doesn't grow the count.COUNT_DISTINCTover string/int/bytes targets; null values excluded; budget exhaustion fails with the existing"aggregation memory budget exceeded"message.09:01:00,09:01:34on a 1-minute measure) — assertCOUNTreports 2 andCOUNT_DISTINCTreports 1.routingTags, not entity tags.{e}, sharding key{s}, the sameeunder twosvalues,COUNT_DISTINCT(e)ungrouped ons— must reject (an entity-only rule wrongly accepts this and returns 2 for one distinct value).DefaultStagesexpansion; paired with the positive case — the same target in two disjoint buckets of one stage must count once per bucket and must not be rejected.(stage, shard, group)fed twice doesn't double-count; two partials differing only in shard do add.replicas=0vs.replicas=1agreement (the permanent regression form of the parallel investigation above).CHANGES.mdentry ifreplicas=1divergence reproduces.