fix: two silent-correctness defects that block the alpha3 tag (#875, #881) - #883
Conversation
…881) Both make a correct-looking operation produce wrong data with no error. Both were reproduced on main before anything was changed, and each arm goes red when its own fix is reverted. #875 -- A PROJECTION CREATED MID-TRANSACTION MISSED THE WRITES AFTER IT. PgColumnarProjectionFanoutRow builds the write state's projection-writer list on first use and latches it, INCLUDING when the list comes back empty, which is what it is before any projection exists. So a write before add_projection() latched an empty list, the back-fill populated the new projection from the rows that already existed, and every later write in that transaction skipped it in silence. Measured 116 base rows against 105 in the projection. add_projection() now drops that cache -- after the back-fill, so what the back-fill sees is unchanged and only what follows it is affected. #881 -- AN ARROW IMPORT IGNORED THE WIDTH, SIGN AND SCALE THE FILE DECLARED. imp_apply_field inspected only Date, Time and Timestamp. Everything else took its stride and interpretation from the TARGET column: uint64 2^63+5 into bigint -> -9223372036854775803 int64 1,2,3,4 into int -> 0,0,1,2 decimal(10,2) 1.25 into numeric(20,4) -> 0.0125 fixed_size_binary(32) into uuid -> the first 16 bytes The buffer-length check already refused a file whose carrier is NARROWER than the target. These are the same-width-or-wider cases, where the buffer is long enough and nothing complained. Refused now with 42804. Deliberately WITHIN a family: an int64 read into a timestamp as raw microseconds is long-standing accepted behaviour with its own control, and my first attempt broke it. Cross-family behaviour is unchanged. REMOVAL PROOFS, one .so per arm, every mutation asserted applied. #875, in test/projections.sh (71 checks): unmutated 71 + 0 call site removed 69 + 2 function body gutted 69 + 2 #881, in test/arrow_import.sh (72 checks): unmutated 72 + 0 Int check removed 68 + 4 uint64/int64 return 00000 Float check removed 71 + 1 Decimal check removed 71 + 1 FixedSizeBinary check removed 71 + 1 THE FSB ARM EXISTS BECAUSE THE FIRST VERSION OF THIS DID NOT HAVE ONE. Removing that check reddened nothing: the narrowing case an obvious arm would use is already caught by the buffer-length check, so it cannot tell the guard from its absence. The arm that discriminates uses a WIDER carrier -- 32 bytes into uuid's 16 -- where the buffer is long enough and the reader takes the first half. Also here, both from the same release review: #876 the "does not exist" error now names rebuild_projections(), which recovers it. The declaration is intact and the old message denied it. #877 the CHANGELOG said all three visibility-map clears are held by tests. Two were not until #878. Corrected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017V7PhZ1TzoVVNsACFXTbdT
|
Full PG 17.10 matrix on this branch: 239 ran, 5 skipped, 0 incomplete, 0 failed, |
|
Reviewed adversarially at Your removal proof reproduces exactlyIndependently, one The Int reds are the three refusal arms plus Ask 3 — the
|
…on (#875) Found in review of this PR, on the sibling function. drop_projection deletes the projection row, its metadata and its declaration, and left the write state's cached writer pointing at what it had just removed. Writes after it in the same transaction kept appending: the rows land in a projection storage whose catalog rows are gone, and the transaction commits with an orphan. Reproduced before fixing, and the control is what identifies the cause: drop in its OWN transaction 0 orphan storage ids drop MID-transaction 1 orphan storage id Same fix, already in this branch and callable. MY FIRST ATTEMPT TO REPRODUCE THIS SAID "CANNOT REPRODUCE", and the reason is worth the comment it now carries in the suite. My orphan query excluded row-group storage ids present in pgcolumnar.storage -- but a projection's storage is registered there too, so the filter hid the exact row the arm exists to find. Two fixtures, both 0, before the catalog dump showed the row sitting there. A reviewer's finding I could not reproduce turned out to be my instrument. The count is also scoped to ONE relation. A database-wide count is not independent: the first arm's orphan is still present when the control runs, so the control failed for the previous arm's reason. That version read as a stronger removal proof than it was -- two arms red instead of one. Removal proof, one .so per arm, mutation asserted applied: unmutated 73 + 0 drop_projection's reset removed 72 + 1 the mid-transaction arm only The own-transaction control stays green under the mutation, which is what pins this to the cache rather than to drop_projection's own cleanup. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017V7PhZ1TzoVVNsACFXTbdT
Verified at
|
OffgridwithJD
left a comment
There was a problem hiding this comment.
Approved at dcdccc4.
Both defects reproduce on main and are gone here, and every number either of us published about this branch I re-derived independently — one .so per arm, each mutation asserted applied and read back out of the file.
projections.sh unmutated 73+0 drop_projection's reset removed 72+1
FAIL a mid-transaction drop leaves no
orphan projection storage (that arm ONLY)
arrow_import.sh unmutated 72+0 Int removed 68+4 FSB removed 71+1
Int refuses everything 59+13, including BOTH `control:` arms
The last row is the one your own proof did not make: you showed the guards fire; that shows they cannot fire indiscriminately. A guard that refuses its whole family cannot get out of that suite.
The drop_projection mirror is closed and I checked the placement concern it raises rather than assuming it. PgColumnarResetProjectionWritersForRelation flushes before it clears, and in drop_projection that flush now runs after the projection's catalog rows are gone — so anything still buffered could land in storage that no longer has catalog rows. It does not:
dcdccc4 5ad3676 (pre-fix)
50 rows buffered 0 orphans 1 orphan
5000 rows buffered 0 orphans 1 orphan
40000 rows buffered 0 orphans -
drop in its own transaction 0 orphans -
add_projection mid-txn (#875) 100/100 rows -
The pre-fix column is the load-bearing part. Zeros from a query I had just rewritten prove nothing on their own, and running the same query where the answer is known non-zero is the only thing that makes the zeros mean anything.
On #881's scope: I could not break the narrowing. fixed_size_binary(32) into bytea and decimal128(10,2) into unconstrained numeric are both already refused by the buffer-count check — two buffers where Binary and Utf8 need three — which is the same upstream check that made your first FSB fixture undiscriminating. A suspected false refusal also dissolved: arrow_kind_for_type returns A_UTF8 for numeric with no typmod, so the Decimal case breaks out before the scale comparison. Two constructed cases, not an enumeration of the tag/kind matrix. And is_signed absent → false → refused is correct rather than over-strict: FlatBuffers gives an absent bool its default, and a writer emitting a signed integer must set it because true is non-default.
Gate: live head re-read from the API and required to equal the sha I measured, with pending == 0, fail == 0 and pass == total, inside the same conditional as this approval. 12/12.
Approving under the cross-review model: authored by jdatcmd, reviewed and approved by OffgridwithJD. I merge nothing, and the alpha3 tag is yours to cut.
The two release blockers for alpha3. Both make a correct-looking operation produce wrong data
with no error. Both were reproduced on
mainbefore anything changed, and each arm goes red whenits own fix is reverted.
#875 — a projection created mid-transaction missed the writes after it
Measured on
main: 116 rows in the base table, 105 in the projection. No error. A coveringprojection scan then answers as though the missing rows were never inserted.
PgColumnarProjectionFanoutRowbuilds the write state's projection-writer list on first use andlatches it — including when the list comes back empty, which is what it is before the
projection exists.
add_projection()now drops that cache, after the back-fill, so what theback-fill sees is unchanged and only what follows it is affected.
#881 — an Arrow import ignored the width, sign and scale the file declared
imp_apply_fieldinspected onlyDate,TimeandTimestamp. Everything else took its strideand interpretation from the target column:
uint64bigintint64intdecimal128(10,2)numeric(20,4)fixed_size_binary(32)uuidAll four imported without an error, and the wrong values are persisted. Refused now with
42804.The existing buffer-length check already caught files whose carrier is narrower than the
target. These are the same-width-or-wider cases, where the buffer is long enough and nothing
complained.
Scope is deliberately within a family. An
int64read into atimestampas raw microsecondsis long-standing accepted behaviour with its own control in this suite. My first attempt broke
that control; cross-family behaviour is unchanged.
Removal proofs
One
.soper arm, every mutation asserted applied.test/projections.sh— 71 checks:test/arrow_import.sh— 72 checks:uint64/int64return00000— the silent caseXX001, not the accurate code00000The FSB arm exists because the first version of this PR had none. Removing that check
reddened nothing: the narrowing case an obvious arm would reach for is already caught by the
buffer-length check, so it cannot tell the guard from its absence. The arm that discriminates
uses a wider carrier — 32 bytes into
uuid's 16 — where the buffer is long enough and thereader silently takes the first half. Four
00000-returning controls sit beside the refusals sothe guard cannot pass by refusing everything.
Also here, from the same release review
projection "p" does not existerror now carries a hint namingpgcolumnar.rebuild_projections(), which recovers it. The old message denied a declarationthat is intact. The underlying re-recording stays open as read_projection raises 42704 after any rewrite: projection rows keep the retired storage id #876.
not until test: the visibility map is cleared wherever LIVE rows are renumbered (#877) #878. Corrected.
🤖 Generated with Claude Code
https://claude.ai/code/session_017V7PhZ1TzoVVNsACFXTbdT