laskoviymishka commented on code in PR #1419:
URL: https://github.com/apache/iceberg-go/pull/1419#discussion_r3539732342
##########
table/arrow_utils.go:
##########
@@ -1848,7 +1853,25 @@ func positionDeleteRecordsToDataFilesDV(ctx
context.Context, rootLocation string
return metadata.PartitionSpecByID(int(id))
})
+ // Seed the writer with any deletion vector the referenced data
file
+ // already carries so its previously deleted positions survive
into the
+ // merged DV. The read path that produced args.itr already
applied these
Review Comment:
This isn't quite right — `makePositionDeleteRecordsForFilter` builds the
`FileScanTask` without `DeleteFiles`, so the scan does not apply the existing
DVs; the incoming batches can carry already-deleted positions. Correctness
still holds, but only because `bitmap.Set` is idempotent, so re-setting old
positions on top of the seed is a no-op.
I'd reword to say the scan does not apply existing deletes and the seed is
what guarantees the old positions survive. As written, someone later reads
this, concludes the seed is redundant, removes it, and the original bug is back.
##########
table/transaction.go:
##########
@@ -1913,9 +1939,50 @@ func (t *Transaction) writePositionDeletesForFiles(ctx
context.Context, fs io.IO
}
}
+ // Supersede the old DVs only after the merged replacements are staged,
so a
+ // write failure above leaves the existing DVs untouched.
+ for _, dvFile := range existingDVs {
+ updater.removeDeletionVector(dvFile)
+ }
+
return referenced, nil
}
+// collectExistingDVs returns the live deletion vectors that reference any of
+// files, keyed by the referenced data file path. Used by the v3 merge-on-read
+// delete path to fold prior deletes into a new DV and remove the superseded
+// entries, preserving the spec's one-DV-per-data-file invariant.
+func (t *Transaction) collectExistingDVs(fs io.IO, files []iceberg.DataFile)
(map[string]iceberg.DataFile, error) {
+ s := t.meta.currentSnapshot()
+ if s == nil {
+ return nil, nil
+ }
+
+ wanted := make(map[string]struct{}, len(files))
+ for _, df := range files {
+ wanted[df.FilePath()] = struct{}{}
+ }
+
+ result := make(map[string]iceberg.DataFile)
+ for df, err := range s.dataFiles(fs, nil) {
Review Comment:
`s.dataFiles(fs, nil)` runs with `discardDeleted=false`, so this pulls in
DELETED-status entries too. After a second delete, the snapshot's
deleted-entries manifest still carries the superseded old DV as a DELETED entry
for the same referenced data file. On a third delete this loop can yield both
the live ADDED merged DV and the stale DELETED one for the same path, and
`result[*ref] = df` is last-write-wins into a map — with the current manifest
concat order (added, then position deletes, then deleted-files) the DELETED
ghost lands last and wins. That seeds the new DV from a stale bitmap
(resurrecting positions deleted in the 2nd commit), calls
`removeDeletionVector` on a DV that no longer exists, and writes a Puffin
missing the merged positions.
The guard is already in the PR — `liveDVCount` in the test skips
`entry.Status() == EntryStatusDELETED`. I'd give `collectExistingDVs` the same
treatment: iterate `s.entries(fs, iceberg.ManifestContentDeletes)` and
`continue` on DELETED status, which also narrows the scan to delete manifests
instead of walking everything. wdyt?
##########
table/dv/dv_writer.go:
##########
@@ -108,6 +108,31 @@ func (w *DVWriter) Add(dataFilePath string, positions
[]int64, specID int32, par
}
}
+// Load seeds the writer with an already-written deletion vector for a data
+// file so subsequent Add calls merge new positions into it. The spec permits
+// at most one DV per data file per snapshot, so when a data file that already
+// has a DV receives more deletes the old positions must be carried into the
+// replacement DV (and the old DV superseded) rather than dropped. Load must be
+// called before any Add for the same path; specID and partitionData are
+// captured exactly as Add's first call would, and the supplied bitmap is
+// unioned into the entry. A nil bitmap simply registers the entry so it is
+// re-emitted. Callers retain ownership of the passed bitmap; only its set
+// positions are copied.
+func (w *DVWriter) Load(dataFilePath string, bitmap *RoaringPositionBitmap,
specID int32, partitionData map[int]any) {
Review Comment:
The doc says Load "must be called before any Add for the same path," but
nothing enforces that — call it after Add, or twice, and it silently ORs into
the live entry and discards the later `specID`/`partitionData`. Idempotent OR
makes it harmless today, but the doc promises a precondition the code doesn't
hold. I'd either error when the entry already exists, or soften the doc to
describe what actually happens.
Also "a nil bitmap simply registers the entry" — `ReadDV` always returns
non-nil, so that path never runs, and if it did it'd register a cardinality-0
DV. I'd document that `bitmap` must be non-nil instead.
##########
table/mor_delete_pruning_test.go:
##########
@@ -95,6 +103,79 @@ func newMergeOnReadTestTable(t *testing.T) *table.Table {
return table.New(table.Identifier{"db", "mor_delete_test"}, meta,
metaLoc, fsF, cat)
}
+// TestV3MergeOnReadDoubleDeleteMergesDeletionVector guards against a second
+// merge-on-read DELETE on a data file that already has a deletion vector
+// writing a second live DV. The spec permits at most one DV per data file, so
+// the new deletes must merge into the existing DV (which is then superseded),
+// not accumulate as a parallel one. A second live DV corrupts the table:
+// buildDVIndex rejects it at scan time with "can't index multiple deletion
+// vectors". See issue #1372.
+func TestV3MergeOnReadDoubleDeleteMergesDeletionVector(t *testing.T) {
+ ctx := context.Background()
+ tbl := newMergeOnReadTestTableVersion(t, "3")
+
+ arrowSchema := arrow.NewSchema([]arrow.Field{
+ {Name: "id", Type: arrow.PrimitiveTypes.Int64, Nullable: false},
+ {Name: "data", Type: arrow.BinaryTypes.String, Nullable: true},
+ }, nil)
+ data, err := array.TableFromJSON(memory.DefaultAllocator, arrowSchema,
[]string{
+
`[{"id":1,"data":"a"},{"id":2,"data":"b"},{"id":3,"data":"c"},{"id":4,"data":"d"},{"id":5,"data":"e"}]`,
+ })
+ require.NoError(t, err)
+ defer data.Release()
+
+ tbl, err = tbl.Append(ctx, array.NewTableReader(data, -1), nil)
+ require.NoError(t, err)
+
+ // First delete writes DV #1 against the single data file.
+ tbl, err = tbl.Delete(ctx, iceberg.EqualTo(iceberg.Reference("id"),
int64(2)), nil)
+ require.NoError(t, err)
+ require.Equal(t, []int64{1, 3, 4, 5}, idsInTable(t, tbl))
+ require.Equal(t, 1, liveDVCount(t, tbl), "first delete must write
exactly one DV")
+
+ // Second delete on the same data file must merge into DV #1, not add a
+ // second live DV.
+ tbl, err = tbl.Delete(ctx, iceberg.EqualTo(iceberg.Reference("id"),
int64(4)), nil)
+ require.NoError(t, err)
+
+ require.Equal(t, 1, liveDVCount(t, tbl),
+ "the two deletes must collapse into a single merged deletion
vector")
+ assert.Equal(t, []int64{1, 3, 5}, idsInTable(t, tbl),
+ "both id=2 and id=4 must be deleted and no previously deleted
row may resurrect")
Review Comment:
This stops at two deletes, but the ghost-DV bug above only triggers on the
third — the second delete is what writes the DELETED entry the third then picks
up. Could we add a triple-delete variant (append, delete id=2, delete id=4,
delete id=1, assert `liveDVCount == 1` and rows `[3, 5]`)? That's the test that
would've caught this.
While we're here, a partitioned / multi-data-file case would exercise the
per-file `pCtx` capture in the seed loop, which the single unpartitioned file
doesn't touch today.
##########
table/arrow_utils.go:
##########
@@ -1848,7 +1853,25 @@ func positionDeleteRecordsToDataFilesDV(ctx
context.Context, rootLocation string
return metadata.PartitionSpecByID(int(id))
})
+ // Seed the writer with any deletion vector the referenced data
file
+ // already carries so its previously deleted positions survive
into the
+ // merged DV. The read path that produced args.itr already
applied these
+ // existing deletes, so the incoming batches hold only the new
positions;
+ // without the seed the replacement DV would silently resurrect
rows. The
+ // spec allows at most one DV per data file, so the caller
supersedes the
+ // old DV once this merged one is written.
hasEntries := false
+ for filePath, bitmap := range args.existingDVs {
+ pCtx, ok := partitionContextByFilePath[filePath]
+ if !ok {
+ yield(nil, fmt.Errorf("unexpected missing
partition context for path %s", filePath))
+
+ return
+ }
+ writer.Load(filePath, bitmap, pCtx.specID,
pCtx.partitionData)
+ hasEntries = true
Review Comment:
`hasEntries` is set true for every existing-DV path even when that file gets
no new delete records this commit. If we seed a file but `posDeleteRecIter`
yields nothing for it, we still flush an identical-replacement Puffin and
remove the old DV — churn for no change. Gating the flush on whether records
were actually added (or `!bitmap.IsEmpty()`) would avoid rewriting an unchanged
DV. Latent, not a correctness bug.
##########
table/transaction.go:
##########
@@ -1913,9 +1939,50 @@ func (t *Transaction) writePositionDeletesForFiles(ctx
context.Context, fs io.IO
}
}
+ // Supersede the old DVs only after the merged replacements are staged,
so a
+ // write failure above leaves the existing DVs untouched.
+ for _, dvFile := range existingDVs {
+ updater.removeDeletionVector(dvFile)
+ }
+
return referenced, nil
}
+// collectExistingDVs returns the live deletion vectors that reference any of
+// files, keyed by the referenced data file path. Used by the v3 merge-on-read
+// delete path to fold prior deletes into a new DV and remove the superseded
+// entries, preserving the spec's one-DV-per-data-file invariant.
+func (t *Transaction) collectExistingDVs(fs io.IO, files []iceberg.DataFile)
(map[string]iceberg.DataFile, error) {
+ s := t.meta.currentSnapshot()
+ if s == nil {
+ return nil, nil
+ }
+
+ wanted := make(map[string]struct{}, len(files))
+ for _, df := range files {
+ wanted[df.FilePath()] = struct{}{}
+ }
+
+ result := make(map[string]iceberg.DataFile)
+ for df, err := range s.dataFiles(fs, nil) {
+ if err != nil {
+ return nil, err
Review Comment:
Every other error return in this function wraps with `%w` — this one returns
the iterator error bare. `fmt.Errorf("scanning existing deletion vectors: %w",
err)` keeps it consistent.
##########
table/arrow_utils.go:
##########
@@ -1848,7 +1853,25 @@ func positionDeleteRecordsToDataFilesDV(ctx
context.Context, rootLocation string
return metadata.PartitionSpecByID(int(id))
})
+ // Seed the writer with any deletion vector the referenced data
file
+ // already carries so its previously deleted positions survive
into the
+ // merged DV. The read path that produced args.itr already
applied these
+ // existing deletes, so the incoming batches hold only the new
positions;
+ // without the seed the replacement DV would silently resurrect
rows. The
+ // spec allows at most one DV per data file, so the caller
supersedes the
+ // old DV once this merged one is written.
hasEntries := false
+ for filePath, bitmap := range args.existingDVs {
+ pCtx, ok := partitionContextByFilePath[filePath]
+ if !ok {
+ yield(nil, fmt.Errorf("unexpected missing
partition context for path %s", filePath))
Review Comment:
This message is identical to the one in the batch path just below, so in
logs there's no telling which fired. A distinguishing string for the seed path
would save some head-scratching later.
##########
table/transaction.go:
##########
@@ -1891,11 +1892,36 @@ func (t *Transaction) writePositionDeletesForFiles(ctx
context.Context, fs io.IO
partitionContextByFilePath[df.FilePath()] =
partitionContext{partitionData: df.Partition(), specID: df.SpecID()}
}
+ // V3 tables store deletes as deletion vectors, and the spec allows at
most
+ // one DV per data file. A data file being partially deleted may already
+ // carry a DV from an earlier delete; those existing positions are
folded
+ // into the new DV (the scan that produced the records already applied
them,
+ // so the new records hold only the incremental deletes) and the
superseded
+ // DVs are removed. Without this a second delete on the same file would
+ // write a second live DV, corrupting the table (buildDVIndex rejects
it).
+ var existingDVs map[string]iceberg.DataFile
+ if t.meta.formatVersion >= 3 {
+ existingDVs, err = t.collectExistingDVs(fs, files)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ existingDVBitmaps := make(map[string]*dv.RoaringPositionBitmap,
len(existingDVs))
Review Comment:
The `formatVersion >= 3` guard covers the `collectExistingDVs` call, but
this alloc + `ReadDV` loop still runs on v1/v2 (harmless — `existingDVs` is nil
so the loop is len-0). Moving the `make` and loop inside the v3 block keeps the
whole existing-DV machinery behind one guard.
--
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]