This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new 7ed6b981e refactor(table): separate transaction deletion
implementation (#2286)
7ed6b981e is described below
commit 7ed6b981e64b63bb15a6983b4bcae4d38ccbbdf0
Author: Minh Vu <[email protected]>
AuthorDate: Fri Oct 23 22:31:53 2026 +0200
refactor(table): separate transaction deletion implementation (#2286)
* refactor(table): separate transaction deletion implementation
Signed-off-by: Minh Vu <[email protected]>
* test(table): serialize global goroutine leak assertion
---------
Signed-off-by: Minh Vu <[email protected]>
Co-authored-by: Matt Topol <[email protected]>
---
table/transaction.go | 1017 -----------------------------------------
table/transaction_delete.go | 1052 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 1052 insertions(+), 1017 deletions(-)
diff --git a/table/transaction.go b/table/transaction.go
index 59a6c65df..9fbdf8462 100644
--- a/table/transaction.go
+++ b/table/transaction.go
@@ -23,7 +23,6 @@ import (
"encoding/json"
"errors"
"fmt"
- "iter"
"log/slog"
"runtime"
"slices"
@@ -37,11 +36,8 @@ import (
"github.com/apache/iceberg-go"
iceberginternal "github.com/apache/iceberg-go/internal"
"github.com/apache/iceberg-go/io"
- "github.com/apache/iceberg-go/table/dv"
- tblutils "github.com/apache/iceberg-go/table/internal"
"github.com/apache/iceberg-go/table/substrait"
"github.com/google/uuid"
- "golang.org/x/sync/errgroup"
)
type snapshotUpdate struct {
@@ -2492,1019 +2488,6 @@ func (t *Transaction) Overwrite(ctx context.Context,
rdr array.RecordReader, sna
return t.apply(updates, reqs)
}
-func (t *Transaction) performCopyOnWriteDeletion(ctx context.Context,
operation Operation, snapshotProps iceberg.Properties, filter
iceberg.BooleanExpression, caseSensitive bool, concurrency int)
(*snapshotProducer, io.WriteFileIO, error) {
- meta, err := t.txnMeta()
- if err != nil {
- return nil, nil, err
- }
- fs, err := t.tbl.fsF(ctx)
- if err != nil {
- return nil, nil, err
- }
- wfs, err := requireWriteFileIO(fs)
- if err != nil {
- return nil, nil, err
- }
-
- if meta.NameMapping() == nil {
- nameMapping := meta.CurrentSchema().NameMapping()
- mappingJson, err := json.Marshal(nameMapping)
- if err != nil {
- return nil, nil, err
- }
- err = t.SetProperties(iceberg.Properties{DefaultNameMappingKey:
string(mappingJson)})
- if err != nil {
- return nil, nil, err
- }
- }
-
- commitUUID := uuid.New()
- updater := t.updateSnapshot(wfs, snapshotProps,
operation).mergeOverwrite(&commitUUID, filter)
- updater.setManifestConcurrency(concurrency)
-
- filesToDelete, entriesToRewrite, err :=
t.classifyFilesForDeletions(ctx, fs, filter, caseSensitive, concurrency)
- if err != nil {
- return nil, nil, err
- }
-
- // SetProperties above may have staged a name mapping, which replaces
t.meta, so re-read it.
- meta, err = t.txnMeta()
- if err != nil {
- return nil, nil, err
- }
-
- // Rewrites need the live deletes to drop rows already deleted, and
every removed file must take its deletion vector with it.
- // DVs only exist from v3, so a v2 delete that only drops whole files
skips the read.
- var (
- deletes planningDeletes
- builtMeta Metadata
- )
- if len(entriesToRewrite) > 0 || (len(filesToDelete) > 0 &&
meta.formatVersion >= 3) {
- builtMeta, err = meta.Build()
- if err != nil {
- return nil, nil, fmt.Errorf("failed to build metadata:
%w", err)
- }
- deletes, err = t.readPlanningDeletes(fs, builtMeta)
- if err != nil {
- return nil, nil, err
- }
- }
-
- for _, df := range filesToDelete {
- deletes.dvs.removeDataFile(updater, df)
- }
-
- if len(entriesToRewrite) > 0 {
- if err := t.rewriteFilesWithFilter(ctx, fs, updater, builtMeta,
deletes, entriesToRewrite, filter, caseSensitive, concurrency); err != nil {
- return nil, nil, err
- }
- }
-
- // Reject the commit if a concurrent snapshot added deletes against any
- // data file this operation removes. The overwriteFiles producer only
- // checks added data files (and only under serializable isolation);
- // without this check a refresh-and-replay would swap the original file
- // for a rewrite built from the stale snapshot, dropping the concurrent
- // deletes and resurrecting their rows. Mirrors Java's copy-on-write
- // validateNoConflictingDeletes: no isolation gating. The check is
- // shared with RewriteDataFiles via rewriteValidator, so changes there
- // apply here too. A concurrent removal of one of these files is caught
- // earlier, by the retry rebuild's checkRemovedFiles
(ErrCommitDiverged).
- removed := slices.Clip(filesToDelete)
- for _, entry := range entriesToRewrite {
- removed = append(removed, entry.DataFile())
- }
- if len(removed) > 0 {
- t.addValidator(rewriteValidator(removed))
- }
-
- return updater, wfs, nil
-}
-
-func (t *Transaction) performMergeOnReadDeletion(ctx context.Context,
snapshotProps iceberg.Properties, filter iceberg.BooleanExpression,
caseSensitive bool, concurrency int) (*snapshotProducer, error) {
- meta, err := t.txnMeta()
- if err != nil {
- return nil, err
- }
- fs, err := t.tbl.fsF(ctx)
- if err != nil {
- return nil, err
- }
- wfs, err := requireWriteFileIO(fs)
- if err != nil {
- return nil, err
- }
-
- if meta.NameMapping() == nil {
- nameMapping := meta.CurrentSchema().NameMapping()
- mappingJson, err := json.Marshal(nameMapping)
- if err != nil {
- return nil, err
- }
- err = t.SetProperties(iceberg.Properties{DefaultNameMappingKey:
string(mappingJson)})
- if err != nil {
- return nil, err
- }
- }
-
- commitUUID := uuid.New()
- updater := t.updateSnapshot(wfs, snapshotProps,
OpDelete).mergeOverwrite(&commitUUID, filter)
- updater.setManifestConcurrency(concurrency)
-
- filesToDelete, withPartialDeletions, err :=
t.classifyFilesForDeletions(ctx, fs, filter, caseSensitive, concurrency)
- if err != nil {
- return nil, err
- }
-
- // On v3 every removed file takes its deletion vector with it, and a
partially deleted file's vector is
- // merged into its replacement. Both need only the deletion vectors,
read once.
- // DVs only exist from v3, so v2 skips the read.
- var dvs liveDeletionVectors
- if meta.formatVersion >= 3 && (len(filesToDelete) > 0 ||
len(withPartialDeletions) > 0) {
- dvs, err = t.readLiveDeletionVectors(fs)
- if err != nil {
- return nil, err
- }
- }
-
- for _, df := range filesToDelete {
- dvs.removeDataFile(updater, df)
- }
-
- if len(withPartialDeletions) > 0 {
- partialFiles := make([]iceberg.DataFile, 0,
len(withPartialDeletions))
- for _, entry := range withPartialDeletions {
- partialFiles = append(partialFiles, entry.DataFile())
- }
- referenced, err := t.writePositionDeletesForFiles(ctx, fs,
updater, dvs, partialFiles, filter, caseSensitive, concurrency, commitUUID)
- if err != nil {
- return nil, err
- }
-
- // Verify the data files these position deletes target still
exist on
- // the branch head at commit time. The overwriteFiles producer
only
- // validates partition-filter overlap; without this check a
concurrent
- // commit that removed a referenced data file would silently
orphan the
- // deletes (they would apply to rewritten data or dangle).
Mirrors
- // RowDelta: collect referenced data-file paths, leave out the
ones
- // staged earlier in the transaction, and run
validateDataFilesExist
- // unconditionally (no isolation gating).
- referenced, err = t.headDataFiles(fs, referenced, nil)
- if err != nil {
- return nil, err
- }
- if len(referenced) > 0 {
- t.addValidator(func(cc *conflictContext) error {
- return validateDataFilesExist(cc, referenced)
- })
- }
- }
-
- return updater, nil
-}
-
-type DeleteOption func(deleteOp *deleteOperation)
-
-type deleteOperation struct {
- caseSensitive bool
- concurrency int
-}
-
-// WithDeleteConcurrency overwrites the default concurrency for delete
operations.
-// Default: runtime.GOMAXPROCS(0)
-func WithDeleteConcurrency(concurrency int) DeleteOption {
- return func(op *deleteOperation) {
- if concurrency <= 0 {
- op.concurrency = runtime.GOMAXPROCS(0)
-
- return
- }
- op.concurrency = concurrency
- }
-}
-
-// WithDeleteCaseInsensitive changes the binding of the filter to be case
insensitive instead of the
-// Default: case sensitive
-// Note that the sensitivity only applies to the field name and not the
evaluation of the literals on string fields.
-func WithDeleteCaseInsensitive() DeleteOption {
- return func(deleteOp *deleteOperation) {
- deleteOp.caseSensitive = false
- }
-}
-
-// Delete deletes records matching the provided filter.
-//
-// The provided filter acts as a row-level predicate on existing data:
-// - Files where all rows match the filter (strict match) are completely
deleted
-// - Files where some rows match and others don't (partial match) are
rewritten to keep only non-matching rows
-// - Files where no rows match the filter are kept unchanged
-//
-// A row matches only if the filter is true for it. Rows where the filter
evaluates to NULL, for example
-// `age = 30` on a NULL age, do not match and are kept.
-//
-// The filter uses both inclusive and strict metrics evaluators on file
statistics to classify files:
-// - Inclusive evaluator identifies candidate files that may contain
matching rows
-// - Strict evaluator determines if all rows in a file must match the filter
-// - Files that pass inclusive but not strict evaluation are rewritten with
filtered data
-//
-// On a v3 table, every data file the delete removes or rewrites takes its
deletion vector with it.
-// A merge-on-read delete replaces the existing deletion vector of a file it
deletes rows from.
-//
-// A commit that removes a deletion vector is not replayed: if another writer
commits first,
-// it fails with ErrCommitFailed instead of refreshing and retrying, so reload
the table and run the delete again.
-//
-// The concurrency parameter controls the level of parallelism for manifest
processing and file rewriting and
-// can be overridden using the WithOverwriteConcurrency option. Defaults to
runtime.GOMAXPROCS(0).
-func (t *Transaction) Delete(ctx context.Context, filter
iceberg.BooleanExpression, snapshotProps iceberg.Properties, opts
...DeleteOption) (err error) {
- meta, err := t.txnMeta()
- if err != nil {
- return err
- }
- deleteOp := deleteOperation{
- concurrency: runtime.GOMAXPROCS(0),
- caseSensitive: true,
- }
- for _, apply := range opts {
- apply(&deleteOp)
- }
-
- var updater *snapshotProducer
- writeDeleteMode := WriteDeleteModeDefault
- // Only copy on write is supported on v1 so we ignore any override to
the write delete mode unless the version is
- // 2 and up
- if meta.formatVersion > 1 {
- writeDeleteMode = meta.props.Get(WriteDeleteModeKey,
WriteDeleteModeDefault)
- }
- switch writeDeleteMode {
- case WriteModeCopyOnWrite:
- updater, _, err = t.performCopyOnWriteDeletion(ctx, OpDelete,
snapshotProps, filter, deleteOp.caseSensitive, deleteOp.concurrency)
- if err != nil {
- return err
- }
- case WriteModeMergeOnRead:
- updater, err = t.performMergeOnReadDeletion(ctx, snapshotProps,
filter, deleteOp.caseSensitive, deleteOp.concurrency)
- if err != nil {
- return err
- }
- default:
- return fmt.Errorf("unsupported write mode: '%s'",
writeDeleteMode)
- }
-
- updates, reqs, err := updater.commit(ctx)
- if err != nil {
- return err
- }
-
- return t.apply(updates, reqs)
-}
-
-// classifyFilesForDeletions classifies existing data files based on the
provided filter.
-// Returns files to delete completely, the manifest entries of files that
partially match, and any error.
-// Partial matches stay entries because their sequence numbers decide which
deletes apply and seed row-lineage synthesis.
-func (t *Transaction) classifyFilesForDeletions(ctx context.Context, fs io.IO,
filter iceberg.BooleanExpression, caseSensitive bool, concurrency int)
(filesToDelete []iceberg.DataFile, filesWithPartialDeletions
[]iceberg.ManifestEntry, err error) {
- meta, err := t.txnMeta()
- if err != nil {
- return nil, nil, err
- }
-
- s := t.planningSnapshot(meta)
- if s == nil {
- return nil, nil, nil
- }
-
- if filter == nil || filter.Equals(iceberg.AlwaysTrue{}) {
- for df, err := range s.dataFiles(fs, nil) {
- if err != nil {
- return nil, nil, err
- }
- if df.ContentType() == iceberg.EntryContentData {
- filesToDelete = append(filesToDelete, df)
- }
- }
-
- return filesToDelete, nil, nil
- }
-
- return t.classifyFilesForFilteredDeletions(ctx, fs, filter,
caseSensitive, concurrency)
-}
-
-type fileClassificationTask struct {
- meta Metadata
- partitionFilters *keyDefaultMapErr[int, iceberg.BooleanExpression]
- caseSensitive bool
- rowFilter iceberg.BooleanExpression
-}
-
-func newFileClassificationTask(meta Metadata, rowFilter
iceberg.BooleanExpression, caseSensitive bool) *fileClassificationTask {
- classificationTask := &fileClassificationTask{
- meta: meta,
- caseSensitive: caseSensitive,
- rowFilter: rowFilter,
- }
- classificationTask.partitionFilters =
newKeyDefaultMapWrapErr(classificationTask.buildPartitionProjection)
-
- return classificationTask
-}
-
-func (t *fileClassificationTask) buildManifestEvaluator(specID int)
(func(iceberg.ManifestFile) (bool, error), error) {
- return buildManifestEvaluator(specID, t.meta, t.meta.CurrentSchema(),
t.partitionFilters, t.caseSensitive)
-}
-
-func (t *fileClassificationTask) buildPartitionProjection(specID int)
(iceberg.BooleanExpression, error) {
- return buildPartitionProjection(specID, t.meta, t.meta.CurrentSchema(),
t.rowFilter, t.caseSensitive)
-}
-
-// classifyFilesForFilteredDeletions classifies files for filtered overwrite
operations.
-// Returns files to delete completely, the manifest entries of files that
partially match, and any error.
-func (t *Transaction) classifyFilesForFilteredDeletions(ctx context.Context,
fs io.IO, filter iceberg.BooleanExpression, caseSensitive bool, concurrency
int) (filesToDelete []iceberg.DataFile, filesWithPartialDeletes
[]iceberg.ManifestEntry, err error) {
- meta, err := t.txnMeta()
- if err != nil {
- return nil, nil, err
- }
-
- schema := meta.CurrentSchema()
- builtMeta, err := meta.Build()
- if err != nil {
- return nil, nil, err
- }
-
- inclusiveEvaluator, err := newInclusiveMetricsEvaluator(schema, filter,
caseSensitive, false)
- if err != nil {
- return nil, nil, fmt.Errorf("failed to create inclusive metrics
evaluator: %w", err)
- }
-
- // A file is deleted as a whole only if the filter is true for all of
its
- // rows. The strict evaluator treats predicates as two-valued, so
NotEqual
- // and NotIn would also count rows where the filter is NULL. Evaluate
the
- // negation of the rows the rewrite keeps instead, so that both paths
agree.
- notTrue, err := isNotTrueExpr(filter)
- if err != nil {
- return nil, nil, fmt.Errorf("failed to build negated filter:
%w", err)
- }
-
- strictEvaluator, err := newStrictMetricsEvaluator(schema,
iceberg.NewNot(notTrue), caseSensitive, false)
- if err != nil {
- return nil, nil, fmt.Errorf("failed to create strict metrics
evaluator: %w", err)
- }
-
- classificationTask := newFileClassificationTask(builtMeta, filter,
caseSensitive)
- manifestEvaluators :=
newKeyDefaultMapWrapErr(classificationTask.buildManifestEvaluator)
-
- s := t.planningSnapshot(meta)
- var manifests []iceberg.ManifestFile
- if s != nil {
- manifests, err = s.Manifests(fs)
- if err != nil {
- return nil, nil, fmt.Errorf("failed to get manifests:
%w", err)
- }
- }
-
- var mu sync.Mutex
-
- g, _ := errgroup.WithContext(ctx)
- g.SetLimit(min(concurrency, len(manifests)))
-
- for _, manifest := range manifests {
- g.Go(func() error {
- manifestEval, err :=
manifestEvaluators.Get(int(manifest.PartitionSpecID()))
- if err != nil {
- return fmt.Errorf("failed to build manifest
evaluator for spec %d: %w", manifest.PartitionSpecID(), err)
- }
- match, err := manifestEval(manifest)
- if err != nil {
- return fmt.Errorf("failed to evaluate manifest
%s: %w", manifest.FilePath(), err)
- }
- if !match {
- return nil
- }
-
- localDelete := make([]iceberg.DataFile, 0)
- localRewrite := make([]iceberg.ManifestEntry, 0)
-
- for entry, err := range manifest.Entries(fs, false) {
- if err != nil {
- return fmt.Errorf("failed to fetch
manifest entries: %w", err)
- }
- if entry.Status() == iceberg.EntryStatusDELETED
{
- continue
- }
-
- df := entry.DataFile()
- if df.ContentType() != iceberg.EntryContentData
{
- continue
- }
-
- inclusive, err := inclusiveEvaluator(df)
- if err != nil {
- return fmt.Errorf("failed to evaluate
data file %s with inclusive evaluator: %w", df.FilePath(), err)
- }
-
- if !inclusive {
- continue
- }
-
- strict, err := strictEvaluator(df)
- if err != nil {
- return fmt.Errorf("failed to evaluate
data file %s with strict evaluator: %w", df.FilePath(), err)
- }
-
- if strict {
- localDelete = append(localDelete, df)
- } else {
- localRewrite = append(localRewrite,
entry)
- }
- }
-
- if len(localDelete) > 0 || len(localRewrite) > 0 {
- mu.Lock()
- filesToDelete = append(filesToDelete,
localDelete...)
- filesWithPartialDeletes =
append(filesWithPartialDeletes, localRewrite...)
- mu.Unlock()
- }
-
- return nil
- })
- }
-
- if err := g.Wait(); err != nil {
- return nil, nil, err
- }
-
- return filesToDelete, filesWithPartialDeletes, nil
-}
-
-// maps a data file path to the live deletion vector that references it.
-type liveDeletionVectors map[string]iceberg.ManifestEntry
-
-// removeDataFile removes df and, as the spec requires, the deletion vector
that references it.
-//
-// Removing a deletion vector makes the commit non-replayable (see
snapshotProducer.commitManifests).
-// Position and equality delete files are left alone, since they can still
apply to other files.
-func (dvs liveDeletionVectors) removeDataFile(updater *snapshotProducer, df
iceberg.DataFile) {
- updater.deleteDataFile(df)
- if dv, ok := dvs[df.FilePath()]; ok {
- updater.removeDeletionVector(dv.DataFile())
- }
-}
-
-// planningDeletes indexes the live delete files of the planning snapshot the
same way scan planning indexes them.
-type planningDeletes struct {
- positional *positionalDeleteIndex
- dvs liveDeletionVectors
- equality *equalityDeleteIndex
-}
-
-// classifyLiveDeletes reads every live delete file of the planning snapshot
and sorts it by kind.
-//
-// Known limitation (#2160): every live delete manifest of the snapshot is
read on each call,
-// with no partition pruning, so a narrow delete or rewrite on a delete-heavy
table pays for all of them.
-func (t *Transaction) classifyLiveDeletes(fs io.IO)
(classifiedManifestEntries, error) {
- meta, err := t.txnMeta()
- if err != nil {
- return classifiedManifestEntries{}, err
- }
-
- var liveDeletes []iceberg.ManifestEntry
- if s := t.planningSnapshot(meta); s != nil {
- // Skip DELETED entries: a replaced DV stays behind as one, and
merging it
- // into a new DV would bring back deleted rows. See #1372.
- for entry, err := range s.entries(fs,
iceberg.ManifestContentDeletes, true) {
- if err != nil {
- return classifiedManifestEntries{},
fmt.Errorf("failed to read delete manifests: %w", err)
- }
- liveDeletes = append(liveDeletes, entry)
- }
- }
-
- return classifyManifestEntries(liveDeletes)
-}
-
-// readPlanningDeletes reads and indexes every live delete file of the
planning snapshot. Copy-on-write
-// uses it because a rewrite must apply every kind of delete to the rows it
keeps.
-//
-// Delete manifests are not pruned by the row filter: with filter id=2 the
rewrite keeps id=1,
-// so a delete on id=1 must still be applied.
-func (t *Transaction) readPlanningDeletes(fs io.IO, builtMeta Metadata)
(planningDeletes, error) {
- meta, err := t.txnMeta()
- if err != nil {
- return planningDeletes{}, err
- }
-
- classified, err := t.classifyLiveDeletes(fs)
- if err != nil {
- return planningDeletes{}, err
- }
- positional, err :=
buildPositionalDeleteIndex(classified.positionalDeleteEntries)
- if err != nil {
- return planningDeletes{}, err
- }
- dvs, err := buildDVIndex(classified.dvEntries)
- if err != nil {
- return planningDeletes{}, err
- }
- equality, err :=
buildEqualityDeleteIndex(classified.equalityDeleteEntries, builtMeta,
meta.CurrentSchema())
- if err != nil {
- return planningDeletes{}, err
- }
-
- return planningDeletes{positional: positional, dvs: dvs, equality:
equality}, nil
-}
-
-// readLiveDeletionVectors returns the live deletion vectors of the planning
snapshot. Merge-on-read uses it
-// because it only drops or merges deletion vectors, so it skips building the
position and equality delete
-// indexes and neither pays for them nor fails on them.
-func (t *Transaction) readLiveDeletionVectors(fs io.IO) (liveDeletionVectors,
error) {
- classified, err := t.classifyLiveDeletes(fs)
- if err != nil {
- return nil, err
- }
-
- return buildDVIndex(classified.dvEntries)
-}
-
-// rewriteFilesWithFilter rewrites data files by preserving only rows that do
NOT match the filter.
-// Rows already removed by a delete are dropped too, since those deletes stop
applying once the file is replaced.
-// The filter binding and substrait conversion are computed once here and
reused across every file.
-func (t *Transaction) rewriteFilesWithFilter(ctx context.Context, fs io.IO,
updater *snapshotProducer, builtMeta Metadata, deletes planningDeletes, entries
[]iceberg.ManifestEntry, filter iceberg.BooleanExpression, caseSensitive bool,
concurrency int) error {
- meta, err := t.txnMeta()
- if err != nil {
- return err
- }
-
- complementFilter, err := isNotTrueExpr(filter)
- if err != nil {
- return fmt.Errorf("failed to build complement filter: %w", err)
- }
-
- // Bind + convert the complement filter once for the whole rewrite. The
- // per-batch filter function is reused across every record batch from
- // every file in this rewrite — substrait conversion in particular is
- // non-trivial so doing it inside the iterator loop wastes work.
- postFilter, err := prepareBatchFilter(complementFilter,
meta.CurrentSchema(), caseSensitive)
- if err != nil {
- return err
- }
-
- for _, entry := range entries {
- task, err := fileScanTaskForDataEntry(entry,
deletes.positional, deletes.dvs, deletes.equality)
- if err != nil {
- return err
- }
-
- // Use a separate UUID for rewrite operations to avoid filename
collisions with new data files
- rewriteUUID := uuid.New()
- args := rewriteSingleFileArgs{
- fs: fs,
- builtMeta: builtMeta,
- task: task,
- filter: complementFilter,
- postFilter: postFilter,
- caseSensitive: caseSensitive,
- commitUUID: rewriteUUID,
- concurrency: concurrency,
- }
- rewrittenFiles, err := t.rewriteSingleFile(ctx, args)
- if err != nil {
- return fmt.Errorf("failed to rewrite file %s: %w",
task.File.FilePath(), err)
- }
-
- deletes.dvs.removeDataFile(updater, task.File)
- for _, rewrittenFile := range rewrittenFiles {
- updater.appendDataFile(rewrittenFile)
- }
- }
-
- return nil
-}
-
-// isNotTrueExpr returns an expression that holds exactly for the rows where
-// filter is not true, i.e. where it is false or NULL. Plain NOT(filter) is not
-// enough: under three-valued logic `v = 5` is NULL when v is NULL, and so is
-// NOT(v = 5), so a copy-on-write rewrite would drop rows that never matched.
-//
-// The result contains no NOT. Row-group pruning pushes a NOT down with
-// RewriteNotExpr, which turns NOT(x < 5) into x >= 5, and that is false for
-// NaN. Negating the predicates here keeps NaN rows in both places.
-func isNotTrueExpr(filter iceberg.BooleanExpression)
(iceberg.BooleanExpression, error) {
- res, err := iceberg.VisitExpr(filter, isNotTrueVisitor{})
- if err != nil {
- return nil, err
- }
-
- return res.notTrue, nil
-}
-
-// truthExprs holds two never-NULL expressions for a sub-expression e:
-// notTrue holds iff e is false or NULL, notFalse holds iff e is true or NULL.
-type truthExprs struct {
- notTrue, notFalse iceberg.BooleanExpression
-}
-
-type isNotTrueVisitor struct{}
-
-func (isNotTrueVisitor) VisitTrue() truthExprs {
- return truthExprs{notTrue: iceberg.AlwaysFalse{}, notFalse:
iceberg.AlwaysTrue{}}
-}
-
-func (isNotTrueVisitor) VisitFalse() truthExprs {
- return truthExprs{notTrue: iceberg.AlwaysTrue{}, notFalse:
iceberg.AlwaysFalse{}}
-}
-
-func (isNotTrueVisitor) VisitNot(child truthExprs) truthExprs {
- return truthExprs{notTrue: child.notFalse, notFalse: child.notTrue}
-}
-
-func (isNotTrueVisitor) VisitAnd(left, right truthExprs) truthExprs {
- return truthExprs{
- notTrue: iceberg.NewOr(left.notTrue, right.notTrue),
- notFalse: iceberg.NewAnd(left.notFalse, right.notFalse),
- }
-}
-
-func (isNotTrueVisitor) VisitOr(left, right truthExprs) truthExprs {
- return truthExprs{
- notTrue: iceberg.NewAnd(left.notTrue, right.notTrue),
- notFalse: iceberg.NewOr(left.notFalse, right.notFalse),
- }
-}
-
-func (isNotTrueVisitor) VisitUnbound(pred iceberg.UnboundPredicate) truthExprs
{
- negated := pred.Negate()
- switch pred.Op() {
- case iceberg.OpIsNull, iceberg.OpNotNull, iceberg.OpNotNan:
- // never evaluates to NULL
- return truthExprs{notTrue: negated, notFalse: pred}
- case iceberg.OpIsNan:
- // is_nan(NULL) is false, so NULL rows survive. The guard also
covers
- // files written before the column existed, where NotNaN on the
missing
- // column translates to AlwaysFalse.
- return truthExprs{notTrue: iceberg.NewOr(negated,
iceberg.IsNull(pred.Term())), notFalse: pred}
- case iceberg.OpLT, iceberg.OpLTEQ, iceberg.OpGT, iceberg.OpGTEQ:
- // NaN fails both x < 5 and x >= 5. IsNaN binds to AlwaysFalse
for
- // non-floating-point terms.
- negated = iceberg.NewOr(negated, iceberg.IsNaN(pred.Term()))
- }
-
- // Any other predicate is NULL when its term is NULL. Guard In and NotIn
- // too: binding turns a set that ends up with one value into Equal or
- // NotEqual, which are NULL for a NULL term. A scan with a multi-valued
- // NotIn still matches NULL rows, see
- // https://github.com/apache/iceberg-go/issues/2132.
- isNull := iceberg.IsNull(pred.Term())
-
- return truthExprs{
- notTrue: iceberg.NewOr(negated, isNull),
- notFalse: iceberg.NewOr(pred, isNull),
- }
-}
-
-func (isNotTrueVisitor) VisitBound(pred iceberg.BoundPredicate) truthExprs {
- panic(fmt.Errorf("%w: found already bound predicate: %s",
iceberg.ErrInvalidArgument, pred))
-}
-
-// rewriteSingleFileArgs bundles the parameters for
[Transaction.rewriteSingleFile]
-// — there are several same-typed fields (two filter values, plus a UUID and an
-// int) so positional ordering offers no compile-time order protection.
-type rewriteSingleFileArgs struct {
- fs io.IO
- // builtMeta is the transaction's metadata, built once for the whole
rewrite.
- builtMeta Metadata
- // task is the source file as planned by [fileScanTaskForDataEntry].
- // Its deletes must be applied, and its sequence number seeds
_last_updated_sequence_number
- // for rows whose value is null in the file.
- task FileScanTask
- // filter is the per-row complement (rows that survive the rewrite).
- filter iceberg.BooleanExpression
- // postFilter is the pre-bound, pre-substrait-converted version of
- // filter. When preserveRowLineage applies it is the only place the
- // filter is evaluated; the scanner's row filter is bypassed so
- // _row_id positions stay correct.
- postFilter func(context.Context, arrow.RecordBatch)
(arrow.RecordBatch, error)
- caseSensitive bool
- commitUUID uuid.UUID
- concurrency int
-}
-
-// rewriteSingleFile reads a single data file, applies the filter, and writes
-// new files with filtered data. See [rewriteSingleFileArgs] for parameter
-// semantics.
-func (t *Transaction) rewriteSingleFile(ctx context.Context, args
rewriteSingleFileArgs) ([]iceberg.DataFile, error) {
- meta, err := t.txnMeta()
- if err != nil {
- return nil, err
- }
-
- // Preserve row lineage for v3 tables: include _row_id and
- // _last_updated_sequence_number in the scan projection so they are read
- // from the source file (or synthesized from file metadata when null)
and
- // written explicitly into the output Parquet. The Iceberg v3 spec says
- // _last_updated_sequence_number on a data file row is the sequence
- // number of the snapshot that last *logically* updated the row — for a
- // pure CoW rewrite the surviving rows are unchanged, so they must keep
- // their original value rather than inheriting the new commit's seq.
- //
- // When preserving lineage, the row filter must NOT be applied inside
the
- // scanner because _row_id synthesis depends on original file position.
The
- // filter is applied post-synthesis in the record iterator instead.
- //
- // Keep the real filter available to Parquet row-group pruning while the
- // scanner's row filter stays AlwaysTrue. Pruning happens before rows
are
- // materialized, so _row_id synthesis still sees the original positions
of
- // every surviving row group and the post-synthesis filter remains
correct.
- _, originalFirstRowID, _, _, _ :=
iceberginternal.BorrowedDataFilePointers(args.task.File)
- preserveRowLineage := meta.formatVersion >= 3 && originalFirstRowID !=
nil
- projectedSchema := meta.CurrentSchema()
- var factoryOpts []writerFactoryOption
- var rowGroupFilter iceberg.BooleanExpression
- if preserveRowLineage {
- rowGroupFilter, err = iceberg.BindExpr(meta.CurrentSchema(),
args.filter, args.caseSensitive)
- if err != nil {
- return nil, fmt.Errorf("failed to bind row-group
filter: %w", err)
- }
-
- projectedSchema = iceberg.SchemaWithRowLineage(projectedSchema)
- factoryOpts = append(factoryOpts,
withFactoryFileSchema(projectedSchema))
- }
-
- // When preserving row lineage, the bound scan-time filter is replaced
- // with AlwaysTrue and the rebound version inside prepareBatchFilter is
- // the one actually applied. Skip the wasted BindExpr in that case.
- scanFilter := iceberg.BooleanExpression(iceberg.AlwaysTrue{})
- if !preserveRowLineage {
- boundFilter, err := iceberg.BindExpr(meta.CurrentSchema(),
args.filter, args.caseSensitive)
- if err != nil {
- return nil, fmt.Errorf("failed to bind filter: %w", err)
- }
- scanFilter = boundFilter
- }
-
- scanner := &arrowScan{
- metadata: args.builtMeta,
- fs: args.fs,
- scanSchema: meta.CurrentSchema(),
- projectedSchema: projectedSchema,
- boundRowFilter: scanFilter,
- rowGroupFilter: rowGroupFilter,
- caseSensitive: args.caseSensitive,
- rowLimit: -1, // No limit
- concurrency: args.concurrency,
- }
-
- arrowSchema, recordIter, err := scanner.GetRecords(ctx,
[]FileScanTask{args.task})
- if err != nil {
- return nil, fmt.Errorf("failed to get records from original
file: %w", err)
- }
-
- // When preserving row lineage, use an Arrow schema with field IDs so
that
- // recordsToDataFiles can resolve _row_id via field ID rather than the
name
- // mapping (which doesn't include metadata columns).
- if preserveRowLineage {
- arrowSchema, err =
SchemaToArrowSchemaWithOptions(projectedSchema, ArrowSchemaOptions{
- IncludeFieldIDs: true,
- TableProperties: args.builtMeta.Properties(),
- })
- if err != nil {
- return nil, fmt.Errorf("failed to build arrow schema
with field IDs: %w", err)
- }
- }
-
- // Wrap the iterator to release records after consumption.
- // When preserving row lineage, also apply the (pre-bound) filter here
- // post-synthesis so _row_id positions stay correct.
- releaseIter := func(yield func(arrow.RecordBatch, error) bool) {
- for rec, err := range recordIter {
- if err != nil {
- yield(nil, err)
-
- return
- }
-
- if preserveRowLineage {
- rec, err = args.postFilter(ctx, rec)
- if err != nil {
- yield(nil, err)
-
- return
- }
- if rec.NumRows() == 0 {
- rec.Release()
-
- continue
- }
- }
-
- if !yield(rec, nil) {
- rec.Release()
-
- return
- }
- rec.Release()
- }
- }
-
- wfs, err := requireWriteFileIO(args.fs)
- if err != nil {
- return nil, err
- }
-
- var result []iceberg.DataFile
- itr := recordsToDataFiles(ctx, t.tbl.Location(), meta,
recordWritingArgs{
- sc: arrowSchema,
- itr: releaseIter,
- fs: wfs,
- writeUUID: &args.commitUUID,
- factoryOpts: factoryOpts,
- })
-
- for df, err := range itr {
- if err != nil {
- return nil, err
- }
- result = append(result, df)
- }
-
- return result, nil
-}
-
-// writePositionDeletesForFiles writes position-delete files for the rows in
-// files that match filter, appends them to updater, and returns the distinct
-// data-file paths those deletes reference. A position-delete that does not
-// record its referenced data file is omitted from the returned paths (it
-// cannot be existence-checked), matching RowDelta.referencedDataFiles.
-func (t *Transaction) writePositionDeletesForFiles(ctx context.Context, fs
io.IO, updater *snapshotProducer, dvs liveDeletionVectors, files
[]iceberg.DataFile, filter iceberg.BooleanExpression, caseSensitive bool,
concurrency int, commitUUID uuid.UUID) ([]string, error) {
- meta, err := t.txnMeta()
- if err != nil {
- return nil, err
- }
-
- posDeleteRecIter, err := t.makePositionDeleteRecordsForFilter(ctx, fs,
files, filter, caseSensitive, concurrency)
- if err != nil {
- return nil, err
- }
- wfs, err := requireWriteFileIO(fs)
- if err != nil {
- return nil, err
- }
-
- partitionContextByFilePath := make(map[string]partitionContext,
len(files))
- for _, df := range files {
- 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 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).
- // dvs is empty on v2.
- existingDVs := make(map[string]iceberg.DataFile)
- existingDVBitmaps := make(map[string]*dv.RoaringPositionBitmap)
- for _, df := range files {
- entry, ok := dvs[df.FilePath()]
- if !ok {
- continue
- }
- bitmap, err := dv.ReadDV(fs, entry.DataFile())
- if err != nil {
- return nil, fmt.Errorf("failed to read existing
deletion vector for %s: %w", df.FilePath(), err)
- }
- existingDVs[df.FilePath()] = entry.DataFile()
- existingDVBitmaps[df.FilePath()] = bitmap
- }
-
- posDeleteFiles := positionDeleteRecordsToDataFiles(ctx,
t.tbl.Location(), meta, partitionContextByFilePath, recordWritingArgs{
- sc: PositionalDeleteArrowSchema,
- itr: posDeleteRecIter,
- writeUUID: &commitUUID,
- fs: wfs,
- existingDVs: existingDVBitmaps,
- })
-
- var referenced []string
- seen := make(map[string]struct{})
- for f, err := range posDeleteFiles {
- if err != nil {
- return nil, err
- }
- updater.appendDeleteFile(f)
- if ref :=
iceberginternal.BorrowedDataFileReferencedDataFile(f); ref != nil && *ref != ""
{
- if _, ok := seen[*ref]; !ok {
- seen[*ref] = struct{}{}
- referenced = append(referenced, *ref)
- }
- }
- }
-
- // 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
-}
-
-func (t *Transaction) makePositionDeleteRecordsForFilter(ctx context.Context,
fs io.IO, files []iceberg.DataFile, filter iceberg.BooleanExpression,
caseSensitive bool, concurrency int) (seq2 iter.Seq2[arrow.RecordBatch, error],
err error) {
- meta, err := t.txnMeta()
- if err != nil {
- return nil, err
- }
-
- tasks := make([]FileScanTask, 0, len(files))
- for _, f := range files {
- tasks = append(tasks, FileScanTask{
- File: f,
- Start: 0,
- Length: f.FileSizeBytes(),
- })
- }
-
- boundFilter, err := iceberg.BindExpr(meta.CurrentSchema(), filter,
caseSensitive)
- if err != nil {
- return nil, fmt.Errorf("failed to bind filter: %w", err)
- }
-
- builtMeta, err := meta.Build()
- if err != nil {
- return nil, fmt.Errorf("failed to build metadata: %w", err)
- }
-
- scanner := &arrowScan{
- metadata: builtMeta,
- fs: fs,
- scanSchema: meta.CurrentSchema(),
- projectedSchema: meta.CurrentSchema(),
- boundRowFilter: boundFilter,
- caseSensitive: caseSensitive,
- rowLimit: -1, // No limit
- concurrency: concurrency,
- }
- invariants, err := scanner.scanInvariants(builtMeta.Properties())
- if err != nil {
- return nil, err
- }
-
- deletesPerFile, err := readAllDeleteFiles(ctx, fs, tasks, concurrency)
- if err != nil {
- // readAllDeleteFiles can return a partially-populated map
alongside
- // the error if some goroutines completed before the failure.
- releasePerFilePosDeletes(deletesPerFile)
-
- return nil, err
- }
-
- extSet := substrait.NewExtensionSet()
-
- ctx, cancel := context.WithCancelCause(exprs.WithExtensionIDSet(ctx,
extSet))
- taskChan := make(chan tblutils.Enumerated[FileScanTask], len(tasks))
-
- numWorkers := min(concurrency, len(tasks))
- records := make(chan enumeratedRecord, numWorkers)
-
- var wg sync.WaitGroup
- wg.Add(numWorkers)
- for range numWorkers {
- go func() {
- defer wg.Done()
- sink := newRecordSink(records)
- for {
- if err := sink.reserve(ctx); err != nil {
- return
- }
- select {
- case <-ctx.Done():
- return
- case task, ok := <-taskChan:
- if !ok {
- return
- }
-
- if err :=
scanner.producePosDeletesFromTask(ctx, task,
deletesPerFile[task.Value.File.FilePath()], sink, invariants); err != nil {
- cancel(err)
-
- return
- }
- }
- }
- }()
- }
-
- go func() {
- for i, t := range tasks {
- taskChan <- tblutils.Enumerated[FileScanTask]{
- Value: t, Index: i, Last: i == len(tasks)-1,
- }
- }
- close(taskChan)
-
- wg.Wait()
- close(records)
- }()
-
- return createIterator(ctx, uint(numWorkers), records, deletesPerFile,
cancel, scanner.rowLimit), nil
-}
-
func (t *Transaction) Scan(opts ...ScanOption) (*Scan, error) {
meta, err := t.txnMeta()
if err != nil {
diff --git a/table/transaction_delete.go b/table/transaction_delete.go
new file mode 100644
index 000000000..ccf383295
--- /dev/null
+++ b/table/transaction_delete.go
@@ -0,0 +1,1052 @@
+// 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 (
+ "context"
+ "encoding/json"
+ "fmt"
+ "iter"
+ "runtime"
+ "slices"
+ "sync"
+
+ "github.com/apache/arrow-go/v18/arrow"
+ "github.com/apache/arrow-go/v18/arrow/compute/exprs"
+ "github.com/apache/iceberg-go"
+ iceberginternal "github.com/apache/iceberg-go/internal"
+ "github.com/apache/iceberg-go/io"
+ "github.com/apache/iceberg-go/table/dv"
+ tblutils "github.com/apache/iceberg-go/table/internal"
+ "github.com/apache/iceberg-go/table/substrait"
+ "github.com/google/uuid"
+ "golang.org/x/sync/errgroup"
+)
+
+func (t *Transaction) performCopyOnWriteDeletion(ctx context.Context,
operation Operation, snapshotProps iceberg.Properties, filter
iceberg.BooleanExpression, caseSensitive bool, concurrency int)
(*snapshotProducer, io.WriteFileIO, error) {
+ meta, err := t.txnMeta()
+ if err != nil {
+ return nil, nil, err
+ }
+ fs, err := t.tbl.fsF(ctx)
+ if err != nil {
+ return nil, nil, err
+ }
+ wfs, err := requireWriteFileIO(fs)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ if meta.NameMapping() == nil {
+ nameMapping := meta.CurrentSchema().NameMapping()
+ mappingJson, err := json.Marshal(nameMapping)
+ if err != nil {
+ return nil, nil, err
+ }
+ err = t.SetProperties(iceberg.Properties{DefaultNameMappingKey:
string(mappingJson)})
+ if err != nil {
+ return nil, nil, err
+ }
+ }
+
+ commitUUID := uuid.New()
+ updater := t.updateSnapshot(wfs, snapshotProps,
operation).mergeOverwrite(&commitUUID, filter)
+ updater.setManifestConcurrency(concurrency)
+
+ filesToDelete, entriesToRewrite, err :=
t.classifyFilesForDeletions(ctx, fs, filter, caseSensitive, concurrency)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // SetProperties above may have staged a name mapping, which replaces
t.meta, so re-read it.
+ meta, err = t.txnMeta()
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // Rewrites need the live deletes to drop rows already deleted, and
every removed file must take its deletion vector with it.
+ // DVs only exist from v3, so a v2 delete that only drops whole files
skips the read.
+ var (
+ deletes planningDeletes
+ builtMeta Metadata
+ )
+ if len(entriesToRewrite) > 0 || (len(filesToDelete) > 0 &&
meta.formatVersion >= 3) {
+ builtMeta, err = meta.Build()
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to build metadata:
%w", err)
+ }
+ deletes, err = t.readPlanningDeletes(fs, builtMeta)
+ if err != nil {
+ return nil, nil, err
+ }
+ }
+
+ for _, df := range filesToDelete {
+ deletes.dvs.removeDataFile(updater, df)
+ }
+
+ if len(entriesToRewrite) > 0 {
+ if err := t.rewriteFilesWithFilter(ctx, fs, updater, builtMeta,
deletes, entriesToRewrite, filter, caseSensitive, concurrency); err != nil {
+ return nil, nil, err
+ }
+ }
+
+ // Reject the commit if a concurrent snapshot added deletes against any
+ // data file this operation removes. The overwriteFiles producer only
+ // checks added data files (and only under serializable isolation);
+ // without this check a refresh-and-replay would swap the original file
+ // for a rewrite built from the stale snapshot, dropping the concurrent
+ // deletes and resurrecting their rows. Mirrors Java's copy-on-write
+ // validateNoConflictingDeletes: no isolation gating. The check is
+ // shared with RewriteDataFiles via rewriteValidator, so changes there
+ // apply here too. A concurrent removal of one of these files is caught
+ // earlier, by the retry rebuild's checkRemovedFiles
(ErrCommitDiverged).
+ removed := slices.Clip(filesToDelete)
+ for _, entry := range entriesToRewrite {
+ removed = append(removed, entry.DataFile())
+ }
+ if len(removed) > 0 {
+ t.addValidator(rewriteValidator(removed))
+ }
+
+ return updater, wfs, nil
+}
+
+func (t *Transaction) performMergeOnReadDeletion(ctx context.Context,
snapshotProps iceberg.Properties, filter iceberg.BooleanExpression,
caseSensitive bool, concurrency int) (*snapshotProducer, error) {
+ meta, err := t.txnMeta()
+ if err != nil {
+ return nil, err
+ }
+ fs, err := t.tbl.fsF(ctx)
+ if err != nil {
+ return nil, err
+ }
+ wfs, err := requireWriteFileIO(fs)
+ if err != nil {
+ return nil, err
+ }
+
+ if meta.NameMapping() == nil {
+ nameMapping := meta.CurrentSchema().NameMapping()
+ mappingJson, err := json.Marshal(nameMapping)
+ if err != nil {
+ return nil, err
+ }
+ err = t.SetProperties(iceberg.Properties{DefaultNameMappingKey:
string(mappingJson)})
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ commitUUID := uuid.New()
+ updater := t.updateSnapshot(wfs, snapshotProps,
OpDelete).mergeOverwrite(&commitUUID, filter)
+ updater.setManifestConcurrency(concurrency)
+
+ filesToDelete, withPartialDeletions, err :=
t.classifyFilesForDeletions(ctx, fs, filter, caseSensitive, concurrency)
+ if err != nil {
+ return nil, err
+ }
+
+ // On v3 every removed file takes its deletion vector with it, and a
partially deleted file's vector is
+ // merged into its replacement. Both need only the deletion vectors,
read once.
+ // DVs only exist from v3, so v2 skips the read.
+ var dvs liveDeletionVectors
+ if meta.formatVersion >= 3 && (len(filesToDelete) > 0 ||
len(withPartialDeletions) > 0) {
+ dvs, err = t.readLiveDeletionVectors(fs)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ for _, df := range filesToDelete {
+ dvs.removeDataFile(updater, df)
+ }
+
+ if len(withPartialDeletions) > 0 {
+ partialFiles := make([]iceberg.DataFile, 0,
len(withPartialDeletions))
+ for _, entry := range withPartialDeletions {
+ partialFiles = append(partialFiles, entry.DataFile())
+ }
+ referenced, err := t.writePositionDeletesForFiles(ctx, fs,
updater, dvs, partialFiles, filter, caseSensitive, concurrency, commitUUID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Verify the data files these position deletes target still
exist on
+ // the branch head at commit time. The overwriteFiles producer
only
+ // validates partition-filter overlap; without this check a
concurrent
+ // commit that removed a referenced data file would silently
orphan the
+ // deletes (they would apply to rewritten data or dangle).
Mirrors
+ // RowDelta: collect referenced data-file paths, leave out the
ones
+ // staged earlier in the transaction, and run
validateDataFilesExist
+ // unconditionally (no isolation gating).
+ referenced, err = t.headDataFiles(fs, referenced, nil)
+ if err != nil {
+ return nil, err
+ }
+ if len(referenced) > 0 {
+ t.addValidator(func(cc *conflictContext) error {
+ return validateDataFilesExist(cc, referenced)
+ })
+ }
+ }
+
+ return updater, nil
+}
+
+type DeleteOption func(deleteOp *deleteOperation)
+
+type deleteOperation struct {
+ caseSensitive bool
+ concurrency int
+}
+
+// WithDeleteConcurrency overwrites the default concurrency for delete
operations.
+// Default: runtime.GOMAXPROCS(0)
+func WithDeleteConcurrency(concurrency int) DeleteOption {
+ return func(op *deleteOperation) {
+ if concurrency <= 0 {
+ op.concurrency = runtime.GOMAXPROCS(0)
+
+ return
+ }
+ op.concurrency = concurrency
+ }
+}
+
+// WithDeleteCaseInsensitive changes the binding of the filter to be case
insensitive instead of the
+// Default: case sensitive
+// Note that the sensitivity only applies to the field name and not the
evaluation of the literals on string fields.
+func WithDeleteCaseInsensitive() DeleteOption {
+ return func(deleteOp *deleteOperation) {
+ deleteOp.caseSensitive = false
+ }
+}
+
+// Delete deletes records matching the provided filter.
+//
+// The provided filter acts as a row-level predicate on existing data:
+// - Files where all rows match the filter (strict match) are completely
deleted
+// - Files where some rows match and others don't (partial match) are
rewritten to keep only non-matching rows
+// - Files where no rows match the filter are kept unchanged
+//
+// A row matches only if the filter is true for it. Rows where the filter
evaluates to NULL, for example
+// `age = 30` on a NULL age, do not match and are kept.
+//
+// The filter uses both inclusive and strict metrics evaluators on file
statistics to classify files:
+// - Inclusive evaluator identifies candidate files that may contain
matching rows
+// - Strict evaluator determines if all rows in a file must match the filter
+// - Files that pass inclusive but not strict evaluation are rewritten with
filtered data
+//
+// On a v3 table, every data file the delete removes or rewrites takes its
deletion vector with it.
+// A merge-on-read delete replaces the existing deletion vector of a file it
deletes rows from.
+//
+// A commit that removes a deletion vector is not replayed: if another writer
commits first,
+// it fails with ErrCommitFailed instead of refreshing and retrying, so reload
the table and run the delete again.
+//
+// The concurrency parameter controls the level of parallelism for manifest
processing and file rewriting and
+// can be overridden using the WithOverwriteConcurrency option. Defaults to
runtime.GOMAXPROCS(0).
+func (t *Transaction) Delete(ctx context.Context, filter
iceberg.BooleanExpression, snapshotProps iceberg.Properties, opts
...DeleteOption) (err error) {
+ meta, err := t.txnMeta()
+ if err != nil {
+ return err
+ }
+ deleteOp := deleteOperation{
+ concurrency: runtime.GOMAXPROCS(0),
+ caseSensitive: true,
+ }
+ for _, apply := range opts {
+ apply(&deleteOp)
+ }
+
+ var updater *snapshotProducer
+ writeDeleteMode := WriteDeleteModeDefault
+ // Only copy on write is supported on v1 so we ignore any override to
the write delete mode unless the version is
+ // 2 and up
+ if meta.formatVersion > 1 {
+ writeDeleteMode = meta.props.Get(WriteDeleteModeKey,
WriteDeleteModeDefault)
+ }
+ switch writeDeleteMode {
+ case WriteModeCopyOnWrite:
+ updater, _, err = t.performCopyOnWriteDeletion(ctx, OpDelete,
snapshotProps, filter, deleteOp.caseSensitive, deleteOp.concurrency)
+ if err != nil {
+ return err
+ }
+ case WriteModeMergeOnRead:
+ updater, err = t.performMergeOnReadDeletion(ctx, snapshotProps,
filter, deleteOp.caseSensitive, deleteOp.concurrency)
+ if err != nil {
+ return err
+ }
+ default:
+ return fmt.Errorf("unsupported write mode: '%s'",
writeDeleteMode)
+ }
+
+ updates, reqs, err := updater.commit(ctx)
+ if err != nil {
+ return err
+ }
+
+ return t.apply(updates, reqs)
+}
+
+// classifyFilesForDeletions classifies existing data files based on the
provided filter.
+// Returns files to delete completely, the manifest entries of files that
partially match, and any error.
+// Partial matches stay entries because their sequence numbers decide which
deletes apply and seed row-lineage synthesis.
+func (t *Transaction) classifyFilesForDeletions(ctx context.Context, fs io.IO,
filter iceberg.BooleanExpression, caseSensitive bool, concurrency int)
(filesToDelete []iceberg.DataFile, filesWithPartialDeletions
[]iceberg.ManifestEntry, err error) {
+ meta, err := t.txnMeta()
+ if err != nil {
+ return nil, nil, err
+ }
+
+ s := t.planningSnapshot(meta)
+ if s == nil {
+ return nil, nil, nil
+ }
+
+ if filter == nil || filter.Equals(iceberg.AlwaysTrue{}) {
+ for df, err := range s.dataFiles(fs, nil) {
+ if err != nil {
+ return nil, nil, err
+ }
+ if df.ContentType() == iceberg.EntryContentData {
+ filesToDelete = append(filesToDelete, df)
+ }
+ }
+
+ return filesToDelete, nil, nil
+ }
+
+ return t.classifyFilesForFilteredDeletions(ctx, fs, filter,
caseSensitive, concurrency)
+}
+
+type fileClassificationTask struct {
+ meta Metadata
+ partitionFilters *keyDefaultMapErr[int, iceberg.BooleanExpression]
+ caseSensitive bool
+ rowFilter iceberg.BooleanExpression
+}
+
+func newFileClassificationTask(meta Metadata, rowFilter
iceberg.BooleanExpression, caseSensitive bool) *fileClassificationTask {
+ classificationTask := &fileClassificationTask{
+ meta: meta,
+ caseSensitive: caseSensitive,
+ rowFilter: rowFilter,
+ }
+ classificationTask.partitionFilters =
newKeyDefaultMapWrapErr(classificationTask.buildPartitionProjection)
+
+ return classificationTask
+}
+
+func (t *fileClassificationTask) buildManifestEvaluator(specID int)
(func(iceberg.ManifestFile) (bool, error), error) {
+ return buildManifestEvaluator(specID, t.meta, t.meta.CurrentSchema(),
t.partitionFilters, t.caseSensitive)
+}
+
+func (t *fileClassificationTask) buildPartitionProjection(specID int)
(iceberg.BooleanExpression, error) {
+ return buildPartitionProjection(specID, t.meta, t.meta.CurrentSchema(),
t.rowFilter, t.caseSensitive)
+}
+
+// classifyFilesForFilteredDeletions classifies files for filtered overwrite
operations.
+// Returns files to delete completely, the manifest entries of files that
partially match, and any error.
+func (t *Transaction) classifyFilesForFilteredDeletions(ctx context.Context,
fs io.IO, filter iceberg.BooleanExpression, caseSensitive bool, concurrency
int) (filesToDelete []iceberg.DataFile, filesWithPartialDeletes
[]iceberg.ManifestEntry, err error) {
+ meta, err := t.txnMeta()
+ if err != nil {
+ return nil, nil, err
+ }
+
+ schema := meta.CurrentSchema()
+ builtMeta, err := meta.Build()
+ if err != nil {
+ return nil, nil, err
+ }
+
+ inclusiveEvaluator, err := newInclusiveMetricsEvaluator(schema, filter,
caseSensitive, false)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to create inclusive metrics
evaluator: %w", err)
+ }
+
+ // A file is deleted as a whole only if the filter is true for all of
its
+ // rows. The strict evaluator treats predicates as two-valued, so
NotEqual
+ // and NotIn would also count rows where the filter is NULL. Evaluate
the
+ // negation of the rows the rewrite keeps instead, so that both paths
agree.
+ notTrue, err := isNotTrueExpr(filter)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to build negated filter:
%w", err)
+ }
+
+ strictEvaluator, err := newStrictMetricsEvaluator(schema,
iceberg.NewNot(notTrue), caseSensitive, false)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to create strict metrics
evaluator: %w", err)
+ }
+
+ classificationTask := newFileClassificationTask(builtMeta, filter,
caseSensitive)
+ manifestEvaluators :=
newKeyDefaultMapWrapErr(classificationTask.buildManifestEvaluator)
+
+ s := t.planningSnapshot(meta)
+ var manifests []iceberg.ManifestFile
+ if s != nil {
+ manifests, err = s.Manifests(fs)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to get manifests:
%w", err)
+ }
+ }
+
+ var mu sync.Mutex
+
+ g, _ := errgroup.WithContext(ctx)
+ g.SetLimit(min(concurrency, len(manifests)))
+
+ for _, manifest := range manifests {
+ g.Go(func() error {
+ manifestEval, err :=
manifestEvaluators.Get(int(manifest.PartitionSpecID()))
+ if err != nil {
+ return fmt.Errorf("failed to build manifest
evaluator for spec %d: %w", manifest.PartitionSpecID(), err)
+ }
+ match, err := manifestEval(manifest)
+ if err != nil {
+ return fmt.Errorf("failed to evaluate manifest
%s: %w", manifest.FilePath(), err)
+ }
+ if !match {
+ return nil
+ }
+
+ localDelete := make([]iceberg.DataFile, 0)
+ localRewrite := make([]iceberg.ManifestEntry, 0)
+
+ for entry, err := range manifest.Entries(fs, false) {
+ if err != nil {
+ return fmt.Errorf("failed to fetch
manifest entries: %w", err)
+ }
+ if entry.Status() == iceberg.EntryStatusDELETED
{
+ continue
+ }
+
+ df := entry.DataFile()
+ if df.ContentType() != iceberg.EntryContentData
{
+ continue
+ }
+
+ inclusive, err := inclusiveEvaluator(df)
+ if err != nil {
+ return fmt.Errorf("failed to evaluate
data file %s with inclusive evaluator: %w", df.FilePath(), err)
+ }
+
+ if !inclusive {
+ continue
+ }
+
+ strict, err := strictEvaluator(df)
+ if err != nil {
+ return fmt.Errorf("failed to evaluate
data file %s with strict evaluator: %w", df.FilePath(), err)
+ }
+
+ if strict {
+ localDelete = append(localDelete, df)
+ } else {
+ localRewrite = append(localRewrite,
entry)
+ }
+ }
+
+ if len(localDelete) > 0 || len(localRewrite) > 0 {
+ mu.Lock()
+ filesToDelete = append(filesToDelete,
localDelete...)
+ filesWithPartialDeletes =
append(filesWithPartialDeletes, localRewrite...)
+ mu.Unlock()
+ }
+
+ return nil
+ })
+ }
+
+ if err := g.Wait(); err != nil {
+ return nil, nil, err
+ }
+
+ return filesToDelete, filesWithPartialDeletes, nil
+}
+
+// maps a data file path to the live deletion vector that references it.
+type liveDeletionVectors map[string]iceberg.ManifestEntry
+
+// removeDataFile removes df and, as the spec requires, the deletion vector
that references it.
+//
+// Removing a deletion vector makes the commit non-replayable (see
snapshotProducer.commitManifests).
+// Position and equality delete files are left alone, since they can still
apply to other files.
+func (dvs liveDeletionVectors) removeDataFile(updater *snapshotProducer, df
iceberg.DataFile) {
+ updater.deleteDataFile(df)
+ if dv, ok := dvs[df.FilePath()]; ok {
+ updater.removeDeletionVector(dv.DataFile())
+ }
+}
+
+// planningDeletes indexes the live delete files of the planning snapshot the
same way scan planning indexes them.
+type planningDeletes struct {
+ positional *positionalDeleteIndex
+ dvs liveDeletionVectors
+ equality *equalityDeleteIndex
+}
+
+// classifyLiveDeletes reads every live delete file of the planning snapshot
and sorts it by kind.
+//
+// Known limitation (#2160): every live delete manifest of the snapshot is
read on each call,
+// with no partition pruning, so a narrow delete or rewrite on a delete-heavy
table pays for all of them.
+func (t *Transaction) classifyLiveDeletes(fs io.IO)
(classifiedManifestEntries, error) {
+ meta, err := t.txnMeta()
+ if err != nil {
+ return classifiedManifestEntries{}, err
+ }
+
+ var liveDeletes []iceberg.ManifestEntry
+ if s := t.planningSnapshot(meta); s != nil {
+ // Skip DELETED entries: a replaced DV stays behind as one, and
merging it
+ // into a new DV would bring back deleted rows. See #1372.
+ for entry, err := range s.entries(fs,
iceberg.ManifestContentDeletes, true) {
+ if err != nil {
+ return classifiedManifestEntries{},
fmt.Errorf("failed to read delete manifests: %w", err)
+ }
+ liveDeletes = append(liveDeletes, entry)
+ }
+ }
+
+ return classifyManifestEntries(liveDeletes)
+}
+
+// readPlanningDeletes reads and indexes every live delete file of the
planning snapshot. Copy-on-write
+// uses it because a rewrite must apply every kind of delete to the rows it
keeps.
+//
+// Delete manifests are not pruned by the row filter: with filter id=2 the
rewrite keeps id=1,
+// so a delete on id=1 must still be applied.
+func (t *Transaction) readPlanningDeletes(fs io.IO, builtMeta Metadata)
(planningDeletes, error) {
+ meta, err := t.txnMeta()
+ if err != nil {
+ return planningDeletes{}, err
+ }
+
+ classified, err := t.classifyLiveDeletes(fs)
+ if err != nil {
+ return planningDeletes{}, err
+ }
+ positional, err :=
buildPositionalDeleteIndex(classified.positionalDeleteEntries)
+ if err != nil {
+ return planningDeletes{}, err
+ }
+ dvs, err := buildDVIndex(classified.dvEntries)
+ if err != nil {
+ return planningDeletes{}, err
+ }
+ equality, err :=
buildEqualityDeleteIndex(classified.equalityDeleteEntries, builtMeta,
meta.CurrentSchema())
+ if err != nil {
+ return planningDeletes{}, err
+ }
+
+ return planningDeletes{positional: positional, dvs: dvs, equality:
equality}, nil
+}
+
+// readLiveDeletionVectors returns the live deletion vectors of the planning
snapshot. Merge-on-read uses it
+// because it only drops or merges deletion vectors, so it skips building the
position and equality delete
+// indexes and neither pays for them nor fails on them.
+func (t *Transaction) readLiveDeletionVectors(fs io.IO) (liveDeletionVectors,
error) {
+ classified, err := t.classifyLiveDeletes(fs)
+ if err != nil {
+ return nil, err
+ }
+
+ return buildDVIndex(classified.dvEntries)
+}
+
+// rewriteFilesWithFilter rewrites data files by preserving only rows that do
NOT match the filter.
+// Rows already removed by a delete are dropped too, since those deletes stop
applying once the file is replaced.
+// The filter binding and substrait conversion are computed once here and
reused across every file.
+func (t *Transaction) rewriteFilesWithFilter(ctx context.Context, fs io.IO,
updater *snapshotProducer, builtMeta Metadata, deletes planningDeletes, entries
[]iceberg.ManifestEntry, filter iceberg.BooleanExpression, caseSensitive bool,
concurrency int) error {
+ meta, err := t.txnMeta()
+ if err != nil {
+ return err
+ }
+
+ complementFilter, err := isNotTrueExpr(filter)
+ if err != nil {
+ return fmt.Errorf("failed to build complement filter: %w", err)
+ }
+
+ // Bind + convert the complement filter once for the whole rewrite. The
+ // per-batch filter function is reused across every record batch from
+ // every file in this rewrite — substrait conversion in particular is
+ // non-trivial so doing it inside the iterator loop wastes work.
+ postFilter, err := prepareBatchFilter(complementFilter,
meta.CurrentSchema(), caseSensitive)
+ if err != nil {
+ return err
+ }
+
+ for _, entry := range entries {
+ task, err := fileScanTaskForDataEntry(entry,
deletes.positional, deletes.dvs, deletes.equality)
+ if err != nil {
+ return err
+ }
+
+ // Use a separate UUID for rewrite operations to avoid filename
collisions with new data files
+ rewriteUUID := uuid.New()
+ args := rewriteSingleFileArgs{
+ fs: fs,
+ builtMeta: builtMeta,
+ task: task,
+ filter: complementFilter,
+ postFilter: postFilter,
+ caseSensitive: caseSensitive,
+ commitUUID: rewriteUUID,
+ concurrency: concurrency,
+ }
+ rewrittenFiles, err := t.rewriteSingleFile(ctx, args)
+ if err != nil {
+ return fmt.Errorf("failed to rewrite file %s: %w",
task.File.FilePath(), err)
+ }
+
+ deletes.dvs.removeDataFile(updater, task.File)
+ for _, rewrittenFile := range rewrittenFiles {
+ updater.appendDataFile(rewrittenFile)
+ }
+ }
+
+ return nil
+}
+
+// isNotTrueExpr returns an expression that holds exactly for the rows where
+// filter is not true, i.e. where it is false or NULL. Plain NOT(filter) is not
+// enough: under three-valued logic `v = 5` is NULL when v is NULL, and so is
+// NOT(v = 5), so a copy-on-write rewrite would drop rows that never matched.
+//
+// The result contains no NOT. Row-group pruning pushes a NOT down with
+// RewriteNotExpr, which turns NOT(x < 5) into x >= 5, and that is false for
+// NaN. Negating the predicates here keeps NaN rows in both places.
+func isNotTrueExpr(filter iceberg.BooleanExpression)
(iceberg.BooleanExpression, error) {
+ res, err := iceberg.VisitExpr(filter, isNotTrueVisitor{})
+ if err != nil {
+ return nil, err
+ }
+
+ return res.notTrue, nil
+}
+
+// truthExprs holds two never-NULL expressions for a sub-expression e:
+// notTrue holds iff e is false or NULL, notFalse holds iff e is true or NULL.
+type truthExprs struct {
+ notTrue, notFalse iceberg.BooleanExpression
+}
+
+type isNotTrueVisitor struct{}
+
+func (isNotTrueVisitor) VisitTrue() truthExprs {
+ return truthExprs{notTrue: iceberg.AlwaysFalse{}, notFalse:
iceberg.AlwaysTrue{}}
+}
+
+func (isNotTrueVisitor) VisitFalse() truthExprs {
+ return truthExprs{notTrue: iceberg.AlwaysTrue{}, notFalse:
iceberg.AlwaysFalse{}}
+}
+
+func (isNotTrueVisitor) VisitNot(child truthExprs) truthExprs {
+ return truthExprs{notTrue: child.notFalse, notFalse: child.notTrue}
+}
+
+func (isNotTrueVisitor) VisitAnd(left, right truthExprs) truthExprs {
+ return truthExprs{
+ notTrue: iceberg.NewOr(left.notTrue, right.notTrue),
+ notFalse: iceberg.NewAnd(left.notFalse, right.notFalse),
+ }
+}
+
+func (isNotTrueVisitor) VisitOr(left, right truthExprs) truthExprs {
+ return truthExprs{
+ notTrue: iceberg.NewAnd(left.notTrue, right.notTrue),
+ notFalse: iceberg.NewOr(left.notFalse, right.notFalse),
+ }
+}
+
+func (isNotTrueVisitor) VisitUnbound(pred iceberg.UnboundPredicate) truthExprs
{
+ negated := pred.Negate()
+ switch pred.Op() {
+ case iceberg.OpIsNull, iceberg.OpNotNull, iceberg.OpNotNan:
+ // never evaluates to NULL
+ return truthExprs{notTrue: negated, notFalse: pred}
+ case iceberg.OpIsNan:
+ // is_nan(NULL) is false, so NULL rows survive. The guard also
covers
+ // files written before the column existed, where NotNaN on the
missing
+ // column translates to AlwaysFalse.
+ return truthExprs{notTrue: iceberg.NewOr(negated,
iceberg.IsNull(pred.Term())), notFalse: pred}
+ case iceberg.OpLT, iceberg.OpLTEQ, iceberg.OpGT, iceberg.OpGTEQ:
+ // NaN fails both x < 5 and x >= 5. IsNaN binds to AlwaysFalse
for
+ // non-floating-point terms.
+ negated = iceberg.NewOr(negated, iceberg.IsNaN(pred.Term()))
+ }
+
+ // Any other predicate is NULL when its term is NULL. Guard In and NotIn
+ // too: binding turns a set that ends up with one value into Equal or
+ // NotEqual, which are NULL for a NULL term. A scan with a multi-valued
+ // NotIn still matches NULL rows, see
+ // https://github.com/apache/iceberg-go/issues/2132.
+ isNull := iceberg.IsNull(pred.Term())
+
+ return truthExprs{
+ notTrue: iceberg.NewOr(negated, isNull),
+ notFalse: iceberg.NewOr(pred, isNull),
+ }
+}
+
+func (isNotTrueVisitor) VisitBound(pred iceberg.BoundPredicate) truthExprs {
+ panic(fmt.Errorf("%w: found already bound predicate: %s",
iceberg.ErrInvalidArgument, pred))
+}
+
+// rewriteSingleFileArgs bundles the parameters for
[Transaction.rewriteSingleFile]
+// — there are several same-typed fields (two filter values, plus a UUID and an
+// int) so positional ordering offers no compile-time order protection.
+type rewriteSingleFileArgs struct {
+ fs io.IO
+ // builtMeta is the transaction's metadata, built once for the whole
rewrite.
+ builtMeta Metadata
+ // task is the source file as planned by [fileScanTaskForDataEntry].
+ // Its deletes must be applied, and its sequence number seeds
_last_updated_sequence_number
+ // for rows whose value is null in the file.
+ task FileScanTask
+ // filter is the per-row complement (rows that survive the rewrite).
+ filter iceberg.BooleanExpression
+ // postFilter is the pre-bound, pre-substrait-converted version of
+ // filter. When preserveRowLineage applies it is the only place the
+ // filter is evaluated; the scanner's row filter is bypassed so
+ // _row_id positions stay correct.
+ postFilter func(context.Context, arrow.RecordBatch)
(arrow.RecordBatch, error)
+ caseSensitive bool
+ commitUUID uuid.UUID
+ concurrency int
+}
+
+// rewriteSingleFile reads a single data file, applies the filter, and writes
+// new files with filtered data. See [rewriteSingleFileArgs] for parameter
+// semantics.
+func (t *Transaction) rewriteSingleFile(ctx context.Context, args
rewriteSingleFileArgs) ([]iceberg.DataFile, error) {
+ meta, err := t.txnMeta()
+ if err != nil {
+ return nil, err
+ }
+
+ // Preserve row lineage for v3 tables: include _row_id and
+ // _last_updated_sequence_number in the scan projection so they are read
+ // from the source file (or synthesized from file metadata when null)
and
+ // written explicitly into the output Parquet. The Iceberg v3 spec says
+ // _last_updated_sequence_number on a data file row is the sequence
+ // number of the snapshot that last *logically* updated the row — for a
+ // pure CoW rewrite the surviving rows are unchanged, so they must keep
+ // their original value rather than inheriting the new commit's seq.
+ //
+ // When preserving lineage, the row filter must NOT be applied inside
the
+ // scanner because _row_id synthesis depends on original file position.
The
+ // filter is applied post-synthesis in the record iterator instead.
+ //
+ // Keep the real filter available to Parquet row-group pruning while the
+ // scanner's row filter stays AlwaysTrue. Pruning happens before rows
are
+ // materialized, so _row_id synthesis still sees the original positions
of
+ // every surviving row group and the post-synthesis filter remains
correct.
+ _, originalFirstRowID, _, _, _ :=
iceberginternal.BorrowedDataFilePointers(args.task.File)
+ preserveRowLineage := meta.formatVersion >= 3 && originalFirstRowID !=
nil
+ projectedSchema := meta.CurrentSchema()
+ var factoryOpts []writerFactoryOption
+ var rowGroupFilter iceberg.BooleanExpression
+ if preserveRowLineage {
+ rowGroupFilter, err = iceberg.BindExpr(meta.CurrentSchema(),
args.filter, args.caseSensitive)
+ if err != nil {
+ return nil, fmt.Errorf("failed to bind row-group
filter: %w", err)
+ }
+
+ projectedSchema = iceberg.SchemaWithRowLineage(projectedSchema)
+ factoryOpts = append(factoryOpts,
withFactoryFileSchema(projectedSchema))
+ }
+
+ // When preserving row lineage, the bound scan-time filter is replaced
+ // with AlwaysTrue and the rebound version inside prepareBatchFilter is
+ // the one actually applied. Skip the wasted BindExpr in that case.
+ scanFilter := iceberg.BooleanExpression(iceberg.AlwaysTrue{})
+ if !preserveRowLineage {
+ boundFilter, err := iceberg.BindExpr(meta.CurrentSchema(),
args.filter, args.caseSensitive)
+ if err != nil {
+ return nil, fmt.Errorf("failed to bind filter: %w", err)
+ }
+ scanFilter = boundFilter
+ }
+
+ scanner := &arrowScan{
+ metadata: args.builtMeta,
+ fs: args.fs,
+ scanSchema: meta.CurrentSchema(),
+ projectedSchema: projectedSchema,
+ boundRowFilter: scanFilter,
+ rowGroupFilter: rowGroupFilter,
+ caseSensitive: args.caseSensitive,
+ rowLimit: -1, // No limit
+ concurrency: args.concurrency,
+ }
+
+ arrowSchema, recordIter, err := scanner.GetRecords(ctx,
[]FileScanTask{args.task})
+ if err != nil {
+ return nil, fmt.Errorf("failed to get records from original
file: %w", err)
+ }
+
+ // When preserving row lineage, use an Arrow schema with field IDs so
that
+ // recordsToDataFiles can resolve _row_id via field ID rather than the
name
+ // mapping (which doesn't include metadata columns).
+ if preserveRowLineage {
+ arrowSchema, err =
SchemaToArrowSchemaWithOptions(projectedSchema, ArrowSchemaOptions{
+ IncludeFieldIDs: true,
+ TableProperties: args.builtMeta.Properties(),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to build arrow schema
with field IDs: %w", err)
+ }
+ }
+
+ // Wrap the iterator to release records after consumption.
+ // When preserving row lineage, also apply the (pre-bound) filter here
+ // post-synthesis so _row_id positions stay correct.
+ releaseIter := func(yield func(arrow.RecordBatch, error) bool) {
+ for rec, err := range recordIter {
+ if err != nil {
+ yield(nil, err)
+
+ return
+ }
+
+ if preserveRowLineage {
+ rec, err = args.postFilter(ctx, rec)
+ if err != nil {
+ yield(nil, err)
+
+ return
+ }
+ if rec.NumRows() == 0 {
+ rec.Release()
+
+ continue
+ }
+ }
+
+ if !yield(rec, nil) {
+ rec.Release()
+
+ return
+ }
+ rec.Release()
+ }
+ }
+
+ wfs, err := requireWriteFileIO(args.fs)
+ if err != nil {
+ return nil, err
+ }
+
+ var result []iceberg.DataFile
+ itr := recordsToDataFiles(ctx, t.tbl.Location(), meta,
recordWritingArgs{
+ sc: arrowSchema,
+ itr: releaseIter,
+ fs: wfs,
+ writeUUID: &args.commitUUID,
+ factoryOpts: factoryOpts,
+ })
+
+ for df, err := range itr {
+ if err != nil {
+ return nil, err
+ }
+ result = append(result, df)
+ }
+
+ return result, nil
+}
+
+// writePositionDeletesForFiles writes position-delete files for the rows in
+// files that match filter, appends them to updater, and returns the distinct
+// data-file paths those deletes reference. A position-delete that does not
+// record its referenced data file is omitted from the returned paths (it
+// cannot be existence-checked), matching RowDelta.referencedDataFiles.
+func (t *Transaction) writePositionDeletesForFiles(ctx context.Context, fs
io.IO, updater *snapshotProducer, dvs liveDeletionVectors, files
[]iceberg.DataFile, filter iceberg.BooleanExpression, caseSensitive bool,
concurrency int, commitUUID uuid.UUID) ([]string, error) {
+ meta, err := t.txnMeta()
+ if err != nil {
+ return nil, err
+ }
+
+ posDeleteRecIter, err := t.makePositionDeleteRecordsForFilter(ctx, fs,
files, filter, caseSensitive, concurrency)
+ if err != nil {
+ return nil, err
+ }
+ wfs, err := requireWriteFileIO(fs)
+ if err != nil {
+ return nil, err
+ }
+
+ partitionContextByFilePath := make(map[string]partitionContext,
len(files))
+ for _, df := range files {
+ 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 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).
+ // dvs is empty on v2.
+ existingDVs := make(map[string]iceberg.DataFile)
+ existingDVBitmaps := make(map[string]*dv.RoaringPositionBitmap)
+ for _, df := range files {
+ entry, ok := dvs[df.FilePath()]
+ if !ok {
+ continue
+ }
+ bitmap, err := dv.ReadDV(fs, entry.DataFile())
+ if err != nil {
+ return nil, fmt.Errorf("failed to read existing
deletion vector for %s: %w", df.FilePath(), err)
+ }
+ existingDVs[df.FilePath()] = entry.DataFile()
+ existingDVBitmaps[df.FilePath()] = bitmap
+ }
+
+ posDeleteFiles := positionDeleteRecordsToDataFiles(ctx,
t.tbl.Location(), meta, partitionContextByFilePath, recordWritingArgs{
+ sc: PositionalDeleteArrowSchema,
+ itr: posDeleteRecIter,
+ writeUUID: &commitUUID,
+ fs: wfs,
+ existingDVs: existingDVBitmaps,
+ })
+
+ var referenced []string
+ seen := make(map[string]struct{})
+ for f, err := range posDeleteFiles {
+ if err != nil {
+ return nil, err
+ }
+ updater.appendDeleteFile(f)
+ if ref :=
iceberginternal.BorrowedDataFileReferencedDataFile(f); ref != nil && *ref != ""
{
+ if _, ok := seen[*ref]; !ok {
+ seen[*ref] = struct{}{}
+ referenced = append(referenced, *ref)
+ }
+ }
+ }
+
+ // 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
+}
+
+func (t *Transaction) makePositionDeleteRecordsForFilter(ctx context.Context,
fs io.IO, files []iceberg.DataFile, filter iceberg.BooleanExpression,
caseSensitive bool, concurrency int) (seq2 iter.Seq2[arrow.RecordBatch, error],
err error) {
+ meta, err := t.txnMeta()
+ if err != nil {
+ return nil, err
+ }
+
+ tasks := make([]FileScanTask, 0, len(files))
+ for _, f := range files {
+ tasks = append(tasks, FileScanTask{
+ File: f,
+ Start: 0,
+ Length: f.FileSizeBytes(),
+ })
+ }
+
+ boundFilter, err := iceberg.BindExpr(meta.CurrentSchema(), filter,
caseSensitive)
+ if err != nil {
+ return nil, fmt.Errorf("failed to bind filter: %w", err)
+ }
+
+ builtMeta, err := meta.Build()
+ if err != nil {
+ return nil, fmt.Errorf("failed to build metadata: %w", err)
+ }
+
+ scanner := &arrowScan{
+ metadata: builtMeta,
+ fs: fs,
+ scanSchema: meta.CurrentSchema(),
+ projectedSchema: meta.CurrentSchema(),
+ boundRowFilter: boundFilter,
+ caseSensitive: caseSensitive,
+ rowLimit: -1, // No limit
+ concurrency: concurrency,
+ }
+ invariants, err := scanner.scanInvariants(builtMeta.Properties())
+ if err != nil {
+ return nil, err
+ }
+
+ deletesPerFile, err := readAllDeleteFiles(ctx, fs, tasks, concurrency)
+ if err != nil {
+ // readAllDeleteFiles can return a partially-populated map
alongside
+ // the error if some goroutines completed before the failure.
+ releasePerFilePosDeletes(deletesPerFile)
+
+ return nil, err
+ }
+
+ extSet := substrait.NewExtensionSet()
+
+ ctx, cancel := context.WithCancelCause(exprs.WithExtensionIDSet(ctx,
extSet))
+ taskChan := make(chan tblutils.Enumerated[FileScanTask], len(tasks))
+
+ numWorkers := min(concurrency, len(tasks))
+ records := make(chan enumeratedRecord, numWorkers)
+
+ var wg sync.WaitGroup
+ wg.Add(numWorkers)
+ for range numWorkers {
+ go func() {
+ defer wg.Done()
+ sink := newRecordSink(records)
+ for {
+ if err := sink.reserve(ctx); err != nil {
+ return
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case task, ok := <-taskChan:
+ if !ok {
+ return
+ }
+
+ if err :=
scanner.producePosDeletesFromTask(ctx, task,
deletesPerFile[task.Value.File.FilePath()], sink, invariants); err != nil {
+ cancel(err)
+
+ return
+ }
+ }
+ }
+ }()
+ }
+
+ go func() {
+ for i, t := range tasks {
+ taskChan <- tblutils.Enumerated[FileScanTask]{
+ Value: t, Index: i, Last: i == len(tasks)-1,
+ }
+ }
+ close(taskChan)
+
+ wg.Wait()
+ close(records)
+ }()
+
+ return createIterator(ctx, uint(numWorkers), records, deletesPerFile,
cancel, scanner.rowLimit), nil
+}