Skip to content

Add citus.enable_or_clause_arm_pruning for per-shard OR-arm pruning (… - #8859

Open
Colm (colm-mchugh) wants to merge 1 commit into
release-14.0from
release-14-8642-bp
Open

Colm (colm-mchugh) wants to merge 1 commit into
release-14.0from
release-14-8642-bp

Conversation

@colm-mchugh

Copy link
Copy Markdown
Contributor

…#8642)

DESCRIPTION: Adds citus.enable_or_clause_arm_pruning to drop unreachable OR arms per shard

Summary

Adds an optimization, GUC citus.enable_or_clause_arm_pruning (default on), for multi-shard SELECTs whose WHERE clause is a top-level
OR where each arm constrains the distribution column, e.g.:

SELECT * FROM t WHERE (k = 1 AND v = 7) OR (k = 2 AND v = 7);

Today Citus pushes the full N-arm OR to every shard, so each shard runs an
N-way bitmap OR even though only the arm whose key hashes to that shard can
match. With this enabled, each shard's task query keeps only the arms that can
match on that shard, so the worker runs a single precise index scan.

Before / After (2 shards, index on (k, v))

Before (citus.enable_or_clause_arm_pruning = off, stock behavior):

->  Task (shard owning k=1)
      ->  Bitmap Heap Scan
            Recheck Cond: ((k=1 AND v=7) OR (k=2 AND v=7))
            ->  BitmapOr
                  ->  Bitmap Index Scan  Index Cond: (k=1 AND v=7)
                  ->  Bitmap Index Scan  Index Cond: (k=2 AND v=7)   <- dead on this shard
->  Task (shard owning k=2)   ... same full 2-way BitmapOr ...

After (= on):

->  Task (shard owning k=1):  Index Only Scan  Index Cond: (k=1 AND v=7)
->  Task (shard owning k=2):  Index Only Scan  Index Cond: (k=2 AND v=7)

How it works

In SqlTaskList() (the logical/physical-planner path used by multi-shard
SELECTs — these are not router queries), while generating each per-shard task
query, drop the arms of top-level OR expressions that cannot match on that
task's shard.

  • PruneUnreachableOrArms() recurses through top-level List/AND; for an
    OR it keeps only the arms whose pruned shard set (from the existing PruneShards()) contains the task's shard. An arm with no constraint on the
    distribution column prunes to all shards and is therefore always kept, so the
    rewrite is a guaranteed semantic no-op.
  • Reachability depends only on (relation, rangeTableId, arm), never on the
    task, so ReachableShardListForArm() caches results in a job-wide hash table
    (HTAB) keyed on (relationId, rangeTableId, arm): PruneShards() runs once
    per distinct arm instead of once per (shard x arm). The hash uses the arm's
    serialized form and the comparator uses node equal(), so structurally identical arms from different per-task query copies share one entry and hash
    collisions never produce a wrong hit.

Safety

  • On by default; set citus.enable_or_clause_arm_pruning = off to restore
    the previous behavior byte-for-byte (the entire new code path is inside if (EnableOrClauseArmPruning)). The full check-multi schedule (193 tests)
    passes with the default on; the only expected-output change is additive DEBUG lines in multi_hash_pruning (the per-arm PruneShards() now runs
    during task generation) — no result/row changes.
  • Correctness invariant: an arm is dropped on shard S only when it provably
    matches no row on S. Two independent code reviews found no correctness issues;
    joins, self-joins, params, NULLs, reference-table joins, ranges/IN, nested
    and mixed boolean, and value collisions were all validated.

Tests

src/test/regress/sql/multi_or_arm_pruning.sql (wired into multi_1_schedule):

  • 5 before/after per-shard Filter comparisons (shard_filters() helper),
    including a hash-collision case that asserts the
    >=2-equality-arms-kept
    shape and is self-checking (if k=1/k=5 ever stop colliding the grouped
    filter splits and the test fails rather than silently passing), plus pure-range
    and mixed range+equality shapes.
  • 18 result-parity checks (off vs on, multiset equality via EXCEPT ALL):
    basic, shard collision, no-dist-constraint arm, mixed AND/OR, nested OR, range, IN, colocated join, cross-table-arm join, reference-table join, null-distribution-key (single-shard) join, repartition (non-colocated) join,
    prepared/parameterized query, no-WHERE, top-level NOT, and no-op cases.
  • The NULL-key join exercises the !HasDistributionKeyCacheEntry eligibility
    guard; the repartition join exercises the fragmentType != CITUS_RTE_RELATION
    guard.
  • Deterministic and environment-independent (helpers normalize shard ids; no
    ports/task-order dependence). The Filter outputs were verified against an
    independent ground-truth query formulation, not just off-vs-on parity. Passes
    the real pg_regress harness.

Known considerations / follow-ups

  • The "BEFORE" EXPLAIN line reflects the worker planner's boolean factoring of
    the OR, which is mildly PG-version-sensitive; may need a variant expected file
    for PG17/18. The "AFTER" lines (what the feature controls) are robust.
  • CHANGELOG intentionally not touched (Citus generates it at release time).

DESCRIPTION: PR description that will go into the change log, up to 78 characters

@codecov

codecov Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.50000% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.33%. Comparing base (0f781d1) to head (e865538).

❗ There is a different number of reports uploaded between BASE (0f781d1) and HEAD (e865538). Click for more details.

HEAD has 75 uploads less than BASE
Flag BASE (0f781d1) HEAD (e865538)
17_regress_check-columnar-isolation 1 0
16_regress_check-enterprise-isolation-logicalrep-3 1 0
17_regress_check-columnar 1 0
18_regress_check-split_14.2-1_v14.2.0_coordinatoronly 1 0
17_regress_check-multi-mx 1 0
18_regress_check-enterprise-isolation-logicalrep-3_14.2-1_v14.2.0_workeronly 1 0
18_regress_check-enterprise-isolation-logicalrep-1 1 0
18_regress_check-enterprise-isolation 1 0
18_arbitrary_configs_5 1 0
18_regress_check-enterprise-isolation_14.2-1_all 1 0
16_regress_check-enterprise-isolation-logicalrep-1 1 0
18_regress_check-enterprise 1 0
16_arbitrary_configs_2 1 0
18_cdc_installcheck 1 0
17_regress_check-multi-1-create-citus 1 0
16_regress_check-multi-1-create-citus 1 0
17_regress_check-operations 1 0
16_regress_check-operations 1 0
18_regress_check-enterprise-isolation-logicalrep-2_14.2-1_all 1 0
17_regress_check-isolation 1 0
18_regress_check-isolation_14.2-1_v14.2.0_coordinatoronly 1 0
18_regress_check-multi_v14.2.0_all 1 0
17_arbitrary_configs_0 1 0
18_regress_check-operations_v14.2.0_all 1 0
18_regress_check-operations 1 0
18_regress_check-operations_14.2-1_v14.2.0_coordinatoronly 1 0
18_regress_check-multi-1_14.2-1_v14.2.0_workeronly 1 0
18_regress_check-multi_14.2-1_all 1 0
18_regress_check-multi-1_14.2-1_v14.2.0_coordinatoronly 1 0
17_arbitrary_configs_4 1 0
18_regress_check-multi-mx 1 0
16_regress_check-multi-mx 1 0
16_regress_check-multi 1 0
18_regress_check-multi-1_v14.2.0_all 1 0
18_arbitrary_configs_4 1 0
17_arbitrary_configs_2 1 0
17_regress_check-multi-1 1 0
18_regress_check-multi_14.2-1_v14.2.0_workeronly 1 0
18_arbitrary_configs_2 1 0
18_regress_check-enterprise-isolation-logicalrep-1_14.2-1_all 1 0
18_regress_check-columnar-isolation 1 0
16_arbitrary_configs_1 1 0
17_cdc_installcheck 1 0
17_regress_check-multi 1 0
18_arbitrary_configs_1 1 0
18_regress_check-enterprise-isolation-logicalrep-1_14.2-1_v14.2.0_workeronly 1 0
17_regress_check-enterprise-isolation 1 0
16_arbitrary_configs_0 1 0
18_regress_check-isolation_v14.2.0_all 1 0
16_cdc_installcheck 1 0
18_regress_check-multi-1 1 0
18_regress_check-multi-1_14.2-1_all 1 0
18_regress_check-isolation_14.2-1_v14.2.0_workeronly 1 0
18_regress_check-multi_14.2-1_v14.2.0_coordinatoronly 1 0
18_arbitrary_configs_0 1 0
18_regress_check-multi 1 0
17_arbitrary_configs_1 1 0
18_regress_check-operations_14.2-1_v14.2.0_workeronly 1 0
18_regress_check-operations_14.2-1_all 1 0
16_regress_check-multi-1 1 0
16_18_upgrade 1 0
18_regress_check-enterprise-isolation_14.2-1_v14.2.0_coordinatoronly 1 0
16_regress_check-failure 1 0
17_regress_check-enterprise 1 0
17_regress_check-failure 1 0
18_regress_check-failure 1 0
18_regress_check-enterprise-isolation-logicalrep-1_v14.2.0_all 1 0
18_regress_check-enterprise-isolation_v14.2.0_all 1 0
17_regress_check-enterprise-isolation-logicalrep-1 1 0
18_regress_check-enterprise-isolation-logicalrep-1_14.2-1_v14.2.0_coordinatoronly 1 0
16_arbitrary_configs_5 1 0
17_arbitrary_configs_5 1 0
18_regress_check-enterprise_14.2-1_v14.2.0_workeronly 1 0
16_regress_check-enterprise-isolation 1 0
16_regress_check-enterprise 1 0
Additional details and impacted files
@@                Coverage Diff                @@
##           release-14.0    #8859       +/-   ##
=================================================
- Coverage         88.50%   69.33%   -19.17%     
=================================================
  Files               289      289               
  Lines             64614    64702       +88     
  Branches           8126     8138       +12     
=================================================
- Hits              57188    44863    -12325     
- Misses             5085    16732    +11647     
- Partials           2341     3107      +766     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…8642)

DESCRIPTION: Adds citus.enable_or_clause_arm_pruning to drop unreachable
OR arms per shard

## Summary
Adds an optimization, GUC `citus.enable_or_clause_arm_pruning`
(**default on**), for multi-shard SELECTs whose `WHERE` clause is a
top-level
`OR` where each arm constrains the distribution column, e.g.:

```sql
SELECT * FROM t WHERE (k = 1 AND v = 7) OR (k = 2 AND v = 7);
```

Today Citus pushes the **full N-arm OR to every shard**, so each shard
runs an
N-way bitmap OR even though only the arm whose key hashes to that shard
can
match. With this enabled, each shard's task query keeps only the arms
that can
match on that shard, so the worker runs a single precise index scan.

## Before / After (2 shards, index on `(k, v)`)

Before (`citus.enable_or_clause_arm_pruning = off`, stock behavior):
```
->  Task (shard owning k=1)
      ->  Bitmap Heap Scan
            Recheck Cond: ((k=1 AND v=7) OR (k=2 AND v=7))
            ->  BitmapOr
                  ->  Bitmap Index Scan  Index Cond: (k=1 AND v=7)
                  ->  Bitmap Index Scan  Index Cond: (k=2 AND v=7)   <- dead on this shard
->  Task (shard owning k=2)   ... same full 2-way BitmapOr ...
```

After (`= on`):
```
->  Task (shard owning k=1):  Index Only Scan  Index Cond: (k=1 AND v=7)
->  Task (shard owning k=2):  Index Only Scan  Index Cond: (k=2 AND v=7)
```

## How it works
In `SqlTaskList()` (the logical/physical-planner path used by
multi-shard
SELECTs — these are not router queries), while generating each per-shard
task
query, drop the arms of top-level OR expressions that cannot match on
that
task's shard.

- `PruneUnreachableOrArms()` recurses through top-level `List`/`AND`;
for an
  `OR` it keeps only the arms whose pruned shard set (from the existing
`PruneShards()`) contains the task's shard. An arm with no constraint on
the
distribution column prunes to all shards and is therefore always kept,
so the
  rewrite is a guaranteed semantic no-op.
- Reachability depends only on `(relation, rangeTableId, arm)`, never on
the
task, so `ReachableShardListForArm()` caches results in a job-wide hash
table
(`HTAB`) keyed on `(relationId, rangeTableId, arm)`: `PruneShards()`
runs once
per distinct arm instead of once per (shard x arm). The hash uses the
arm's
serialized form and the comparator uses node `equal()`, so structurally
identical arms from different per-task query copies share one entry and
hash
  collisions never produce a wrong hit.

## Safety
- **On by default**; set `citus.enable_or_clause_arm_pruning = off` to
restore
the previous behavior byte-for-byte (the entire new code path is inside
`if (EnableOrClauseArmPruning)`). The full `check-multi` schedule (193
tests)
passes with the default on; the only expected-output change is additive
`DEBUG` lines in `multi_hash_pruning` (the per-arm `PruneShards()` now
runs
  during task generation) — no result/row changes.
- Correctness invariant: an arm is dropped on shard S only when it
provably
matches no row on S. Two independent code reviews found no correctness
issues;
joins, self-joins, params, NULLs, reference-table joins, ranges/IN,
nested
  and mixed boolean, and value collisions were all validated.

## Tests
`src/test/regress/sql/multi_or_arm_pruning.sql` (wired into
`multi_1_schedule`):
- 5 before/after per-shard `Filter` comparisons (`shard_filters()`
helper),
including a hash-collision case that asserts the
`>=2`-equality-arms-kept
shape and is self-checking (if `k=1`/`k=5` ever stop colliding the
grouped
filter splits and the test fails rather than silently passing), plus
pure-range
  and mixed range+equality shapes.
- 18 result-parity checks (off vs on, multiset equality via `EXCEPT
ALL`):
basic, shard collision, no-dist-constraint arm, mixed AND/OR, nested OR,
range, `IN`, colocated join, cross-table-arm join, reference-table join,
null-distribution-key (single-shard) join, repartition (non-colocated)
join,
prepared/parameterized query, no-WHERE, top-level `NOT`, and no-op
cases.
- The `NULL`-key join exercises the `!HasDistributionKeyCacheEntry`
eligibility
guard; the repartition join exercises the `fragmentType !=
CITUS_RTE_RELATION`
  guard.
- Deterministic and environment-independent (helpers normalize shard
ids; no
ports/task-order dependence). The `Filter` outputs were verified against
an
independent ground-truth query formulation, not just off-vs-on parity.
Passes
  the real pg_regress harness.

## Known considerations / follow-ups
- The "BEFORE" EXPLAIN line reflects the worker planner's boolean
factoring of
the OR, which is mildly PG-version-sensitive; may need a variant
expected file
  for PG17/18. The "AFTER" lines (what the feature controls) are robust.
- CHANGELOG intentionally not touched (Citus generates it at release
time).

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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