zeroshade commented on PR #1113:
URL: https://github.com/apache/arrow-go/pull/1113#issuecomment-5221316979

   Thanks for this — the architecture here is sound and worth keeping: the 
checkpoint graph built once at construction, the parent-before-child restore 
ordering, the multibuffer/string-view truncation, and the memo-table rehashing 
all look correct. I found no refcount imbalance, no mutation of already-built 
arrays, and no hash entry left reachable after truncation. Your test suite is 
green, and `go vet`'s only complaint reproduces on unmodified `main`.
   
   That said, I think this needs changes before merge. Everything below was 
reproduced against `0b09f34c`.
   
   ## Root cause
   
   The rollback path uses `Builder.Resize` as a transactional rewind primitive, 
but `Resize` is an allocation/capacity hint, not an exact logical truncation — 
it's free to round up, no-op, or compare in the wrong units, and it never 
clears discarded validity bits. The fact that the PR already reaches into six 
builders' unexported `length` fields to compensate is the design signalling 
this. The four issues below are all the same cause.
   
   ## 1. `string` / `large_string` rollback silently corrupts data
   
   Rows `{"s":"aaa","o":1}`, failing `{"s":"bbb","o":"bad"}`, then 
`{"s":"ccc","o":2}` produces `["aaa", ""]` instead of `["aaa","ccc"]`.
   
   `binarybuilder.go:252` compares a **byte** count against an **element** 
count:
   
   ```go
   b.offsets.resize((n + 1) * b.offsetByteWidth)
   if (n * b.offsetByteWidth) < b.offsets.Len() {   // bytes  <  elements
       b.offsets.SetLength(n * b.offsetByteWidth)   // never reached when 
shrinking
   }
   ```
   
   `int32BufferBuilder.Len()` returns `b.length / arrow.Int32SizeBytes` 
(elements), so shrinking 2→1 evaluates `4 < 2` → false and the offsets buffer 
never shrinks. The stale offset leaves `offsets=[0,3,3,6]` with `length=2`, so 
`value[1]` is `bytes[3:3]` = `""` while `"ccc"` sits unreferenced. It's silent 
because a duplicated offset is a *legal* encoding of an empty string — no 
panic, and `ValidateFull` can't catch it.
   
   **Worth separating out:** this is a pre-existing latent bug, not a 
regression from your diff. `RecordBuilder.Resize(-1)` (`record.go:370-381`, 
from #805) already shrank builders mid-build, and reproduces the same 
corruption with no JSON involved. I'd suggest fixing `binarybuilder.go:252` as 
its own commit with a regression test — it's a real bug on `main` today. It 
can't be deferred past this PR though, since this change makes the path 
reachable from any JSON stream with a string column and one malformed row.
   
   ## 2. `FixedSizeBinaryBuilder` loses data
   
   Rewinds `Len()` but never truncates its `values` byte buffer, and gets no 
checkpoint (unlike `BinaryBuilder`). Same `aaa` / `bbb`(bad) / `ccc` sequence → 
`["aaa","bbb"]`, with `"ccc"` lost.
   
   ## 3. `BooleanBuilder` / `NullBuilder` lengths are never rewound → panic
   
   `NullBuilder.Resize` is a no-op (`null.go:141`); `BooleanBuilder.Resize` 
rounds `n` up to `minBuilderCapacity` (32) before resizing 
(`booleanbuilder.go:155-157`), so a failure within the first 32 values isn't 
removed:
   
   ```
   {a: bool, b: int32}, failed row {"a":true,"b":"invalid"}
   after failed row:    a.Len()=1  b.Len()=0
   after next good row: a.Len()=2  b.Len()=1
   NewRecordBatch panics: some fields have excessive number of rows (want at 
most 1, have 2)
   ```
   
   Identical with a leading `NullBuilder`. A recoverable decode error leaves 
the builder permanently misaligned.
   
   ## 4. Discarded validity bits aren't cleared → rolled-back value resurfaces
   
   Affects every field type. `resize` recomputes `nulls` for the kept prefix 
but leaves the set bit behind, and `AppendNull` then increments `nulls` without 
clearing it:
   
   ```
   good {"a":10}, failed {"a":20,...}, then {"a":null,...}
   array: [10 20]      <- null was appended at index 1; the discarded 20 came 
back
   Len()=2 NullN()=1   IsNull(0)=false  IsNull(1)=false
   ValidateFull: null count value (1) does not match actual number of nulls in 
array (0)
   ```
   
   Nested variant: the list / list-view / fixed-size-list / map / struct 
corrections restore `length` but not `nulls`, so after rollback `Len()==0` 
while `NullN()==1`, poisoning the next row.
   
   Since the goal of this PR is preventing failed rows from affecting 
subsequent rows, it's worth noting the rollback path currently introduces that 
same class of corruption for plain int32 and utf8 columns.
   
   ## Suggested fix shape
   
   `Resize` can't carry this. I'd suggest a real per-builder exact-truncate 
primitive (e.g. an unexported `truncate(n int)` on `Builder`), or extending 
your existing `checkpointState` mechanism to *every* builder rather than only 
Binary/BinaryView/dictionary — rewinding length, offsets, data buffers, and 
children exactly, and zeroing the discarded validity-bitmap tail. `NullBuilder` 
and `BooleanBuilder` need explicit length handling regardless, since their 
`Resize` can't express a sub-32 truncation. This can't be fully fixed inside 
`record.go`'s switch, because the state needing rewind is unexported 
per-builder state that's unreachable for extension and third-party builders.
   
   ## Test gaps
   
   Contract claims nine type families; several have no rollback coverage:
   
   - **No rollback test at all:** maps, fixed-size lists, Boolean, Null, 
large-list, large-list-view.
   - **Length-only assertions** (`Len()==0`, no following-row content check): 
sparse union, dense union, list-view.
   - No failed-valid → null-at-same-index case, or failed-null → valid case 
(these expose #4).
   - No direct memo-table `Truncate` test (truncate → reinsert identical 
dropped value, hash collisions, null indices).
   - No test that builds an array, reuses the builder, rolls back, then asserts 
the already-built array is unchanged.
   - No custom `CheckpointableBuilder` test for repeated capture/restore or 
checkpoint resource lifetime.
   
   ## Smaller notes
   
   - `CheckpointState` has no commit/discard/release lifecycle — the REE 
checkpoint can retain the previously decoded object after a successful batch, 
and a custom checkpoint holding a refcounted resource has no success-path hook 
to release it.
   - The union child graph goes stale if `AppendChild` is called after 
`RecordBuilder` construction; worth making that topology restriction explicit 
or detected.
   - `timestamp_with_offset.go`: every public `Resize`, including capacity 
*growth*, resets `lastOffset`, forcing an unnecessary new REE run — correctness 
is fine, but compression regresses.
   - Doc comments for the exported `TimestampWithOffsetBuilder.Resize` and 
`NewCheckpoint`; and the exported interfaces should document that one 
checkpoint object is reused and `Capture` is called repeatedly.
   
   Happy to help review the follow-up. I have runnable repro tests for all four 
issues above if it'd be useful to have them posted here.
   


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

Reply via email to