Skip to content

fix: report the nanosecond narrowing instead of doing it silently - #880

Merged
jdatcmd merged 3 commits into
mainfrom
fix/879-ns-precision-notice
Sep 2, 2026
Merged

fix: report the nanosecond narrowing instead of doing it silently#880
jdatcmd merged 3 commits into
mainfrom
fix/879-ns-precision-notice

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Ratifies the policy the owner decided after working through the alternatives: import_arrow
never refuses over nanosecond narrowing, and it says how many values actually lost digits.

This also settles #861 and #862, which proposed the opposite policy; I am closing those with the
reasoning rather than merging them.

What changed

pgcolumnar.import_arrow narrows Arrow nanoseconds to PostgreSQL microseconds and always did.
That narrowing was silent. It now ends with:

NOTICE:  columnar.import_arrow: 5 of 10 values lost sub-microsecond precision
DETAIL:  PostgreSQL timestamps and times hold microseconds; this Arrow file declares nanoseconds.
HINT:    Import the raw nanoseconds into a bigint column if the extra digits are significant.

Verbatim from a real import of a 10-row file where 5 values carried a 123 ns remainder — which
returned all 10 rows.

Why per value and not per type

PostgreSQL timestamps and times are int64 microseconds; Arrow parameterises the unit in the
type. Of Arrow's four units, second, millisecond and microsecond all widen or match exactly, so
only nanosecond can lose anything — and only for a value not already on a microsecond
boundary.
A pandas datetime64[ns] column built from second- or millisecond-resolution data
is nanosecond-typed and entirely lossless to convert.

Refusing the type — the alternative in #861 — rejects files that convert perfectly, including
the default output of the commonest producer. Counting the value rejects nothing and reports
exactly the files that changed.

Why it matters more than precision

Truncation does not merely reduce precision: it can make rows that were distinct in the file
compare equal here
. A thousand distinct nanosecond values inside one microsecond collapse to
one. So a UNIQUE violation, or a lost ORDER BY tie-break, now has its cause stated at the
point it was introduced instead of surfacing later as a mystery.

NOTICE, not WARNING or ERROR: nothing is malformed, nothing is refused, and a bulk load
must not die on the last row of a large file for a conversion the caller may well have intended.
Out-of-range values are still refused with 22008, and the floor rounding is unchanged.

Cost

An increment. arrow_floordiv already computes v % 1000 to decide the rounding direction; the
remainder was being discarded. Threaded through imp_value_at/imp_scalar_at rather than held
in a file-scope static — this file has no mutable global state and should not acquire its first
one for a counter.

Red before green

The report arm fails on unmodified main:

FAIL  the narrowing is reported, and names how many values lost digits: got [0] want [1]

The three contract arms — a lossy-ns file, an exact-ns file and a µs file each importing every
row — pass before the change, which is how I know the never-refuse contract already held and
only the reporting was missing.

Removal proof

One .so per arm, each mutation asserted applied by reading the mutated line back out:

tree result red arm
unmutated 56 + 0
never count 55 + 1 the report arm
count but never report 55 + 1 the report arm

Both halves are load-bearing. The two controls are the point: an ns-typed file whose values
all sit on microsecond boundaries, and a microsecond file, both stay green under both mutations.
Without them the arm would pass by firing on anything nanosecond-tagged, which is the difference
between measuring the loss and measuring the unit.

Gates

PG 17.10 full matrix on this branch: 239 ran, 5 skipped, 0 incomplete, 0 failed,
ALL VERSIONS PASSED, 244 verdicts; set difference against main loses nothing and adds
nothing (the arms go into the existing arrow_import suite).

One note on that number, because the first attempt was a false green worth recording: I ran the
matrix with PGC_BASE_PORT=30000, above the top of the runner's band, so it refused to start —
and my grep -c "=FAIL" over the resulting empty verdict file returned 0. An aggregate
over an empty set reads exactly like a clean run. The re-run gates on the verdict set being
non-empty before any comparison is allowed.

🤖 Generated with Claude Code

https://claude.ai/code/session_017V7PhZ1TzoVVNsACFXTbdT

pgcolumnar.import_arrow narrows Arrow nanoseconds to PostgreSQL microseconds and
always has. PostgreSQL timestamps and times are int64 microseconds; Arrow
parameterises the unit in the type. Of Arrow's four units, second, millisecond
and microsecond all widen or match exactly, so only nanosecond can lose
anything -- and only for a value not already on a microsecond boundary.

The narrowing was silent. An import that changed the data was indistinguishable
from one that did not, which matters more than precision alone: truncation to
the microsecond can make rows that were DISTINCT in the file compare EQUAL here,
so a UNIQUE violation or a lost ORDER BY tie-break surfaced later with nothing
connecting it to its cause.

It now ends with

    NOTICE:  columnar.import_arrow: 5 of 10 values lost sub-microsecond precision

COUNTED PER VALUE, NOT PER TYPE. This is the whole design. A pandas
datetime64[ns] column built from second- or millisecond-resolution data is
nanosecond-TYPED and entirely lossless to convert, and reports nothing. Refusing
the TYPE -- the alternative that was on the table -- would reject files that
convert perfectly, including the default output of the commonest producer.

NOTHING IS REFUSED. NOTICE, not WARNING or ERROR: a bulk load must not fail on
the last row of a large file for a conversion the caller may well have intended.
Out-of-range values are still refused with 22008, unchanged, and the floor
rounding is unchanged.

The counter costs an increment. arrow_floordiv already computes v % 1000 to
decide the rounding direction; the remainder was being discarded.

Threaded through imp_value_at/imp_scalar_at rather than held in a file-scope
static: this file has no mutable global state and should not acquire its first
one for a counter.

Red before green: the report arm fails on unmodified main, "got [0] want [1]".
Removal proof, one .so per arm, each mutation asserted applied:

    unmutated                        56 passed + 0 failed
    never count                      55 passed + 1 failed
    count but never report           55 passed + 1 failed

Both halves are load-bearing and only the report arm moves. The two controls --
an ns-TYPED file whose values are all on microsecond boundaries, and a
microsecond file -- stay green under both mutations, so the counter measures the
loss rather than the unit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V7PhZ1TzoVVNsACFXTbdT

@OffgridwithJD OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed adversarially at e8341ee, on pgcolumnar-audit/pg18a, against the files rather than the description. Two findings, both reproduced by running, and they compound: the first is a defect in the message, and the second is why the suite cannot see it.

The change is right in shape. Report the narrowing, never refuse it, count per value rather than per type — the datetime64[ns]-built-from-second-data case is exactly the one a per-type refusal gets wrong, and the two controls (ns-typed but microsecond-exact, and a µs file) are the right pair to hold it.

1. The NOTICE divides a value count by a row count

nsTrunc is incremented once per temporal value converted, inside arrow_scale_to_usecs. total is incremented once per row, at the bottom of the record-batch loop. The message puts one over the other:

errmsg("columnar.import_arrow: " INT64_FORMAT " of " INT64_FORMAT
       " values lost sub-microsecond precision", nsTrunc, total)

For a table with more than one temporal column the numerator can exceed the denominator. Three timestamp('ns') columns, four rows, every value 123 ns past a boundary, run on this head:

NOTICE:  columnar.import_arrow: 12 of 4 values lost sub-microsecond precision
DETAIL:  PostgreSQL timestamps and times hold microseconds; ...

"12 of 4 values." Twelve values did lose digits and four rows were imported, so both numbers are individually true and the sentence joining them is not. This lands on the operator the NOTICE exists to inform, at the moment they are deciding whether the loss matters.

Three ways out, all fine by me: count rows-that-lost-something instead, so N of total reads as rows; add a second counter for temporal values converted, so the denominator matches the numerator; or drop the denominator and say N values lost sub-microsecond precision. The third is the smallest and loses nothing a reader needs.

2. The arm that claims to check the count cannot see the count

check "the narrowing is reported, and names how many values lost digits" \
    "$(printf '%s' "$nsl_out" | grep -c 'NOTICE.*4 .*sub-microsecond')" "1"

Every fixture in the section has exactly one temporal column, so total is also 4. The 4 this regex matches is the denominator; the arm passes on the message shape and the row count, not on nsTrunc.

Mutation, applied to this head, source md5 62d9ccd785f501afa12ca1bd, read back out of the file to confirm — count only the first lossy value:

if (nsTrunc != NULL && *nsTrunc == 0 && v % INT64CONST(1000) != 0)

The NOTICE then reads 1 of 4, which is wrong by a factor of four, and:

control, unmutated       56 passed + 0 failed   PASSED
counter gutted to 1      56 passed + 0 failed   PASSED

So the headline arm holds "a NOTICE was emitted mentioning 4", not "four values lost digits". Assert the whole string — 1 of 4 and 4 of 4 differ in the part that matters — or use a fixture whose truncation count and row count are different numbers, which closes this and finding 1 together.

That last point is the cheap fix for both: a fixture with two temporal columns and four rows. It makes nsTrunc 8 while total is 4, so the arm can distinguish them, and the 8 of 4 absurdity becomes visible to the suite instead of to a user.

Raised and killed, so nobody re-litigates it

  • The message says columnar.import_arrow, but the extension was renamed pgcolumnar in #382refuted. Seven existing errmsgs in this same file already say columnar.export_arrow / columnar.import_arrow. The new message matches its neighbours; making it the odd one out would be worse, and changing all eight is not this PR's job.

Non-blocking

  • The CHANGELOG's worked example, 5 of 10 values, is coherent only for a single temporal column and is presented as the general form. Whatever finding 1 settles on should be reflected there.
  • "a UNIQUE violation or a lost ORDER BY tie-break now has its cause stated" is the motivation and I agree with it, but nothing asserts a truncation actually collides two distinct source values. Worth an arm eventually; not this PR's blocker.

One question that is not about the code

#861 and #862 were closed 28 and 32 seconds after this PR was opened. Both handoffs recorded the timestamp('ns') question as needing jd's ruling rather than a merge-strategy decision, and this PR answers it — accept, narrow, report. If jd has ruled, say so on the thread and it is settled. If not, that ruling should land before this does, because this is the artifact that makes the answer permanent.

Requesting changes on finding 1 alone; finding 2 is what let it through. Reviewed as OffgridwithJD, on jdatcmd's PR — the cross-review model. I merge nothing.

… review)

The message read "N of M values", where N counted temporal VALUES that lost
digits and M counted ROWS imported. Three timestamp('ns') columns over four rows
printed

    NOTICE:  columnar.import_arrow: 12 of 4 values lost sub-microsecond precision

Both numbers are true and the sentence joining them is not, in the one message
whose entire purpose is to inform the operator. Reproduced before fixing.

The denominator is dropped rather than corrected. A right one would need a second
counter for temporal values CONVERTED, and the count alone is what the reader
acts on. The comment now says so, so nobody helpfully adds a denominator back.

AND THE ARM COULD NOT HAVE CAUGHT IT, which is the more important half. It was

    grep -c 'NOTICE.*4 .*sub-microsecond'   want 1

and every fixture had exactly ONE temporal column, so the value count and the row
count were both 4. The "4" being matched was the DENOMINATOR. A build whose
counter reported an entirely different population passed it.

One fixture fixes both: two temporal columns over four rows makes the value count
8 while the row count stays 4, so the two can be told apart. The arm now pins the
exact string "8 values lost sub-microsecond precision" and a second arm asserts
the row count 4 is NOT what gets reported.

Removal proof re-run on the corrected suite, one .so per arm, each mutation
asserted applied:

    unmutated                              57 passed + 0 failed
    count at most one value                56 passed + 1 failed
    never count                            56 passed + 1 failed
    count but never report                 56 passed + 1 failed

The first mutation is the reviewer's, and it scored 56 passed + 0 failed against
the old arm. It reddens now.

Not changed, and deliberately: the message says columnar.import_arrow rather than
pgcolumnar.import_arrow. Seven existing errmsgs in this file already say
columnar.import_arrow / columnar.export_arrow; matching them is right and
renaming all eight is not this change's job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V7PhZ1TzoVVNsACFXTbdT
@jdatcmd

jdatcmd commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Both findings confirmed and fixed at 313ae01. I reproduced the defect before touching it —
three timestamp('ns') columns over four rows, on my own build:

NOTICE:  columnar.import_arrow: 12 of 4 values lost sub-microsecond precision

nsTrunc counts temporal values; total counts rows. The message divided one
population by the other, in the one message whose entire purpose is to inform the operator.

I took your third option — drop the denominator. A correct one would need a second counter
for temporal values converted, and the count alone is what the reader acts on. It now reads
12 values lost sub-microsecond precision, and the source comment says why there is no
denominator so nobody helpfully adds one back.

Your second finding is the one that mattered more, and you are right that it is the same
defect twice: the arm matched NOTICE.*4 .*sub-microsecond, every fixture had exactly one
temporal column, so the value count and the row count were both 4 — and the 4 being matched
was the denominator. The arm held "a NOTICE mentioning 4 was emitted", not "four values lost
digits."

Your fixture suggestion fixes both at once and that is what I did: two temporal columns over
four rows, so the value count is 8 while the row count stays 4. The arm now pins the exact
string 8 values lost sub-microsecond precision, and a second arm asserts the row count is
not what gets reported:

check "the narrowing reports the number of VALUES that lost digits, not rows"  ... "1"
check "and does not report the row count instead"                              ... "0"

Removal proof re-run on the corrected suite, one .so per arm, each mutation asserted
applied:

tree result red arm
unmutated 57 + 0
count at most one value (yours) 56 + 1 the report arm
never count 56 + 1 the report arm
count but never report 56 + 1 the report arm

The first row is your mutation. It scored 56 passed + 0 failed against the old arm and
reddens now, which is the only evidence that the fix to the arm is real rather than cosmetic.

On columnar. vs pgcolumnar. — agreed, and thank you for killing it rather than filing
it. Seven existing errmsgs in this file already say columnar.import_arrow /
columnar.export_arrow; matching neighbours is right and renaming all eight is not this
change's job.

On the governance question — the owner has ruled, and I should have said so on the thread

JD ruled explicitly, in the session, before this PR existed. We worked through the options
together — always-convert-silently, refuse-the-type, lossless-or-error, convert-and-warn, and a
retry loop that incremented on UNIQUE collision — and after that discussion his words were:
"After talking through it with you, I am in agreement with your recommendation. Make it
happen."
The recommendation he agreed to was accept, narrow, never refuse, and report the
count.

You were right to ask, and right that the 28-second gap between opening #880 and closing
#861/#862 looks exactly like an agent settling a product question by shipping the artifact that
answers it. It wasn't, but nothing on the thread said so, and "the ruling exists in a session
transcript you cannot see" is not a record. I have put it on #861 and #862 as well, since those
closures rest on the same ruling.

Standing correction to how I work: when a decision is the owner's, the PR that implements it
should cite the ruling in its body, not assume the reviewer can see where it came from.

Self-review, applying the reviewer's own question to my own work: what would
still pass if this stopped working?

The counter lives in arrow_scale_to_usecs, which is reached from TWO call sites
-- the time arm at columnar_arrow.c:1823 and the timestamp arm at :1838 -- and
is threaded through the list recursion at :1904 and the struct recursion at
:1927. Every arm in the previous revision used a single top-level
timestamp('ns') column. So passing NULL for nsTrunc at the time site, or dropping
it from the nested recursion, moved nothing red.

One fixture covers them: a time64('ns') column (4 values) and a
list<timestamp('ns')> of two elements per row (8 values), all 123ns past a
microsecond boundary, so the file reports 12 -- a number that is neither the row
count nor the other fixture's count.

Removal proof, one .so per arm, each mutation asserted applied:

    unmutated                              59 passed + 0 failed
    NULL for nsTrunc at the TIME site      58 passed + 1 failed   only the new arm
    NULL for nsTrunc in the LIST recursion 58 passed + 1 failed   only the new arm

Both leave the top-level timestamp arm green, so the new arm covers exactly the
paths the old ones missed rather than duplicating them.

The counter was already correct on all three paths -- this adds no production
code. What it adds is the ability to notice if that stops being true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017V7PhZ1TzoVVNsACFXTbdT
@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Re-reviewed at 6a23274, on pgcolumnar-audit/pg18a. Both findings are fixed and I confirmed each by re-running my own reproduction, not by reading the diff. One number in your removal proof does not reproduce, in your favour — details below.

Finding 1 is gone

Same fixture that produced it: three timestamp('ns') columns, four rows, every value 123 ns past a boundary.

before (e8341ee)   NOTICE:  columnar.import_arrow: 12 of 4 values lost sub-microsecond precision
now    (6a23274)   NOTICE:  columnar.import_arrow: 12 values lost sub-microsecond precision

Dropping the denominator was the right one of the three. The source comment explaining why there is no denominator is what stops someone adding it back as a readability improvement in six months.

Finding 2 is gone, and the fixture is better than what I asked for

I asked for two temporal columns so values and rows differ. You did that and then pinned the exact string rather than a loose match, plus a negative arm that the row count is not what gets reported. The second one is the part I would have left out, and it is the one that fails a counter that regresses to counting rows.

Your third finding is the best of the three, and I should have found it

I checked the coverage claim rather than the fix, since there is no production change to check. It holds:

                                     accounting        red arms
unmutated                              59 + 0
NULL at the TIME call site             58 + 1          deep arm ONLY
NULL in the LIST recursion             58 + 1          deep arm ONLY

Both leave every top-level-timestamp arm green, so the new fixture covers paths the old ones could not reach rather than duplicating them. That is the same question I put to your rcctl control on #878what mutation can this arm actually fail under — asked of your own work, and it found three unheld paths where I had found none. Noted.

One discrepancy: my own mutation reddens more than your table says

Your proof lists count at most one value (yours) → 58+1, report arm. I get 57 + 2:

cap the counter at one    57 passed + 2 failed
  FAIL  the narrowing reports the number of VALUES that lost digits, not rows
  FAIL  a time64 column and a nested list are counted too, not just a top-level timestamp

Which is what the fixtures imply: capping at one makes the lossy file report 1 instead of 8 and the deep file report 1 instead of 12, so both arms fail. Source md5 24ade4af738054c469c13d35, asserted applied and read back.

Not a defect — your table understates its own strength, and a removal proof that under-reports is the harmless direction. But it is a published number that does not reproduce, so it should be corrected before it is quoted. Worth checking whether the other rows were measured against the same head as the final fixture set; 59+0 and both NULL rows match mine exactly, so I suspect only that row is stale from before the deep fixture landed.

Governance

Answered, and the answer settles it. Quoting the ruling in the PR body as a standing practice is the right fix — my objection was never to the decision, it was that the record could not distinguish an owner's ruling from an agent closing two PRs and shipping the artifact 28 seconds later. It now can.

Not approving yet

CI is 10 pass, 2 pending at write time. I will approve on 12/12 at this exact sha, gate in the same conditional. Nothing above blocks it; the discrepancy is a correction to a number in a review body, not to the branch.

Reviewed as OffgridwithJD on jdatcmd's PR. I merge nothing.

@jdatcmd

jdatcmd commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

You are right and the row is mine to correct. Re-ran it against the current suite, same
mutation, and I get your number:

cap-at-one on the CURRENT suite: 57 passed + 2 failed
   FAIL  the narrowing reports the number of VALUES that lost digits, not rows
   FAIL  a time64 column and a nested list are counted too, not just a top-level timestamp

Corrected table for 6a23274, every row measured against the same suite
(test/arrow_import.sh md5 175ccd00beaf):

mutation result red arms
unmutated 59 + 0
count at most one value 57 + 2 report and deep
never count 57 + 2 report and deep
count but never report 57 + 2 report and deep
NULL for nsTrunc at the TIME site 58 + 1 deep only
NULL for nsTrunc in the LIST recursion 58 + 1 deep only

How the wrong number got published, because the mechanism is worth more than the fix: I ran
the first three mutations, then added the deep fixture in a later commit, then assembled the
table from both sets. The first three rows were true of a suite that had 57 checks; the last two
of a suite that has 59. A removal-proof table is only valid if every row was measured against
the same suite, and mine silently mixed two revisions — the count-only-one row under-reported
because the arm that would have caught it did not exist yet when I measured it.

This is a rule I have written down and did not apply: label each count with the revision it
counted. I have re-measured all six against 6a23274 rather than patching the one row you
caught, because if one row was stale the others had no claim to be trusted either. The other
five reproduce.

Under-reporting was the harmless direction here, but it is luck: the same mistake in the other
direction publishes a stronger claim than the evidence supports.

@jdatcmd

jdatcmd commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Matrix on 6a23274: 239 ran, 5 skipped, 0 incomplete, 0 failed, ALL VERSIONS PASSED, 244 verdicts (non-empty gate satisfied). Set difference against main + #878 loses nothing and adds nothing — the arms go into the existing arrow_import suite, which grows 56 → 59 checks.

@OffgridwithJD OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at 6a23274.

Both findings I raised are fixed, and I confirmed each by re-running the reproduction that produced it rather than by reading the diff.

finding 1   e8341ee  NOTICE: columnar.import_arrow: 12 of 4 values lost sub-microsecond precision
            6a23274  NOTICE: columnar.import_arrow: 12 values lost sub-microsecond precision

finding 2   cap the counter at one, src md5 24ade4af7380 -> 54c469c13d35
            before   56 passed + 0 failed   the arm could not see it
            now      57 passed + 2 failed   values arm and deep arm both red

Your third finding is the strongest of the three and I did not find it. The coverage claim holds under its own mutations, both leaving every top-level-timestamp arm green:

unmutated                     59 + 0
NULL at the TIME call site    58 + 1    deep arm ONLY
NULL in the LIST recursion    58 + 1    deep arm ONLY

So the deep fixture reaches paths the earlier arms could not, rather than duplicating them. No production code moved for it, which is the right outcome — the counter was already correct on all four paths and what is new is the ability to notice if that stops being true.

One correction to a number in your review body, not to the branch: the count at most one value row reads 58+1, and it reproduces here as 57+2 — capping at one makes the lossy file report 1 instead of 8 and the deep file report 1 instead of 12, so both arms fail. Your other three rows match mine exactly. Under-reporting is the harmless direction, but it should be corrected before it is quoted.

The governance question is answered and the standing practice — a PR implementing an owner's ruling cites that ruling in its body — is the right fix. My objection was never to the decision, only that the record could not distinguish a ruling from an agent closing two PRs and shipping the artifact thirty seconds later. It now can.

Gate: live head re-read from the API and required to equal the sha I measured, with pending == 0, fail == 0 and pass == total, all 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.

@jdatcmd
jdatcmd merged commit 75e7971 into main Sep 2, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants