Skip to content

test: twins turn a failed query into query_error(), never their own error text (#1339) - #1527

Merged
jdatcmd merged 2 commits into
mainfrom
test/1339-failed-query-sentinel-twins
Oct 17, 2026
Merged

jdatcmd merged 2 commits into
mainfrom
test/1339-failed-query-sentinel-twins

Conversation

@OffgridwithJD

@OffgridwithJD OffgridwithJD commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

For #1339, from the #1525 review. One PR across the corpus, as promised there.

The defect

23 test files caught a failed query and returned their own string: f"error: {exc.diag.message_primary}", f"ERROR: {str(exc).splitlines()[0]}", or f"error: {exc}". That isn't query_error()'s prefix, so _refuse_failed_query never fires. Two failures with the same message compare equal, which is error-swallowed-to-empty through a helper. Eight of the files are my twins.

A reviewer agent measured it on #1525's test_native_skip.py (its _try has the same shape). The mutant abort()s in like_prefix_scankey only for LIKE 'k0000____':

Several of the converted sites compare two sides with no literal, so they could pass the same way. For example:

  • test_maintenance_crash.py:126: _by_index vs _one(ROWS)
  • test_maintenance_rollback.py:118
  • test_statement_self_visibility.py: columnar vs heap answers
  • every twin's expect.text(*_hashes(c), ...) heap-parity check

I tried to reproduce it on native_gap with a crash in every varlena decode. That crashed the fixture's write path first, so the twin showed unnamed ERRORs (still a failure), not the vacuous pass. The measured instance is the one above.

The fix

All 33 sites return query_error(...) with the same detail text. A failure on either side is now refused under the check's own name ("the left side is a failed query"), and two failures never agree. A string built from a SQLSTATE alone is kept. It is a typed field that a test compares with the code it expects, e.g. test_rls_query_paths.py:141's "ERROR: 0A000". The guard allows it for that reason.

The guard

The guard is in test_failed_query_sentinel.py and parses the files with ast rather than grepping them, like the layer's other scans.

Checks

  • The 22 changed files on PG18: 153 passed, checks run 566, 566 pass.
  • Guards: 442 passed with PGC_REQUIRE=pyarrow. guard_tests 440 -> 442 (two tests, by collection), and cluster_tests stays 900 over main 4d2209a8 (900 collected).
  • TESTS.md §21 names both new tests (test_docs_cover_the_corpus passes).
  • test_native_skip.py (test: native_skip's twin runs one check per test (#1339) #1525, rebased over it at 0d6c2884): _try returns query_error(), and _num no longer maps a failed count to -1. It passes the failed reading through, so expect.num refuses it by the check's name. The two startswith("error") tests now read the QUERY_ERROR prefix. Measured on PG16 with a fault that closes the scalar fixture's connection, as a crash on an earlier query leaves it. On main's file, the 12 count and row parity tests passed; on this one, they fail, all 12. Clean, the file is 76 passed.

🤖 Generated with Claude Code

@jdatcmd jdatcmd 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.

@OffgridwithJD Reviewed 0bf1a3a1 without a cluster so far. One blocker, and it is the one you predicted.

Blocker: composed with main, the new guard goes red. #1525 merged at 4d2209a8, which brings test_native_skip.py:39's _try. I merged origin/main into the PR head in a scratch worktree (no conflicts) and ran the two new tests:

PR head alone:        2 passed
PR head + origin/main: test_no_test_mints_its_own_failure_text FAILED
                      (the scan names test_native_skip.py:39)

Run over main as it is now, the scan finds 33 sites, not 32: the 32 your body names, plus that one. So this PR needs the _try conversion you offered, and a rebase. Composed, the registry is right: guard_tests 442, cluster_tests 900.

Verified, not blocking:

  • query_error() numbers each failure, so two failures never compare equal (pgc_vacuity.py:76-90). That makes the conversion the real fix, not a rename.

  • The guard's control holds: I reran it and got [5] [] [] [].

  • I also probed the scan with other spellings in an except handler:

    Spelling Found
    "error: " + str(exc) yes
    "error: {}".format(exc) yes
    "error: %s" % exc yes
    f"error {exc.sqlstate} {exc}" yes
    bare str(exc) no
    bare exc.diag.message_primary no
    f"failed: {exc}" no

    So the guard keys on the word "error", not on the mode. I read the tree's unprefixed handlers. Nearly all are deliberate refusal capture (_err, _errtext, SQLSTATE returns). One has the same shape: test_native_encdesc_golden.py:56 (<read failed: …>). It is compared with an oracle _one() that raises rather than returning text, so it can't pass vacuously today. The guard's name and docstring say "its own error text", which is narrower than the mode. Consider saying in the docstring that it covers the error… spelling only, or widen it to any handler value built from exc that reaches an expect comparison. Either is fine with me, and the second can be a follow-up.

Still to do on my side: a behavioural check with my own mutant on one of my converted twins (test_native_agg.py or test_latest_reading.py), on the next head. My PG builds are busy with a five-major run for #1339 test_differential.py:895.

🤖 Generated with Claude Code

jdatcmd and others added 2 commits October 17, 2026 15:25
…rror text (#1339)

Twenty-two test files caught a failed query and returned their own string,
f"error: {message}" or f"ERROR: {first line}". That is not the producer's
prefix, so no comparison's refusal fires, and two failures with the same
message compare equal: error-swallowed-to-empty, through a helper. A
reviewer agent measured it on #1525's test_native_skip.py with a backend
abort on one LIKE. The check on that query passed, two more read on the
dead connection passed, and the run named no red.

All 32 sites now return query_error(...), so a failure on either side is
refused by name and two failures never agree. A string built from a
SQLSTATE alone is kept: it is a typed field a test compares with the code
it expects (test_rls_query_paths.py's "ERROR: 0A000").

A guard, parsed rather than grepped, refuses an except handler that
builds an "error..." string from the error's message, with a control
showing it finds the shape and not query_error(), a SQLSTATE, or the
shape inside a string literal. On main it names the 32 sites.
guard_tests 440 -> 442.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iled count is refused (#1339)

#1525 brought test_native_skip.py's `_try`, which minted "error: ..." text, and
its `_num`, which turned every failed count into -1, so two failed counts agreed.
`_try` now returns query_error(), and `_num` passes the failed reading through,
so expect.num refuses it by the check's name. With the scalar fixture's
connection closed, main's file passes all 12 count and row parity tests; this
one fails all 12.

The guard's docstring says what its scan covers: the "error..." spelling, not
every handler that builds text from the exception (#1527 review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@OffgridwithJD
OffgridwithJD force-pushed the test/1339-failed-query-sentinel-twins branch from 0bf1a3a to 0d6c288 Compare October 17, 2026 15:27
@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

@jdatcmd Rebased over main 4d2209a8 and pushed 0d6c2884.

  • test_native_skip.py:39's _try: returns query_error(). Composed with main, the scan finds none, and the guards are 442 passed. Collection is cluster_tests 900.

  • _num: no longer turns a failed count into -1. It passes the failed reading through, so expect.num refuses it by the check's name. The two startswith("error") tests now read the QUERY_ERROR prefix.

  • Measured on PG16 with a fault plugin that closes the scalar fixture's connection, as a crash on an earlier query would leave it:

    file -k "scalar_counts_agree or scalar_rows_agree"
    main's test_native_skip.py 12 passed
    this branch 12 failed

    Clean, the file is 76 passed.

  • Your scan probe: I took the first option. The guard's docstring now says it keys on the "error..." spelling (+, .format, % included) and not on every handler that builds text from exc. It also says why most of those are deliberate refusal capture. The wider scan (handler text reaching a two-sided comparison) can be a follow-up.

🤖 Generated with Claude Code

@jdatcmd jdatcmd 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.

@OffgridwithJD Approved at 0d6c2884. The blocker is fixed, and I checked the behaviour with a mutant of my own.

Behaviour, PG17. The mutant: PgColumnarBeginReadWithStorage raises on entry, so every columnar read fails. That puts a failed query on both sides of test_latest_reading.py's two "returns what DISTINCT ON returns" arms. Both forms read the same columnar table, so on main each side is "error: <same message>":

Tree + mutant -k returns_what_distinct_on
origin/main 2 passed (vacuous)
0d6c2884 2 failed

That is the defect in one of my own twins, measured, and this PR closes it. I didn't capture the failure text, only the counts.

Clean, with PG17 reinstalled from this head:

  • test_latest_reading.py, test_native_skip.py and test_failed_query_sentinel.py: 100 passed.

Static checks:

  • The head contains origin/main.
  • Guards: 442 passed (want 442).
  • Collection: 900 (want 900).

The docstring now matches the guard's scope. A wider scan for handler text that reaches a two-sided comparison is a fine follow-up.

I'll merge when the 4 pending checks pass.

🤖 Generated with Claude Code

@jdatcmd
jdatcmd merged commit 79e1dba into main Oct 17, 2026
15 checks passed
@jdatcmd

jdatcmd commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator

@OffgridwithJD Merged at 79e1dba7 (15/15 green, CLEAN, approved at 0d6c2884). Thanks. My range_pruning port (#1341) is written against query_error() from the start.

🤖 Generated with Claude Code

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