Keep IN/OR conditions indexed when ANDed with a non-indexable condition - #4786
Open
alco wants to merge 4 commits into
Open
Keep IN/OR conditions indexed when ANDed with a non-indexable condition#4786alco wants to merge 4 commits into
alco wants to merge 4 commits into
Conversation
`WhereCondition.optimise_where/1` only accepted a single indexable
operation on either side of an `AND`. An `OR` tree (which is what an
`IN (...)` list becomes) matched neither pattern, so a clause such as
category_id IN ('a', 'b') AND deleted_at IS NULL
fell through to `:not_optimised` and the whole conjunction, indexable
`IN` included, landed in `other_shapes`. Every change then had to
evaluate the full where clause of every shape on that node, at a cost
linear in shapes x IN-list size. The same thing happened one level down
for `tenant_id = $1 AND category_id IN (...) AND deleted_at IS NULL`,
where the residual handed to the child node has the same form, so the
shape looked indexed at the top level while routing was still O(n).
Distribute the `AND` over the `OR` tree: `(a OR b) AND c` becomes
`(a AND c) OR (b AND c)`. Each branch is then `indexable AND residual`,
which the existing `and_where` machinery already handles, so `c` is only
evaluated for the shapes the index selects. The same rule now indexes
`IN (...) AND IN (...)`, where distributing over one tree leaves the
other as the residual that the child node splits again; since that
multiplies the number of index leaves, it is only done while the
product stays under a cap (1000 leaves).
Fixes #4742
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018WW5twMXeovo29MyQdGWkW
Distributing `AND` over two `OR`/`IN` trees creates n x m index leaves for a single shape. Each leaf costs roughly 1 KB of ETS and the whole expansion is paid on every `add_shape`/`remove_shape` (and again for every shape on restart), so the right cap depends on the workload: routing throughput favours a high cap, cheap shape creation/removal favours a low one. Measured at the 1000-leaf default a shape costs about 1 MB and 20 ms to add; at 10 000 leaves about 20 MB and 1 s. Replace the module attribute with `shape_filter_max_distributed_leaves`, wired the same way as the other performance tweaks: an `ELECTRIC_TWEAKS_SHAPE_FILTER_MAX_DISTRIBUTED_LEAVES` env var, a default in `Electric.Config`, threaded through `tweaks` into the stack config. `Filter.new/1` and `Filter.indexed_shape?/2` resolve it from an explicit option, then the stack config, then the application default, so the filter and the `ShapeStatus` indexability check in the same stack always agree. A value of 0 disables distribution over two trees while leaving `OR-tree AND <non-indexable>` indexed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018WW5twMXeovo29MyQdGWkW
The cap on distributing `AND` over two `OR` trees compared a syntactic
leaf count (`OR` adds, `AND` multiplies) against the limit. That counted
the branches of every `OR`, including ones that are not indexable and
therefore stay a single residual entry, so a clause such as
a IN (20) AND c IN (20) AND (d > 1 OR d < 0 OR d IS NULL)
was estimated at 1200 leaves and refused, when it actually expands to
400.
Have `optimise_where/2` accumulate the leaf count as it recurses: each
indexable operation carries `leaves:` (its residual's count, or 1) and
the `{:or, left, right}` tuple gains a fourth element with the sum of its
branches. `:not_optimised` counts as one `other_shapes` entry. The cap
check then multiplies the two sides' counts directly, which is exact for
what the tree will build and does not re-optimise any subtree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018WW5twMXeovo29MyQdGWkW
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4786 +/- ##
==========================================
+ Coverage 60.02% 60.07% +0.05%
==========================================
Files 397 397
Lines 43772 43772
Branches 12588 12590 +2
==========================================
+ Hits 26272 26297 +25
+ Misses 17418 17394 -24
+ Partials 82 81 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An indexable
IN (...)/ORcondition was dropped from the shape filter index as soon as it wasANDed with a condition that is not itself a single indexable operation:WhereCondition.optimise_where/1only recognised a%{operation: _}map on either side of anAND. AnORtree (which is what anINlist is turned into since #4134) returns{:or, left, right}, which matched neither pattern, so the whole conjunction fell through to:not_optimisedand landed inother_shapes. Every change on that table then evaluated the full where clause,INlist included, for every such shape on the node — O(shapes × IN-list size) per change instead of an index lookup.The same thing happened one level down the tree.
"tenant_id" = $1 AND "category_id" IN (...) AND "deleted_at" IS NULLindexes the equality at the root, but the residual handed to the child node isIN (...) AND IS NULL, which hit the same bug there.Filter.indexed_shape?/1reportedtruefor that shape while routing under a given tenant was still linear.The docs list
optimized_condition AND non_optimized_conditionas an optimised form, so this is a bug against documented behaviour.Fix
Distribute the
ANDover theORtree:(a OR b) AND cbecomes(a AND c) OR (b AND c). Each branch isindexable AND residual, which the existingand_where/ branch-key machinery already handles: the branch is indexed andcis only evaluated for the shapes the index selects.remove_shapedispatches on the sameoptimise_whereresult, so cleanup stays symmetric.Three new cases in the
ANDclause ofoptimise_where:{:or, ...}AND:not_optimised(either order) → distribute, unconditionally. The number of index leaves is the same as for theORtree on its own.{:or, ...}AND{:or, ...}(e.g.id IN (...) AND number IN (...)) → distribute over the left tree; the right tree becomes the residual of each branch and is split again by the child node, giving a nested trie (nentries at the parent,munder each). This multiplies the number of index leaves, so it is only done while the product of both sides' leaf counts is within a cap. Above the cap the clause falls back toother_shapes, i.e. today's behaviour. Leaf counts are accumulated byoptimise_where/2as it recurses (each indexable operation carriesleaves:, anORtree carries the sum of its branches,:not_optimisedis oneother_shapesentry), so the count is exactly what the tree will build — a non-indexableORsuch as(d > 1 OR d IS NULL)next to twoINlists counts as one residual leaf, not its branches — and no subtree is optimised twice.No change for clauses that were already indexed.
Configurable cap
The cap is
shape_filter_max_distributed_leaves, default 1000, settable withELECTRIC_TWEAKS_SHAPE_FILTER_MAX_DISTRIBUTED_LEAVESand wired like the other performance tweaks (Electric.Configdefault →tweaks→ stack config).Filter.new/1andFilter.indexed_shape?/2resolve it from an explicit option, then the stack config, then the application default, so the filter and theShapeStatusindexability check in a stack always agree.0disables distribution over two trees while leavingOR-tree AND <non-indexable>indexed.Why it's a knob — the trade-off is workload-dependent. Measured on a dev build for one
a IN (n) AND b IN (m)shape:Add/remove cost lands on the serialised
ShapeLogCollectorpath and is paid again for every shape on restart, so a deployment that creates/removes many such shapes may want a lower cap; one with many long-lived shapes and high write volume may want a higher one.Per-leaf cost (~1 KB, dominated by the
branch_keypath list on eachwhere_condleaf row) is pre-existing: a bareIN (K)list is already O(K²) memory and O(K³)add_shapetime onmain(IN (1000)≈ 8 MB / 8 s). This PR doesn't change that;IN (K) AND <residual>shapes now inherit it where they used to be cheap-to-add / slow-to-route. A compact branch-key representation is the follow-up that would make raising the default reasonable.Before / after
Running the reproduction script from #4742 on this branch:
The new reduction-budget tests in
filter_test.exs(--include performance) fail onmainwith routing cost growing linearly in the number of shapes and pass here within the same budget as the existingfield = const1 OR field = const2test.Fixes #4742
Notes
add_shapeis O(node population) for unindexed clauses: the wholeother_shapesmap is copied in and out of ETS per insert #4743 (theother_shapesmap being copied in and out of ETS on every insert) is independent and not addressed here. This change moves the affected shapes out ofother_shapes, which removes the trigger the reporter hit, but the quadratic build cost for genuinely non-indexable shapes remains.🤖 Generated with Claude Code
https://claude.ai/code/session_018WW5twMXeovo29MyQdGWkW