Skip to content

feat(dataobj/postings,stats): Add merge builders and row decoders - #22229

Merged
trevorwhitney merged 5 commits into
mainfrom
dataobj-compactor/postings-merge-primitives
Jun 3, 2026
Merged

feat(dataobj/postings,stats): Add merge builders and row decoders#22229
trevorwhitney merged 5 commits into
mainfrom
dataobj-compactor/postings-merge-primitives

Conversation

@trevorwhitney

@trevorwhitney trevorwhitney commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

What this does

Part 1 of 6 splitting up #21972 (IndexMerge executor) into reviewable PRs.

Introduces the row-level primitives the IndexMerge compaction executor needs to merge index sections, kept entirely within the postings and stats section packages so they have no dependency on the engine/executor.

postings

  • MergeBuilder: a row-only sibling of Builder that stores already-aggregated label/bloom rows directly, bypassing the per-observation aggregator. Both builders produce identical sections.
  • Row / DecodeRow / BuildColumnIndex: canonical Arrow RecordBatch decoders, plus LabelEntry()/BloomEntry() conversions, so the executor and tests decode rows through one shared path.
  • LabelEntry/BloomEntry now carry int64 timestamps directly (matching the on-disk encoding); columnarEncode consumes the public types, dropping a round-trip and a bloom unmarshal on the merge path with no change to the observation hot path.
  • sortLabelEntries/sortBloomEntries: shared sort helpers so Builder and MergeBuilder flush agree by construction.

Stacked PR series (split of #21972)

  1. this PR — postings/stats primitives
  2. indexobj MergeBuilder (stacks on this) - refactor(dataobj/indexobj): Split observation and merge builders into separate types #22230
  3. physicalpb IndexMerge marshalling (independent) - feat(engine/physicalpb): Add IndexMerge node marshalling #22231
  4. workflow failed-task EOF fix (independent) - fix(engine/workflow): Surface failed-task errors before results EOF #22232
  5. executor core (stacks on 2) - feat(dataobj-compactor): IndexMerge executor over postings + stats sections #22233
  6. config/wiring (stacks on 5) - fix(deps): Update go toolchain directive to v1.26.4 [SECURITY] (main) - autoclosed #22234
Introduces the row-level primitives the IndexMerge compaction executor
needs to merge index sections, kept entirely within the section
packages so they have no dependency on the engine/executor.

postings:
  - MergeBuilder: a row-only sibling of Builder that stores
    already-aggregated label/bloom rows directly, bypassing the
    per-observation aggregator (which would double-count
    UncompressedSize when fed pre-merged rows). It encodes via the same
    columnarEncode helper as observation-mode flush, so both builders
    produce byte-identical on-disk sections — a querier cannot tell
    which one wrote a section.
  - Row / DecodeRow / BuildColumnIndex: canonical Arrow RecordBatch
    decoders, plus LabelEntry()/BloomEntry() conversions, so the
    executor and tests decode rows through one shared path instead of
    re-implementing column lookup.
  - LabelEntry/BloomEntry now carry int64 timestamps directly (matching
    the on-disk encoding), and columnarEncode consumes the public types,
    dropping a round-trip and a bloom unmarshal on the merge path with
    no change to the observation hot path.
  - sortLabelEntries/sortBloomEntries: shared sort helpers so Builder
    and MergeBuilder flush agree by construction.

stats:
  - Row decoding (ColumnIndex/BuildColumnIndex/DecodeRow) mirroring the
    postings decoders, reusing the existing Stat type.

Observation-mode Builder behavior and on-disk bytes are unchanged.
rfratto
rfratto previously approved these changes Jun 3, 2026
Comment on lines +12 to +17
// MergeBuilder accumulates posting entries from pre-aggregated rows (e.g., from
// existing postings sections) and merges them. Unlike Builder, which aggregates
// per-observation, MergeBuilder accepts already-aggregated entries.
// Call [MergeBuilder.Flush] to encode all accumulated data and write a postings
// section to the provided [dataobj.SectionWriter].
type MergeBuilder struct {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name here is a little confusing, but I really don't have any better suggestions for what it should be instead

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, but I don't know what to call it, let'd follow up on this later if it's still a concern

return fmt.Errorf("label posting entry with key (%q, %d, %q, %q) already exists", entry.ObjectPath, entry.SectionIndex, entry.ColumnName, entry.LabelValue)
}

b.labels[key] = &entry

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure why this is taking a reference. Feels like an unnecessary allocation?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did it to avoid the map copy, but you're right this is worse because of allocation / heap escape. fixed.

Storing *LabelEntry/*BloomEntry forced the by-value parameter to escape to
the heap on every Append call, adding a per-entry allocation in the merge
hot path. The entries are never mutated after insertion and Flush copies
them back into value slices, so the pointer indirection bought nothing.
Store values directly to drop the allocation.
@trevorwhitney
trevorwhitney merged commit 03cd800 into main Jun 3, 2026
89 checks passed
@trevorwhitney
trevorwhitney deleted the dataobj-compactor/postings-merge-primitives branch June 3, 2026 20:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

2 participants