Skip to content

Keep IN/OR conditions indexed when ANDed with a non-indexable condition - #4786

Open
alco wants to merge 4 commits into
mainfrom
fix/index-or-tree-under-and
Open

Keep IN/OR conditions indexed when ANDed with a non-indexable condition#4786
alco wants to merge 4 commits into
mainfrom
fix/index-or-tree-under-and

Conversation

@alco

@alco alco commented Aug 27, 2026

Copy link
Copy Markdown
Member

Summary

An indexable IN (...) / OR condition was dropped from the shape filter index as soon as it was ANDed with a condition that is not itself a single indexable operation:

"category_id" IN ('c1', 'c2', 'c3') AND "deleted_at" IS NULL

WhereCondition.optimise_where/1 only recognised a %{operation: _} map on either side of an AND. An OR tree (which is what an IN list is turned into since #4134) returns {:or, left, right}, which matched neither pattern, so the whole conjunction fell through to :not_optimised and landed in other_shapes. Every change on that table then evaluated the full where clause, IN list 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 NULL indexes the equality at the root, but the residual handed to the child node is IN (...) AND IS NULL, which hit the same bug there. Filter.indexed_shape?/1 reported true for that shape while routing under a given tenant was still linear.

The docs list optimized_condition AND non_optimized_condition as an optimised form, so this is a bug against documented behaviour.

Fix

Distribute the AND over the OR tree: (a OR b) AND c becomes (a AND c) OR (b AND c). Each branch is indexable AND residual, which the existing and_where / branch-key machinery already handles: the branch is indexed and c is only evaluated for the shapes the index selects. remove_shape dispatches on the same optimise_where result, so cleanup stays symmetric.

Three new cases in the AND clause of optimise_where:

  • {:or, ...} AND :not_optimised (either order) → distribute, unconditionally. The number of index leaves is the same as for the OR tree 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 (n entries at the parent, m under 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 to other_shapes, i.e. today's behaviour. Leaf counts are accumulated by optimise_where/2 as it recurses (each indexable operation carries leaves:, an OR tree carries the sum of its branches, :not_optimised is one other_shapes entry), so the count is exactly what the tree will build — a non-indexable OR such as (d > 1 OR d IS NULL) next to two IN lists 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 with ELECTRIC_TWEAKS_SHAPE_FILTER_MAX_DISTRIBUTED_LEAVES and wired like the other performance tweaks (Electric.Config default → tweaks → 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 a stack always agree. 0 disables distribution over two trees while leaving OR-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:

leaves indexed add remove ETS route/change
1000 (25×40) yes 20 ms 21 ms 1.0 MB 5–18 µs
1024 (32×32), cap 1000 no (fallback) 0.1 ms 0.1 ms 98 KB 38 µs per shape on the node
10 000 (100×100), cap 1000 no (fallback) 0.6 ms 0.6 ms 304 KB 115 µs per shape on the node
10 000 (100×100), cap 10 000 yes 945 ms 1.1 s 20 MB 8 µs

Add/remove cost lands on the serialised ShapeLogCollector path 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_key path list on each where_cond leaf row) is pre-existing: a bare IN (K) list is already O(K²) memory and O(K³) add_shape time on main (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:

IN(3): true
IN(3) AND TRUE: true          # was false
IN(3) AND IS NULL: true       # was false

The new reduction-budget tests in filter_test.exs (--include performance) fail on main with routing cost growing linearly in the number of shapes and pass here within the same budget as the existing field = const1 OR field = const2 test.

Fixes #4742

Notes

🤖 Generated with Claude Code

https://claude.ai/code/session_018WW5twMXeovo29MyQdGWkW

alco and others added 4 commits August 27, 2026 12:26
`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

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 60.07%. Comparing base (dc07a1e) to head (bb8a3be).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

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     
Flag Coverage Δ
packages/agents 72.64% <ø> (ø)
packages/agents-mcp 77.70% <ø> (ø)
packages/agents-mobile 80.67% <ø> (ø)
packages/agents-runtime 83.78% <ø> (+0.06%) ⬆️
packages/agents-server 75.65% <ø> (+0.17%) ⬆️
packages/agents-server-ui 8.32% <ø> (ø)
packages/electric-ax 51.06% <ø> (ø)
packages/experimental 87.73% <ø> (ø)
packages/react-hooks 86.48% <ø> (ø)
packages/start 82.83% <ø> (ø)
packages/typescript-client 91.95% <ø> (+0.11%) ⬆️
packages/y-electric 56.05% <ø> (ø)
typescript 60.07% <ø> (+0.05%) ⬆️
unit-tests 60.07% <ø> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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 indexable IN/OR conjunct is dropped from the filter index when ANDed with a non-optimized condition

1 participant