Skip to content

feat: add stats and postings index sections with encoding abstraction - #21442

Merged
trevorwhitney merged 13 commits into
mainfrom
twhitney/new-index-sections
Apr 10, 2026
Merged

feat: add stats and postings index sections with encoding abstraction#21442
trevorwhitney merged 13 commits into
mainfrom
twhitney/new-index-sections

Conversation

@trevorwhitney

@trevorwhitney trevorwhitney commented Apr 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Implement stats and postings section packages for index objects, enabling sort-schema-based storage locality
  • Introduce an encoding abstraction (SectionEncoder, ColumnReader, Section) so section packages can swap between encoding implementations at runtime. this was done to make it easier to switch to the new dataset APIs @rfratto is currently experimenting with if they come to fruition.
  • Wire new sections into the index builder pipeline with new calculation steps and builder methods
  • Sort schema is injectable (currently hardcoded to [service_name], designed for future per-tenant configuration) cc @shantanualsi
  • On-disk serialization is deferred — builders use in-memory round-trip for testing; Flush will be implemented in a followup PR

Commit-by-commit review guide

The bulk of the work is done if the first 3 commits:

  1. feat(stats): add stats section package — Full section package: type definitions, builder (accumulate/sort/split), columnar encoder, reader, row reader, 12 tests.
  2. feat(postings): add postings section package — Same pattern with nullable columns (label_value, bloom_filter), binary columns (stream_id_bitmap), and bitmap normalization. 14 tests.
  3. feat(index): wire stats and postings into index builder pipeline — streamLabels context plumbing, AppendStat/AppendLabelPosting/AppendBloomPosting on indexobj.Builder, statsCalculation + labelPostingsCalculation + columnValuesCalculation bloom extension, injectable sort schema via shared constants.

Encoding abstraction

The section packages define a SectionEncoder function type that the builder receives at construction. The current implementation (ColumnarEncoder) builds columnar arrays directly using the existing pkg/columnar primitives. This was done with the hopes of being able to easily add a DatasetEncoder implementation if that investigation proves fruitful. Doing it this way the two can be swapped in via config — no changes to builders, readers, or calculation steps (though we will need to think about how to track the format/version changes in the objects themselves).

Builder (accumulate, sort, split) — encoding-agnostic
  → SectionEncoder (injected function)
    ├─ ColumnarEncoder (current, uses pkg/columnar)
    └─ DatasetEncoder  (future, uses pkg/dataset/array)
  → Section (encoding-agnostic)
    → ColumnReader interface
      → RowReader (uses columnar.Array — shared by both paths)
Design spec

Stats Section

Type: {Namespace: "github.com/grafana/loki", Kind: "stats", Version: 1}

One row per unique (object_path, section_index, label_value) in the sort schema.

Column Type Description
object_path UTF8 Path to the logs data object
section_index Int64 Section index within the data object
run_id Int64 FNV-64a hash of object_path
sort_schema UTF8 Comma-delimited sort key names
service_name UTF8 Label value for the sort key
min_timestamp Int64 Min timestamp (UnixNano)
max_timestamp Int64 Max timestamp (UnixNano)
row_count Int64 Log record count
uncompressed_size Int64 Sum of len(log.Line)

Sort order: [service_name, min_timestamp]

Postings Section

Type: {Namespace: "github.com/grafana/loki", Kind: "postings", Version: 1}

One row per (kind, object_path, section_index, column_name, label_value) tuple.

Column Type Nullable Description
kind Int64 No 0=Bloom, 1=Label
object_path UTF8 No Path to the logs data object
section_index Int64 No Section index
column_name UTF8 No Indexed label/column name
label_value UTF8 Yes Label value (Kind=Label only)
bloom_filter Binary Yes Bloom filter bytes (Kind=Bloom only)
stream_id_bitmap Binary No LSB bit-packed bitmap (Arrow convention)
uncompressed_size Int64 No Sum of len(log.Line)
min_timestamp Int64 No Min timestamp (UnixNano)
max_timestamp Int64 No Max timestamp (UnixNano)

Sort order: [kind, column_name, label_value, min_timestamp]

Stream ID Bitmap

  • Uses source stream IDs from the data object (scoped per data object per tenant)
  • LSB numbering matching Arrow validity bitmaps: bitmap[N/8] & (1 << (N%8))
  • All bitmaps in a section normalized to the same length for correct bitwise operations

Section Splitting

Accumulate-sort-then-split: rows sorted globally, streamed into sections up to TargetSectionSize. Split points are size-based, not aligned to sort key boundaries.

Future work

  • Dataset API encoder — Add encode_dataset.go implementations when pkg/dataset/array lands (from Robert's branch). The encoding abstraction is already in place.
  • Section storage format — On-disk serialization for persisting sections to index objects.
  • Query path integration — Wire postings reads into the metastore.
  • Configurable sort schema — Per-tenant configuration (injection point exists in calculation steps).
@trevorwhitney
trevorwhitney force-pushed the twhitney/new-index-sections branch 8 times, most recently from 87d3d3e to f246574 Compare April 7, 2026 19:51
@trevorwhitney
trevorwhitney force-pushed the twhitney/new-index-sections branch 7 times, most recently from 26eccd6 to 06133b0 Compare April 7, 2026 21:26
@trevorwhitney
trevorwhitney force-pushed the twhitney/new-index-sections branch from 06133b0 to bb7b8b6 Compare April 7, 2026 21:32
@trevorwhitney

Copy link
Copy Markdown
Collaborator Author

@cursor please review

@cursor

cursor Bot commented Apr 7, 2026

Copy link
Copy Markdown

Excellent! The review document has been created. Now let me create a concise summary message for the user:

PR Review Complete ✅

I've completed a comprehensive review of your PR implementing stats and postings index sections with encoding abstraction. Here's my assessment:

Overall: APPROVED

The implementation is excellent - well-architected, thoroughly tested, and production-ready for the in-memory round-trip phase.

Key Findings

Strengths:

  • Encoding Abstraction: Clean design that makes swapping implementations trivial
  • Test Coverage: 40 tests total across all packages, all passing
  • Code Quality: Follows Go best practices, proper error handling, good documentation
  • Architecture: Clear separation of concerns between builder, encoder, and reader layers

Test Results:

✅ pkg/dataobj/sections/stats: 12/12 tests pass (0.007s)
✅ pkg/dataobj/sections/postings: 14/14 tests pass (0.004s)  
✅ pkg/dataobj/index: 10/10 tests pass (0.727s)
✅ pkg/dataobj/index/indexobj: 4/4 tests pass (0.364s)

Detailed Review Areas

  1. Stats Section

    • Correct sort implementation (label values → timestamp)
    • Size-based section splitting works correctly
    • Dynamic label columns handled elegantly
    • Excellent test coverage including edge cases
  2. Postings Section

    • Nullable column handling is correct
    • Bitmap normalization ensures bitwise ops will work
    • LSB bit ordering matches Arrow convention
    • Sort order [Kind, ColumnName, LabelValue, MinTimestamp] verified
  3. Index Integration

    • Stats calculation uses composite keys to avoid ambiguity
    • Label postings builds bitmaps correctly with auto-resize
    • Builder integration is clean with lazy initialization
    • Deferred serialization approach is intentional and correct

Minor Suggestions (Non-blocking)

  1. Sort Schema Parsing: Consider extracting to shared helper (duplicated in 2 places)
  2. TODO Clarification: Add note that TODOs wait for on-disk flush implementation
  3. Size Estimation: Document the 6*8 bytes calculation more explicitly

No Critical Issues Found

  • No memory leaks, race conditions, or panics
  • No incorrect error handling or security concerns
  • All edge cases properly tested

Commit Structure

The commit-by-commit approach is excellent:

  1. Stats section package
  2. Postings section package
  3. Multi-key sort schema support
  4. Index builder integration
  5. Linter fixes

Each commit is self-contained and reviewable independently.


Full detailed review saved to: /workspace/PR_REVIEW.md

The PR is ready to merge! The encoding abstraction provides a solid foundation for adding the Dataset encoder when Robert's work lands, and the deferred on-disk serialization is the right approach for this phase.

Open in Web Open in Cursor 
@cla-assistant

cla-assistant Bot commented Apr 7, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@cla-assistant

cla-assistant Bot commented Apr 7, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ trevorwhitney
❌ cursoragent
You have signed the CLA already but the status is still pending? Let us recheck it.

@trevorwhitney
trevorwhitney force-pushed the twhitney/new-index-sections branch from 7754a7b to 0b05cad Compare April 7, 2026 21:55
@trevorwhitney
trevorwhitney marked this pull request as ready for review April 7, 2026 21:59
@trevorwhitney
trevorwhitney requested a review from a team as a code owner April 7, 2026 21:59
…ocations

Clamp RowReader.Read output to destination buffer size in both stats and
postings sections to prevent out-of-bounds writes when callers pass a
buffer smaller than the section row count.

Hoist strings.Builder out of the per-record loop in statsCalculation and
defer labelMap allocation to the new-aggregate branch to reduce GC
pressure in the hot path.

@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.

Good start! I didn't look at the stats section because I imagine I'd have the same feedback for it that I had with the postings section.

Comment thread pkg/dataobj/index/label_postings_calculation.go
Comment thread pkg/dataobj/index/label_postings_calculation.go Outdated
Comment thread pkg/dataobj/index/stats_calculation.go Outdated
Comment thread pkg/dataobj/index/stats_calculation.go Outdated
Comment thread pkg/dataobj/index/stats_calculation.go Outdated
Comment thread pkg/dataobj/index/stats_calculation.go Outdated
Comment thread pkg/dataobj/index/column_values.go Outdated
//
// Flush writes are done via [Builder.Flush], which encodes all accumulated
// rows into a set of [Section] values that can be read back via a [Reader].
type Builder 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 type should implement the dataobj.SectionBuilder interface to fully wire up properly (the Flush method doesn't match).

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 think this will change where/how we wire up the encoder, so in my mind it makes most sense to do this in the next PR when implement serialization. I'll add a TODO to track this.

- Use SHA-224 for stats run ID instead of FNV-1a for consistency with
  data object key generation
- Replace strings.Builder with FNV-64a hash for stats composite key to
  avoid per-record allocations (strings.Builder.Reset discards memory)
- Create label postings for all stream label key/value pairs, not just
  sort schema keys, so all indexed labels are queryable
- Add Name() to logsIndexCalculation interface for per-step metrics
- Add calculator step duration histogram metric
- Add benchmark for label postings ProcessBatch
- Simplify column_values ProcessBatch with single early-return guard
- Add SectionBuilder TODOs to postings and stats builders
- Wire calculator metrics registration into index builder

@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.

LGTM!

Comment thread pkg/dataobj/index/calculate.go Outdated
objectPath string
sectionIdx int64
streamIDLookup map[int64]int64
streamLabels map[int64]labels.Labels // source stream ID -> labels

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.

Can we add a TODO here to monitor memory usage? This is going to be a shared object-scoped map. Streams sections are usually small enough that it shouldn't be a problem, but if it is we'll need to find a different approach here.

for k := range c.postingsByKey {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {

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: slices.SortFunc is the "modern" way to do this (and faster)

"github.com/grafana/loki/v3/pkg/memory"
)

type labelPostingsCalculation 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.

Can we add a doc comment here explaining that this is section-scoped? Ditto for all the other ones.

Comment thread pkg/dataobj/index/stats_calculation.go Outdated
return nil
}

func writeHashKey(h hash.Hash64, i int, key, value string) error {

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.

Can you write a benchmark for this? Since this is on the hot path and h is an interface, I think this will be kind of slow.

If it is slow, you can fix it by passing in a *bytes.Buffer, writing your key into that, then passing the buffer into h.Write (and resetting the buffer between calls).

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.

that was a good call, ~3x faster with zero allocations.

Comment thread pkg/dataobj/index/stats_calculation.go Outdated
Comment on lines +117 to +120
// Compute run-ID from object path using SHA-224.
// We take the first 8 bytes as an int64.
sum := sha256.Sum224([]byte(calcCtx.objectPath))
runID := int64(binary.BigEndian.Uint64(sum[:8]))

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.

I know I mentioned that we should store a run ID to make it easier to easily know how to group sections by whether they follow the sort schema, but I think we should take this out in favour of trying to group things at run time by looking at the stats section.

This will particularly be helpful if we get a set of sections that already happen to be sorted and so they can be implicitly merged into an existing "run" without needing compaction to generate a new run ID first.


// Append adds a [Posting] row to the builder.
func (b *Builder) Append(p Posting) {
b.rows = append(b.rows, p)

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.

I'm worried about the memory overhead of accumulating all postings for the lifetime of the builder, but I think we can revisit this once you implement serialization.

// [Kind, ColumnName, LabelValue, MinTimestamp]. All comparisons are
// lexicographic. Nil LabelValue sorts as empty string so Bloom entries come
// before Label entries for the same column_name.
func comparePostings(a, b Posting) bool {

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.

Can we have an ultimate tiebreaker if all other things match? You can use comparison object paths + section indexes.

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.

for posterity, I decided not to do this as the dataobj and section should be guaranteed to be the same here. I did add a final tie breaker per @rfratto's suggestion of max timestamp, so if min timestamps are equal the posting with the smaller max timestamp comes first.

ObjectPath string
SectionIndex int64
ColumnName string
LabelValue *string // nil for Bloom postings

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.

A pointer to a string feels odd to me. I know this is for blooms, but can we leave this as a string and then have the encoder encode a NULL for this column (regardless of what might be in LabelValue)?

)

for i, r := range b.rows {
rowSize := 5*8 + len(r.ObjectPath) + len(r.ColumnName) + len(r.BloomFilter) + len(r.StreamIDBitmap) // 5 int64 columns × 8 bytes + variable-length fields

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 should probably be a helper method on Posting

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're moving towards columnar reads across the board. Is now the time to switch this over to columnar reads so we don't have to refactor it later? (I'm ok with this being a follow up)

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.

will do after serialization

- Replace sort.Slice with slices.SortFunc in label_postings_calculation
  and stats_calculation for idiomatic Go
- Optimize writeHashKey by building composite key in bytes.Buffer before
  hashing, eliminating per-Write string-to-byte allocations (~3x faster)
- Change Posting.LabelValue from *string to string; use empty string for
  Bloom postings and Kind check in encoder for null column writes
- Add MaxTimestamp as final tie-breaker in postings and stats sort order
- Extract Posting.Size() helper method for row size estimation
- Remove unused readNullableStringColumn/extractNullableStringValues
- Fix unused runIDs variable in stats row_reader
@trevorwhitney
trevorwhitney merged commit 8553815 into main Apr 10, 2026
88 checks passed
@trevorwhitney
trevorwhitney deleted the twhitney/new-index-sections branch April 10, 2026 23:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

2 participants