gardenia opened a new issue, #18902:
URL: https://github.com/apache/iceberg/issues/18902

   ### Apache Iceberg version
   
   1.12.0 (latest release)
   
   ### Query engine
   
   Spark
   
   ### Please describe the bug 🐞
   
   ## Describe the bug
   
   If the connection fails after a commit request has been sent to a REST 
catalog (no response, a
   connection reset, a read timeout), the Java client reports an ordinary 
failure:
   
   ```
   org.apache.iceberg.exceptions.RESTException: Error occurred while processing 
POST request
   Caused by: org.apache.hc.core5.http.NoHttpResponseException: 127.0.0.1:43911 
failed to respond
   ```
   
   The server may well have applied the commit. In that case the outcome is 
unknown, just as for a
   5xx response, but the client doesn't say so:
   
   - It is not a `CommitStateUnknownException`, so callers have no warning that 
the commit may have
     landed. An application or scheduler that retries the failed operation 
writes the data twice.
   - `RESTTableOperations.commit` never attempts the reconciliation it already 
does for 5xx
     responses, where it reloads the table and checks whether the snapshot it 
added is there.
   
   For comparison, the same commit answered with a 504 is reconciled: Spark 
reports success, with one
   copy of the data.
   
   ## Cause
   
   `HTTPClient.execute` wraps any `IOException` in a plain `RESTException`, so 
a transport failure
   never reaches `ErrorHandlers.tableCommitHandler()`, which maps 
500/502/503/504 on a commit to
   `CommitStateUnknownException`. `RESTTableOperations` already carries a note 
about this:
   
   ```java
   // TODO: ensure that the HTTP client lib passes HTTP client errors to the 
error handler
   ```
   
   `RESTSessionCatalog.commitTransaction` (multi-table transactions) has the 
same problem.
   
   ## To reproduce
   
   With Iceberg's own reference catalog (`apache/iceberg-rest-fixture`) and a 
small proxy between Spark
   and the catalog that forwards one table commit, lets the catalog apply it, 
and then closes the
   connection instead of relaying the response:
   
   ```python
   """Forwards HTTP to a REST catalog. After GET /__arm, the next table commit 
is
   forwarded (so the catalog applies it) but its response is never sent: the
   connection is closed instead, as a network failure would."""
   import http.client, http.server, re, sys, threading
   
   LISTEN, UPSTREAM = int(sys.argv[1]), int(sys.argv[2])
   armed = threading.Event()
   COMMIT = re.compile(r"/namespaces/[^/]+/tables/[^/]+$")
   SKIP = {"host", "connection", "content-length", "transfer-encoding"}
   
   class Handler(http.server.BaseHTTPRequestHandler):
       protocol_version = "HTTP/1.1"
   
       def forward(self):
           if self.path == "/__arm":
               armed.set()
               self.send_response(200); self.send_header("Content-Length", 
"0"); self.end_headers()
               return
           n = int(self.headers.get("Content-Length") or 0)
           body = self.rfile.read(n) if n else None
           upstream = http.client.HTTPConnection("127.0.0.1", UPSTREAM)
           upstream.request(self.command, self.path, body, {k: v for k, v in 
self.headers.items() if k.lower() not in SKIP})
           resp = upstream.getresponse()
           data = resp.read()
           if self.command == "POST" and COMMIT.search(self.path.split("?")[0]) 
and armed.is_set():
               armed.clear()
               print(f"proxy: catalog answered {resp.status} to POST 
{self.path}; dropping the connection instead", flush=True)
               self.close_connection = True
               return
           self.send_response(resp.status)
           for k, v in resp.getheaders():
               if k.lower() not in SKIP:
                   self.send_header(k, v)
           self.send_header("Content-Length", str(len(data)))
           self.end_headers()
           if self.command != "HEAD":
               self.wfile.write(data)
   
       do_GET = do_POST = do_DELETE = do_HEAD = do_PUT = forward
   
       def log_message(self, *args):
           pass
   
   http.server.ThreadingHTTPServer(("127.0.0.1", LISTEN), 
Handler).serve_forever()
   ```
   
   ```sh
   docker run -d --network host -e CATALOG_WAREHOUSE=file:///tmp/wh/ -v 
/tmp/wh:/tmp/wh apache/iceberg-rest-fixture
   python3 proxy.py 18291 8181
   ```
   
   Then, in Spark 3.5 with `iceberg-spark-runtime-3.5_2.12-1.12.0`, with 
catalog `via` pointing at the
   proxy (`http://127.0.0.1:18291`) and catalog `direct` at the catalog 
(`http://127.0.0.1:8181`):
   
   ```sql
   CREATE NAMESPACE via.x;
   CREATE TABLE via.x.t (id bigint) USING iceberg;
   INSERT INTO via.x.t VALUES (1);
   -- GET http://127.0.0.1:18291/__arm
   INSERT INTO via.x.t VALUES (2);   -- RESTException: Error occurred while 
processing POST request
   SELECT count(*) FROM direct.x.t WHERE id = 2;   -- 1: the commit landed
   INSERT INTO via.x.t VALUES (2);   -- the obvious retry succeeds
   SELECT count(*) FROM direct.x.t WHERE id = 2;   -- 2: duplicated
   ```
   
   The proxy logs that the catalog answered the dropped commit with a 200.
   
   Other engines and operations behave the same way. Checked with another REST 
catalog that can inject
   the same fault:
   
   | operation | engine reports | did the commit land? |
   |---|---|---|
   | Spark `INSERT`, catalog answers 504 after applying it | success 
(reconciled) | yes, one copy |
   | Spark `INSERT`, connection closed after applying it | `RESTException` | 
yes |
   | Trino `INSERT`, connection closed after applying it | `Failed to commit 
the transaction during insert: Error occurred while processing POST request` | 
yes |
   | Trino `DELETE`, connection closed after applying it | `Failed to commit 
the transaction during write: Error occurred while processing POST request` | 
yes |
   
   The same holds for Spark `rewrite_data_files`, CTAS, `CREATE OR REPLACE 
TABLE … AS SELECT` and
   `DELETE`, and Trino CTAS and `optimize`. In every case the engines kept the 
data files, so the
   tables themselves stay consistent; the risk is a retry.
   
   ## Related
   
   - #6778 reported this in 2023. It was closed as fixed by #8397 and #8599, 
which made engines clean up
     only after failures marked `CleanableFailure`. That stopped the data loss: 
the engines above all
     keep their files. But the commit is still reported as an ordinary failure, 
and still isn't
     reconciled, which is what this issue is about. The concern raised there 
was converting every
     `IOException` in `HTTPClient`; the fix proposed below applies only to 
commit requests and keeps a
     failure to connect as an ordinary failure.
   - #12792 hit the same cause in `rewrite_manifests` (`RESTException` caused by
     `SocketTimeoutException: Read timed out`), and the discussion there agreed 
an `IOException` on a
     commit should be a `CommitStateUnknownException`. No fix followed for that 
part.
   - #14320 added the reconciliation for 5xx that this would extend to 
transport failures.
   
   ## Proposed fix
   
   Report a transport failure on a commit request as 
`CommitStateUnknownException`, so that it is
   handled like a 5xx: snapshot-add-only commits are reconciled, and anything 
else is reported as
   unknown rather than failed. A `ConnectException` proves the request was 
never sent, so it can stay
   an ordinary failure. This applies to `RESTTableOperations.commit` and
   `RESTSessionCatalog.commitTransaction`.
   
   With that change, the Spark `INSERT` whose connection was closed after the 
commit reports success
   with one copy, the same as for the 504, against both the reference catalog 
and another REST catalog.
   The change applies cleanly to `main`, where the REST client tests pass.
   
   
   
   ### 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]

Reply via email to