technicolorbeat commented on PR #1956:
URL: https://github.com/apache/iceberg-go/pull/1956#issuecomment-5515330804

   > All three of my prior blockers are fixed (DCO on every commit, merge 
commit gone, branch is `MERGEABLE`), and the mechanism itself is sound. I want 
to call out the test suite specifically: I reverted each of the eight defensive 
changes in turn and **every one was caught by a named test** — no inert 
assertions, no tautologies. That is the strongest test suite I've reviewed in 
this batch.
   > 
   > Two substantive problems remain.
   > 
   > ## Blocking
   > **1. `table/deferred_snapshots.go:213-215` and `:171-173` — the deferred 
parser rejects metadata the eager parser accepts.**
   > 
   > `validateSummaryObject` and `validateStringArray` are hand-rolled 
stand-ins for what `encoding/json` does when decoding into `map[string]string` 
/ `[]string`, and they diverge on JSON `null`. Go's decoder stores `null` into 
a string as `""` without error; both validators require the value token to 
start with `"`. Minimal reproducers — both parse clean today, both fail with 
`snapshot-loading-mode=refs`:
   > 
   > ```
   > "summary":   {"operation":"append","extra":null}
   >   eager=<nil>   defer=invalid metadata: snapshot …: cannot unmarshal 
summary value into string
   > 
   > "manifests": ["a.avro", null]
   >   eager=<nil>   defer=invalid metadata: snapshot …: cannot unmarshal 
manifest location into string
   > ```
   > 
   > Why this matters more than a normal parse-strictness difference: it fires 
on the _commit response_, i.e. **after the server has already applied the 
commit**. `Transaction.Commit` would return `ErrInvalidMetadata` for a 
transaction that actually succeeded, and the natural user reaction — retry — 
risks a double-apply. It also directly contradicts the PR's claim that "the 
public `Metadata` API and v1/v2/v3 behavior are unchanged": enabling a 
_performance_ mode must not change what parses.
   > 
   > Fix: in `validateSummaryObject`, accept a `null` value token 
(`bytes.HasPrefix(raw[i:], []byte("null"))` → advance 4 and continue), and 
likewise for elements in `validateStringArray`; add a parity test asserting 
both documents above still parse under the deferred path. A lower-risk 
alternative is to validate by decoding into `map[string]string`/`[]string` for 
unreferenced entries — you lose part of the allocation win but get parity for 
free.
   > 
   > (A third, much more obscure instance of the same class: 
`"summary":{"operation":"","operation":"append"}` — Go's last-key-wins map 
decode accepts it, the scanner rejects on the first key. Not worth fixing on 
its own, but the null fix should come with a comment stating the intended 
acceptance contract.)
   > 
   > **2. `table/metadata.go:425-429` + `table/table.go:210` — the realistic 
post-commit path is an unmeasured ~21% allocation regression.**
   > 
   > `Table.NewTransaction*` calls `MetadataBuilderFromBase`, which calls 
`snapshotsForMarshal()` and fully materialises. So the _second_ write on a 
committed table pays the deferred index build **and** the full decode. Measured 
(parse + `MetadataBuilderFromBase`, `-count=3`):
   > 
   > metadata bytes     eager allocs/op deferred allocs/op      Δ       eager 
B/op      deferred B/op   Δ
   > 7,056      906     1,096   **+21.0%**      58,952  77,272  **+31.1%**
   > 59,078     6,132   7,405   **+20.8%**      419,130 582,586 **+39.0%**
   > 582,880    58,345  70,424  **+20.7%**      3,953,508       5,547,678       
**+40.3%**
   > 1,999,830  198,613 239,713 **+20.7%**      13,850,244      19,212,749      
**+38.7%**
   > 5,856,882  580,444 700,558 **+20.7%**      41,413,480      57,358,112      
**+38.5%**
   > At 5.86 MB the deferred side was also consistently slower in wall time 
(256/278/293 ms vs 213/227/239 ms).
   > 
   > Your own `BenchmarkStageDeferredAllSnapshots` already contains this 
evidence — at ~2 MB it reports 239,675 allocs / 19.07 MB versus 
`BenchmarkStageFullParse`'s 174,658 allocs / 10.43 MB — but it's only described 
as "the intentional fallback … pay the deferred full-decoding cost once" and is 
never put next to the eager baseline. That reads as "you pay it once" rather 
than "it is strictly worse than not deferring".
   > 
   > Please either (a) add this parse→`NewTransaction` pair to the benchmark 
set and state the trade-off in the description and in the doc comment on 
`ParseMetadataBytesDeferredSnapshots`, or (b) carry the deferred state into 
`MetadataBuilder` so a builder that never touches history doesn't materialise. 
I don't think (b) is required for this PR, but (a) is — as written, a reader 
would conclude this is a strict improvement for REST commit traffic, and for 
any table that gets more than one write per `Table` object it isn't.
   > 
   > ## Major
   > **3. `table/metadata.go:3071`,`:3169`,`:3289` — marshalling a metadata 
_value_ silently drops unreferenced snapshots.** The three `MarshalJSON` 
methods that project `snapshotsForMarshal()` have **pointer receivers**, so 
`json.Marshal` on a non-addressable `metadataV2` value skips them and falls 
back to reflection over `commonMetadata.SnapshotList` — which under deferral 
holds only the ref-reachable subset. Measured on the example v2 document with 
one unreferenced snapshot: `json.Marshal(ptr)` → **2** snapshots, 
`json.Marshal(*ptr)` → **1**, no error.
   > 
   > Every production write path is safe today (`catalog/internal/utils.go:72` 
encodes the interface, which holds a pointer; `metadataV1.ToV2()` returns a 
value but clears `deferredSnapshots` first), so this is latent — I could not 
find a live call site that trips it. But this PR is what turns the consequence 
into _silent snapshot loss in a written metadata file_ rather than a missing 
`current-snapshot-id`, and the type does return by value (`ToV2`). Fix is three 
characters: value receivers (`func (m metadataV2) MarshalJSON()`), which puts 
the method in both method sets — `Snapshot.MarshalJSON` at `snapshots.go:306` 
already uses that form. Please add a regression test asserting 
`json.Marshal(*meta.(*metadataV2))` preserves the full history.
   > 
   > **4. `table/metadata.go:2384-2390`,`:2372-2378` — a deferred decode error 
is swallowed and presented as "no snapshots".** `allSnapshots()` discards the 
error and returns `nil`, so `Snapshots()` reports an empty history and 
`Equals()` compares two empty lists as equal; `SnapshotByID` returns `nil`, 
indistinguishable from absent. The two callers that _can_ return an error 
(`MarshalJSON`, `MetadataBuilderFromBase`) do propagate it, which is the right 
half.
   > 
   > I tried hard to reach the swallowed path and couldn't — 
`deferredSnapshotFields` is field-for-field type-identical to `Snapshot` except 
`manifests`/`summary`, the validators are equal-or-stricter than 
`encoding/json` (20,000-case fuzz found 0 eager-stricter), and `encoding/json` 
has syntax-checked the document before `splitJSONArray` runs. So it isn't 
currently reachable. But it's precisely the "not yet decoded treated as absent" 
failure mode, and the invariant keeping it unreachable is exactly the one 
Blocking 1 shows isn't being maintained deliberately. At minimum add a comment 
at `:2384` recording that `snapshotsForMarshal` cannot fail for state produced 
by `prepareDeferredSnapshots`, and why.
   > 
   > ## What checks out
   > * **8/8 mutations killed** — reverting the head commit, moving 
`s.mu.RUnlock()` before `&s.entries[i]` (real data race at 
`deferred_snapshots.go:108` under `-race -count=5`), deleting each 
`MarshalJSON`, stripping `ToV2` materialisation, `Equals` → `c.SnapshotList`, 
`Snapshots()` → `c.SnapshotList`, stripping the v3 `snapshots` projection, 
stripping the deferred branch from `SnapshotByID`. Each caught by a named test.
   > * **Differential fuzz, 20,000 generated v2 documents**: 3,697 divergences, 
**all in the deferred-stricter direction, 0 eager-stricter**. For all 16,303 
mutually-accepted documents, deferred and eager marshalled **byte-identically** 
while still deferred, and `SnapshotByID` on an unreferenced snapshot returned a 
value `reflect.DeepEqual` to the eager one.
   > * **Enumerated every non-test reader** of `commonMetadata.SnapshotList` 
between response decode and final metadata (`metadata.go:451`, `:1403`, 
`:2364-2370`, `:2736`, `:2868`, `:3187`, `:3314`, `:3338`, `:3090`). All are 
either safe by construction — the current snapshot and every ref target are 
eagerly decoded, and `deferredSnapshots` is assigned _after_ 
`finishDeferredMetadataUnmarshal`, so `checkRefsExist` can't see a 
half-populated state — or duplicated synchronously in 
`prepareDeferredSnapshots`. Every public reader in the repo goes through 
`Snapshots()`/`SnapshotByID`, which materialise. One hole found (Major 3).
   > * **Benchmarks reproduce.** At ~2 MB I measure 41,409 vs 174,658 allocs/op 
against your 41,503 vs 174,749 — a match to <0.1%. Historical-lookup delta 
41,460 vs 41,407 (+53) against your +52.
   > * `golangci-lint` 0 issues; `-race` clean on both touched packages; CI 
15/15.
   > 
   > ## Minor
   > * The new `MarshalJSON` wrappers move the top-level `snapshots` key to the 
**end** of the serialized object for v1, v2 _and_ v3, in **all** 
snapshot-loading modes, because Go orders fields by index sequence and the 
explicit `SnapshotList` field follows the embedded `*Alias`. Semantically 
irrelevant, but it changes every metadata file this client writes, and 
"v1/v2/v3 behavior are unchanged" doesn't cover it.
   > * `metadata.go:1953-1974` — `ParseMetadataBytesDeferredSnapshots` 
duplicates the preflight of `ParseMetadataBytes` almost verbatim; factor out 
the shared step. It also drops the `ret.Version() != formatVersion` cross-check 
the eager path does at `:1942-1945`. I couldn't construct an input where they 
disagree, so this is a consistency nit.
   > * `metadata.go:450-453` — the current-snapshot presence check uses 
`common.snapshotIndex, common.SnapshotList` (the eager subset) even though 
`b.snapshotIndex`/`b.snapshotList` were built two lines earlier. Correct today 
only because the current snapshot is always eagerly decoded; using `b.*` 
removes the dependency.
   > * `deferred_snapshots.go:279` — `splitJSONArray` re-trims a slice its only 
caller already trimmed, and returns offsets the caller applies to _its_ slice. 
Works only because the second trim is a no-op; a future caller passing 
untrimmed bytes gets silently wrong spans. (I stress-tested the scanner on 
braces/commas/brackets inside strings, escaped quotes and backslashes, nested 
structures, empty and whitespace-only arrays, and a 501-element array — all 
correct.)
   > * Commit hygiene: the builder assertion at 
`deferred_snapshots_test.go:87-100` was present from the first commit, and 
reverting only the head commit's single hunk makes it fail — so `go test 
./table/` was **red at both `d704277d` and `187c473b`**. Bisect-hostile; please 
squash or fix in place.
   > * ASF headers present on both new files; range-over-int used correctly 
(the index loops in the scanners are genuine index arithmetic and correctly not 
converted).
   > 
   > ## Prior items
   > * DCO missing on 3 of 4 commits → **Fixed**, all three commits signed off.
   > * Merge commit `d6986d2` → **Fixed**, history is linear.
   > * `mergeStateStatus: DIRTY` → **Fixed**, now `MERGEABLE`.
   > * Mode resolution / unknown values rejected → **still correct, N.A.**
   > * Per-entry `sync.Once`, never a panic → **Confirmed and now stronger**: 
reverting `187c473b`'s lock ordering produces a real race the suite catches.
   > * "No observable loss; `Snapshots()` fully materialises" → **Partially 
fixed.** True for the interface path, and the head commit closed the 
`MetadataBuilderFromBase` leak my prior review missed. But the value-marshal 
path does observably lose snapshots (Major 3), and `Snapshots()` returns nil 
rather than an error on a deferred decode failure (Major 4).
   > * Unknown snapshot fields round-trip → **Confirmed.** Fuzz cases carrying 
unknown nested objects round-trip byte-identically on both paths.
   > * Aliasing / `json.RawMessage` ownership → **Confirmed, unchanged.**
   > * "The lazy-decode design is sound, no findings against the mechanism" → 
**partially superseded.** The mechanism is sound; that round didn't examine 
validation parity between the two parsers, nor benchmark the 
parse→`NewTransaction` sequence. Those are Blocking 1 and 2.
   > 
   > ## Description
   > * **"The public `Metadata` API and v1/v2/v3 behavior are unchanged" is 
inaccurate twice** — the deferred path rejects documents the eager path 
accepts, and serialized top-level key order changes for all three versions in 
all modes.
   > * "Synchronously validate … summary values. Historical corruption 
therefore remains a parse-time error" — true, but the validation isn't 
_equivalent_ to the eager path's; it's strictly stricter on `null`.
   > * "Callers that request all snapshots pay the deferred full-decoding cost 
once" — understated. Full materialisation costs **+37% allocations and +83% 
bytes** versus never deferring, from your own benchmarks, and it's triggered by 
`Table.NewTransaction`, not only by explicit history access.
   > * Benchmark revisions are honestly disclosed as stale relative to head. 
Allocation figures still reproduce to <0.1%, so the substance holds. Two don't: 
the claimed **38.2% improvement at 7,056 B** isn't statistically 
distinguishable on my box (290.0 µs vs 218.5 µs, p=0.123 over 10 interleaved 
rounds) — allocations at that size are unambiguously better (−41%), so I'd lead 
with allocations at the small end rather than a wall-clock percentage. And 
**−25.2% B/op at ~2 MB** now measures **−29.9%**, because main's baseline 
moved. Worth re-measuring on the rebased head.
   > 
   > > _This review was drafted by an AI-assisted tool and confirmed by an 
Iceberg Go maintainer. The findings cite the project's review criteria; if you 
think one is mis-applied, please reply and a maintainer will weigh in._
   
   
   
   Thanks for the detailed review and for validating the test suite so 
thoroughly. I reproduced the findings and pushed the follow-up changes.
   
   At a high level, I aligned deferred parsing with eager JSON semantics, 
ensured full snapshot history is preserved across serialization, documented the 
deferred-state invariants, and added regression and parse-to-builder benchmark 
coverage.
   
   The updated benchmarks make the trade-off explicit. At ~2 MB, deferred 
commit-response parsing improves from **78.420 ms to 50.326 ms**, with **76.3% 
fewer allocations** and **29.9% fewer allocated bytes**. However, immediately 
creating a `MetadataBuilder` forces full materialization: parse-plus-builder 
increases from **80.611 ms to 98.314 ms**, with **20.7% more allocations** and 
**38.9% more allocated bytes**.
   
   I agree this is not a strict improvement for repeated writes on the same 
returned `Table`. This PR optimizes the `snapshot-loading-mode=refs` 
commit-response path when full history is not immediately required. Carrying 
deferred state into `MetadataBuilder` would be a separate follow-up - #1986 
   


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