You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The shapes guide lists "Mixed optimized and non-optimized conditions with AND/OR" as an optimized where-clause pattern, and states that Electric maintains "a consistent throughput of ~5,000 row changes per second no matter how many shapes you have".
That combination is not optimized. When an IN list (or any OR tree) is ANDed with a condition
that is not itself a single indexable operation, the whole conjunction is dropped into other_shapes and evaluated per shape on every change. The indexable IN goes with it. Routing then costs O(shapes on that node) per change.
"field" IN (...) on its own indexes correctly. Adding any non-optimized conjunct removes it from the index, including a constant that folds to TRUE.
Expected: the IN list stays indexed, and the non-optimized conjunct is evaluated as a residual against the shapes the index selects.
Actual: the IN list is dropped from the index and the whole clause is evaluated against every shape on the node, for every change.
Versions: Electric sync-service v1.7.8, also reproduced on main @ 917589195. Plain HTTP client; this is server-side filter behaviour and does not depend on the client.
Reproduction
Save as packages/sync-service/repro_in_and.exs, run with MIX_ENV=test mix run --no-start repro_in_and.exs:
aliasElectric.Shapes.FilteraliasElectric.Shapes.ShapealiasSupport.StubInspectorinspector=StubInspector.new(tables: ["items"],columns: [%{name: "id",type: "text",pk_position: 0},%{name: "tenant_id",type: "text"},%{name: "category_id",type: "text"},%{name: "deleted_at",type: "timestamptz"}])indexed?=fnwhere->Filter.indexed_shape?(Shape.new!("items",where: where,inspector: inspector))endIO.inspect(indexed?.(~s|"category_id" IN ('c1','c2','c3')|),label: "IN(3)")IO.inspect(indexed?.(~s|"category_id" IN ('c1','c2','c3') AND TRUE|),label: "IN(3) AND TRUE")IO.inspect(indexed?.(~s|"category_id" IN ('c1','c2','c3') AND "deleted_at" IS NULL|),label: "IN(3) AND IS NULL")
Output on v1.7.8:
IN(3): true
IN(3) AND TRUE: false
IN(3) AND IS NULL: false
Mechanism
defpoptimise_where(%Func{name: "and",args: [left,right]})docase{optimise_where(left),optimise_where(right)}do{%{operation: _}=optimisation,_}-># index left, residualise right{_,%{operation: _}=optimisation}-># index right, residualise left_->:not_optimisedendend
optimise_where on an OR returns an {:or, left, right} tuple, which matches neither %{operation: _} pattern. AND(or_tree, non_indexable) therefore falls through to :not_optimised, and add_shape/5 sends the whole clause to add_shape_to_other_shapes/5.
An and_where residual is re-optimised inside a child WhereCondition node, so this does not have to happen at the top level. It happens at whichever node's residual has the form AND(or_tree, non_indexable).
Control:"tenant_id" = 't1' AND "category_id" IN ('c1'..'c5') indexes at both levels, tenant_id at the root and the IN splitting per value in the child node. Hierarchical indexing works, and the IN would be indexed. The trigger is one non-optimized conjunct in the residual.
Adding a third conjunct to that working clause breaks it:
"tenant_id"= $1AND"category_id"IN ($2..$n) AND"deleted_at" IS NULL
Every change matching tenant_id now evaluates the IN list of every shape on that node.
Impact
Filter.affected_shapes/2, µs per change, v1.7.8. K is the IN list size, n the number of
shapes sharing the indexed parent value. Medians of 5 to 10 interleaved samples, spreads within a
few percent.
K
n
µs/change
implied row changes/sec
1
20,000
35
28,000
10
100
763
1,300
10
1,000
7,954
126
10
5,000
39,843
25
45
1,000
27,084
37
45
5,000
150,237
6.7
150
100
10,519
95
150
1,000
96,697
10
Cost is linear in n × K at roughly 0.8 µs per (shape × IN element). At K=45 and n=5,000 the filter sustains about 6.7 changes/sec, against a documented ~5,000 that should hold "no matter how many shapes you have". This is filter routing alone, which is serialized in the ShapeLogCollector.
K = 1 is unaffected, as expected, since a one-element IN is a plain equality.
Where clause optimisation for OR operator #4134 generalised to OR splitting and removed the IN special case, with the rule "split OR
branches only when both sides are indexable". The interaction with a non-indexable AND sibling
does not appear to have been considered, and we could not find an existing issue covering it.
Eval.Decomposer and DnfPlan implement full DNF decomposition but are wired only into subquery
routing (maybe_register_subquery_shape), so they are unavailable to plain shapes.
Suggested direction
Distribute AND over OR, so (a OR b) AND c becomes (a AND c) OR (b AND c) and each disjunct still reaches an index. Two guards keep the expansion bounded: refuse distribution unless the residual is OR-free, so a IN (n) AND b IN (m) falls back to current behaviour instead of fanning out to n × m; and cap the number of disjuncts.
We run a patch along these lines in production and can open a PR with benchmarks and differential tests if that direction is useful.
Environment
Electric sync-service v1.7.8, also verified on main @ 917589195. The affected code is
unchanged from v1.6.10 through main.
Elixir 1.20.2 / OTP 29.0.2.
Written with AI assistance and reviewed by a human before filing. The reproduction script was run against v1.7.8 and produces the output shown. The timings are measured rather than estimated, on one machine, with both arms on the same toolchain image. We can re-run anything or share the benchmark harness if the numbers are hard to reproduce.
Summary
The shapes guide lists "Mixed optimized and non-optimized conditions with
AND/OR" as an optimized where-clause pattern, and states that Electric maintains "a consistent throughput of ~5,000 row changes per second no matter how many shapes you have".That combination is not optimized. When an
INlist (or anyORtree) isANDed with a conditionthat is not itself a single indexable operation, the whole conjunction is dropped into
other_shapesand evaluated per shape on every change. The indexableINgoes with it. Routing then costs O(shapes on that node) per change."field" IN (...)on its own indexes correctly. Adding any non-optimized conjunct removes it from the index, including a constant that folds toTRUE.Expected: the
INlist stays indexed, and the non-optimized conjunct is evaluated as a residual against the shapes the index selects.Actual: the
INlist is dropped from the index and the whole clause is evaluated against every shape on the node, for every change.Versions: Electric sync-service v1.7.8, also reproduced on
main@917589195. Plain HTTP client; this is server-side filter behaviour and does not depend on the client.Reproduction
Save as
packages/sync-service/repro_in_and.exs, run withMIX_ENV=test mix run --no-start repro_in_and.exs:Output on v1.7.8:
Mechanism
optimise_whereon anORreturns an{:or, left, right}tuple, which matches neither%{operation: _}pattern.AND(or_tree, non_indexable)therefore falls through to:not_optimised, andadd_shape/5sends the whole clause toadd_shape_to_other_shapes/5.An
and_whereresidual is re-optimised inside a childWhereConditionnode, so this does not have to happen at the top level. It happens at whichever node's residual has the formAND(or_tree, non_indexable).Control:
"tenant_id" = 't1' AND "category_id" IN ('c1'..'c5')indexes at both levels,tenant_idat the root and theINsplitting per value in the child node. Hierarchical indexing works, and theINwould be indexed. The trigger is one non-optimized conjunct in the residual.Adding a third conjunct to that working clause breaks it:
Every change matching
tenant_idnow evaluates theINlist of every shape on that node.Impact
Filter.affected_shapes/2, µs per change, v1.7.8.Kis theINlist size,nthe number ofshapes sharing the indexed parent value. Medians of 5 to 10 interleaved samples, spreads within a
few percent.
Cost is linear in
n × Kat roughly 0.8 µs per (shape ×INelement). At K=45 and n=5,000 the filter sustains about 6.7 changes/sec, against a documented ~5,000 that should hold "no matter how many shapes you have". This is filter routing alone, which is serialized in theShapeLogCollector.K = 1is unaffected, as expected, since a one-elementINis a plain equality.Prior art
ANDindexing and the@>inclusion index.INlists viaflatten_or_equalities. Review comment on that PR: "it should justhandle OR rather than special case IN".
ORoperator #4134 generalised toORsplitting and removed theINspecial case, with the rule "split ORbranches only when both sides are indexable". The interaction with a non-indexable
ANDsiblingdoes not appear to have been considered, and we could not find an existing issue covering it.
Eval.DecomposerandDnfPlanimplement full DNF decomposition but are wired only into subqueryrouting (
maybe_register_subquery_shape), so they are unavailable to plain shapes.Suggested direction
Distribute
ANDoverOR, so(a OR b) AND cbecomes(a AND c) OR (b AND c)and each disjunct still reaches an index. Two guards keep the expansion bounded: refuse distribution unless the residual isOR-free, soa IN (n) AND b IN (m)falls back to current behaviour instead of fanning out ton × m; and cap the number of disjuncts.We run a patch along these lines in production and can open a PR with benchmarks and differential tests if that direction is useful.
Environment
main@917589195. The affected code isunchanged from v1.6.10 through
main.Written with AI assistance and reviewed by a human before filing. The reproduction script was run against v1.7.8 and produces the output shown. The timings are measured rather than estimated, on one machine, with both arms on the same toolchain image. We can re-run anything or share the benchmark harness if the numbers are hard to reproduce.