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

   ### Apache Iceberg version
   
   1.10.1 (`apache/iceberg-rest-fixture:1.10.1`). The same code is on `main` 
(`6951360`).
   
   ### Query engine
   
   None (REST API directly)
   
   ### Please describe the bug 🐞
   
   If two requests to `POST /v1/{prefix}/transactions/commit` change the same 
table at the same time, both get a 200, but only one of the two changes ends up 
in the table. The other one is just gone.
   
   It's worse with guarded changes. If both transactions add a snapshot and 
require `main` to still be empty (`assert-ref-snapshot-id` with `null`), at 
most one should commit. Both still get a 200, and then one of the snapshots is 
missing.
   
   The single-table endpoint handles the same requests correctly, and so do the 
same transactions sent one after the other.
   
   | Catalog | Change | Two transactions at once | Single-table endpoint | 
Transactions one after the other |
   | --- | --- | --- | --- | --- |
   | REST fixture 1.10.1 | set a property | both 200, one change missing in 10 
of 10 rounds (4 runs) | nothing missing | nothing missing |
   | Apache Hive 4.2.0 REST catalog | set a property | 10 of 10 | nothing 
missing | nothing missing |
   | Apache Hive 4.2.1 REST catalog | set a property | 10 of 10 | nothing 
missing | nothing missing |
   | Apache Hive 4.2.1 REST catalog | add a snapshot, guarded on empty `main` | 
both 200, one snapshot missing in 10 of 10 | 200/409 | 200/409 |
   
   "Missing" means the change is absent from two separate loads of the table 
after both responses came back.
   
   **Cause**
   
   The fixture's `commitTransaction` builds a `Transaction` for each table, 
applies the request to the transaction's table operations with 
`CatalogHandlers.commit`, and then calls `commitTransaction()`:
   
   
https://github.com/apache/iceberg/blob/6951360ec8a01644d3c24824ab635e06a82f8a7c/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java#L564-L587
   
   `CatalogHandlers.commit` checks the requirements and writes the new metadata 
straight into the transaction's staged state. It never adds a pending update to 
the transaction:
   
   
https://github.com/apache/iceberg/blob/6951360ec8a01644d3c24824ab635e06a82f8a7c/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java#L620-L660
   
   When the second request reaches the table, the first one has already changed 
it. The write fails with `CommitFailedException`, and `commitSimpleTransaction` 
retries. The retry calls `applyUpdates`, which refreshes the transaction to the 
current table and replays its pending updates. There are none, so the change is 
dropped:
   
   
https://github.com/apache/iceberg/blob/6951360ec8a01644d3c24824ab635e06a82f8a7c/core/src/main/java/org/apache/iceberg/BaseTransaction.java#L439-L455
   
   `BaseMetastoreTableOperations` then sees nothing to write, logs "Nothing to 
commit." and returns, and the request gets a 200. The requirements were checked 
once, against the old state, and are never checked again. That's why both 
guarded snapshots go through.
   
   Hive's log shows this sequence for every lost change:
   
   ```
   CommitFailedException: Cannot commit: Base metadata location '...' is not 
same as the current table metadata location '...'
        at 
org.apache.iceberg.hive.HiveTableOperations.doCommit(HiveTableOperations.java:172)
        at 
org.apache.iceberg.BaseMetastoreTableOperations.commit(BaseMetastoreTableOperations.java:126)
        at 
org.apache.iceberg.BaseTransaction.commitSimpleTransaction(BaseTransaction.java:424)
        at 
org.apache.iceberg.BaseTransaction.commitTransaction(BaseTransaction.java:319)
        at 
org.apache.iceberg.rest.HMSCatalogAdapter.commitTransaction(HMSCatalogAdapter.java:406)
   INFO BaseMetastoreTableOperations: Nothing to commit.
   ```
   
   **Why it matters beyond the test fixture**
   
   `RESTCatalogAdapter` is test code, but Apache Hive's REST catalog ships a 
copy of the same `commitTransaction` in `HMSCatalogAdapter`, in Hive 4.1.0, 
4.2.0 and 4.2.1. The results above for Hive are from the official 
`apache/hive:standalone-metastore-4.2.0` and `4.2.1` images. Other REST 
catalogs built on the adapter appear to use the same pattern, but I haven't 
tested them.
   
   Related, but not the same thing: #18552 (requirements on tables that a 
transaction doesn't update aren't checked) and #16232 (a REPLACE transaction 
overwrites concurrent changes).
   
   I haven't tried a fix. Two directions that seem possible: apply each table 
change as a pending update so that a retry replays it, re-checking the 
requirements, or fail the transaction with a conflict instead of retrying when 
the table has moved.
   
   **To reproduce**
   
   Against `docker run -p 8181:8181 apache/iceberg-rest-fixture:1.10.1`:
   
   ```sh
   ./repro.sh http://localhost:8181 10
   ```
   
   ```
   round 1: 200 200, lost: acknowledged but missing from two loads: b
   ...
   round 10: 200 200, lost: acknowledged but missing from two loads: b
   10 of 10 rounds lost an acknowledged change; 0 inconclusive
   ```
   
   `./repro.sh http://localhost:8181 10 table` sends the same changes to the 
single-table endpoint, and `sequential` sends the transactions one after the 
other. Both report nothing lost. To try the snapshot case, set `MANIFEST_1` and 
`MANIFEST_2` to two manifest lists the server can read.
   
   <details><summary><code>repro.sh</code> (bash, curl, jq)</summary>
   
   ```sh
   #!/usr/bin/env bash
   # Two concurrent requests to POST /v1/{prefix}/transactions/commit, each
   # changing the same fresh table, repeated. A catalog may reject one with 409,
   # but it must not answer 200/204 to both and then lose one of the changes.
   #
   # usage: repro-transaction-lost-update.sh CATALOG_URL [ROUNDS] [MODE]
   #   CATALOG_URL  catalog root, without /v1 (e.g. http://localhost:8181)
   #   MODE         transaction (default), table: same changes through the
   #                single-table endpoint, or sequential: one request after the 
other
   #   WAREHOUSE    env: warehouse for /v1/config, if the catalog needs one
   #   TIMEOUT      env: seconds allowed per request (default 30); a request 
that
   #                runs out of time makes its round inconclusive
   #   MANIFEST_1, MANIFEST_2
   #                env: two existing manifest lists the server can read. When 
set,
   #                each writer adds a snapshot guarded on an empty main branch
   #                instead of setting a property, so at most one may commit.
   #
   # Each round is "lost" when a change answered 2xx is missing from two later
   # loads, "inconclusive" when a write got anything but 2xx or 409 (a 5xx, a
   # timeout, no connection) or a load failed, and otherwise fine.
   # Exit status: 3 if any round lost a change, 4 if none did but some round was
   # inconclusive, 0 if every round was fine, 1 on a setup error.
   # Needs bash, curl and jq.
   set -euo pipefail
   url=${1%/}; rounds=${2:-10}; mode=${3:-transaction}
   fail() { echo "error: $*" >&2; exit 1; }
   case "$mode" in transaction|table|sequential) ;; *) fail "unknown mode 
'$mode'" ;; esac
   [[ $rounds =~ ^[1-9][0-9]*$ ]] || fail "rounds must be a positive number"
   timeout=${TIMEOUT:-30}
   [[ $timeout =~ ^[1-9][0-9]*$ ]] || fail "TIMEOUT must be a positive number 
of seconds"
   if [ -n "${MANIFEST_1:-}" ] && [ -n "${MANIFEST_2:-}" ]; then snapshots=1
   elif [ -z "${MANIFEST_1:-}" ] && [ -z "${MANIFEST_2:-}" ]; then snapshots=""
   else fail "set both MANIFEST_1 and MANIFEST_2, or neither"; fi
   
   dir=$(mktemp -d)
   # request NAME METHOD PATH [BODY]: the response body goes to $dir/NAME, and 
the
   # HTTP status (000 if there was no response) is printed.
   request() {
     local body=()
     [ $# -ge 4 ] && body=(-d "$4")
     curl -sS --max-time "$timeout" -o "$dir/$1" -w '%{http_code}' -H 
'Content-Type: application/json' \
       -X "$2" "$base$3" ${body[@]+"${body[@]}"} 2>"$dir/$1.err" || true
   }
   
   base=$url
   [ "$(request config GET "/v1/config${WAREHOUSE:+?warehouse=$WAREHOUSE}")" = 
200 ] \
     || fail "GET /v1/config: $(cat "$dir/config" "$dir/config.err" 
2>/dev/null)"
   prefix=$(jq -r '.overrides.prefix // .defaults.prefix // empty' 
"$dir/config")
   base="$url/v1${prefix:+/$prefix}"
   ns="lost_update_$(date +%s)"
   code=$(request ns POST /namespaces "{\"namespace\":[\"$ns\"]}")
   case "$code" in 200|204) ;; *) fail "create namespace: HTTP $code $(cat 
"$dir/ns")" ;; esac
   
   change() { # table uuid writer -> the writer's table change
     if [ -n "$snapshots" ]; then
       local list id
       if [ "$3" = a ]; then list=$MANIFEST_1 id=1; else list=$MANIFEST_2 id=2; 
fi
       jq -nc --arg t "$1" --arg uuid "$2" --arg ns "$ns" --arg list "$list" 
--argjson id "$id" '{
         identifier: {namespace: [$ns], name: $t},
         requirements: [{type: "assert-table-uuid", uuid: $uuid},
                        {type: "assert-ref-snapshot-id", ref: "main", 
"snapshot-id": null}],
         updates: [{action: "add-snapshot", snapshot: {"snapshot-id": $id, 
"sequence-number": 1,
                      "timestamp-ms": (now * 1000 | floor), "manifest-list": 
$list,
                      summary: {operation: "append"}, "schema-id": 0}},
                   {action: "set-snapshot-ref", "ref-name": "main", type: 
"branch", "snapshot-id": $id}]}'
     else
       jq -nc --arg t "$1" --arg uuid "$2" --arg ns "$ns" --arg key "writer_$3" 
'{
         identifier: {namespace: [$ns], name: $t},
         requirements: [{type: "assert-table-uuid", uuid: $uuid}],
         updates: [{action: "set-properties", updates: {($key): "1"}}]}'
     fi
   }
   
   send() { # round table writer change; the status goes to 
$dir/ROUND.WRITER.code
     if [ "$mode" = table ]; then
       request "$1.$3" POST "/namespaces/$ns/tables/$2" "$(jq -c 
'{requirements, updates}' <<<"$4")"
     else
       request "$1.$3" POST /transactions/commit "$(jq -c '{"table-changes": 
[.]}' <<<"$4")"
     fi > "$dir/$1.$3.code"
   }
   
   has_change() { # metadata-file writer: does the loaded table have this 
writer's change?
     if [ -n "$snapshots" ]; then
       jq -e --argjson id "$([ "$2" = a ] && echo 1 || echo 2)" \
         '[.snapshots[]?."snapshot-id"] | index($id) != null' "$1" >/dev/null
     else
       jq -e --arg k "writer_$2" '.properties | has($k)' "$1" >/dev/null
     fi
   }
   
   load() { # round table uuid try -> $dir/ROUND.meta.TRY, or fail if not that 
table
     local name="$1.load.$4"
     [ "$(request "$name" GET "/namespaces/$ns/tables/$2")" = 200 ] || return 1
     jq -e --arg uuid "$3" '.metadata."table-uuid" == $uuid' "$dir/$name" 
>/dev/null || return 1
     jq -c .metadata "$dir/$name" > "$dir/$1.meta.$4"
   }
   
   lost_rounds=0 unknown_rounds=0
   for r in $(seq "$rounds"); do
     t="t$r"
     code=$(request "$r.create" POST "/namespaces/$ns/tables" \
       
"{\"name\":\"$t\",\"schema\":{\"type\":\"struct\",\"fields\":[{\"id\":1,\"name\":\"id\",\"required\":true,\"type\":\"long\"}]}}")
     [ "$code" = 200 ] || fail "create table $t: HTTP $code $(cat 
"$dir/$r.create")"
     uuid=$(jq -er '.metadata."table-uuid"' "$dir/$r.create") || fail "create 
table $t: no table UUID"
     a=$(change "$t" "$uuid" a); b=$(change "$t" "$uuid" b)
     if [ "$mode" = sequential ]; then
       send "$r" "$t" a "$a"; send "$r" "$t" b "$b"
     else
       send "$r" "$t" a "$a" & pa=$!; send "$r" "$t" b "$b" & pb=$!; wait "$pa" 
"$pb"
     fi
     ca=$(cat "$dir/$r.a.code"); cb=$(cat "$dir/$r.b.code")
   
     result="" acked=""
     for w in a b; do
       case "$(cat "$dir/$r.$w.code")" in
         2??) acked+=" $w" ;;
         409) ;;
         *) result="inconclusive: writer $w got HTTP $(cat "$dir/$r.$w.code")" 
;;
       esac
     done
     if [ -z "$result" ]; then
       if ! load "$r" "$t" "$uuid" 1; then
         result="inconclusive: the table could not be loaded afterwards"
       else
         missing=""
         for w in $acked; do has_change "$dir/$r.meta.1" "$w" || missing+=" 
$w"; done
         if [ -n "$missing" ]; then
           # Confirm with a second load before calling it lost.
           sleep 0.5
           if ! load "$r" "$t" "$uuid" 2; then
             result="inconclusive: the second load failed"
           else
             still=""
             for w in $missing; do has_change "$dir/$r.meta.2" "$w" || still+=" 
$w"; done
             [ -n "$still" ] && result="lost: acknowledged but missing from two 
loads:$still"
           fi
         fi
       fi
     fi
     case "$result" in
       lost*) lost_rounds=$((lost_rounds + 1)) ;;
       inconclusive*) unknown_rounds=$((unknown_rounds + 1)) ;;
     esac
     echo "round $r: $ca $cb${result:+, $result}"
   done
   echo "$lost_rounds of $rounds rounds lost an acknowledged change; 
$unknown_rounds inconclusive"
   [ "$lost_rounds" -eq 0 ] || exit 3
   [ "$unknown_rounds" -eq 0 ] || exit 4
   
   ```
   
   </details>
   
   ### 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
   
   Found by @aiskakov28 and @aldusialn.
   


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