laskoviymishka commented on code in PR #2006:
URL: https://github.com/apache/iceberg-go/pull/2006#discussion_r4046674822
##########
table/evaluators.go:
##########
@@ -1901,3 +1901,37 @@ func newBloomFilterPredicatesFromRewritten(expr
iceberg.BooleanExpression) ([]in
return iceberg.VisitExpr(expr, &bloomPredicateCollector{})
}
+
+// newDictionaryPredicates reuses the same conservative EqualTo/In collector
+// as Bloom filters. The physical literal bytes are also the representation
+// needed to compare values decoded from a PLAIN dictionary page.
+func newDictionaryPredicates(expr iceberg.BooleanExpression)
([]internal.RowGroupDictionaryPred, error) {
+ if expr == nil {
+ return nil, nil
+ }
+
+ rewritten, err := iceberg.RewriteNotExpr(expr)
+ if err != nil {
+ return nil, err
+ }
+
+ return newDictionaryPredicatesFromRewritten(rewritten)
+}
+
+func newDictionaryPredicatesFromRewritten(expr iceberg.BooleanExpression)
([]internal.RowGroupDictionaryPred, error) {
+ bloomPreds, err := newBloomFilterPredicatesFromRewritten(expr)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(bloomPreds) == 0 {
+ return nil, nil
+ }
+
+ dictionaryPreds := make([]internal.RowGroupDictionaryPred,
len(bloomPreds))
+ for i, pred := range bloomPreds {
+ dictionaryPreds[i] = internal.RowGroupDictionaryPred(pred)
Review Comment:
This bare conversion is what makes the "can evolve independently" line on
`RowGroupDictionaryPred` untrue in practice. It only compiles because both
structs have identical fields in the same order today, and if
`RowGroupBloomPred` ever gains a bloom-specific field this silently
zero-initializes it in the dict copy with no compile error and no failing test.
I'd either drop the "evolve independently" claim and note that the two are
intentionally coupled, or collect the dict predicates with their own visitor.
Not blocking, but worth picking one before the comment and the code drift apart.
##########
table/internal/parquet_files.go:
##########
@@ -1936,12 +1959,30 @@ func (w wrapPqArrowReader) GetRecords(ctx
context.Context, cols []int, tester an
}
var (
- fieldIDToColIdx map[int]int
- bfReader *metadata.BloomFilterReader
+ fieldIDToColIdx map[int]int
+ bfReader *metadata.BloomFilterReader
+ dictionaryReader *file.Reader
)
- if len(rowGroupTester.BloomPreds) > 0 {
+ if len(rowGroupTester.BloomPreds) > 0 ||
len(rowGroupTester.DictionaryPreds) > 0 {
fieldIDToColIdx = buildFieldIDToColIdx(fileMeta)
+ }
+ var dictionaryPredsByColumn map[int][]int
+ if len(rowGroupTester.DictionaryPreds) > 0 {
+ dictionaryPredsByColumn =
groupRowGroupDictionaryPredicates(
+ fieldIDToColIdx, rowGroupTester.DictionaryPreds)
+ if len(dictionaryPredsByColumn) > 0 &&
w.dictionarySource != nil {
+ // Dictionary inspection is an optional
optimisation. If the
+ // section-read reader cannot be created, use
the main reader and
+ // keep the existing conservative behaviour.
+ dictionaryReader, _ =
newParquetDictionaryReader(
Review Comment:
I'd make the ownership of this reader explicit before merge. When
`newParquetDictionaryReader` succeeds we get an owned `*file.Reader` that's
never closed, and we can't just add a `defer dictionaryReader.Close()` to fix
that: `file.Reader.Close()` closes the underlying `io.Closer`, and
`dictionarySource` is the same handle the main reader is still using, so
closing it corrupts every subsequent read.
So the code is only correct because it leaks, and the fallback branch
(`dictionaryReader = w.ParquetReader()`) is a borrowed reader that must never
be closed at all. That's a fragile contract for the next person.
I'd wrap `dictionarySource` in a non-closeable shim before handing it to
`newParquetDictionaryReader`, then `defer Close()` on the owned reader only
(frees its buffer pool, leaves the shared source alone). While we're here, the
fallback-to-main-reader path has no test and it shares reader state with the
subsequent data read, so it's worth one.
##########
table/internal/parquet_files.go:
##########
@@ -2019,6 +2075,302 @@ func (w wrapPqArrowReader) GetRecords(ctx
context.Context, cols []int, tester an
return w.GetRecordReader(ctx, cols, rgList)
}
+// Once a few consecutive row groups survive dictionary checks, further checks
+// are unlikely to pay for themselves. Stopping is fail-open: it can only leave
+// extra row groups to the normal reader, never drop matching data.
+const parquetDictionaryKeepStreakLimit = 2
+
+func newParquetDictionaryReader(
+ source parquet.ReaderAtSeeker, fileMeta *metadata.FileMetaData, mem
memory.Allocator,
+) (*file.Reader, error) {
+ readProps := parquet.NewReaderProperties(mem)
+ readProps.BufferedStreamEnabled = true
+
+ return file.NewParquetReader(source,
+ file.WithMetadata(fileMeta), file.WithReadProps(readProps))
+}
+
+func groupRowGroupDictionaryPredicates(
+ fieldIDToColIdx map[int]int,
+ preds []RowGroupDictionaryPred,
+) map[int][]int {
+ predsByColumn := make(map[int][]int)
+ for i, pred := range preds {
+ if len(pred.PhysBytes) == 0 {
+ continue
+ }
+
+ colIdx, ok := fieldIDToColIdx[pred.FieldID]
+ if ok {
+ predsByColumn[colIdx] = append(predsByColumn[colIdx], i)
+ }
+ }
+
+ return predsByColumn
+}
+
+// checkRowGroupDictionaries checks each dictionary predicate against the
+// complete dictionary for its column in row group rg. It returns false only
+// when the column metadata proves that every data page is dictionary encoded
+// and none of the predicate values occur in the dictionary. Missing encoding
+// metadata, dictionary pages, unsupported physical types, malformed pages, or
+// reader errors keep the row group so this optimisation cannot drop data.
+func checkRowGroupDictionaries(
+ rdr *file.Reader,
+ fileMeta *metadata.FileMetaData,
+ rg int,
+ predsByColumn map[int][]int,
+ preds []RowGroupDictionaryPred,
+) bool {
+ if len(preds) == 0 || len(predsByColumn) == 0 {
+ return true
+ }
+
+ rgMeta := fileMeta.RowGroup(rg)
+ rgReader := rdr.RowGroup(rg)
+ for colIdx, predIndexes := range predsByColumn {
+ chunk, err := rgMeta.ColumnChunk(colIdx)
+ if err != nil || !parquetColumnUsesOnlyDictionaryData(chunk) {
+ continue
+ }
+
+ column := fileMeta.Schema.Column(colIdx)
+ pageRdr, err := rgReader.GetColumnPageReader(colIdx)
+ if err != nil {
+ continue
+ }
+
+ dictPage, dictErr := pageRdr.GetDictionaryPage()
+ if dictErr != nil || dictPage == nil {
+ if dictPage != nil {
+ dictPage.Release()
+ }
+ _ = pageRdr.Close()
+
+ continue
+ }
+
+ matches, known := dictionaryMatchesPredicates(
+ dictPage, column.PhysicalType(), column.TypeLength(),
preds, predIndexes)
+ dictPage.Release()
+ closeErr := pageRdr.Close()
+ if !known {
+ continue
+ }
+ if closeErr != nil {
+ // A close error makes an otherwise conclusive
dictionary result
+ // unusable, so keep the row group conservatively.
+ slog.Warn("dictionary row-group pruning skipped after
page reader close error",
+ "rowGroup", rg, "err", closeErr)
+
+ continue
+ }
+
+ for _, predIndex := range predIndexes {
+ if !matches[predIndex] {
+ return false
+ }
+ }
+ }
+
+ return true
+}
+
+// parquetColumnUsesOnlyDictionaryData verifies that the optional encoding
+// statistics identify a dictionary page and dictionary-encoded data pages,
+// with no PLAIN fallback pages. EncodingStats is deliberately required: the
+// column encoding list cannot distinguish the PLAIN dictionary page itself
+// from a later PLAIN data page in older files.
+func parquetColumnUsesOnlyDictionaryData(chunk *metadata.ColumnChunkMetaData)
bool {
+ if !chunk.HasDictionaryPage() {
+ return false
+ }
+
+ stats := chunk.EncodingStats()
+ if len(stats) == 0 {
+ return false
+ }
+
+ var hasDictionaryPage, hasDictionaryData bool
+ for _, stat := range stats {
+ switch stat.PageType {
+ case file.PageTypeDictionaryPage:
+ if stat.Encoding != parquet.Encodings.Plain &&
stat.Encoding != parquet.Encodings.PlainDict {
+ return false
+ }
+ hasDictionaryPage = true
+ case file.PageTypeDataPage, file.PageTypeDataPageV2:
+ if stat.Encoding != parquet.Encodings.RLEDict &&
stat.Encoding != parquet.Encodings.PlainDict {
+ return false
+ }
+ hasDictionaryData = true
+ default:
+ return false
+ }
+ }
+
+ return hasDictionaryPage && hasDictionaryData
+}
+
+// dictionaryMatchesPredicates decodes a PLAIN dictionary page and records
+// which requested predicates have at least one matching value. The returned
+// known flag is false for unsupported or malformed input that prevents a
+// pruning decision, which means the caller must retain the row group.
+func dictionaryMatchesPredicates(
+ page *file.DictionaryPage,
+ physicalType parquet.Type,
+ typeLen int,
+ preds []RowGroupDictionaryPred,
+ predIndexes []int,
+) ([]bool, bool) {
+ pageEncoding := parquet.Encoding(page.Encoding())
+ if pageEncoding != parquet.Encodings.Plain && pageEncoding !=
parquet.Encodings.PlainDict {
+ return nil, false
+ }
+
+ width, variableWidth, ok := parquetDictionaryValueLayout(physicalType,
typeLen)
+ if !ok {
+ return nil, false
+ }
+
+ for _, predIndex := range predIndexes {
+ usable := false
+ for _, candidate := range preds[predIndex].PhysBytes {
+ if variableWidth || len(candidate) == width {
+ usable = true
+
+ break
+ }
+ }
+ if !usable {
+ return nil, false
+ }
+ }
+
+ // predIndexes contains indexes into the full preds slice, so matches
keeps
+ // the global predicate indexes instead of remapping them per column.
+ matches := make([]bool, len(preds))
+ data := page.Data()
+ offset := 0
+ numValues := page.NumValues()
Review Comment:
One edge here I'd guard: if a page reports `NumValues() == 0` with empty
data, the decode loop is a no-op, `remaining` stays above 0, and `offset !=
len(data)` is `0 != 0` (false), so we return `(matches, true)` with every match
false and the caller prunes the group.
`parquetColumnUsesOnlyDictionaryData` makes that unreachable for a valid
file, but a malformed page slips past it because the completeness check runs
before we read the page. This is the one path where a degenerate page causes an
incorrect prune instead of a conservative keep, which is exactly the invariant
this rests on.
`if numValues == 0 { return nil, false }` right after the `< 0` guard closes
it.
##########
table/internal/parquet_files.go:
##########
@@ -1990,6 +2033,19 @@ func (w wrapPqArrowReader) GetRecords(ctx
context.Context, cols []int, tester an
}
}
+ if use && dictionaryPruningActive {
+ use = checkRowGroupDictionaries(
+ dictionaryReader, fileMeta, rg,
dictionaryPredsByColumn, rowGroupTester.DictionaryPreds)
+ if use {
+ dictionaryKeepStreak++
+ if dictionaryKeepStreak >=
parquetDictionaryKeepStreakLimit {
+ dictionaryPruningActive = false
Review Comment:
This is the piece I'd hold on. Once `dictionaryKeepStreak` hits 2,
`dictionaryPruningActive` flips to false and stays false for the rest of the
file. The `dictionaryKeepStreak = 0` reset only runs inside the `use &&
dictionaryPruningActive` branch, so a later prunable group never turns pruning
back on.
For the common shape (a value clustered in a few groups, then absent across
a long tail) that means we stop pruning right where the savings are: groups 0-4
pruned, 5 and 6 kept, then 7-99 read without a dict check even though none of
them contain the value. The `dictionary target in every group` benchmark only
exercises the case where nothing was prunable anyway, so nothing flags the
regression.
I'd either raise the limit (Java-side heuristics sit around 8-16) or tie the
cutoff to the fraction of groups pruned rather than an absolute streak, and add
a unit test with a 4+ group file where the target is in groups 0-1 and absent
after, asserting on Survivors that the tail still gets pruned. wdyt?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]