-
-
Notifications
You must be signed in to change notification settings - Fork 423
feat: implement peek on walrus query #1707
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
filipecabaco
wants to merge
2
commits into
main
Choose a base branch
from
feat/peek-before-change
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+366
−2
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
159 changes: 159 additions & 0 deletions
159
lib/realtime/tenants/repo/migrations/20260210000000_create_peek_and_list_changes_function.ex
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| defmodule Realtime.Tenants.Migrations.CreatePeekAndListChangesFunction do | ||
| @moduledoc false | ||
|
|
||
| use Ecto.Migration | ||
|
|
||
| def up do | ||
| execute(""" | ||
| create or replace function realtime.list_changes(publication name, slot_name name, max_changes int, max_record_bytes int) | ||
| returns setof realtime.wal_rls | ||
| language sql | ||
| as $$ | ||
| with peek as ( | ||
| select 1 | ||
| from pg_logical_slot_peek_changes( | ||
| slot_name, null, 1, | ||
| 'include-pk', 'true', | ||
| 'include-transaction', 'false', | ||
| 'include-timestamp', 'true', | ||
| 'include-type-oids', 'true', | ||
| 'format-version', '2' | ||
| ) | ||
| limit 1 | ||
| ), | ||
| pub as ( | ||
| select | ||
| concat_ws( | ||
| ',', | ||
| case when bool_or(pubinsert) then 'insert' else null end, | ||
| case when bool_or(pubupdate) then 'update' else null end, | ||
| case when bool_or(pubdelete) then 'delete' else null end | ||
| ) as w2j_actions, | ||
| coalesce( | ||
| string_agg( | ||
| realtime.quote_wal2json(format('%I.%I', schemaname, tablename)::regclass), | ||
| ',' | ||
| ) filter (where ppt.tablename is not null and ppt.tablename not like '% %'), | ||
| '' | ||
| ) w2j_add_tables | ||
| from | ||
| pg_publication pp | ||
| left join pg_publication_tables ppt | ||
| on pp.pubname = ppt.pubname | ||
| where | ||
| pp.pubname = publication | ||
| and exists (select 1 from peek) | ||
| group by | ||
| pp.pubname | ||
| limit 1 | ||
| ), | ||
| w2j as ( | ||
| select | ||
| x.*, pub.w2j_add_tables | ||
| from | ||
| pub, | ||
| pg_logical_slot_get_changes( | ||
| slot_name, null, max_changes, | ||
| 'include-pk', 'true', | ||
| 'include-transaction', 'false', | ||
| 'include-timestamp', 'true', | ||
| 'include-type-oids', 'true', | ||
| 'format-version', '2', | ||
| 'actions', pub.w2j_actions, | ||
| 'add-tables', pub.w2j_add_tables | ||
| ) x | ||
| ) | ||
| select | ||
| xyz.wal, | ||
| xyz.is_rls_enabled, | ||
| xyz.subscription_ids, | ||
| xyz.errors | ||
| from | ||
| w2j, | ||
| realtime.apply_rls( | ||
| wal := w2j.data::jsonb, | ||
| max_record_bytes := max_record_bytes | ||
| ) xyz(wal, is_rls_enabled, subscription_ids, errors) | ||
| where | ||
| w2j.w2j_add_tables <> '' | ||
| and xyz.subscription_ids[1] is not null | ||
|
|
||
| union all | ||
|
|
||
| select | ||
| null::jsonb, | ||
| null::boolean, | ||
| '{}'::uuid[], | ||
| '{peek_empty}'::text[] | ||
| where | ||
| not exists (select 1 from peek) | ||
| $$; | ||
| """) | ||
| end | ||
|
|
||
| def down do | ||
| execute(""" | ||
| create or replace function realtime.list_changes(publication name, slot_name name, max_changes int, max_record_bytes int) | ||
| returns setof realtime.wal_rls | ||
| language sql | ||
| set log_min_messages to 'fatal' | ||
| as $$ | ||
| with pub as ( | ||
| select | ||
| concat_ws( | ||
| ',', | ||
| case when bool_or(pubinsert) then 'insert' else null end, | ||
| case when bool_or(pubupdate) then 'update' else null end, | ||
| case when bool_or(pubdelete) then 'delete' else null end | ||
| ) as w2j_actions, | ||
| coalesce( | ||
| string_agg( | ||
| realtime.quote_wal2json(format('%I.%I', schemaname, tablename)::regclass), | ||
| ',' | ||
| ) filter (where ppt.tablename is not null and ppt.tablename not like '% %'), | ||
| '' | ||
| ) w2j_add_tables | ||
| from | ||
| pg_publication pp | ||
| left join pg_publication_tables ppt | ||
| on pp.pubname = ppt.pubname | ||
| where | ||
| pp.pubname = publication | ||
| group by | ||
| pp.pubname | ||
| limit 1 | ||
| ), | ||
| w2j as ( | ||
| select | ||
| x.*, pub.w2j_add_tables | ||
| from | ||
| pub, | ||
| pg_logical_slot_get_changes( | ||
| slot_name, null, max_changes, | ||
| 'include-pk', 'true', | ||
| 'include-transaction', 'false', | ||
| 'include-timestamp', 'true', | ||
| 'include-type-oids', 'true', | ||
| 'format-version', '2', | ||
| 'actions', pub.w2j_actions, | ||
| 'add-tables', pub.w2j_add_tables | ||
| ) x | ||
| ) | ||
| select | ||
| xyz.wal, | ||
| xyz.is_rls_enabled, | ||
| xyz.subscription_ids, | ||
| xyz.errors | ||
| from | ||
| w2j, | ||
| realtime.apply_rls( | ||
| wal := w2j.data::jsonb, | ||
| max_record_bytes := max_record_bytes | ||
| ) xyz(wal, is_rls_enabled, subscription_ids, errors) | ||
| where | ||
| w2j.w2j_add_tables <> '' | ||
| and xyz.subscription_ids[1] is not null | ||
| $$; | ||
| """) | ||
| end | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| defmodule Realtime.Integration.ReplicationsTest do | ||
| use Realtime.DataCase, async: false | ||
|
|
||
| alias Extensions.PostgresCdcRls.Replications | ||
| alias Extensions.PostgresCdcRls.Subscriptions | ||
| alias Realtime.Database | ||
|
|
||
| @publication "supabase_realtime_test" | ||
| @poll_interval 100 | ||
|
|
||
| setup do | ||
| tenant = Containers.checkout_tenant(run_migrations: true) | ||
|
|
||
| {:ok, conn} = | ||
| tenant | ||
| |> Database.from_tenant("realtime_rls") | ||
| |> Map.from_struct() | ||
| |> Keyword.new() | ||
| |> Postgrex.start_link() | ||
|
|
||
| slot_name = "supabase_realtime_test_slot_#{System.unique_integer([:positive])}" | ||
|
|
||
| on_exit(fn -> | ||
| try do | ||
| Postgrex.query(conn, "select pg_drop_replication_slot($1)", [slot_name]) | ||
| catch | ||
| _, _ -> :ok | ||
| end | ||
| end) | ||
|
|
||
| {:ok, subscription_params} = Subscriptions.parse_subscription_params(%{"event" => "*", "schema" => "public"}) | ||
| params_list = [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}] | ||
| {:ok, _} = Subscriptions.create(conn, @publication, params_list, self(), self()) | ||
| {:ok, _} = Replications.prepare_replication(conn, slot_name) | ||
|
|
||
| # Drain any setup changes | ||
| Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576) | ||
|
|
||
| %{conn: conn, slot_name: slot_name} | ||
| end | ||
|
|
||
| describe "replication polling lifecycle" do | ||
| test "prepare, poll, consume full cycle", %{conn: conn, slot_name: slot_name} do | ||
| {time, result} = | ||
| :timer.tc(fn -> | ||
| Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576) | ||
| end) | ||
|
|
||
| assert {:ok, %Postgrex.Result{num_rows: 1, rows: [sentinel]}} = result | ||
| assert Enum.at(sentinel, 8) == ["peek_empty"] | ||
| assert time < 50_000, "Expected peek short-circuit under 50ms, took #{div(time, 1000)}ms" | ||
|
|
||
| Process.sleep(@poll_interval) | ||
|
|
||
| Postgrex.query!(conn, "INSERT INTO public.test (details) VALUES ('row_1')", []) | ||
| Postgrex.query!(conn, "INSERT INTO public.test (details) VALUES ('row_2')", []) | ||
| Postgrex.query!(conn, "INSERT INTO public.test (details) VALUES ('row_3')", []) | ||
|
|
||
| Process.sleep(@poll_interval) | ||
|
|
||
| {:ok, %Postgrex.Result{num_rows: 3, rows: rows}} = | ||
| Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576) | ||
|
|
||
| [row | _] = rows | ||
| assert Enum.at(row, 0) == "INSERT" | ||
| assert Enum.at(row, 1) == "public" | ||
| assert Enum.at(row, 2) == "test" | ||
|
|
||
| Process.sleep(@poll_interval) | ||
|
|
||
| {:ok, %Postgrex.Result{num_rows: 1, rows: [sentinel]}} = | ||
| Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576) | ||
|
|
||
| assert Enum.at(sentinel, 8) == ["peek_empty"] | ||
| end | ||
|
|
||
| test "polls empty multiple times then captures a change when it arrives", %{conn: conn, slot_name: slot_name} do | ||
| for _ <- 1..5 do | ||
| {:ok, %Postgrex.Result{num_rows: 1, rows: [sentinel]}} = | ||
| Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576) | ||
|
|
||
| assert Enum.at(sentinel, 8) == ["peek_empty"] | ||
| Process.sleep(@poll_interval) | ||
| end | ||
|
|
||
| Postgrex.query!(conn, "INSERT INTO public.test (details) VALUES ('delayed_arrival')", []) | ||
| Process.sleep(@poll_interval) | ||
|
|
||
| {:ok, %Postgrex.Result{num_rows: 1, rows: [row]}} = | ||
| Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576) | ||
|
|
||
| assert Enum.at(row, 0) == "INSERT" | ||
| assert Enum.at(row, 1) == "public" | ||
| assert Enum.at(row, 2) == "test" | ||
|
|
||
| Process.sleep(@poll_interval) | ||
|
|
||
| {:ok, %Postgrex.Result{num_rows: 1, rows: [sentinel]}} = | ||
| Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576) | ||
|
|
||
| assert Enum.at(sentinel, 8) == ["peek_empty"] | ||
| end | ||
|
|
||
| test "prepare_replication is idempotent", %{conn: conn, slot_name: slot_name} do | ||
| {:ok, _} = Replications.prepare_replication(conn, slot_name) | ||
| Process.sleep(@poll_interval) | ||
| {:ok, _} = Replications.prepare_replication(conn, slot_name) | ||
| end | ||
|
|
||
| test "terminate_backend returns slot_not_found for unknown slots", %{conn: conn} do | ||
| assert {:error, :slot_not_found} = | ||
| Replications.terminate_backend(conn, "nonexistent_slot_#{System.unique_integer([:positive])}") | ||
| end | ||
|
|
||
| test "get_pg_stat_activity_diff returns elapsed seconds for active connection", %{conn: conn} do | ||
| {:ok, %Postgrex.Result{rows: [[pid]]}} = Postgrex.query(conn, "SELECT pg_backend_pid()", []) | ||
| Postgrex.query!(conn, "SET application_name = 'realtime_rls'", []) | ||
| Process.sleep(@poll_interval) | ||
|
|
||
| assert {:ok, diff} = Replications.get_pg_stat_activity_diff(conn, pid) | ||
| assert is_integer(diff) | ||
| end | ||
| end | ||
|
|
||
| describe "peek vs RLS distinction" do | ||
| setup do | ||
| tenant = Containers.checkout_tenant(run_migrations: true) | ||
|
|
||
| {:ok, conn} = | ||
| tenant | ||
| |> Database.from_tenant("realtime_rls") | ||
| |> Map.from_struct() | ||
| |> Keyword.new() | ||
| |> Postgrex.start_link() | ||
|
|
||
| slot_name = "supabase_realtime_rls_slot_#{System.unique_integer([:positive])}" | ||
|
|
||
| on_exit(fn -> | ||
| try do | ||
| Postgrex.query(conn, "select pg_drop_replication_slot($1)", [slot_name]) | ||
| catch | ||
| _, _ -> :ok | ||
| end | ||
| end) | ||
|
|
||
| {:ok, subscription_params} = | ||
| Subscriptions.parse_subscription_params(%{ | ||
| "event" => "*", | ||
| "schema" => "public", | ||
| "table" => "test", | ||
| "filter" => "details=eq.no_match" | ||
| }) | ||
|
|
||
| params_list = [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}] | ||
| {:ok, _} = Subscriptions.create(conn, @publication, params_list, self(), self()) | ||
| {:ok, _} = Replications.prepare_replication(conn, slot_name) | ||
|
|
||
| Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576) | ||
|
|
||
| %{conn: conn, slot_name: slot_name} | ||
| end | ||
|
|
||
| test "returns 0 rows when WAL changes exist but are filtered by subscription", %{ | ||
| conn: conn, | ||
| slot_name: slot_name | ||
| } do | ||
| # Peek is empty - sentinel row | ||
| {:ok, %Postgrex.Result{num_rows: 1, rows: [sentinel]}} = | ||
| Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576) | ||
|
|
||
| assert Enum.at(sentinel, 8) == ["peek_empty"] | ||
|
|
||
| # Insert a row that doesn't match the filter (details != "no_match") | ||
| Postgrex.query!(conn, "INSERT INTO public.test (details) VALUES ('rls_filtered')", []) | ||
| Process.sleep(@poll_interval) | ||
|
|
||
| # WAL changes consumed but subscription filter doesn't match - 0 rows, no sentinel | ||
| {:ok, %Postgrex.Result{num_rows: 0}} = | ||
| Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576) | ||
|
|
||
| Process.sleep(@poll_interval) | ||
|
|
||
| # After consumption, peek is empty again - sentinel returned | ||
| {:ok, %Postgrex.Result{num_rows: 1, rows: [sentinel]}} = | ||
| Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576) | ||
|
|
||
| assert Enum.at(sentinel, 8) == ["peek_empty"] | ||
| end | ||
| end | ||
| end | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid a hard 50ms timing assertion to prevent flaky CI.
The strict bound is environment-sensitive and can intermittently fail even when behavior is correct. Consider relaxing the threshold or asserting functional behavior only.
🧪 Suggested relaxation
📝 Committable suggestion
🤖 Prompt for AI Agents