Skip to content

Commit 214e644

Browse files
authored
Store UUID filter values as compact binaries (#4608)
## Summary - Store parsed UUID values as 16-byte binaries (rather than 36 byte strings) inside the sync-service filter/eval representation, converting back to canonical UUID text at API and SQL output boundaries. This reduces memory use across the system, but especially in where-clause filter indexes, such as the equality and subquery index keys, by avoiding full canonical UUID strings for indexed values. - Bump the ShapeStatus metadata version so persisted shape comparable terms created with the old UUID string representation are ignored and rebuilt under the new representation. - Add a changeset for `@core/sync-service`. ## Testing - `mix test test/electric/shape_cache/shape_status_test.exs test/electric/shape_cache/shape_status/shape_db_test.exs` - Temporarily patched the standard oracle schema from text/int IDs to UUID PK/FK columns and ran `mix test --only oracle test/integration/oracle_property_test.exs` successfully. This patch was not committed. - Temporarily ran the larger UUID oracle stress check successfully: `CHECK_TIMEOUT=60000 SHAPE_COUNT=800 MUTATIONS_PER_TXN=10 TXNS_PER_BATCH=10 BATCH_COUNT=200 SKIP_REPATCH_PREWARM=true mix test --seed 8 --only oracle test/integration/oracle_property_test.exs`. This patch was not committed. - Temporarily added route/subset coverage for `id = $1::uuid` and `id = CAST($1 AS uuid)` against the `items.id` UUID column; the four targeted tests passed and were not committed.
1 parent 0632b1c commit 214e644

10 files changed

Lines changed: 142 additions & 11 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@core/sync-service': patch
3+
---
4+
5+
Store parsed UUID values as 16-byte binaries instead of canonical text inside the sync-service filter evaluator. This reduces memory use in where-clause filter indexes, especially equality and subquery index keys, while converting UUIDs back to canonical strings at API and SQL output boundaries. The shape metadata version is bumped so persisted shape comparable terms created with the old UUID string representation are ignored and rebuilt.

packages/sync-service/lib/electric/replication/eval.ex

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,14 @@ defmodule Electric.Replication.Eval do
7272
for binary protocol encoding.
7373
7474
Most types (integers, floats, booleans, dates, times, etc.) use native Elixir
75-
types that Postgrex handles directly. UUID is a notable exception: the eval
76-
system stores UUIDs as human-readable strings, but Postgrex expects 16-byte
77-
raw binaries.
75+
types that Postgrex handles directly. UUIDs are the notable type here because
76+
Postgres exposes them as canonical text in decoded rows/API JSON, but Postgrex
77+
expects 16-byte raw binaries when sending UUID query parameters. The eval
78+
representation stores UUIDs in that compact Postgrex-ready form and converts
79+
back to canonical text at type-aware output boundaries.
7880
"""
81+
def value_to_postgrex(<<_::128>> = value, :uuid), do: value
82+
7983
def value_to_postgrex(value, :uuid) when is_binary(value) do
8084
{:ok, bin} = Ecto.UUID.dump(value)
8185
bin

packages/sync-service/lib/electric/replication/eval/env/known_functions.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ defmodule Electric.Replication.Eval.Env.KnownFunctions do
9494
def bool_out(false), do: "f"
9595
end
9696

97-
defpostgres("uuidout(uuid) -> text", delegate: &BasicTypes.noop/1)
97+
defpostgres("uuidout(uuid) -> text", delegate: &Casting.uuid_to_string/1)
9898

9999
## Comparison functions
100100

packages/sync-service/lib/electric/replication/eval/sql_generator.ex

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ defmodule Electric.Replication.Eval.SqlGenerator do
1717
"""
1818

1919
alias Electric.Replication.Eval.Parser.{Const, Ref, Func, Array, RowExpr}
20+
alias Electric.Replication.PostgresInterop.Casting
2021

2122
# PostgreSQL operator precedence (higher number = tighter binding)
2223
# See: https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-PRECEDENCE
@@ -254,6 +255,15 @@ defmodule Electric.Replication.Eval.SqlGenerator do
254255
defp to_sql_prec(%Const{value: true}), do: {"true", @prec_atom}
255256
defp to_sql_prec(%Const{value: false}), do: {"false", @prec_atom}
256257

258+
defp to_sql_prec(%Const{type: :uuid, value: value}) when is_binary(value) do
259+
{uuid_literal(value), @prec_atom}
260+
end
261+
262+
defp to_sql_prec(%Const{type: {:array, type}, value: value}) when is_list(value) do
263+
elements = Enum.map_join(value, ", ", &const_list_element_to_sql(&1, type))
264+
{"ARRAY[#{elements}]", @prec_atom}
265+
end
266+
257267
defp to_sql_prec(%Const{value: value}) when is_binary(value) do
258268
escaped = String.replace(value, "'", "''")
259269
{"'#{escaped}'", @prec_atom}
@@ -352,6 +362,18 @@ defmodule Electric.Replication.Eval.SqlGenerator do
352362
"ARRAY[#{elements}]"
353363
end
354364

365+
defp const_list_element_to_sql(value, :uuid) when is_binary(value), do: uuid_literal(value)
366+
defp const_list_element_to_sql(nil, _type), do: "NULL"
367+
368+
defp const_list_element_to_sql(values, {:array, type}) when is_list(values) do
369+
elements = Enum.map_join(values, ", ", &const_list_element_to_sql(&1, type))
370+
"ARRAY[#{elements}]"
371+
end
372+
373+
defp const_list_element_to_sql(value, _type), do: const_list_element_to_sql(value)
374+
375+
defp uuid_literal(value), do: "'#{Casting.uuid_to_string(value)}'::uuid"
376+
355377
# Helper for ANY/ALL: extract the operator, left operand, and array right operand
356378
# from a Func with map_over_array_in_pos set
357379
defp extract_mapped_operator(%Func{name: name, args: [left, right]}) do

packages/sync-service/lib/electric/replication/postgres_interop/casting.ex

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,12 @@ defmodule Electric.Replication.PostgresInterop.Casting do
4444

4545
def parse_uuid(maybe_uuid) do
4646
{:ok, value} = Ecto.UUID.dump(maybe_uuid)
47-
Ecto.UUID.load!(value)
47+
value
48+
end
49+
50+
def uuid_to_string(maybe_uuid) do
51+
{:ok, uuid} = Ecto.UUID.cast(maybe_uuid)
52+
uuid
4853
end
4954

5055
def parse_date("epoch"), do: Date.from_iso8601!("1970-01-01")

packages/sync-service/lib/electric/shape_cache/shape_status.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ defmodule Electric.ShapeCache.ShapeStatus do
3333
}
3434

3535
# MUST be updated when Shape.comparable/1 changes.
36-
@version 9
36+
@version 10
3737

3838
# Tuple format: {handle, hash, snapshot_started, last_read_time, generation}
3939
@shape_last_used_time_pos 4

packages/sync-service/test/electric/replication/eval/env_test.exs

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,45 @@ defmodule Electric.Replication.Eval.EnvTest do
33

44
alias Electric.Replication.Eval.Env
55

6-
describe "const_to_pg_string/3" do
6+
describe "parse_const/3" do
77
setup do
88
env = Env.new()
9-
%{env: env}
9+
uuid = "550e8400-e29b-41d4-a716-446655440000"
10+
{:ok, uuid_bytes} = Ecto.UUID.dump(uuid)
11+
%{env: env, uuid: uuid, uuid_bytes: uuid_bytes}
12+
end
13+
14+
test "parses uuid values as compact 16-byte binaries", %{
15+
env: env,
16+
uuid: uuid,
17+
uuid_bytes: uuid_bytes
18+
} do
19+
assert {:ok, ^uuid_bytes} = Env.parse_const(env, uuid, :uuid)
20+
assert byte_size(uuid_bytes) == 16
21+
end
22+
23+
test "rejects invalid 16-byte uuid text", %{env: env} do
24+
assert :error = Env.parse_const(env, "abcdefghijklmnop", :uuid)
25+
end
26+
27+
test "parses uuid arrays as compact 16-byte binaries", %{
28+
env: env,
29+
uuid: uuid,
30+
uuid_bytes: uuid_bytes
31+
} do
32+
assert {:ok, [^uuid_bytes]} = Env.parse_const(env, "{#{uuid}}", {:array, :uuid})
33+
end
34+
35+
test "rejects invalid 16-byte uuid text inside arrays", %{env: env} do
36+
assert :error = Env.parse_const(env, "{abcdefghijklmnop}", {:array, :uuid})
37+
end
38+
end
39+
40+
describe "const_to_pg_string/3" do
41+
setup do
42+
uuid = "550e8400-e29b-41d4-a716-446655440000"
43+
{:ok, uuid_bytes} = Ecto.UUID.dump(uuid)
44+
%{env: Env.new(), uuid: uuid, uuid_bytes: uuid_bytes}
1045
end
1146

1247
test "converts text type values as-is", %{env: env} do
@@ -62,9 +97,12 @@ defmodule Electric.Replication.Eval.EnvTest do
6297
assert Env.const_to_pg_string(env, timestamp, :timestamp) == "2025-01-15T14:30:00"
6398
end
6499

65-
test "converts uuid type as-is (noop)", %{env: env} do
66-
uuid = "550e8400-e29b-41d4-a716-446655440000"
67-
assert Env.const_to_pg_string(env, uuid, :uuid) == uuid
100+
test "converts compact uuid values back to canonical text", %{
101+
env: env,
102+
uuid: uuid,
103+
uuid_bytes: uuid_bytes
104+
} do
105+
assert Env.const_to_pg_string(env, uuid_bytes, :uuid) == uuid
68106
end
69107

70108
test "converts simple arrays of integers", %{env: env} do

packages/sync-service/test/electric/replication/eval/runner_test.exs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ defmodule Electric.Replication.Eval.RunnerTest do
2828
"null_int" => nil
2929
})
3030
end
31+
32+
test "parses uuid refs as compact 16-byte binaries" do
33+
uuid = "550e8400-e29b-41d4-a716-446655440000"
34+
{:ok, uuid_bytes} = Ecto.UUID.dump(uuid)
35+
36+
assert {:ok, %{["id"] => ^uuid_bytes}} =
37+
Runner.record_to_ref_values(%{["id"] => :uuid}, %{"id" => uuid})
38+
end
3139
end
3240

3341
describe "execute/2" do

packages/sync-service/test/electric/replication/eval/sql_generator_test.exs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,14 @@ defmodule Electric.Replication.Eval.SqlGeneratorTest do
412412
assert SqlGenerator.to_sql(%Const{value: "it's"}) == "'it''s'"
413413
end
414414

415+
test "uuid" do
416+
uuid = "550e8400-e29b-41d4-a716-446655440000"
417+
{:ok, uuid_bytes} = Ecto.UUID.dump(uuid)
418+
419+
assert SqlGenerator.to_sql(%Const{type: :uuid, value: uuid_bytes}) ==
420+
"'#{uuid}'::uuid"
421+
end
422+
415423
test "integer" do
416424
assert SqlGenerator.to_sql(%Const{value: 42}) == "42"
417425
end
@@ -436,6 +444,15 @@ defmodule Electric.Replication.Eval.SqlGeneratorTest do
436444
assert SqlGenerator.to_sql(ast) == "ARRAY['a', 'b']"
437445
end
438446

447+
test "constant-folded uuid array" do
448+
uuid = "550e8400-e29b-41d4-a716-446655440000"
449+
{:ok, uuid_bytes} = Ecto.UUID.dump(uuid)
450+
451+
ast = %Const{type: {:array, :uuid}, value: [uuid_bytes]}
452+
453+
assert SqlGenerator.to_sql(ast) == "ARRAY['#{uuid}'::uuid]"
454+
end
455+
439456
test "empty array" do
440457
ast = %Array{elements: []}
441458
assert SqlGenerator.to_sql(ast) == "ARRAY[]"

packages/sync-service/test/electric/shapes/filter_test.exs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,38 @@ defmodule Electric.Shapes.FilterTest do
9090
assert Filter.affected_shapes(filter, insert) == MapSet.new(["s2"])
9191
end
9292

93+
test "returns shapes affected by uuid equality using compact index keys" do
94+
uuid1 = "550e8400-e29b-41d4-a716-446655440000"
95+
uuid2 = "550e8400-e29b-41d4-a716-446655440001"
96+
{:ok, uuid1_bytes} = Ecto.UUID.dump(uuid1)
97+
98+
inspector =
99+
StubInspector.new(
100+
tables: ["uuid_table"],
101+
columns: [%{name: "id", type: "uuid", pk_position: 0}]
102+
)
103+
104+
filter =
105+
Filter.new()
106+
|> Filter.add_shape(
107+
"s1",
108+
Shape.new!("uuid_table", where: "id = '#{uuid1}'", inspector: inspector)
109+
)
110+
|> Filter.add_shape(
111+
"s2",
112+
Shape.new!("uuid_table", where: "id = '#{uuid2}'", inspector: inspector)
113+
)
114+
115+
index_entries = :ets.tab2list(filter.eq_index_table)
116+
117+
assert Enum.any?(index_entries, &match?({{_, "id", ^uuid1_bytes}, _}, &1))
118+
refute Enum.any?(index_entries, &match?({{_, "id", ^uuid1}, _}, &1))
119+
120+
insert = %NewRecord{relation: {"public", "uuid_table"}, record: %{"id" => uuid1}}
121+
122+
assert Filter.affected_shapes(filter, insert) == MapSet.new(["s1"])
123+
end
124+
93125
test "returns shapes affected by delete" do
94126
filter =
95127
Filter.new()

0 commit comments

Comments
 (0)