amenck opened a new issue, #16942:
URL: https://github.com/apache/iceberg/issues/16942
### Apache Iceberg version
1.11.0 (latest release)
### Query engine
Spark
### Please describe the bug 🐞
As shown in the repro below, when two concurrent `createOrReplace` calls run
on the same table, the snapshot from whichever table finishes first is dropped
from the history.
```python
"""
Minimal reproduction: createOrReplace silently drops concurrent writers'
snapshots.
Sequential createOrReplace calls correctly preserve all snapshots in the
table's
history. But when two createOrReplace calls race, the retry path in
BaseTransaction.commitReplaceTransaction refreshes `base` (the latest
committed
metadata) without rebuilding `current` (the metadata being committed). The
retried
commit overwrites the table with stale metadata, dropping any snapshots the
other
writer added.
Run:
pip install pyspark==3.5.5
python repro_create_or_replace_snapshot_loss.py
"""
import os
import shutil
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
ICEBERG_VERSION = "1.11.0"
SPARK_MAJOR = "3.5"
JAR_NAME = f"iceberg-spark-runtime-{SPARK_MAJOR}_2.12-{ICEBERG_VERSION}.jar"
JAR_PATH = os.path.join("/tmp", JAR_NAME)
WAREHOUSE = "/tmp/iceberg-repro-warehouse"
TABLE = "local.db.repro"
if not os.path.exists(JAR_PATH):
url = (
f"https://repo1.maven.org/maven2/org/apache/iceberg/"
f"iceberg-spark-runtime-{SPARK_MAJOR}_2.12/{ICEBERG_VERSION}/{JAR_NAME}"
)
print(f"Downloading {JAR_NAME} ...")
urllib.request.urlretrieve(url, JAR_PATH)
if os.path.exists(WAREHOUSE):
shutil.rmtree(WAREHOUSE)
from pyspark.sql import SparkSession
spark = (
SparkSession.builder
.master("local[4]")
.config("spark.jars", JAR_PATH)
.config("spark.sql.catalog.local",
"org.apache.iceberg.spark.SparkCatalog")
.config("spark.sql.catalog.local.type", "hadoop")
.config("spark.sql.catalog.local.warehouse", WAREHOUSE)
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
.getOrCreate()
)
spark.sql("CREATE NAMESPACE IF NOT EXISTS local.db")
# ---------------------------------------------------------------------------
# Part 1: Sequential createOrReplace preserves all snapshots (expected
behavior)
# ---------------------------------------------------------------------------
print("=" * 70)
print("Part 1: Sequential createOrReplace — snapshots should be preserved")
print("=" * 70)
df_a = spark.createDataFrame([(1, "a")], schema=["id", "label"])
df_a.writeTo(TABLE).using("iceberg").createOrReplace()
df_b = spark.createDataFrame([(2, "b")], schema=["id", "label"])
df_b.writeTo(TABLE).using("iceberg").createOrReplace()
df_c = spark.createDataFrame([(3, "c")], schema=["id", "label"])
df_c.writeTo(TABLE).using("iceberg").createOrReplace()
sequential_snapshots = spark.sql(f"SELECT snapshot_id FROM
{TABLE}.snapshots").count()
print(f" After 3 sequential createOrReplace calls: {sequential_snapshots}
snapshots")
assert sequential_snapshots == 3, f"Expected 3 snapshots, got
{sequential_snapshots}"
print(" OK — all 3 snapshots preserved")
# ---------------------------------------------------------------------------
# Part 2: Concurrent createOrReplace drops snapshots (bug)
# ---------------------------------------------------------------------------
print()
print("=" * 70)
print("Part 2: Concurrent createOrReplace — snapshots should be preserved,
but are lost")
print("=" * 70)
ITERATIONS = 10
lost_count = 0
for i in range(ITERATIONS):
pre_count = spark.sql(f"SELECT * FROM {TABLE}.snapshots").count()
barrier = Barrier(2)
def write(writer_id: int) -> None:
barrier.wait()
df = spark.createDataFrame(
[(i * 10 + writer_id, f"writer_{writer_id}")],
schema=["id", "label"],
)
df.writeTo(TABLE).using("iceberg").createOrReplace()
with ThreadPoolExecutor(max_workers=2) as pool:
futures = [pool.submit(write, writer_id=w) for w in (1, 2)]
for fut in futures:
fut.result()
post_count = spark.sql(f"SELECT * FROM {TABLE}.snapshots").count()
added = post_count - pre_count
if added < 2:
lost_count += 1
print(f" iteration {i}: LOST — 2 writers committed but only {added}
new snapshot(s) appeared")
else:
print(f" iteration {i}: ok")
print()
if lost_count > 0:
print(f"BUG CONFIRMED: {lost_count}/{ITERATIONS} iterations lost a
snapshot.")
print()
print("Root cause: BaseTransaction.commitReplaceTransaction refreshes
`base`")
print("on retry but does not rebuild `current`, so the retried commit
overwrites")
print("the table metadata with a stale version that is missing the
concurrent")
print("writer's snapshot.")
else:
print(f"No snapshot loss detected in {ITERATIONS} iterations.")
print("The race condition did not trigger — try increasing ITERATIONS.")
spark.stop()
```
I would expect `createOrReplace` to preserve the snapshot history, including
snapshots that committed concurrently.
I'm not quite certain if this is a bug, or if this behavior is intended. In
the use-case I'm working on, it most definitely manifests as a bug--we expect
snapshots to that commit concurrently to be preserved in the history. I'd argue
that this expectation makes sense, since the snapshot at least temporarily
could have been read by clients (before being clobbered by the second write).
## Environment
Iceberg: 1.11.0
Spark: 3.5.5
Catalog: Hadoop
Initially saw this happening in EMR with an AWS Glue catalog, but was able
to get the minimal repro above.
### Willingness to contribute
- [ ] I can contribute a fix for this bug independently
- [x] I would be willing to contribute a fix for this bug with guidance from
the Iceberg community
- [ ] I cannot contribute a fix for this bug at this time
--
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]