-
Notifications
You must be signed in to change notification settings - Fork 229
perf(table): filter position-delete reads by file path #1937
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
819aaca
b0cc2d1
7b31629
c90c455
27f5b81
89aeb79
ab57a44
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -54,15 +54,16 @@ const ( | |
| var PositionalDeleteArrowSchema, _ = SchemaToArrowSchema(iceberg.PositionalDeleteSchema, nil, true, false) | ||
|
|
||
| type ( | ||
| positionDeletes = []*arrow.Chunked | ||
| perFilePosDeletes = map[string]positionDeletes | ||
| positionDeletes = []*arrow.Chunked | ||
| perFilePosDeletes = map[string]positionDeletes | ||
| perDeleteFileTargets = map[string]map[string]struct{} | ||
| ) | ||
|
|
||
| // releasePerFilePosDeletes releases every Arrow chunk in a positional-delete | ||
| // map. Required on every error return between readAllDeleteFiles and the | ||
| // iterator returned by createIterator — Arrow allocations are not freed by | ||
| // GC, so dropping the map on the floor leaks the chunks. Safe to call on a | ||
| // nil map; the nil-chunk guard is defensive — readDeletes never inserts a | ||
| // nil map; the nil-chunk guard is defensive — position-delete readers never insert a | ||
| // nil *arrow.Chunked, but the guard keeps callers safe if that invariant | ||
| // ever changes (e.g. when readAllDeletionVectors lands and starts merging | ||
| // into the same map). | ||
|
|
@@ -79,16 +80,35 @@ func releasePerFilePosDeletes(deletesPerFile perFilePosDeletes) { | |
| func readAllDeleteFiles(ctx context.Context, fs iceio.IO, tasks []FileScanTask, concurrency int) (perFilePosDeletes, error) { | ||
| deletesPerFile := make(perFilePosDeletes) | ||
| uniqueDeletes := make(map[string]iceberg.DataFile) | ||
| targetsByDelete := make(perDeleteFileTargets) | ||
|
|
||
| for _, t := range tasks { | ||
| for _, d := range t.DeleteFiles { | ||
| if d.ContentType() != iceberg.EntryContentPosDeletes { | ||
| continue | ||
| } | ||
|
|
||
| if _, ok := uniqueDeletes[d.FilePath()]; !ok { | ||
| uniqueDeletes[d.FilePath()] = d | ||
| deletePath := d.FilePath() | ||
| if _, ok := uniqueDeletes[deletePath]; !ok { | ||
| uniqueDeletes[deletePath] = d | ||
| } | ||
|
|
||
| targets, ok := targetsByDelete[deletePath] | ||
| if !ok { | ||
| targets = make(map[string]struct{}) | ||
| targetsByDelete[deletePath] = targets | ||
| } | ||
| // A nil target set means that at least one task did not carry a | ||
| // usable data-file path. Keep the old whole-file read in that case. | ||
| if targets == nil { | ||
| continue | ||
| } | ||
| if t.File == nil || t.File.FilePath() == "" { | ||
| targetsByDelete[deletePath] = nil | ||
|
|
||
| continue | ||
| } | ||
| targets[t.File.FilePath()] = struct{}{} | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. minor — Filtering also drops deletes from a delete file not assigned to the task — a semantic change, not just perf The target set is built only from tasks that reference a given delete file, so rows in that file addressing a data file whose task did not list it are now discarded. Previously the whole file was read and those rows landed in deletesPerFile, where the other task's lookup would apply them. I believe the new behaviour is the spec-correct one (delete-to-data assignment is the planner's job, and applying an unassigned delete is over-deletion), and I confirmed no under-deletion is possible because targets always contains t.File.FilePath() for every task that references the file. Still, the PR is framed as pure perf with an explicit 'keeps the existing result shape' claim, and this changes observable output. Worth a sentence in the PR body and a regression test pinning the intended semantics. |
||
| } | ||
|
|
||
|
|
@@ -107,7 +127,7 @@ func readAllDeleteFiles(ctx context.Context, fs iceio.IO, tasks []FileScanTask, | |
| defer close(perFileChan) | ||
| for _, v := range uniqueDeletes { | ||
| g.Go(func() error { | ||
| deletes, err := readDeletes(gctx, fs, v) | ||
| deletes, err := readDeletesForPaths(gctx, fs, v, targetsByDelete[v.FilePath()]) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
@@ -366,12 +386,14 @@ func (c *posDeleteCursor) next() (int64, bool) { | |
|
|
||
| type posDeleteAccumulator struct { | ||
| mem memory.Allocator | ||
| targets map[string]struct{} | ||
| builders map[string]*array.Int64Builder | ||
| } | ||
|
|
||
| func newPosDeleteAccumulator(ctx context.Context) *posDeleteAccumulator { | ||
| func newPosDeleteAccumulator(ctx context.Context, targets map[string]struct{}) *posDeleteAccumulator { | ||
| return &posDeleteAccumulator{ | ||
| mem: compute.GetAllocator(ctx), | ||
| targets: targets, | ||
| builders: make(map[string]*array.Int64Builder), | ||
| } | ||
| } | ||
|
|
@@ -472,6 +494,12 @@ func (a *posDeleteAccumulator) appendFilePathChunk(ctx context.Context, filePath | |
| } | ||
|
|
||
| path := paths.Value(i) | ||
| if len(a.targets) > 0 { | ||
| if _, ok := a.targets[path]; !ok { | ||
| continue | ||
| } | ||
| } | ||
|
|
||
| builder, ok := a.builders[path] | ||
| if !ok { | ||
| path = strings.Clone(path) | ||
|
|
@@ -510,6 +538,7 @@ func (a *posDeleteAccumulator) appendChunked(ctx context.Context, filePathCol, p | |
| return ctx.Err() | ||
| } | ||
|
|
||
| // appendRecord requires columns projected in file_path, pos order. | ||
| func (a *posDeleteAccumulator) appendRecord(ctx context.Context, record arrow.RecordBatch) error { | ||
| if record.NumCols() != 2 { | ||
| return fmt.Errorf("%w: projected position delete record has %d columns, expected 2", | ||
|
|
@@ -522,8 +551,15 @@ func (a *posDeleteAccumulator) appendRecord(ctx context.Context, record arrow.Re | |
| posCol.DataType(), posCol.NullN()); err != nil { | ||
| return err | ||
| } | ||
| if err := validatePosDeleteColumnLengths(filePathCol.Len(), posCol.Len()); err != nil { | ||
| return err | ||
| } | ||
|
|
||
| posArr := posCol.(*array.Int64) | ||
| posArr, ok := posCol.(*array.Int64) | ||
| if !ok { | ||
| return fmt.Errorf("%w: unsupported pos record array type %T in position delete file", | ||
| iceberg.ErrInvalidSchema, posCol) | ||
| } | ||
| posCursor := posDeleteCursor{chunks: []*array.Int64{posArr}} | ||
| if err := a.appendFilePathChunk(ctx, filePathCol, &posCursor); err != nil { | ||
| return err | ||
|
|
@@ -533,7 +569,7 @@ func (a *posDeleteAccumulator) appendRecord(ctx context.Context, record arrow.Re | |
| } | ||
|
|
||
| func groupPosDeletesByFilePath(ctx context.Context, filePathCol, posCol *arrow.Chunked) (results map[string]*arrow.Chunked, err error) { | ||
| acc := newPosDeleteAccumulator(ctx) | ||
| acc := newPosDeleteAccumulator(ctx, nil) | ||
| defer func() { | ||
| if err != nil { | ||
| acc.release() | ||
|
|
@@ -607,7 +643,9 @@ func releasePosDeletes(deletes map[string]*arrow.Chunked) { | |
| } | ||
| } | ||
|
|
||
| func readDeletes(ctx context.Context, fs iceio.IO, dataFile iceberg.DataFile) (_ map[string]*arrow.Chunked, err error) { | ||
| func readDeletesForPaths(ctx context.Context, fs iceio.IO, dataFile iceberg.DataFile, | ||
| targets map[string]struct{}, | ||
| ) (_ map[string]*arrow.Chunked, err error) { | ||
| src, err := tblutils.GetFile(ctx, fs, dataFile, true) | ||
| if err != nil { | ||
| return nil, err | ||
|
|
@@ -629,15 +667,20 @@ func readDeletes(ctx context.Context, fs iceio.IO, dataFile iceberg.DataFile) (_ | |
| return nil, err | ||
| } | ||
|
|
||
| records, err := rdr.GetRecords(ctx, columns, nil) | ||
| tester, err := newPositionDeleteRowGroupTester(schema, targets) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| records, err := rdr.GetRecords(ctx, columns, tester) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| // Do not unify dictionaries here: appendFilePathChunk decodes each batch to | ||
| // string values, so independent dictionaries across batches are safe. | ||
| defer records.Release() | ||
|
|
||
| acc := newPosDeleteAccumulator(ctx) | ||
| acc := newPosDeleteAccumulator(ctx, targets) | ||
| defer func() { | ||
| // Returning an error assigns the named return value before deferred | ||
| // functions run, which releases builders on every error path. | ||
|
|
@@ -661,6 +704,121 @@ func readDeletes(ctx context.Context, fs iceio.IO, dataFile iceberg.DataFile) (_ | |
| return acc.finish(), nil | ||
| } | ||
|
|
||
| func newPositionDeleteRowGroupTester(schema *arrow.Schema, targets map[string]struct{}) (*tblutils.ParquetRowGroupTester, error) { | ||
| if len(targets) == 0 || len(targets) > inPredicateLimit { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| return nil, nil | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. minor — Field-ID validation is skipped by the early return, so the same corrupt file errors or reads depending on query shape newPositionDeleteRowGroupTester returns (nil, nil) before calling positionDeletePruningEnabled whenever len(targets)==0 or len(targets)>inPredicateLimit. A delete file with swapped or duplicated reserved field IDs therefore fails with ErrInvalidSchema when a query touches 1..200 data files, but reads successfully when it touches >200, or when any task lacks a usable path and the nil whole-file fallback kicks in. Neither path is unsafe (validation only gates pushdown, and the unvalidated paths do no pruning), so this is a consistency/support concern rather than a correctness one — but a scan that fails only for some query shapes is hard to diagnose. Consider validating unconditionally, or documenting that validation is deliberately scoped to the pushdown path. |
||
| } | ||
| pruningEnabled, err := positionDeletePruningEnabled(schema) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if !pruningEnabled { | ||
| return nil, nil | ||
| } | ||
|
|
||
| paths := make([]string, 0, len(targets)) | ||
| for path := range targets { | ||
| paths = append(paths, path) | ||
| } | ||
|
|
||
| var filter iceberg.BooleanExpression | ||
| if len(paths) == 1 { | ||
| // A single target is the common case. EqualTo avoids building the | ||
| // set literal used by IsIn and gives the stats/bloom planners the | ||
| // simpler predicate directly. | ||
| filter = iceberg.EqualTo(iceberg.Reference("file_path"), paths[0]) | ||
| } else { | ||
| slices.Sort(paths) | ||
| filter = iceberg.IsIn(iceberg.Reference("file_path"), paths...) | ||
| } | ||
| filter, err = iceberg.BindExpr(iceberg.PositionalDeleteSchema, filter, true) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| statsFn, err := newParquetRowGroupStatsEvaluator(iceberg.PositionalDeleteSchema, filter, false) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| bloomPreds, err := newBloomFilterPredicates(filter) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return &tblutils.ParquetRowGroupTester{ | ||
| StatsFn: statsFn, | ||
| BloomPreds: bloomPreds, | ||
| }, nil | ||
| } | ||
|
|
||
| func positionDeletePruningEnabled(schema *arrow.Schema) (bool, error) { | ||
| // Row-group stats and Bloom predicates are keyed by Parquet physical field | ||
| // IDs, while projection resolves these columns by their spec-defined names. | ||
| // pqarrow carries the Parquet IDs into Arrow metadata, so only enable | ||
| // pushdown when those two views agree for the reserved delete columns. | ||
| physicalIDs := make(map[int]int) | ||
| var collectIDs func([]arrow.Field) | ||
| collectIDs = func(fields []arrow.Field) { | ||
| for _, field := range fields { | ||
| if id := getFieldID(field); id != nil { | ||
| physicalIDs[*id]++ | ||
| } | ||
| if nested, ok := field.Type.(arrow.NestedType); ok { | ||
| collectIDs(nested.Fields()) | ||
| } | ||
| } | ||
| } | ||
| collectIDs(schema.Fields()) | ||
| if len(physicalIDs) == 0 { | ||
| // External position-delete files are allowed to omit Iceberg field IDs. | ||
| // The name-based projection and row-level target filter remain safe, but | ||
| // stats and Bloom pruning cannot be trusted without the IDs. | ||
| return false, nil | ||
| } | ||
|
|
||
| deleteFields := iceberg.PositionalDeleteSchema.Fields() | ||
| for _, field := range deleteFields { | ||
| if physicalIDs[field.ID] > 1 { | ||
| return false, fmt.Errorf("%w: position delete field ID %d is not unique", | ||
| iceberg.ErrInvalidSchema, field.ID) | ||
| } | ||
| } | ||
|
|
||
| pruningEnabled := true | ||
| for _, want := range deleteFields { | ||
| indices := schema.FieldIndices(want.Name) | ||
| if len(indices) != 1 { | ||
| return false, fmt.Errorf("%w: position delete file must contain exactly one %q column, found %d", | ||
| iceberg.ErrInvalidSchema, want.Name, len(indices)) | ||
| } | ||
|
|
||
| fieldID := getFieldID(schema.Field(indices[0])) | ||
| if fieldID == nil { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The guard you added is the right call for the genuinely corrupt cases, but I think this nil branch is a touch too strict. A delete file from a mixed-version writer that stamps a field ID on some other column (say a v3 row field) but not on if fieldID == nil {
// IDs present on other columns but not on file_path/pos:
// fall back to name-based reading with no pruning.
return false, nil
}The murkier one is the |
||
| if physicalIDs[want.ID] != 0 { | ||
| return false, fmt.Errorf("%w: position delete field ID %d is assigned to another column instead of %q", | ||
| iceberg.ErrInvalidSchema, want.ID, want.Name) | ||
| } | ||
| // Missing IDs only disable pruning. Keep checking the remaining | ||
| // columns so this fallback cannot hide an invalid ID mapping. | ||
| pruningEnabled = false | ||
|
|
||
| continue | ||
| } | ||
| if *fieldID != want.ID { | ||
| if physicalIDs[want.ID] != 0 { | ||
| return false, fmt.Errorf("%w: position delete column %q has field ID %d, want %d; field ID %d is assigned to another column", | ||
| iceberg.ErrInvalidSchema, want.Name, *fieldID, want.ID, want.ID) | ||
| } | ||
| // A non-canonical ID is safe for name-based reading, but not for | ||
| // stats or Bloom pruning. Keep the pre-pruning read compatible with | ||
| // external writers that renumber fields. | ||
| pruningEnabled = false | ||
| } | ||
| } | ||
|
|
||
| return pruningEnabled, nil | ||
| } | ||
|
|
||
| func positionDeleteProjectionIndices(schema *arrow.Schema, reader tblutils.FileReader) ([]int, error) { | ||
| filePathIndex, posIndex, err := positionDeleteColumnIndices(schema) | ||
| if err != nil { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| // Licensed to the Apache Software Foundation (ASF) under one | ||
| // or more contributor license agreements. See the NOTICE file | ||
| // distributed with this work for additional information | ||
| // regarding copyright ownership. The ASF licenses this file | ||
| // to you under the Apache License, Version 2.0 (the | ||
| // "License"); you may not use this file except in compliance | ||
| // with the License. You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, | ||
| // software distributed under the License is distributed on an | ||
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| // KIND, either express or implied. See the License for the | ||
| // specific language governing permissions and limitations | ||
| // under the License. | ||
|
|
||
| package table | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/apache/arrow-go/v18/arrow" | ||
| "github.com/apache/arrow-go/v18/arrow/array" | ||
| "github.com/apache/arrow-go/v18/arrow/compute" | ||
| "github.com/apache/arrow-go/v18/arrow/memory" | ||
| "github.com/apache/arrow-go/v18/parquet" | ||
| "github.com/apache/arrow-go/v18/parquet/pqarrow" | ||
| "github.com/apache/iceberg-go" | ||
| iceio "github.com/apache/iceberg-go/io" | ||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestReadDeletesForPathsRejectsNestedBloomFieldIDCollision(t *testing.T) { | ||
| mem := memory.NewCheckedAllocator(memory.DefaultAllocator) | ||
| ctx := compute.WithAllocator(t.Context(), mem) | ||
| defer mem.AssertSize(t, 0) | ||
|
|
||
| fields := PositionalDeleteArrowSchema.Fields() | ||
| fields = append(fields, arrow.Field{Name: "row", Type: arrow.ListOfField(arrow.Field{ | ||
| Name: "element", Type: arrow.BinaryTypes.String, | ||
| Metadata: fields[0].Metadata, | ||
| })}) | ||
| schema := arrow.NewSchema(fields, nil) | ||
| record := mustLoadRecordBatchFromJSON(schema, | ||
| `[{"file_path":"data.parquet","pos":1,"row":["unrelated"]}]`) | ||
| defer record.Release() | ||
| tbl := array.NewTableFromRecords(schema, []arrow.RecordBatch{record}) | ||
| defer tbl.Release() | ||
|
|
||
| fs := iceio.NewMemFS() | ||
| const deletePath = "mem://bucket/deletes/nested-bloom.parquet" | ||
| writer, err := fs.Create(deletePath) | ||
| require.NoError(t, err) | ||
| require.NoError(t, pqarrow.WriteTable(tbl, writer, 1, | ||
| parquet.NewWriterProperties(parquet.WithStats(true), parquet.WithBloomFilterEnabled(true)), | ||
| pqarrow.DefaultWriterProps())) | ||
| require.NoError(t, writer.Close()) | ||
|
|
||
| file := newPosDeleteFile(t, deletePath, 1, 128) | ||
| allDeletes, err := readDeletesForPaths(ctx, fs, file, nil) | ||
| require.NoError(t, err) | ||
| defer releasePosDeletes(allDeletes) | ||
| assert.Equal(t, []int64{1}, int64Values(allDeletes["data.parquet"])) | ||
|
|
||
| filtered, err := readDeletesForPaths(ctx, fs, file, map[string]struct{}{"data.parquet": {}}) | ||
| defer releasePosDeletes(filtered) | ||
| require.ErrorIs(t, err, iceberg.ErrInvalidSchema) | ||
| assert.Nil(t, filtered) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The nil sentinel here is the whole-file fallback for the mixed-task case, and it's a different code path from the filtered read, but I don't see a test that exercises it end to end.
TestReadAllDeleteFilesUsesTaskDataFilePathsonly covers a single task with a valid path.The construction I'd want: two tasks sharing this delete file, one with
File.FilePath()set todata-A.parquetand one withFile == nil, then assertreadAllDeleteFilesreturns rows for bothdata-Aanddata-B, i.e. the whole file, not justdata-A. That also locks in the ordering here, since thetargets == nilcheck has to stay ahead of thet.File == nilcheck and nothing tests that today. A refactor that reorders them, or swaps nil for an empty map, would silently drop the other task's deletes with everything still green.