feat: add stats and postings index sections with encoding abstraction - #21442
Conversation
87d3d3e to
f246574
Compare
26eccd6 to
06133b0
Compare
…lculation pipeline
06133b0 to
bb7b8b6
Compare
|
@cursor please review |
|
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 FindingsStrengths:
Test Results: Detailed Review Areas
Minor Suggestions (Non-blocking)
No Critical Issues Found
Commit StructureThe commit-by-commit approach is excellent:
Each commit is self-contained and reviewable independently. Full detailed review saved to: 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. |
|
|
7754a7b to
0b05cad
Compare
…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
left a comment
There was a problem hiding this comment.
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.
| // | ||
| // 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 { |
There was a problem hiding this comment.
This type should implement the dataobj.SectionBuilder interface to fully wire up properly (the Flush method doesn't match).
There was a problem hiding this comment.
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
| objectPath string | ||
| sectionIdx int64 | ||
| streamIDLookup map[int64]int64 | ||
| streamLabels map[int64]labels.Labels // source stream ID -> labels |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
nit: slices.SortFunc is the "modern" way to do this (and faster)
| "github.com/grafana/loki/v3/pkg/memory" | ||
| ) | ||
|
|
||
| type labelPostingsCalculation struct { |
There was a problem hiding this comment.
Can we add a doc comment here explaining that this is section-scoped? Ditto for all the other ones.
| return nil | ||
| } | ||
|
|
||
| func writeHashKey(h hash.Hash64, i int, key, value string) error { |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
that was a good call, ~3x faster with zero allocations.
| // 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])) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
Can we have an ultimate tiebreaker if all other things match? You can use comparison object paths + section indexes.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
This should probably be a helper method on Posting
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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


Summary
statsandpostingssection packages for index objects, enabling sort-schema-based storage localitySectionEncoder,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.[service_name], designed for future per-tenant configuration) cc @shantanualsiFlushwill be implemented in a followup PRCommit-by-commit review guide
The bulk of the work is done if the first 3 commits:
feat(stats): add stats section package— Full section package: type definitions, builder (accumulate/sort/split), columnar encoder, reader, row reader, 12 tests.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.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
SectionEncoderfunction type that the builder receives at construction. The current implementation (ColumnarEncoder) builds columnar arrays directly using the existingpkg/columnarprimitives. This was done with the hopes of being able to easily add aDatasetEncoderimplementation 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).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.
object_pathsection_indexrun_idobject_pathsort_schemaservice_namemin_timestampmax_timestamprow_countuncompressed_sizelen(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.
kindobject_pathsection_indexcolumn_namelabel_valuebloom_filterstream_id_bitmapuncompressed_sizelen(log.Line)min_timestampmax_timestampSort order:
[kind, column_name, label_value, min_timestamp]Stream ID Bitmap
bitmap[N/8] & (1 << (N%8))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
encode_dataset.goimplementations whenpkg/dataset/arraylands (from Robert's branch). The encoding abstraction is already in place.