Skip to content

Commit 4a7b9d7

Browse files
committed
add test for cache eviction, fix eviction
1 parent db771da commit 4a7b9d7

File tree

3 files changed

+137
-24
lines changed

3 files changed

+137
-24
lines changed

lib/mosaic/shard_router.ex

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ defmodule Mosaic.ShardRouter do
5252

5353
def find_similar_shards(query_vector, limit, opts \\ []), do: GenServer.call(__MODULE__, {:find_similar, query_vector, limit, opts}, 30_000)
5454
def reset_state(), do: GenServer.call(__MODULE__, :reset_state)
55+
def get_cache_state(), do: GenServer.call(__MODULE__, :get_cache_state)
5556

5657
def handle_call({:find_similar, query_vector, limit, opts}, _from, state) do
5758
min_similarity = Keyword.get(opts, :min_similarity, Mosaic.Config.get(:min_similarity))
@@ -73,15 +74,17 @@ defmodule Mosaic.ShardRouter do
7374
Exqlite.Sqlite3.close(state.routing_conn)
7475

7576
routing_db_path = Mosaic.Config.get(:routing_db_path)
77+
max_size = Mosaic.Config.get(:routing_cache_max_size) # <-- Add this line
78+
7679
{:ok, conn} = Exqlite.Sqlite3.open(routing_db_path)
7780
initialize_routing_schema(conn)
7881

7982
cache_table = :ets.new(:shard_cache, [:set, :private])
8083
access_table = :ets.new(:shard_access, [:ordered_set, :private])
8184
bloom_filters = load_bloom_filters(conn)
82-
counter = preload_hot_shards(conn, state.max_size, cache_table, access_table, 0)
85+
counter = preload_hot_shards(conn, max_size, cache_table, access_table, 0) # <-- Use max_size
8386

84-
new_state = %State{routing_conn: conn, cache_table: cache_table, access_table: access_table, access_counts: %{}, bloom_filters: bloom_filters, counter: counter, max_size: state.max_size, cache_hits: 0, cache_misses: 0}
87+
new_state = %State{routing_conn: conn, cache_table: cache_table, access_table: access_table, access_counts: %{}, bloom_filters: bloom_filters, counter: counter, max_size: max_size, cache_hits: 0, cache_misses: 0} # <-- Use max_size
8588
{:reply, :ok, new_state}
8689
end
8790

@@ -90,27 +93,39 @@ defmodule Mosaic.ShardRouter do
9093
{:reply, shards, state}
9194
end
9295

96+
def handle_call(:get_cache_state, _from, state) do
97+
cache_keys = :ets.tab2list(state.cache_table) |> Enum.map(fn {id, _} -> id end) |> Enum.sort()
98+
access_list = :ets.tab2list(state.access_table) |> Enum.sort()
99+
{:reply, %{cache_keys: cache_keys, access_list: access_list}, state}
100+
end
101+
93102
defp find_similar_cached(query_vector, limit, min_similarity, filter_ids, vector_math_impl, state) do
94103
query_norm = vector_math_impl.norm(query_vector)
95104

96105
candidates = if filter_ids do
97-
filter_ids |> Enum.filter_map(&:ets.member(state.cache_table, &1), fn id -> case :ets.lookup(state.cache_table, id) do [{^id, shard}] -> shard; [] -> nil end end) |> Enum.reject(&is_nil/1)
106+
filter_ids |> Enum.filter_map(&:ets.member(state.cache_table, &1), fn id ->
107+
case :ets.lookup(state.cache_table, id) do
108+
[{^id, shard}] -> shard
109+
[] -> nil
110+
end
111+
end) |> Enum.reject(&is_nil/1)
98112
else
99113
:ets.tab2list(state.cache_table) |> Enum.map(fn {_id, shard} -> shard end)
100114
end
101115

102-
if length(candidates) > 0 do
103-
shards = candidates |> Enum.map(fn shard ->
104-
centroid_vector = :erlang.binary_to_term(shard.centroid)
105-
similarity = vector_math_impl.cosine_similarity(query_vector, query_norm, centroid_vector, shard.centroid_norm)
106-
Map.put(shard, :similarity, similarity)
107-
end) |> Enum.filter(&(&1.similarity >= min_similarity)) |> Enum.sort_by(& &1.similarity, :desc) |> Enum.take(limit)
116+
cached_shards = candidates |> Enum.map(fn shard ->
117+
centroid_vector = :erlang.binary_to_term(shard.centroid)
118+
similarity = vector_math_impl.cosine_similarity(query_vector, query_norm, centroid_vector, shard.centroid_norm)
119+
Map.put(shard, :similarity, similarity)
120+
end) |> Enum.filter(&(&1.similarity >= min_similarity))
108121

109-
{shards, %{state | cache_hits: state.cache_hits + length(shards)}}
122+
if length(cached_shards) >= limit do
123+
result = Enum.sort_by(cached_shards, & &1.similarity, :desc) |> Enum.take(limit)
124+
{result, %{state | cache_hits: state.cache_hits + length(result)}}
110125
else
111-
shards = find_similar_db(query_vector, limit, min_similarity, filter_ids, vector_math_impl, state)
112-
new_counter = Enum.reduce(shards, state.counter, fn shard, cnt -> add_to_cache(shard, cnt, state); cnt + 1 end)
113-
{shards, %{state | counter: new_counter, cache_misses: state.cache_misses + length(shards)}}
126+
db_shards = find_similar_db(query_vector, limit, min_similarity, filter_ids, vector_math_impl, state)
127+
new_counter = Enum.reduce(db_shards, state.counter, fn shard, cnt -> add_to_cache(shard, cnt, state) end)
128+
{db_shards, %{state | counter: new_counter, cache_misses: state.cache_misses + 1}}
114129
end
115130
end
116131

@@ -135,17 +150,28 @@ defmodule Mosaic.ShardRouter do
135150
end
136151

137152
defp add_to_cache(shard, counter, state) do
138-
cache_size = :ets.info(state.cache_table, :size)
139-
if cache_size >= state.max_size do
140-
case :ets.first(state.access_table) do
141-
:"$end_of_table" -> :ok
142-
{old_counter, old_id} ->
143-
:ets.delete(state.access_table, {old_counter, old_id})
144-
:ets.delete(state.cache_table, old_id)
145-
end
153+
# Check if shard already exists
154+
case :ets.lookup(state.cache_table, shard.id) do
155+
[{_, _}] ->
156+
# Already cached, just update access
157+
:ets.match_delete(state.access_table, {{:_, shard.id}, :_})
158+
:ets.insert(state.access_table, {{counter, shard.id}, true})
159+
counter + 1
160+
[] ->
161+
# Not cached, need to add
162+
cache_size = :ets.info(state.cache_table, :size)
163+
if cache_size >= state.max_size do
164+
case :ets.first(state.access_table) do
165+
:"$end_of_table" -> :ok
166+
{old_counter, old_id} ->
167+
:ets.delete(state.access_table, {old_counter, old_id})
168+
:ets.delete(state.cache_table, old_id)
169+
end
170+
end
171+
:ets.insert(state.cache_table, {shard.id, shard})
172+
:ets.insert(state.access_table, {{counter, shard.id}, true})
173+
counter + 1
146174
end
147-
:ets.insert(state.cache_table, {shard.id, shard})
148-
:ets.insert(state.access_table, {{counter, shard.id}, true})
149175
end
150176

151177
defp filter_by_bloom(keywords, bloom_filters) do
@@ -182,6 +208,7 @@ defmodule Mosaic.ShardRouter do
182208

183209
defp update_access_stats(shards, state) do
184210
new_counts = Enum.reduce(shards, state.access_counts, fn shard, counts -> Map.update(counts, shard.id, 1, &(&1 + 1)) end)
211+
185212
new_counter = Enum.reduce(shards, state.counter, fn shard, cnt ->
186213
shard_id = shard.id
187214
case :ets.lookup(state.cache_table, shard_id) do
@@ -192,6 +219,7 @@ defmodule Mosaic.ShardRouter do
192219
[] -> cnt
193220
end
194221
end)
222+
195223
%{state | access_counts: new_counts, counter: new_counter}
196224
end
197225

test/mosaic/query_engine_test.exs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ defmodule Mosaic.QueryEngineTest do
88
temp_dir = Path.join(System.tmp_dir!(), "test_query_#{System.unique_integer([:positive])}")
99
File.mkdir_p!(temp_dir)
1010
on_exit(fn -> File.rm_rf!(temp_dir) end)
11-
11+
1212
# Check if QueryEngine is running
13+
unless Process.whereis(Mosaic.QueryEngine) do
14+
raise "QueryEngine not started - check application supervision"
15+
end
1316
case Process.whereis(Mosaic.QueryEngine) do
1417
nil -> {:ok, skip: true, temp_dir: temp_dir}
1518
_pid -> {:ok, skip: false, temp_dir: temp_dir}

test/mosaic/shard_router_test.exs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,88 @@ defmodule Mosaic.ShardRouterTest do
8181
)
8282
end
8383

84+
test "cache hit path returns cached shards", %{routing_db_path: routing_db_path} do
85+
{:ok, conn} = Exqlite.Sqlite3.open(routing_db_path)
86+
insert_shard_metadata(conn, "cached1", "/path1.db", 100)
87+
insert_shard_centroid(conn, "cached1", List.duplicate(1.0, 1536), 1.0)
88+
Mosaic.ShardRouter.reset_state()
89+
90+
query_vector = List.duplicate(1.0, 1536)
91+
{:ok, result1} = Mosaic.ShardRouter.find_similar_shards(query_vector, 1, vector_math_impl: TestVectorMath)
92+
{:ok, result2} = Mosaic.ShardRouter.find_similar_shards(query_vector, 1, vector_math_impl: TestVectorMath)
93+
94+
assert result1 == result2
95+
# Verify cache was actually used (check internal metrics)
96+
end
97+
98+
test "LRU eviction when cache exceeds max_size", %{routing_db_path: routing_db_path} do
99+
# Insert exactly 3 shards with VERY different vectors so we can control which ones match
100+
{:ok, conn} = Exqlite.Sqlite3.open(routing_db_path)
101+
102+
insert_shard_metadata(conn, "shard1", "/path1.db", 100)
103+
insert_shard_centroid(conn, "shard1", List.duplicate(1.0, 1536), :math.sqrt(1536))
104+
105+
insert_shard_metadata(conn, "shard2", "/path2.db", 200)
106+
insert_shard_centroid(conn, "shard2", List.duplicate(-1.0, 1536), :math.sqrt(1536))
107+
108+
insert_shard_metadata(conn, "shard3", "/path3.db", 300)
109+
insert_shard_centroid(conn, "shard3", List.duplicate(0.0, 1536) |> List.replace_at(0, 1.0), 1.0)
110+
111+
Mosaic.ShardRouter.reset_state()
112+
Exqlite.Sqlite3.close(conn)
113+
114+
# Query 1: Load shard1 only (highest similarity to [1,1,1,...])
115+
{:ok, [r1]} = Mosaic.ShardRouter.find_similar_shards(List.duplicate(1.0, 1536), 1, vector_math_impl: TestVectorMath, min_similarity: 0.9)
116+
assert r1.id == "shard1"
117+
118+
# Query 2: Load shard2 only (highest similarity to [-1,-1,-1,...])
119+
{:ok, [r2]} = Mosaic.ShardRouter.find_similar_shards(List.duplicate(-1.0, 1536), 1, vector_math_impl: TestVectorMath, min_similarity: 0.9)
120+
assert r2.id == "shard2"
121+
122+
# Cache now has shard1, shard2 (full)
123+
124+
# Query 3: Load shard3 - should evict shard1 (oldest)
125+
{:ok, [r3]} = Mosaic.ShardRouter.find_similar_shards([1.0] ++ List.duplicate(0.0, 1535), 1, vector_math_impl: TestVectorMath, min_similarity: 0.5)
126+
assert r3.id == "shard3"
127+
128+
%{cache_keys: cache_keys} = Mosaic.ShardRouter.get_cache_state()
129+
assert Enum.sort(cache_keys) == ["shard2", "shard3"]
130+
end
131+
132+
test "access updates move shard to end of LRU", %{routing_db_path: routing_db_path} do
133+
{:ok, conn} = Exqlite.Sqlite3.open(routing_db_path)
134+
on_exit(fn -> Exqlite.Sqlite3.close(conn) end)
135+
136+
# Orthogonal vectors - each shard only matches its own direction
137+
v1 = [1.0] ++ List.duplicate(0.0, 1535)
138+
v2 = [0.0, 1.0] ++ List.duplicate(0.0, 1534)
139+
v3 = [0.0, 0.0, 1.0] ++ List.duplicate(0.0, 1533)
140+
141+
insert_shard_metadata(conn, "shard1", "/path1.db", 100)
142+
insert_shard_centroid(conn, "shard1", v1, 1.0)
143+
144+
insert_shard_metadata(conn, "shard2", "/path2.db", 200)
145+
insert_shard_centroid(conn, "shard2", v2, 1.0)
146+
147+
insert_shard_metadata(conn, "shard3", "/path3.db", 300)
148+
insert_shard_centroid(conn, "shard3", v3, 1.0)
149+
150+
Mosaic.ShardRouter.reset_state()
151+
152+
# Load shard1 and shard2 (query matches both)
153+
query1 = [1.0, 1.0] ++ List.duplicate(0.0, 1534)
154+
{:ok, _} = Mosaic.ShardRouter.find_similar_shards(query1, 2, vector_math_impl: TestVectorMath, min_similarity: 0.5)
155+
156+
# Re-access shard1 (moves to end of LRU)
157+
{:ok, _} = Mosaic.ShardRouter.find_similar_shards(v1, 1, vector_math_impl: TestVectorMath, min_similarity: 0.99)
158+
159+
# Query for shard3 - cached shards have 0 similarity, forces DB lookup
160+
{:ok, _} = Mosaic.ShardRouter.find_similar_shards(v3, 1, vector_math_impl: TestVectorMath, min_similarity: 0.99)
161+
162+
%{cache_keys: cache_keys} = Mosaic.ShardRouter.get_cache_state()
163+
assert Enum.sort(cache_keys) == ["shard1", "shard3"]
164+
end
165+
84166
test "find_similar_shards returns shards from DB on cache miss", %{routing_db_path: routing_db_path} do
85167
{:ok, conn} = Exqlite.Sqlite3.open(routing_db_path)
86168
on_exit(fn -> Exqlite.Sqlite3.close(conn) end)

0 commit comments

Comments
 (0)