paulcaron16k opened a new issue, #3896:
URL: https://github.com/apache/iceberg-python/issues/3896

   ### Apache Iceberg version
   
   main (development) — also reproduced on 0.12.0 and 0.11.1.
   
   ### Please describe the bug 🐞
   
   **Table commits fail intermittently against a remote-signing catalog, 
because the request signer is unregistered while other threads are signing.**
   
   Roughly **3–4% of appends** come back `403 AccessDenied` under PyIceberg's 
own concurrent write path. PyIceberg's writer is concurrent by default, so no 
unusual usage is needed to reach this.
   
   It surfaces as an opaque
   
   ```
   PermissionError: Access Denied
   ```
   
   raised out of `s3fs`, several frames from its cause, with nothing tying it 
to signing. Every unsigned request we caught on the wire was a `PUT` of a 
**manifest** (`*-m0.avro`) during commit — 5 of 5 and 3 of 3 across two runs — 
so what fails is the commit itself, not a stray read.
   
   Environment: Lakekeeper 0.13.1 vending remote signing, MinIO, 
`s3.path-style-access=true`.
   
   ---
   
   ## Root cause
   
   `pyiceberg/io/fsspec.py::_s3` installs the signer like this (lines 234–238 
on `main`; the unregister/register pair is 237–238):
   
   ```python
   fs = S3FileSystem(**s3_fs_kwargs)
   
   for event_name, event_function in register_events.items():
       fs.s3.meta.events.unregister(event_name, unique_id=1925)
       fs.s3.meta.events.register_last(event_name, event_function, 
unique_id=1925)
   ```
   
   fsspec caches filesystem instances, so `fs` — and the botocore client and 
event emitter hanging off it — is **shared by every thread**. Between the 
`unregister` and the `register_last` **no signer is installed**, and `_s3()` 
has already set `config_kwargs["signature_version"] = UNSIGNED`, so botocore 
will not sign in its place. A request signed in that window goes out with **no 
`Authorization` header at all**, and the store answers `403 AccessDenied`.
   
   `FsspecFileIO.get_fs` caches per thread and every table gets its own 
`FileIO`, so a short workload performs **over a hundred** unregister/register 
cycles against that one shared emitter, each racing whatever writes are in 
flight.
   
   The response code is the clue that took us longest to read correctly: it is 
always `AccessDenied` and never `SignatureDoesNotMatch` — which is what an 
**unsigned** request produces, not a mis-signed one.
   
   ## Evidence
   
   90 writes per run against a live stack, counting requests that reached the 
wire without an `Authorization` header (a `before-send.s3` hook):
   
   | configuration | append failures | requests sent unsigned |
   |---|---|---|
   | as shipped | 2 / 4 / 5 | 2 / 4 / 5 |
   | fsspec instance cache disabled (133 separate clients) | 0 | 0 |
   | **signer registered once, never unregistered (one shared client)** | **0** 
| **0** |
   | `PYICEBERG_MAX_WORKERS=1` | 0 | 0 |
   
   Unsigned-on-wire equals append failures **exactly**, run after run.
   
   The third row is the one that matters: the failures stop **while the client 
is still shared**, which separates this from a general concurrency problem.
   
   ## Suggested fix
   
   One line, same place — drop the `unregister`:
   
   ```python
   for event_name, event_function in register_events.items():
       fs.s3.meta.events.register_last(event_name, event_function, 
unique_id=1925)
   ```
   
   `HierarchicalEmitter._register_section` already returns early for a 
`unique_id` it holds:
   
   ```python
   if unique_id in self._unique_id_handlers:
       # We've already registered a handler using this unique_id
       # so we don't need to register it again.
       ...
       return
   ```
   
   so re-registering an equivalent signer was **already** a no-op, and the 
`unregister` only opens the window. Every signer `_s3()` builds derives from 
the same `properties`, so which instance stays installed does not matter — only 
that one always is.
   
   ## Reproduction
   
   Fork and branch: **[paulcaron16k/iceberg-python @ 
`bug/s3v4restsigner_unregisters_signer_mid_flight`](https://github.com/paulcaron16k/iceberg-python/tree/bug/s3v4restsigner_unregisters_signer_mid_flight)**
 — one commit on top of current `main`, adding 
`tests/io/test_fsspec_signer_registration.py`. Seven tests, **no credentials 
and no network**.
   
   The one that reads the library is 
`test_s3_unregisters_the_signer_on_a_client_it_shares`: two `_s3()` calls, 
fsspec hands back the same filesystem, and `unregister` is caught on **both** — 
the second while a perfectly good signer is installed. **It fails once the 
`unregister` is dropped**, which is what ties it to the fix rather than to 
incidental structure. The four existing signer tests in 
`tests/io/test_fsspec.py` pass before and after.
   
   ## A second, independent defect in the same file
   
   Reported here because it is four lines away, but it is **not** the cause of 
the above and can be fixed separately.
   
   `pyiceberg/io/fsspec.py:160` applies the signing service's headers with 
`add_header`, which **appends**:
   
   ```python
   for key, value in response_json["headers"].items():
       request.headers.add_header(key, ", ".join(value))
   ```
   
   A signing service echoes back headers it was given as well as those it 
computed, so every echoed header ends up on the request twice. Instrumenting a 
real workload, **880 of 1615** sign calls ended with a duplicate of a header 
named in `SignedHeaders` — `expect`, `x-amz-checksum-crc32`, 
`x-amz-sdk-checksum-algorithm` on `PutObject`; `if-match`, `range`, 
`x-amz-checksum-mode` on `HeadObject`/`GetObject`.
   
   In fairness: on MinIO this is **latent**. De-duplicating the headers left 
the failure count unchanged, and only the registration fix above stopped the 
403s. But SigV4 combines repeated headers comma-separated, so a stricter 
implementation is entitled to reject them, and the signing service's header set 
is authoritative regardless.
   
   It is invisible to the current tests because they assert on 
`dict(request.headers)`, and `dict()` collapses repeated keys to one value — 
any regression test needs `get_all`.
   
   Suggested fix, replace rather than append:
   
   ```python
   for key, value in response_json["headers"].items():
       if key in request.headers:
           del request.headers[key]
       request.headers.add_header(key, ", ".join(value))
   ```
   
   ## Related, not proposed here
   
   The fsspec cache key comes from `s3_fs_kwargs`, which does **not** include 
the signer's URI or endpoint — so two catalogs sharing an S3 endpoint and 
credentials but signing through different services share one filesystem. Today 
the last `_s3()` caller's signer wins; with the fix above the first one does. 
Neither is correct. The complete answer is for the signer configuration to 
participate in the filesystem's identity, but that changes which objects are 
cached, so we have left it as a separate decision rather than folding it in.
   
   ### Willingness to contribute
   
   - [x] I would be willing to contribute a fix for this bug with guidance from 
the Iceberg community
   
   The branch above already carries the tests, and the one-line fix is 
validated against a live stack. Happy to open a PR for either or both defects — 
guidance welcome on whether you want them split, and on the cache-key question.
   
   Contact: [email protected]
   


-- 
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