apache / apache/iceberg

Spark: copy-on-write `UPDATE ... WHERE <subquery>` can silently duplicate rows — race in `SparkCopyOnWriteScan.filter()` when both `Union` branches apply the runtime file filter concurrently (Spark 4.0.x / 4.1.x)

Open
#18,004 1 comment 0 reactions 0 assignees View on GitHub
bug
Dominant language
Java
Stars
9.2k
Forks
3.5k
Avg merge
2d 11h
Merged PRs (30d)
132

Description

### Apache Iceberg version

1.11.0 (latest release)

### Query engine

Spark

### Please describe the bug 🐞

**Symptom.** A copy-on-write `UPDATE t ... WHERE EXISTS ()` intermittently commits a snapshot that **adds a rewritten copy of every row in the table while deleting only the affected data file(s)**. All rows outside the affected files end up duplicated (byte-identical old + new copy). No error, no warning. In production a 71M-row / 922-file table was doubled twice on consecutive days: the commit added 577 data files and deleted 2 (`added-records` 71,428,702, `deleted-records` 154,479). The next MERGE into the table then fails with `MERGE_CARDINALITY_VIOLATION` because every id now matches two target rows.

**Minimal reproducer** (plain Spark SQL, ~35 lines). `t` is a COW table with exactly one row per data file (so a correct UPDATE touches 1 file and a corrupt one rewrites all of them); `m` holds the id to update — any subquery works, this is just the smallest shape that races (see "Sensitivity").

```python
# ICEBERG_PKG=org.apache.iceberg:iceberg-spark-runtime-4.0_2.13:1.11.0 python repro.py [files=1000] [runs=10]
# 4.1: iceberg-spark-runtime-4.1_2.13:1.11.0 + pyspark 4.1.3 3.5: iceberg-spark-runtime-3.5_2.12:1.8.1 + pyspark 3.5.5
import os, sys, tempfile
from pyspark.sql import SparkSession, functions as F

FILES = int(sys.argv[1]) if len(sys.argv) > 1 else 1000
RUNS = int(sys.argv[2]) if len(sys.argv) > 2 else 10
spark = (SparkSession.builder.master("local[4]")
.config("spark.jars.packages", os.environ.get("ICEBERG_PKG", "org.apache.iceberg:iceberg-spark-runtime-4.0_2.13:1.11.0"))
.config("spark.sql.extensions", "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
.config("spark.sql.catalog.local", "org.apache.iceberg.spark.SparkCatalog")
.config("spark.sql.catalog.local.type", "hadoop")
.config("spark.sql.catalog.local.warehouse", tempfile.mkdtemp())
.getOrCreate())
spark.sparkContext.setLogLevel("ERROR")
spark.sql("CREATE NAMESPACE IF NOT EXISTS local.db")
spark.createDataFrame([(0,)], "id bigint").createOrReplaceTempView("m") # the id(s) to update

dups = 0
for i in range(1, RUNS + 1):
spark.sql("DROP TABLE IF EXISTS local.db.t")
spark.sql("CREATE TABLE local.db.t (id BIGINT, v STRING) USING iceberg TBLPROPERTIES ('write.update.mode'='copy-on-write')")
spark.range(FILES).withColumn("v", F.lit("old")).repartitionByRange(FILES, "id").writeTo("local.db.t").append() # 1 row per file
spark.sql("UPDATE local.db.t t SET v = 'new' WHERE EXISTS (SELECT 1 FROM m WHERE m.id = t.id)")
rows = spark.table("local.db.t").count()
c = spark.sql("SELECT summary['added-data-files'] a, summary['deleted-data-files'] d, summary['added-records'] r "
"FROM local.db.t.snapshots ORDER BY committed_at DESC LIMIT 1").first()
dups += rows != FILES
print(f"run {i:2d}: rows={rows} {'DUPLICATED' if rows != FILES else 'ok'} commit: added_files={c.a} deleted_files={c.d} added_rows={c.r}")
print(f"{dups}/{RUNS} runs produced duplicate rows")
```

Output (all Spark configs default, AQE on):
```
run 1: rows=1999 DUPLICATED commit: added_files=1 deleted_files=1 added_rows=1000
run 2: rows=1000 ok commit: added_files=1 deleted_files=1 added_rows=1
...
```
| stack | duplicated runs |
|---|---|
| Spark 4.0.4 + Iceberg 1.11.0 | 3/10 (9/23 across batches) |
| Spark 4.1.3 + Iceberg 1.11.0 | 4/10 (12/30 across batches) |
| Spark 3.5.5 + Iceberg 1.8.1 | 0/10 (0/20) |

A corrupt run writes `FILES` rows (`added_rows=1000`) while deleting only the 1 affected file; a correct run writes 1.

**Mechanism** (from source at tag `apache-iceberg-1.11.0`, `spark/v4.0`, plus Spark 4.0.4 sources, event logs and stage metrics of the production runs):

1. Spark rewrites an UPDATE whose WHERE contains a subquery as `ReplaceData(Union(Filter(cond, S), Filter(NOT cond, S)))` (`RewriteUpdateTable.buildReplaceDataWithUnionPlan`): an "updated rows" branch and a "carry-over" branch (unchanged rows of the affected files) that read **the same** `SparkCopyOnWriteScan` `S` (`GroupBasedRowLevelOperationScanPlanning` replaces all occurrences with one scan relation; `RowLevelOperationRuntimeGroupFiltering` attaches the runtime file filter to both occurrences via `r.scan eq scan`). Both `BatchScanExec`s call `S.filter(...)` then `S.toBatch().planInputPartitions()` (`BatchScanExec.filteredPartitions`, lines 63–117 in 4.0.4).
2. `SparkCopyOnWriteScan.filter()` (`SparkCopyOnWriteScan.java` 124–138) is not synchronized and is not atomic: it **publishes `this.filteredLocations = fileLocations` (line 125) first**, then streams `tasks()` to build `filteredTasks`, logs, and only then calls `resetTasks(filteredTasks)` (line 138), which is what invalidates the memoized task groups (`SparkPartitioningAwareScan.resetTasks`, 246–249: `taskGroups = null; tasks = filteredTasks`). `tasks()`/`taskGroups()` are individually `synchronized` (179, 204) but the check-then-act spans the whole method. The guard on line 124 (`filteredLocations == null || fileLocations.size() < filteredLocations.size()`) exists precisely because "Spark may call this multiple times for UPDATEs with subqueries ... the same scan on both sides" — it anticipates multiple calls, but not concurrent ones.
3. `taskGroups()` is memoized **at planning time**, before any runtime filter runs (`outputPartitioning()` calls `taskGroups().size()`, lines 107–122; Spark's `EnsureRequirements` invokes it), and `SparkBatch` captures the task-group list at construction.
4. When AQE prepares the two branch stages concurrently, the second caller sees `filteredLocations` already set → takes the no-op path → `toBatch()` captures the still-memoized **pre-narrowing** task groups → that branch reads **all** files. Exactly one `"N of M task(s) ... matched runtime file filter"` INFO line is logged per run (the winner). If the losing branch is the carry-over branch, every row is rewritten while the commit deletes only `configuredScan.tasks()` = the narrowed files → duplicates. If it is the updated-rows branch the result is correct but the whole table is scanned. Hence ~50% corruption per run once the calls overlap.

Production evidence (Spark 4.0.4, 922 files): both branches carry `RuntimeFilters: [dynamicpruningexpression(_file IN subquery#...)]` in all 9 AQE plan versions; one narrowing log (`2 of 922 task(s) matched ... 2 location(s)`); stage metrics show one branch 2 tasks / 154,479 rows and the other 1,086 tasks / 71,428,702 rows in **both** a corrupt and a clean run — the corrupt one had the full scan feeding the carry-over branch (SortMergeJoin output 71,428,700 → write of 577 files), the clean one had it feeding the updated-rows branch (2 rows out). The two branch jobs were submitted 4 ms apart.

**Sensitivity (controls, Spark 4.0.4).** The hazard is constant; corruption requires AQE to prepare the two branch stages concurrently so the two `filter()` calls overlap. In the minimal repro that happens for a local-relation subquery side (branch jobs submitted 1–2 ms apart → 3/10); with `m` as a physical table, a temp view over a table scan, a cached read, or a derived aggregate, the branches were prepared sequentially (~25–65 ms apart, unchanged at 5,000 files) → 0/N. Production reached the concurrent case with a derived temp view over an already-materialized cached DataFrame + a table scan (4 ms apart). Spark 3.5.5 never overlapped in our runs, although the Iceberg code is identical.

**Workarounds verified with the same harness:** expressing the operation as `MERGE INTO local.db.t t USING m ON t.id = m.id WHEN MATCHED THEN UPDATE SET v = 'new'` (single read of the target, one `filter()` call) → 0/20 on Spark 4.0.4 and 0/20 on 4.1.3; `spark.sql.adaptive.enabled=false` for the statement → 0/10. Merge-on-read avoids the carry-over rewrite entirely. Making `m` a physical table only changes the timing.

**Prior art searched (none found):** issues/PRs mentioning `SparkCopyOnWriteScan` (#16295/#16303 migration to `SupportsRuntimeV2Filtering`, #6209, #9215 — none about concurrency), `resetTasks`, `filteredLocations`, duplicate-row issues (#14667, #7005 are different mechanisms), PR #2206 (original UPDATE-with-subquery design), dev list, Spark JIRA `SupportsRuntimeV2Filtering` (SPARK-45866 is context only).

**Suggested fix.**
- Make `filter()` atomic with respect to the task groups: synchronize `filter()`/`resetTasks()` on the scan (the getters already are), or compute `filteredTasks` and call `resetTasks()` **before** publishing `filteredLocations` (and make it `volatile`). Either closes the window.
- Defense in depth: in the copy-on-write commit (`SparkWrite.CopyOnWriteOperation`), fail if the rewritten data cannot have come only from `configuredScan.tasks()` (e.g. added records exceed the rows in the replaced files for an UPDATE/DELETE), instead of committing duplicates silently.
- Spark side (I can cross-file): `SupportsRuntimeV2Filtering.filter()` may be invoked concurrently on one shared scan from the two branches of the UPDATE rewrite; the API does not document thread-safety expectations, and 4.x prepares the branches concurrently where 3.5 did not.

### Willingness to contribute

- [ ] I can contribute a fix for this bug independently
- [ ] I would be willing to contribute a fix for this bug with guidance from the Iceberg community
- [x] I cannot contribute a fix for this bug at this time

Contributor guide

Open the contributing guide

Research direction

Start by running the minimal Python reproducer across the listed Spark and Iceberg versions. Read SparkCopyOnWriteScan.filter(), resetTasks(), and taskGroups(), then inspect RewriteUpdateTable.buildReplaceDataWithUnionPlan and the shared scan behavior. Done means concurrent runtime filtering no longer causes UPDATE ... WHERE EXISTS to duplicate unaffected rows.

Written by the indexing model from the issue text.

Assessment

Tech stack
java, spark
Domain
databases, distributed-systems
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.