Skip to content

fix: a join whose runtime-filter build is empty no longer reads the fact table (#1447) - #1449

Merged
jdatcmd merged 1 commit into
mainfrom
fix/1447-empty-build
Oct 16, 2026
Merged

jdatcmd merged 1 commit into
mainfrom
fix/1447-empty-build

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1447.

The bug

With a large dimension filtered to no row, a join to a columnar fact table read every fact chunk group for an answer of no rows. The answer was right; the cost was a full read.

Why. When the outer's startup cost is below the hash's total cost, the hash join fetches its first outer row before it builds. So the fact scan ran. The coordinator had already drained the build, found it empty, and attached its Bloom check. That check rejects every row when the build is empty, so the scan rejected its way to the end of the table.

tree, PG17 Chunk Groups Read Runtime Filter Rows Rejected
main 5 of 5 5000
buildEmpty forced false 1 0
this PR 0 0

The fix (src/columnar_runtime_filter.c)

PgColumnarExecRuntimeFilter returns end-of-data as soon as the build is found empty. The coordinator wraps only inner joins (PgColumnarSetJoinPathlist returns for any other jointype), and an inner join with an empty build has no rows. A rescan resets prepared, so each execution decides again.

Evidence

Red first, both twins, PG17:

arm main stale-rescan mutant (prepared = false removed from the rescan)
an empty build reads no fact chunk group red: shell got [5] want [0], pytest got 6 want 0 —
an empty build on one rescan does not empty the next green red: shell ERROR: ... tap read before drain, pytest no rows

The rescan arm guards the reset that the new early return relies on. Its first outer row builds nothing, and its second builds every key.

Premises, so the arms can't pass on nothing:

  • the coordinator ran, and the build really was empty;
  • heap's rescan answer holds its keys (0|200 in the shell, 0|150 in pytest).

Green, PG15 to 19: native_join_runtime_filter.sh 62/62 on each major; test_join_runtime_filter.py 65 checks over 15 tests on each major.

Ledger. Seven rows. an empty build reads no fact chunk group is dated by the tree before the fix, and the rescan arm by the mutation. The other five arrive never. The census is 3571 / 2644 / 927. pgc_ledger.py gate with --registered and --against origin/main returns rc 0, with the ceiling 221 -> 221; orphan-scan reports 0 orphans.

Counts. Collection gives 643 (641 -> 643). The guard set passes (438).

Also

The old small-dimension empty-build arm (#1339's test_join_runtime_filter.py:542, weak) can only fail on a crash, because with a small dimension the hash join never runs its outer side. Its shell comment claimed to catch "a leftover hull from a previous execution", which it can't. The comment now says what the arm can and can't catch, and the new arms carry the real test. I'll tick that #1339 entry when this merges.

🤖 Generated with Claude Code

…act table (#1447)

With a large dimension filtered to no row, the hash join fetches its first
outer row before it builds the hash table, so the fact scan ran. The runtime
filter coordinator had already found the build empty, and the Bloom check
rejected every fact row one at a time. Every chunk group was read for an answer
of no rows: 5 of 5 groups and 5000 rows rejected, where without the check the
scan stopped after one row.

The coordinator wraps only inner joins, and an inner join with an empty build
has no rows. So it now returns end-of-data as soon as it finds the build empty.
A rescan resets prepared, so each execution decides again.

Red first, both twins, PG17: on main the large-dimension empty build reads every
fact group (shell 5, pytest 6, where 0 are required). A new rescan arm, whose
first outer row builds nothing and second builds every key, is red when prepared
is kept across a rescan: an internal error in the shell, no rows in pytest. It
guards the reset the early return relies on. Found while verifying #1339's
weak empty-build entry; the old small-dimension arm can only fail on a crash,
because the hash join never runs its outer side, and its comment now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jdatcmd

jdatcmd commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

@OffgridwithJD, ready for review. It fixes #1447, which I found while verifying #1339's weak empty-build entry: an empty runtime-filter build made the fact scan read every chunk group. Both twins are red first, and the PG15–19 results are in the description. A useful thing to probe: whether any non-inner join can reach the coordinator. My reading is that PgColumnarSetJoinPathlist returns for anything but JOIN_INNER.

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

@jdatcmd Approving b2c6de5. Thanks for taking #1447.

Measured on PG18 (release build, my own prefix and worktree, with the .so md5 printed for each run):

source native_join_runtime_filter.sh test_join_runtime_filter.py
head 62/62 65 checks, 15 tests
the early return removed 61/62: an empty build reads no fact chunk group, got [5] want [0] the same arm, got 6 want 0
the rescan's prepared = false removed 60/62: the new rescan arm, tap read before drain, and rescan answers equal heap the new arm's InternalError_, and lateral rescan answers equal heap
mine: the coordinator allowed to wrap a LEFT JOIN (:331) 61/62: LEFT refusal, got [1] want [0] the same arm

The last row is the one I wanted. The early return is sound only because the coordinator wraps inner joins alone, since an outer join with an empty build still returns its preserved side. That restriction is already pinned by an existing arm, so the new return can't quietly become wrong if someone widens it.

Order: my chain (#1444 → #1446 → #1448, plus item 66 coming next) needs restating onto e04e1e0 anyway. If #1449 lands first, I'll restate onto it in the same pass.

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

An empty runtime-filter build makes the fact scan read every chunk group

2 participants