mzzz-zzm commented on issue #830:
URL: https://github.com/apache/iceberg-go/issues/830#issuecomment-4265738135

   Great progress on #911 and #912! I've been working on the 
"refresh-and-replay" piece
   that #912 explicitly defers, and I ran into six bugs that will all fire once
   `num-retries` is raised above zero. Documenting them here before opening a 
PR, since
   #912 describes this as a follow-up.
   
   ---
   
   ### Context
   
   I tested a `rebase()` implementation against AWS S3 Tables (a real managed 
Iceberg REST
   catalog backed by S3). The test spins up two goroutines writing to the same 
table
   concurrently and expects both to eventually succeed via the OCC retry loop. 
Six bugs
   prevent that from working. Four are OCC retry correctness issues; two are 
data races
   exposed by concurrent manifest writing.
   
   None of these are visible today with the default `num-retries=0`, but they 
will all
   surface once that default is raised.
   
   ---
   
   ### OCC retry bugs (Bugs 1–4)
   
   These four bugs interact in a chain, which is why they are hard to discover 
one at a time:
   
   ```
   Attempt 0  →  Writer B loses CAS race  →  catalog returns 400 (not 409)
                                               ↓
   Bug 4: 400 not mapped to ErrCommitFailed → retry loop exits immediately
                                               (no retry ever fires)
   ```
   
   Once Bug 4 is fixed, retries fire but hit the remaining bugs in sequence:
   
   ```
   Attempt 1  →  Bug 3: -1 sentinel in JSON → catalog rejects every retry 
unconditionally
   
   Once Bug 3 is fixed:
   Attempt 1  →  Bug 1: stale lastSequenceNumber → duplicate sequence number → 
400
   
   Once Bug 1 is fixed:
   Attempt 1  →  Bug 2: same snapshotID resubmitted → catalog rejects re-use → 
400
   
   Once Bug 2 is fixed:  both writers succeed.
   ```
   
   ---
   
   #### Bug 1 — `rebase()` does not sync `lastSequenceNumber` 
(`table/snapshot_producers.go`)
   
   `rebase(freshMeta)` advances `parentSnapshotID` and the current-snapshot 
pointer in
   `MetadataBuilder`, but does **not** update 
`MetadataBuilder.lastSequenceNumber`.
   
   The next `commit()` calls `nextSequenceNumber()`, which returns
   `*lastSequenceNumber + 1` — still the stale pre-conflict value. The retried 
snapshot
   gets a sequence number the concurrent winner already used.
   
   S3 Tables validates sequence numbers strictly:
   ```
   bad_request: The specified metadata is not valid
   ```
   
   **Fix:** copy `freshMeta.LastSequenceNumber()` into 
`sp.txn.meta.lastSequenceNumber`
   inside `rebase()`.
   
   ---
   
   #### Bug 2 — `rebase()` reuses the same `snapshotID` on every retry 
(`table/snapshot_producers.go`)
   
   `snapshotID` is assigned once in `createSnapshotProducer()` and never 
changed.
   After a rejection, `rebase()` + `commit()` resubmit the **same** snapshot-id.
   
   S3 Tables records every attempted snapshot-id — including those from 
rejected commits —
   and refuses a re-submission:
   ```
   bad_request: The specified metadata is not valid
   ```
   
   This is a distinct failure path from Bug 1: even with the correct sequence 
number, the
   id was already seen.
   
   **Fix:** call `sp.txn.meta.newSnapshotID()` at the top of each `rebase()` 
call.
   
   ---
   
   #### Bug 3 — `commit()` serialises `-1` sentinels into the JSON payload 
(`table/snapshot_producers.go`)
   
   `commit()` builds the set-snapshot-ref update as:
   
   ```go
   NewSetSnapshotRefUpdate(branch, sp.snapshotID, BranchRef, -1, -1, -1)
   ```
   
   The three trailing args are `MaxRefAgeMs`, `MaxSnapshotAgeMs`, 
`MinSnapshotsToKeep`.
   `-1` is the internal sentinel for "not set", but the struct serialises them 
literally:
   
   ```json
   "max-ref-age-ms": -1,
   "max-snapshot-age-ms": -1,
   "min-snapshots-to-keep": -1
   ```
   
   The struct uses `omitempty`, but `omitempty` on `int64` omits only `0`, not 
`-1`.
   The initial commit (attempt 0) goes through `MetadataBuilder.SetSnapshotRef` 
which
   reaches the catalog via a different path that ends up omitting these fields 
— so the
   bug is **invisible on the first attempt and only appears on retries**.
   
   S3 Tables rejects the explicit negative values:
   ```
   bad_request: The specified metadata is not valid
   ```
   
   **Fix:** pass `0` instead of `-1` to `NewSetSnapshotRefUpdate()`. The 
`omitempty` tags
   then correctly omit the fields, matching the behaviour of the initial commit.
   
   > Note: the Java client omits these fields by using `null`; the Go struct 
relies on
   > `omitempty` + zero value. Passing `-1` breaks that contract.
   
   ---
   
   #### Bug 4 — REST catalog does not map HTTP 400 to `ErrCommitFailed` 
(`catalog/rest/rest.go`)
   
   `CommitTable`'s error map covers only HTTP 409 → `ErrCommitFailed`. The 
retry loop in
   `commitWithRetry()` gates on `errors.Is(err, ErrCommitConflict)`.
   
   S3 Tables returns **HTTP 400**, not 409, for all the OCC conflicts above. 
Because 400
   maps to the generic `ErrBadRequest`, the retry loop treats it as 
non-retryable and
   exits immediately — meaning none of Bugs 1–3 can even be reached.
   
   **Fix:** add `http.StatusBadRequest → ErrCommitFailed` to the `CommitTable` 
error map,
   with a comment noting it handles catalogs that return 400 for OCC conflicts 
instead of
   409. This only affects `CommitTable`; other endpoints use separate error 
maps so they
   are unaffected.
   
   ---
   
   ### Data race bugs (Bugs 5–6)
   
   These are independent of the OCC chain but are triggered by the same 
concurrent manifest
   writes. They show up under `go test -race` on any concurrent-writer workload.
   
   ---
   
   #### Bug 5 — Data race in `Schema.MarshalJSON()` (`schema.go`)
   
   `MarshalJSON()` contained:
   
   ```go
   if s.IdentifierFieldIDs == nil {
       s.IdentifierFieldIDs = []int{}   // unsynchronised write on shared 
*Schema
   }
   ```
   
   During manifest writing, goroutines sharing the same `*Schema` (fetched from
   `MetadataBuilder`) call `json.Marshal` concurrently. This write races with 
reads in
   other goroutines.
   
   **Fix:** work on a local copy instead of touching the receiver:
   
   ```go
   ids := s.IdentifierFieldIDs
   if ids == nil { ids = []int{} }
   aliasCopy := *(*Alias)(s)
   aliasCopy.IdentifierFieldIDs = ids
   return json.Marshal(struct{ ... }{..., Alias: &aliasCopy})
   ```
   
   ---
   
   #### Bug 6 — Data race in `ExpressionEvaluator()` (`visitors.go`)
   
   `ExpressionEvaluator()` returned the **bound method** of a single allocated 
instance:
   
   ```go
   return (&exprEvaluator{bound: bound}).Eval, nil
   ```
   
   `exprEvaluator.Eval` stores the `StructLike` row into a struct field before 
walking:
   
   ```go
   func (e *exprEvaluator) Eval(st StructLike) (bool, error) {
       e.st = st                        // write on shared instance
       return VisitExpr(e.bound, e)
   }
   ```
   
   When conflict detection scans multiple manifests in parallel via `errgroup`, 
all
   goroutines call the same returned func with different rows, racing on `e.st`.
   
   **Fix:** construct a fresh instance on every call:
   
   ```go
   return func(st StructLike) (bool, error) {
       return VisitExpr(bound, &exprEvaluator{bound: bound, st: st})
   }, nil
   ```
   
   ---
   
   ### Relationship to PR #912
   
   PR #912 is exactly the right skeleton. The PR description says:
   
   > "The default num-retries is 0 until refresh-and-replay lands in a 
follow-up; without
   > refreshing requirements between attempts, retrying a contended OCC commit 
would fail
   > deterministically."
   
   The bugs above are precisely *why* it fails deterministically. The `rebase()`
   implementation that provides the refresh-and-replay step:
   
   1. Advances `parentSnapshotID` to the new HEAD so `existingManifests()` 
reads the
      correct snapshot (equivalent to Java `SnapshotProducer.apply()` being 
re-called)
   2. Syncs `lastSequenceNumber` from `freshMeta` (Bug 1)
   3. Regenerates `snapshotID` (Bug 2)
   4. Increments an `attempt` counter so each retry writes a distinct 
manifest-list
      filename (`snap-<id>-<attempt>-<uuid>.avro`, matching Java behaviour)
   5. Is called once per pending producer inside the existing retry loop in
      `commitWithRetry()`, before `commit()` is called again
   
   Bugs 3 and 4 are simpler independent fixes that should land regardless of 
whether
   `rebase()` ships at the same time.
   
   ---
   
   ### Testing
   
   - **Unit tests** (no AWS credentials needed): 10/10 pass, covering the retry 
loop,
     conflict detection, orphaned manifest cleanup, and context cancellation.
   - **AWS integration tests** (`-tags integration_aws`): 4/4 pass against a 
live S3 Tables
     endpoint, including a concurrent two-writer scenario where both goroutines 
must
     eventually commit successfully.
   - Verified on latest `main` (rebased onto `add5fce`, post-merge of the 
dependency bumps
     from the past few days).
   
   Happy to open a PR once there is buy-in. I can split the fixes into separate 
commits or
   a single PR, whichever is easier to review.
   


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