Skip to content

perf: PG19 RI fast path for FK checks + planner join-path allocations (#20) - #41

Merged
alexshapalov merged 3 commits into
mainfrom
perf/issue-20
Nov 4, 2026
Merged

alexshapalov merged 3 commits into
mainfrom
perf/issue-20

Conversation

@alexshapalov

Copy link
Copy Markdown
Contributor

Lands the two patches contributed in #20 by @hoangquocvietuet, cherry-picked onto current main with their commits and authorship unchanged. They applied cleanly (no conflicts, Cargo.lock delta is the new intra-workspace deps of ri_triggers).

Fixes #20

What changes

  1. ri_triggers: PostgreSQL 19 per-row RI fast path (67572923cb). RI_FKey_check probes the referenced unique index and KEY SHARE locks the row directly instead of running the cached SPI plan per row. Falls back to the unchanged SPI path for partitioned referenced tables, temporal FKs, non-btree indexes, collation mismatches, operators no longer in the opfamily, and FK columns that need a cast. Mirrors upstream master ri_FastPathCheck step for step (CCI under the PK lock, constraint reload, snapshot taken after the lock, owner security context, permission checks, TM_* handling as in ExecLockRows).
  2. planner: drop allocation/copying in the join-path hot loop (66ef4718b8). Behavior-preserving: path lists iterated in place, borrowed relids/clause lists, exact-size copies after add_path_precheck, build_join_pathkeys_len prefix slicing.

Verification (this branch, fast-profile build, M1 Pro, synchronous_commit=off, fsync=off)

Issue benchmark, medians of 3:

PostgreSQL 16.11 pgx main pgx this branch
200k rows, 2 FKs per row 1.15 s 2.75 s 1.21 s (−56%)
same insert, no FKs 0.24 s 0.42 s 0.39 s
8-way unnest … WITH ORDINALITY … USING (ord) EXPLAIN ×300 1.35 s 2.88 s 2.54 s (−12%)

Correctness:

  • cargo test --profile fast-profile -p ri_triggers -p planner -p pathnode -p costsize: 22 / 203 / 0 / 30 passed.
  • benchmarks/pgx/regress-baseline.py on both binaries: 231 tests, 219 byte-exact and the same 12 pre-existing diffs on each; 239 of 240 result files byte-identical. The one difference is memoize.out, where only the leader/worker split of cache hits moved (run-dependent, and that test already fails against expected on main).
  • 22 single-session FK scenarios (RC/RR/SERIALIZABLE same-transaction inserts, BEFORE-trigger-created parent, deferred FK, read-only transaction, int4→int8 and int→numeric cross-type, composite FK in reversed column order, COLLATE "C" key, partitioned PK, self-reference, MATCH FULL, unprivileged role, FORCE RLS on the PK table, ON CONFLICT DO UPDATE, UNIQUE … INCLUDE target, REINDEX between inserts): identical output to main and same outcomes as PG16. Only difference: errors raised on the fast path carry no SPI CONTEXT: line, as in upstream PG19.
  • 10 two-session lock scenarios (delete / key update / non-key update of the parent while the child insert waits, with commit and rollback; uncommitted parent; RR and SERIALIZABLE serialization failures): identical outcomes to main and PG16, same CONTEXT: caveat.
  • Planner patch reviewed independently for in-place iteration soundness, pathkeys-prefix equivalence, precheck ordering and tie-break changes: no findings.

Notes

  • Fast-path error texts match pgx's existing SPI path ("cannot execute SELECT in a read-only transaction", "could not serialize access due to concurrent update"), not PG19's.
  • Not covered by a dedicated test: SERIALIZABLE predicate locking on the PK index; both paths use the same index-scan code.

🤖 Generated with Claude Code

…heck

RI_FKey_check now probes the referenced table's unique index directly and
KEY SHARE locks the matching row (ri_FastPathCheck, ri_FastPathProbeOne,
ri_LockPKTuple, recheck_matched_pk_tuple, ri_check_fastpath_index,
ri_populate_fastpath_metadata, build_index_scankeys, ri_CheckPermissions,
ri_CheckFunctionPermissions), instead of running the cached SPI plan per
row. Everything else keeps the unchanged SPI path.

Ported from PG19's ri_triggers.c as it stands after upstream removed
batching, so only the per-row path exists. Exclusions, as upstream: the
fast path is skipped for partitioned referenced tables, temporal (PERIOD)
FKs, non-btree referenced indexes, index collations differing from the
referenced column's, pf_eq operators that are no longer the opfamily's
equality member, and FK columns that need a cast function (not binary-
coercible) to the operator's input type. The eligibility verdict and the
scan-key metadata are cached on the constraint-cache entry; this port hands
out clones of RiConstraintInfo, so PG19's dead-FastPathMeta list is not
needed.

Both open upstream issues are handled: the row lock is refused in a
read-only transaction (non-temp PK) like ExecCheckXactReadOnly refuses the
SPI query, and the lock uses the scan snapshot's curcid, as ExecLockRows
uses es_output_cid, so "attempted to lock invisible tuple" cannot arise.

Deliberate deviations from PG19, to match pgx's SPI path exactly: the
read-only error names the command "SELECT" (pgx's ExecCheckXactReadOnly
text), and a concurrent delete under RR/SERIALIZABLE reports "could not
serialize access due to concurrent update" (ExecLockRows' text in both C
and pgx). Errors raised on the fast path carry no SPI CONTEXT line, as in
PG19.

New intra-workspace deps of ri_triggers (no external crates; Cargo.lock
only lists them for ri_triggers): indexam, tableam, exectuples, types_scan,
types_slot for the index scan and tuple lock, namespace_seams for
isTempNamespace, objectaccess for InvokeFunctionExecuteHook.
Behavior-preserving (same iteration order, plans and costs); every loop that
now reads a rel's path list in place only adds paths to the joinrel, and
create_unique_path caches on the rel without touching its lists.

joinpath.rs
- match_unsorted_outer, consider_parallel_nestloop/_mergejoin,
  hash_inner_and_outer: index outerrel/innerrel pathlist, partial_pathlist
  and cheapest_parameterized_paths in place instead of pgvec_clone_shallow
  snapshots; outer pathkeys go into one scratch buffer reused per outer path.
- generate_mergejoin_paths: take the caller's outer_pathkeys copy instead of
  cloning outerpath's again; read innerrel's pathlist in place; compute the
  trimmed mergeclauses at most once per sortkeycnt without the extra clones
  of mergeclauses/innersortkeys/merge_pathkeys.
- sort_inner_and_outer: no clones for the partial merge leg; parallel-safe
  inner lookup reads pathlist in place.
- try_mergejoin_path/try_partial_mergejoin_path: borrow key/clause slices;
  sort-key elision is a slice rebinding instead of clearing owned vectors.
- try_nestloop_path, try_partial_nestloop_path, clause_sides_match_join,
  select_mergejoin_clauses, compute_semi_anti_join_factors,
  add_paths_to_joinrel (param_source_rels), get_memoize_path: borrow relids
  and rel/path lists instead of relids_copy / Relids::clone / list clones.

pathkeys.rs
- build_join_pathkeys -> build_join_pathkeys_len: the join's pathkeys are a
  prefix of the outer pathkeys, so callers borrow a slice (no per-outer-path
  vector); truncate_useless_pathkeys shares the count via useful_pathkeys_len.

pathnode
- create_mergejoin_path takes slices and copies them with exact-size
  allocations, so precheck-rejected candidates allocate nothing.
- create_nestloop_path filters restrict clauses straight into a pre-sized
  vector; borrows the outer/inner relids.
- calc_non_nestloop_required_outer borrows instead of copying four relids.

costsize
- final_cost_nestloop/hashjoin/mergejoin pass the (not yet arena-owned)
  path's clause lists directly instead of cloning them; hashjoin re-borrows
  the inner rel's relids instead of cloning them.
@alexshapalov
alexshapalov merged commit 4eb571d into main Nov 4, 2026
10 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.

Perf: FK checks ~2.6x and join planning ~3x slower than PostgreSQL — two patches (PG19 RI fast path, planner allocations)

2 participants