msabiransari opened a new issue, #18743:
URL: https://github.com/apache/iceberg/issues/18743
### Apache Iceberg version
1.12.0 (latest release)
### Query engine
Other
### Please describe the bug 🐞
**Summary.** With `JdbcCatalog`, if the database connection drops *after*
PostgreSQL has applied the commit's `UPDATE ... SET metadata_location = ? WHERE
metadata_location = ?` but before the client reads the response,
`JdbcClientPool` (via `ClientPoolImpl.run`) reconnects and **replays the same
UPDATE**. The replay matches 0 rows because the compare-and-swap predicate no
longer holds, so `JdbcTableOperations.updateTable` throws
`CommitFailedException` for a commit that is already durable.
`CommitFailedException` is a `CleanableFailure`, so:
* with `commit.retry.num-retries=0`: `SnapshotProducer`/`BaseTransaction`
run `cleanAll()` and **delete the manifest list and manifests that the table's
current metadata now references**. The table is left unreadable
(`NotFoundException` on scan).
* with the default retries (4), single writer: the `Tasks` retry refreshes,
reuses the staged snapshot id, takes the rollback branch in
`SnapshotProducer.commit()` and returns normally, so it heals by accident.
* with the default retries and **a concurrent writer committing between the
lost acknowledgement and the retry**: both commits return normally, but `main`
is rewound to writer 1's snapshot and the other writer's committed data file is
no longer visible. Nothing throws.
Verified on Iceberg 1.12.0 against PostgreSQL 17.11 (JDK 21, PgJDBC 42.7.14,
`InMemoryFileIO`), five runs, deterministic. The relevant code is unchanged on
`main` as of 2742348d.
**Mechanism (1.12.0 sources).**
1. `ClientPoolImpl.run(action, retry)` re-runs the whole action after
`reconnect()` when `isConnectionException(exc)`
(`core/.../ClientPoolImpl.java:69-95`). `JdbcClientPool` passes `retryByDefault
= true` and treats SQLSTATE `08000/08003/08006/08007/40001` as connection
exceptions (`JdbcClientPool.java:50-51, 110-114`). A dropped PostgreSQL socket
surfaces as `08006`.
2. `JdbcUtil.update(...)` runs the CAS `UPDATE` inside
`connections.run(...)` with that default retry (`JdbcUtil.java:533-561`). The
statement is not idempotent: once applied, a replay updates 0 rows.
3. `JdbcTableOperations.updateTable` turns anything other than 1 row into
`CommitFailedException` (`JdbcTableOperations.java:146-163`) without checking
whether `newMetadataLocation` is already the table's current (or a historical)
metadata location.
4. `CommitFailedException implements CleanableFailure`;
`TableOperations.requireStrictCleanup()` defaults to true and JDBC does not
override it. `SnapshotProducer.commit()` then calls `cleanAll()`
(`SnapshotProducer.java:536-540, 610-616`), and `BaseTransaction` does the same
through `cleanUp()` / `cleanAllUpdates()` (`BaseTransaction.java:380-385,
415-432`). Both delete through `deleteFile` -> the table `FileIO`.
Other catalogs already reconcile this case: `HiveTableOperations` (`:375`),
`GlueTableOperations` (`:174`), `DynamoDbTableOperations` and
`BigQueryTableOperations` call `checkCommitStatus`/`checkCommitStatusStrict`
from `BaseMetastoreOperations` when the outcome is uncertain. The JDBC table
and view operations do not.
**Reproduction.** A small PostgreSQL-protocol-aware TCP proxy sits between
the catalog and the server. Once armed, it forwards the catalog's `UPDATE
iceberg_tables SET metadata_location ...`, withholds every backend message up
to and including ReadyForQuery, and closes both sockets. PostgreSQL has
committed the row; the client only sees a dropped connection. Full sources
(about 350 lines, Gradle project, no object store needed) are in the collapsed
section at the end. Run:
```
docker run -d --name iceberg-repro-pg -e POSTGRES_PASSWORD=pw -p
127.0.0.1:55432:5432 postgres:17-alpine
gradle installDist
build/install/iceberg-repro/bin/iceberg-repro 55432 2>&1 | grep '^RESULT'
```
Output on stock 1.12.0 (ids vary per run):
```
RESULT plain_retries_default: responseDropped=true | commit returned
normally | table: snapshots=1, current manifest list exists=true, scan OK, data
files=1 | deleteFile calls:
[snap-429960218544967894-2-b4186a2a-1e7d-40a5-9931-b097c60e6bc4.avro]
RESULT plain_retries_0: responseDropped=true | threw CommitFailedException:
Failed to update table ns.plain_retries_0 from catalog c | table: snapshots=1,
current manifest list exists=false, scan FAILED NotFoundException: No in-memory
file found for location:
mem://wh/ns/plain_retries_0/metadata/snap-1137813634021871497-1-a9635f87-9362-4b3f-9683-f1e347777bfc.avro
| deleteFile calls:
[snap-1137813634021871497-1-a9635f87-9362-4b3f-9683-f1e347777bfc.avro,
a9635f87-9362-4b3f-9683-f1e347777bfc-m0.avro]
RESULT txn_retries_default: responseDropped=true | commit returned normally
| table: snapshots=1, current manifest list exists=true, scan OK, data files=1
| deleteFile calls:
[snap-8670333551669072922-2-8668fa5b-da14-45b6-95d5-43bfaf649abc.avro]
RESULT txn_retries_0: responseDropped=true | threw CommitFailedException:
Failed to update table ns.txn_retries_0 from catalog c | table: snapshots=1,
current manifest list exists=false, scan FAILED NotFoundException: No in-memory
file found for location:
mem://wh/ns/txn_retries_0/metadata/snap-9194260192876454534-1-03961be1-29bc-4877-9a09-22de841777f3.avro
| deleteFile calls:
[snap-9194260192876454534-1-03961be1-29bc-4877-9a09-22de841777f3.avro,
03961be1-29bc-4877-9a09-22de841777f3-m0.avro]
RESULT concurrent_writer_default_retries: responseDropped=true | writer 1
commit returned normally | after both writers returned: snapshots in
metadata=2, data files visible on main=1 (expected 2: writer 1 and the
concurrent writer)
```
The stack of the false failure (retries=0, transaction case):
```
org.apache.iceberg.exceptions.CommitFailedException: Failed to update table
ns.txn_retries_0 from catalog c
at
org.apache.iceberg.jdbc.JdbcTableOperations.updateTable(JdbcTableOperations.java:160)
at
org.apache.iceberg.jdbc.JdbcTableOperations.doCommit(JdbcTableOperations.java:115)
at
org.apache.iceberg.BaseMetastoreTableOperations.commit(BaseMetastoreTableOperations.java:127)
at
org.apache.iceberg.BaseTransaction.lambda$commitSimpleTransaction$1(BaseTransaction.java:371)
at
org.apache.iceberg.util.Tasks$Builder.runTaskWithRetry(Tasks.java:416)
...
```
**Expected.** A commit that the database applied is reported as succeeded,
or at worst as `CommitStateUnknownException` (not cleanable). Files referenced
by the table's current metadata are never deleted, and a concurrent writer's
committed snapshot is never rewound.
**Relation to existing work.** #18402 / #18407 describe the same cleanup
consequence (an applied commit treated as a cleanable failure, then
`cleanAll()`), triggered on the REST/Flink side; the comment on #18407 about a
concurrent-writer rewind is exactly the fifth scenario above, reproduced here
on the JDBC catalog. #18407's guard would stop the file deletion but not the
false `CommitFailedException` or the rewind, so the two are complementary.
#17828 / #17829 (MySQL `SQLRecoverableException`) would widen the same replay
surface.
**Proposed fix (prototype verified).** In `JdbcTableOperations.updateTable`,
when the UPDATE affects 0 rows, call the existing
`checkCommitStatusStrict(newMetadataLocation, metadata)` before throwing. The
new metadata location is unique per attempt, so finding it as the current
location or in the metadata log proves the UPDATE landed:
* `SUCCESS`: return as committed (log at INFO);
* `UNKNOWN`: throw `CommitStateUnknownException` (not cleanable);
* `FAILURE`: `CommitFailedException` as today.
`JdbcViewOperations.updateView` (`:159-168`) has the same shape and needs
the same change. With the patch (diff in the collapsed section), the same five
scenarios give:
```
RESULT plain_retries_default: ... commit returned normally | ... scan OK,
data files=1 | deleteFile calls: []
RESULT plain_retries_0: ... commit returned normally | ... scan OK, data
files=1 | deleteFile calls: []
RESULT txn_retries_default: ... commit returned normally | ... scan OK, data
files=1 | deleteFile calls: []
RESULT txn_retries_0: ... commit returned normally | ... scan OK, data
files=1 | deleteFile calls: []
RESULT concurrent_writer_default_retries: ... writer 1 commit returned
normally | ... snapshots in metadata=2, data files visible on main=2 (expected
2: writer 1 and the concurrent writer)
```
A variant that only compares the table's *current* metadata location fixes
the first four scenarios but not the concurrent-writer one, so the
history-aware check matters. Cost: one extra refresh on the failure path only.
I am happy to open the PR (table + view operations, plus a `TestJdbcCatalog`
case that executes the UPDATE and then throws an `SQLException` with state
`08006` once, with and without retries, and with a second writer in between),
and to coordinate with #18407.
<details>
<summary>Reproduction sources (Gradle project, JDK 21)</summary>
`settings.gradle.kts`
```kotlin
rootProject.name = "iceberg-repro"
```
`build.gradle.kts`
```kotlin
plugins { java; application }
repositories { mavenCentral() }
java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
dependencies {
implementation("org.apache.iceberg:iceberg-core:1.12.0")
implementation("org.apache.iceberg:iceberg-api:1.12.0")
runtimeOnly("org.postgresql:postgresql:42.7.14")
runtimeOnly("org.slf4j:slf4j-simple:2.0.17")
}
application { mainClass = "repro.LostAck" }
```
`src/main/java/repro/LostAck.java`
```java
package repro;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.iceberg.AppendFiles;
import org.apache.iceberg.DataFile;
import org.apache.iceberg.DataFiles;
import org.apache.iceberg.FileFormat;
import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.Schema;
import org.apache.iceberg.Snapshot;
import org.apache.iceberg.Table;
import org.apache.iceberg.Transaction;
import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.jdbc.JdbcCatalog;
import org.apache.iceberg.types.Types;
public final class LostAck {
static Map<String, String> props(int port) {
Map<String, String> p = new HashMap<>();
p.put("uri", "jdbc:postgresql://127.0.0.1:" + port +
"/postgres?sslmode=disable&gssEncMode=disable");
p.put("jdbc.user", "postgres");
p.put("jdbc.password", "pw");
p.put("jdbc.schema-version", "V1");
p.put("jdbc.init-catalog-tables", "true");
p.put("warehouse", "mem://wh");
p.put("io-impl", RecordingFileIO.class.getName());
return p;
}
public static void main(String[] args) throws Exception {
int pgPort = Integer.parseInt(args[0]);
try (PgProxy proxy = new PgProxy(pgPort)) {
JdbcCatalog catalog = new JdbcCatalog();
catalog.initialize("c", props(proxy.port()));
catalog.createNamespace(org.apache.iceberg.catalog.Namespace.of("ns"));
Schema schema = new Schema(Types.NestedField.required(1, "id",
Types.LongType.get()));
int i = 0;
for (boolean txn : new boolean[] {false, true}) {
for (String retries : new String[] {"default(4)", "0"}) {
String name = (txn ? "txn" : "plain") + "_retries_" +
(retries.equals("0") ? "0" : "default");
run(catalog, proxy, schema, name, txn,
retries.equals("0"), i++);
}
}
concurrentWriter(catalog, proxy, schema);
catalog.close();
}
}
static void run(JdbcCatalog catalog, PgProxy proxy, Schema schema,
String name, boolean txn, boolean noRetry, int n)
throws Exception {
TableIdentifier id = TableIdentifier.of("ns", name);
Map<String, String> tp = new HashMap<>();
if (noRetry) {
tp.put("commit.retry.num-retries", "0");
}
Table t = catalog.createTable(id, schema,
PartitionSpec.unpartitioned(), tp);
DataFile df = DataFiles.builder(PartitionSpec.unpartitioned())
.withPath("mem://wh/data/" + name + "-1.parquet")
.withFormat(FileFormat.PARQUET)
.withFileSizeInBytes(10)
.withRecordCount(5)
.build();
RecordingFileIO.DELETED.clear();
proxy.drops = 0;
proxy.armed.set(true);
String outcome;
try {
if (txn) {
Transaction tx = t.newTransaction();
tx.newAppend().appendFile(df).commit();
tx.commitTransaction();
} else {
AppendFiles a = t.newAppend().appendFile(df);
a.commit();
}
outcome = "commit returned normally";
} catch (Exception e) {
outcome = "threw " + e.getClass().getSimpleName() + ": " +
firstLine(e);
}
boolean dropped = proxy.drops > 0;
proxy.armed.set(false);
// Inspect with a fresh catalog connected straight to PostgreSQL (no
proxy).
JdbcCatalog fresh = new JdbcCatalog();
fresh.initialize("c", props(proxy.upstream()));
String state;
try {
Table r = fresh.loadTable(id);
Snapshot cur = r.currentSnapshot();
if (cur == null) {
state = "no current snapshot";
} else {
boolean listExists;
try {
listExists =
r.io().newInputFile(cur.manifestListLocation()).exists();
} catch (RuntimeException e) {
listExists = false; // InMemoryFileIO throws
NotFoundException for a missing object
}
String files;
try {
int c = 0;
try (var tasks = r.newScan().planFiles()) {
for (var ignored : tasks) {
c++;
}
}
files = "scan OK, data files=" + c;
} catch (Exception e) {
files = "scan FAILED " + e.getClass().getSimpleName() +
": " + firstLine(e);
}
state = "snapshots=" + countSnapshots(r) + ", current
manifest list exists=" + listExists + ", " + files;
}
} catch (Exception e) {
state = "load FAILED " + e;
}
fresh.close();
System.out.println("RESULT " + name + ": responseDropped=" + dropped
+ " | " + outcome + " | table: " + state
+ " | deleteFile calls: " + RecordingFileIO.DELETED);
}
/** Lost acknowledgement, then another writer commits on top before the
first writer's retry (default retries). */
static void concurrentWriter(JdbcCatalog catalog, PgProxy proxy, Schema
schema) throws Exception {
TableIdentifier id = TableIdentifier.of("ns",
"concurrent_default_retries");
Table t = catalog.createTable(id, schema);
JdbcCatalog other = new JdbcCatalog();
other.initialize("c", props(proxy.upstream()));
proxy.onDrop = () -> {
Table o = other.loadTable(id);
o.newFastAppend().appendFile(file("concurrent-B")).commit();
};
proxy.armed.set(true);
String outcome;
try {
t.newAppend().appendFile(file("writer1-A")).commit();
outcome = "commit returned normally";
} catch (Exception e) {
outcome = "threw " + e.getClass().getSimpleName();
}
proxy.armed.set(false);
proxy.onDrop = null;
Table r = other.loadTable(id);
int files = 0;
try (var tasks = r.newScan().planFiles()) {
for (var task : tasks) {
System.out.println(" data file in current table state: " +
task.file().location());
files++;
}
}
other.close();
System.out.println("RESULT concurrent_writer_default_retries:
responseDropped=" + (proxy.drops > 0) + " | writer 1 " + outcome
+ " | after both writers returned: snapshots in metadata=" +
countSnapshots(r) + ", data files visible on main=" + files
+ " (expected 2: writer 1 and the concurrent writer)");
}
static DataFile file(String name) {
return DataFiles.builder(PartitionSpec.unpartitioned())
.withPath("mem://wh/data/" + name + ".parquet")
.withFormat(FileFormat.PARQUET)
.withFileSizeInBytes(10)
.withRecordCount(5)
.build();
}
static int countSnapshots(Table t) {
int c = 0;
for (Snapshot ignored : t.snapshots()) {
c++;
}
return c;
}
static String firstLine(Throwable e) {
String m = String.valueOf(e.getMessage());
int nl = m.indexOf('\n');
return nl > 0 ? m.substring(0, nl) : m;
}
}
```
`src/main/java/repro/PgProxy.java`
```java
package repro;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* TCP proxy that forwards PostgreSQL traffic and, once armed, lets the
first catalog
* "UPDATE iceberg_tables SET metadata_location" reach the server, then
swallows the whole response and closes
* both sockets. PostgreSQL has committed the row; the client only sees a
dropped connection.
*/
final class PgProxy implements AutoCloseable {
private static final byte[] CAS =
"UPDATE iceberg_tables SET
metadata_location".getBytes(StandardCharsets.US_ASCII);
private final ServerSocket server;
private final int upstreamPort;
final AtomicBoolean armed = new AtomicBoolean();
volatile int drops;
/** Runs after PostgreSQL committed the UPDATE and before the client
learns anything. */
volatile Runnable onDrop;
PgProxy(int upstreamPort) throws IOException {
this.upstreamPort = upstreamPort;
this.server = new ServerSocket(0, 50,
InetAddress.getLoopbackAddress());
Thread t = new Thread(this::accept, "proxy-accept");
t.setDaemon(true);
t.start();
}
int port() {
return server.getLocalPort();
}
int upstream() {
return upstreamPort;
}
private void accept() {
while (!server.isClosed()) {
try {
Socket c = server.accept();
Socket u = new Socket(InetAddress.getLoopbackAddress(),
upstreamPort);
boolean[] casSent = {false};
daemon(() -> c2s(c, u, casSent));
daemon(() -> s2c(c, u, casSent));
} catch (IOException e) {
return;
}
}
}
private static void daemon(Runnable r) {
Thread t = new Thread(r);
t.setDaemon(true);
t.start();
}
private void c2s(Socket c, Socket u, boolean[] casSent) {
try (InputStream in = c.getInputStream(); OutputStream out =
u.getOutputStream()) {
byte[] buf = new byte[65536];
int n;
while ((n = in.read(buf)) > 0) {
if (armed.get() && !casSent[0] && indexOf(buf, n) >= 0) {
casSent[0] = true;
}
out.write(buf, 0, n);
out.flush();
}
} catch (IOException ignored) {
// closed
} finally {
close(c, u);
}
}
private void s2c(Socket c, Socket u, boolean[] casSent) {
try (DataInputStream in = new DataInputStream(u.getInputStream());
OutputStream out = c.getOutputStream()) {
while (true) {
int type = in.read();
if (type < 0) {
return;
}
int len = in.readInt();
byte[] body = new byte[len - 4];
in.readFully(body);
if (casSent[0] && armed.get()) {
if (type == 'Z') { // ReadyForQuery: the statement has
been committed by the server
armed.set(false);
drops++;
Runnable r = onDrop;
if (r != null) {
r.run();
}
return;
}
continue; // withhold ParseComplete, BindComplete,
CommandComplete "UPDATE 1", ...
}
out.write(type);
out.write(new byte[] {(byte) (len >>> 24), (byte) (len >>>
16), (byte) (len >>> 8), (byte) len});
out.write(body);
out.flush();
}
} catch (IOException ignored) {
// closed
} finally {
close(c, u);
}
}
private static int indexOf(byte[] hay, int n) {
outer:
for (int i = 0; i + CAS.length <= n; i++) {
for (int j = 0; j < CAS.length; j++) {
if (hay[i + j] != CAS[j]) {
continue outer;
}
}
return i;
}
return -1;
}
private static void close(Socket a, Socket b) {
try { a.close(); } catch (IOException ignored) { }
try { b.close(); } catch (IOException ignored) { }
}
@Override
public void close() throws IOException {
server.close();
}
}
```
`src/main/java/repro/RecordingFileIO.java`
```java
package repro;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.apache.iceberg.inmemory.InMemoryFileIO;
/** InMemoryFileIO that records every deleteFile call. */
public class RecordingFileIO extends InMemoryFileIO {
static final List<String> DELETED = new CopyOnWriteArrayList<>();
@Override
public void deleteFile(String location) {
DELETED.add(location.substring(location.lastIndexOf('/') + 1));
super.deleteFile(location);
}
}
```
</details>
<details>
<summary>Prototype patch against the 1.12.0 sources</summary>
```diff
--- a/core/src/main/java/org/apache/iceberg/jdbc/JdbcTableOperations.java
+++ b/core/src/main/java/org/apache/iceberg/jdbc/JdbcTableOperations.java
@@ -32,6 +32,7 @@
import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.exceptions.AlreadyExistsException;
import org.apache.iceberg.exceptions.CommitFailedException;
+import org.apache.iceberg.exceptions.CommitStateUnknownException;
import org.apache.iceberg.exceptions.NoSuchNamespaceException;
import org.apache.iceberg.exceptions.NoSuchTableException;
import org.apache.iceberg.io.FileIO;
@@ -112,7 +113,7 @@
String oldMetadataLocation = base.metadataFileLocation();
// Start atomic update
LOG.debug("Committing existing table: {}", tableName());
- updateTable(newMetadataLocation, oldMetadataLocation);
+ updateTable(newMetadataLocation, oldMetadataLocation, metadata);
} else {
// table not exists create it
LOG.debug("Committing new table: {}", tableName());
@@ -143,7 +144,8 @@
}
}
- private void updateTable(String newMetadataLocation, String
oldMetadataLocation)
+ private void updateTable(
+ String newMetadataLocation, String oldMetadataLocation, TableMetadata
metadata)
throws SQLException, InterruptedException {
int updatedRecords =
JdbcUtil.updateTable(
@@ -156,9 +158,23 @@
if (updatedRecords == 1) {
LOG.debug("Successfully committed to existing table: {}",
tableIdentifier);
- } else {
- throw new CommitFailedException(
- "Failed to update table %s from catalog %s", tableIdentifier,
catalogName);
+ return;
+ }
+
+ // The UPDATE is a compare-and-swap. Zero rows means either another
writer won, or the connection
+ // dropped after the database applied this UPDATE and the pool replayed
it. The new metadata
+ // location is unique to this attempt, so finding it in the table's
history proves it was applied.
+ switch (checkCommitStatusStrict(newMetadataLocation, metadata)) {
+ case SUCCESS:
+ LOG.info("Commit to {} was already applied, treating it as
successful", tableIdentifier);
+ return;
+ case UNKNOWN:
+ throw new CommitStateUnknownException(
+ new CommitFailedException(
+ "Failed to update table %s from catalog %s",
tableIdentifier, catalogName));
+ default:
+ throw new CommitFailedException(
+ "Failed to update table %s from catalog %s", tableIdentifier,
catalogName);
}
}
```
</details>
### Willingness to contribute
- [x] 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
- [ ] 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]