Marouane-elfadli opened a new pull request, #18824:
URL: https://github.com/apache/iceberg/pull/18824
Related to #18713
## Summary
`SparkTable.deleteWhere` ignores `spark.wap.id`. A `DELETE` that Spark
handles as
a metadata delete is therefore committed straight to the current branch
instead
of being staged. This PR makes `deleteWhere` stage the commit and record the
`wap.id`, the same way the other two write paths already do. It touches Spark
3.5, 4.0, 4.1 and 4.2.
## How Spark reaches this code
For `DELETE FROM t WHERE <filter>`, Spark first asks the table whether the
filter
can be applied as a metadata-only delete
(`SupportsDeleteV2.canDeleteWhere`). In
Iceberg that is true when the filter matches whole partitions or whole
files, so
no data has to be rewritten. If it is true, Spark calls
`SparkTable.deleteWhere`.
If not, it plans a row-level write, which goes through
`SparkPositionDeltaWrite`
(merge-on-read) or `SparkWrite` (copy-on-write).
That gives three write paths for a DELETE:
| Path | Class | Handled WAP before this PR? |
|---|---|---|
| Merge-on-read | `SparkPositionDeltaWrite.commitOperation` | Yes |
| Copy-on-write | `SparkWrite.commitOperation` | Yes |
| Metadata delete | `SparkTable.deleteWhere` | **No** |
## Where the bug is
File:
`spark/vX.Y/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java`
Method: `deleteWhere(Predicate[] predicates)`
This is the method before the change (Spark 4.1, lines 264 to 291):
```java
public void deleteWhere(Predicate[] predicates) {
Expression deleteExpr = SparkV2Filters.convert(predicates);
if (deleteExpr == Expressions.alwaysFalse()) {
LOG.info("Skipping the delete operation as the condition is always
false");
return;
}
DeleteFiles deleteFiles =
table()
.newDelete()
.set("spark.app.id", spark().sparkContext().applicationId())
.deleteFromRowFilter(deleteExpr);
String writeBranch =
SparkTableUtil.determineWriteBranch(
spark(), table(), branch, CaseInsensitiveStringMap.empty());
if (writeBranch != null) {
deleteFiles.toBranch(writeBranch);
}
if (!CommitMetadata.commitProperties().isEmpty()) {
CommitMetadata.commitProperties().forEach(deleteFiles::set);
}
deleteFiles.commit();
}
```
Nothing in it reads `spark.wap.id` or the `write.wap.enabled` table property.
`deleteFiles.commit()` runs without `stageOnly()`, so
`SnapshotProducer.commit()`
takes the `setBranchSnapshot` branch and moves the branch head.
For comparison, this is what the other two paths do in their
`commitOperation`:
```java
if (wapEnabled && wapId != null) {
operation.set(SnapshotSummary.STAGED_WAP_ID_PROP, wapId);
operation.stageOnly();
}
```
## What goes wrong
With `write.wap.enabled=true` and `SET spark.wap.id = 'x'`:
1. `DELETE FROM t WHERE <whole partition>` runs through `deleteWhere`.
2. The delete is committed to `main` immediately. Readers see it before
anyone
audited or published anything.
3. No snapshot has `summary['wap.id'] = 'x'`, so
`SELECT snapshot_id FROM t.snapshots WHERE summary['wap.id'] = 'x'`
returns
zero rows.
4. `cherrypick_snapshot` has no snapshot ID to work with.
The code never looks at the table format version or at deletion vectors, so
this
also happens on v2 tables.
## The fix
The block below is added in `deleteWhere`, after the branch is resolved and
before `CommitMetadata` is applied (Spark 4.1 and 4.2):
```java
// honor spark.wap.id for metadata deletes so the change is staged rather
than applied to the
// current branch
SparkWriteConf writeConf = new SparkWriteConf(spark(), table());
if (writeConf.wapEnabled() && writeConf.wapId() != null) {
deleteFiles.set(SnapshotSummary.STAGED_WAP_ID_PROP, writeConf.wapId());
deleteFiles.stageOnly();
}
```
On Spark 3.5 and 4.0 the field names differ and `SparkWriteConf` has no
two-argument constructor, so it reads:
```java
SparkWriteConf writeConf =
new SparkWriteConf(sparkSession(), icebergTable, Collections.emptyMap());
```
What each line does:
- `SparkWriteConf` provides `wapEnabled()` (the `write.wap.enabled` table
property) and `wapId()` (the `spark.wap.id` session setting). These are
the same
two values `SparkWrite` and `SparkPositionDeltaWrite` use, so all three
paths
now share one definition of "WAP is on".
- `STAGED_WAP_ID_PROP` writes `wap.id` into the snapshot summary. That is
what
makes the snapshot findable in `.snapshots`.
- `stageOnly()` adds the snapshot to the table metadata without moving the
branch. `main` stays where it was until a cherry-pick.
Imports added to each `SparkTable.java`: `SnapshotSummary`,
`SparkWriteConf`, and
`java.util.Collections` (3.5 and 4.0 only).
When WAP is off or `spark.wap.id` is not set, the new condition is false and
the
method behaves exactly as before.
## Differences between Spark versions
| Version | Table field | Session accessor | `SparkWriteConf` call |
|---|---|---|---|
| 4.1, 4.2 | `table()` | `spark()` | `new SparkWriteConf(spark(), table())` |
| 3.5, 4.0 | `icebergTable` | `sparkSession()` | `new
SparkWriteConf(sparkSession(), icebergTable, Collections.emptyMap())` |
## Files changed
For each of Spark 3.5, 4.0, 4.1 and 4.2:
-
`spark/vX.Y/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java`
(the fix)
-
`spark/vX.Y/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestDelete.java`
(two new tests)
## Tests
Two tests are added to `TestDelete` in each Spark version. Both enable
`write.wap.enabled`, set `spark.wap.id`, run a `DELETE`, then check the
table.
- `testMetadataDeleteWithWapIdIsStaged`: partitioned table with one `hr` row
and
one `eng` row. `DELETE ... WHERE dep = 'hr'` removes a whole partition,
which
goes through `deleteWhere`. The test checks that a plain `SELECT` still
returns
both rows and that exactly one snapshot has the matching `wap.id`. Without
the
fix the `hr` row disappears from `main` and no snapshot has the `wap.id`,
so
this test should fail on the old code. I have not run it against the old
code.
- `testRowLevelDeleteWithWapIdIsStaged`: same table setup with two `hr` rows.
`DELETE ... WHERE id = 1` goes through the row-level path. This path
already
staged correctly, so the test is there to catch regressions.
Both tests are skipped when the test is parameterized with a branch, like the
existing WAP branch tests, because `spark.wap.id` only applies to a table
identifier without a branch.
I have not run the full suite yet. <!-- replace this line with what you
actually ran -->
## How to check it by hand
```sql
CREATE TABLE t (id INT, dep STRING) USING iceberg PARTITIONED BY (dep)
TBLPROPERTIES ('write.wap.enabled' = 'true');
INSERT INTO t VALUES (1, 'hr'), (5, 'eng');
SET spark.wap.id = 'wap_001';
DELETE FROM t WHERE dep = 'hr';
SELECT * FROM t; -- both
rows
SELECT snapshot_id FROM t.snapshots WHERE summary['wap.id'] = 'wap_001'; --
one row
```
Before the fix the first query returns only `(5, 'eng')` and the second
returns
nothing.
## What this does not fix
- The INSERT symptom in #18713 (staged rows showing up on `main`). I read
through `SparkWrite` and `SnapshotProducer` and found nothing that would
cause
it, and the issue has no steps to reproduce it. I am not claiming it is
fixed.
- The exact repro in the issue. It uses an unpartitioned table with all three
rows in one file, so I am not sure that `DELETE` takes the metadata path.
This
PR fixes that path, but I could not confirm it is the one the reporter hit.
- Publishing a staged DELETE. `CherryPickOperation` only handles staged
append,
dynamic overwrite and fast-forward snapshots, so a staged delete can only
be
published by fast-forward. That limit is unchanged.
--
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]