Skip to content

feat(dataobj-compactor): IndexMerge executor over postings + stats sections - #22233

Merged
trevorwhitney merged 15 commits into
mainfrom
dataobj-compactor/index-merge-executor-core
Jun 5, 2026
Merged

feat(dataobj-compactor): IndexMerge executor over postings + stats sections#22233
trevorwhitney merged 15 commits into
mainfrom
dataobj-compactor/index-merge-executor-core

Conversation

@trevorwhitney

@trevorwhitney trevorwhitney commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

What this does

Part 5 (final) of splitting up #21972 (IndexMerge executor) into reviewable PRs.

Stacked on #22230 (indexobj MergeBuilder → which stacks on #22229 postings/stats primitives). Review/merge those first.

Replaces the zero-byte IndexMerge stub with the real K-way merge over index sections (postings + stats), producing one merged index object per task, and wires the compaction worker to drive it end to end.

Executor (executeIndexMerge)

  1. Existence-check on OutputIndexPath via bucket.Exists — retries short-circuit idempotently, making compaction re-entrant.
  2. Per-task TTL deadline via context.WithTimeout(node.TaskTTL).
  3. classifyRuns scans every section of each referenced source object (deduped by ObjectPath, opened once) and routes each by section type to a postings or stats pile, skipping streams/pointers/indexPointers. SectionIndex is the planner's stable tiebreaker, not a dispatch hint. A pre-flight pass asserts all stats sections share one SortSchema (the K-way merge comparator requires a total order).
  4. K-way merge over postings using the util/loser tournament tree (same primitive sortmerge uses for log sections), with a last-wins reducer that records full-key collisions via xcap.
  5. K-way merge over stats with an aggregating reducer: min/max/sum/sum on equal-key rows.
  6. Build + stream-upload via indexobj.MergeBuilder directly (no intermediate buffering). On ErrBuilderEmpty, upload a zero-byte sentinel.

Wiring

  • executor.Config grows ScratchStore + IndexobjCfg (grouped by use-case), both zero-value-safe for query-only callers.
  • The compaction worker threads ScratchStore (operator's configured scratch backend) and the new Cfg.DataObj.Compaction.IndexobjBuilder config end-to-end through enginecompactor.WorkerParamsengine/workerinternal/worker → per-task executor.Config.
  • DataObjCompactionWorker now depends on the ScratchStore module so the store initializes first.
  • The coordinator end-to-end test seeds index objects and asserts the merged outputs exist after the ToC swap.
@loki-gh-app loki-gh-app Bot added the type/docs Issues related to technical documentation; the Docs Squad uses this label across many repositories label Jun 3, 2026
@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

💻 Deploy preview available (feat(engine/executor,compactor): IndexMerge executor over postings + stats sections):

@trevorwhitney
trevorwhitney marked this pull request as ready for review June 3, 2026 17:54
@trevorwhitney
trevorwhitney requested a review from a team as a code owner June 3, 2026 17:54
Base automatically changed from dataobj-compactor/indexobj-merge-builder to dataobj-compactor/postings-merge-primitives June 3, 2026 19:23
Base automatically changed from dataobj-compactor/postings-merge-primitives to main June 3, 2026 20:43
…stats sections

Replaces the zero-byte IndexMerge stub with the real K-way merge over
index sections (postings + stats), producing one merged index object
per task, and wires the compaction worker to drive it end to end.

Executor (executeIndexMerge):
  1. Existence-check on OutputIndexPath via bucket.Exists, so retries
     short-circuit idempotently.
  2. Per-task TTL deadline via context.WithTimeout(node.TaskTTL).
  3. classifyRuns scans every section of each referenced source object
     (deduped by ObjectPath, opened once) and routes each by type to a
     postings or stats pile, skipping streams/pointers/indexPointers.
     SectionIndex is the planner's stable tiebreaker, not a dispatch
     hint, so dispatch must be by section type. A pre-flight pass
     asserts all stats sections share one SortSchema (the K-way merge
     comparator requires a total order).
  4. K-way merge over postings piles using the util/loser tournament
     tree (the same primitive sortmerge uses for log sections), with a
     last-wins reducer that records full-key collisions via xcap.
  5. K-way merge over stats piles with an aggregating reducer:
     min(min)/max(max)/sum(row_count)/sum(uncompressed_size) on
     equal-key rows.
  6. Build via indexobj.MergeBuilder and stream-upload the resulting
     object directly (no intermediate buffering). On ErrBuilderEmpty,
     upload a zero-byte sentinel so retries short-circuit on Exists.

executor.Config grows ScratchStore + IndexobjCfg (grouped by
use-case: query tuning / shared plumbing / query-side / compaction-
side). Both are zero-value-safe for query-only callers.

Wiring: the compaction worker threads ScratchStore (from the operator's
configured scratch backend) and the new Cfg.DataObj.Compaction.
IndexobjBuilder config end-to-end through enginecompactor.WorkerParams,
engine/worker, internal/worker, and the per-task executor.Config.
DataObjCompactionWorker now depends on the ScratchStore module so the
store is initialized first.

The coordinator end-to-end test seeds real index objects so the real
executor has sections to merge and asserts the merged outputs exist
after the ToC swap.
A per-task execution deadline is a task-orchestration concern, not part
of the physical plan the executor consumes. Carrying it on the node and
enforcing it in executeIndexMerge promotes orchestration policy into the
compute layer.

Remove the context.WithTimeout(node.TaskTTL) guard (and its dedicated
test) from the executor. The deadline will be reintroduced at the
orchestration layer (the wire Task envelope, alongside MaxTimeRange,
which already carries execution-control metadata outside the fragment)
as separate work covering both IndexMerge and LogMerge uniformly.

The TaskTTL field remains on physical.IndexMerge for now; its removal
and the orchestration-layer replacement are tracked separately.
@trevorwhitney
trevorwhitney force-pushed the dataobj-compactor/index-merge-executor-core branch from 5acee0d to a2ae6c8 Compare June 3, 2026 20:49

@rfratto rfratto left a comment

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.

first review pass, I need to take a much closer look at the index_merge*.go files

Comment thread pkg/engine/compactor/config.go Outdated
Comment on lines +170 to +175
defaults := NewConfig()
if cfg.MaxRunningCompactionTasks == 0 && cfg.IndexobjBuilder.TargetObjectSize == 0 {
cfg.MaxRunningCompactionTasks = defaults.MaxRunningCompactionTasks
cfg.Scheduler.Endpoint = defaults.Scheduler.Endpoint
cfg.IndexobjBuilder = defaults.IndexobjBuilder
}

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.

We've never really done a NewConfig pattern before, and it also doesn't work here: defining a flag will pre-set the pointer with the default value. I'd suggest getting rid of NewConfig and letting flag registration drive defaults, and find other solutions for the other call sites which are calling NewConfig().

@@ -37,14 +39,16 @@ type StreamFilterer interface {
}

type Config 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 added comments here are a little wonky, now the doc comment for BatchSize is "Query execution tuning" which is certainly strange.

It looks like they're meant to be markers for the struct field groupings rather than actual documentation on fields. Can we add blank lines after them?

Also, I don't know what the difference between "Query execution tuning" and "Query-side plumbing" is lol

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.

yeah, there's no real distinction there other than the "plumbing" was added later. I'll clean these up. The goal is to call out which is used by compaction vs query to point out the start of a smell I was getting these belong in different types, but I don't think the lines are clear enough/warranted in this PR.

Comment thread pkg/xcap/stats_definitions.go Outdated
Comment on lines +16 to +24
// IndexMerge statistics. Count the number of equal-key collisions observed
// during a K-way index merge. A non-zero value indicates the upstream
// invariant, that each (ObjectPath, SectionIndex) is referenced by exactly one
// source index, has been violated — the merge tolerates this via last-wins, but
// we want to know how often it happens.
var (
StatIndexMergeDuplicatePostings = NewStatisticInt64("index.merge.duplicate.postings", AggregationTypeSum)
StatIndexMergeDuplicateStats = NewStatisticInt64("index.merge.duplicate.stats", AggregationTypeSum)
)

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.

nit: defining stats centrally in xcap is a pattern I'm trying to get rid of; have an agent look at what pkg/engine does now for defining + reporting stats.

@trevorwhitney trevorwhitney changed the title feat(engine/executor,compactor): IndexMerge executor over postings + stats sections Jun 4, 2026

@rfratto rfratto left a comment

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.

Here's the other review pass :)

}

// statsPileReader reads stats.Stat records from a stats section in order.
type statsPileReader 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.

This threw me off so much and it took me a while to figure out why. I think it's because the pile term is being introduced here, but the API being implemented has nothing to do with piles, it's purely a row-based iterator over the columnar reader.

I'd suggest two things:

  • Remove pileIdx from the type, since it's only being stored to carry around state on behalf of one specific call site.

  • Move this to the stats package, calling it either stats.RowReader, stats.Iterator, stats.RowAdapter, or something that makes it more clear specifically what logic is being implemented.

Same comment applies to the type in index_merge_postings.go.

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.

yup, I removed all mention of pile here and moved these to row readers in their respective package. reads much better now, very helpful feedback!

// are reduced into a single record before being yielded.
//
// on return (success or error) every pile's Close is called.
func merge[R any](

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 generics made this a bit hard to understand, but I think I have a few suggestions for this:

The reducer logic kicks in when the current value is the same as the previous value. That can be done at the call site; the merge doesn't need to be doing that.

Removing the reducer logic from merge should simplify the state quite a bit. After you've refactored, you might find that we don't need to have a merge function at all. But if you think a merge function is still justifiable, I'd ask that we make some internal type definitions for cmp and the return iterator, which IMO would make the signature easier to understand.

BTW: In my last comment I suggested removing pileIdx from the row iterator adapter. You can encapsulate that here instead, since pileIdx matches up with the index in piles:

type positionedSeq struct {
  Seq   sequence[R] 
  Index int 
}

posSequences := make([]positionedSeq, len(seq)) 
for i := range seq {
  posSeqeunces[i] = positionedSeq{Seq: seq[i], Index: i} 
}  

// use posSequences in loser tree instead so you can access 
// their index for tie breaking. 

Though you actually might not need to use index as a tie breaker. I'd ask Bryan if it's necessary or if loser tree handles that automatically and guarantees stable ordering if there's equal keys.

@trevorwhitney trevorwhitney Jun 4, 2026

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.

after removing the reduce function that was just a closure around metrics anyway, this was greatly simplified to the point of being able to inline it and just invoke the loser tree directly at both call sites. @bboreham any thoughts on the need for the index as a tie breaker?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think it's stable; this line implies to me that two sequences that compare equal will alternate.

However it also doesn't seem hard to add a line there to use the index (as already known to loser tree) if it had a tri-state compare function. Since this version is inside Loki it's low-impact to try the change.

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.

seems worth doing at some point but I'm kicking that can for now, but I asked AI to create an issue #22285 (and it created a very verbose one, surprise surprise)

Spec and plan for moving the index-merge pile row iterators into the
postings/stats section packages as reusable RowReader types, with a thin
indexedSeq adapter in the executor to carry the merge pile index.
The executor's bespoke rowReader[R] interface duplicated the existing
pkg/iter/v2.CloseIterator[T] (Next/At/Err/Close). Rename RowReader.Value to
At so postings.RowReader and stats.RowReader implement iter.CloseIterator,
and have pileSequence embed it rather than redeclaring the same method set.
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

💻 Deploy preview deleted (feat(dataobj-compactor): IndexMerge executor over postings + stats sections).

@rfratto rfratto left a comment

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.

Much easier to follow :) Up to you if you want to try stable sorting in the loser tree directly or merge as is!

@trevorwhitney
trevorwhitney merged commit 030dd44 into main Jun 5, 2026
97 checks passed
@trevorwhitney
trevorwhitney deleted the dataobj-compactor/index-merge-executor-core branch June 5, 2026 17:58
zwmyxzs pushed a commit to zwmyxzs/loki that referenced this pull request Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/dataobj-compaction type/docs Issues related to technical documentation; the Docs Squad uses this label across many repositories

3 participants