feat: serial hash-join runtime filter (#752) - #945
Conversation
OffgridwithJD
left a comment
There was a problem hiding this comment.
Reviewed at b175e00a. The design is sound and the refusal set is the right one, but three defects block it. Two of them are invisible on the configuration this was verified on, and the third returns wrong answers.
All measurements below are on the pgcolumnar-audit container, assert builds in /usr/local/pg{15..19}a plus the distro PostgreSQL 18.4.
1. The head does not compile on an assert build of PostgreSQL 16, 17, 18 or 19
src/columnar_runtime_filter.c:544 and :794 use linitial_node(Plan, …). castNode expands to castNodeImpl(T_##_type_, …) only when USE_ASSERT_CHECKING is defined, and Plan is an abstract node, so there is no T_Plan tag to name.
Compiling that one file, PR head unmodified:
| build | version | rc | T_Plan undeclared |
|---|---|---|---|
| pg15a | 15.18 | 0 | 0 |
| pg16a | 16.14 | 1 | 2 |
| pg17a | 17.6 | 1 | 2 |
| pg18a | 18.4 | 1 | 2 |
| pg19a | 19beta2 | 1 | 2 |
| distro | 18.4 (non-assert) | 0 | 0 |
Two configurations hide it, and you used one of them. PostgreSQL 15 still had T_Plan in its handwritten nodes.h enum. A non-assert build never expands the tag at all, because castNode is a plain cast there.
Asking the preprocessor rather than grep, T_Plan is declared on pg15a only; T_HashJoin is declared on all six, which is the control that makes that answer worth reading.
Fix: (Plan *) linitial(customScan->custom_plans). That is what the code means — any Plan subtype.
2. The PR's own primary query aborts the backend on every assert build
PgColumnarReadSetRuntimeRange calls ScanKeyEntryInitialize with procedure = InvalidOid and flags = 0. An invalid procedure is legal only for a NULL-search key.
TRAP: failed Assert("flags & (SK_SEARCHNULL | SK_SEARCHNOTNULL)"), File: "scankey.c", Line: 53, PID: 3782905
ExceptionalCondition
ScanKeyEntryInitialize
pgcolumnar.so(+0x31cfc) <- PgColumnarReadSetRuntimeRange
pgcolumnar.so(+0x37ecf)
pgcolumnar.so(+0x386dc)
...
standard_ExecutorRun
LOG: client backend (PID 3782905) was terminated by signal 6: Aborted
DETAIL: Failed process was running: … SET pgcolumnar.enable_join_runtime_filter=on;
SELECT count(*),sum(f.k) FROM f JOIN d ON f.k=d.k
That is your own suite's first query, on your own fixture. With the filter off the same query returns 200|1620100.
A non-assert build takes the else arm, MemSets sk_func to zero and continues — which is harmless only by accident, because pgcolumnar_make_predicates never reads sk_func (measured: zero occurrences of sk_func in columnar_reader.c).
Fix: set the six fields the builder actually reads directly — sk_flags, sk_attno, sk_strategy, sk_subtype, sk_argument — and drop ScanKeyEntryInitialize. MemSet(keys, 0, sizeof(keys)) above already zeroes the rest.
With only those two changes, your suite is 42/42 on pg18a. So these two lines are the whole distance between this PR and the state you reported; the 42/42 claim is true of a non-assert build and not of an assert build, and this repo requires the assert build.
3. A join with a qual on the fact table silently returns no rows
This is the blocking one. PgColumnarAttachRuntimeBloom turns late materialization on after BeginCustomScan has already decided against it:
if (state->qualCols == NULL)
state->qualCols = palloc0(sizeof(bool) * state->nTotalColumns);
state->qualCols[attno - 1] = true;
state->lateMat = true;When qualCols was NULL, the new array marks only the join key. But the filter the reader then calls, pgcolumnar_scan_row_filter, evaluates ExecQual(ss->ps.qual, …) — the scan's whole qual. PgColumnarReadNextRowFiltered states the contract this breaks, in its own header:
qualCols is [natts] and marks the columns the filter reads. The filter is called with only those decoded; it must not look at any other column.
So the qual is evaluated against columns that were never decoded, and the rows it wrongly rejects are dropped inside the reader, where nothing re-checks them.
SELECT count(*) FROM f JOIN d ON f.k=d.k WHERE …, on pg18a, with the two fixes above applied so the query can run at all. f(k int, a int), 20000 rows, a = k; d holds 8001..8200; true answer 200.
| qual on the fact table | GUCs | filter on | filter off | |
|---|---|---|---|---|
f.k > 0 |
default | 200 | 200 | agree |
f.a > 5 |
default | 200 | 200 | agree |
f.k > 0 AND f.a > 5 |
default | 0 | 200 | differ |
f.a > 5 |
enable_late_materialization=off |
0 | 200 | differ |
f.a > 5 AND f.a <> (random()*(-1))::int |
default | 0 | 200 | differ |
The first two rows are the controls that isolate the cause, and they matter as much as the failures:
f.k > 0is safe because the qual reads nothing but the key, which the attach did decode.f.a > 5is safe becausekwas deferrable, sosetup_late_materializationleftqualColsnon-NULL and the attach took its other branch and added the key to the existing set.
The failures are the three ways qualCols reaches the attach as NULL while the scan still has a qual: nothing deferrable (the qual reads every projected column), the GUC off, and a volatile qual. None of them needs an unusual query; the third row changes no setting at all.
Your own instrumentation names the mechanism and the counters reconcile:
enable_join_runtime_filter=on
Custom Scan (Columnar Runtime Filter Coordinator) (actual rows=0.00)
-> Hash Join (actual rows=0.00)
-> Custom Scan (PgColumnarScan) on f (actual rows=0.00)
Rows Removed by Filter: 210
Columnar Rows Filtered Before Materialization: 1000
Runtime Filter Groups Removed: 19
Runtime Filter Rows Rejected: 790
enable_join_runtime_filter=off
Hash Join (actual rows=200.00)
-> Custom Scan (PgColumnarScan) on f (actual rows=19995.00)
Rows Removed by Filter: 5
Columnar Rows Filtered Before Materialization: 0
The hull removes 19 of 20 groups correctly, leaving 1000 rows. Of those, 790 are rejected by the Bloom filter and 210 reach the qual: 790 + 210 = 1000. All 210 are then removed by k > 0 AND a > 5 — including the 200 rows whose a is 8001..8200. The qual rejected every row it was required to accept, because a was never decoded. Columnar Rows Filtered Before Materialization goes from 0 to 1000 across the two runs, which is the two-pass path being switched on by the attach.
The loss runs one way only. A row the undecoded qual wrongly keeps is re-checked by ExecScan on the complete row, so it is filtered correctly; a row it wrongly drops never reaches ExecScan. Confirmed: with the qual inverted to f.k > 0 AND f.a < 5, whose true answer is 0, both settings return 0. So the symptom is always missing rows, never extra ones, and never an error.
Three ways to fix it, in my order of preference:
- In the attach, when
qualColsis NULL andcscan->scan.plan.qual != NIL, derive the qual's columns exactly aspgcolumnar_setup_late_materializationdoes and then add the key. One decision, in one place, and it makes the attach's promise true. - Refuse the attach in that case, and let the scan run without a Bloom filter. The range filter is unaffected and keeps most of the win.
- Keep the Bloom filter out of the two-pass path: probe it in
PgColumnarScanNextafter the row is built.
Whichever you pick, the attach should stop writing lateMat unconditionally. Turning a path on behind the GUC that disables it is a separate problem from this bug, and row four of the table is that problem showing through.
Why both suites missed it. Neither twin ever puts a local qual on the columnar fact table of a wrapped join. I enumerated every WHERE in both: the shell suite has none on f; the pytest twin's are dimr.k > v.x on the dimension side and factp.k BETWEEN 10 AND 180 on the projection case, where the coordinator deliberately does not wrap the outer. 42 shell checks and 10 pytest checks, and the qual-on-the-fact-table shape appears in none of them. A regression test for this belongs in both twins — and per the two-harness rule, built independently in each.
Smaller things
The comment above PgColumnarAttachRuntimeBloom documents a different function. columnar_customscan.c:3745 heads PgColumnarAttachRuntimeBloom with a comment titled PgColumnarAttachRuntimeRange, describing the hull. PgColumnarAttachRuntimeRange itself, below it, has no comment. Given that the bloom attach is where defect 3 lives, the function whose contract most needed stating is the one whose comment is about its neighbour.
Runtime Filter Rows Rejected undercounts. The reader has two filter callbacks; at columnar_reader.c:2971 it calls nativeQualFilterNoCount when nativeQualCounted is set, and only pgcolumnar_scan_row_filter increments runtimeRowsRejected. Rejections through the no-count arm are therefore invisible in EXPLAIN. I have not measured whether that arm is reachable with a Bloom filter attached, so treat the reachability as unverified; the asymmetry in the two callbacks is plain in the source either way.
The build tap reports 200 rows while EXPLAIN says it never executed. Visible in the plan above: Custom Scan (Columnar Runtime Filter Build Tap) … (never executed) with Runtime Filter Build Rows: 200 beneath it, over a Seq Scan on d with actual rows=200.00. That is the coordinator draining the source directly, which is the right fix for the ExecProcNode(tap) crash you describe — but a node marked never-executed that reports a row count and has a child that did execute will be read as an instrumentation bug by whoever meets it next. Worth a sentence in the EXPLAIN output or the docs.
The cost discount makes this default-on without a measured win. customPath->path.total_cost is the private hash path's cost minus min(outer work, 5%), so the coordinator always beats the core path it wraps, for every qualifying shape. The PR is explicit that it is not measured on the #401 fixture, and the coordinator always pays a full spool of the build side plus a per-row Bloom probe. The precedent here is parallel_flush, which stayed opt-in for exactly this reason: it won on some shapes and the default flip was declined until the shapes were known. I would ship enable_join_runtime_filter defaulting to off and flip it with a measurement, rather than ship a 5% placeholder that decides every plan.
A rescan can leave a stale hull attached. PgColumnarReScanRuntimeFilter detaches the Bloom filter but not the range predicates, and PgColumnarReadSetRuntimeRange's replace arm only runs when a new hull is produced. If the next build side yields no hull, readState->predicates still holds the previous one. I could not turn that into a wrong answer — buildEmpty makes the Bloom filter reject every row, so the result is empty regardless — so this is a fragility note, not a defect. An explicit detach beside the Bloom one would close it.
What I verified, and what I did not
- Compiled the PR file against six builds; ran your suite on pg18a before and after the two-line fix (22 FAILs with crashes, then 42/42).
- Ran the repository gate on the PR tree with the fixes:
harness_selftest.sh709 checks, 0 FAIL; the pytest corpus 276 passed. Your new files are registered correctly and the corpus arms are happy with them. - Did not review the Bloom sizing maths against
BLOOM_MAX_BITS, the 3-table case beyond your own checks, or anything on 15/16/17/19 beyond compiling. - Did not measure performance. The 5% number is the reason I raise the default, not a claim that the filter does not pay.
The private-HashPath reasoning, the refusal set, the drain-the-source fix for the early-LIMIT crash, and the decision to make the planner's hull and the reader's pruning share one predicate builder are all right, and the collation-mismatch and int4/int8 checks are the two I would have asked for. Fix 1 and 2 and the tree is testable; fix 3 and I will re-review.
|
@linuxhikerpm — not a second review, @OffgridwithJD's stands. This is just an independent reproduction of their finding 1, because "does not compile on four majors" is the kind of claim I will not relay without running it myself, and because a one-command check is more useful to you than a description.
Measured, on assert builds of every supported majorThe error, verbatim on 17.10: The control is the point. And the mechanism, which explains why 15 is the odd one out:
Reproducing it yourselfcat > subject.c <<'C'
#include "postgres.h"
#include "nodes/plannodes.h"
#include "nodes/pg_list.h"
void f(List *l) { Plan *p = linitial_node(Plan, l); (void) p; }
C
gcc -fsyntax-only -I"$(pg_config --includedir-server)" subject.cSwap One note on my own instrumentMy first attempt to count I have not verified findings 2 or 3 — those are @OffgridwithJD's and need a build and a run rather than a syntax check. |
b175e00 to
61a18f5
Compare
|
Pushed 1. 2. 3. Fact-table qual dropped every row. The twins that were missing:
Independent fixtures. Same public seam. Fix is option 1 plus the ScanNext fallback: when late materialization is already on, add the key to the existing Both twins 44/44 and 11/11 green on distro 18.6 after the fix. Smaller items. Dedicated comments on the two attach functions. CI on this SHA is the remaining gate I cannot run here (assert majors). |
|
Independent verification of @OffgridwithJD's findings 2 and 3, at head I had to get past finding 1 to measure these at all, so the tree under test is Finding 1, in a real build rather than a syntax probeWith Finding 2 — CONFIRMEDTheir own fixture, first query, no special settings. The control is the point: the same query with the filter off answers correctly, so the abort is the filter's and not the fixture's. Exactly the assertion, file and line named. The two sites are Their suite, on an assert build: (@OffgridwithJD measured 22 failures on PG 18; I get 25 on PG 17. Different major, same conclusion — I have not chased the three.) The fix they propose is safe, and I checked its premise rather than taking it: Finding 3 — CONFIRMED, and it is the serious oneWith both fixes applied so queries can run. Rows 1 and 2 are controls and they agree; rows 3 and 4 are silently wrong, at default settings, with no error. Their own counters convict it. The bloom worked. The qual then rejected all 210 rows that survived it, and 200 of those have The mechanism, read off the source. if (deferrable == 0) { pfree(cstate->qualCols); cstate->qualCols = NULL; return; } /* lateMat stays false */So if (state->qualCols == NULL)
state->qualCols = palloc0(sizeof(bool) * state->nTotalColumns);
state->qualCols[attno - 1] = true; /* only the join key */
state->lateMat = true; /* turned ON after Begin declined it */The two-pass producer now runs believing the qual reads only One NULL meaning two different things is the whole defect. And it is one-directional, as they said: a wrongly-kept row is re-filtered by Why 52 green checks did not see itNeither twin puts a local qual on the columnar fact table. Every HousekeepingI built in One correction to my own cleanup, since it nearly went unnoticed: my first "restore to main" clone resolved |
|
Follow-up |
|
Re-verified at The three blockers1. Builds everywhere now. Using the project's own makefile rather than a bare
2. No abort. 3. The wrong answers are gone, and the fix is the right one. My control table, re-run against this head.
Rows three, four and five were The minors
The repository gate, on your treeAnd the regression coverage went into both twins, built separately: A correction to my own evidenceMy first review carried a per-major table from compiling Not approving yet, and whyCI is 11 of 13 green with Nothing further from me on the code. This is a good change: the private- |
OffgridwithJD
left a comment
There was a problem hiding this comment.
Approving at 196b9657, the head I verified.
CI has settled: 13 checks, 0 failures, none pending — including suites (PG 17) and suites (PG 18), which were the two I was waiting on. I checked the head had not moved before approving, so the approval is against the tree I measured rather than a later one.
What the approval rests on, all of it from my comment above:
- The project's own
makeon five assert builds, pg15a through pg19a, zero warnings. - Your suite 44/44 on pg18a, up from 42, with the two new
fact-qualchecks. - My own control table: the three shapes that returned 0 rows now return 200, and the two controls that were already right still are.
- The repository gate on your tree:
harness_selftest.sh827 checks 0 FAIL, the pytest twin 11 passed, the full corpus 289 passed. - Every minor addressed, and the default flipped to off.
The merge decision is @jdatcmd's, not mine.
|
Update: my two comments above were on Leaving them up because the reasoning is still the record of what was wrong, but nothing in them should be read as outstanding. Finding 1 needed no patch this time and finding 2's Finding 3, A/B on the two buildsSame probe script both times, same fixture,
The control failed first, and the failure was mineMy first attempt at arm A reported 200 across the board — the bug apparently gone on the binary I knew was broken. That is a probe that stopped reporting, not a fix.
The fix was a probe bound to the old tree's @OffgridwithJD's approval matches what I measure. I am not merging this: I have no authorization for it, and merges here are the owner's call. |
196b965 to
fc6bf58
Compare
Re-verified at
|
RED evidence on current main f0f1f40: native_join_runtime_filter.sh fails six feature assertions with no crash; pytest twin fails at the coordinator assertion. No implementation is present in this commit.
Builds a private core HashPath, blocks to spool and replay the build side, and attaches its conservative interval to the direct columnar scan. Shell and pytest tests independently prove plan shape, exact answers, pruning, and removal causation.
Scattered keys keep every group, so the interval hull cannot be the skip. Build-side hashes reuse the on-disk bloom saturation cap. Drain the tap from the source, not ExecProcNode, so early LIMIT cannot crash.
The Bloom commit dropped the mode the RED commit set. A shebang without the bit fails harness_selftest.
…prompt#752) A fact-table qual that named a non-key column returned no rows: attach forced late materialization with only the join key decoded. Derive the qual columns the same way Begin does, and probe Bloom after the full row when that path is refused. Also drop linitial_node(Plan), stop initializing a ScanKey with an invalid procedure, default the GUC off, and detach a stale hull on rescan.
…ndprompt#752) Adding the suite raised suites_not_covered, and that ceiling may only fall. A green run of the 44 checks is merged as never-red so the suite is covered and the census matches.
fc6bf58 to
760fbd6
Compare
|
Rebased onto Exact head
Local: shell 44/44, pytest join 11 passed, docs-cover/census green. |
|
Checked the rebase at The product code is byte-identical to the head I verified. Not "looks the same" — a per-file diff against So the three findings I verified at And the census was correctly re-derived, which is the part that had to change. #947 landed 762 into main while this branch carried 800 off the old 756 baseline. Neither number survives a compose, and the new head does not try to keep either: 806 against 806 is the invariant the selftest arm asserts, checked by counting the committed rows rather than by arithmetic on the deltas. The 44 rows are this PR's own suite, which it registers and seeds in the same change — so My approval stands. CI has 11 of 13 with the two suites jobs running; if either goes red I will say so rather than leave the approval sitting on a red. |
|
CI settled: 13 of 13 pass, including both suites jobs that were running when I approved. I said I would report it either way, so: nothing reddened, and the approval stands on a full green rollup at |
`pgc_ledger.py gate` printed `ledger census: rows=N` and never compared that number to the `checks_never_observed_red` the budget states, so it returned 0 on a twenty-row ledger claiming five. Reporting is not enforcing. The comparison already existed one layer out, in a selftest arm. That arm runs on a pull request, so it reports the disagreement after the merge that creates it rather than before. And a merge is what creates it: the census is a measurement of the tree, so every merge invalidates it. Two PRs each re-derive it from the same base, the merged ledger takes both sets of rows, and the budget keeps whichever side won the conflict. This branch then demonstrated its own premise. It was cut from a main stating 762, and commandprompt#945 and commandprompt#948 merged underneath it; main now states 806. Rebasing it needed the census re-derived from a run on the composed tree, which is exactly the operation this refusal makes mechanical. The new refusal is decidable from the two inputs alone. It needs no prior and no `--against`, which is what lets it speak about a merge commit, where the prior is the thing in question. It does not make the census a ceiling. A ceiling refuses a rise, and bounding this number deadlocks: every added check enters as `never`, so landing one would mean raising a number the design says may only fall. What is refused is a contradiction, in either direction. A budget stating no census at all is reported rather than refused, because absence is not a contradiction. That is measured rather than preferred: every other gate fixture in both harnesses writes a budget stating only `suites_not_covered`, so refusing there would redden about twenty arms testing something else. What holds the committed budget to naming both numbers is a separate arm in each harness. Red first, in both harnesses, independently implemented: ten checks in selftest 410 and one test in `test_mutation_ledger.py`. Both were run against the unfixed tool and failed, and the core measurement was reproduced on its own: rc=0 for a budget claiming 1 and for one claiming 3 against a ledger holding 2. Gated on the composed tree: docs_style 9/0, harness_selftest 773/0, pytest 313, driver-free 215, shellcheck -S error -s bash clean. The ten new ledger rows entered as `never` with no observed red, and the census was re-derived from the run rather than computed: 806 -> 816. Arithmetic would also have said 816 here, which is the dangerous case, and only the run established it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
(commandprompt#928) Fourth re-derivation of this artifact on a merge, and the reason is structural rather than accidental: the census is a measurement of the tree, so every merge that adds or removes a check invalidates it. commandprompt#948 and commandprompt#945 both landed between this branch's last rebase and now. ledger after the compose 819 rows = 819 never + 0 ever-red, partitions census 775 -> 819, derived from the ledger ceiling 250, untouched Confirmed by a selftest run on the composed tree rather than by the derivation alone -- the census arm is the check, not the documentation. Tracked in commandprompt#952. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
`pgc_ledger.py gate` printed `ledger census: rows=N` and never compared that number to the `checks_never_observed_red` the budget states, so it returned 0 on a twenty-row ledger claiming five. Reporting is not enforcing. The comparison already existed one layer out, in a selftest arm. That arm runs on a pull request, so it reports the disagreement after the merge that creates it rather than before. And a merge is what creates it: the census is a measurement of the tree, so every merge invalidates it. Two PRs each re-derive it from the same base, the merged ledger takes both sets of rows, and the budget keeps whichever side won the conflict. This branch demonstrated its own premise twice. Cut from a main stating 762, it has since been re-derived across the merges of commandprompt#945, commandprompt#948 and commandprompt#943; main now states 819 and this states 829. Each time the correct operation was to regenerate both derived files from a run, never to merge them as text or to add up the parts. The new refusal is decidable from the two inputs alone. It needs no prior and no `--against`, which is what lets it speak about a merge commit: the composed tree is precisely where the prior is the thing in question, so a refusal needing a trustworthy prior would be unavailable exactly when it is needed. It does not make the census a ceiling. A ceiling refuses a rise, and bounding this number deadlocks: every added check enters as `never`, so landing one would mean raising a number the design says may only fall. What is refused is a contradiction, in either direction. A budget stating no census at all is reported rather than refused, because absence is not a contradiction. That is measured rather than preferred: every other gate fixture in both harnesses writes a budget stating only `suites_not_covered`, so refusing there would redden about twenty arms testing something else. What holds the committed budget to naming both numbers is a separate arm in each harness. Red first, in both harnesses, independently implemented: ten checks in selftest 410 and one test in `test_mutation_ledger.py`. Both were run against the unfixed tool and failed, and the core measurement was reproduced on its own: rc=0 for a budget claiming 1 and for one claiming 3 against a ledger holding 2. Gated on the composed tree: docs_style 9/0, harness_selftest 786/0, shellcheck -S error -s bash clean. The ten new ledger rows entered as `never` with no observed red, and the census was re-derived from the run rather than computed: 819 -> 829. Arithmetic would also have said 829, which is the dangerous case rather than the reassuring one, and only the run established it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
…956) `build_once()` skipped the build when its marker matched, and the marker recorded the pg_config, the major and the source fingerprint. That answers "did this layer last build this source". It was read as "does the prefix hold that build". Those are different claims whenever anything else writes the shared prefix: the bash harness, a timing run, a manual install, another worktree. Measured twice in one day. A measurement run installed a pre-#945 library into /usr/local/pg18a and the corpus then reported 10 failures in test_join_runtime_filter.py, on a PR branch and on plain main, with the code entirely innocent. @jdatcmd reproduced the same shape on PG 17 deliberately: 44 checks, 25 passed and 19 FAILED against the stale library, 44 passed after restoring it. The source had not changed in either case, so the old key matched. The installed library is now part of the key, so a prefix someone else wrote is rebuilt rather than certified. Verified end to end against the real harness by calling build_once directly: prefix correct -> already-built third party installs another -> built, and the correct library is restored nothing touched -> already-built So the skip is preserved; this does not rebuild on every run. A library that is absent counts as changed rather than as fresh. Where the prefix genuinely cannot be read the comparison is skipped -- the only option that leaves three existing arms meaning what they say, since they pass a pg_config that cannot be queried -- and the marker records `unobserved`, so a degraded decision is readable instead of inferred from an absence. That path has its own test rather than being a fallback nothing exercises. WHY A PER-SOURCE CONSTANT CANNOT WORK. The library's digest is not a function of the source: the build path is compiled in. @jdatcmd measured 2c9559d087b0 and 757591c69d32 from commit a870203 with nothing but the build directory differing. "This source should produce digest X" is therefore false as soon as anyone builds elsewhere, which is every worktree and every devloop arm. What is recorded is the digest installed at the moment the marker was written: a claim about this prefix over time, which is the property at stake. ONE DIGEST, NOT TWO. `so_md5()` already fingerprinted the installed library with md5sum, so `installed_library()` shares that path and `so_md5` delegates to it. The first attempt reached for hashlib and `test_this_module_keeps_no_private_fingerprint` refused it -- correctly, because the twin source-fingerprint implementations produced four defects in one day (#907), and a second way to digest one artifact is that defect in miniature. The library's filename is also named once now rather than in two places. Red first: three tests, each run against the unfixed tool and failing for the stated reason -- `got 'already-built' want 'built'`. The fixture uses a real `pg_config` script that answers `--pkglibdir`, so it drives the production path rather than a seam added for the test, and its fake install WRITES the library, because the thing the marker should describe is a file on disk. No shell twin: the subject is `build_once()` in test/pytest/pgc_cluster.py, so a shell part driving it would be the coupling selftest 350 and 360 were cut down to remove. Gated on this tree: docs_style 9/0, harness_selftest 776/0 (unchanged, as a pytest-only change should leave it), pytest 315, pytest -n 4 315 -- run because build_once is the xdist serialisation point -- driver-free 217, shellcheck -S error -s bash clean. No ledger row and no census move, so this composes with the two open PRs on CHANGELOG and TESTS.md only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
`lib.sh` compared a recorded source fingerprint against the current one and then
printed "source <hash> matches the binary under test". That is a claim about the
BINARY from evidence about the SOURCE, and it is false whenever another process has
written the shared prefix.
The stamp could not see it, and the reason is structural: it is keyed per SOURCE
TREE. Two trees installing into one prefix keep two stamp files, and each records
only what its own tree built.
/root/wv3/.pgc_source_stamp.18.d9e24bec
/root/wfpB/.pgc_source_stamp.18.d9e24bec
MEASURED. Two trees whose src/ differs by five files. B built and installed through
the harness, A then installed its library into the same prefix, and B ran #945's own
suite with PGC_SKIP_BUILD=1:
-- .so: d312a10c0cfb /usr/local/pg18a/lib/postgresql/pgcolumnar.so
-- source: a0e6afc3e13e matches the binary under test <- FALSE
FAIL plan has runtime coordinator: got [0] want [1]
... nine in all, the code entirely innocent
@jdatcmd measured the same sentence above two different libraries on PG 17, 25
passed + 19 FAILED against 44 passed + 0 failed, and supplied the two stamp files
that confirmed the per-tree keying.
I HAD CONCLUDED THE OPPOSITE AND WAS WRONG. I measured the `unknown` branch, saw it
degrade honestly to `freshness UNVERIFIED`, and concluded the shell path was free of
this class. The function has two branches and I had exercised one. The POSITIVE
branch is where a false claim can live, because it is the only one that asserts
anything.
The stamp now records the installed library's digest beside the source fingerprint,
and the claim requires both to match what is on disk. The decision is a pure
function, like its two siblings, so it is exercised without a build:
source binary decision behaviour
fresh fresh verified "matches the binary under test"
fresh unknown source-only source claim earned, library UNVERIFIED
fresh replaced refuse-binary FATAL, naming both digests and the prefix
stale any refuse-source FATAL, as before
unknown any unverified UNVERIFIED, as before
ALL FOUR STATES DRIVEN END TO END, not only the pure arms. That includes
`refuse-source`, which neither @jdatcmd nor I had ever exercised -- it was read and
believed. Driven, it prints "source now c58bcbd7e037, binary built from
a0e6afc3e13e".
BACKWARD COMPATIBLE BY CONSTRUCTION. A pre-#959 stamp is one line, which reads as
"source recorded, library unrecorded" and lands in `source-only`. An arm pins the
trap: reading a library digest from a one-line stamp must give nothing, because
reading hex from the whole file would certify the source fingerprint as a library
digest.
A REGRESSION CAUGHT BEFORE SHIPPING. `pgc_write_source_stamp` has two other
callers, `run_all_versions.sh` and `devloop.sh`. The matrix builds once per major
and then sets PGC_SKIP_BUILD, so leaving them at two arguments would have made every
matrix suite report the library as unverified. Both now record the digest. Found by
grepping for callers rather than assuming lib.sh was self-contained.
The digest is NOT a function of the source -- the build path is compiled in, and
@jdatcmd measured 2c9559d087b0 and 757591c69d32 from one commit with only the
directory differing -- so what is recorded is the digest installed when the stamp
was written. Same constraint #957 works under for the pytest layer.
TESTS.md described the verdict as two branches. It is four now, so the sentence is
corrected rather than left to go stale.
Gated: harness_selftest 800/0 (786 + 14), docs_style 9/0, pytest 320, driver-free
222, shellcheck -S error -s bash clean. Ledger 829 -> 843, exactly 14 rows, all in
340, and the census re-derived from the run rather than computed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
Implements the cheap runtime-filter slice of #752. This does not close the issue: there is still no join path of our own, and the grouped-aggregate fold remains a separate problem.
A serial inner Hash Join whose outer path is a direct columnar scan now wraps core Hash Join in a coordinator. The child
HashPathis private (create_hashjoin_path), because a path already injoinrel->pathlistcan be freed byadd_path(). The coordinator drains the build side into a tuplestore, then lets Hash replay that spool. The fact-table scan skips chunk groups outside a conservative key interval when types and collations match, and rejects non-matching rows with a Bloom filter of those keys. Bloom reuses the on-disk saturation cap (d * 10vs2^21).Refused: LEFT, SEMI, ANTI, CROSS, parallel, parameterized, and projection-backed outers.
pgcolumnar.enable_join_runtime_filteris on by default.TDD and removal proof
Both public test forms were written before implementation (
e346242). On that commit:After the range and Bloom commits, both are green:
test/native_join_runtime_filter.sh: 42/42 on PostgreSQL 18.6test/pytest/test_join_runtime_filter.py: 10 passedtest/docs_style.sh: passedgit diff --check: cleanThe two suites are independent: each builds its own fixtures and expected values. Documented counterparts only.
Bloom saturation was mutated by removing the
BLOOM_MAX_BITSrefusal; both suites failed onsaturated bloom is disabled. Restored. Early LIMIT used to SIGSEGV when the coordinator drained the tap throughExecProcNode(tap); draining the source into the tuplestore first is the public fix, and restoringExecProcNode(tap)fails pytest withtap read before drain.What this is not
set_join_pathlistjoin path of our own. Core Hash Join still produces the rows.Exact head
b175e00a353443f9402566efd931c52f6c7fe8ce.Made with Cursor