Skip to content

Commit 1d0b599

Browse files
improvement: avoid quadratic cost evaluating in-list filters at runtime (ash-project#2802)
* test: characterise the equality `in` means at runtime `Ash.Query.Operator.In.evaluate/1` decides membership with `Comp.equal?/2`, which is semantic rather than structural equality: `1` equals `1.0`, a `Decimal` equals both the integer and the string spelling the same number, an `Ash.CiString` equals a binary differing only in case, and — since OTP 27 made `0.0` and `-0.0` distinct terms — signed zeroes still equal each other. None of that was covered. Pin it down before touching the function, so that any later change to how membership is decided has to keep meaning the same thing. The `Version` struct stands in for the comparability any application can add for its own types with `Ash.Type.Comparable.defcomparable/3`, which is why no amount of knowledge about built-in types can substitute for asking `Comp`. * improvement: decide `in` by set membership before comparing members `Ash.Query.Operator.In.new/2` builds a `MapSet` out of the member list, but `evaluate/1` never used it as one — it walked the members, calling `Comp.equal?/2` on each. The set bought nothing, and every record put through a runtime filter paid a scan of the whole list. That is quadratic exactly where it hurts. `Ash.Filter.Runtime` evaluates the filter once per record, and a relationship load frames as `source_attribute in [N parent ids]` over the N records it selects: N records by N members. Nearly all of that time is spent in `Comp.new/2`, which resolves the `Comparable` implementation by concatenating a module name at runtime, once per member per record. A set miss cannot settle the question, because `Comp.equal?/2` matches values that a `MapSet` holds apart and applications can add further pairings with `Ash.Type.Comparable.defcomparable/3`. A set hit can settle it: equality is reflexive, so a member that is the same term is a member that compares equal. Take that hit in constant time, and fall back to the scan only on a miss. Filtering N records against N ids, the shape a relationship load takes: records before after 500 79.9 ms 0.4 ms 1,000 380 ms 0.7 ms 2,000 1.42 s 1.5 ms 4,000 5.55 s 3.2 ms 8,000 21.7 s 5.9 ms Per-record cost is now flat rather than growing with the size of the list. Records whose value is absent from the list still walk the members; that is the price of semantic equality staying semantic.
1 parent eb7f5ef commit 1d0b599

2 files changed

Lines changed: 158 additions & 0 deletions

File tree

lib/ash/query/operator/in.ex

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ defmodule Ash.Query.Operator.In do
3030
def evaluate(%{left: nil}), do: {:known, nil}
3131
def evaluate(%{right: nil}), do: {:known, nil}
3232

33+
def evaluate(%{left: left, right: %MapSet{} = right}) do
34+
# `Comp.equal?/2` is semantic rather than structural equality — `1` equals
35+
# `1.0`, a `Decimal` equals the integer and the string spelling the same
36+
# number, an `Ash.CiString` equals a binary differing only in case — so a
37+
# set miss does not imply the value is absent. A set hit does imply a match,
38+
# though, so take it directly and only walk the members when it misses.
39+
{:known, MapSet.member?(right, left) or Enum.any?(right, &Comp.equal?(&1, left))}
40+
end
41+
3342
def evaluate(%{left: left, right: right}) do
3443
{:known, Enum.any?(right, &Comp.equal?(&1, left))}
3544
end

test/query/operator/in_test.exs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,29 @@
22
#
33
# SPDX-License-Identifier: MIT
44

5+
defmodule Ash.Query.Operator.InTest.Version do
6+
@moduledoc false
7+
defstruct [:number]
8+
end
9+
10+
import Ash.Type.Comparable
11+
12+
# Stands in for the comparability any application can add for its own types with
13+
# `Ash.Type.Comparable.defcomparable/3`. `In` cannot know about it ahead of time,
14+
# which is why set membership alone can never decide a miss.
15+
defcomparable left :: Ash.Query.Operator.InTest.Version, right :: BitString do
16+
Comp.compare(left.number, right)
17+
end
18+
519
defmodule Ash.Query.Operator.InTest do
620
use ExUnit.Case
721

22+
import Ash.Expr
23+
824
alias Ash.Query.Operator.Eq
925
alias Ash.Query.Operator.In
26+
alias Ash.Query.Operator.InTest.Version
27+
alias Ash.Test.Domain, as: Domain
1028

1129
describe "compare/2" do
1230
test "returns :mutually_inclusive for equal left and right values" do
@@ -30,4 +48,135 @@ defmodule Ash.Query.Operator.InTest do
3048
assert In.compare(left, right) == :left_includes_right
3149
end
3250
end
51+
52+
describe "evaluate/1" do
53+
test "a member of the set matches" do
54+
assert In.evaluate(%In{left: "b", right: MapSet.new(["a", "b", "c"])}) == {:known, true}
55+
end
56+
57+
test "a value absent from the set does not match" do
58+
assert In.evaluate(%In{left: "z", right: MapSet.new(["a", "b", "c"])}) == {:known, false}
59+
end
60+
61+
test "an empty set matches nothing" do
62+
assert In.evaluate(%In{left: "a", right: MapSet.new([])}) == {:known, false}
63+
end
64+
65+
test "a nil operand is unknown rather than false" do
66+
assert In.evaluate(%In{left: nil, right: MapSet.new(["a"])}) == {:known, nil}
67+
assert In.evaluate(%In{left: "a", right: nil}) == {:known, nil}
68+
end
69+
70+
test "a list on the right is supported alongside a set" do
71+
assert In.evaluate(%In{left: "b", right: ["a", "b"]}) == {:known, true}
72+
assert In.evaluate(%In{left: "z", right: ["a", "b"]}) == {:known, false}
73+
end
74+
end
75+
76+
# `Comp.equal?/2` is semantic, not structural. Each of these values is absent
77+
# from the set under term equality but equal to a member under `Comp`, so each
78+
# one only matches if the set miss falls back to comparing the members.
79+
describe "evaluate/1 with semantically equal but structurally distinct members" do
80+
test "integers and floats" do
81+
assert In.evaluate(%In{left: 1, right: MapSet.new([1.0])}) == {:known, true}
82+
assert In.evaluate(%In{left: 1.0, right: MapSet.new([1])}) == {:known, true}
83+
end
84+
85+
test "decimals against integers and strings" do
86+
assert In.evaluate(%In{left: Decimal.new(1), right: MapSet.new([1])}) == {:known, true}
87+
assert In.evaluate(%In{left: 1, right: MapSet.new([Decimal.new(1)])}) == {:known, true}
88+
assert In.evaluate(%In{left: Decimal.new(1), right: MapSet.new(["1"])}) == {:known, true}
89+
end
90+
91+
test "decimals differing only in trailing zeroes" do
92+
assert In.evaluate(%In{left: Decimal.new("1.0"), right: MapSet.new([Decimal.new("1.00")])}) ==
93+
{:known, true}
94+
end
95+
96+
test "case insensitive strings against binaries" do
97+
assert In.evaluate(%In{left: Ash.CiString.new("FOO"), right: MapSet.new(["foo"])}) ==
98+
{:known, true}
99+
100+
assert In.evaluate(%In{left: "FOO", right: MapSet.new([Ash.CiString.new("foo")])}) ==
101+
{:known, true}
102+
end
103+
104+
test "atoms against binaries" do
105+
assert In.evaluate(%In{left: :foo, right: MapSet.new(["foo"])}) == {:known, true}
106+
end
107+
108+
# Since OTP 27 `0.0` and `-0.0` are distinct terms under `===`, and so under
109+
# the term equality a `MapSet` is built on — `MapSet.new([0.0, -0.0])` has two
110+
# members. They are equal under `==`, and so under `Comp`, which is what `in`
111+
# has always meant here.
112+
test "signed zeroes, which a set holds apart but Comp does not" do
113+
assert In.evaluate(%In{left: -0.0, right: MapSet.new([0.0])}) == {:known, true}
114+
assert In.evaluate(%In{left: 0.0, right: MapSet.new([-0.0])}) == {:known, true}
115+
assert In.evaluate(%In{left: -0.0, right: MapSet.new([0])}) == {:known, true}
116+
end
117+
118+
test "signed zero decimals, which are distinct terms but compare equal" do
119+
positive = Decimal.new("0.0")
120+
negative = Decimal.new("-0.0")
121+
122+
refute positive == negative
123+
assert Decimal.compare(positive, negative) == :eq
124+
125+
assert In.evaluate(%In{left: negative, right: MapSet.new([positive])}) == {:known, true}
126+
assert In.evaluate(%In{left: positive, right: MapSet.new([negative])}) == {:known, true}
127+
assert In.evaluate(%In{left: negative, right: MapSet.new([0])}) == {:known, true}
128+
end
129+
130+
test "types made comparable by the application" do
131+
assert In.evaluate(%In{left: %Version{number: "1.0"}, right: MapSet.new(["1.0"])}) ==
132+
{:known, true}
133+
134+
assert In.evaluate(%In{left: %Version{number: "1.0"}, right: MapSet.new(["2.0"])}) ==
135+
{:known, false}
136+
end
137+
end
138+
139+
describe "filter_matches/3" do
140+
defmodule Post do
141+
@moduledoc false
142+
use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets
143+
144+
attributes do
145+
uuid_primary_key :id
146+
attribute :title, :string, public?: true
147+
attribute :score, :decimal, public?: true
148+
end
149+
150+
actions do
151+
defaults [:read]
152+
end
153+
end
154+
155+
defp matching_titles(posts, filter) do
156+
{:ok, matches} = Ash.Filter.Runtime.filter_matches(Domain, posts, filter)
157+
Enum.map(matches, & &1.title)
158+
end
159+
160+
setup do
161+
%{posts: Enum.map(["a", "b", "c"], &struct(Post, title: &1, score: Decimal.new(1)))}
162+
end
163+
164+
test "selects exactly the records whose value is in the list", %{posts: posts} do
165+
filter = Ash.Filter.parse!(Post, expr(title in ["a", "c"]))
166+
167+
assert matching_titles(posts, filter) == ["a", "c"]
168+
end
169+
170+
test "selects nothing when no record's value is in the list", %{posts: posts} do
171+
filter = Ash.Filter.parse!(Post, expr(title in ["x", "y"]))
172+
173+
assert matching_titles(posts, filter) == []
174+
end
175+
176+
test "selects records whose value is only semantically in the list", %{posts: posts} do
177+
filter = Ash.Filter.parse!(Post, expr(score in [1]))
178+
179+
assert matching_titles(posts, filter) == ["a", "b", "c"]
180+
end
181+
end
33182
end

0 commit comments

Comments
 (0)