+ No drift detected. Tenant schema matches the catalog.
+
+ """
+ end
+
+ defp pg_delta_plan({:ok, %{status: :changes, sql: sql}}) do
+ assigns = %{sql: sql}
+
+ ~H"""
+ Understand the latency between nodes across the Realtime cluster.
-
- <%= for {_pair, p} <- @pings do %>
-
-
From: <%= p.from_region %> - <%= p.from_node %>
-
To: <%= p.region %> - <%= p.node %>
-
<%= p.latency %> ms
-
<%= p.timestamp %>
-
- <% end %>
+
+
+
From: <%= p.payload.from_region %> - <%= p.payload.from_node %>
+
To: <%= p.payload.region %> - <%= p.payload.node %>
+
<%= p.payload.latency %> ms
+
<%= p.payload.timestamp %>
+
diff --git a/lib/realtime_web/open_api_schemas.ex b/lib/realtime_web/open_api_schemas.ex
index d5fa9dbb0..108a91ff9 100644
--- a/lib/realtime_web/open_api_schemas.ex
+++ b/lib/realtime_web/open_api_schemas.ex
@@ -55,6 +55,28 @@ defmodule RealtimeWeb.OpenApiSchemas do
def params, do: {"Tenant Batch Params", "application/json", __MODULE__}
end
+ defmodule BroadcastSingleJsonParams do
+ @moduledoc false
+ require OpenApiSpex
+
+ OpenApiSpex.schema(%{
+ type: :object,
+ description: "JSON payload - any valid JSON object",
+ example: %{"text" => "hello world", "user" => "alice"}
+ })
+ end
+
+ defmodule BroadcastSingleBinaryParams do
+ @moduledoc false
+ require OpenApiSpex
+
+ OpenApiSpex.schema(%{
+ type: :string,
+ format: :binary,
+ description: "Binary payload - raw binary data"
+ })
+ end
+
defmodule TenantParams do
@moduledoc false
require OpenApiSpex
@@ -111,6 +133,20 @@ defmodule RealtimeWeb.OpenApiSchemas do
type: :number,
description: "Maximum payload size in KB"
},
+ max_client_presence_events_per_window: %Schema{
+ type: :number,
+ description: "Maximum client presence events (overrides environment default when set)",
+ nullable: true
+ },
+ client_presence_window_ms: %Schema{
+ type: :number,
+ description: "Client presence rate limit window in milliseconds (overrides environment default when set)",
+ nullable: true
+ },
+ presence_enabled: %Schema{
+ type: :boolean,
+ description: "When true, presence is enabled for clients that do not explicitly opt in"
+ },
extensions: %Schema{
type: :array,
items: %Schema{
@@ -214,6 +250,16 @@ defmodule RealtimeWeb.OpenApiSchemas do
}
}
},
+ max_client_presence_events_per_window: %Schema{
+ type: :number,
+ description: "Maximum client presence events (overrides environment default when set)",
+ nullable: true
+ },
+ client_presence_window_ms: %Schema{
+ type: :number,
+ description: "Client presence rate limit window in milliseconds (overrides environment default when set)",
+ nullable: true
+ },
inserted_at: %Schema{type: :string, format: "date-time", description: "Insert timestamp"},
extensions: %Schema{
type: :array,
@@ -313,18 +359,25 @@ defmodule RealtimeWeb.OpenApiSchemas do
type: :boolean,
description: "Indicates if Realtime has an active connection to the tenant database"
},
+ replication_connected: %Schema{
+ type: :boolean,
+ description: "Indicates if Realtime has an active replication connection for broadcast changes"
+ },
connected_cluster: %Schema{
type: :integer,
description: "The count of currently connected clients for a tenant on the Realtime cluster"
}
},
required: [
- :external_id,
- :jwt_secret
+ :healthy,
+ :db_connected,
+ :replication_connected,
+ :connected_cluster
],
example: %{
healthy: true,
db_connected: true,
+ replication_connected: true,
connected_cluster: 10
}
})
diff --git a/lib/realtime_web/plugs/assign_tenant.ex b/lib/realtime_web/plugs/assign_tenant.ex
index 69b52e8ab..4b48631e1 100644
--- a/lib/realtime_web/plugs/assign_tenant.ex
+++ b/lib/realtime_web/plugs/assign_tenant.ex
@@ -5,14 +5,12 @@ defmodule RealtimeWeb.Plugs.AssignTenant do
import Plug.Conn
import Phoenix.Controller, only: [json: 2]
- require Logger
-
alias Realtime.Api
- alias Realtime.Api.Tenant
alias Realtime.Database
alias Realtime.GenCounter
alias Realtime.RateCounter
alias Realtime.Tenants
+ alias Realtime.Tenants.Cache
def init(opts) do
opts
@@ -20,7 +18,7 @@ defmodule RealtimeWeb.Plugs.AssignTenant do
def call(%Plug.Conn{host: host} = conn, _opts) do
with {:ok, external_id} <- Database.get_external_id(host),
- %Tenant{} = tenant <- Api.get_tenant_by_external_id(external_id) do
+ {:ok, tenant} <- Cache.fetch_tenant_by_external_id(external_id) do
Logger.metadata(external_id: external_id, project: external_id)
OpenTelemetry.Tracer.set_attributes(external_id: external_id)
@@ -32,7 +30,7 @@ defmodule RealtimeWeb.Plugs.AssignTenant do
assign(conn, :tenant, tenant)
else
- nil -> error_response(conn, "Tenant not found in database")
+ _ -> error_response(conn, "Tenant not found in database")
end
end
diff --git a/lib/realtime_web/plugs/auth_tenant.ex b/lib/realtime_web/plugs/auth_tenant.ex
index 11bf2e0bc..a077977d4 100644
--- a/lib/realtime_web/plugs/auth_tenant.ex
+++ b/lib/realtime_web/plugs/auth_tenant.ex
@@ -2,8 +2,6 @@ defmodule RealtimeWeb.AuthTenant do
@moduledoc """
Authorization plug to ensure that only authorized clients can connect to the their tenant's endpoints.
"""
- require Logger
-
import Plug.Conn
import Phoenix.Controller, only: [json: 2]
@@ -42,6 +40,9 @@ defmodule RealtimeWeb.AuthTenant do
[] ->
nil
+ [""] ->
+ nil
+
[value | _] ->
[bearer, token] = value |> String.split(" ")
bearer = String.downcase(bearer)
diff --git a/lib/realtime_web/plugs/baggage_request_id.ex b/lib/realtime_web/plugs/baggage_request_id.ex
index c23616f82..7cfc0b274 100644
--- a/lib/realtime_web/plugs/baggage_request_id.ex
+++ b/lib/realtime_web/plugs/baggage_request_id.ex
@@ -8,7 +8,6 @@ defmodule RealtimeWeb.Plugs.BaggageRequestId do
def baggage_key, do: Application.get_env(:realtime, :request_id_baggage_key, "request-id")
- require Logger
alias Plug.Conn
@behaviour Plug
diff --git a/lib/realtime_web/plugs/parsers/octet_stream.ex b/lib/realtime_web/plugs/parsers/octet_stream.ex
new file mode 100644
index 000000000..ead0d4c51
--- /dev/null
+++ b/lib/realtime_web/plugs/parsers/octet_stream.ex
@@ -0,0 +1,41 @@
+defmodule RealtimeWeb.Plugs.Parsers.OctetStream do
+ @moduledoc """
+ `Plug.Parsers` implementation for `application/octet-stream` request bodies.
+
+ The raw binary body is placed in `conn.body_params` under the `"_binary"`
+ key, mirroring how `Plug.Parsers.JSON` exposes non-map top-level JSON via
+ `"_json"`.
+
+ Supports the same options as `Plug.Conn.read_body/2`: `:length`,
+ `:read_length`, `:read_timeout`, and `:body_reader`. Defaults inherit from
+ `Plug.Conn.read_body/2` (8 MB max length).
+ """
+
+ @behaviour Plug.Parsers
+
+ @impl true
+ def init(opts) do
+ Keyword.pop(opts, :body_reader, {Plug.Conn, :read_body, []})
+ end
+
+ @impl true
+ def parse(conn, "application", "octet-stream", _headers, {{mod, fun, args}, opts}) do
+ case apply(mod, fun, [conn, opts | args]) do
+ {:ok, body, conn} ->
+ {:ok, %{"_binary" => body}, conn}
+
+ {:more, _data, conn} ->
+ {:error, :too_large, conn}
+
+ {:error, :timeout} ->
+ raise Plug.TimeoutError
+
+ {:error, _} ->
+ raise Plug.BadRequestError
+ end
+ end
+
+ def parse(conn, _type, _subtype, _headers, _opts) do
+ {:next, conn}
+ end
+end
diff --git a/lib/realtime_web/plugs/rate_limiter.ex b/lib/realtime_web/plugs/rate_limiter.ex
index ed2f4c47d..4214e40e3 100644
--- a/lib/realtime_web/plugs/rate_limiter.ex
+++ b/lib/realtime_web/plugs/rate_limiter.ex
@@ -4,8 +4,6 @@ defmodule RealtimeWeb.Plugs.RateLimiter do
"""
import Plug.Conn
import Phoenix.Controller, only: [json: 2]
- require Logger
-
alias Realtime.Api.Tenant
def init(opts) do
diff --git a/lib/realtime_web/plugs/validate_broadcast_content_type.ex b/lib/realtime_web/plugs/validate_broadcast_content_type.ex
new file mode 100644
index 000000000..a6a29932b
--- /dev/null
+++ b/lib/realtime_web/plugs/validate_broadcast_content_type.ex
@@ -0,0 +1,41 @@
+defmodule RealtimeWeb.Plugs.ValidateBroadcastContentType do
+ @moduledoc """
+ Validates the request `Content-Type` for the broadcast-single endpoint.
+
+ Allowed: `application/json` and `application/octet-stream` (optionally with
+ parameters such as `; charset=utf-8`). A missing header is also allowed for
+ backward compatibility with callers that historically POSTed JSON without
+ setting the header.
+
+ Any other media type is rejected with a 415 response carrying a JSON body.
+ """
+ import Plug.Conn
+
+ @allowed ["json", "octet-stream"]
+
+ def init(opts), do: opts
+
+ def call(conn, _opts) do
+ case get_req_header(conn, "content-type") do
+ [] ->
+ conn
+
+ [content_type | _] ->
+ case Plug.Conn.Utils.content_type(content_type) do
+ {:ok, "application", subtype, _params} when subtype in @allowed ->
+ conn
+
+ _ ->
+ conn
+ |> put_resp_content_type("application/json")
+ |> send_resp(
+ 415,
+ Jason.encode!(%{
+ error: "Unsupported Media Type. Use application/json or application/octet-stream"
+ })
+ )
+ |> halt()
+ end
+ end
+ end
+end
diff --git a/lib/realtime_web/router.ex b/lib/realtime_web/router.ex
index 1e368f6d2..980c92b73 100644
--- a/lib/realtime_web/router.ex
+++ b/lib/realtime_web/router.ex
@@ -2,7 +2,6 @@ defmodule RealtimeWeb.Router do
use RealtimeWeb, :router
require Logger
- require OpenTelemetry.Tracer, as: Tracer
import RealtimeWeb.ChannelsAuthorization, only: [authorize: 3]
@@ -37,8 +36,16 @@ defmodule RealtimeWeb.Router do
plug(:set_span_request_id)
end
+ pipeline :broadcast_single do
+ plug(:accepts, ["json", "octet-stream"])
+ plug(RealtimeWeb.Plugs.ValidateBroadcastContentType)
+ plug(RealtimeWeb.Plugs.AssignTenant)
+ plug(RealtimeWeb.Plugs.RateLimiter)
+ plug(:set_span_request_id)
+ end
+
pipeline :dashboard_admin do
- plug(:dashboard_basic_auth)
+ plug(:dashboard_auth)
end
pipeline :metrics do
@@ -70,12 +77,14 @@ defmodule RealtimeWeb.Router do
scope "/admin", RealtimeWeb do
pipe_through [:browser, :dashboard_admin]
live("/tenants", TenantsLive.Index, :index)
+ live("/feature-flags", FeatureFlagsLive.Index, :index)
end
scope "/metrics", RealtimeWeb do
pipe_through(:metrics)
get("/", MetricsController, :index)
+ get("/:region", MetricsController, :region)
end
scope "/api" do
@@ -89,6 +98,7 @@ defmodule RealtimeWeb.Router do
resources("/tenants", TenantController, param: "tenant_id", except: [:edit, :new])
post("/tenants/:tenant_id/reload", TenantController, :reload)
+ post("/tenants/:tenant_id/shutdown", TenantController, :shutdown)
get("/tenants/:tenant_id/health", TenantController, :health)
end
@@ -104,6 +114,12 @@ defmodule RealtimeWeb.Router do
post("/broadcast", BroadcastController, :broadcast)
end
+ scope "/api", RealtimeWeb do
+ pipe_through([:open_cors, :broadcast_single, :secure_tenant_api])
+
+ post("/broadcast/:topic/events/:event", BroadcastSingleController, :broadcast)
+ end
+
# Enables LiveDashboard only for development
#
# If you want to use the LiveDashboard in production, you should put
@@ -125,19 +141,25 @@ defmodule RealtimeWeb.Router do
ecto_psql_extras_options: [long_running_queries: [threshold: "200 milliseconds"]],
metrics: RealtimeWeb.Telemetry,
additional_pages: [
- route_name: Realtime.Dashboard.ProcessDump
+ route_name: RealtimeWeb.Dashboard.ProcessDump,
+ recon_trace: RealtimeWeb.Dashboard.ReconTrace,
+ node_info: RealtimeWeb.Dashboard.NodeInfo,
+ tenant_info: RealtimeWeb.Dashboard.TenantInfo,
+ tenant_migrations: RealtimeWeb.Dashboard.TenantMigrations,
+ sql_inspector: RealtimeWeb.Dashboard.SqlInspector,
+ feature_flags: RealtimeWeb.Dashboard.FeatureFlags
]
)
end
defp check_auth(conn, [secret_key, blocklist_key]) do
- secret = Application.fetch_env!(:realtime, secret_key)
+ secrets = :realtime |> Application.fetch_env!(secret_key) |> List.wrap()
blocklist = Application.get_env(:realtime, blocklist_key, [])
with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
token <- Regex.replace(~r/\s|\n/, URI.decode(token), ""),
false <- token in blocklist,
- {:ok, _claims} <- authorize(token, secret, nil) do
+ {:ok, _claims} <- authorize_any(token, secrets) do
conn
else
_ ->
@@ -147,17 +169,36 @@ defmodule RealtimeWeb.Router do
end
end
- defp dashboard_basic_auth(conn, _opts) do
- user = System.fetch_env!("DASHBOARD_USER")
- password = System.fetch_env!("DASHBOARD_PASSWORD")
- Plug.BasicAuth.basic_auth(conn, username: user, password: password)
+ defp authorize_any(token, secrets) do
+ Enum.find_value(secrets, {:error, :unauthorized}, fn secret ->
+ case authorize(token, secret, nil) do
+ {:ok, claims} -> {:ok, claims}
+ _ -> nil
+ end
+ end)
+ end
+
+ defp dashboard_auth(conn, _opts) do
+ case Application.fetch_env!(:realtime, :dashboard_auth) do
+ :zta ->
+ {conn, user} = NimbleZTA.Cloudflare.authenticate(Realtime.ZTA, conn)
+ if user, do: conn, else: conn |> send_resp(403, "") |> halt()
+
+ :basic_auth ->
+ {user, password} = Application.fetch_env!(:realtime, :dashboard_credentials)
+ Plug.BasicAuth.basic_auth(conn, username: user, password: password)
+ end
+ catch
+ :exit, reason ->
+ Logger.error("ZTA authentication failed: #{inspect(reason)}")
+ conn |> send_resp(503, "") |> halt()
end
defp set_span_request_id(conn, _) do
# Must have been set by BaggageRequestId
# We can't set the span attribute there because the phoenix span only starts after it reaches the Router
if request_id = Logger.metadata()[:request_id] do
- Tracer.set_attribute(:request_id, request_id)
+ OpenTelemetry.Tracer.set_attribute(:request_id, request_id)
end
conn
diff --git a/lib/realtime_web/socket.ex b/lib/realtime_web/socket.ex
new file mode 100644
index 000000000..174ca788a
--- /dev/null
+++ b/lib/realtime_web/socket.ex
@@ -0,0 +1,130 @@
+defmodule RealtimeWeb.Socket do
+ @moduledoc """
+ A drop-in replacement for `use Phoenix.Socket` that adds Realtime-specific
+ transport behaviour:
+
+ * Sets `:max_heap_size` on the transport process during `init/1`
+ * Schedules periodic traffic measurement via `handle_info/2`
+ * Wraps `handle_in/2` with error handling for malformed WebSocket messages
+ """
+
+ defmacro __using__(opts) do
+ quote do
+ import Phoenix.Socket
+ @behaviour Phoenix.Socket
+ @before_compile Phoenix.Socket
+ Module.register_attribute(__MODULE__, :phoenix_channels, accumulate: true)
+ @phoenix_socket_options unquote(opts)
+
+ @behaviour Phoenix.Socket.Transport
+
+ @doc false
+ def child_spec(opts) do
+ Phoenix.Socket.__child_spec__(__MODULE__, opts, @phoenix_socket_options)
+ end
+
+ @doc false
+ def drainer_spec(opts) do
+ Phoenix.Socket.__drainer_spec__(__MODULE__, opts, @phoenix_socket_options)
+ end
+
+ @doc false
+ def connect(map), do: Phoenix.Socket.__connect__(__MODULE__, map, @phoenix_socket_options)
+
+ @doc false
+ def init(state) when is_tuple(state) do
+ Process.flag(:max_heap_size, :persistent_term.get({__MODULE__, :websocket_max_heap_size}))
+
+ Process.send_after(
+ self(),
+ {:measure_traffic, 0, 0},
+ :persistent_term.get({__MODULE__, :measure_traffic_interval_in_ms})
+ )
+
+ Phoenix.Socket.__init__(state)
+ end
+
+ @doc false
+ def handle_in({payload, opts}, {_state, socket} = full_state) do
+ Phoenix.Socket.__in__({payload, opts}, full_state)
+ rescue
+ e in Phoenix.Socket.InvalidMessageError ->
+ RealtimeWeb.RealtimeChannel.Logging.log_error(socket, "MalformedWebSocketMessage", e.message)
+ {:ok, full_state}
+
+ e in Jason.DecodeError ->
+ RealtimeWeb.RealtimeChannel.Logging.log_error(
+ socket,
+ "MalformedWebSocketMessage",
+ Jason.DecodeError.message(e)
+ )
+
+ {:ok, full_state}
+
+ e ->
+ RealtimeWeb.RealtimeChannel.Logging.log_error(socket, "UnknownErrorOnWebSocketMessage", Exception.message(e))
+ {:ok, full_state}
+ end
+
+ @doc false
+ def handle_info(
+ {:measure_traffic, previous_recv, previous_send},
+ {_, %{assigns: assigns, transport_pid: transport_pid}} = state
+ ) do
+ tenant_external_id = Map.get(assigns, :tenant)
+
+ %{latest_recv: latest_recv, latest_send: latest_send} =
+ RealtimeWeb.Socket.collect_traffic_telemetry(
+ transport_pid,
+ tenant_external_id,
+ previous_recv,
+ previous_send
+ )
+
+ Process.send_after(
+ self(),
+ {:measure_traffic, latest_recv, latest_send},
+ :persistent_term.get({__MODULE__, :measure_traffic_interval_in_ms})
+ )
+
+ {:ok, state}
+ end
+
+ def handle_info(message, state), do: Phoenix.Socket.__info__(message, state)
+
+ @doc false
+ def terminate(reason, state), do: Phoenix.Socket.__terminate__(reason, state)
+ end
+ end
+
+ @doc false
+ def collect_traffic_telemetry(nil, _tenant_external_id, previous_recv, previous_send),
+ do: %{latest_recv: previous_recv, latest_send: previous_send}
+
+ def collect_traffic_telemetry(transport_pid, tenant_external_id, previous_recv, previous_send) do
+ %{send_oct: latest_send, recv_oct: latest_recv} =
+ transport_pid
+ |> Process.info(:links)
+ |> then(fn {:links, links} -> links end)
+ |> Enum.filter(&is_port/1)
+ |> Enum.reduce(%{send_oct: 0, recv_oct: 0}, fn link, acc ->
+ case :inet.getstat(link, [:send_oct, :recv_oct]) do
+ {:ok, stats} ->
+ send_oct = Keyword.get(stats, :send_oct, 0)
+ recv_oct = Keyword.get(stats, :recv_oct, 0)
+ %{send_oct: acc.send_oct + send_oct, recv_oct: acc.recv_oct + recv_oct}
+
+ {:error, _} ->
+ acc
+ end
+ end)
+
+ send_delta = max(0, latest_send - previous_send)
+ recv_delta = max(0, latest_recv - previous_recv)
+
+ :telemetry.execute([:realtime, :channel, :output_bytes], %{size: send_delta}, %{tenant: tenant_external_id})
+ :telemetry.execute([:realtime, :channel, :input_bytes], %{size: recv_delta}, %{tenant: tenant_external_id})
+
+ %{latest_recv: latest_recv, latest_send: latest_send}
+ end
+end
diff --git a/lib/realtime_web/socket/user_broadcast.ex b/lib/realtime_web/socket/user_broadcast.ex
new file mode 100644
index 000000000..7caba33ce
--- /dev/null
+++ b/lib/realtime_web/socket/user_broadcast.ex
@@ -0,0 +1,39 @@
+defmodule RealtimeWeb.Socket.UserBroadcast do
+ @moduledoc """
+ Defines a message sent from pubsub to channels and vice-versa.
+
+ The message format requires the following keys:
+
+ * `:topic` - The string topic or topic:subtopic pair namespace, for example "messages", "messages:123"
+ * `:user_event`- The string user event name, for example "my-event"
+ * `:user_payload_encoding`- :json or :binary
+ * `:user_payload` - The actual message payload
+
+ Optionally metadata which is a map to be JSON encoded
+ """
+
+ alias Phoenix.Socket.Broadcast
+
+ @type t :: %__MODULE__{}
+ defstruct topic: nil, user_event: nil, user_payload: nil, user_payload_encoding: nil, metadata: nil
+
+ @spec convert_to_json_broadcast(t) :: {:ok, Broadcast.t()} | {:error, String.t()}
+ def convert_to_json_broadcast(%__MODULE__{user_payload_encoding: :json} = user_broadcast) do
+ payload = %{
+ "event" => user_broadcast.user_event,
+ "payload" => Jason.Fragment.new(user_broadcast.user_payload),
+ "type" => "broadcast"
+ }
+
+ payload =
+ if user_broadcast.metadata do
+ Map.put(payload, "meta", user_broadcast.metadata)
+ else
+ payload
+ end
+
+ {:ok, %Broadcast{event: "broadcast", payload: payload, topic: user_broadcast.topic}}
+ end
+
+ def convert_to_json_broadcast(%__MODULE__{}), do: {:error, "User payload encoding is not JSON"}
+end
diff --git a/lib/realtime_web/socket/v2_serializer.ex b/lib/realtime_web/socket/v2_serializer.ex
new file mode 100644
index 000000000..4c4b62170
--- /dev/null
+++ b/lib/realtime_web/socket/v2_serializer.ex
@@ -0,0 +1,231 @@
+defmodule RealtimeWeb.Socket.V2Serializer do
+ @moduledoc """
+ Custom serializer that is a superset of Phoenix's V2 JSONSerializer
+ that handles user broadcast and user broadcast push
+ """
+
+ @behaviour Phoenix.Socket.Serializer
+
+ @push 0
+ @reply 1
+ @broadcast 2
+ @user_broadcast_push 3
+ @user_broadcast 4
+
+ alias Phoenix.Socket.{Message, Reply, Broadcast}
+ alias RealtimeWeb.Socket.UserBroadcast
+
+ @impl true
+ def fastlane!(%UserBroadcast{} = msg) do
+ metadata =
+ if msg.metadata do
+ Phoenix.json_library().encode!(msg.metadata)
+ else
+ msg.metadata
+ end
+
+ topic_size = byte_size!(msg.topic, :topic, 255)
+ user_event_size = byte_size!(msg.user_event, :user_event, 255)
+ metadata_size = byte_size!(metadata, :metadata, 255)
+ user_payload_encoding = if msg.user_payload_encoding == :json, do: 1, else: 0
+
+ bin = <<
+ @user_broadcast::size(8),
+ topic_size::size(8),
+ user_event_size::size(8),
+ metadata_size::size(8),
+ user_payload_encoding::size(8),
+ msg.topic::binary-size(topic_size),
+ msg.user_event::binary-size(user_event_size),
+ metadata || <<>>::binary-size(metadata_size),
+ msg.user_payload::binary
+ >>
+
+ {:socket_push, :binary, bin}
+ end
+
+ def fastlane!(%Broadcast{payload: {:binary, data}} = msg) do
+ topic_size = byte_size!(msg.topic, :topic, 255)
+ event_size = byte_size!(msg.event, :event, 255)
+
+ bin = <<
+ @broadcast::size(8),
+ topic_size::size(8),
+ event_size::size(8),
+ msg.topic::binary-size(topic_size),
+ msg.event::binary-size(event_size),
+ data::binary
+ >>
+
+ {:socket_push, :binary, bin}
+ end
+
+ def fastlane!(%Broadcast{payload: %{}} = msg) do
+ data = Phoenix.json_library().encode_to_iodata!([nil, nil, msg.topic, msg.event, msg.payload])
+ {:socket_push, :text, data}
+ end
+
+ def fastlane!(%Broadcast{payload: invalid}) do
+ raise ArgumentError, "expected broadcasted payload to be a map, got: #{inspect(invalid)}"
+ end
+
+ @impl true
+ def encode!(%Reply{payload: {:binary, data}} = reply) do
+ status = to_string(reply.status)
+ join_ref = to_string(reply.join_ref)
+ ref = to_string(reply.ref)
+ join_ref_size = byte_size!(join_ref, :join_ref, 255)
+ ref_size = byte_size!(ref, :ref, 255)
+ topic_size = byte_size!(reply.topic, :topic, 255)
+ status_size = byte_size!(status, :status, 255)
+
+ bin = <<
+ @reply::size(8),
+ join_ref_size::size(8),
+ ref_size::size(8),
+ topic_size::size(8),
+ status_size::size(8),
+ join_ref::binary-size(join_ref_size),
+ ref::binary-size(ref_size),
+ reply.topic::binary-size(topic_size),
+ status::binary-size(status_size),
+ data::binary
+ >>
+
+ {:socket_push, :binary, bin}
+ end
+
+ def encode!(%Reply{} = reply) do
+ data = [
+ reply.join_ref,
+ reply.ref,
+ reply.topic,
+ "phx_reply",
+ %{status: reply.status, response: reply.payload}
+ ]
+
+ {:socket_push, :text, Phoenix.json_library().encode_to_iodata!(data)}
+ end
+
+ def encode!(%Message{payload: {:binary, data}} = msg) do
+ join_ref = to_string(msg.join_ref)
+ join_ref_size = byte_size!(join_ref, :join_ref, 255)
+ topic_size = byte_size!(msg.topic, :topic, 255)
+ event_size = byte_size!(msg.event, :event, 255)
+
+ bin = <<
+ @push::size(8),
+ join_ref_size::size(8),
+ topic_size::size(8),
+ event_size::size(8),
+ join_ref::binary-size(join_ref_size),
+ msg.topic::binary-size(topic_size),
+ msg.event::binary-size(event_size),
+ data::binary
+ >>
+
+ {:socket_push, :binary, bin}
+ end
+
+ def encode!(%Message{payload: %{}} = msg) do
+ data = [msg.join_ref, msg.ref, msg.topic, msg.event, msg.payload]
+ {:socket_push, :text, Phoenix.json_library().encode_to_iodata!(data)}
+ end
+
+ def encode!(%Message{payload: invalid}) do
+ raise ArgumentError, "expected payload to be a map, got: #{inspect(invalid)}"
+ end
+
+ @impl true
+ def decode!(raw_message, opts) do
+ case Keyword.fetch(opts, :opcode) do
+ {:ok, :text} -> decode_text(raw_message)
+ {:ok, :binary} -> decode_binary(raw_message)
+ end
+ end
+
+ defp decode_text(raw_message) do
+ case Phoenix.json_library().decode!(raw_message) do
+ [join_ref, ref, topic, event, payload | _] ->
+ %Message{topic: topic, event: event, payload: payload, ref: ref, join_ref: join_ref}
+
+ other ->
+ raise Phoenix.Socket.InvalidMessageError,
+ "expected V2 array, got: #{inspect(other, limit: 200, printable_limit: 200)}"
+ end
+ end
+
+ defp decode_binary(<<
+ @push::size(8),
+ join_ref_size::size(8),
+ ref_size::size(8),
+ topic_size::size(8),
+ event_size::size(8),
+ join_ref::binary-size(join_ref_size),
+ ref::binary-size(ref_size),
+ topic::binary-size(topic_size),
+ event::binary-size(event_size),
+ data::binary
+ >>) do
+ %Message{
+ topic: topic,
+ event: event,
+ payload: {:binary, data},
+ ref: ref,
+ join_ref: join_ref
+ }
+ end
+
+ defp decode_binary(<<
+ @user_broadcast_push::size(8),
+ join_ref_size::size(8),
+ ref_size::size(8),
+ topic_size::size(8),
+ user_event_size::size(8),
+ metadata_size::size(8),
+ user_payload_encoding::size(8),
+ join_ref::binary-size(join_ref_size),
+ ref::binary-size(ref_size),
+ topic::binary-size(topic_size),
+ user_event::binary-size(user_event_size),
+ metadata::binary-size(metadata_size),
+ user_payload::binary
+ >>) do
+ user_payload_encoding = if user_payload_encoding == 0, do: :binary, else: :json
+
+ metadata =
+ if metadata_size > 0 do
+ Phoenix.json_library().decode!(metadata)
+ else
+ %{}
+ end
+
+ # Encoding as Message because that's how Phoenix Socket and Channel.Server expects things to show up
+ # Here we abuse the payload field to carry a tuple of (user_event, user payload encoding, user payload, metadata)
+ %Message{
+ topic: topic,
+ event: "broadcast",
+ payload: {user_event, user_payload_encoding, user_payload, metadata},
+ ref: ref,
+ join_ref: join_ref
+ }
+ end
+
+ defp byte_size!(nil, _kind, _max), do: 0
+
+ defp byte_size!(bin, kind, max) do
+ case byte_size(bin) do
+ size when size <= max ->
+ size
+
+ oversized ->
+ raise ArgumentError, """
+ unable to convert #{kind} to binary.
+
+ #{inspect(bin)}
+
+ must be less than or equal to #{max} bytes, but is #{oversized} bytes.
+ """
+ end
+ end
+end
diff --git a/lib/realtime_web/tenant_broadcaster.ex b/lib/realtime_web/tenant_broadcaster.ex
index ee8646614..b0a95d679 100644
--- a/lib/realtime_web/tenant_broadcaster.ex
+++ b/lib/realtime_web/tenant_broadcaster.ex
@@ -5,12 +5,40 @@ defmodule RealtimeWeb.TenantBroadcaster do
alias Phoenix.PubSub
- @spec pubsub_broadcast(tenant_id :: String.t(), PubSub.topic(), PubSub.message(), PubSub.dispatcher()) :: :ok
- def pubsub_broadcast(tenant_id, topic, message, dispatcher) do
- collect_payload_size(tenant_id, message)
+ @type message_type :: :broadcast | :presence | :postgres_changes
- Realtime.GenRpc.multicast(PubSub, :local_broadcast, [Realtime.PubSub, topic, message, dispatcher], key: topic)
+ @spec pubsub_direct_broadcast(
+ node :: node(),
+ tenant_id :: String.t(),
+ PubSub.topic(),
+ PubSub.message(),
+ PubSub.dispatcher(),
+ message_type
+ ) ::
+ :ok
+ def pubsub_direct_broadcast(node, tenant_id, topic, message, dispatcher, message_type) do
+ collect_payload_size(tenant_id, message, message_type)
+
+ do_direct_broadcast(node, topic, message, dispatcher)
+
+ :ok
+ end
+
+ # Remote
+ defp do_direct_broadcast(node, topic, message, dispatcher) when node != node() do
+ PubSub.direct_broadcast(node, Realtime.PubSub, topic, message, dispatcher)
+ end
+
+ # Local
+ defp do_direct_broadcast(_node, topic, message, dispatcher) do
+ PubSub.local_broadcast(Realtime.PubSub, topic, message, dispatcher)
+ end
+ @spec pubsub_broadcast(tenant_id :: String.t(), PubSub.topic(), PubSub.message(), PubSub.dispatcher(), message_type) ::
+ :ok
+ def pubsub_broadcast(tenant_id, topic, message, dispatcher, message_type) do
+ collect_payload_size(tenant_id, message, message_type)
+ PubSub.broadcast(Realtime.PubSub, topic, message, dispatcher)
:ok
end
@@ -19,30 +47,28 @@ defmodule RealtimeWeb.TenantBroadcaster do
from :: pid,
PubSub.topic(),
PubSub.message(),
- PubSub.dispatcher()
+ PubSub.dispatcher(),
+ message_type
) ::
:ok
- def pubsub_broadcast_from(tenant_id, from, topic, message, dispatcher) do
- collect_payload_size(tenant_id, message)
-
- Realtime.GenRpc.multicast(
- PubSub,
- :local_broadcast_from,
- [Realtime.PubSub, from, topic, message, dispatcher],
- key: topic
- )
-
+ def pubsub_broadcast_from(tenant_id, from, topic, message, dispatcher, message_type) do
+ collect_payload_size(tenant_id, message, message_type)
+ PubSub.broadcast_from(Realtime.PubSub, from, topic, message, dispatcher)
:ok
end
@payload_size_event [:realtime, :tenants, :payload, :size]
- defp collect_payload_size(tenant_id, payload) when is_struct(payload) do
+ @spec collect_payload_size(tenant_id :: String.t(), payload :: term, message_type :: message_type) :: :ok
+ def collect_payload_size(tenant_id, payload, message_type) when is_struct(payload) do
# Extracting from struct so the __struct__ bit is not calculated as part of the payload
- collect_payload_size(tenant_id, Map.from_struct(payload))
+ collect_payload_size(tenant_id, Map.from_struct(payload), message_type)
end
- defp collect_payload_size(tenant_id, payload) do
- :telemetry.execute(@payload_size_event, %{size: :erlang.external_size(payload)}, %{tenant: tenant_id})
+ def collect_payload_size(tenant_id, payload, message_type) do
+ :telemetry.execute(@payload_size_event, %{size: :erlang.external_size(payload)}, %{
+ tenant: tenant_id,
+ message_type: message_type
+ })
end
end
diff --git a/lib/realtime_web/views/tenant_view.ex b/lib/realtime_web/views/tenant_view.ex
index a74428f7d..7e19c26e6 100644
--- a/lib/realtime_web/views/tenant_view.ex
+++ b/lib/realtime_web/views/tenant_view.ex
@@ -30,7 +30,10 @@ defmodule RealtimeWeb.TenantView do
Map.drop(settings, ["db_password"])
end)
end),
- private_only: tenant.private_only
+ private_only: tenant.private_only,
+ max_client_presence_events_per_window: tenant.max_client_presence_events_per_window,
+ client_presence_window_ms: tenant.client_presence_window_ms,
+ presence_enabled: tenant.presence_enabled
}
end
end
diff --git a/mise.lock b/mise.lock
new file mode 100644
index 000000000..2ac11a4ba
--- /dev/null
+++ b/mise.lock
@@ -0,0 +1,80 @@
+# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
+
+[[tools.elixir]]
+version = "1.18.4-otp-27"
+backend = "core:elixir"
+
+[[tools.erlang]]
+version = "27.3.4.10"
+backend = "core:erlang"
+
+[tools.erlang."platforms.macos-arm64"]
+checksum = "blake3:0d27e4815676201f374d135c1a9a8ee7d41535c3477509be9836232c57afddab"
+
+[[tools.node]]
+version = "24.14.1"
+backend = "core:node"
+
+[tools.node."platforms.linux-arm64"]
+checksum = "sha256:734ff04fa7f8ed2e8a78d40cacf5ac3fc4515dac2858757cbab313eb483ba8a2"
+url = "https://nodejs.org/dist/v24.14.1/node-v24.14.1-linux-arm64.tar.gz"
+
+[tools.node."platforms.linux-arm64-musl"]
+checksum = "sha256:734ff04fa7f8ed2e8a78d40cacf5ac3fc4515dac2858757cbab313eb483ba8a2"
+url = "https://nodejs.org/dist/v24.14.1/node-v24.14.1-linux-arm64.tar.gz"
+
+[tools.node."platforms.linux-x64"]
+checksum = "sha256:ace9fa104992ed0829642629c46ca7bd7fd6e76278cb96c958c4b387d29658ea"
+url = "https://nodejs.org/dist/v24.14.1/node-v24.14.1-linux-x64.tar.gz"
+
+[tools.node."platforms.linux-x64-musl"]
+checksum = "sha256:ace9fa104992ed0829642629c46ca7bd7fd6e76278cb96c958c4b387d29658ea"
+url = "https://nodejs.org/dist/v24.14.1/node-v24.14.1-linux-x64.tar.gz"
+
+[tools.node."platforms.macos-arm64"]
+checksum = "sha256:25495ff85bd89e2d8a24d88566d7e2f827c6b0d3d872b2cebf75371f93fcb1fe"
+url = "https://nodejs.org/dist/v24.14.1/node-v24.14.1-darwin-arm64.tar.gz"
+
+[tools.node."platforms.macos-x64"]
+checksum = "sha256:2526230ad7d922be82d4fdb1e7ee1e84303e133e3b4b0ec4c2897ab31de0253d"
+url = "https://nodejs.org/dist/v24.14.1/node-v24.14.1-darwin-x64.tar.gz"
+
+[tools.node."platforms.windows-x64"]
+checksum = "sha256:6e50ce5498c0cebc20fd39ab3ff5df836ed2f8a31aa093cecad8497cff126d70"
+url = "https://nodejs.org/dist/v24.14.1/node-v24.14.1-win-x64.zip"
+
+[[tools."npm:@supabase/pg-delta"]]
+version = "1.0.0-alpha.30"
+backend = "npm:@supabase/pg-delta"
+
+[[tools.supabase]]
+version = "2.105.0"
+backend = "aqua:supabase/cli"
+
+[tools.supabase."platforms.linux-arm64"]
+checksum = "sha256:7e090059cdd28e2a6233298f2ff7b7c819e0fac040b975561c80ca5b8a583b56"
+url = "https://github.com/supabase/cli/releases/download/v2.105.0/supabase_linux_arm64.tar.gz"
+
+[tools.supabase."platforms.linux-arm64-musl"]
+checksum = "sha256:7e090059cdd28e2a6233298f2ff7b7c819e0fac040b975561c80ca5b8a583b56"
+url = "https://github.com/supabase/cli/releases/download/v2.105.0/supabase_linux_arm64.tar.gz"
+
+[tools.supabase."platforms.linux-x64"]
+checksum = "sha256:11ac4410c11e8b03f0cc7fd9316d68146695b0e06115a0663364b07e7feb6db8"
+url = "https://github.com/supabase/cli/releases/download/v2.105.0/supabase_linux_amd64.tar.gz"
+
+[tools.supabase."platforms.linux-x64-musl"]
+checksum = "sha256:11ac4410c11e8b03f0cc7fd9316d68146695b0e06115a0663364b07e7feb6db8"
+url = "https://github.com/supabase/cli/releases/download/v2.105.0/supabase_linux_amd64.tar.gz"
+
+[tools.supabase."platforms.macos-arm64"]
+checksum = "sha256:930ffe5ce66c97917c43c6e1c712825b628c9665caaae5a6db109f816d50192d"
+url = "https://github.com/supabase/cli/releases/download/v2.105.0/supabase_darwin_arm64.tar.gz"
+
+[tools.supabase."platforms.macos-x64"]
+checksum = "sha256:c0af429bd5748ef03642e2d755c6a2d07fb52dd733b5ba592aeff598a36e585f"
+url = "https://github.com/supabase/cli/releases/download/v2.105.0/supabase_darwin_amd64.tar.gz"
+
+[tools.supabase."platforms.windows-x64"]
+checksum = "sha256:f4c11bb8b3c34fcf5ab6dd41cb1e9403416c7c319d668f24b3769f004bd24824"
+url = "https://github.com/supabase/cli/releases/download/v2.105.0/supabase_windows_amd64.tar.gz"
diff --git a/mise.toml b/mise.toml
new file mode 100644
index 000000000..9c2519a7f
--- /dev/null
+++ b/mise.toml
@@ -0,0 +1,76 @@
+[tools]
+elixir = "1.18.4-otp-27"
+erlang = "27"
+node = "24"
+supabase = "2.105.0"
+"npm:@supabase/pg-delta" = "1.0.0-alpha.30"
+
+[env]
+API_JWT_SECRET = "dev"
+DB_ENC_KEY = "1234567890123456"
+METRICS_JWT_SECRET = "dev"
+DASHBOARD_USER = "admin"
+DASHBOARD_PASSWORD = "admin"
+
+[tasks.dev]
+description = "Start the dev server"
+run = "iex --name ${NAME}@127.0.0.1 --cookie cookie -S mix phx.server"
+env = { NAME = "pink", PORT = "4000", REGION = "us-east-1", GEN_RPC_TCP_SERVER_PORT = "5369", GEN_RPC_TCP_CLIENT_PORT = "5469" }
+
+[tasks.dev-orange]
+description = "Start another dev server (orange)"
+run = "iex --name ${NAME}@127.0.0.1 --cookie cookie -S mix phx.server"
+env = { NAME = "orange", PORT = "4001", REGION = "eu-west-1", GEN_RPC_TCP_SERVER_PORT = "5469", GEN_RPC_TCP_CLIENT_PORT = "5369" }
+
+[tasks.db-start]
+description = "Start all dev databases"
+run = "docker compose -f compose.dbs.yml up -d --wait"
+
+[tasks.db-stop]
+description = "Stop all dev databases"
+run = "docker compose -f compose.dbs.yml stop"
+
+[tasks.db-rm]
+description = "Remove all dev databases"
+run = "docker compose -f compose.dbs.yml rm -sf"
+
+[tasks.realtime-start]
+description = "Start realtime server and dev databases"
+run = "docker compose up"
+
+[tasks.realtime-stop]
+description = "Stop realtime server and dev databases"
+run = "docker compose stop"
+
+[tasks.realtime-rm]
+description = "Remove realtime server and dev databases"
+run = "docker compose rm -sf"
+
+[tasks.realtime-rebuild]
+description = "Rebuild realtime server and dev databases"
+run = "docker compose down --remove-orphans && docker compose build"
+
+[tasks.test-start]
+description = "Start test services"
+run = "docker compose -f compose.tests.yml up"
+
+[tasks.test-stop]
+description = "Stop test services"
+run = "docker compose -f compose.tests.yml stop"
+
+[tasks.test-rm]
+description = "Remove test services"
+run = "docker compose -f compose.tests.yml rm -sf"
+
+[tasks.e2e]
+description = "Run e2e tests locally. Starts supabase if needed. Pass --url http://127.0.0.1:4000 to test against the local dev server."
+dir = "test/e2e"
+run = """
+ supabase start
+ eval "$(supabase status --output env)"
+ bun run realtime-check.ts --env local \
+ --publishable-key "$PUBLISHABLE_KEY" \
+ --secret-key "$SECRET_KEY" \
+ --db-url "postgresql://postgres:postgres@127.0.0.1:54322/postgres" \
+ "$@"
+"""
diff --git a/mix.exs b/mix.exs
index d0f8a267b..d3dbf3d5e 100644
--- a/mix.exs
+++ b/mix.exs
@@ -4,8 +4,8 @@ defmodule Realtime.MixProject do
def project do
[
app: :realtime,
- version: "2.46.2",
- elixir: "~> 1.17.3",
+ version: "2.109.1",
+ elixir: "~> 1.18",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
@@ -53,11 +53,12 @@ defmodule Realtime.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
- {:phoenix, "~> 1.7.0"},
+ phoenix_dep(),
{:phoenix_ecto, "~> 4.4.0"},
{:ecto_sql, "~> 3.11"},
{:ecto_psql_extras, "~> 0.8"},
- {:postgrex, "~> 0.20.0"},
+ {:postgrex, "~> 0.22"},
+ {:db_connection, github: "elixir-ecto/db_connection", branch: "master", override: true},
{:phoenix_html, "~> 3.2"},
{:phoenix_live_view, "~> 0.18"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
@@ -65,21 +66,26 @@ defmodule Realtime.MixProject do
{:phoenix_view, "~> 2.0"},
{:esbuild, "~> 0.4", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.1", runtime: Mix.env() == :dev},
- {:telemetry_metrics, "~> 0.6"},
+ {:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.19"},
{:jason, "~> 1.3"},
- {:plug_cowboy, "~> 2.6"},
+ {:plug_cowboy, "~> 2.8"},
{:libcluster, "~> 3.3"},
{:libcluster_postgres, "~> 0.2"},
{:uuid, "~> 1.1"},
- {:prom_ex, "~> 1.8"},
+ {:prom_ex, "~> 1.10"},
+ # prom_ex depends on peep ~> 3.0 but there is no issue using peep ~> 4.0
+ # https://github.com/akoutmos/prom_ex/pull/270
+ {:peep, "~> 4.3", override: true},
{:joken, "~> 2.5.0"},
- {:ex_json_schema, "~> 0.7"},
+ {:nimble_zta, "~> 0.1"},
+ {:ex_json_schema, "~> 0.11"},
{:recon, "~> 2.5"},
{:mint, "~> 1.4"},
{:logflare_logger_backend, "~> 0.11"},
{:syn, "~> 3.3"},
+ {:forum, path: "./forum"},
{:cachex, "~> 4.0"},
{:open_api_spex, "~> 3.16"},
{:corsica, "~> 2.0"},
@@ -90,7 +96,8 @@ defmodule Realtime.MixProject do
{:opentelemetry_phoenix, "~> 2.0"},
{:opentelemetry_cowboy, "~> 1.0"},
{:opentelemetry_ecto, "~> 1.2"},
- {:gen_rpc, git: "https://github.com/supabase/gen_rpc.git", ref: "d161cf263c661a534eaabf80aac7a34484dac772"},
+ {:gen_rpc, git: "https://github.com/emqx/gen_rpc.git", tag: "3.6.1"},
+ {:req, "~> 0.5"},
{:mimic, "~> 1.0", only: :test},
{:floki, ">= 0.30.0", only: :test},
{:mint_web_socket, "~> 1.0", only: :test},
@@ -102,11 +109,18 @@ defmodule Realtime.MixProject do
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.4", only: :dev, runtime: false},
{:poolboy, "~> 1.5", only: :test},
- {:req, "~> 0.5", only: :test},
{:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false}
]
end
+ defp phoenix_dep do
+ if path = System.get_env("PHOENIX_PATH") do
+ {:phoenix, path: path, override: true}
+ else
+ {:phoenix, override: true, github: "supabase/phoenix", branch: "feat/presence-custom-dispatcher-1.7.19"}
+ end
+ end
+
# Aliases are shortcuts or tasks specific to the current project.
# For example, to install project dependencies and perform other setup tasks, run:
#
@@ -116,15 +130,16 @@ defmodule Realtime.MixProject do
defp aliases do
[
setup: ["deps.get", "ecto.setup", "cmd npm install --prefix assets"],
- "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/dev_seeds.exs"],
+ "ecto.setup": ["ecto.create", "ecto.migrate", "seed"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
- test: [
+ seed: ["run priv/repo/dev_seeds.exs"],
+ "test.setup": [
"cmd epmd -daemon",
"ecto.create --quiet",
- "run priv/repo/seeds_before_migration.exs",
- "ecto.migrate --migrations-path=priv/repo/migrations",
- "test"
+ "ecto.migrate"
],
+ test: ["test.setup", "test"],
+ "test.partitioned": ["test.setup", "test --partitions 4"],
"assets.deploy": ["esbuild default --minify", "tailwind default --minify", "phx.digest"]
]
end
diff --git a/mix.lock b/mix.lock
index 76eb0d980..42f41b7c7 100644
--- a/mix.lock
+++ b/mix.lock
@@ -3,40 +3,40 @@
"benchee": {:hex, :benchee, "1.1.0", "f3a43817209a92a1fade36ef36b86e1052627fd8934a8b937ac9ab3a76c43062", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}], "hexpm", "7da57d545003165a012b587077f6ba90b89210fd88074ce3c60ce239eb5e6d93"},
"bertex": {:hex, :bertex, "1.3.0", "0ad0df9159b5110d9d2b6654f72fbf42a54884ef43b6b651e6224c0af30ba3cb", [:mix], [], "hexpm", "0a5d5e478bb5764b7b7bae37cae1ca491200e58b089df121a2fe1c223d8ee57a"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
- "cachex": {:hex, :cachex, "4.0.3", "95e88c3ef4d37990948eaecccefe40b4ce4a778e0d7ade29081e6b7a89309ee2", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_hash_ring, "~> 6.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "d5d632da7f162f8a190f1c39b712c0ebc9cf0007c4e2029d44eddc8041b52d55"},
- "castore": {:hex, :castore, "1.0.11", "4bbd584741601eb658007339ea730b082cc61f3554cf2e8f39bf693a11b49073", [:mix], [], "hexpm", "e03990b4db988df56262852f20de0f659871c35154691427a5047f4967a16a62"},
+ "cachex": {:hex, :cachex, "4.1.1", "574c5cd28473db313a0a76aac8c945fe44191659538ca6a1e8946ec300b1a19f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_hash_ring, "~> 6.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "d6b7449ff98d6bb92dda58bd4fc3189cae9f99e7042054d669596f56dc503cd8"},
+ "castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"},
"chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"},
"corsica": {:hex, :corsica, "2.1.3", "dccd094ffce38178acead9ae743180cdaffa388f35f0461ba1e8151d32e190e6", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "616c08f61a345780c2cf662ff226816f04d8868e12054e68963e95285b5be8bc"},
- "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"},
+ "cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
- "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"},
- "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"},
+ "cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"},
+ "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"},
"ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"},
- "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"},
- "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
+ "db_connection": {:git, "https://github.com/elixir-ecto/db_connection.git", "7ea461e4d13caa6d2c2483d30c932680f0fdf408", [branch: "master"]},
+ "decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"},
"deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"},
- "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"},
- "ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"},
+ "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"},
+ "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"},
"ecto_psql_extras": {:hex, :ecto_psql_extras, "0.8.8", "aa02529c97f69aed5722899f5dc6360128735a92dd169f23c5d50b1f7fdede08", [:mix], [{:ecto_sql, "~> 3.7", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, "> 0.16.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1 or ~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "04c63d92b141723ad6fed2e60a4b461ca00b3594d16df47bbc48f1f4534f2c49"},
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"},
"erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"},
- "esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"},
+ "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"},
"ex_hash_ring": {:hex, :ex_hash_ring, "6.0.4", "bef9d2d796afbbe25ab5b5a7ed746e06b99c76604f558113c273466d52fa6d6b", [:mix], [], "hexpm", "89adabf31f7d3dfaa36802ce598ce918e9b5b33bae8909ac1a4d052e1e567d18"},
- "ex_json_schema": {:hex, :ex_json_schema, "0.10.2", "7c4b8c1481fdeb1741e2ce66223976edfb9bccebc8014f6aec35d4efe964fb71", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "37f43be60f8407659d4d0155a7e45e7f406dab1f827051d3d35858a709baf6a6"},
- "excoveralls": {:hex, :excoveralls, "0.18.3", "bca47a24d69a3179951f51f1db6d3ed63bca9017f476fe520eb78602d45f7756", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "746f404fcd09d5029f1b211739afb8fb8575d775b21f6a3908e7ce3e640724c6"},
+ "ex_json_schema": {:hex, :ex_json_schema, "0.11.4", "d2f7d31894d048f79ed6c5a76515c266d5bd137438c53fa39c55f6ae98a05f47", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "0bbe87044ef0154be2a91ab6927d69c5fcccdb21908a135653fc10dcbbb79c3b"},
+ "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"},
"expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"},
- "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
- "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
- "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"},
- "gen_rpc": {:git, "https://github.com/supabase/gen_rpc.git", "d161cf263c661a534eaabf80aac7a34484dac772", [ref: "d161cf263c661a534eaabf80aac7a34484dac772"]},
+ "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
+ "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
+ "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"},
+ "gen_rpc": {:git, "https://github.com/emqx/gen_rpc.git", "891f90d713e83e3fca049345fb641afd9a1def28", [tag: "3.6.1"]},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"},
"grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"},
- "ham": {:hex, :ham, "0.3.0", "7cd031b4a55fba219c11553e7b13ba73bd86eab4034518445eff1e038cb9a44d", [:mix], [], "hexpm", "7d6c6b73d7a6a83233876cc1b06a4d9b5de05562b228effda4532f9a49852bf6"},
+ "ham": {:hex, :ham, "0.3.2", "02ae195f49970ef667faf9d01bc454fb80909a83d6c775bcac724ca567aeb7b3", [:mix], [], "hexpm", "b71cc684c0e5a3d32b5f94b186770551509e93a9ae44ca1c1a313700f2f6a69a"},
"hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"},
- "hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
+ "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
+ "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"joken": {:hex, :joken, "2.5.0", "09be497d804b8115eb6f07615cef2e60c2a1008fb89dc0aef0d4c4b4609b99aa", [:mix], [{:jose, "~> 1.11.2", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "22b25c89617c5ed8ca7b31026340a25ea0f9ca7160f9706b79be9ed81fdf74e7"},
"jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"},
"jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"},
@@ -45,65 +45,68 @@
"logflare_api_client": {:hex, :logflare_api_client, "0.3.5", "c427ebf65a8402d68b056d4a5ef3e1eb3b90c0ad1d0de97d1fe23807e0c1b113", [:mix], [{:bertex, "~> 1.3", [hex: :bertex, repo: "hexpm", optional: false]}, {:finch, "~> 0.10", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "16d29abcb80c4f72745cdf943379da02a201504813c3aa12b4d4acb0302b7723"},
"logflare_etso": {:hex, :logflare_etso, "1.1.2", "040bd3e482aaf0ed20080743b7562242ec5079fd88a6f9c8ce5d8298818292e9", [:mix], [{:ecto, "~> 3.8", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "ab96be42900730a49b132891f43a9be1d52e4ad3ee9ed9cb92565c5f87345117"},
"logflare_logger_backend": {:hex, :logflare_logger_backend, "0.11.4", "3a5df94e764b7c8ee4bd7b875a480a34a27807128d8459aa59ea63b2b38bddc7", [:mix], [{:bertex, "~> 1.3", [hex: :bertex, repo: "hexpm", optional: false]}, {:logflare_api_client, "~> 0.3.5", [hex: :logflare_api_client, repo: "hexpm", optional: false]}, {:logflare_etso, "~> 1.1.2", [hex: :logflare_etso, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "00998d81b3c481ad93d2bf25e66d1ddb1a01ad77d994e2c1a7638c6da94755c5"},
- "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
+ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mimic": {:hex, :mimic, "1.12.0", "34c9d1fb8e756df09ca5f96861d273f2bb01063df1a6a51a4c101f9ad7f07a9c", [:mix], [{:ham, "~> 0.2", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "eaa43d495d6f3bc8099b28886e05a1b09a2a6be083f6385c3abc17599e5e2c43"},
- "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"},
+ "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mint_web_socket": {:hex, :mint_web_socket, "1.0.4", "0b539116dbb3d3f861cdf5e15e269a933cb501c113a14db7001a3157d96ffafd", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "027d4c5529c45a4ba0ce27a01c0f35f284a5468519c045ca15f43decb360a991"},
- "mix_audit": {:hex, :mix_audit, "2.1.4", "0a23d5b07350cdd69001c13882a4f5fb9f90fbd4cbf2ebc190a2ee0d187ea3e9", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "fd807653cc8c1cada2911129c7eb9e985e3cc76ebf26f4dd628bb25bbcaa7099"},
+ "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
"mix_test_watch": {:hex, :mix_test_watch, "1.3.0", "2ffc9f72b0d1f4ecf0ce97b044e0e3c607c3b4dc21d6228365e8bc7c2856dc77", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "f9e5edca976857ffac78632e635750d158df14ee2d6185a15013844af7570ffe"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
- "observer_cli": {:hex, :observer_cli, "1.8.1", "edfe0c0f983631961599326f239f6e99750aba7387515002b1284dcfe7fcd6d2", [:mix, :rebar3], [{:recon, "~> 2.5.6", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "a3cd6300dd8290ade93d688fbd79c872e393b01256309dd7a653feb13c434fb4"},
+ "nimble_zta": {:hex, :nimble_zta, "0.1.2", "cb3d9f12963b36004a2cebbfe7b8a16fc186cef87e561485d5c7940ee5c8f093", [:mix], [{:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "a806e7c7a3c2cc09f0f58334f210f59a6ab6facdf64a3d7421a5776b3d00dca4"},
+ "observer_cli": {:hex, :observer_cli, "1.8.4", "09030c04d2480499037ba33d801c6e02adba4e7244a05e05b984b5a82843be71", [:mix, :rebar3], [{:recon, "~> 2.5.6", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "0fcd71ac723bcd2d91266d99b3c3ccd9465c71c9f392d900cea8effdc1a1485c"},
"octo_fetch": {:hex, :octo_fetch, "0.4.0", "074b5ecbc08be10b05b27e9db08bc20a3060142769436242702931c418695b19", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "cf8be6f40cd519d7000bb4e84adcf661c32e59369ca2827c4e20042eda7a7fc6"},
- "open_api_spex": {:hex, :open_api_spex, "3.21.2", "6a704f3777761feeb5657340250d6d7332c545755116ca98f33d4b875777e1e5", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "f42ae6ed668b895ebba3e02773cfb4b41050df26f803f2ef634c72a7687dc387"},
- "opentelemetry": {:hex, :opentelemetry, "1.5.0", "7dda6551edfc3050ea4b0b40c0d2570423d6372b97e9c60793263ef62c53c3c2", [:rebar3], [{:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "cdf4f51d17b592fc592b9a75f86a6f808c23044ba7cf7b9534debbcc5c23b0ee"},
- "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.0", "63ca1742f92f00059298f478048dfb826f4b20d49534493d6919a0db39b6db04", [:mix, :rebar3], [], "hexpm", "3dfbbfaa2c2ed3121c5c483162836c4f9027def469c41578af5ef32589fcfc58"},
+ "open_api_spex": {:hex, :open_api_spex, "3.22.3", "0e383bf23cc3a060bffaebbcd09fc06bfc908d948c00e518aed36bbf8a2fe473", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "5f74f1878fdc38f8e961b0b943ac7af88dcf3a82a0c0ef6680ddfd3d161aecbd"},
+ "opentelemetry": {:hex, :opentelemetry, "1.6.0", "0954dbe12f490ee7b126c9e924cf60141b1238a02dfc700907eadde4dcc20460", [:rebar3], [{:opentelemetry_api, "~> 1.4.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "5fd0123d65d2649f10e478e7444927cd9fbdffcaeb8c1c2fcae3d486d18c5e62"},
+ "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.1", "e071429a37441a0fe9097eeea0ff921ebadce8eba8e1ce297b05a43c7a0d121f", [:mix, :rebar3], [], "hexpm", "39bdb6ad740bc13b16215cb9f233d66796bbae897f3bf6eb77abb712e87c3c26"},
"opentelemetry_cowboy": {:hex, :opentelemetry_cowboy, "1.0.0", "786c7cde66a2493323c79d2c94e679ff501d459a9b403d8b60b9bef116333117", [:rebar3], [{:cowboy_telemetry, "~> 0.4", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.1", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7575716eaccacd0eddc3e7e61403aecb5d0a6397183987d6049094aeb0b87a7c"},
"opentelemetry_ecto": {:hex, :opentelemetry_ecto, "1.2.0", "2382cb47ddc231f953d3b8263ed029d87fbf217915a1da82f49159d122b64865", [:mix], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "70dfa2e79932e86f209df00e36c980b17a32f82d175f0068bf7ef9a96cf080cf"},
- "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.8.0", "5d546123230771ef4174e37bedfd77e3374913304cd6ea3ca82a2add49cd5d56", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.5.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "a1f9f271f8d3b02b81462a6bfef7075fd8457fdb06adff5d2537df5e2264d9af"},
+ "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.9.0", "e344bf5e3dab2815fe381b0cac172c06cfc29ecf792c5d74cbbd2b3184af359c", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.6.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "2030a59e33afff6aaeba847d865c8db5dc3873db87a9257df2ca03cafd9e0478"},
"opentelemetry_phoenix": {:hex, :opentelemetry_phoenix, "2.0.1", "c664cdef205738cffcd409b33599439a4ffb2035ef6e21a77927ac1da90463cb", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.1", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:plug, ">= 1.11.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a24fdccdfa6b890c8892c6366beab4a15a27ec0c692b0f77ec2a862e7b235f6e"},
"opentelemetry_process_propagator": {:hex, :opentelemetry_process_propagator, "0.3.0", "ef5b2059403a1e2b2d2c65914e6962e56371570b8c3ab5323d7a8d3444fb7f84", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "7243cb6de1523c473cba5b1aefa3f85e1ff8cc75d08f367104c1e11919c8c029"},
"opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "1.27.0", "acd0194a94a1e57d63da982ee9f4a9f88834ae0b31b0bd850815fe9be4bbb45f", [:mix, :rebar3], [], "hexpm", "9681ccaa24fd3d810b4461581717661fd85ff7019b082c2dff89c7d5b1fc2864"},
"opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.1.2", "410ab4d76b0921f42dbccbe5a7c831b8125282850be649ee1f70050d3961118a", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "641ab469deb181957ac6d59bce6e1321d5fe2a56df444fc9c19afcad623ab253"},
"otel_http": {:hex, :otel_http, "0.2.0", "b17385986c7f1b862f5d577f72614ecaa29de40392b7618869999326b9a61d8a", [:rebar3], [], "hexpm", "f2beadf922c8cfeb0965488dd736c95cc6ea8b9efce89466b3904d317d7cc717"},
- "phoenix": {:hex, :phoenix, "1.7.19", "36617efe5afbd821099a8b994ff4618a340a5bfb25531a1802c4d4c634017a57", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "ba4dc14458278773f905f8ae6c2ec743d52c3a35b6b353733f64f02dfe096cd6"},
+ "peep": {:hex, :peep, "4.3.1", "5157b7ed02d1fa90af2f67768230084c8bc82ec1513e6982e46d6fb1ec5f957f", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e96cca0c194a1ed8b0a8b109fa2244a3bfb23acf2b45c01434007ffb67859fe"},
+ "phoenix": {:git, "https://github.com/supabase/phoenix.git", "7b884cc0cc1a49ad2bc272acda2e622b3e11c139", [branch: "feat/presence-custom-dispatcher-1.7.19"]},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"},
"phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"},
- "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.6", "7b1f0327f54c9eb69845fd09a77accf922f488c549a7e7b8618775eb603a62c7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "1681ab813ec26ca6915beb3414aa138f298e17721dc6a2bde9e6eb8a62360ff6"},
- "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"},
+ "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
+ "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.20.17", "f396bbdaf4ba227b82251eb75ac0afa6b3da5e509bc0d030206374237dfc9450", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61d741ffb78c85fdbca0de084da6a48f8ceb5261a79165b5a0b59e5f65ce98b"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
- "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
- "plug_cowboy": {:hex, :plug_cowboy, "2.7.2", "fdadb973799ae691bf9ecad99125b16625b1c6039999da5fe544d99218e662e4", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "245d8a11ee2306094840c000e8816f0cbed69a23fc0ac2bcf8d7835ae019bb2f"},
- "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
+ "plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"},
+ "plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"},
+ "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
- "postgres_replication": {:git, "https://github.com/filipecabaco/postgres_replication.git", "69129221f0263aa13faa5fbb8af97c28aeb4f71c", []},
- "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"},
- "prom_ex": {:hex, :prom_ex, "1.9.0", "63e6dda6c05cdeec1f26c48443dcc38ffd2118b3665ae8d2bd0e5b79f2aea03e", [:mix], [{:absinthe, ">= 1.6.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.0.2", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.5.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.4.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.3", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.5.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.14.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.12.1", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.5", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.0", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "01f3d4f69ec93068219e686cc65e58a29c42bea5429a8ff4e2121f19db178ee6"},
- "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
+ "postgres_replication": {:git, "https://github.com/filipecabaco/postgres_replication.git", "3b0700ee38a1dddaf7936c5793d6f35431fee2cd", []},
+ "postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
+ "prom_ex": {:hex, :prom_ex, "1.11.0", "1f6d67f2dead92224cb4f59beb3e4d319257c5728d9638b4a5e8ceb51a4f9c7e", [:mix], [{:absinthe, ">= 1.7.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.11.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "76b074bc3730f0802978a7eb5c7091a65473eaaf07e99ec9e933138dcc327805"},
+ "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"},
- "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"},
+ "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"},
+ "rustler": {:hex, :rustler, "0.37.3", "5f4e6634d43b26f0a69834dd1d3ed4e1710b022a053bf4a670220c9540c92602", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a6872c6f53dcf00486d1e7f9e046e20e01bf1654bdacc4193016c2e8002b32a2"},
"sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"},
"snabbkaffe": {:git, "https://github.com/kafka4beam/snabbkaffe", "b59298334ed349556f63405d1353184c63c66534", [tag: "1.0.10"]},
- "sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
+ "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
- "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"},
+ "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"},
"syn": {:hex, :syn, "3.3.0", "4684a909efdfea35ce75a9662fc523e4a8a4e8169a3df275e4de4fa63f99c486", [:rebar3], [], "hexpm", "e58ee447bc1094bdd21bf0acc102b1fbf99541a508cd48060bf783c245eaf7d6"},
"table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"},
- "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"},
- "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
- "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"},
+ "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
+ "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
+ "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"},
- "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"},
- "tesla": {:hex, :tesla, "1.13.2", "85afa342eb2ac0fee830cf649dbd19179b6b359bec4710d02a3d5d587f016910", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "960609848f1ef654c3cdfad68453cd84a5febecb6ed9fed9416e36cd9cd724f9"},
- "tls_certificate_check": {:hex, :tls_certificate_check, "1.28.0", "c39bf21f67c2d124ae905454fad00f27e625917e8ab1009146e916e1df6ab275", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3ab058c3f9457fffca916729587415f0ddc822048a0e5b5e2694918556d92df1"},
+ "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
+ "tesla": {:hex, :tesla, "1.15.3", "3a2b5c37f09629b8dcf5d028fbafc9143c0099753559d7fe567eaabfbd9b8663", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "98bb3d4558abc67b92fb7be4cd31bb57ca8d80792de26870d362974b58caeda7"},
+ "tls_certificate_check": {:hex, :tls_certificate_check, "1.29.0", "4473005eb0bbdad215d7083a230e2e076f538d9ea472c8009fd22006a4cfc5f6", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "5b0d0e5cb0f928bc4f210df667304ed91c5bff2a391ce6bdedfbfe70a8f096c5"},
"typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"},
"unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"},
"uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
- "yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"},
+ "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"},
}
diff --git a/priv/repo/dev_seeds.exs b/priv/repo/dev_seeds.exs
index 7dec7895a..7b69a6ea2 100644
--- a/priv/repo/dev_seeds.exs
+++ b/priv/repo/dev_seeds.exs
@@ -1,5 +1,3 @@
-import Ecto.Adapters.SQL, only: [query!: 3]
-
alias Realtime.Api.Tenant
alias Realtime.Database
alias Realtime.Repo
@@ -7,6 +5,7 @@ alias Realtime.Tenants
tenant_name = "realtime-dev"
default_db_host = "127.0.0.1"
+publication = "supabase_realtime"
{:ok, tenant} =
Repo.transaction(fn ->
@@ -30,6 +29,8 @@ default_db_host = "127.0.0.1"
"db_host" => System.get_env("DB_HOST", default_db_host),
"db_user" => System.get_env("DB_USER", "supabase_admin"),
"db_password" => System.get_env("DB_PASSWORD", "postgres"),
+ "db_user_realtime" => System.get_env("DB_USER_REALTIME", "supabase_realtime_admin"),
+ "db_pass_realtime" => System.get_env("DB_PASS_REALTIME", "postgres"),
"db_port" => System.get_env("DB_PORT", "5433"),
"region" => "us-east-1",
"poll_interval_ms" => 100,
@@ -41,36 +42,31 @@ default_db_host = "127.0.0.1"
})
|> Repo.insert!()
- publication = "supabase_realtime"
-
- [
- "drop publication if exists #{publication}",
- "drop table if exists public.test_tenant;",
- "create table public.test_tenant ( id SERIAL PRIMARY KEY, details text );",
- "grant all on table public.test_tenant to anon;",
- "grant all on table public.test_tenant to postgres;",
- "grant all on table public.test_tenant to authenticated;",
- "create publication #{publication} for table public.test_tenant"
- ]
- |> Enum.each(&query!(Repo, &1, []))
-
tenant
end)
# Reset Tenant DB
-settings = Database.from_tenant(tenant, "realtime_migrations", :stop)
-settings = %{settings | max_restarts: 0, ssl: false}
-{:ok, tenant_conn} = Database.connect_db(settings)
+{:ok, settings} = Database.from_tenant(tenant, "realtime_seeds", :stop)
+{:ok, admin_conn} = Database.connect_db(%{settings | username: "supabase_admin", max_restarts: 0, ssl: false})
-Postgrex.transaction(tenant_conn, fn db_conn ->
- Postgrex.query!(db_conn, "DROP SCHEMA IF EXISTS realtime CASCADE", [])
- Postgrex.query!(db_conn, "CREATE SCHEMA IF NOT EXISTS realtime", [])
+Postgrex.transaction(admin_conn, fn db_conn ->
+ [
+ "grant usage on schema realtime to postgres, anon, authenticated, service_role",
+ "grant all on schema realtime to supabase_realtime_admin with grant option",
+ "drop publication if exists #{publication}",
+ "drop table if exists public.test_tenant",
+ "create table public.test_tenant ( id SERIAL PRIMARY KEY, details text )",
+ "grant all on table public.test_tenant to anon, authenticated, supabase_realtime_admin",
+ "create publication #{publication} for table public.test_tenant"
+ ]
+ |> Enum.each(&Postgrex.query!(db_conn, &1))
end)
+# Enable supabase_realtime_admin to include SetupSupabaseRealtimeAdmin in tenant catalog
+{:ok, _} = Realtime.Api.upsert_feature_flag(%{name: "use_supabase_realtime_admin", enabled: true})
+
case Tenants.Migrations.run_migrations(tenant) do
:ok -> :ok
:noop -> :ok
_ -> raise "Running Migrations failed"
end
-
-Tenants.Migrations.run_migrations(tenant)
diff --git a/priv/repo/migrations/20250926223044_set_default_presence_value.exs b/priv/repo/migrations/20250926223044_set_default_presence_value.exs
new file mode 100644
index 000000000..5f1833a34
--- /dev/null
+++ b/priv/repo/migrations/20250926223044_set_default_presence_value.exs
@@ -0,0 +1,10 @@
+defmodule Realtime.Repo.Migrations.SetDefaultPresenceValue do
+ use Ecto.Migration
+ @disable_ddl_transaction true
+ @disable_migration_lock true
+ def change do
+ alter table(:tenants) do
+ modify :max_presence_events_per_second, :integer, default: 1000
+ end
+ end
+end
diff --git a/priv/repo/migrations/20251204170944_nullable_jwt_secrets.exs b/priv/repo/migrations/20251204170944_nullable_jwt_secrets.exs
new file mode 100644
index 000000000..342a80ad9
--- /dev/null
+++ b/priv/repo/migrations/20251204170944_nullable_jwt_secrets.exs
@@ -0,0 +1,13 @@
+defmodule Realtime.Repo.Migrations.NullableJwtSecrets do
+ use Ecto.Migration
+
+ def change do
+ alter table(:tenants) do
+ modify :jwt_secret, :text, null: true
+ end
+
+ create constraint(:tenants, :jwt_secret_or_jwt_jwks_required,
+ check: "jwt_secret IS NOT NULL OR jwt_jwks IS NOT NULL"
+ )
+ end
+end
diff --git a/priv/repo/migrations/20251218000543_ensure_jwt_secret_is_text.exs b/priv/repo/migrations/20251218000543_ensure_jwt_secret_is_text.exs
new file mode 100644
index 000000000..008c9d7db
--- /dev/null
+++ b/priv/repo/migrations/20251218000543_ensure_jwt_secret_is_text.exs
@@ -0,0 +1,9 @@
+defmodule Realtime.Repo.Migrations.EnsureJwtSecretIsText do
+ use Ecto.Migration
+
+ def change do
+ alter table(:tenants) do
+ modify :jwt_secret, :text, null: true
+ end
+ end
+end
diff --git a/priv/repo/migrations/20260209232800_add_max_client_presence_events_per_second.exs b/priv/repo/migrations/20260209232800_add_max_client_presence_events_per_second.exs
new file mode 100644
index 000000000..403ad77c5
--- /dev/null
+++ b/priv/repo/migrations/20260209232800_add_max_client_presence_events_per_second.exs
@@ -0,0 +1,10 @@
+defmodule Realtime.Repo.Migrations.AddMaxClientPresenceEventsPerSecond do
+ use Ecto.Migration
+
+ def change do
+ alter table(:tenants) do
+ add :max_client_presence_events_per_window, :integer, null: true
+ add :client_presence_window_ms, :integer, null: true
+ end
+ end
+end
diff --git a/priv/repo/migrations/20260304000000_add_presence_enabled_to_tenants.exs b/priv/repo/migrations/20260304000000_add_presence_enabled_to_tenants.exs
new file mode 100644
index 000000000..0e032afb4
--- /dev/null
+++ b/priv/repo/migrations/20260304000000_add_presence_enabled_to_tenants.exs
@@ -0,0 +1,9 @@
+defmodule Realtime.Repo.Migrations.AddPresenceEnabledToTenants do
+ use Ecto.Migration
+
+ def change do
+ alter table(:tenants) do
+ add :presence_enabled, :boolean, default: false, null: false
+ end
+ end
+end
diff --git a/priv/repo/migrations/20260422000000_create_feature_flags.exs b/priv/repo/migrations/20260422000000_create_feature_flags.exs
new file mode 100644
index 000000000..3792025b4
--- /dev/null
+++ b/priv/repo/migrations/20260422000000_create_feature_flags.exs
@@ -0,0 +1,18 @@
+defmodule Realtime.Repo.Migrations.CreateFeatureFlags do
+ use Ecto.Migration
+
+ def change do
+ create table(:feature_flags, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :name, :string, null: false
+ add :enabled, :boolean, null: false, default: false
+ timestamps()
+ end
+
+ create unique_index(:feature_flags, [:name])
+
+ alter table(:tenants) do
+ add :feature_flags, :map, null: false, default: %{}
+ end
+ end
+end
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
index 95715f77b..d5caf2b61 100644
--- a/priv/repo/seeds.exs
+++ b/priv/repo/seeds.exs
@@ -30,6 +30,8 @@ default_db_host = "host.docker.internal"
"db_host" => System.get_env("DB_HOST", default_db_host),
"db_user" => System.get_env("DB_USER", "supabase_admin"),
"db_password" => System.get_env("DB_PASSWORD", "postgres"),
+ "db_user_realtime" => System.get_env("DB_USER_REALTIME", "supabase_realtime_admin"),
+ "db_pass_realtime" => System.get_env("DB_PASS_REALTIME", "postgres"),
"db_port" => System.get_env("DB_PORT", "5433"),
"region" => "us-east-1",
"poll_interval_ms" => 100,
diff --git a/priv/repo/tenant_db_catalog_17.json b/priv/repo/tenant_db_catalog_17.json
new file mode 100644
index 000000000..c67fb82c9
--- /dev/null
+++ b/priv/repo/tenant_db_catalog_17.json
@@ -0,0 +1,3256 @@
+{
+ "version": 170006,
+ "currentUser": "supabase_admin",
+ "aggregates": {},
+ "collations": {},
+ "compositeTypes": {
+ "type:realtime.user_defined_filter": {
+ "schema": "realtime",
+ "name": "user_defined_filter",
+ "row_security": false,
+ "force_row_security": false,
+ "has_indexes": false,
+ "has_rules": false,
+ "has_triggers": false,
+ "has_subclasses": false,
+ "is_populated": true,
+ "replica_identity": "n",
+ "is_partition": false,
+ "options": null,
+ "partition_bound": null,
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "columns": [
+ {
+ "name": "column_name",
+ "position": 1,
+ "data_type": "text",
+ "data_type_str": "text",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null
+ },
+ {
+ "name": "op",
+ "position": 2,
+ "data_type": "realtime.equality_op",
+ "data_type_str": "realtime.equality_op",
+ "is_custom_type": true,
+ "custom_type_type": "e",
+ "custom_type_category": "E",
+ "custom_type_schema": "realtime",
+ "custom_type_name": "equality_op",
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null
+ },
+ {
+ "name": "value",
+ "position": 3,
+ "data_type": "text",
+ "data_type_str": "text",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null
+ }
+ ],
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "USAGE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "USAGE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ },
+ "type:realtime.wal_column": {
+ "schema": "realtime",
+ "name": "wal_column",
+ "row_security": false,
+ "force_row_security": false,
+ "has_indexes": false,
+ "has_rules": false,
+ "has_triggers": false,
+ "has_subclasses": false,
+ "is_populated": true,
+ "replica_identity": "n",
+ "is_partition": false,
+ "options": null,
+ "partition_bound": null,
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "columns": [
+ {
+ "name": "name",
+ "position": 1,
+ "data_type": "text",
+ "data_type_str": "text",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null
+ },
+ {
+ "name": "type_name",
+ "position": 2,
+ "data_type": "text",
+ "data_type_str": "text",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null
+ },
+ {
+ "name": "type_oid",
+ "position": 3,
+ "data_type": "oid",
+ "data_type_str": "oid",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null
+ },
+ {
+ "name": "value",
+ "position": 4,
+ "data_type": "jsonb",
+ "data_type_str": "jsonb",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null
+ },
+ {
+ "name": "is_pkey",
+ "position": 5,
+ "data_type": "boolean",
+ "data_type_str": "boolean",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null
+ },
+ {
+ "name": "is_selectable",
+ "position": 6,
+ "data_type": "boolean",
+ "data_type_str": "boolean",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null
+ }
+ ],
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "USAGE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "USAGE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ },
+ "type:realtime.wal_rls": {
+ "schema": "realtime",
+ "name": "wal_rls",
+ "row_security": false,
+ "force_row_security": false,
+ "has_indexes": false,
+ "has_rules": false,
+ "has_triggers": false,
+ "has_subclasses": false,
+ "is_populated": true,
+ "replica_identity": "n",
+ "is_partition": false,
+ "options": null,
+ "partition_bound": null,
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "columns": [
+ {
+ "name": "wal",
+ "position": 1,
+ "data_type": "jsonb",
+ "data_type_str": "jsonb",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null
+ },
+ {
+ "name": "is_rls_enabled",
+ "position": 2,
+ "data_type": "boolean",
+ "data_type_str": "boolean",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null
+ },
+ {
+ "name": "subscription_ids",
+ "position": 3,
+ "data_type": "uuid[]",
+ "data_type_str": "uuid[]",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null
+ },
+ {
+ "name": "errors",
+ "position": 4,
+ "data_type": "text[]",
+ "data_type_str": "text[]",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null
+ }
+ ],
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "USAGE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "USAGE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ }
+ },
+ "domains": {},
+ "enums": {
+ "type:realtime.action": {
+ "schema": "realtime",
+ "name": "action",
+ "owner": "supabase_realtime_admin",
+ "labels": [
+ {
+ "sort_order": 1,
+ "label": "INSERT"
+ },
+ {
+ "sort_order": 2,
+ "label": "UPDATE"
+ },
+ {
+ "sort_order": 3,
+ "label": "DELETE"
+ },
+ {
+ "sort_order": 4,
+ "label": "TRUNCATE"
+ },
+ {
+ "sort_order": 5,
+ "label": "ERROR"
+ }
+ ],
+ "comment": null,
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "USAGE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "USAGE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ },
+ "type:realtime.equality_op": {
+ "schema": "realtime",
+ "name": "equality_op",
+ "owner": "supabase_realtime_admin",
+ "labels": [
+ {
+ "sort_order": 1,
+ "label": "eq"
+ },
+ {
+ "sort_order": 2,
+ "label": "neq"
+ },
+ {
+ "sort_order": 3,
+ "label": "lt"
+ },
+ {
+ "sort_order": 4,
+ "label": "lte"
+ },
+ {
+ "sort_order": 5,
+ "label": "gt"
+ },
+ {
+ "sort_order": 6,
+ "label": "gte"
+ },
+ {
+ "sort_order": 7,
+ "label": "in"
+ }
+ ],
+ "comment": null,
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "USAGE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "USAGE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ }
+ },
+ "extensions": {},
+ "procedures": {
+ "procedure:realtime.\"cast\"(text,regtype)": {
+ "schema": "realtime",
+ "name": "\"cast\"",
+ "kind": "f",
+ "return_type": "jsonb",
+ "return_type_schema": "pg_catalog",
+ "language": "plpgsql",
+ "security_definer": false,
+ "volatility": "i",
+ "parallel_safety": "u",
+ "execution_cost": 100,
+ "result_rows": 0,
+ "is_strict": false,
+ "leakproof": false,
+ "returns_set": false,
+ "argument_count": 2,
+ "argument_default_count": 0,
+ "argument_names": [
+ "val",
+ "type_"
+ ],
+ "argument_types": [
+ "text",
+ "regtype"
+ ],
+ "all_argument_types": [],
+ "argument_modes": null,
+ "argument_defaults": null,
+ "source_code": "\ndeclare\n res jsonb;\nbegin\n if type_::text = 'bytea' then\n return to_jsonb(val);\n end if;\n execute format('select to_jsonb(%L::'|| type_::text || ')', val) into res;\n return res;\nend\n",
+ "binary_path": null,
+ "sql_body": null,
+ "definition": "CREATE OR REPLACE FUNCTION realtime.\"cast\"(val text, type_ regtype)\n RETURNS jsonb\n LANGUAGE plpgsql\n IMMUTABLE\nAS $function$\ndeclare\n res jsonb;\nbegin\n if type_::text = 'bytea' then\n return to_jsonb(val);\n end if;\n execute format('select to_jsonb(%L::'|| type_::text || ')', val) into res;\n return res;\nend\n$function$\n",
+ "config": null,
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "anon",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "authenticated",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "service_role",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "EXECUTE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ },
+ "procedure:realtime.apply_rls(jsonb,integer)": {
+ "schema": "realtime",
+ "name": "apply_rls",
+ "kind": "f",
+ "return_type": "realtime.wal_rls",
+ "return_type_schema": "realtime",
+ "language": "plpgsql",
+ "security_definer": false,
+ "volatility": "v",
+ "parallel_safety": "u",
+ "execution_cost": 100,
+ "result_rows": 1000,
+ "is_strict": false,
+ "leakproof": false,
+ "returns_set": true,
+ "argument_count": 2,
+ "argument_default_count": 1,
+ "argument_names": [
+ "wal",
+ "max_record_bytes"
+ ],
+ "argument_types": [
+ "jsonb",
+ "integer"
+ ],
+ "all_argument_types": [],
+ "argument_modes": null,
+ "argument_defaults": "(1024 * 1024)",
+ "source_code": "\ndeclare\n -- Regclass of the table e.g. public.notes\n entity_ regclass = (quote_ident(wal ->> 'schema') || '.' || quote_ident(wal ->> 'table'))::regclass;\n\n -- I, U, D, T: insert, update ...\n action realtime.action = (\n case wal ->> 'action'\n when 'I' then 'INSERT'\n when 'U' then 'UPDATE'\n when 'D' then 'DELETE'\n else 'ERROR'\n end\n );\n\n -- Is row level security enabled for the table\n is_rls_enabled bool = relrowsecurity from pg_class where oid = entity_;\n\n subscriptions realtime.subscription[] = array_agg(subs)\n from\n realtime.subscription subs\n where\n subs.entity = entity_\n -- Filter by action early - only get subscriptions interested in this action\n -- action_filter column can be: '*' (all), 'INSERT', 'UPDATE', or 'DELETE'\n and (subs.action_filter = '*' or subs.action_filter = action::text);\n\n -- Subscription vars\n working_role regrole;\n working_selected_columns text[];\n claimed_role regrole;\n claims jsonb;\n\n subscription_id uuid;\n subscription_has_access bool;\n visible_to_subscription_ids uuid[] = '{}';\n\n -- structured info for wal's columns\n columns realtime.wal_column[];\n -- previous identity values for update/delete\n old_columns realtime.wal_column[];\n\n error_record_exceeds_max_size boolean = octet_length(wal::text) > max_record_bytes;\n\n -- Primary jsonb output for record\n output jsonb;\n\n -- Loop record for iterating unique roles (outer loop)\n role_record record;\n -- Loop record for iterating unique selected_columns within a role (inner loop)\n cols_record record;\n -- Subscription ids visible at the role level (before fanning out by selected_columns)\n visible_role_sub_ids uuid[] = '{}';\n\nbegin\n perform set_config('role', null, true);\n\n columns =\n array_agg(\n (\n x->>'name',\n x->>'type',\n x->>'typeoid',\n realtime.cast(\n (x->'value') #>> '{}',\n coalesce(\n (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4\n (x->>'type')::regtype\n )\n ),\n (pks ->> 'name') is not null,\n true\n )::realtime.wal_column\n )\n from\n jsonb_array_elements(wal -> 'columns') x\n left join jsonb_array_elements(wal -> 'pk') pks\n on (x ->> 'name') = (pks ->> 'name');\n\n old_columns =\n array_agg(\n (\n x->>'name',\n x->>'type',\n x->>'typeoid',\n realtime.cast(\n (x->'value') #>> '{}',\n coalesce(\n (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4\n (x->>'type')::regtype\n )\n ),\n (pks ->> 'name') is not null,\n true\n )::realtime.wal_column\n )\n from\n jsonb_array_elements(wal -> 'identity') x\n left join jsonb_array_elements(wal -> 'pk') pks\n on (x ->> 'name') = (pks ->> 'name');\n\n for role_record in\n select claims_role\n from (select distinct claims_role from unnest(subscriptions)) t\n order by claims_role::text\n loop\n working_role := role_record.claims_role;\n\n -- Update `is_selectable` for columns and old_columns (once per role)\n columns =\n array_agg(\n (\n c.name,\n c.type_name,\n c.type_oid,\n c.value,\n c.is_pkey,\n pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT')\n )::realtime.wal_column\n )\n from\n unnest(columns) c;\n\n old_columns =\n array_agg(\n (\n c.name,\n c.type_name,\n c.type_oid,\n c.value,\n c.is_pkey,\n pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT')\n )::realtime.wal_column\n )\n from\n unnest(old_columns) c;\n\n if action <> 'DELETE' and count(1) = 0 from unnest(columns) c where c.is_pkey then\n -- Fan out 400 error per distinct selected_columns for this role\n for cols_record in\n select selected_columns\n from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t\n order by coalesce(array_to_string(selected_columns, ','), '')\n loop\n working_selected_columns := cols_record.selected_columns;\n return next (\n jsonb_build_object(\n 'schema', wal ->> 'schema',\n 'table', wal ->> 'table',\n 'type', action\n ),\n is_rls_enabled,\n (select array_agg(s.subscription_id) from unnest(subscriptions) as s where s.claims_role = working_role and (s.selected_columns is not distinct from working_selected_columns)),\n array['Error 400: Bad Request, no primary key']\n )::realtime.wal_rls;\n end loop;\n\n -- The claims role does not have SELECT permission to the primary key of entity\n elsif action <> 'DELETE' and sum(c.is_selectable::int) <> count(1) from unnest(columns) c where c.is_pkey then\n -- Fan out 401 error per distinct selected_columns for this role\n for cols_record in\n select selected_columns\n from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t\n order by coalesce(array_to_string(selected_columns, ','), '')\n loop\n working_selected_columns := cols_record.selected_columns;\n return next (\n jsonb_build_object(\n 'schema', wal ->> 'schema',\n 'table', wal ->> 'table',\n 'type', action\n ),\n is_rls_enabled,\n (select array_agg(s.subscription_id) from unnest(subscriptions) as s where s.claims_role = working_role and (s.selected_columns is not distinct from working_selected_columns)),\n array['Error 401: Unauthorized']\n )::realtime.wal_rls;\n end loop;\n\n else\n -- Create the prepared statement (once per role)\n if is_rls_enabled and action <> 'DELETE' then\n if (select 1 from pg_prepared_statements where name = 'walrus_rls_stmt' limit 1) > 0 then\n deallocate walrus_rls_stmt;\n end if;\n execute realtime.build_prepared_statement_sql('walrus_rls_stmt', entity_, columns);\n end if;\n\n -- Collect all visible subscription IDs for this role (filter check + RLS check)\n visible_role_sub_ids = '{}';\n\n for subscription_id, claims in (\n select\n subs.subscription_id,\n subs.claims\n from\n unnest(subscriptions) subs\n where\n subs.entity = entity_\n and subs.claims_role = working_role\n and (\n realtime.is_visible_through_filters(columns, subs.filters)\n or (\n action = 'DELETE'\n and realtime.is_visible_through_filters(old_columns, subs.filters)\n )\n )\n ) loop\n\n if not is_rls_enabled or action = 'DELETE' then\n visible_role_sub_ids = visible_role_sub_ids || subscription_id;\n else\n -- Check if RLS allows the role to see the record\n perform\n -- Trim leading and trailing quotes from working_role because set_config\n -- doesn't recognize the role as valid if they are included\n set_config('role', trim(both '\"' from working_role::text), true),\n set_config('request.jwt.claims', claims::text, true);\n\n execute 'execute walrus_rls_stmt' into subscription_has_access;\n\n if subscription_has_access then\n visible_role_sub_ids = visible_role_sub_ids || subscription_id;\n end if;\n end if;\n end loop;\n\n perform set_config('role', null, true);\n\n -- Inner loop: per distinct selected_columns for this role\n for cols_record in\n select selected_columns\n from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t\n order by coalesce(array_to_string(selected_columns, ','), '')\n loop\n working_selected_columns := cols_record.selected_columns;\n\n output = jsonb_build_object(\n 'schema', wal ->> 'schema',\n 'table', wal ->> 'table',\n 'type', action,\n 'commit_timestamp', to_char(\n ((wal ->> 'timestamp')::timestamptz at time zone 'utc'),\n 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"'\n ),\n 'columns', (\n select\n jsonb_agg(\n jsonb_build_object(\n 'name', pa.attname,\n 'type', pt.typname\n )\n order by pa.attnum asc\n )\n from\n pg_attribute pa\n join pg_type pt\n on pa.atttypid = pt.oid\n left join (\n select unnest(conkey) as pkey_attnum\n from pg_constraint\n where conrelid = entity_ and contype = 'p'\n ) pk on pk.pkey_attnum = pa.attnum\n where\n attrelid = entity_\n and attnum > 0\n and pg_catalog.has_column_privilege(working_role, entity_, pa.attname, 'SELECT')\n and (working_selected_columns is null or pa.attname = any(working_selected_columns) or pk.pkey_attnum is not null)\n )\n )\n -- Add \"record\" key for insert and update\n || case\n when action in ('INSERT', 'UPDATE') then\n jsonb_build_object(\n 'record',\n (\n select\n jsonb_object_agg(\n -- if unchanged toast, get column name and value from old record\n coalesce((c).name, (oc).name),\n case\n when (c).name is null then (oc).value\n else (c).value\n end\n )\n from\n unnest(columns) c\n full outer join unnest(old_columns) oc\n on (c).name = (oc).name\n where\n coalesce((c).is_selectable, (oc).is_selectable)\n and (working_selected_columns is null or coalesce((c).name, (oc).name) = any(working_selected_columns) or coalesce((c).is_pkey, (oc).is_pkey))\n and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))\n )\n )\n else '{}'::jsonb\n end\n -- Add \"old_record\" key for update and delete\n || case\n when action = 'UPDATE' then\n jsonb_build_object(\n 'old_record',\n (\n select jsonb_object_agg((c).name, (c).value)\n from unnest(old_columns) c\n where\n (c).is_selectable\n and (working_selected_columns is null or (c).name = any(working_selected_columns) or (c).is_pkey)\n and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))\n )\n )\n when action = 'DELETE' then\n jsonb_build_object(\n 'old_record',\n (\n select jsonb_object_agg((c).name, (c).value)\n from unnest(old_columns) c\n where\n (c).is_selectable\n and (working_selected_columns is null or (c).name = any(working_selected_columns) or (c).is_pkey)\n and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))\n and ( not is_rls_enabled or (c).is_pkey ) -- if RLS enabled, we can't secure deletes so filter to pkey\n )\n )\n else '{}'::jsonb\n end;\n\n -- Filter visible_role_sub_ids to those matching the current selected_columns group\n visible_to_subscription_ids = coalesce(\n (\n select array_agg(s.subscription_id)\n from unnest(subscriptions) s\n where s.claims_role = working_role\n and (s.selected_columns is not distinct from working_selected_columns)\n and s.subscription_id = any(visible_role_sub_ids)\n ),\n '{}'::uuid[]\n );\n\n return next (\n output,\n is_rls_enabled,\n visible_to_subscription_ids,\n case\n when error_record_exceeds_max_size then array['Error 413: Payload Too Large']\n else '{}'\n end\n )::realtime.wal_rls;\n end loop;\n\n end if;\n end loop;\n\n perform set_config('role', null, true);\nend;\n",
+ "binary_path": null,
+ "sql_body": null,
+ "definition": "CREATE OR REPLACE FUNCTION realtime.apply_rls(wal jsonb, max_record_bytes integer DEFAULT (1024 * 1024))\n RETURNS SETOF realtime.wal_rls\n LANGUAGE plpgsql\nAS $function$\ndeclare\n -- Regclass of the table e.g. public.notes\n entity_ regclass = (quote_ident(wal ->> 'schema') || '.' || quote_ident(wal ->> 'table'))::regclass;\n\n -- I, U, D, T: insert, update ...\n action realtime.action = (\n case wal ->> 'action'\n when 'I' then 'INSERT'\n when 'U' then 'UPDATE'\n when 'D' then 'DELETE'\n else 'ERROR'\n end\n );\n\n -- Is row level security enabled for the table\n is_rls_enabled bool = relrowsecurity from pg_class where oid = entity_;\n\n subscriptions realtime.subscription[] = array_agg(subs)\n from\n realtime.subscription subs\n where\n subs.entity = entity_\n -- Filter by action early - only get subscriptions interested in this action\n -- action_filter column can be: '*' (all), 'INSERT', 'UPDATE', or 'DELETE'\n and (subs.action_filter = '*' or subs.action_filter = action::text);\n\n -- Subscription vars\n working_role regrole;\n working_selected_columns text[];\n claimed_role regrole;\n claims jsonb;\n\n subscription_id uuid;\n subscription_has_access bool;\n visible_to_subscription_ids uuid[] = '{}';\n\n -- structured info for wal's columns\n columns realtime.wal_column[];\n -- previous identity values for update/delete\n old_columns realtime.wal_column[];\n\n error_record_exceeds_max_size boolean = octet_length(wal::text) > max_record_bytes;\n\n -- Primary jsonb output for record\n output jsonb;\n\n -- Loop record for iterating unique roles (outer loop)\n role_record record;\n -- Loop record for iterating unique selected_columns within a role (inner loop)\n cols_record record;\n -- Subscription ids visible at the role level (before fanning out by selected_columns)\n visible_role_sub_ids uuid[] = '{}';\n\nbegin\n perform set_config('role', null, true);\n\n columns =\n array_agg(\n (\n x->>'name',\n x->>'type',\n x->>'typeoid',\n realtime.cast(\n (x->'value') #>> '{}',\n coalesce(\n (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4\n (x->>'type')::regtype\n )\n ),\n (pks ->> 'name') is not null,\n true\n )::realtime.wal_column\n )\n from\n jsonb_array_elements(wal -> 'columns') x\n left join jsonb_array_elements(wal -> 'pk') pks\n on (x ->> 'name') = (pks ->> 'name');\n\n old_columns =\n array_agg(\n (\n x->>'name',\n x->>'type',\n x->>'typeoid',\n realtime.cast(\n (x->'value') #>> '{}',\n coalesce(\n (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4\n (x->>'type')::regtype\n )\n ),\n (pks ->> 'name') is not null,\n true\n )::realtime.wal_column\n )\n from\n jsonb_array_elements(wal -> 'identity') x\n left join jsonb_array_elements(wal -> 'pk') pks\n on (x ->> 'name') = (pks ->> 'name');\n\n for role_record in\n select claims_role\n from (select distinct claims_role from unnest(subscriptions)) t\n order by claims_role::text\n loop\n working_role := role_record.claims_role;\n\n -- Update `is_selectable` for columns and old_columns (once per role)\n columns =\n array_agg(\n (\n c.name,\n c.type_name,\n c.type_oid,\n c.value,\n c.is_pkey,\n pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT')\n )::realtime.wal_column\n )\n from\n unnest(columns) c;\n\n old_columns =\n array_agg(\n (\n c.name,\n c.type_name,\n c.type_oid,\n c.value,\n c.is_pkey,\n pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT')\n )::realtime.wal_column\n )\n from\n unnest(old_columns) c;\n\n if action <> 'DELETE' and count(1) = 0 from unnest(columns) c where c.is_pkey then\n -- Fan out 400 error per distinct selected_columns for this role\n for cols_record in\n select selected_columns\n from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t\n order by coalesce(array_to_string(selected_columns, ','), '')\n loop\n working_selected_columns := cols_record.selected_columns;\n return next (\n jsonb_build_object(\n 'schema', wal ->> 'schema',\n 'table', wal ->> 'table',\n 'type', action\n ),\n is_rls_enabled,\n (select array_agg(s.subscription_id) from unnest(subscriptions) as s where s.claims_role = working_role and (s.selected_columns is not distinct from working_selected_columns)),\n array['Error 400: Bad Request, no primary key']\n )::realtime.wal_rls;\n end loop;\n\n -- The claims role does not have SELECT permission to the primary key of entity\n elsif action <> 'DELETE' and sum(c.is_selectable::int) <> count(1) from unnest(columns) c where c.is_pkey then\n -- Fan out 401 error per distinct selected_columns for this role\n for cols_record in\n select selected_columns\n from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t\n order by coalesce(array_to_string(selected_columns, ','), '')\n loop\n working_selected_columns := cols_record.selected_columns;\n return next (\n jsonb_build_object(\n 'schema', wal ->> 'schema',\n 'table', wal ->> 'table',\n 'type', action\n ),\n is_rls_enabled,\n (select array_agg(s.subscription_id) from unnest(subscriptions) as s where s.claims_role = working_role and (s.selected_columns is not distinct from working_selected_columns)),\n array['Error 401: Unauthorized']\n )::realtime.wal_rls;\n end loop;\n\n else\n -- Create the prepared statement (once per role)\n if is_rls_enabled and action <> 'DELETE' then\n if (select 1 from pg_prepared_statements where name = 'walrus_rls_stmt' limit 1) > 0 then\n deallocate walrus_rls_stmt;\n end if;\n execute realtime.build_prepared_statement_sql('walrus_rls_stmt', entity_, columns);\n end if;\n\n -- Collect all visible subscription IDs for this role (filter check + RLS check)\n visible_role_sub_ids = '{}';\n\n for subscription_id, claims in (\n select\n subs.subscription_id,\n subs.claims\n from\n unnest(subscriptions) subs\n where\n subs.entity = entity_\n and subs.claims_role = working_role\n and (\n realtime.is_visible_through_filters(columns, subs.filters)\n or (\n action = 'DELETE'\n and realtime.is_visible_through_filters(old_columns, subs.filters)\n )\n )\n ) loop\n\n if not is_rls_enabled or action = 'DELETE' then\n visible_role_sub_ids = visible_role_sub_ids || subscription_id;\n else\n -- Check if RLS allows the role to see the record\n perform\n -- Trim leading and trailing quotes from working_role because set_config\n -- doesn't recognize the role as valid if they are included\n set_config('role', trim(both '\"' from working_role::text), true),\n set_config('request.jwt.claims', claims::text, true);\n\n execute 'execute walrus_rls_stmt' into subscription_has_access;\n\n if subscription_has_access then\n visible_role_sub_ids = visible_role_sub_ids || subscription_id;\n end if;\n end if;\n end loop;\n\n perform set_config('role', null, true);\n\n -- Inner loop: per distinct selected_columns for this role\n for cols_record in\n select selected_columns\n from (select distinct selected_columns from unnest(subscriptions) s where s.claims_role = working_role) t\n order by coalesce(array_to_string(selected_columns, ','), '')\n loop\n working_selected_columns := cols_record.selected_columns;\n\n output = jsonb_build_object(\n 'schema', wal ->> 'schema',\n 'table', wal ->> 'table',\n 'type', action,\n 'commit_timestamp', to_char(\n ((wal ->> 'timestamp')::timestamptz at time zone 'utc'),\n 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"'\n ),\n 'columns', (\n select\n jsonb_agg(\n jsonb_build_object(\n 'name', pa.attname,\n 'type', pt.typname\n )\n order by pa.attnum asc\n )\n from\n pg_attribute pa\n join pg_type pt\n on pa.atttypid = pt.oid\n left join (\n select unnest(conkey) as pkey_attnum\n from pg_constraint\n where conrelid = entity_ and contype = 'p'\n ) pk on pk.pkey_attnum = pa.attnum\n where\n attrelid = entity_\n and attnum > 0\n and pg_catalog.has_column_privilege(working_role, entity_, pa.attname, 'SELECT')\n and (working_selected_columns is null or pa.attname = any(working_selected_columns) or pk.pkey_attnum is not null)\n )\n )\n -- Add \"record\" key for insert and update\n || case\n when action in ('INSERT', 'UPDATE') then\n jsonb_build_object(\n 'record',\n (\n select\n jsonb_object_agg(\n -- if unchanged toast, get column name and value from old record\n coalesce((c).name, (oc).name),\n case\n when (c).name is null then (oc).value\n else (c).value\n end\n )\n from\n unnest(columns) c\n full outer join unnest(old_columns) oc\n on (c).name = (oc).name\n where\n coalesce((c).is_selectable, (oc).is_selectable)\n and (working_selected_columns is null or coalesce((c).name, (oc).name) = any(working_selected_columns) or coalesce((c).is_pkey, (oc).is_pkey))\n and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))\n )\n )\n else '{}'::jsonb\n end\n -- Add \"old_record\" key for update and delete\n || case\n when action = 'UPDATE' then\n jsonb_build_object(\n 'old_record',\n (\n select jsonb_object_agg((c).name, (c).value)\n from unnest(old_columns) c\n where\n (c).is_selectable\n and (working_selected_columns is null or (c).name = any(working_selected_columns) or (c).is_pkey)\n and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))\n )\n )\n when action = 'DELETE' then\n jsonb_build_object(\n 'old_record',\n (\n select jsonb_object_agg((c).name, (c).value)\n from unnest(old_columns) c\n where\n (c).is_selectable\n and (working_selected_columns is null or (c).name = any(working_selected_columns) or (c).is_pkey)\n and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))\n and ( not is_rls_enabled or (c).is_pkey ) -- if RLS enabled, we can't secure deletes so filter to pkey\n )\n )\n else '{}'::jsonb\n end;\n\n -- Filter visible_role_sub_ids to those matching the current selected_columns group\n visible_to_subscription_ids = coalesce(\n (\n select array_agg(s.subscription_id)\n from unnest(subscriptions) s\n where s.claims_role = working_role\n and (s.selected_columns is not distinct from working_selected_columns)\n and s.subscription_id = any(visible_role_sub_ids)\n ),\n '{}'::uuid[]\n );\n\n return next (\n output,\n is_rls_enabled,\n visible_to_subscription_ids,\n case\n when error_record_exceeds_max_size then array['Error 413: Payload Too Large']\n else '{}'\n end\n )::realtime.wal_rls;\n end loop;\n\n end if;\n end loop;\n\n perform set_config('role', null, true);\nend;\n$function$\n",
+ "config": null,
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "anon",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "authenticated",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "service_role",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "EXECUTE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ },
+ "procedure:realtime.broadcast_changes(text,text,text,text,text,record,record,text)": {
+ "schema": "realtime",
+ "name": "broadcast_changes",
+ "kind": "f",
+ "return_type": "void",
+ "return_type_schema": "pg_catalog",
+ "language": "plpgsql",
+ "security_definer": false,
+ "volatility": "v",
+ "parallel_safety": "u",
+ "execution_cost": 100,
+ "result_rows": 0,
+ "is_strict": false,
+ "leakproof": false,
+ "returns_set": false,
+ "argument_count": 8,
+ "argument_default_count": 1,
+ "argument_names": [
+ "topic_name",
+ "event_name",
+ "operation",
+ "table_name",
+ "table_schema",
+ "new",
+ "old",
+ "level"
+ ],
+ "argument_types": [
+ "text",
+ "text",
+ "text",
+ "text",
+ "text",
+ "record",
+ "record",
+ "text"
+ ],
+ "all_argument_types": [],
+ "argument_modes": null,
+ "argument_defaults": "'ROW'::text",
+ "source_code": "\nDECLARE\n -- Declare a variable to hold the JSONB representation of the row\n row_data jsonb := '{}'::jsonb;\nBEGIN\n IF level = 'STATEMENT' THEN\n RAISE EXCEPTION 'function can only be triggered for each row, not for each statement';\n END IF;\n -- Check the operation type and handle accordingly\n IF operation = 'INSERT' OR operation = 'UPDATE' OR operation = 'DELETE' THEN\n row_data := jsonb_build_object('old_record', OLD, 'record', NEW, 'operation', operation, 'table', table_name, 'schema', table_schema);\n PERFORM realtime.send (row_data, event_name, topic_name);\n ELSE\n RAISE EXCEPTION 'Unexpected operation type: %', operation;\n END IF;\nEXCEPTION\n WHEN OTHERS THEN\n RAISE EXCEPTION 'Failed to process the row: %', SQLERRM;\nEND;\n\n",
+ "binary_path": null,
+ "sql_body": null,
+ "definition": "CREATE OR REPLACE FUNCTION realtime.broadcast_changes(topic_name text, event_name text, operation text, table_name text, table_schema text, new record, old record, level text DEFAULT 'ROW'::text)\n RETURNS void\n LANGUAGE plpgsql\nAS $function$\nDECLARE\n -- Declare a variable to hold the JSONB representation of the row\n row_data jsonb := '{}'::jsonb;\nBEGIN\n IF level = 'STATEMENT' THEN\n RAISE EXCEPTION 'function can only be triggered for each row, not for each statement';\n END IF;\n -- Check the operation type and handle accordingly\n IF operation = 'INSERT' OR operation = 'UPDATE' OR operation = 'DELETE' THEN\n row_data := jsonb_build_object('old_record', OLD, 'record', NEW, 'operation', operation, 'table', table_name, 'schema', table_schema);\n PERFORM realtime.send (row_data, event_name, topic_name);\n ELSE\n RAISE EXCEPTION 'Unexpected operation type: %', operation;\n END IF;\nEXCEPTION\n WHEN OTHERS THEN\n RAISE EXCEPTION 'Failed to process the row: %', SQLERRM;\nEND;\n\n$function$\n",
+ "config": null,
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "EXECUTE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ },
+ "procedure:realtime.build_prepared_statement_sql(text,regclass,realtime.wal_column[])": {
+ "schema": "realtime",
+ "name": "build_prepared_statement_sql",
+ "kind": "f",
+ "return_type": "text",
+ "return_type_schema": "pg_catalog",
+ "language": "sql",
+ "security_definer": false,
+ "volatility": "v",
+ "parallel_safety": "u",
+ "execution_cost": 100,
+ "result_rows": 0,
+ "is_strict": false,
+ "leakproof": false,
+ "returns_set": false,
+ "argument_count": 3,
+ "argument_default_count": 0,
+ "argument_names": [
+ "prepared_statement_name",
+ "entity",
+ "columns"
+ ],
+ "argument_types": [
+ "text",
+ "regclass",
+ "realtime.wal_column[]"
+ ],
+ "all_argument_types": [],
+ "argument_modes": null,
+ "argument_defaults": null,
+ "source_code": "\n /*\n Builds a sql string that, if executed, creates a prepared statement to\n tests retrive a row from *entity* by its primary key columns.\n Example\n select realtime.build_prepared_statement_sql('public.notes', '{\"id\"}'::text[], '{\"bigint\"}'::text[])\n */\n select\n 'prepare ' || prepared_statement_name || ' as\n select\n exists(\n select\n 1\n from\n ' || entity || '\n where\n ' || string_agg(quote_ident(pkc.name) || '=' || quote_nullable(pkc.value #>> '{}') , ' and ') || '\n )'\n from\n unnest(columns) pkc\n where\n pkc.is_pkey\n group by\n entity\n ",
+ "binary_path": null,
+ "sql_body": null,
+ "definition": "CREATE OR REPLACE FUNCTION realtime.build_prepared_statement_sql(prepared_statement_name text, entity regclass, columns realtime.wal_column[])\n RETURNS text\n LANGUAGE sql\nAS $function$\n /*\n Builds a sql string that, if executed, creates a prepared statement to\n tests retrive a row from *entity* by its primary key columns.\n Example\n select realtime.build_prepared_statement_sql('public.notes', '{\"id\"}'::text[], '{\"bigint\"}'::text[])\n */\n select\n 'prepare ' || prepared_statement_name || ' as\n select\n exists(\n select\n 1\n from\n ' || entity || '\n where\n ' || string_agg(quote_ident(pkc.name) || '=' || quote_nullable(pkc.value #>> '{}') , ' and ') || '\n )'\n from\n unnest(columns) pkc\n where\n pkc.is_pkey\n group by\n entity\n $function$\n",
+ "config": null,
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "anon",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "authenticated",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "service_role",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "EXECUTE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ },
+ "procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)": {
+ "schema": "realtime",
+ "name": "check_equality_op",
+ "kind": "f",
+ "return_type": "boolean",
+ "return_type_schema": "pg_catalog",
+ "language": "plpgsql",
+ "security_definer": false,
+ "volatility": "i",
+ "parallel_safety": "u",
+ "execution_cost": 100,
+ "result_rows": 0,
+ "is_strict": false,
+ "leakproof": false,
+ "returns_set": false,
+ "argument_count": 4,
+ "argument_default_count": 0,
+ "argument_names": [
+ "op",
+ "type_",
+ "val_1",
+ "val_2"
+ ],
+ "argument_types": [
+ "realtime.equality_op",
+ "regtype",
+ "text",
+ "text"
+ ],
+ "all_argument_types": [],
+ "argument_modes": null,
+ "argument_defaults": null,
+ "source_code": "\n /*\n Casts *val_1* and *val_2* as type *type_* and check the *op* condition for truthiness\n */\n declare\n op_symbol text = (\n case\n when op = 'eq' then '='\n when op = 'neq' then '!='\n when op = 'lt' then '<'\n when op = 'lte' then '<='\n when op = 'gt' then '>'\n when op = 'gte' then '>='\n when op = 'in' then '= any'\n else 'UNKNOWN OP'\n end\n );\n res boolean;\n begin\n execute format(\n 'select %L::'|| type_::text || ' ' || op_symbol\n || ' ( %L::'\n || (\n case\n when op = 'in' then type_::text || '[]'\n else type_::text end\n )\n || ')', val_1, val_2) into res;\n return res;\n end;\n ",
+ "binary_path": null,
+ "sql_body": null,
+ "definition": "CREATE OR REPLACE FUNCTION realtime.check_equality_op(op realtime.equality_op, type_ regtype, val_1 text, val_2 text)\n RETURNS boolean\n LANGUAGE plpgsql\n IMMUTABLE\nAS $function$\n /*\n Casts *val_1* and *val_2* as type *type_* and check the *op* condition for truthiness\n */\n declare\n op_symbol text = (\n case\n when op = 'eq' then '='\n when op = 'neq' then '!='\n when op = 'lt' then '<'\n when op = 'lte' then '<='\n when op = 'gt' then '>'\n when op = 'gte' then '>='\n when op = 'in' then '= any'\n else 'UNKNOWN OP'\n end\n );\n res boolean;\n begin\n execute format(\n 'select %L::'|| type_::text || ' ' || op_symbol\n || ' ( %L::'\n || (\n case\n when op = 'in' then type_::text || '[]'\n else type_::text end\n )\n || ')', val_1, val_2) into res;\n return res;\n end;\n $function$\n",
+ "config": null,
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "anon",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "authenticated",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "service_role",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "EXECUTE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ },
+ "procedure:realtime.is_visible_through_filters(realtime.wal_column[],realtime.user_defined_filter[])": {
+ "schema": "realtime",
+ "name": "is_visible_through_filters",
+ "kind": "f",
+ "return_type": "boolean",
+ "return_type_schema": "pg_catalog",
+ "language": "sql",
+ "security_definer": false,
+ "volatility": "i",
+ "parallel_safety": "u",
+ "execution_cost": 100,
+ "result_rows": 0,
+ "is_strict": false,
+ "leakproof": false,
+ "returns_set": false,
+ "argument_count": 2,
+ "argument_default_count": 0,
+ "argument_names": [
+ "columns",
+ "filters"
+ ],
+ "argument_types": [
+ "realtime.wal_column[]",
+ "realtime.user_defined_filter[]"
+ ],
+ "all_argument_types": [],
+ "argument_modes": null,
+ "argument_defaults": null,
+ "source_code": "\n /*\n Should the record be visible (true) or filtered out (false) after *filters* are applied\n */\n select\n -- Default to allowed when no filters present\n $2 is null -- no filters. this should not happen because subscriptions has a default\n or array_length($2, 1) is null -- array length of an empty array is null\n or bool_and(\n coalesce(\n realtime.check_equality_op(\n op:=f.op,\n type_:=coalesce(\n col.type_oid::regtype, -- null when wal2json version <= 2.4\n col.type_name::regtype\n ),\n -- cast jsonb to text\n val_1:=col.value #>> '{}',\n val_2:=f.value\n ),\n false -- if null, filter does not match\n )\n )\n from\n unnest(filters) f\n join unnest(columns) col\n on f.column_name = col.name;\n ",
+ "binary_path": null,
+ "sql_body": null,
+ "definition": "CREATE OR REPLACE FUNCTION realtime.is_visible_through_filters(columns realtime.wal_column[], filters realtime.user_defined_filter[])\n RETURNS boolean\n LANGUAGE sql\n IMMUTABLE\nAS $function$\n /*\n Should the record be visible (true) or filtered out (false) after *filters* are applied\n */\n select\n -- Default to allowed when no filters present\n $2 is null -- no filters. this should not happen because subscriptions has a default\n or array_length($2, 1) is null -- array length of an empty array is null\n or bool_and(\n coalesce(\n realtime.check_equality_op(\n op:=f.op,\n type_:=coalesce(\n col.type_oid::regtype, -- null when wal2json version <= 2.4\n col.type_name::regtype\n ),\n -- cast jsonb to text\n val_1:=col.value #>> '{}',\n val_2:=f.value\n ),\n false -- if null, filter does not match\n )\n )\n from\n unnest(filters) f\n join unnest(columns) col\n on f.column_name = col.name;\n $function$\n",
+ "config": null,
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "anon",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "authenticated",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "service_role",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "EXECUTE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ },
+ "procedure:realtime.list_changes(name,name,integer,integer)": {
+ "schema": "realtime",
+ "name": "list_changes",
+ "kind": "f",
+ "return_type": "record",
+ "return_type_schema": "pg_catalog",
+ "language": "sql",
+ "security_definer": false,
+ "volatility": "v",
+ "parallel_safety": "u",
+ "execution_cost": 100,
+ "result_rows": 1000,
+ "is_strict": false,
+ "leakproof": false,
+ "returns_set": true,
+ "argument_count": 4,
+ "argument_default_count": 0,
+ "argument_names": [
+ "publication",
+ "slot_name",
+ "max_changes",
+ "max_record_bytes",
+ "wal",
+ "is_rls_enabled",
+ "subscription_ids",
+ "errors",
+ "slot_changes_count"
+ ],
+ "argument_types": [
+ "name",
+ "name",
+ "integer",
+ "integer"
+ ],
+ "all_argument_types": [
+ "name",
+ "name",
+ "integer",
+ "integer",
+ "jsonb",
+ "boolean",
+ "uuid[]",
+ "text[]",
+ "bigint"
+ ],
+ "argument_modes": [
+ "i",
+ "i",
+ "i",
+ "i",
+ "t",
+ "t",
+ "t",
+ "t",
+ "t"
+ ],
+ "argument_defaults": null,
+ "source_code": "\n WITH pub AS (\n SELECT\n concat_ws(\n ',',\n CASE WHEN bool_or(pubinsert) THEN 'insert' ELSE NULL END,\n CASE WHEN bool_or(pubupdate) THEN 'update' ELSE NULL END,\n CASE WHEN bool_or(pubdelete) THEN 'delete' ELSE NULL END\n ) AS w2j_actions,\n coalesce(\n string_agg(\n realtime.quote_wal2json(format('%I.%I', schemaname, tablename)::regclass),\n ','\n ) filter (WHERE ppt.tablename IS NOT NULL),\n ''\n ) AS w2j_add_tables\n FROM pg_publication pp\n LEFT JOIN pg_publication_tables ppt ON pp.pubname = ppt.pubname\n WHERE pp.pubname = publication\n GROUP BY pp.pubname\n LIMIT 1\n ),\n -- MATERIALIZED ensures pg_logical_slot_get_changes is called exactly once\n w2j AS MATERIALIZED (\n SELECT x.*, pub.w2j_add_tables\n FROM pub,\n pg_logical_slot_get_changes(\n slot_name, null, max_changes,\n 'include-pk', 'true',\n 'include-transaction', 'false',\n 'include-timestamp', 'true',\n 'include-type-oids', 'true',\n 'format-version', '2',\n 'actions', pub.w2j_actions,\n 'add-tables', pub.w2j_add_tables\n ) x\n ),\n slot_count AS (\n SELECT count(*)::bigint AS cnt\n FROM w2j\n WHERE w2j.w2j_add_tables <> ''\n ),\n rls_filtered AS (\n SELECT xyz.wal, xyz.is_rls_enabled, xyz.subscription_ids, xyz.errors\n FROM w2j,\n realtime.apply_rls(\n wal := w2j.data::jsonb,\n max_record_bytes := max_record_bytes\n ) xyz(wal, is_rls_enabled, subscription_ids, errors)\n WHERE w2j.w2j_add_tables <> ''\n AND xyz.subscription_ids[1] IS NOT NULL\n )\n SELECT rf.wal, rf.is_rls_enabled, rf.subscription_ids, rf.errors, sc.cnt\n FROM rls_filtered rf, slot_count sc\n\n UNION ALL\n\n SELECT null, null, null, null, sc.cnt\n FROM slot_count sc\n WHERE NOT EXISTS (SELECT 1 FROM rls_filtered)\n",
+ "binary_path": null,
+ "sql_body": null,
+ "definition": "CREATE OR REPLACE FUNCTION realtime.list_changes(publication name, slot_name name, max_changes integer, max_record_bytes integer)\n RETURNS TABLE(wal jsonb, is_rls_enabled boolean, subscription_ids uuid[], errors text[], slot_changes_count bigint)\n LANGUAGE sql\n SET log_min_messages TO 'fatal'\nAS $function$\n WITH pub AS (\n SELECT\n concat_ws(\n ',',\n CASE WHEN bool_or(pubinsert) THEN 'insert' ELSE NULL END,\n CASE WHEN bool_or(pubupdate) THEN 'update' ELSE NULL END,\n CASE WHEN bool_or(pubdelete) THEN 'delete' ELSE NULL END\n ) AS w2j_actions,\n coalesce(\n string_agg(\n realtime.quote_wal2json(format('%I.%I', schemaname, tablename)::regclass),\n ','\n ) filter (WHERE ppt.tablename IS NOT NULL),\n ''\n ) AS w2j_add_tables\n FROM pg_publication pp\n LEFT JOIN pg_publication_tables ppt ON pp.pubname = ppt.pubname\n WHERE pp.pubname = publication\n GROUP BY pp.pubname\n LIMIT 1\n ),\n -- MATERIALIZED ensures pg_logical_slot_get_changes is called exactly once\n w2j AS MATERIALIZED (\n SELECT x.*, pub.w2j_add_tables\n FROM pub,\n pg_logical_slot_get_changes(\n slot_name, null, max_changes,\n 'include-pk', 'true',\n 'include-transaction', 'false',\n 'include-timestamp', 'true',\n 'include-type-oids', 'true',\n 'format-version', '2',\n 'actions', pub.w2j_actions,\n 'add-tables', pub.w2j_add_tables\n ) x\n ),\n slot_count AS (\n SELECT count(*)::bigint AS cnt\n FROM w2j\n WHERE w2j.w2j_add_tables <> ''\n ),\n rls_filtered AS (\n SELECT xyz.wal, xyz.is_rls_enabled, xyz.subscription_ids, xyz.errors\n FROM w2j,\n realtime.apply_rls(\n wal := w2j.data::jsonb,\n max_record_bytes := max_record_bytes\n ) xyz(wal, is_rls_enabled, subscription_ids, errors)\n WHERE w2j.w2j_add_tables <> ''\n AND xyz.subscription_ids[1] IS NOT NULL\n )\n SELECT rf.wal, rf.is_rls_enabled, rf.subscription_ids, rf.errors, sc.cnt\n FROM rls_filtered rf, slot_count sc\n\n UNION ALL\n\n SELECT null, null, null, null, sc.cnt\n FROM slot_count sc\n WHERE NOT EXISTS (SELECT 1 FROM rls_filtered)\n$function$\n",
+ "config": [
+ "log_min_messages=fatal"
+ ],
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "EXECUTE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ },
+ "procedure:realtime.quote_wal2json(regclass)": {
+ "schema": "realtime",
+ "name": "quote_wal2json",
+ "kind": "f",
+ "return_type": "text",
+ "return_type_schema": "pg_catalog",
+ "language": "sql",
+ "security_definer": false,
+ "volatility": "i",
+ "parallel_safety": "u",
+ "execution_cost": 100,
+ "result_rows": 0,
+ "is_strict": true,
+ "leakproof": false,
+ "returns_set": false,
+ "argument_count": 1,
+ "argument_default_count": 0,
+ "argument_names": [
+ "entity"
+ ],
+ "argument_types": [
+ "regclass"
+ ],
+ "all_argument_types": [],
+ "argument_modes": null,
+ "argument_defaults": null,
+ "source_code": "\n SELECT\n realtime.wal2json_escape_identifier(nsp.nspname::text)\n || '.'\n || realtime.wal2json_escape_identifier(pc.relname::text)\n FROM pg_class pc\n JOIN pg_namespace nsp ON pc.relnamespace = nsp.oid\n WHERE pc.oid = entity\n",
+ "binary_path": null,
+ "sql_body": null,
+ "definition": "CREATE OR REPLACE FUNCTION realtime.quote_wal2json(entity regclass)\n RETURNS text\n LANGUAGE sql\n IMMUTABLE STRICT\nAS $function$\n SELECT\n realtime.wal2json_escape_identifier(nsp.nspname::text)\n || '.'\n || realtime.wal2json_escape_identifier(pc.relname::text)\n FROM pg_class pc\n JOIN pg_namespace nsp ON pc.relnamespace = nsp.oid\n WHERE pc.oid = entity\n$function$\n",
+ "config": null,
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "anon",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "authenticated",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "service_role",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "EXECUTE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ },
+ "procedure:realtime.send(jsonb,text,text,boolean)": {
+ "schema": "realtime",
+ "name": "send",
+ "kind": "f",
+ "return_type": "void",
+ "return_type_schema": "pg_catalog",
+ "language": "plpgsql",
+ "security_definer": false,
+ "volatility": "v",
+ "parallel_safety": "u",
+ "execution_cost": 100,
+ "result_rows": 0,
+ "is_strict": false,
+ "leakproof": false,
+ "returns_set": false,
+ "argument_count": 4,
+ "argument_default_count": 1,
+ "argument_names": [
+ "payload",
+ "event",
+ "topic",
+ "private"
+ ],
+ "argument_types": [
+ "jsonb",
+ "text",
+ "text",
+ "boolean"
+ ],
+ "all_argument_types": [],
+ "argument_modes": null,
+ "argument_defaults": "true",
+ "source_code": "\nDECLARE\n generated_id uuid;\n final_payload jsonb;\nBEGIN\n BEGIN\n generated_id := gen_random_uuid();\n\n -- Check if payload has an 'id' key, if not, add the generated UUID\n IF payload ? 'id' THEN\n final_payload := payload;\n ELSE\n final_payload := jsonb_set(payload, '{id}', to_jsonb(generated_id));\n END IF;\n\n -- Set the topic configuration\n EXECUTE format('SET LOCAL realtime.topic TO %L', topic);\n\n INSERT INTO realtime.messages (id, payload, event, topic, private, extension)\n VALUES (generated_id, final_payload, event, topic, private, 'broadcast');\n EXCEPTION\n WHEN OTHERS THEN\n RAISE WARNING 'WarnSendingBroadcastMessage: %', SQLERRM;\n END;\nEND;\n",
+ "binary_path": null,
+ "sql_body": null,
+ "definition": "CREATE OR REPLACE FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean DEFAULT true)\n RETURNS void\n LANGUAGE plpgsql\nAS $function$\nDECLARE\n generated_id uuid;\n final_payload jsonb;\nBEGIN\n BEGIN\n generated_id := gen_random_uuid();\n\n -- Check if payload has an 'id' key, if not, add the generated UUID\n IF payload ? 'id' THEN\n final_payload := payload;\n ELSE\n final_payload := jsonb_set(payload, '{id}', to_jsonb(generated_id));\n END IF;\n\n -- Set the topic configuration\n EXECUTE format('SET LOCAL realtime.topic TO %L', topic);\n\n INSERT INTO realtime.messages (id, payload, event, topic, private, extension)\n VALUES (generated_id, final_payload, event, topic, private, 'broadcast');\n EXCEPTION\n WHEN OTHERS THEN\n RAISE WARNING 'WarnSendingBroadcastMessage: %', SQLERRM;\n END;\nEND;\n$function$\n",
+ "config": null,
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "EXECUTE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ },
+ "procedure:realtime.send_binary(bytea,text,text,boolean)": {
+ "schema": "realtime",
+ "name": "send_binary",
+ "kind": "f",
+ "return_type": "void",
+ "return_type_schema": "pg_catalog",
+ "language": "plpgsql",
+ "security_definer": false,
+ "volatility": "v",
+ "parallel_safety": "u",
+ "execution_cost": 100,
+ "result_rows": 0,
+ "is_strict": false,
+ "leakproof": false,
+ "returns_set": false,
+ "argument_count": 4,
+ "argument_default_count": 1,
+ "argument_names": [
+ "payload",
+ "event",
+ "topic",
+ "private"
+ ],
+ "argument_types": [
+ "bytea",
+ "text",
+ "text",
+ "boolean"
+ ],
+ "all_argument_types": [],
+ "argument_modes": null,
+ "argument_defaults": "true",
+ "source_code": "\nDECLARE\n generated_id uuid;\nBEGIN\n BEGIN\n generated_id := gen_random_uuid();\n\n EXECUTE format('SET LOCAL realtime.topic TO %L', topic);\n\n INSERT INTO realtime.messages (id, binary_payload, event, topic, private, extension)\n VALUES (generated_id, payload, event, topic, private, 'broadcast');\n EXCEPTION\n WHEN OTHERS THEN\n RAISE WARNING 'WarnSendingBroadcastMessage: %', SQLERRM;\n END;\nEND;\n",
+ "binary_path": null,
+ "sql_body": null,
+ "definition": "CREATE OR REPLACE FUNCTION realtime.send_binary(payload bytea, event text, topic text, private boolean DEFAULT true)\n RETURNS void\n LANGUAGE plpgsql\nAS $function$\nDECLARE\n generated_id uuid;\nBEGIN\n BEGIN\n generated_id := gen_random_uuid();\n\n EXECUTE format('SET LOCAL realtime.topic TO %L', topic);\n\n INSERT INTO realtime.messages (id, binary_payload, event, topic, private, extension)\n VALUES (generated_id, payload, event, topic, private, 'broadcast');\n EXCEPTION\n WHEN OTHERS THEN\n RAISE WARNING 'WarnSendingBroadcastMessage: %', SQLERRM;\n END;\nEND;\n$function$\n",
+ "config": null,
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "EXECUTE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ },
+ "procedure:realtime.subscription_check_filters()": {
+ "schema": "realtime",
+ "name": "subscription_check_filters",
+ "kind": "f",
+ "return_type": "trigger",
+ "return_type_schema": "pg_catalog",
+ "language": "plpgsql",
+ "security_definer": false,
+ "volatility": "v",
+ "parallel_safety": "u",
+ "execution_cost": 100,
+ "result_rows": 0,
+ "is_strict": false,
+ "leakproof": false,
+ "returns_set": false,
+ "argument_count": 0,
+ "argument_default_count": 0,
+ "argument_names": null,
+ "argument_types": [],
+ "all_argument_types": [],
+ "argument_modes": null,
+ "argument_defaults": null,
+ "source_code": "\ndeclare\n col_names text[] = coalesce(\n array_agg(a.attname order by a.attnum),\n '{}'::text[]\n )\n from\n pg_catalog.pg_attribute a\n where\n a.attrelid = new.entity\n and a.attnum > 0\n and not a.attisdropped\n and pg_catalog.has_column_privilege(\n (new.claims ->> 'role'),\n a.attrelid,\n a.attnum,\n 'SELECT'\n );\n filter realtime.user_defined_filter;\n col_type regtype;\n in_val jsonb;\n selected_col text;\nbegin\n for filter in select * from unnest(new.filters) loop\n if not filter.column_name = any(col_names) then\n raise exception 'invalid column for filter %', filter.column_name;\n end if;\n\n col_type = (\n select atttypid::regtype\n from pg_catalog.pg_attribute\n where attrelid = new.entity\n and attname = filter.column_name\n );\n if col_type is null then\n raise exception 'failed to lookup type for column %', filter.column_name;\n end if;\n\n if filter.op = 'in'::realtime.equality_op then\n in_val = realtime.cast(filter.value, (col_type::text || '[]')::regtype);\n if coalesce(jsonb_array_length(in_val), 0) > 100 then\n raise exception 'too many values for `in` filter. Maximum 100';\n end if;\n else\n perform realtime.cast(filter.value, col_type);\n end if;\n end loop;\n\n if new.selected_columns is not null then\n for selected_col in select * from unnest(new.selected_columns) loop\n if not selected_col = any(col_names) then\n raise exception 'invalid column for select %', selected_col;\n end if;\n end loop;\n end if;\n\n new.filters = coalesce(\n array_agg(f order by f.column_name, f.op, f.value),\n '{}'\n ) from unnest(new.filters) f;\n\n new.selected_columns = (\n select array_agg(c order by c)\n from unnest(new.selected_columns) c\n );\n\n return new;\nend;\n",
+ "binary_path": null,
+ "sql_body": null,
+ "definition": "CREATE OR REPLACE FUNCTION realtime.subscription_check_filters()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$\ndeclare\n col_names text[] = coalesce(\n array_agg(a.attname order by a.attnum),\n '{}'::text[]\n )\n from\n pg_catalog.pg_attribute a\n where\n a.attrelid = new.entity\n and a.attnum > 0\n and not a.attisdropped\n and pg_catalog.has_column_privilege(\n (new.claims ->> 'role'),\n a.attrelid,\n a.attnum,\n 'SELECT'\n );\n filter realtime.user_defined_filter;\n col_type regtype;\n in_val jsonb;\n selected_col text;\nbegin\n for filter in select * from unnest(new.filters) loop\n if not filter.column_name = any(col_names) then\n raise exception 'invalid column for filter %', filter.column_name;\n end if;\n\n col_type = (\n select atttypid::regtype\n from pg_catalog.pg_attribute\n where attrelid = new.entity\n and attname = filter.column_name\n );\n if col_type is null then\n raise exception 'failed to lookup type for column %', filter.column_name;\n end if;\n\n if filter.op = 'in'::realtime.equality_op then\n in_val = realtime.cast(filter.value, (col_type::text || '[]')::regtype);\n if coalesce(jsonb_array_length(in_val), 0) > 100 then\n raise exception 'too many values for `in` filter. Maximum 100';\n end if;\n else\n perform realtime.cast(filter.value, col_type);\n end if;\n end loop;\n\n if new.selected_columns is not null then\n for selected_col in select * from unnest(new.selected_columns) loop\n if not selected_col = any(col_names) then\n raise exception 'invalid column for select %', selected_col;\n end if;\n end loop;\n end if;\n\n new.filters = coalesce(\n array_agg(f order by f.column_name, f.op, f.value),\n '{}'\n ) from unnest(new.filters) f;\n\n new.selected_columns = (\n select array_agg(c order by c)\n from unnest(new.selected_columns) c\n );\n\n return new;\nend;\n$function$\n",
+ "config": null,
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "anon",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "authenticated",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "service_role",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "EXECUTE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ },
+ "procedure:realtime.to_regrole(text)": {
+ "schema": "realtime",
+ "name": "to_regrole",
+ "kind": "f",
+ "return_type": "regrole",
+ "return_type_schema": "pg_catalog",
+ "language": "sql",
+ "security_definer": false,
+ "volatility": "i",
+ "parallel_safety": "u",
+ "execution_cost": 100,
+ "result_rows": 0,
+ "is_strict": false,
+ "leakproof": false,
+ "returns_set": false,
+ "argument_count": 1,
+ "argument_default_count": 0,
+ "argument_names": [
+ "role_name"
+ ],
+ "argument_types": [
+ "text"
+ ],
+ "all_argument_types": [],
+ "argument_modes": null,
+ "argument_defaults": null,
+ "source_code": " select role_name::regrole ",
+ "binary_path": null,
+ "sql_body": null,
+ "definition": "CREATE OR REPLACE FUNCTION realtime.to_regrole(role_name text)\n RETURNS regrole\n LANGUAGE sql\n IMMUTABLE\nAS $function$ select role_name::regrole $function$\n",
+ "config": null,
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "anon",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "authenticated",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "service_role",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "EXECUTE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ },
+ "procedure:realtime.topic()": {
+ "schema": "realtime",
+ "name": "topic",
+ "kind": "f",
+ "return_type": "text",
+ "return_type_schema": "pg_catalog",
+ "language": "sql",
+ "security_definer": false,
+ "volatility": "s",
+ "parallel_safety": "u",
+ "execution_cost": 100,
+ "result_rows": 0,
+ "is_strict": false,
+ "leakproof": false,
+ "returns_set": false,
+ "argument_count": 0,
+ "argument_default_count": 0,
+ "argument_names": null,
+ "argument_types": [],
+ "all_argument_types": [],
+ "argument_modes": null,
+ "argument_defaults": null,
+ "source_code": "\nselect nullif(current_setting('realtime.topic', true), '')::text;\n",
+ "binary_path": null,
+ "sql_body": null,
+ "definition": "CREATE OR REPLACE FUNCTION realtime.topic()\n RETURNS text\n LANGUAGE sql\n STABLE\nAS $function$\nselect nullif(current_setting('realtime.topic', true), '')::text;\n$function$\n",
+ "config": null,
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "EXECUTE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ },
+ "procedure:realtime.wal2json_escape_identifier(text)": {
+ "schema": "realtime",
+ "name": "wal2json_escape_identifier",
+ "kind": "f",
+ "return_type": "text",
+ "return_type_schema": "pg_catalog",
+ "language": "sql",
+ "security_definer": false,
+ "volatility": "i",
+ "parallel_safety": "u",
+ "execution_cost": 100,
+ "result_rows": 0,
+ "is_strict": true,
+ "leakproof": false,
+ "returns_set": false,
+ "argument_count": 1,
+ "argument_default_count": 0,
+ "argument_names": [
+ "name"
+ ],
+ "argument_types": [
+ "text"
+ ],
+ "all_argument_types": [],
+ "argument_modes": null,
+ "argument_defaults": null,
+ "source_code": "\n -- Prefix `\\`, `,`, `.`, and any whitespace with `\\`\n SELECT regexp_replace(name, '([\\\\,.[:space:]])', '\\\\\\1', 'g')\n",
+ "binary_path": null,
+ "sql_body": null,
+ "definition": "CREATE OR REPLACE FUNCTION realtime.wal2json_escape_identifier(name text)\n RETURNS text\n LANGUAGE sql\n IMMUTABLE STRICT\nAS $function$\n -- Prefix `\\`, `,`, `.`, and any whitespace with `\\`\n SELECT regexp_replace(name, '([\\\\,.[:space:]])', '\\\\\\1', 'g')\n$function$\n",
+ "config": null,
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "privileges": [
+ {
+ "grantee": "PUBLIC",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "EXECUTE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "EXECUTE",
+ "grantable": false
+ }
+ ],
+ "security_labels": []
+ }
+ },
+ "indexes": {
+ "index:realtime.messages.messages_inserted_at_topic_index": {
+ "schema": "realtime",
+ "table_name": "messages",
+ "name": "messages_inserted_at_topic_index",
+ "storage_params": [],
+ "statistics_target": [
+ -1,
+ -1
+ ],
+ "index_type": "btree",
+ "tablespace": null,
+ "is_unique": false,
+ "is_primary": false,
+ "is_exclusion": false,
+ "nulls_not_distinct": false,
+ "immediate": true,
+ "is_clustered": false,
+ "is_replica_identity": false,
+ "key_columns": [
+ 9,
+ 3
+ ],
+ "column_collations": [
+ null,
+ null
+ ],
+ "operator_classes": [
+ "default",
+ "default"
+ ],
+ "column_options": [
+ 3,
+ 0
+ ],
+ "index_expressions": null,
+ "partial_predicate": "((extension = 'broadcast'::text) AND (private IS TRUE))",
+ "table_relkind": "p",
+ "is_owned_by_constraint": false,
+ "is_partitioned_index": true,
+ "is_index_partition": false,
+ "parent_index_name": null,
+ "definition": "CREATE INDEX messages_inserted_at_topic_index ON ONLY realtime.messages USING btree (inserted_at DESC, topic) WHERE extension = 'broadcast'::text AND private IS TRUE",
+ "comment": null,
+ "owner": "supabase_realtime_admin"
+ },
+ "index:realtime.subscription.ix_realtime_subscription_entity": {
+ "schema": "realtime",
+ "table_name": "subscription",
+ "name": "ix_realtime_subscription_entity",
+ "storage_params": [],
+ "statistics_target": [
+ -1
+ ],
+ "index_type": "btree",
+ "tablespace": null,
+ "is_unique": false,
+ "is_primary": false,
+ "is_exclusion": false,
+ "nulls_not_distinct": false,
+ "immediate": true,
+ "is_clustered": false,
+ "is_replica_identity": false,
+ "key_columns": [
+ 4
+ ],
+ "column_collations": [
+ null
+ ],
+ "operator_classes": [
+ "default"
+ ],
+ "column_options": [
+ 0
+ ],
+ "index_expressions": null,
+ "partial_predicate": null,
+ "table_relkind": "r",
+ "is_owned_by_constraint": false,
+ "is_partitioned_index": false,
+ "is_index_partition": false,
+ "parent_index_name": null,
+ "definition": "CREATE INDEX ix_realtime_subscription_entity ON realtime.subscription USING btree (entity)",
+ "comment": null,
+ "owner": "supabase_realtime_admin"
+ },
+ "index:realtime.subscription.subscription_subscription_id_entity_filters_action_filter_selec": {
+ "schema": "realtime",
+ "table_name": "subscription",
+ "name": "subscription_subscription_id_entity_filters_action_filter_selec",
+ "storage_params": [],
+ "statistics_target": [
+ -1,
+ -1,
+ -1,
+ -1,
+ -1
+ ],
+ "index_type": "btree",
+ "tablespace": null,
+ "is_unique": true,
+ "is_primary": false,
+ "is_exclusion": false,
+ "nulls_not_distinct": false,
+ "immediate": true,
+ "is_clustered": false,
+ "is_replica_identity": false,
+ "key_columns": [
+ 2,
+ 4,
+ 5,
+ 10,
+ 0
+ ],
+ "column_collations": [
+ null,
+ null,
+ null,
+ null,
+ null
+ ],
+ "operator_classes": [
+ "default",
+ "default",
+ "pg_catalog.array_ops",
+ "default",
+ "pg_catalog.array_ops"
+ ],
+ "column_options": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "index_expressions": "COALESCE(selected_columns, '{}'::text[])",
+ "partial_predicate": null,
+ "table_relkind": "r",
+ "is_owned_by_constraint": false,
+ "is_partitioned_index": false,
+ "is_index_partition": false,
+ "parent_index_name": null,
+ "definition": "CREATE UNIQUE INDEX subscription_subscription_id_entity_filters_action_filter_selec ON realtime.subscription USING btree (subscription_id, entity, filters, action_filter, COALESCE(selected_columns, '{}'::text[]))",
+ "comment": null,
+ "owner": "supabase_realtime_admin"
+ }
+ },
+ "materializedViews": {},
+ "subscriptions": {},
+ "publications": {},
+ "rlsPolicies": {},
+ "roles": {},
+ "schemas": {
+ "schema:realtime": {
+ "name": "realtime",
+ "owner": "supabase_admin",
+ "comment": null,
+ "privileges": [
+ {
+ "grantee": "supabase_admin",
+ "privilege": "CREATE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_admin",
+ "privilege": "USAGE",
+ "grantable": false
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "USAGE",
+ "grantable": true
+ },
+ {
+ "grantee": "anon",
+ "privilege": "USAGE",
+ "grantable": false
+ },
+ {
+ "grantee": "authenticated",
+ "privilege": "USAGE",
+ "grantable": false
+ },
+ {
+ "grantee": "service_role",
+ "privilege": "USAGE",
+ "grantable": false
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "CREATE",
+ "grantable": true
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "USAGE",
+ "grantable": true
+ }
+ ],
+ "security_labels": []
+ }
+ },
+ "sequences": {},
+ "tables": {
+ "table:realtime.messages": {
+ "schema": "realtime",
+ "name": "messages",
+ "persistence": "p",
+ "row_security": true,
+ "force_row_security": false,
+ "has_indexes": true,
+ "has_rules": false,
+ "has_triggers": false,
+ "has_subclasses": false,
+ "is_populated": true,
+ "replica_identity": "d",
+ "replica_identity_index": null,
+ "is_partition": false,
+ "options": null,
+ "partition_bound": null,
+ "partition_by": "RANGE (inserted_at)",
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "parent_schema": null,
+ "parent_name": null,
+ "columns": [
+ {
+ "name": "topic",
+ "position": 3,
+ "data_type": "text",
+ "data_type_str": "text",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": true,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null,
+ "security_labels": []
+ },
+ {
+ "name": "extension",
+ "position": 4,
+ "data_type": "text",
+ "data_type_str": "text",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": true,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null,
+ "security_labels": []
+ },
+ {
+ "name": "payload",
+ "position": 5,
+ "data_type": "jsonb",
+ "data_type_str": "jsonb",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null,
+ "security_labels": []
+ },
+ {
+ "name": "event",
+ "position": 6,
+ "data_type": "text",
+ "data_type_str": "text",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null,
+ "security_labels": []
+ },
+ {
+ "name": "private",
+ "position": 7,
+ "data_type": "boolean",
+ "data_type_str": "boolean",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": "false",
+ "comment": null,
+ "security_labels": []
+ },
+ {
+ "name": "updated_at",
+ "position": 8,
+ "data_type": "timestamp without time zone",
+ "data_type_str": "timestamp without time zone",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": true,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": "now()",
+ "comment": null,
+ "security_labels": []
+ },
+ {
+ "name": "inserted_at",
+ "position": 9,
+ "data_type": "timestamp without time zone",
+ "data_type_str": "timestamp without time zone",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": true,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": "now()",
+ "comment": null,
+ "security_labels": []
+ },
+ {
+ "name": "id",
+ "position": 10,
+ "data_type": "uuid",
+ "data_type_str": "uuid",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": true,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": "gen_random_uuid()",
+ "comment": null,
+ "security_labels": []
+ },
+ {
+ "name": "binary_payload",
+ "position": 11,
+ "data_type": "bytea",
+ "data_type_str": "bytea",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null,
+ "security_labels": []
+ }
+ ],
+ "constraints": [
+ {
+ "name": "messages_payload_exclusive",
+ "constraint_type": "c",
+ "deferrable": false,
+ "initially_deferred": false,
+ "validated": false,
+ "is_local": true,
+ "no_inherit": false,
+ "is_temporal": false,
+ "is_partition_clone": false,
+ "parent_constraint_schema": null,
+ "parent_constraint_name": null,
+ "parent_table_schema": null,
+ "parent_table_name": null,
+ "key_columns": [
+ "payload",
+ "binary_payload"
+ ],
+ "foreign_key_columns": null,
+ "foreign_key_table": null,
+ "foreign_key_schema": null,
+ "foreign_key_table_is_partition": null,
+ "foreign_key_parent_schema": null,
+ "foreign_key_parent_table": null,
+ "foreign_key_effective_schema": null,
+ "foreign_key_effective_table": null,
+ "on_update": null,
+ "on_delete": null,
+ "match_type": null,
+ "check_expression": "((payload IS NULL) OR (binary_payload IS NULL))",
+ "owner": "supabase_realtime_admin",
+ "definition": "CHECK (payload IS NULL OR binary_payload IS NULL) NOT VALID",
+ "comment": null
+ },
+ {
+ "name": "messages_pkey",
+ "constraint_type": "p",
+ "deferrable": false,
+ "initially_deferred": false,
+ "validated": true,
+ "is_local": true,
+ "no_inherit": true,
+ "is_temporal": false,
+ "is_partition_clone": false,
+ "parent_constraint_schema": null,
+ "parent_constraint_name": null,
+ "parent_table_schema": null,
+ "parent_table_name": null,
+ "key_columns": [
+ "id",
+ "inserted_at"
+ ],
+ "foreign_key_columns": null,
+ "foreign_key_table": null,
+ "foreign_key_schema": null,
+ "foreign_key_table_is_partition": null,
+ "foreign_key_parent_schema": null,
+ "foreign_key_parent_table": null,
+ "foreign_key_effective_schema": null,
+ "foreign_key_effective_table": null,
+ "on_update": null,
+ "on_delete": null,
+ "match_type": null,
+ "check_expression": null,
+ "owner": "supabase_realtime_admin",
+ "definition": "PRIMARY KEY (id, inserted_at)",
+ "comment": null
+ }
+ ],
+ "privileges": [
+ {
+ "grantee": "postgres",
+ "privilege": "DELETE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "INSERT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "MAINTAIN",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "REFERENCES",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "SELECT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "TRIGGER",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "TRUNCATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "UPDATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "anon",
+ "privilege": "INSERT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "anon",
+ "privilege": "SELECT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "anon",
+ "privilege": "UPDATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "authenticated",
+ "privilege": "INSERT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "authenticated",
+ "privilege": "SELECT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "authenticated",
+ "privilege": "UPDATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "service_role",
+ "privilege": "INSERT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "service_role",
+ "privilege": "SELECT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "service_role",
+ "privilege": "UPDATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "DELETE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "INSERT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "MAINTAIN",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "REFERENCES",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "SELECT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "TRIGGER",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "TRUNCATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "UPDATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "DELETE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "INSERT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "MAINTAIN",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "REFERENCES",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "SELECT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "TRIGGER",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "TRUNCATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "UPDATE",
+ "grantable": false,
+ "columns": null
+ }
+ ],
+ "security_labels": []
+ },
+ "table:realtime.schema_migrations": {
+ "schema": "realtime",
+ "name": "schema_migrations",
+ "persistence": "p",
+ "row_security": false,
+ "force_row_security": false,
+ "has_indexes": true,
+ "has_rules": false,
+ "has_triggers": false,
+ "has_subclasses": false,
+ "is_populated": true,
+ "replica_identity": "d",
+ "replica_identity_index": null,
+ "is_partition": false,
+ "options": null,
+ "partition_bound": null,
+ "partition_by": null,
+ "owner": "supabase_admin",
+ "comment": null,
+ "parent_schema": null,
+ "parent_name": null,
+ "columns": [
+ {
+ "name": "version",
+ "position": 1,
+ "data_type": "bigint",
+ "data_type_str": "bigint",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": true,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null,
+ "security_labels": []
+ },
+ {
+ "name": "inserted_at",
+ "position": 2,
+ "data_type": "timestamp without time zone",
+ "data_type_str": "timestamp(0) without time zone",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null,
+ "security_labels": []
+ }
+ ],
+ "constraints": [
+ {
+ "name": "schema_migrations_pkey",
+ "constraint_type": "p",
+ "deferrable": false,
+ "initially_deferred": false,
+ "validated": true,
+ "is_local": true,
+ "no_inherit": true,
+ "is_temporal": false,
+ "is_partition_clone": false,
+ "parent_constraint_schema": null,
+ "parent_constraint_name": null,
+ "parent_table_schema": null,
+ "parent_table_name": null,
+ "key_columns": [
+ "version"
+ ],
+ "foreign_key_columns": null,
+ "foreign_key_table": null,
+ "foreign_key_schema": null,
+ "foreign_key_table_is_partition": null,
+ "foreign_key_parent_schema": null,
+ "foreign_key_parent_table": null,
+ "foreign_key_effective_schema": null,
+ "foreign_key_effective_table": null,
+ "on_update": null,
+ "on_delete": null,
+ "match_type": null,
+ "check_expression": null,
+ "owner": "supabase_admin",
+ "definition": "PRIMARY KEY (version)",
+ "comment": null
+ }
+ ],
+ "privileges": [
+ {
+ "grantee": "supabase_admin",
+ "privilege": "DELETE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_admin",
+ "privilege": "INSERT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_admin",
+ "privilege": "MAINTAIN",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_admin",
+ "privilege": "REFERENCES",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_admin",
+ "privilege": "SELECT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_admin",
+ "privilege": "TRIGGER",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_admin",
+ "privilege": "TRUNCATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_admin",
+ "privilege": "UPDATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "DELETE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "INSERT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "MAINTAIN",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "REFERENCES",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "SELECT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "TRIGGER",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "TRUNCATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "UPDATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "DELETE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "INSERT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "MAINTAIN",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "REFERENCES",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "SELECT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "TRIGGER",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "TRUNCATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "UPDATE",
+ "grantable": false,
+ "columns": null
+ }
+ ],
+ "security_labels": []
+ },
+ "table:realtime.subscription": {
+ "schema": "realtime",
+ "name": "subscription",
+ "persistence": "p",
+ "row_security": false,
+ "force_row_security": false,
+ "has_indexes": true,
+ "has_rules": false,
+ "has_triggers": true,
+ "has_subclasses": false,
+ "is_populated": true,
+ "replica_identity": "d",
+ "replica_identity_index": null,
+ "is_partition": false,
+ "options": null,
+ "partition_bound": null,
+ "partition_by": null,
+ "owner": "supabase_realtime_admin",
+ "comment": null,
+ "parent_schema": null,
+ "parent_name": null,
+ "columns": [
+ {
+ "name": "id",
+ "position": 1,
+ "data_type": "bigint",
+ "data_type_str": "bigint",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": true,
+ "is_identity": true,
+ "is_identity_always": true,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null,
+ "security_labels": []
+ },
+ {
+ "name": "subscription_id",
+ "position": 2,
+ "data_type": "uuid",
+ "data_type_str": "uuid",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": true,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null,
+ "security_labels": []
+ },
+ {
+ "name": "entity",
+ "position": 4,
+ "data_type": "regclass",
+ "data_type_str": "regclass",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": true,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null,
+ "security_labels": []
+ },
+ {
+ "name": "filters",
+ "position": 5,
+ "data_type": "realtime.user_defined_filter[]",
+ "data_type_str": "realtime.user_defined_filter[]",
+ "is_custom_type": true,
+ "custom_type_type": "b",
+ "custom_type_category": "A",
+ "custom_type_schema": "realtime",
+ "custom_type_name": "_user_defined_filter",
+ "not_null": true,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": "'{}'::realtime.user_defined_filter[]",
+ "comment": null,
+ "security_labels": []
+ },
+ {
+ "name": "claims",
+ "position": 7,
+ "data_type": "jsonb",
+ "data_type_str": "jsonb",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": true,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null,
+ "security_labels": []
+ },
+ {
+ "name": "claims_role",
+ "position": 8,
+ "data_type": "regrole",
+ "data_type_str": "regrole",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": true,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": true,
+ "collation": null,
+ "default": "realtime.to_regrole((claims ->> 'role'::text))",
+ "comment": null,
+ "security_labels": []
+ },
+ {
+ "name": "created_at",
+ "position": 9,
+ "data_type": "timestamp without time zone",
+ "data_type_str": "timestamp without time zone",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": true,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": "timezone('utc'::text, now())",
+ "comment": null,
+ "security_labels": []
+ },
+ {
+ "name": "action_filter",
+ "position": 10,
+ "data_type": "text",
+ "data_type_str": "text",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": "'*'::text",
+ "comment": null,
+ "security_labels": []
+ },
+ {
+ "name": "selected_columns",
+ "position": 11,
+ "data_type": "text[]",
+ "data_type_str": "text[]",
+ "is_custom_type": false,
+ "custom_type_type": null,
+ "custom_type_category": null,
+ "custom_type_schema": null,
+ "custom_type_name": null,
+ "not_null": false,
+ "is_identity": false,
+ "is_identity_always": false,
+ "is_generated": false,
+ "collation": null,
+ "default": null,
+ "comment": null,
+ "security_labels": []
+ }
+ ],
+ "constraints": [
+ {
+ "name": "pk_subscription",
+ "constraint_type": "p",
+ "deferrable": false,
+ "initially_deferred": false,
+ "validated": true,
+ "is_local": true,
+ "no_inherit": true,
+ "is_temporal": false,
+ "is_partition_clone": false,
+ "parent_constraint_schema": null,
+ "parent_constraint_name": null,
+ "parent_table_schema": null,
+ "parent_table_name": null,
+ "key_columns": [
+ "id"
+ ],
+ "foreign_key_columns": null,
+ "foreign_key_table": null,
+ "foreign_key_schema": null,
+ "foreign_key_table_is_partition": null,
+ "foreign_key_parent_schema": null,
+ "foreign_key_parent_table": null,
+ "foreign_key_effective_schema": null,
+ "foreign_key_effective_table": null,
+ "on_update": null,
+ "on_delete": null,
+ "match_type": null,
+ "check_expression": null,
+ "owner": "supabase_realtime_admin",
+ "definition": "PRIMARY KEY (id)",
+ "comment": null
+ },
+ {
+ "name": "subscription_action_filter_check",
+ "constraint_type": "c",
+ "deferrable": false,
+ "initially_deferred": false,
+ "validated": true,
+ "is_local": true,
+ "no_inherit": false,
+ "is_temporal": false,
+ "is_partition_clone": false,
+ "parent_constraint_schema": null,
+ "parent_constraint_name": null,
+ "parent_table_schema": null,
+ "parent_table_name": null,
+ "key_columns": [
+ "action_filter"
+ ],
+ "foreign_key_columns": null,
+ "foreign_key_table": null,
+ "foreign_key_schema": null,
+ "foreign_key_table_is_partition": null,
+ "foreign_key_parent_schema": null,
+ "foreign_key_parent_table": null,
+ "foreign_key_effective_schema": null,
+ "foreign_key_effective_table": null,
+ "on_update": null,
+ "on_delete": null,
+ "match_type": null,
+ "check_expression": "(action_filter = ANY (ARRAY['*'::text, 'INSERT'::text, 'UPDATE'::text, 'DELETE'::text]))",
+ "owner": "supabase_realtime_admin",
+ "definition": "CHECK (action_filter = ANY (ARRAY['*'::text, 'INSERT'::text, 'UPDATE'::text, 'DELETE'::text]))",
+ "comment": null
+ }
+ ],
+ "privileges": [
+ {
+ "grantee": "postgres",
+ "privilege": "DELETE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "INSERT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "MAINTAIN",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "REFERENCES",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "SELECT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "TRIGGER",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "TRUNCATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "postgres",
+ "privilege": "UPDATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "anon",
+ "privilege": "SELECT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "authenticated",
+ "privilege": "SELECT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "service_role",
+ "privilege": "SELECT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "DELETE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "INSERT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "MAINTAIN",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "REFERENCES",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "SELECT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "TRIGGER",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "TRUNCATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "dashboard_user",
+ "privilege": "UPDATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "DELETE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "INSERT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "MAINTAIN",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "REFERENCES",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "SELECT",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "TRIGGER",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "TRUNCATE",
+ "grantable": false,
+ "columns": null
+ },
+ {
+ "grantee": "supabase_realtime_admin",
+ "privilege": "UPDATE",
+ "grantable": false,
+ "columns": null
+ }
+ ],
+ "security_labels": []
+ }
+ },
+ "triggers": {
+ "trigger:realtime.subscription.tr_check_filters": {
+ "schema": "realtime",
+ "name": "tr_check_filters",
+ "table_name": "subscription",
+ "table_relkind": "r",
+ "function_schema": "realtime",
+ "function_name": "subscription_check_filters",
+ "trigger_type": 23,
+ "enabled": "O",
+ "is_internal": false,
+ "deferrable": false,
+ "initially_deferred": false,
+ "argument_count": 0,
+ "column_numbers": [],
+ "arguments": [],
+ "when_condition": null,
+ "old_table": null,
+ "new_table": null,
+ "is_partition_clone": false,
+ "parent_trigger_name": null,
+ "parent_table_schema": null,
+ "parent_table_name": null,
+ "is_on_partitioned_table": false,
+ "owner": "supabase_realtime_admin",
+ "definition": "CREATE TRIGGER tr_check_filters BEFORE INSERT OR UPDATE ON realtime.subscription FOR EACH ROW EXECUTE FUNCTION realtime.subscription_check_filters()",
+ "comment": null
+ }
+ },
+ "eventTriggers": {},
+ "rules": {},
+ "ranges": {},
+ "views": {},
+ "foreignDataWrappers": {},
+ "servers": {},
+ "userMappings": {},
+ "foreignTables": {},
+ "depends": [
+ {
+ "dependent_stable_id": "acl:procedure:realtime.\"cast\"(text,regtype)::grantee:anon",
+ "referenced_stable_id": "procedure:realtime.\"cast\"(text,regtype)",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.\"cast\"(text,regtype)::grantee:authenticated",
+ "referenced_stable_id": "procedure:realtime.\"cast\"(text,regtype)",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.\"cast\"(text,regtype)::grantee:service_role",
+ "referenced_stable_id": "procedure:realtime.\"cast\"(text,regtype)",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.apply_rls(jsonb,integer)::grantee:anon",
+ "referenced_stable_id": "procedure:realtime.apply_rls(jsonb,integer)",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.apply_rls(jsonb,integer)::grantee:authenticated",
+ "referenced_stable_id": "procedure:realtime.apply_rls(jsonb,integer)",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.apply_rls(jsonb,integer)::grantee:service_role",
+ "referenced_stable_id": "procedure:realtime.apply_rls(jsonb,integer)",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.build_prepared_statement_sql(text,regclass,realtime.wal_column[])::grantee:anon",
+ "referenced_stable_id": "procedure:realtime.build_prepared_statement_sql(text,regclass,realtime.wal_column[])",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.build_prepared_statement_sql(text,regclass,realtime.wal_column[])::grantee:authenticated",
+ "referenced_stable_id": "procedure:realtime.build_prepared_statement_sql(text,regclass,realtime.wal_column[])",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.build_prepared_statement_sql(text,regclass,realtime.wal_column[])::grantee:service_role",
+ "referenced_stable_id": "procedure:realtime.build_prepared_statement_sql(text,regclass,realtime.wal_column[])",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)::grantee:anon",
+ "referenced_stable_id": "procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)::grantee:authenticated",
+ "referenced_stable_id": "procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)::grantee:service_role",
+ "referenced_stable_id": "procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.is_visible_through_filters(realtime.wal_column[],realtime.user_defined_filter[])::grantee:anon",
+ "referenced_stable_id": "procedure:realtime.is_visible_through_filters(realtime.wal_column[],realtime.user_defined_filter[])",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.is_visible_through_filters(realtime.wal_column[],realtime.user_defined_filter[])::grantee:authenticated",
+ "referenced_stable_id": "procedure:realtime.is_visible_through_filters(realtime.wal_column[],realtime.user_defined_filter[])",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.is_visible_through_filters(realtime.wal_column[],realtime.user_defined_filter[])::grantee:service_role",
+ "referenced_stable_id": "procedure:realtime.is_visible_through_filters(realtime.wal_column[],realtime.user_defined_filter[])",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.quote_wal2json(regclass)::grantee:anon",
+ "referenced_stable_id": "procedure:realtime.quote_wal2json(regclass)",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.quote_wal2json(regclass)::grantee:authenticated",
+ "referenced_stable_id": "procedure:realtime.quote_wal2json(regclass)",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.quote_wal2json(regclass)::grantee:service_role",
+ "referenced_stable_id": "procedure:realtime.quote_wal2json(regclass)",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.subscription_check_filters()::grantee:anon",
+ "referenced_stable_id": "procedure:realtime.subscription_check_filters()",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.subscription_check_filters()::grantee:authenticated",
+ "referenced_stable_id": "procedure:realtime.subscription_check_filters()",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.subscription_check_filters()::grantee:service_role",
+ "referenced_stable_id": "procedure:realtime.subscription_check_filters()",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.to_regrole(text)::grantee:anon",
+ "referenced_stable_id": "procedure:realtime.to_regrole(text)",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.to_regrole(text)::grantee:authenticated",
+ "referenced_stable_id": "procedure:realtime.to_regrole(text)",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:procedure:realtime.to_regrole(text)::grantee:service_role",
+ "referenced_stable_id": "procedure:realtime.to_regrole(text)",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:schema:realtime::grantee:anon",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:schema:realtime::grantee:authenticated",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:schema:realtime::grantee:postgres",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:schema:realtime::grantee:service_role",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:schema:realtime::grantee:supabase_realtime_admin",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:table:realtime.messages::grantee:anon",
+ "referenced_stable_id": "table:realtime.messages",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:table:realtime.messages::grantee:authenticated",
+ "referenced_stable_id": "table:realtime.messages",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:table:realtime.messages::grantee:service_role",
+ "referenced_stable_id": "table:realtime.messages",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:table:realtime.schema_migrations::grantee:supabase_realtime_admin",
+ "referenced_stable_id": "table:realtime.schema_migrations",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:table:realtime.subscription::grantee:anon",
+ "referenced_stable_id": "table:realtime.subscription",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:table:realtime.subscription::grantee:authenticated",
+ "referenced_stable_id": "table:realtime.subscription",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "acl:table:realtime.subscription::grantee:service_role",
+ "referenced_stable_id": "table:realtime.subscription",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "column:realtime.subscription.claims_role",
+ "referenced_stable_id": "column:realtime.subscription.claims",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "column:realtime.subscription.claims_role",
+ "referenced_stable_id": "procedure:realtime.to_regrole(text)",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "constraint:realtime.messages.messages_payload_exclusive",
+ "referenced_stable_id": "column:realtime.messages.binary_payload",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "constraint:realtime.messages.messages_payload_exclusive",
+ "referenced_stable_id": "column:realtime.messages.binary_payload",
+ "deptype": "a"
+ },
+ {
+ "dependent_stable_id": "constraint:realtime.messages.messages_payload_exclusive",
+ "referenced_stable_id": "column:realtime.messages.payload",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "constraint:realtime.messages.messages_payload_exclusive",
+ "referenced_stable_id": "column:realtime.messages.payload",
+ "deptype": "a"
+ },
+ {
+ "dependent_stable_id": "constraint:realtime.messages.messages_pkey",
+ "referenced_stable_id": "column:realtime.messages.id",
+ "deptype": "a"
+ },
+ {
+ "dependent_stable_id": "constraint:realtime.messages.messages_pkey",
+ "referenced_stable_id": "column:realtime.messages.inserted_at",
+ "deptype": "a"
+ },
+ {
+ "dependent_stable_id": "constraint:realtime.schema_migrations.schema_migrations_pkey",
+ "referenced_stable_id": "column:realtime.schema_migrations.version",
+ "deptype": "a"
+ },
+ {
+ "dependent_stable_id": "constraint:realtime.subscription.pk_subscription",
+ "referenced_stable_id": "column:realtime.subscription.id",
+ "deptype": "a"
+ },
+ {
+ "dependent_stable_id": "constraint:realtime.subscription.subscription_action_filter_check",
+ "referenced_stable_id": "column:realtime.subscription.action_filter",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "constraint:realtime.subscription.subscription_action_filter_check",
+ "referenced_stable_id": "column:realtime.subscription.action_filter",
+ "deptype": "a"
+ },
+ {
+ "dependent_stable_id": "defacl:supabase_admin:f:schema:realtime:grantee:dashboard_user",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "defacl:supabase_admin:f:schema:realtime:grantee:postgres",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "defacl:supabase_admin:r:schema:realtime:grantee:dashboard_user",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "defacl:supabase_admin:r:schema:realtime:grantee:postgres",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "defacl:supabase_admin:S:schema:realtime:grantee:dashboard_user",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "defacl:supabase_admin:S:schema:realtime:grantee:postgres",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "index:realtime.subscription.ix_realtime_subscription_entity",
+ "referenced_stable_id": "column:realtime.subscription.entity",
+ "deptype": "a"
+ },
+ {
+ "dependent_stable_id": "index:realtime.subscription.ix_realtime_subscription_entity",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "index:realtime.subscription.ix_realtime_subscription_entity",
+ "referenced_stable_id": "table:realtime.subscription",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "index:realtime.subscription.subscription_subscription_id_entity_filters_action_filter_selec",
+ "referenced_stable_id": "column:realtime.subscription.action_filter",
+ "deptype": "a"
+ },
+ {
+ "dependent_stable_id": "index:realtime.subscription.subscription_subscription_id_entity_filters_action_filter_selec",
+ "referenced_stable_id": "column:realtime.subscription.entity",
+ "deptype": "a"
+ },
+ {
+ "dependent_stable_id": "index:realtime.subscription.subscription_subscription_id_entity_filters_action_filter_selec",
+ "referenced_stable_id": "column:realtime.subscription.filters",
+ "deptype": "a"
+ },
+ {
+ "dependent_stable_id": "index:realtime.subscription.subscription_subscription_id_entity_filters_action_filter_selec",
+ "referenced_stable_id": "column:realtime.subscription.selected_columns",
+ "deptype": "a"
+ },
+ {
+ "dependent_stable_id": "index:realtime.subscription.subscription_subscription_id_entity_filters_action_filter_selec",
+ "referenced_stable_id": "column:realtime.subscription.subscription_id",
+ "deptype": "a"
+ },
+ {
+ "dependent_stable_id": "index:realtime.subscription.subscription_subscription_id_entity_filters_action_filter_selec",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "index:realtime.subscription.subscription_subscription_id_entity_filters_action_filter_selec",
+ "referenced_stable_id": "table:realtime.subscription",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "procedure:realtime.\"cast\"(text,regtype)",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "procedure:realtime.apply_rls(jsonb,integer)",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "procedure:realtime.apply_rls(jsonb,integer)",
+ "referenced_stable_id": "type:realtime.wal_rls",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "procedure:realtime.broadcast_changes(text,text,text,text,text,record,record,text)",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "procedure:realtime.build_prepared_statement_sql(text,regclass,realtime.wal_column[])",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "procedure:realtime.check_equality_op(realtime.equality_op,regtype,text,text)",
+ "referenced_stable_id": "type:realtime.equality_op",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "procedure:realtime.is_visible_through_filters(realtime.wal_column[],realtime.user_defined_filter[])",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "procedure:realtime.list_changes(name,name,integer,integer)",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "procedure:realtime.quote_wal2json(regclass)",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "procedure:realtime.send_binary(bytea,text,text,boolean)",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "procedure:realtime.send(jsonb,text,text,boolean)",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "procedure:realtime.subscription_check_filters()",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "procedure:realtime.to_regrole(text)",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "procedure:realtime.topic()",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "procedure:realtime.wal2json_escape_identifier(text)",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "table:realtime.messages",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "table:realtime.schema_migrations",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "table:realtime.subscription",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "trigger:realtime.subscription.tr_check_filters",
+ "referenced_stable_id": "procedure:realtime.subscription_check_filters()",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "trigger:realtime.subscription.tr_check_filters",
+ "referenced_stable_id": "table:realtime.subscription",
+ "deptype": "a"
+ },
+ {
+ "dependent_stable_id": "type:realtime.action",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "type:realtime.equality_op",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "type:realtime.user_defined_filter",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "type:realtime.user_defined_filter",
+ "referenced_stable_id": "type:realtime.equality_op",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "type:realtime.wal_column",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ },
+ {
+ "dependent_stable_id": "type:realtime.wal_rls",
+ "referenced_stable_id": "schema:realtime",
+ "deptype": "n"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/rel/env.sh.eex b/rel/env.sh.eex
index 402da0791..c7c7764ff 100644
--- a/rel/env.sh.eex
+++ b/rel/env.sh.eex
@@ -9,7 +9,10 @@ ip=$(grep fly-local-6pn /etc/hosts | cut -f 1)
if [ "$AWS_EXECUTION_ENV" = "AWS_ECS_FARGATE" ]; then
# for AWS ECS Fargate
- ip=$(hostname -I | awk '{print $3}')
+ ip=$(curl -sf "${ECS_CONTAINER_METADATA_URI_V4}" \
+ | jq -r '.Networks[].IPv6Addresses[]' \
+ | grep -Ev '^f[cd]' \
+ | head -1)
elif [ -n "${POD_IP}" ]; then
# for kubernetes
ip=${POD_IP}
diff --git a/rel/vm.args.eex b/rel/vm.args.eex
index 278da5524..f5e1845c8 100644
--- a/rel/vm.args.eex
+++ b/rel/vm.args.eex
@@ -10,8 +10,8 @@
## Tweak GC to run more often
##-env ERL_FULLSWEEP_AFTER 10
-## Limit process heap for all procs to 1000 MB
-+hmax 1000000000
+## Limit process heap for all procs to 1000 MB. The number here is the number of words
++hmax <%= div(1_000_000_000, :erlang.system_info(:wordsize)) %>
## Set distribution buffer busy limit (default is 1024)
+zdbbl 100000
@@ -19,4 +19,4 @@
## Disable Busy Wait
+sbwt none
+sbwtdio none
-+sbwtdcpu none
\ No newline at end of file
++sbwtdcpu none
diff --git a/run.sh b/run.sh
index 2dddbc1b8..22cd50ea7 100755
--- a/run.sh
+++ b/run.sh
@@ -3,7 +3,7 @@ set -euo pipefail
set -x
ulimit -n
-if [ ! -z "$RLIMIT_NOFILE" ]; then
+if [ -n "${RLIMIT_NOFILE:-}" ]; then
echo "Setting RLIMIT_NOFILE to ${RLIMIT_NOFILE}"
ulimit -Sn "$RLIMIT_NOFILE"
fi
@@ -17,7 +17,7 @@ upload_crash_dump_to_s3() {
s3Port=$ERL_CRASH_DUMP_S3_PORT
if [ "${AWS_CONTAINER_CREDENTIALS_RELATIVE_URI-}" ]; then
- response=$(curl -s http://169.254.170.2$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI)
+ response=$(curl -s "http://169.254.170.2${AWS_CONTAINER_CREDENTIALS_RELATIVE_URI}")
s3Key=$(echo "$response" | grep -o '"AccessKeyId": *"[^"]*"' | grep -o '"[^"]*"$' | tr -d '"')
s3Secret=$(echo "$response" | grep -o '"SecretAccessKey": *"[^"]*"' | grep -o '"[^"]*"$' | tr -d '"')
else
@@ -28,7 +28,7 @@ upload_crash_dump_to_s3() {
filePath=${ERL_CRASH_DUMP_FOLDER:-tmp}/$(date +%s)_${ERL_CRASH_DUMP_FILE_NAME:-erl_crash.dump}
if [ -f "${ERL_CRASH_DUMP_FOLDER:-tmp}/${ERL_CRASH_DUMP_FILE_NAME:-erl_crash.dump}" ]; then
- mv ${ERL_CRASH_DUMP_FOLDER:-tmp}/${ERL_CRASH_DUMP_FILE_NAME:-erl_crash.dump} $filePath
+ mv "${ERL_CRASH_DUMP_FOLDER:-tmp}/${ERL_CRASH_DUMP_FILE_NAME:-erl_crash.dump}" "$filePath"
resource="/${bucket}/realtime/crash_dumps${filePath}"
@@ -36,7 +36,7 @@ upload_crash_dump_to_s3() {
dateValue=$(date -R)
stringToSign="PUT\n\n${contentType}\n${dateValue}\n${resource}"
- signature=$(echo -en ${stringToSign} | openssl sha1 -hmac ${s3Secret} -binary | base64)
+ signature=$(echo -en "${stringToSign}" | openssl sha1 -hmac "${s3Secret}" -binary | base64)
if [ "${ERL_CRASH_DUMP_S3_SSL:-}" = true ]; then
protocol="https"
@@ -49,7 +49,7 @@ upload_crash_dump_to_s3() {
-H "Date: ${dateValue}" \
-H "Content-Type: ${contentType}" \
-H "Authorization: AWS ${s3Key}:${signature}" \
- ${protocol}://${s3Host}:${s3Port}${resource}
+ "${protocol}://${s3Host}:${s3Port}${resource}"
fi
exit "$EXIT_CODE"
@@ -62,7 +62,7 @@ generate_certs() {
openssl req -new -nodes -out server.csr -keyout server.key -subj "/C=US/ST=Delaware/L=New Castle/O=Supabase Inc/CN=$(hostname -f)"
openssl x509 -req -in server.csr -days 90 -CA ca.cert -CAkey ca.key -out server.cert
rm -f ca.key
- CWD=`pwd`
+ CWD=$(pwd)
export GEN_RPC_CACERTFILE="$CWD/ca.cert"
export GEN_RPC_KEYFILE="$CWD/server.key"
export GEN_RPC_CERTFILE="$CWD/server.cert"
@@ -87,10 +87,10 @@ EOF
}
if [ "${ENABLE_ERL_CRASH_DUMP:-false}" = true ]; then
- trap upload_crash_dump_to_s3 INT TERM KILL EXIT
+ trap upload_crash_dump_to_s3 INT TERM EXIT
fi
-if [[ -n "${GENERATE_CLUSTER_CERTS}" ]] ; then
+if [[ -n "${GENERATE_CLUSTER_CERTS:-}" ]] ; then
generate_certs
fi
diff --git a/test/api_jwt_secret_test.exs b/test/api_jwt_secret_test.exs
index 4bf08c7ba..5c8b9bab3 100644
--- a/test/api_jwt_secret_test.exs
+++ b/test/api_jwt_secret_test.exs
@@ -17,4 +17,42 @@ defmodule RealtimeWeb.ApiJwtSecretTest do
conn = get(conn, Routes.tenant_path(conn, :index))
assert conn.status == 200
end
+
+ describe "secret rotation" do
+ setup do
+ previous = Application.get_env(:realtime, :api_jwt_secret)
+ Application.put_env(:realtime, :api_jwt_secret, ["current_secret", "next_secret"])
+ on_exit(fn -> Application.put_env(:realtime, :api_jwt_secret, previous) end)
+ :ok
+ end
+
+ test "api key signed with current secret", %{conn: conn} do
+ jwt = generate_jwt_token("current_secret")
+ conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer " <> jwt)
+ conn = get(conn, Routes.tenant_path(conn, :index))
+ assert conn.status == 200
+ end
+
+ test "api key signed with next secret", %{conn: conn} do
+ jwt = generate_jwt_token("next_secret")
+ conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer " <> jwt)
+ conn = get(conn, Routes.tenant_path(conn, :index))
+ assert conn.status == 200
+ end
+
+ test "api key signed with unknown secret", %{conn: conn} do
+ jwt = generate_jwt_token("unknown_secret")
+ conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer " <> jwt)
+ conn = get(conn, Routes.tenant_path(conn, :index))
+ assert conn.status == 403
+ end
+
+ test "no secrets configured", %{conn: conn} do
+ Application.put_env(:realtime, :api_jwt_secret, [])
+ jwt = generate_jwt_token("current_secret")
+ conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer " <> jwt)
+ conn = get(conn, Routes.tenant_path(conn, :index))
+ assert conn.status == 403
+ end
+ end
end
diff --git a/test/e2e/.gitignore b/test/e2e/.gitignore
index 4c49bd78f..82b274ebd 100644
--- a/test/e2e/.gitignore
+++ b/test/e2e/.gitignore
@@ -1 +1,4 @@
.env
+.env.local
+realtime-check
+result
diff --git a/test/e2e/.tool-versions b/test/e2e/.tool-versions
index ae794d2c3..f1196e82c 100644
--- a/test/e2e/.tool-versions
+++ b/test/e2e/.tool-versions
@@ -1 +1 @@
-deno latest
+bun latest
diff --git a/test/e2e/README.md b/test/e2e/README.md
index 03d7c32a1..0c0e6808f 100644
--- a/test/e2e/README.md
+++ b/test/e2e/README.md
@@ -1,107 +1,136 @@
# Realtime E2E tests
-Our E2E tests intend to test the usage of Realtime with Supabase and ensure we have no breaking changes. They require you to setup your project with some configurations to ensure they work as expected.
+| Option | Description |
+|---|---|
+| `--project` | Supabase project ref (not needed for `--env local`) |
+| `--publishable-key` | Project anon/public key |
+| `--secret-key` | Project service role key |
+| `--db-password` | Database password (required for staging/prod) |
+| `--env` | `local` \| `staging` \| `prod` (default: `prod`) |
+| `--domain` | Email domain for the test user (default: `example.com`) |
+| `--port` | Override URL port (useful for local) |
+| `--test` | Comma-separated list of test categories to run (runs all if omitted) |
+| `--json` | Output results as JSON to stdout (all other output goes to stderr) |
+| `--url` | Override project URL (e.g. `http://127.0.0.1:54321`) |
+| `--db-url` | Override database URL (e.g. `postgresql://postgres:postgres@127.0.0.1:54322/postgres`) |
+| `--otel` | OTLP HTTP endpoint for tracing (e.g. `http://localhost:4318`) |
+| `--otel-token` | Bearer token for authenticated OTLP endpoints |
+
+A random test user is created at the start of each run and deleted automatically when it finishes.
+
+## Test categories
+
+Pass any combination to `--test` as a comma-separated list. Use `functional` to run all non-load suites, or `load` to run all load suites.
+
+| Category | Suites | Tests |
+|---|---|---|
+| `connection` | connection | First connect latency; broadcast message throughput |
+| `load` | load-postgres-changes | Postgres system message latency; INSERT / UPDATE / DELETE throughput via postgres changes |
+| | load-presence | Presence join throughput |
+| | load-broadcast-from-db | Broadcast-from-database throughput |
+| | load-broadcast` | Self-broadcast throughput; REST broadcast API throughput |
+| | load-broadcast-replay | Broadcast replay throughput on channel join |
+| `broadcast` | broadcast extension | Self-broadcast receive; REST broadcast API send-and-receive |
+| `presence` | presence extension | Presence join on public channels; presence join on private channels |
+| `authorization` | authorization check | Private channel denied without permissions; private channel allowed with permissions |
+| `postgres-changes` | postgres changes extension | Filtered INSERT, UPDATE, DELETE events; concurrent INSERT + UPDATE + DELETE |
+| `broadcast-changes` | broadcast changes | DB-triggered broadcast for INSERT, UPDATE, DELETE |
+| `broadcast-replay` | broadcast replay | Replayed messages delivered on join; `meta.replayed` flag set; messages before `since` not replayed |
+
+```bash
+# Run only connection and broadcast tests
+./realtime-check --env local --publishable-key
--secret-key --test connection,broadcast
+
+# Run all load tests
+./realtime-check --env local --publishable-key --secret-key --test load
+
+# Run all functional (non-load) tests
+./realtime-check --env local --publishable-key --secret-key --test functional
+```
+
+## JSON output
+
+When `--json` is used, only the JSON is written to stdout — all progress and diagnostic output goes to stderr — making it safe to pipe directly to `jq`:
-## Setup tests
+```bash
+./realtime-check --json ... | jq '.slis'
+./realtime-check --json ... | jq '.suites["broadcast extension"].tests'
+./realtime-check --json ... | jq 'select(.passed == false)'
+```
-### Project environment
+## Using the binary
-- Run the following SQL
+The pre-built binary requires no runtime — just run it directly.
-```sql
-CREATE TABLE public.pg_changes (
- id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- value text NOT NULL DEFAULT gen_random_uuid ()
-);
+### Local project
-CREATE TABLE public.dummy (
- id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- value text NOT NULL DEFAULT gen_random_uuid ()
-);
+A `supabase/config.toml` is included, so `supabase start` works out of the box.
-CREATE TABLE public.authorization (
- id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
- value text NOT NULL DEFAULT gen_random_uuid ()
-);
+```bash
+supabase start
+./realtime-check --env local --publishable-key --secret-key
+```
-CREATE TABLE public.broadcast_changes (
- id text PRIMARY KEY,
- value text NOT NULL
-);
+### Local project with tracing
-CREATE TABLE public.wallet (
- id text PRIMARY KEY,
- wallet_id text NOT NULL
-);
-INSERT INTO public.wallet (id, wallet_id) VALUES (1, 'wallet_1');
+```bash
+supabase start
+docker compose up -d # starts Jaeger at http://localhost:16686
+./realtime-check --env local --publishable-key --secret-key --otel http://localhost:4318
+```
-ALTER TABLE public.pg_changes ENABLE ROW LEVEL SECURITY;
+For authenticated OTLP endpoints:
-ALTER TABLE public.authorization ENABLE ROW LEVEL SECURITY;
+```bash
+./realtime-check --env local --publishable-key --secret-key \
+ --otel https://otlp.example.com --otel-token
+```
-ALTER TABLE public.broadcast_changes ENABLE ROW LEVEL SECURITY;
+### Remote project
-ALTER TABLE public.wallet ENABLE ROW LEVEL SECURITY;
+```bash
+./realtime-check --project --publishable-key \
+ --secret-key --db-password
+```
-ALTER PUBLICATION supabase_realtime
- ADD TABLE public.pg_changes;
+## Using Bun
-ALTER PUBLICATION supabase_realtime
- ADD TABLE public.dummy;
+Requires [Bun](https://bun.sh).
-CREATE POLICY "authenticated receive on topic" ON "realtime"."messages" AS PERMISSIVE
- FOR SELECT TO authenticated
- USING ( realtime.topic() like 'topic:%');
+### Run without building
-CREATE POLICY "authenticated broadcast on topic" ON "realtime"."messages" AS PERMISSIVE
- FOR INSERT TO authenticated
- WITH CHECK ( realtime.topic() like 'topic:%');
+```bash
+bun install
+bun run check -- --project [ --publishable-key --secret-key --db-password
+```
-CREATE POLICY "authenticated jwt topic in wallet can receive" ON "realtime"."messages" AS PERMISSIVE
- FOR SELECT TO authenticated
- USING ( realtime.topic() like 'jwt_topic:%' AND exists (select wallet_id from public.wallet where wallet_id = (auth.jwt() -> 'sub')::text));
+### Build the binary
-CREATE POLICY "authenticated jwt topic in wallet can broadcast" ON "realtime"."messages" AS PERMISSIVE
- FOR INSERT TO authenticated
- WITH CHECK ( realtime.topic() like 'jwt_topic:%' AND exists (select wallet_id from public.wallet where wallet_id = (auth.jwt() -> 'sub')::text));
+```bash
+bun install
+bun run build
+./realtime-check --project ][ --publishable-key --secret-key --db-password
+```
-CREATE POLICY "allow authenticated users all access" ON "public"."pg_changes" AS PERMISSIVE
- FOR ALL TO authenticated
- USING (TRUE);
+## Using Nix
-CREATE POLICY "authenticated have full access to read on broadcast_changes" ON "public"."broadcast_changes" AS PERMISSIVE
- FOR ALL TO authenticated
- USING (TRUE);
+Requires flakes support. Add this once to `/etc/nix/nix.conf`:
-CREATE OR REPLACE FUNCTION broadcast_changes_for_table_trigger ()
- RETURNS TRIGGER
- AS $$
-DECLARE
- topic text;
-BEGIN
- topic = 'topic:test';
- PERFORM
- realtime.broadcast_changes (topic, TG_OP, TG_OP, TG_TABLE_NAME, TG_TABLE_SCHEMA, NEW, OLD, TG_LEVEL);
- RETURN NULL;
-END;
-$$
-LANGUAGE plpgsql;
+```
+experimental-features = nix-command flakes
+```
-CREATE TRIGGER broadcast_changes_for_table_public_broadcast_changes_trigger
- AFTER INSERT OR UPDATE OR DELETE ON broadcast_changes
- FOR EACH ROW
- EXECUTE FUNCTION broadcast_changes_for_table_trigger ();
+### Build and run
-INSERT INTO "auth"."users" ("instance_id", "id", "aud", "role", "email", "encrypted_password", "email_confirmed_at", "invited_at", "confirmation_token", "confirmation_sent_at", "recovery_token", "recovery_sent_at", "email_change_token_new", "email_change", "email_change_sent_at", "last_sign_in_at", "raw_app_meta_data", "raw_user_meta_data", "is_super_admin", "created_at", "updated_at", "phone", "phone_confirmed_at", "phone_change", "phone_change_token", "phone_change_sent_at", "email_change_token_current", "email_change_confirm_status", "banned_until", "reauthentication_token", "reauthentication_sent_at", "is_sso_user", "deleted_at", "is_anonymous") VALUES ('00000000-0000-0000-0000-000000000000', '93c8bc43-c330-4702-aef2-4ba2c298950a', 'authenticated', 'authenticated', 'filipe@supabase.io', '$2a$10$WQ4tbkMVuS2OUmkX.LRC0uRwH6bU39CbI5bdHuLi82UXhUsjhrLP.', '2025-04-03 03:51:28.207805+00', null, '', '2025-04-03 03:50:59.085609+00', '', null, '', '', null, '2025-04-03 08:01:19.813327+00', '{"provider": "email", "providers": ["email"]}', '{"sub": "92c8bc43-c330-4702-aef2-4ba2c298950a", "email": "filipe@supabase.io", "email_verified": true, "phone_verified": false}', null, '2025-04-03 03:50:59.038087+00', '2025-04-03 22:09:10.979685+00', null, null, '', '', null, '', '0', null, '', null, 'false', null, 'false');
+```bash
+bun run nix
+./result/bin/realtime-check --project ][ --publishable-key --secret-key --db-password
```
-### Test enviroment
+`bun run nix` calls `nix-build.sh`, which automatically updates the `outputHash` in `flake.nix` when `package.json` or `bun.lock` change — no manual hash update needed.
-- Create .env based on .env.template with:
- - PROJECT_URL - URL for the project
- - PROJECT_ANON_TOKEN - Anon authentication token for the project
+---
-## Run tests
+## Deno tests (legacy)
-Run the following command
-`deno test tests.ts --allow-read --allow-net --trace-leaks --allow-env=WS_NO_BUFFER_UTIL`
+See [legacy/README.md](./legacy/README.md).
diff --git a/test/e2e/bun.lock b/test/e2e/bun.lock
new file mode 100644
index 000000000..465b34d79
--- /dev/null
+++ b/test/e2e/bun.lock
@@ -0,0 +1,67 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "realtime-check",
+ "dependencies": {
+ "@opentelemetry/api": "^1.9.1",
+ "@opentelemetry/context-async-hooks": "^2.7.1",
+ "@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
+ "@opentelemetry/resources": "^2.7.1",
+ "@opentelemetry/sdk-trace-base": "^2.7.1",
+ "@opentelemetry/semantic-conventions": "^1.41.1",
+ "@supabase/supabase-js": "2.108.2",
+ "commander": "^14.0.3",
+ "kleur": "^4.1.5",
+ },
+ },
+ },
+ "packages": {
+ "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
+
+ "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw=="],
+
+ "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.7.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ=="],
+
+ "@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="],
+
+ "@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-exporter-base": "0.218.0", "@opentelemetry/otlp-transformer": "0.218.0", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-8dqezsmPhtKitIK/eTipZhYl9EX2/gNQ5zUMhaz3uxEURwfkNf8IPvo6yNfrzbxdtpAOybS/+h7wmIWYqFSpiw=="],
+
+ "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.218.0", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/otlp-transformer": "0.218.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA=="],
+
+ "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/sdk-logs": "0.218.0", "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/sdk-trace-base": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ=="],
+
+ "@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="],
+
+ "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.218.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.218.0", "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag=="],
+
+ "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ=="],
+
+ "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="],
+
+ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
+
+ "@supabase/auth-js": ["@supabase/auth-js@2.108.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag=="],
+
+ "@supabase/functions-js": ["@supabase/functions-js@2.108.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw=="],
+
+ "@supabase/phoenix": ["@supabase/phoenix@0.4.2", "", {}, "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A=="],
+
+ "@supabase/postgrest-js": ["@supabase/postgrest-js@2.108.2", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw=="],
+
+ "@supabase/realtime-js": ["@supabase/realtime-js@2.108.2", "", { "dependencies": { "@supabase/phoenix": "^0.4.2", "tslib": "2.8.1" } }, "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g=="],
+
+ "@supabase/storage-js": ["@supabase/storage-js@2.108.2", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g=="],
+
+ "@supabase/supabase-js": ["@supabase/supabase-js@2.108.2", "", { "dependencies": { "@supabase/auth-js": "2.108.2", "@supabase/functions-js": "2.108.2", "@supabase/postgrest-js": "2.108.2", "@supabase/realtime-js": "2.108.2", "@supabase/storage-js": "2.108.2" } }, "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ=="],
+
+ "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
+
+ "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
+
+ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
+
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ }
+}
diff --git a/test/e2e/docker-compose.yml b/test/e2e/docker-compose.yml
new file mode 100644
index 000000000..8f33b4e4b
--- /dev/null
+++ b/test/e2e/docker-compose.yml
@@ -0,0 +1,11 @@
+# E2e testing infrastructure. Requires `supabase start` to be running first.
+# Run from test/e2e:
+# docker compose up -d
+# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 bun run realtime-check.ts --env local
+services:
+ jaeger:
+ image: jaegertracing/jaeger:2.5.0
+ container_name: e2e-jaeger
+ ports:
+ - "16686:16686"
+ - "4318:4318"
diff --git a/test/e2e/flake.lock b/test/e2e/flake.lock
new file mode 100644
index 000000000..8d169a6c4
--- /dev/null
+++ b/test/e2e/flake.lock
@@ -0,0 +1,61 @@
+{
+ "nodes": {
+ "flake-utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1731533236,
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1772624091,
+ "narHash": "sha256-QKyJ0QGWBn6r0invrMAK8dmJoBYWoOWy7lN+UHzW1jc=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "80bdc1e5ce51f56b19791b52b2901187931f5353",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "flake-utils": "flake-utils",
+ "nixpkgs": "nixpkgs"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/test/e2e/flake.nix b/test/e2e/flake.nix
new file mode 100644
index 000000000..fabdacb53
--- /dev/null
+++ b/test/e2e/flake.nix
@@ -0,0 +1,69 @@
+{
+ description = "realtime-check — Supabase Realtime end-to-end test CLI";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+ flake-utils.url = "github:numtide/flake-utils";
+ };
+
+ outputs = { self, nixpkgs, flake-utils }:
+ flake-utils.lib.eachDefaultSystem (system:
+ let
+ pkgs = nixpkgs.legacyPackages.${system};
+ in
+ let
+ src = pkgs.lib.cleanSourceWith {
+ src = ./.;
+ filter = path: type:
+ let baseName = baseNameOf path;
+ in baseName != "node_modules" && baseName != "result" && baseName != "realtime-check" && baseName != ".env";
+ };
+
+ node_modules = pkgs.stdenv.mkDerivation {
+ name = "realtime-check-node-modules";
+ inherit src;
+ nativeBuildInputs = [ pkgs.bun ];
+ buildPhase = ''
+ export HOME=$TMPDIR
+ bun install --frozen-lockfile
+ '';
+ installPhase = "cp -r node_modules $out";
+ outputHashMode = "recursive";
+ outputHashAlgo = "sha256";
+ outputHash = "sha256-9BL1urw6rGJ7qhd72NIU6TBBVrYuKfcl6z2J/WeXZ0E=";
+ };
+ in {
+ packages.default = pkgs.stdenv.mkDerivation {
+ pname = "realtime-check";
+ version = "0.0.1";
+ inherit src;
+ nativeBuildInputs = [ pkgs.bun ];
+
+ # Bun's `--compile` output is a self-contained binary that appends a
+ # JavaScript blob to the end of the ELF image. Nix's default fixupPhase
+ # runs patchelf (to rewrite the interpreter / RPATH) and strip, both of
+ # which rewrite ELF section layout and corrupt the trailing blob —
+ # causing the binary to fall back to the bare Bun CLI at runtime.
+ # The Bun runtime is statically linked and needs no patching, so we
+ # disable the entire fixup phase.
+ dontFixup = true;
+ dontPatchELF = true;
+ dontStrip = true;
+
+ buildPhase = ''
+ export HOME=$TMPDIR
+ cp -r ${node_modules} node_modules
+ chmod -R u+w node_modules
+ bun build --compile --minify-syntax --minify-whitespace --minify-identifiers realtime-check.ts --outfile realtime-check
+ '';
+ installPhase = ''
+ install -Dm755 realtime-check $out/bin/realtime-check
+ '';
+ };
+
+ devShells.default = pkgs.mkShell {
+ buildInputs = [ pkgs.bun ];
+ };
+ }
+ );
+}
diff --git a/test/e2e/legacy/.tool-versions b/test/e2e/legacy/.tool-versions
new file mode 100644
index 000000000..ae794d2c3
--- /dev/null
+++ b/test/e2e/legacy/.tool-versions
@@ -0,0 +1 @@
+deno latest
diff --git a/test/e2e/legacy/README.md b/test/e2e/legacy/README.md
new file mode 100644
index 000000000..1b6cd1cad
--- /dev/null
+++ b/test/e2e/legacy/README.md
@@ -0,0 +1,106 @@
+# Deno tests (legacy)
+
+The original Deno-based tests require manual database setup before running.
+
+## Project environment
+
+Run the following SQL against your project before running the tests:
+
+```sql
+CREATE TABLE public.pg_changes (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ value text NOT NULL DEFAULT gen_random_uuid ()
+);
+
+CREATE TABLE public.dummy (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ value text NOT NULL DEFAULT gen_random_uuid ()
+);
+
+CREATE TABLE public.authorization (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ value text NOT NULL DEFAULT gen_random_uuid ()
+);
+
+CREATE TABLE public.broadcast_changes (
+ id text PRIMARY KEY,
+ value text NOT NULL
+);
+
+CREATE TABLE public.wallet (
+ id text PRIMARY KEY,
+ wallet_id text NOT NULL
+);
+INSERT INTO public.wallet (id, wallet_id) VALUES (1, 'wallet_1');
+
+ALTER TABLE public.pg_changes ENABLE ROW LEVEL SECURITY;
+
+ALTER TABLE public.authorization ENABLE ROW LEVEL SECURITY;
+
+ALTER TABLE public.broadcast_changes ENABLE ROW LEVEL SECURITY;
+
+ALTER TABLE public.wallet ENABLE ROW LEVEL SECURITY;
+
+ALTER PUBLICATION supabase_realtime
+ ADD TABLE public.pg_changes;
+
+ALTER PUBLICATION supabase_realtime
+ ADD TABLE public.dummy;
+
+CREATE POLICY "authenticated receive on topic" ON "realtime"."messages" AS PERMISSIVE
+ FOR SELECT TO authenticated
+ USING ( realtime.topic() like 'topic:%');
+
+CREATE POLICY "authenticated broadcast on topic" ON "realtime"."messages" AS PERMISSIVE
+ FOR INSERT TO authenticated
+ WITH CHECK ( realtime.topic() like 'topic:%');
+
+CREATE POLICY "authenticated jwt topic in wallet can receive" ON "realtime"."messages" AS PERMISSIVE
+ FOR SELECT TO authenticated
+ USING ( realtime.topic() like 'jwt_topic:%' AND exists (select wallet_id from public.wallet where wallet_id = (auth.jwt() -> 'sub')::text));
+
+CREATE POLICY "authenticated jwt topic in wallet can broadcast" ON "realtime"."messages" AS PERMISSIVE
+ FOR INSERT TO authenticated
+ WITH CHECK ( realtime.topic() like 'jwt_topic:%' AND exists (select wallet_id from public.wallet where wallet_id = (auth.jwt() -> 'sub')::text));
+
+CREATE POLICY "allow authenticated users all access" ON "public"."pg_changes" AS PERMISSIVE
+ FOR ALL TO authenticated
+ USING (TRUE);
+
+CREATE POLICY "authenticated have full access to read on broadcast_changes" ON "public"."broadcast_changes" AS PERMISSIVE
+ FOR ALL TO authenticated
+ USING (TRUE);
+
+CREATE OR REPLACE FUNCTION broadcast_changes_for_table_trigger ()
+ RETURNS TRIGGER
+ AS $$
+DECLARE
+ topic text;
+BEGIN
+ topic = 'topic:test';
+ PERFORM
+ realtime.broadcast_changes (topic, TG_OP, TG_OP, TG_TABLE_NAME, TG_TABLE_SCHEMA, NEW, OLD, TG_LEVEL);
+ RETURN NULL;
+END;
+$$
+LANGUAGE plpgsql;
+
+CREATE TRIGGER broadcast_changes_for_table_public_broadcast_changes_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON broadcast_changes
+ FOR EACH ROW
+ EXECUTE FUNCTION broadcast_changes_for_table_trigger ();
+
+INSERT INTO "auth"."users" ("instance_id", "id", "aud", "role", "email", "encrypted_password", "email_confirmed_at", "invited_at", "confirmation_token", "confirmation_sent_at", "recovery_token", "recovery_sent_at", "email_change_token_new", "email_change", "email_change_sent_at", "last_sign_in_at", "raw_app_meta_data", "raw_user_meta_data", "is_super_admin", "created_at", "updated_at", "phone", "phone_confirmed_at", "phone_change", "phone_change_token", "phone_change_sent_at", "email_change_token_current", "email_change_confirm_status", "banned_until", "reauthentication_token", "reauthentication_sent_at", "is_sso_user", "deleted_at", "is_anonymous") VALUES ('00000000-0000-0000-0000-000000000000', '93c8bc43-c330-4702-aef2-4ba2c298950a', 'authenticated', 'authenticated', 'filipe@supabase.io', '$2a$10$WQ4tbkMVuS2OUmkX.LRC0uRwH6bU39CbI5bdHuLi82UXhUsjhrLP.', '2025-04-03 03:51:28.207805+00', null, '', '2025-04-03 03:50:59.085609+00', '', null, '', '', null, '2025-04-03 08:01:19.813327+00', '{"provider": "email", "providers": ["email"]}', '{"sub": "92c8bc43-c330-4702-aef2-4ba2c298950a", "email": "filipe@supabase.io", "email_verified": true, "phone_verified": false}', null, '2025-04-03 03:50:59.038087+00', '2025-04-03 22:09:10.979685+00', null, null, '', '', null, '', '0', null, '', null, 'false', null, 'false');
+```
+
+## Test environment
+
+Create `.env` based on `.env.template` with:
+- `PROJECT_URL` — URL for the project
+- `PROJECT_ANON_TOKEN` — Anon authentication token for the project
+
+## Run
+
+```bash
+deno test tests.ts --allow-read --allow-net --trace-leaks --allow-env=WS_NO_BUFFER_UTIL
+```
diff --git a/test/e2e/tests.ts b/test/e2e/legacy/tests.ts
similarity index 98%
rename from test/e2e/tests.ts
rename to test/e2e/legacy/tests.ts
index 2711a959e..4193b06c2 100644
--- a/test/e2e/tests.ts
+++ b/test/e2e/legacy/tests.ts
@@ -1,8 +1,5 @@
import { load } from "https://deno.land/std@0.224.0/dotenv/mod.ts";
-import {
- createClient,
- SupabaseClient,
-} from "npm:@supabase/supabase-js@2.49.5-next.5";
+import { createClient, SupabaseClient } from "npm:@supabase/supabase-js@latest";
import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts";
import {
describe,
@@ -69,11 +66,7 @@ describe("broadcast extension", () => {
while (activeChannel.state == "joining") await sleep(0.2);
// Send from unsubscribed channel
- supabase.channel(topic, config).send({
- type: "broadcast",
- event,
- payload: expectedPayload,
- });
+ supabase.channel(topic, config).httpSend(event, expectedPayload);
while (result == null) await sleep(0.2);
diff --git a/test/e2e/nix-build.sh b/test/e2e/nix-build.sh
new file mode 100755
index 000000000..2371f642c
--- /dev/null
+++ b/test/e2e/nix-build.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+FLAKE="flake.nix"
+FAKE_HASH="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
+
+hash_count=$(grep -c 'outputHash = "sha256-' "$FLAKE")
+[[ "$hash_count" -eq 1 ]] || { echo "Expected exactly one outputHash line, found $hash_count"; exit 1; }
+
+trap 'git checkout -- "$FLAKE" 2>/dev/null || true' ERR
+
+update_flake_hash() {
+ local pattern="$1" replacement="$2"
+ sed -i.bak "s|outputHash = \"${pattern}\";|outputHash = \"${replacement}\";|" "$FLAKE"
+ rm -f "${FLAKE}.bak"
+}
+
+update_flake_hash "sha256-.*" "$FAKE_HASH"
+
+echo "Probing for correct node_modules hash..."
+NIX_OUT=$(nix build 2>&1 || true)
+
+REAL_HASH=$(echo "$NIX_OUT" | grep -Eo 'got:[[:space:]]+(sha256|sha512|sha1)-[A-Za-z0-9+/=]{20,}' | awk '{print $2}' | head -n1)
+
+if [[ -z "$REAL_HASH" ]]; then
+ if echo "$NIX_OUT" | grep -q "error:"; then
+ echo "Build failed:"
+ echo "$NIX_OUT"
+ exit 1
+ fi
+ echo "Hash was already correct. Build succeeded."
+ echo "Done. Binary available at ./result/bin/realtime-check"
+ exit 0
+fi
+
+echo "Updating hash to: $REAL_HASH"
+update_flake_hash "$FAKE_HASH" "$REAL_HASH"
+
+echo "Building with correct hash..."
+trap - ERR
+nix build
+echo "Done. Binary available at ./result/bin/realtime-check"
diff --git a/test/e2e/package.json b/test/e2e/package.json
new file mode 100644
index 000000000..9b04ddb6f
--- /dev/null
+++ b/test/e2e/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "realtime-check",
+ "version": "0.0.1",
+ "dependencies": {
+ "@opentelemetry/api": "^1.9.1",
+ "@opentelemetry/context-async-hooks": "^2.7.1",
+ "@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
+ "@opentelemetry/resources": "^2.7.1",
+ "@opentelemetry/sdk-trace-base": "^2.7.1",
+ "@opentelemetry/semantic-conventions": "^1.41.1",
+ "@supabase/supabase-js": "2.108.2",
+ "commander": "^14.0.3",
+ "kleur": "^4.1.5"
+ },
+ "scripts": {
+ "check": "bun run realtime-check.ts --",
+ "build": "bun build --compile --minify-syntax --minify-whitespace --minify-identifiers realtime-check.ts --outfile realtime-check",
+ "nix": "bash nix-build.sh"
+ }
+}
diff --git a/test/e2e/realtime-check.ts b/test/e2e/realtime-check.ts
new file mode 100644
index 000000000..0414abacd
--- /dev/null
+++ b/test/e2e/realtime-check.ts
@@ -0,0 +1,1541 @@
+#!/usr/bin/env bun
+import assert from "assert";
+import { createClient, SupabaseClient } from "@supabase/supabase-js";
+import { Command } from "commander";
+import kleur from "kleur";
+import { SQL } from "bun";
+import { trace, context, SpanStatusCode, SpanKind, ROOT_CONTEXT } from "@opentelemetry/api";
+import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
+import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
+import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
+import { resourceFromAttributes } from "@opentelemetry/resources";
+import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
+
+const program = new Command()
+ .name("realtime-check")
+ .description("End-to-end Realtime test suite against any Supabase project")
+ .option("--project ][", "Supabase project ref (required for staging/prod)")
+ .option("--publishable-key ", "Project publishable (anon) key")
+ .option("--secret-key ", "Project secret (service role) key")
+ .option("--db-password ", "Database password (required for staging/prod)")
+ .option("--env ", "Environment: local | staging | development | prod | production (default: prod)", "prod")
+ .option("--domain ", "Email domain for the test user", "example.com")
+ .option("--port ", "Override URL port (useful for local)")
+ .option("--url ", "Override project URL (e.g. http://127.0.0.1:54321)")
+ .option("--db-url ", "Override database URL (e.g. postgresql://postgres:postgres@127.0.0.1:54322/postgres)")
+ .option("--json", "Output results as JSON to stdout")
+ .option("--otel ", "OTLP HTTP endpoint for tracing (e.g. http://localhost:4318)")
+ .option("--otel-token ", "Bearer token for authenticated OTLP endpoints")
+ .option("--test ", "Comma-separated list of test categories to run: functional,load,connection,load-postgres-changes,load-presence,load-broadcast,load-broadcast-from-db,load-broadcast-replay,broadcast,broadcast-replay,presence,authorization,postgres-changes,postgres-changes-filters,broadcast-changes,broadcast-binary")
+ .option("--debug", "Enable Realtime client debug mode (sets log level to info and enables console logging)")
+ .parse();
+
+const opts = program.opts();
+const ANON_KEY: string = opts.publishableKey;
+const SERVICE_KEY: string = opts.secretKey;
+const dbPassword: string = opts.dbPassword ?? "";
+const { project, domain: EMAIL_DOMAIN, port, json: JSON_OUTPUT, test: TEST_FILTER, otel: OTEL_ARG, otelToken: OTEL_API_TOKEN, url: URL_ARG, dbUrl: DB_URL_ARG, debug: DEBUG } = opts;
+const env: string = opts.env === "production" ? "prod" : opts.env === "development" ? "staging" : opts.env;
+
+const TEST_CATEGORIES = TEST_FILTER
+ ? TEST_FILTER.split(",").map((s: string) => s.trim().toLowerCase())
+ : null;
+
+if (env !== "local" && !project && !(URL_ARG && DB_URL_ARG)) {
+ console.error("--project is required (or provide both --url and --db-url)");
+ process.exit(1);
+}
+if (!ANON_KEY) {
+ console.error("--publishable-key is required");
+ process.exit(1);
+}
+
+const PROJECT_URL = URL_ARG ?? (() => {
+ if (env === "local") return `http://localhost:${port ?? 54321}`;
+ if (env === "staging") return `https://${project}.supabase.red`;
+ return `https://${project}.supabase.co`;
+})();
+
+const DB_URL = DB_URL_ARG ?? (() => {
+ const pw = encodeURIComponent(dbPassword ?? "postgres");
+ if (env === "local") return `postgresql://postgres:${pw}@localhost:${port ?? 54322}/postgres`;
+ if (env === "staging") return `postgresql://postgres:${pw}@db.${project}.supabase.red:5432/postgres`;
+ return `postgresql://postgres:${pw}@db.${project}.supabase.co:5432/postgres`;
+})();
+
+const DB_SSL = env !== "local" ? { rejectUnauthorized: false } : false;
+
+const realtimeLogger = DEBUG
+ ? (kind: string, msg: string, data?: any) => {
+ if (data !== undefined) console.error(`[realtime] ${kind}: ${msg}`, data);
+ else console.error(`[realtime] ${kind}: ${msg}`);
+ }
+ : undefined;
+
+const REALTIME_OPTS = { heartbeatIntervalMs: 5000, timeout: 5000, ...(DEBUG ? { logger: realtimeLogger, logLevel: "info" } : {}) };
+const REALTIME_OPTS_REPLAY = { heartbeatIntervalMs: 5000, timeout: 10000, ...(DEBUG ? { logger: realtimeLogger, logLevel: "info" } : {}) };
+const BROADCAST_CONFIG = { config: { broadcast: { self: true } } };
+const EVENT_TIMEOUT_MS = 8000;
+const RATE_LIMIT_PAUSE_MS = 2000;
+const BROADCAST_API_HEADERS = {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${ANON_KEY}`,
+ "apikey": ANON_KEY,
+};
+const LOAD_MESSAGES = 20;
+const LOAD_SETTLE_MS = 5000;
+const LOAD_DELIVERY_SLO = 99;
+
+const OTEL_ENDPOINT = OTEL_ARG;
+
+let tracer = trace.getTracer("realtime-check");
+let otelProvider: BasicTracerProvider | null = null;
+
+function initOtel() {
+ if (!OTEL_ENDPOINT) return;
+ const contextManager = new AsyncLocalStorageContextManager();
+ contextManager.enable();
+ context.setGlobalContextManager(contextManager);
+ const provider = new BasicTracerProvider({
+ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: "realtime-check" }),
+ spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({
+ url: `${OTEL_ENDPOINT}/v1/traces`,
+ ...(OTEL_API_TOKEN ? { headers: { Authorization: `Bearer ${OTEL_API_TOKEN}` } } : {}),
+ }))],
+ });
+ trace.setGlobalTracerProvider(provider);
+ tracer = trace.getTracer("realtime-check", "0.0.1");
+ otelProvider = provider;
+}
+
+async function flushOtel() {
+ if (otelProvider) await otelProvider.forceFlush();
+}
+
+function patchFetch() {
+ if (!OTEL_ENDPOINT) return;
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = (async function tracedFetch(input: RequestInfo | URL, init?: RequestInit): Promise {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
+ if (url.includes("/rest/v1") || url.includes("/auth/v1/logout") || url.includes("/auth/v1/admin")) return originalFetch(input, init);
+ const method = (init?.method ?? (typeof input === "object" && "method" in input ? input.method : undefined) ?? "GET").toUpperCase();
+ const span = tracer.startSpan(`HTTP ${method}`, {
+ kind: SpanKind.CLIENT,
+ attributes: { "http.method": method, "http.url": url },
+ }, context.active());
+ return context.with(trace.setSpan(context.active(), span), async () => {
+ try {
+ const res = await originalFetch(input, init);
+ span.setAttribute("http.status_code", res.status);
+ if (res.status >= 400) span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${res.status}` });
+ return res;
+ } catch (e: unknown) {
+ const msg = e instanceof Error ? e.message : String(e);
+ span.setStatus({ code: SpanStatusCode.ERROR, message: msg });
+ if (e instanceof Error) span.recordException(e);
+ throw e;
+ } finally {
+ span.end();
+ }
+ });
+ }) as typeof fetch;
+}
+
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+const randomTopic = () => "topic:" + crypto.randomUUID();
+const fmtSqlResult = (result: any[]) => {
+ const count = (result as any).count ?? result.length;
+ return result.length > 0 ? `count=${count} rows=${JSON.stringify(result)}` : `count=${count}`;
+};
+const runSql = (label: string, query: Promise): Promise =>
+ query
+ .then((r) => { log(kleur.dim(`setup: ${label} ok (${fmtSqlResult(r)})`)); return r; })
+ .catch((e: unknown) => { log(kleur.red(`setup: ${label} FAILED: ${e instanceof Error ? e.message : String(e)}`)); throw e; });
+const settle = async (getCount: () => number, expected: number, timeoutMs: number) => {
+ const deadline = performance.now() + timeoutMs;
+ while (getCount() < expected && performance.now() < deadline) await sleep(50);
+};
+const log = (...args: unknown[]) => JSON_OUTPUT ? process.stderr.write(args.map(String).join(" ") + "\n") : console.log(...args);
+
+function measureThroughput(latencies: number[], total: number, label: string, slo: number): Metric[] {
+ const delivered = latencies.length;
+ const deliveryRate = (delivered / total) * 100;
+ const sorted = latencies.slice().sort((a, b) => a - b);
+ if (delivered < total) log(` ${kleur.yellow(`lost ${total - delivered}/${total} ${label}`)}`);
+ assert(deliveryRate >= slo, `Delivery rate ${deliveryRate.toFixed(1)}% below ${slo}% SLO`);
+ return [
+ { label: "delivered", value: deliveryRate, unit: "%" },
+ { label: "p50", value: sorted[Math.ceil(sorted.length * 0.5) - 1] ?? 0, unit: "ms" },
+ { label: "p95", value: sorted[Math.ceil(sorted.length * 0.95) - 1] ?? 0, unit: "ms" },
+ { label: "p99", value: sorted[Math.ceil(sorted.length * 0.99) - 1] ?? 0, unit: "ms" },
+ ];
+}
+
+type Metric = { label: string; value: number; unit: string };
+type TestResult = { suite: string; name: string; passed: boolean; durationMs: number; metrics: Metric[]; error?: string };
+
+let currentSuite = "";
+const results: TestResult[] = [];
+
+async function test(name: string, fn: () => Promise) {
+ const start = performance.now();
+ const span = tracer.startSpan(name, {
+ kind: SpanKind.INTERNAL,
+ attributes: { "suite": currentSuite, "env": env, "project.url": PROJECT_URL },
+ });
+ const testContext = trace.setSpan(ROOT_CONTEXT, span);
+ try {
+ const metrics = await context.with(testContext, fn);
+ const durationMs = performance.now() - start;
+ for (const m of metrics) span.setAttribute(`metric.${m.label}`, `${m.value.toFixed(2)}${m.unit}`);
+ span.setStatus({ code: SpanStatusCode.OK });
+ results.push({ suite: currentSuite, name, passed: true, durationMs, metrics });
+ const summary = metrics.map((m) => `${kleur.dim(m.label + ":")} ${kleur.cyan(`${m.value.toFixed(m.unit === "%" ? 1 : 0)}${m.unit}`)}`).join(" ");
+ log(`${kleur.green("PASS")} ${kleur.dim(currentSuite)} / ${name} ${kleur.dim(durationMs.toFixed(0) + "ms")}${summary ? " " + summary : ""}`);
+ } catch (e: any) {
+ const durationMs = performance.now() - start;
+ span.setStatus({ code: SpanStatusCode.ERROR, message: e?.message ?? String(e) });
+ span.recordException(e);
+ results.push({ suite: currentSuite, name, passed: false, durationMs, metrics: [], error: e?.message ?? String(e) });
+ log(`${kleur.red("FAIL")} ${kleur.dim(currentSuite)} / ${name} ${kleur.dim(durationMs.toFixed(0) + "ms")} ${kleur.red(e?.message ?? e)}`);
+ if (e?.stack) log(kleur.dim(e.stack));
+ } finally {
+ span.end();
+ }
+}
+
+function suite(name: string) {
+ currentSuite = name;
+}
+
+async function waitFor(getter: () => T | null, label: string): Promise<{ value: T; latencyMs: number }> {
+ const span = tracer.startSpan(`wait: ${label}`, { kind: SpanKind.INTERNAL });
+ const start = performance.now();
+ const deadline = start + EVENT_TIMEOUT_MS;
+ let value: T | null;
+ return context.with(trace.setSpan(context.active(), span), async () => {
+ while ((value = getter()) === null && performance.now() < deadline) await sleep(50);
+ const latencyMs = performance.now() - start;
+ if (value === null) {
+ const msg = `Timed out waiting for ${label} (${latencyMs.toFixed(0)}ms)`;
+ span.setStatus({ code: SpanStatusCode.ERROR, message: msg });
+ span.end();
+ throw new Error(msg);
+ }
+ span.setAttribute("latency_ms", latencyMs);
+ span.setStatus({ code: SpanStatusCode.OK });
+ span.end();
+ return { value, latencyMs };
+ });
+}
+
+async function stopClient(supabase: SupabaseClient) {
+ await Promise.all([supabase.removeAllChannels(), supabase.auth.stopAutoRefresh()]);
+ const { error } = await supabase.auth.signOut();
+ if (error) log(kleur.dim(`stopClient signOut: ${error.message}`));
+}
+
+async function signInUser(supabase: SupabaseClient, email: string, password: string) {
+ const span = tracer.startSpan("sign in", { kind: SpanKind.INTERNAL });
+ return context.with(trace.setSpan(context.active(), span), async () => {
+ const { data, error } = await supabase.auth.signInWithPassword({ email, password });
+ if (error) {
+ span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
+ span.end();
+ throw new Error(`Error signing in: ${error.message}`);
+ }
+ span.setStatus({ code: SpanStatusCode.OK });
+ span.end();
+ return data!.session!.access_token;
+ });
+}
+
+async function waitForSubscribed(channel: ReturnType): Promise {
+ const span = tracer.startSpan("wait: subscribe", { kind: SpanKind.INTERNAL });
+ const start = performance.now();
+ const deadline = start + EVENT_TIMEOUT_MS;
+ return context.with(trace.setSpan(context.active(), span), async () => {
+ while (channel.state === "joining" && performance.now() < deadline) await sleep(50);
+ const latencyMs = performance.now() - start;
+ if (channel.state !== "joined") {
+ const msg = `Channel failed to subscribe (topic: ${channel.topic}, state: ${channel.state}, elapsed: ${latencyMs.toFixed(0)}ms)`;
+ span.setStatus({ code: SpanStatusCode.ERROR, message: msg });
+ span.end();
+ throw new Error(msg);
+ }
+ span.setAttribute("latency_ms", latencyMs);
+ span.setStatus({ code: SpanStatusCode.OK });
+ span.end();
+ return latencyMs;
+ });
+}
+
+// Subscribes a channel and waits until it is fully joined.
+// All data operations must happen after this returns to avoid delivery races.
+async function openChannel(channel: ReturnType): Promise {
+ channel.subscribe();
+ return waitForSubscribed(channel);
+}
+
+// Subscribes a postgres_changes channel and waits for both the join and the
+// system:ok confirmation that the server-side WAL subscription is active.
+async function openPostgresChannel(channel: ReturnType): Promise<{ subscribeMs: number; systemMs: number }> {
+ const start = performance.now();
+ let systemOk = false;
+ channel.on("system", "*", ({ status }: { status: string }) => { if (status === "ok") systemOk = true; });
+ const subscribeMs = await openChannel(channel);
+ const { latencyMs: systemMs } = await waitFor(() => systemOk ? true : null, "system ok");
+ return { subscribeMs, systemMs: performance.now() - start };
+}
+
+type TableName = "pg_changes" | "dummy" | "authorization" | "broadcast_changes" | "wallet" | "replay_check";
+
+async function executeInsert(supabase: SupabaseClient, table: TableName, value?: string): Promise {
+ const { data, error } = await supabase.from(table).insert([{ value: value ?? crypto.randomUUID() }]).select("id");
+ if (error) throw new Error(`Error inserting into ${table}: ${error.message}`);
+ return (data as { id: number }[])[0].id;
+}
+
+async function executeUpdate(supabase: SupabaseClient, table: TableName, id: number) {
+ const { error } = await supabase.from(table).update({ value: crypto.randomUUID() }).eq("id", id);
+ if (error) throw new Error(`Error updating ${table}: ${error.message}`);
+}
+
+async function executeDelete(supabase: SupabaseClient, table: TableName, id: number) {
+ const { error } = await supabase.from(table).delete().eq("id", id);
+ if (error) throw new Error(`Error deleting from ${table}: ${error.message}`);
+}
+
+async function setup(): Promise<{ userId: string; testUser: { email: string; password: string }; supabase: SupabaseClient }> {
+ const start = performance.now();
+ const email = `realtime-check-${crypto.randomUUID()}@${EMAIL_DOMAIN}`;
+ const password = crypto.randomUUID();
+
+ log("setup: connecting to database");
+ const sql = new SQL(DB_URL, { tls: DB_SSL || undefined });
+ let userId: string;
+ try {
+ let stepStart = performance.now();
+ log(kleur.dim("setup: truncating existing tables"));
+ await Promise.allSettled([
+ sql`TRUNCATE TABLE public.pg_changes, public.dummy, public.authorization, public.broadcast_changes, public.replay_check`.then(
+ () => log(kleur.dim("setup: truncate ok")),
+ (e: unknown) => log(kleur.dim(`setup: truncate skipped (${e instanceof Error ? e.message : String(e)})`))
+ ),
+ ]);
+ log(kleur.dim(`setup: truncate done (${(performance.now() - stepStart).toFixed(0)}ms)`));
+
+ stepStart = performance.now();
+ log(kleur.dim("setup: creating tables"));
+ await Promise.allSettled([
+ runSql("table pg_changes", sql`CREATE TABLE IF NOT EXISTS public.pg_changes (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ value text NOT NULL DEFAULT gen_random_uuid(),
+ details text
+ )`),
+ runSql("table dummy", sql`CREATE TABLE IF NOT EXISTS public.dummy (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ value text NOT NULL DEFAULT gen_random_uuid()
+ )`),
+ runSql("table authorization", sql`CREATE TABLE IF NOT EXISTS public.authorization (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ value text NOT NULL DEFAULT gen_random_uuid()
+ )`),
+ runSql("table broadcast_changes", sql`CREATE TABLE IF NOT EXISTS public.broadcast_changes (id text PRIMARY KEY, value text NOT NULL, topic text NOT NULL)`),
+ runSql("table wallet", sql`CREATE TABLE IF NOT EXISTS public.wallet (id text PRIMARY KEY, wallet_id text NOT NULL)`),
+ runSql("table replay_check", sql`CREATE TABLE IF NOT EXISTS public.replay_check (
+ id text PRIMARY KEY,
+ topic text NOT NULL,
+ event text NOT NULL,
+ payload jsonb NOT NULL DEFAULT '{}'
+ )`),
+ ]);
+ await runSql("pg_changes details column", sql`ALTER TABLE public.pg_changes ADD COLUMN IF NOT EXISTS details text`);
+ await runSql("pg_changes nullable_value column", sql`ALTER TABLE public.pg_changes ADD COLUMN IF NOT EXISTS nullable_value text`);
+ await runSql("pg_changes replica identity", sql`ALTER TABLE public.pg_changes REPLICA IDENTITY FULL`);
+ log(kleur.dim(`setup: tables done (${(performance.now() - stepStart).toFixed(0)}ms)`));
+
+ stepStart = performance.now();
+ log(kleur.dim("setup: configuring RLS and publications"));
+ await Promise.allSettled([
+ runSql("wallet seed", sql`INSERT INTO public.wallet (id, wallet_id) VALUES ('1', 'wallet_1') ON CONFLICT (id) DO NOTHING`),
+ runSql("dummy RLS disable", sql`ALTER TABLE public.dummy DISABLE ROW LEVEL SECURITY`),
+ runSql("pg_changes RLS enable", sql`ALTER TABLE public.pg_changes ENABLE ROW LEVEL SECURITY`),
+ runSql("authorization RLS enable", sql`ALTER TABLE public.authorization ENABLE ROW LEVEL SECURITY`),
+ runSql("broadcast_changes RLS enable", sql`ALTER TABLE public.broadcast_changes ENABLE ROW LEVEL SECURITY`),
+ runSql("wallet RLS enable", sql`ALTER TABLE public.wallet ENABLE ROW LEVEL SECURITY`),
+ runSql("replay_check RLS enable", sql`ALTER TABLE public.replay_check ENABLE ROW LEVEL SECURITY`),
+ sql`ALTER PUBLICATION supabase_realtime ADD TABLE public.pg_changes`
+ .then((r) => log(kleur.dim(`setup: publication pg_changes ok (${fmtSqlResult(r)})`)))
+ .catch((e: unknown) => log(kleur.dim(`setup: publication pg_changes skipped (${e instanceof Error ? e.message : String(e)})`))),
+ sql`ALTER PUBLICATION supabase_realtime ADD TABLE public.dummy`
+ .then((r) => log(kleur.dim(`setup: publication dummy ok (${fmtSqlResult(r)})`)))
+ .catch((e: unknown) => log(kleur.dim(`setup: publication dummy skipped (${e instanceof Error ? e.message : String(e)})`))),
+ ]);
+ log(kleur.dim(`setup: RLS and publications done (${(performance.now() - stepStart).toFixed(0)}ms)`));
+
+ stepStart = performance.now();
+ log(kleur.dim("setup: creating policies"));
+ await Promise.allSettled([
+ runSql("policy 'authenticated receive on topic'", sql`DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'authenticated receive on topic' AND tablename = 'messages' AND schemaname = 'realtime') THEN
+ CREATE POLICY "authenticated receive on topic" ON "realtime"."messages" AS PERMISSIVE
+ FOR SELECT TO authenticated USING (realtime.topic() like 'topic:%');
+ END IF;
+ END $$`),
+ runSql("policy 'authenticated broadcast on topic'", sql`DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'authenticated broadcast on topic' AND tablename = 'messages' AND schemaname = 'realtime') THEN
+ CREATE POLICY "authenticated broadcast on topic" ON "realtime"."messages" AS PERMISSIVE
+ FOR INSERT TO authenticated WITH CHECK (realtime.topic() like 'topic:%');
+ END IF;
+ END $$`),
+ runSql("policy 'allow authenticated users all access'", sql`DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'allow authenticated users all access' AND tablename = 'pg_changes' AND schemaname = 'public') THEN
+ CREATE POLICY "allow authenticated users all access" ON "public"."pg_changes" AS PERMISSIVE
+ FOR ALL TO authenticated USING (TRUE);
+ END IF;
+ END $$`),
+ runSql("policy 'authenticated have full access to read on broadcast_changes'", sql`DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'authenticated have full access to read on broadcast_changes' AND tablename = 'broadcast_changes' AND schemaname = 'public') THEN
+ CREATE POLICY "authenticated have full access to read on broadcast_changes" ON "public"."broadcast_changes" AS PERMISSIVE
+ FOR ALL TO authenticated USING (TRUE);
+ END IF;
+ END $$`),
+ runSql("policy 'authenticated have full access to replay_check'", sql`DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'authenticated have full access to replay_check' AND tablename = 'replay_check' AND schemaname = 'public') THEN
+ CREATE POLICY "authenticated have full access to replay_check" ON "public"."replay_check" AS PERMISSIVE
+ FOR ALL TO authenticated USING (TRUE) WITH CHECK (TRUE);
+ END IF;
+ END $$`),
+ ]);
+ log(kleur.dim(`setup: policies done (${(performance.now() - stepStart).toFixed(0)}ms)`));
+
+ stepStart = performance.now();
+ log(kleur.dim("setup: creating functions and triggers"));
+ await runSql("function broadcast_changes_for_table_trigger", sql`
+ CREATE OR REPLACE FUNCTION broadcast_changes_for_table_trigger() RETURNS TRIGGER AS $$
+ DECLARE topic text;
+ BEGIN
+ topic = COALESCE(NEW.topic, OLD.topic);
+ PERFORM realtime.broadcast_changes(topic, TG_OP, TG_OP, TG_TABLE_NAME, TG_TABLE_SCHEMA, NEW, OLD, TG_LEVEL);
+ RETURN NULL;
+ END;
+ $$ LANGUAGE plpgsql
+ `);
+ await runSql("broadcast_changes topic column", sql`ALTER TABLE public.broadcast_changes ADD COLUMN IF NOT EXISTS topic text NOT NULL`);
+
+ await runSql("trigger broadcast_changes_for_table_public_broadcast_changes_trigger", sql`
+ DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'broadcast_changes_for_table_public_broadcast_changes_trigger') THEN
+ CREATE TRIGGER broadcast_changes_for_table_public_broadcast_changes_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON broadcast_changes
+ FOR EACH ROW EXECUTE FUNCTION broadcast_changes_for_table_trigger();
+ END IF;
+ END $$
+ `);
+
+ await runSql("function replay_check_trigger", sql`
+ CREATE OR REPLACE FUNCTION replay_check_trigger() RETURNS TRIGGER AS $$
+ BEGIN
+ PERFORM realtime.send(NEW.payload, NEW.event, NEW.topic, true);
+ RETURN NULL;
+ END;
+ $$ LANGUAGE plpgsql
+ `);
+
+ await runSql("trigger replay_check_send_trigger", sql`
+ DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'replay_check_send_trigger') THEN
+ CREATE TRIGGER replay_check_send_trigger
+ AFTER INSERT ON public.replay_check
+ FOR EACH ROW EXECUTE FUNCTION replay_check_trigger();
+ END IF;
+ END $$
+ `);
+
+ log(kleur.dim(`setup: functions and triggers done (${(performance.now() - stepStart).toFixed(0)}ms)`));
+
+ log(kleur.dim("setup: creating test user"));
+ const admin = createClient(PROJECT_URL, SERVICE_KEY);
+ const { data, error } = await admin.auth.admin.createUser({ email, password, email_confirm: true });
+ if (error) throw new Error(`Failed to create test user: ${error.message}`);
+ userId = data.user.id;
+ log(kleur.dim(`setup: done (${(performance.now() - start).toFixed(0)}ms)`));
+ } finally {
+ await sql.close().catch(() => {});
+ }
+
+ const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS });
+ await signInUser(supabase, email, password);
+ return { userId: userId!, testUser: { email, password }, supabase };
+}
+
+async function cleanup(userId: string) {
+ log("cleanup: deleting test user");
+ const sql = new SQL(DB_URL, { tls: DB_SSL || undefined });
+ try {
+ await sql`DELETE FROM auth.users WHERE id = ${userId}`;
+ log(kleur.dim("cleanup: done"));
+ } catch (_e) {
+ log(kleur.yellow("Warning: failed to clean up test user"));
+ } finally {
+ await sql.close().catch(() => {});
+ }
+}
+
+async function runConnectionTest() {
+ suite("connection");
+
+ await test("first connect latency", async () => {
+ const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS });
+ try {
+ const channel = supabase.channel(randomTopic());
+ const connectMs = await openChannel(channel);
+ return [{ label: "connect", value: connectMs, unit: "ms" }];
+ } finally {
+ await stopClient(supabase);
+ }
+ });
+
+ await test("broadcast message throughput", async () => {
+ const MESSAGES = 50;
+ const SETTLE_MS = 3000;
+ const DELIVERY_SLO = 99;
+ const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS });
+ try {
+ const topic = randomTopic();
+ const event = "load";
+ const sendTimes = new Map();
+ const latencies: number[] = [];
+
+ const channel = supabase
+ .channel(topic, BROADCAST_CONFIG)
+ .on("broadcast", { event }, ({ payload }) => {
+ const t = sendTimes.get(payload.seq);
+ if (t !== undefined) latencies.push(performance.now() - t);
+ });
+
+ await openChannel(channel);
+
+ for (let i = 0; i < MESSAGES; i++) {
+ sendTimes.set(i, performance.now());
+ await channel.send({ type: "broadcast", event, payload: { seq: i } });
+ }
+
+ await settle(() => latencies.length, MESSAGES, SETTLE_MS);
+
+ return measureThroughput(latencies, MESSAGES, "messages", DELIVERY_SLO);
+ } finally {
+ await stopClient(supabase);
+ }
+ });
+}
+
+async function runLoadPostgresChangesTests(testUser: { email: string; password: string }) {
+ suite("load-postgres-changes");
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("postgres changes system message latency", async () => {
+ const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS });
+ try {
+ await signInUser(supabase, testUser.email, testUser.password);
+ const channel = supabase
+ .channel(randomTopic(), BROADCAST_CONFIG)
+ .on("postgres_changes", { event: "INSERT", schema: "public", table: "pg_changes" }, () => {});
+ const { systemMs } = await openPostgresChannel(channel);
+ return [{ label: "system", value: systemMs, unit: "ms" }];
+ } finally {
+ await stopClient(supabase);
+ }
+ });
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("postgres changes INSERT throughput", async () => {
+ const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS });
+ try {
+ await signInUser(supabase, testUser.email, testUser.password);
+ const sendTimes = new Map();
+ const latencies: number[] = [];
+
+ const channel = supabase
+ .channel(randomTopic(), BROADCAST_CONFIG)
+ .on("postgres_changes", { event: "INSERT", schema: "public", table: "pg_changes" }, (p) => {
+ const t = sendTimes.get(p.new.id);
+ if (t !== undefined) latencies.push(performance.now() - t);
+ });
+
+ await openPostgresChannel(channel);
+
+ for (let i = 0; i < LOAD_MESSAGES; i++) {
+ const t = performance.now();
+ const id = await executeInsert(supabase, "pg_changes");
+ sendTimes.set(id, t);
+ }
+
+ await settle(() => latencies.length, LOAD_MESSAGES, LOAD_SETTLE_MS);
+
+ return measureThroughput(latencies, LOAD_MESSAGES, "INSERT events", LOAD_DELIVERY_SLO);
+ } finally {
+ await stopClient(supabase);
+ }
+ });
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("postgres changes UPDATE throughput", async () => {
+ const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS });
+ try {
+ await signInUser(supabase, testUser.email, testUser.password);
+ const sendTimes = new Map();
+ const latencies: number[] = [];
+
+ const channel = supabase
+ .channel(randomTopic(), BROADCAST_CONFIG)
+ .on("postgres_changes", { event: "UPDATE", schema: "public", table: "pg_changes" }, (p) => {
+ const t = sendTimes.get(p.new.id);
+ if (t !== undefined) latencies.push(performance.now() - t);
+ });
+
+ await openPostgresChannel(channel);
+
+ const ids = await Promise.all(Array.from({ length: LOAD_MESSAGES }, () => executeInsert(supabase, "pg_changes")));
+
+ await Promise.all(ids.map((id) => {
+ sendTimes.set(id, performance.now());
+ return executeUpdate(supabase, "pg_changes", id);
+ }));
+
+ await settle(() => latencies.length, LOAD_MESSAGES, LOAD_SETTLE_MS);
+
+ return measureThroughput(latencies, LOAD_MESSAGES, "UPDATE events", LOAD_DELIVERY_SLO);
+ } finally {
+ await stopClient(supabase);
+ }
+ });
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("postgres changes DELETE throughput", async () => {
+ const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS });
+ try {
+ await signInUser(supabase, testUser.email, testUser.password);
+ const sendTimes = new Map();
+ const latencies: number[] = [];
+
+ const channel = supabase
+ .channel(randomTopic(), BROADCAST_CONFIG)
+ .on("postgres_changes", { event: "DELETE", schema: "public", table: "pg_changes" }, (p) => {
+ const t = sendTimes.get(p.old.id);
+ if (t !== undefined) latencies.push(performance.now() - t);
+ });
+
+ await openPostgresChannel(channel);
+
+ const ids = await Promise.all(Array.from({ length: LOAD_MESSAGES }, () => executeInsert(supabase, "pg_changes")));
+
+ await Promise.all(ids.map((id) => {
+ sendTimes.set(id, performance.now());
+ return executeDelete(supabase, "pg_changes", id);
+ }));
+
+ await settle(() => latencies.length, LOAD_MESSAGES, LOAD_SETTLE_MS);
+
+ return measureThroughput(latencies, LOAD_MESSAGES, "DELETE events", LOAD_DELIVERY_SLO);
+ } finally {
+ await stopClient(supabase);
+ }
+ });
+}
+
+async function runLoadPresenceTests() {
+ suite("load-presence");
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("presence join throughput", async () => {
+ const CLIENTS = 10;
+ const observer = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS });
+ const senders: ReturnType[] = [];
+ try {
+ const topic = randomTopic();
+ const trackTimes = new Map();
+ const latencies: number[] = [];
+
+ const observerChannel = observer
+ .channel(topic, { config: { broadcast: { self: true }, presence: { key: "observer" } } })
+ .on("presence", { event: "join" }, (e) => {
+ if (e.key === "observer") return;
+ const t = trackTimes.get(e.key);
+ if (t !== undefined) latencies.push(performance.now() - t);
+ });
+ await openChannel(observerChannel);
+
+ const clients = Array.from({ length: CLIENTS }, (_, i) => ({
+ client: createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS }),
+ key: `client-${i}`,
+ }));
+ senders.push(...clients.map((c) => c.client));
+
+ const channels = await Promise.all(clients.map(async ({ client, key }) => {
+ const ch = client.channel(topic, { config: { presence: { key } } });
+ await openChannel(ch);
+ return { ch, key };
+ }));
+
+ await Promise.all(channels.map(({ ch, key }) => {
+ trackTimes.set(key, performance.now());
+ return ch.track({ key });
+ }));
+
+ await settle(() => latencies.length, CLIENTS, LOAD_SETTLE_MS);
+
+ return measureThroughput(latencies, CLIENTS, "presence joins", LOAD_DELIVERY_SLO);
+ } finally {
+ await Promise.all(senders.map((c) => stopClient(c)));
+ await stopClient(observer);
+ }
+ });
+}
+
+async function runLoadBroadcastFromDbTests(testUser: { email: string; password: string }) {
+ suite("load-broadcast-from-db");
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("broadcast from database throughput", async () => {
+ const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS });
+ try {
+ await signInUser(supabase, testUser.email, testUser.password);
+ const testTopic = randomTopic();
+ const sendTimes = new Map();
+ const latencies: number[] = [];
+
+ const channel = supabase
+ .channel(testTopic, { config: { private: true } })
+ .on("broadcast", { event: "INSERT" }, (res) => {
+ const t = sendTimes.get(res.payload.record.id);
+ if (t !== undefined) latencies.push(performance.now() - t);
+ });
+
+ await openChannel(channel);
+
+ await Promise.all(Array.from({ length: LOAD_MESSAGES }, async () => {
+ const id = crypto.randomUUID();
+ sendTimes.set(id, performance.now());
+ await supabase.from("broadcast_changes").insert({ id, value: crypto.randomUUID(), topic: testTopic });
+ }));
+
+ await settle(() => latencies.length, LOAD_MESSAGES, LOAD_SETTLE_MS);
+
+ await supabase.from("broadcast_changes").delete().in("id", [...sendTimes.keys()]);
+
+ return measureThroughput(latencies, LOAD_MESSAGES, "broadcast-from-db events", LOAD_DELIVERY_SLO);
+ } finally {
+ await stopClient(supabase);
+ }
+ });
+}
+
+async function runLoadBroadcastTests() {
+ suite("load-broadcast");
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("broadcast self throughput", async () => {
+ const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS });
+ try {
+ const event = "load";
+ const topic = randomTopic();
+ const sendTimes = new Map();
+ const latencies: number[] = [];
+
+ const channel = supabase
+ .channel(topic, BROADCAST_CONFIG)
+ .on("broadcast", { event }, ({ payload }) => {
+ const t = sendTimes.get(payload.seq);
+ if (t !== undefined) latencies.push(performance.now() - t);
+ });
+
+ await openChannel(channel);
+
+ for (let i = 0; i < LOAD_MESSAGES; i++) {
+ sendTimes.set(i, performance.now());
+ await channel.send({ type: "broadcast", event, payload: { seq: i } });
+ }
+
+ await settle(() => latencies.length, LOAD_MESSAGES, LOAD_SETTLE_MS);
+
+ return measureThroughput(latencies, LOAD_MESSAGES, "broadcast events", LOAD_DELIVERY_SLO);
+ } finally {
+ await stopClient(supabase);
+ }
+ });
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("broadcast API endpoint throughput", async () => {
+ const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS });
+ try {
+ const event = "load";
+ const topic = randomTopic();
+ const sendTimes = new Map();
+ const latencies: number[] = [];
+
+ const channel = supabase
+ .channel(topic, BROADCAST_CONFIG)
+ .on("broadcast", { event }, ({ payload }) => {
+ const t = sendTimes.get(payload.seq);
+ if (t !== undefined) latencies.push(performance.now() - t);
+ });
+
+ await openChannel(channel);
+
+ await Promise.all(Array.from({ length: LOAD_MESSAGES }, async (_, i) => {
+ sendTimes.set(i, performance.now());
+ const res = await fetch(`${PROJECT_URL}/realtime/v1/api/broadcast`, {
+ method: "POST",
+ headers: BROADCAST_API_HEADERS,
+ body: JSON.stringify({ messages: [{ topic, event, payload: { seq: i } }] }),
+ });
+ if (!res.ok) throw new Error(`Broadcast API returned ${res.status}`);
+ }));
+
+ await settle(() => latencies.length, LOAD_MESSAGES, LOAD_SETTLE_MS);
+
+ return measureThroughput(latencies, LOAD_MESSAGES, "broadcast API events", LOAD_DELIVERY_SLO);
+ } finally {
+ await stopClient(supabase);
+ }
+ });
+}
+
+async function runLoadBroadcastReplayTests(testUser: { email: string; password: string }) {
+ suite("load-broadcast-replay");
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("broadcast replay throughput", async () => {
+ const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS_REPLAY });
+ try {
+ await signInUser(supabase, testUser.email, testUser.password);
+ const event = crypto.randomUUID();
+ const topic = randomTopic();
+
+ const since = Date.now() - 1000;
+ await Promise.all(Array.from({ length: LOAD_MESSAGES }, (_, i) =>
+ supabase.from("replay_check").insert({ id: crypto.randomUUID(), topic, event, payload: { seq: i } })
+ ));
+
+ const latencies: number[] = [];
+ const replayStart = performance.now();
+ const receiver = supabase.channel(topic, {
+ config: { private: true, broadcast: { replay: { since, limit: 25 } } },
+ }).on("broadcast", { event }, () => {
+ latencies.push(performance.now() - replayStart);
+ });
+ await openChannel(receiver);
+
+ await settle(() => latencies.length, LOAD_MESSAGES, LOAD_SETTLE_MS);
+
+ return measureThroughput(latencies, LOAD_MESSAGES, "replayed broadcast events", LOAD_DELIVERY_SLO);
+ } finally {
+ await stopClient(supabase);
+ }
+ });
+}
+
+
+async function runBroadcastTests() {
+ suite("broadcast extension");
+
+ await test("user is able to receive self broadcast", async () => {
+ const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS });
+ try {
+ let result: any = null;
+ const event = crypto.randomUUID();
+ const topic = randomTopic();
+ const expectedPayload = { message: crypto.randomUUID() };
+
+ const channel = supabase
+ .channel(topic, BROADCAST_CONFIG)
+ .on("broadcast", { event }, ({ payload }) => (result = payload));
+
+ const subscribeMs = await openChannel(channel);
+ await channel.send({ type: "broadcast", event, payload: expectedPayload });
+ const { latencyMs: eventMs } = await waitFor(() => result, "broadcast event");
+
+ assert.deepStrictEqual(result, expectedPayload);
+ return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }];
+ } finally {
+ await stopClient(supabase);
+ }
+ });
+
+ await test("user is able to use the endpoint to broadcast", async () => {
+ const supabase = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS });
+ try {
+ let result: any = null;
+ const event = crypto.randomUUID();
+ const topic = randomTopic();
+ const expectedPayload = { message: crypto.randomUUID() };
+
+ const channel = supabase
+ .channel(topic, BROADCAST_CONFIG)
+ .on("broadcast", { event }, ({ payload }) => (result = payload));
+
+ const subscribeMs = await openChannel(channel);
+ // Small settle window so server-side subscription routing is ready before the HTTP broadcast arrives.
+ await sleep(100);
+
+ const res = await fetch(`${PROJECT_URL}/realtime/v1/api/broadcast`, {
+ method: "POST",
+ headers: BROADCAST_API_HEADERS,
+ body: JSON.stringify({ messages: [{ topic, event, payload: expectedPayload }] }),
+ });
+ if (!res.ok) throw new Error(`Broadcast API returned ${res.status}`);
+
+ const { latencyMs: eventMs } = await waitFor(() => result, "broadcast event");
+ assert.deepStrictEqual(result, expectedPayload);
+ return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }];
+ } finally {
+ await stopClient(supabase);
+ }
+ });
+}
+
+async function runPresenceTests(_testUser: { email: string; password: string }, supabase: SupabaseClient) {
+ suite("presence extension");
+
+ await test("user is able to receive presence updates", async () => {
+ try {
+ let joinEvent: any = null;
+ const topic = randomTopic();
+ const message = crypto.randomUUID();
+ const key = crypto.randomUUID();
+
+ const channel = supabase
+ .channel(topic, { config: { broadcast: { self: true }, presence: { key } } })
+ .on("presence", { event: "join" }, (e) => (joinEvent = e));
+
+ const subscribeMs = await openChannel(channel);
+ const trackStart = performance.now();
+ if (await channel.track({ message }, { timeout: 5000 }) === "timed out") throw new Error("track() timed out");
+ const trackMs = performance.now() - trackStart;
+ const { latencyMs: eventMs } = await waitFor(() => joinEvent, "presence join");
+
+ assert.strictEqual(joinEvent.key, key);
+ assert.strictEqual(joinEvent.newPresences[0].message, message);
+ return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "track", value: trackMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }];
+ } finally {
+ await supabase.removeAllChannels();
+ }
+ });
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("user is able to receive presence updates on private channels", async () => {
+ try {
+
+ let joinEvent: any = null;
+ const topic = randomTopic();
+ const message = crypto.randomUUID();
+ const key = crypto.randomUUID();
+
+ const channel = supabase
+ .channel(topic, { config: { private: true, broadcast: { self: true }, presence: { key } } })
+ .on("presence", { event: "join" }, (e) => (joinEvent = e));
+
+ const subscribeMs = await openChannel(channel);
+ const trackStart = performance.now();
+ if (await channel.track({ message }, { timeout: 5000 }) === "timed out") throw new Error("track() timed out");
+ const trackMs = performance.now() - trackStart;
+ const { latencyMs: eventMs } = await waitFor(() => joinEvent, "presence join");
+
+ assert.strictEqual(joinEvent.key, key);
+ assert.strictEqual(joinEvent.newPresences[0].message, message);
+ return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "track", value: trackMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }];
+ } finally {
+ await supabase.removeAllChannels();
+ }
+ });
+}
+
+async function runAuthorizationTests(_testUser: { email: string; password: string }, supabase: SupabaseClient) {
+ suite("authorization check");
+
+ await test("user using private channel cannot connect without permissions", async () => {
+ try {
+ const topic = "restricted:" + crypto.randomUUID();
+ const channel = supabase.channel(topic, { config: { private: true } }).subscribe();
+
+ const { value: finalState, latencyMs: rejectMs } = await waitFor(
+ () => channel.state !== "joining" ? channel.state : null,
+ "channel rejection"
+ );
+
+ assert.notStrictEqual(finalState, "joined", `Expected channel to be rejected but state is: ${finalState}`);
+ return [{ label: "rejection", value: rejectMs, unit: "ms" }];
+ } finally {
+ await supabase.removeAllChannels();
+ }
+ });
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("user using private channel can connect with enough permissions", async () => {
+ try {
+ const channel = supabase.channel(randomTopic(), { config: { private: true } });
+ const subscribeMs = await openChannel(channel);
+ return [{ label: "subscribe", value: subscribeMs, unit: "ms" }];
+ } finally {
+ await supabase.removeAllChannels();
+ }
+ });
+}
+
+async function runBroadcastChangesTests(_testUser: { email: string; password: string }, supabase: SupabaseClient) {
+ suite("broadcast changes");
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("authenticated user receives INSERT broadcast change", async () => {
+ try {
+ const testTopic = randomTopic();
+ const id = crypto.randomUUID();
+ const value = crypto.randomUUID();
+ let result: any = null;
+
+ const channel = supabase
+ .channel(testTopic, { config: { private: true } })
+ .on("broadcast", { event: "INSERT" }, (res) => (result = res));
+
+ const subscribeMs = await openChannel(channel);
+ await sleep(500);
+ await supabase.from("broadcast_changes").insert({ value, id, topic: testTopic });
+ const { latencyMs: eventMs } = await waitFor(() => result, "INSERT event");
+
+ assert.strictEqual(result.payload.record.id, id);
+ assert.strictEqual(result.payload.record.value, value);
+ assert.strictEqual(result.payload.old_record, null);
+ assert.strictEqual(result.payload.operation, "INSERT");
+ assert.strictEqual(result.payload.schema, "public");
+ assert.strictEqual(result.payload.table, "broadcast_changes");
+ return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }];
+ } finally {
+ await supabase.removeAllChannels();
+ }
+ });
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("authenticated user receives UPDATE broadcast change", async () => {
+ try {
+ const testTopic = randomTopic();
+ const id = crypto.randomUUID();
+ const originalValue = crypto.randomUUID();
+ const updatedValue = crypto.randomUUID();
+ let result: any = null;
+
+ const channel = supabase
+ .channel(testTopic, { config: { private: true } })
+ .on("broadcast", { event: "UPDATE" }, (res) => (result = res));
+
+ const subscribeMs = await openChannel(channel);
+ await sleep(100);
+ await supabase.from("broadcast_changes").insert({ value: originalValue, id, topic: testTopic });
+ await supabase.from("broadcast_changes").update({ value: updatedValue }).eq("id", id);
+ const { latencyMs: eventMs } = await waitFor(() => result, "UPDATE event");
+
+ assert.strictEqual(result.payload.record.id, id);
+ assert.strictEqual(result.payload.record.value, updatedValue);
+ assert.strictEqual(result.payload.old_record.id, id);
+ assert.strictEqual(result.payload.old_record.value, originalValue);
+ assert.strictEqual(result.payload.operation, "UPDATE");
+ assert.strictEqual(result.payload.schema, "public");
+ assert.strictEqual(result.payload.table, "broadcast_changes");
+ return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }];
+ } finally {
+ await supabase.removeAllChannels();
+ }
+ });
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("authenticated user receives DELETE broadcast change", async () => {
+ try {
+ const testTopic = randomTopic();
+ const id = crypto.randomUUID();
+ const value = crypto.randomUUID();
+ let result: any = null;
+
+ const channel = supabase
+ .channel(testTopic, { config: { private: true } })
+ .on("broadcast", { event: "DELETE" }, (res) => (result = res));
+
+ const subscribeMs = await openChannel(channel);
+ await sleep(100);
+ await supabase.from("broadcast_changes").insert({ value, id, topic: testTopic });
+ await supabase.from("broadcast_changes").delete().eq("id", id);
+ const { latencyMs: eventMs } = await waitFor(() => result, "DELETE event");
+
+ assert.strictEqual(result.payload.record, null);
+ assert.strictEqual(result.payload.old_record.id, id);
+ assert.strictEqual(result.payload.old_record.value, value);
+ assert.strictEqual(result.payload.operation, "DELETE");
+ assert.strictEqual(result.payload.schema, "public");
+ assert.strictEqual(result.payload.table, "broadcast_changes");
+ return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }];
+ } finally {
+ await supabase.removeAllChannels();
+ }
+ });
+}
+
+async function runPostgresChangesTests(_testUser: { email: string; password: string }, supabase: SupabaseClient) {
+ suite("postgres changes extension");
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("user receives INSERT events with filter", async () => {
+ try {
+
+ let result: unknown = null;
+ const uniqueValue = crypto.randomUUID();
+
+ const channel = supabase
+ .channel(randomTopic(), BROADCAST_CONFIG)
+ .on("postgres_changes",
+ { event: "INSERT", schema: "public", table: "pg_changes", filter: `value=eq.${uniqueValue}` },
+ (payload) => (result = payload));
+
+ const { subscribeMs } = await openPostgresChannel(channel);
+ await executeInsert(supabase, "pg_changes", uniqueValue);
+ await executeInsert(supabase, "dummy");
+ const { latencyMs: eventMs } = await waitFor(() => result, "INSERT event");
+
+ assert.strictEqual(result.eventType, "INSERT");
+ assert.strictEqual(result.new.value, uniqueValue);
+ return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }];
+ } finally {
+ await supabase.removeAllChannels();
+ }
+ });
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("user receives UPDATE events with filter", async () => {
+ try {
+
+ let result: unknown = null;
+ const mainId = await executeInsert(supabase, "pg_changes");
+ const fakeId = await executeInsert(supabase, "pg_changes");
+ const dummyId = await executeInsert(supabase, "dummy");
+
+ const channel = supabase
+ .channel(randomTopic(), BROADCAST_CONFIG)
+ .on("postgres_changes",
+ { event: "UPDATE", schema: "public", table: "pg_changes", filter: `id=eq.${mainId}` },
+ (payload) => (result = payload));
+
+ const { subscribeMs } = await openPostgresChannel(channel);
+ await Promise.all([
+ executeUpdate(supabase, "pg_changes", mainId),
+ executeUpdate(supabase, "pg_changes", fakeId),
+ executeUpdate(supabase, "dummy", dummyId),
+ ]);
+ const { latencyMs: eventMs } = await waitFor(() => result, "UPDATE event");
+
+ assert.strictEqual(result.eventType, "UPDATE");
+ assert.strictEqual(result.new.id, mainId);
+ return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }];
+ } finally {
+ await supabase.removeAllChannels();
+ }
+ });
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("user receives DELETE events with filter", async () => {
+ try {
+
+ let result: unknown = null;
+ const mainId = await executeInsert(supabase, "pg_changes");
+ const fakeId = await executeInsert(supabase, "pg_changes");
+ const dummyId = await executeInsert(supabase, "dummy");
+
+ const channel = supabase
+ .channel(randomTopic(), BROADCAST_CONFIG)
+ .on("postgres_changes",
+ { event: "DELETE", schema: "public", table: "pg_changes", filter: `id=eq.${mainId}` },
+ (payload) => (result = payload));
+
+ const { subscribeMs } = await openPostgresChannel(channel);
+ await Promise.all([
+ executeDelete(supabase, "pg_changes", mainId),
+ executeDelete(supabase, "pg_changes", fakeId),
+ executeDelete(supabase, "dummy", dummyId),
+ ]);
+ const { latencyMs: eventMs } = await waitFor(() => result, "DELETE event");
+
+ assert.strictEqual(result.eventType, "DELETE");
+ assert.strictEqual(result.old.id, mainId);
+ return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }];
+ } finally {
+ await supabase.removeAllChannels();
+ }
+ });
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("user receives INSERT, UPDATE and DELETE concurrently", async () => {
+ try {
+ let insertResult: unknown = null, updateResult: unknown = null, deleteResult: unknown = null;
+
+ const insertValue = crypto.randomUUID();
+ const updateId = await executeInsert(supabase, "pg_changes");
+ const deleteId = await executeInsert(supabase, "pg_changes");
+
+ const channel = supabase
+ .channel(randomTopic(), BROADCAST_CONFIG)
+ .on("postgres_changes", { event: "INSERT", schema: "public", table: "pg_changes", filter: `value=eq.${insertValue}` }, (p) => (insertResult = p))
+ .on("postgres_changes", { event: "UPDATE", schema: "public", table: "pg_changes", filter: `id=eq.${updateId}` }, (p) => (updateResult = p))
+ .on("postgres_changes", { event: "DELETE", schema: "public", table: "pg_changes", filter: `id=eq.${deleteId}` }, (p) => (deleteResult = p));
+
+ const { subscribeMs } = await openPostgresChannel(channel);
+
+ await Promise.all([
+ executeInsert(supabase, "pg_changes", insertValue),
+ executeUpdate(supabase, "pg_changes", updateId),
+ executeDelete(supabase, "pg_changes", deleteId),
+ ]);
+
+ const [{ latencyMs: insertMs }, { latencyMs: updateMs }, { latencyMs: deleteMs }] = await Promise.all([
+ waitFor(() => insertResult, "INSERT event"),
+ waitFor(() => updateResult, "UPDATE event"),
+ waitFor(() => deleteResult, "DELETE event"),
+ ]);
+
+ assert.strictEqual(insertResult.eventType, "INSERT");
+ assert.strictEqual(updateResult.eventType, "UPDATE");
+ assert.strictEqual(deleteResult.eventType, "DELETE");
+ return [
+ { label: "subscribe", value: subscribeMs, unit: "ms" },
+ { label: "INSERT", value: insertMs, unit: "ms" },
+ { label: "UPDATE", value: updateMs, unit: "ms" },
+ { label: "DELETE", value: deleteMs, unit: "ms" },
+ ];
+ } finally {
+ await supabase.removeAllChannels();
+ }
+ });
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("select — omitting select returns full payload (backward compatible)", async () => {
+ try {
+ let result: any = null;
+ const uniqueValue = crypto.randomUUID();
+ const details = crypto.randomUUID();
+
+ const channel = supabase
+ .channel(randomTopic(), BROADCAST_CONFIG)
+ .on("postgres_changes",
+ { event: "INSERT", schema: "public", table: "pg_changes", filter: `value=eq.${uniqueValue}` },
+ (payload) => (result = payload));
+
+ const { subscribeMs } = await openPostgresChannel(channel);
+ await supabase.from("pg_changes").insert({ value: uniqueValue, details });
+ const { latencyMs: eventMs } = await waitFor(() => result, "INSERT event");
+
+ assert.strictEqual(result.eventType, "INSERT");
+ assert.ok(result.new.id !== undefined, "id must be present");
+ assert.strictEqual(result.new.value, uniqueValue, "value must be present when no select is used");
+ assert.strictEqual(result.new.details, details, "details must be present when no select is used");
+ return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }];
+ } finally {
+ await supabase.removeAllChannels();
+ }
+ });
+
+}
+
+async function runPostgresChangesFiltersTests(_testUser: { email: string; password: string }, supabase: SupabaseClient) {
+ suite("postgres-changes-filters");
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("in: delivers row whose value is in the list", async () => {
+ try {
+ const tag = crypto.randomUUID().replace(/-/g, "");
+ const values = [`inA${tag}`, `inB${tag}`, `inC${tag}`];
+ const chosen = values[1];
+ let result: any = null;
+
+ const channel = supabase
+ .channel(randomTopic(), BROADCAST_CONFIG)
+ .on("postgres_changes", { event: "INSERT", schema: "public", table: "pg_changes", filter: `value=in.(${values.join(",")})` }, (p) => { if (p.new.value === chosen) result = p; });
+
+ const { subscribeMs } = await openPostgresChannel(channel);
+ await executeInsert(supabase, "pg_changes", chosen);
+ await waitFor(() => result, "in event");
+
+ assert.strictEqual(result.eventType, "INSERT");
+ assert.strictEqual(result.new.value, chosen);
+ return [{ label: "subscribe", value: subscribeMs, unit: "ms" }];
+ } finally {
+ await supabase.removeAllChannels();
+ }
+ });
+
+}
+
+async function runBroadcastReplayTests(_testUser: { email: string; password: string }, supabase: SupabaseClient) {
+ suite("broadcast replay");
+
+ await test("replayed messages are delivered on join", async () => {
+ try {
+ const event = crypto.randomUUID();
+ const topic = randomTopic();
+ const payload = { message: crypto.randomUUID() };
+
+ const since = Date.now() - 1000;
+ await supabase.from("replay_check").insert({ id: crypto.randomUUID(), topic, event, payload });
+
+ await sleep(500);
+
+ let result: any = null;
+ const receiver = supabase.channel(topic, {
+ config: { private: true, broadcast: { replay: { since, limit: 1 } } },
+ }).on("broadcast", { event }, (msg) => (result = msg.payload));
+ const subscribeMs = await openChannel(receiver);
+
+ const { latencyMs: replayMs } = await waitFor(() => result, "replayed broadcast event");
+
+ assert.strictEqual(result.message, payload.message);
+ return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "replay", value: replayMs, unit: "ms" }];
+ } finally {
+ await supabase.removeAllChannels();
+ }
+ });
+
+ await test("replayed messages carry meta.replayed flag", async () => {
+ try {
+ const event = crypto.randomUUID();
+ const topic = randomTopic();
+
+ const since = Date.now() - 1000;
+ await supabase.from("replay_check").insert({ id: crypto.randomUUID(), topic, event, payload: { value: 1 } });
+
+ await sleep(500);
+
+ let receivedMeta: any = null;
+ const receiver = supabase.channel(topic, {
+ config: { private: true, broadcast: { replay: { since, limit: 1 } } },
+ }).on("broadcast", { event }, (msg) => (receivedMeta = msg.meta));
+ await openChannel(receiver);
+
+ await waitFor(() => receivedMeta, "replayed broadcast meta");
+
+ assert.strictEqual(receivedMeta?.replayed, true);
+ return [];
+ } finally {
+ await supabase.removeAllChannels();
+ }
+ });
+
+ await test("messages before since are not replayed", async () => {
+ try {
+ const event = crypto.randomUUID();
+ const topic = randomTopic();
+
+ await supabase.from("replay_check").insert({ id: crypto.randomUUID(), topic, event, payload: { value: "old" } });
+
+ // Sleep to ensure the DB insert timestamp is clearly before `since`,
+ // guarding against clock skew between JS client and DB server.
+ await sleep(1000);
+ const since = Date.now();
+
+ let result: any = null;
+ const receiver = supabase.channel(topic, {
+ config: { private: true, broadcast: { replay: { since, limit: 25 } } },
+ }).on("broadcast", { event }, (msg) => (result = msg.payload));
+ await openChannel(receiver);
+
+ await sleep(500);
+
+ assert.strictEqual(result, null);
+ return [];
+ } finally {
+ await supabase.removeAllChannels();
+ }
+ });
+}
+
+
+function printSummary(totalMs: number) {
+ const passed = results.filter((r) => r.passed);
+ const failed = results.filter((r) => !r.passed);
+ const suites = [...new Set(results.map((r) => r.suite))];
+
+ if (JSON_OUTPUT) {
+ const slis: Record> = {};
+ for (const r of passed) {
+ for (const m of r.metrics) {
+ const key = `${r.suite} / ${r.name}`;
+ slis[key] ??= {};
+ slis[key][m.label] = { value: m.value, unit: m.unit };
+ }
+ }
+ const output = {
+ passed: failed.length === 0,
+ durationMs: Math.round(totalMs),
+ summary: { total: results.length, passed: passed.length, failed: failed.length },
+ slis,
+ suites: Object.fromEntries(suites.map((suite) => {
+ const suiteResults = results.filter((r) => r.suite === suite);
+ return [suite, {
+ passed: suiteResults.every((r) => r.passed),
+ tests: suiteResults.map((r) => ({
+ name: r.name,
+ passed: r.passed,
+ durationMs: Math.round(r.durationMs),
+ ...(r.error ? { error: r.error } : {}),
+ slis: Object.fromEntries(r.metrics.map((m) => [m.label, { value: m.value, unit: m.unit }])),
+ })),
+ }];
+ })),
+ };
+ process.stdout.write(JSON.stringify(output, null, 2) + "\n");
+ return;
+ }
+
+ log(`\n${kleur.bold(`${passed.length} passed, ${failed.length} failed`)} ${kleur.dim(`total ${(totalMs / 1000).toFixed(2)}s`)}`);
+
+ if (failed.length > 0) {
+ log("\nFailed:");
+ for (const r of failed) {
+ log(` ${kleur.red("✗")} ${r.suite} / ${r.name}`);
+ if (r.error) log(` ${kleur.dim(r.error)}`);
+ }
+ }
+}
+
+type SuiteCtx = { testUser: { email: string; password: string }; supabase: SupabaseClient };
+
+const SUITES: Record Promise> = {
+ "connection": () => runConnectionTest(),
+ "load-postgres-changes": ({ testUser }) => runLoadPostgresChangesTests(testUser),
+ "load-presence": () => runLoadPresenceTests(),
+ "load-broadcast": () => runLoadBroadcastTests(),
+ "load-broadcast-from-db": ({ testUser }) => runLoadBroadcastFromDbTests(testUser),
+ "load-broadcast-replay": ({ testUser }) => runLoadBroadcastReplayTests(testUser),
+ "broadcast": () => runBroadcastTests(),
+ "broadcast-replay": ({ testUser, supabase }) => runBroadcastReplayTests(testUser, supabase),
+ "presence": ({ testUser, supabase }) => runPresenceTests(testUser, supabase),
+ "authorization": ({ testUser, supabase }) => runAuthorizationTests(testUser, supabase),
+ "postgres-changes": ({ testUser, supabase }) => runPostgresChangesTests(testUser, supabase),
+ "postgres-changes-filters": ({ testUser, supabase }) => runPostgresChangesFiltersTests(testUser, supabase),
+ "broadcast-changes": ({ testUser, supabase }) => runBroadcastChangesTests(testUser, supabase),
+ "broadcast-binary": ({ supabase }) => runBroadcastBinaryTests(supabase),
+};
+
+async function runBroadcastBinaryTests(supabase: SupabaseClient) {
+ suite("broadcast binary");
+
+ await sleep(RATE_LIMIT_PAUSE_MS);
+ await test("send_binary delivers a binary broadcast", async () => {
+ const sql = new SQL(DB_URL, { tls: DB_SSL || undefined });
+ try {
+ const event = crypto.randomUUID();
+ const topic = randomTopic();
+ const binary = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0x00, 0xff]);
+
+ let result: any = null;
+ const channel = supabase
+ .channel(topic, { config: { private: true } })
+ .on("broadcast", { event }, (msg) => (result = msg.payload));
+
+ const subscribeMs = await openChannel(channel);
+ await sleep(100);
+
+ await sql`SELECT realtime.send_binary(${binary}::bytea, ${event}::text, ${topic}::text, true)`;
+
+ const { latencyMs: eventMs } = await waitFor(() => result, "binary broadcast event");
+
+ const received = result instanceof Uint8Array ? result : new Uint8Array(result);
+ assert.strictEqual(received.length, binary.length, "binary payload length mismatch");
+ assert.ok(binary.every((b, i) => received[i] === b), "binary payload bytes mismatch");
+ return [{ label: "subscribe", value: subscribeMs, unit: "ms" }, { label: "event", value: eventMs, unit: "ms" }];
+ } finally {
+ await sql.close().catch(() => {});
+ await supabase.removeAllChannels();
+ }
+ });
+}
+
+const LOAD_SUITES = Object.keys(SUITES).filter((k) => k.startsWith("load"));
+const FUNCTIONAL_SUITES = Object.keys(SUITES).filter((k) => !k.startsWith("load"));
+
+const DB_REQUIRED_SUITES = new Set([
+ "load-postgres-changes",
+ "load-broadcast-from-db",
+ "load-broadcast-replay",
+ "broadcast-replay",
+ "presence",
+ "authorization",
+ "postgres-changes",
+ "postgres-changes-filters",
+ "broadcast-changes",
+ "broadcast-binary",
+]);
+
+async function main() {
+ initOtel();
+ patchFetch();
+
+ const activeCategories = TEST_CATEGORIES
+ ? TEST_CATEGORIES.flatMap((c: string) => {
+ if (c === "functional") return FUNCTIONAL_SUITES;
+ if (c === "load") return LOAD_SUITES;
+ return [c];
+ })
+ : null;
+
+ if (activeCategories) {
+ const unknown = activeCategories.filter((c: string) => !(c in SUITES));
+ if (unknown.length > 0) {
+ const valid = ["functional", "load", ...Object.keys(SUITES)].join(", ");
+ log(`Unknown test categories: ${unknown.join(", ")}\nValid categories: ${valid}`);
+ process.exit(1);
+ }
+ }
+
+ const suitesToRun = activeCategories
+ ? Object.entries(SUITES).filter(([key]) => activeCategories.includes(key))
+ : Object.entries(SUITES);
+
+ const needsDb = suitesToRun.some(([key]) => DB_REQUIRED_SUITES.has(key));
+
+ if (needsDb && !SERVICE_KEY) {
+ console.error("--secret-key is required");
+ process.exit(1);
+ }
+
+ if (needsDb && env !== "local" && !dbPassword && !DB_URL_ARG) {
+ console.error("--db-password is required for staging and prod environments");
+ process.exit(1);
+ }
+
+ let userId: string | null = null;
+ let testUser: { email: string; password: string } = { email: "", password: "" };
+ let supabase: SupabaseClient = createClient(PROJECT_URL, ANON_KEY, { realtime: REALTIME_OPTS });
+
+ if (needsDb) {
+ const setupResult = await setup();
+ userId = setupResult.userId;
+ testUser = setupResult.testUser;
+ supabase = setupResult.supabase;
+ }
+
+ const start = performance.now();
+ try {
+ for (const [, fn] of suitesToRun) await fn({ testUser, supabase });
+ } finally {
+ await stopClient(supabase);
+ if (userId) await cleanup(userId);
+ }
+
+ printSummary(performance.now() - start);
+ await flushOtel();
+
+ if (results.some((r) => !r.passed)) process.exit(1);
+}
+
+main().catch((e) => {
+ console.error(kleur.red("Fatal error:"), e.message);
+ if (e?.stack) console.error(kleur.dim(e.stack));
+ process.exit(1);
+});
diff --git a/test/e2e/supabase/.branches/_current_branch b/test/e2e/supabase/.branches/_current_branch
new file mode 100644
index 000000000..88d050b19
--- /dev/null
+++ b/test/e2e/supabase/.branches/_current_branch
@@ -0,0 +1 @@
+main
\ No newline at end of file
diff --git a/test/e2e/supabase/.gitignore b/test/e2e/supabase/.gitignore
new file mode 100644
index 000000000..ad9264f0b
--- /dev/null
+++ b/test/e2e/supabase/.gitignore
@@ -0,0 +1,8 @@
+# Supabase
+.branches
+.temp
+
+# dotenvx
+.env.keys
+.env.local
+.env.*.local
diff --git a/test/e2e/supabase/.temp/cli-latest b/test/e2e/supabase/.temp/cli-latest
new file mode 100644
index 000000000..f98a4ce08
--- /dev/null
+++ b/test/e2e/supabase/.temp/cli-latest
@@ -0,0 +1 @@
+v2.105.0
\ No newline at end of file
diff --git a/test/e2e/supabase/config.toml b/test/e2e/supabase/config.toml
new file mode 100644
index 000000000..cfbb86938
--- /dev/null
+++ b/test/e2e/supabase/config.toml
@@ -0,0 +1,388 @@
+# For detailed configuration reference documentation, visit:
+# https://supabase.com/docs/guides/local-development/cli/config
+# A string used to distinguish different Supabase projects on the same host. Defaults to the
+# working directory name when running `supabase init`.
+project_id = "e2e"
+
+[api]
+enabled = true
+# Port to use for the API URL.
+port = 54321
+# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
+# endpoints. `public` and `graphql_public` schemas are included by default.
+schemas = ["public", "graphql_public"]
+# Extra schemas to add to the search_path of every request.
+extra_search_path = ["public", "extensions"]
+# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
+# for accidental or malicious requests.
+max_rows = 1000
+
+[api.tls]
+# Enable HTTPS endpoints locally using a self-signed certificate.
+enabled = false
+# Paths to self-signed certificate pair.
+# cert_path = "../certs/my-cert.pem"
+# key_path = "../certs/my-key.pem"
+
+[db]
+# Port to use for the local database URL.
+port = 54322
+# Port used by db diff command to initialize the shadow database.
+shadow_port = 54320
+# Maximum amount of time to wait for health check when starting the local database.
+health_timeout = "2m"
+# The database major version to use. This has to be the same as your remote database's. Run `SHOW
+# server_version;` on the remote database to check.
+major_version = 17
+
+[db.pooler]
+enabled = false
+# Port to use for the local connection pooler.
+port = 54329
+# Specifies when a server connection can be reused by other clients.
+# Configure one of the supported pooler modes: `transaction`, `session`.
+pool_mode = "transaction"
+# How many server connections to allow per user/database pair.
+default_pool_size = 20
+# Maximum number of client connections allowed.
+max_client_conn = 100
+
+# [db.vault]
+# secret_key = "env(SECRET_VALUE)"
+
+[db.migrations]
+# If disabled, migrations will be skipped during a db push or reset.
+enabled = true
+# Specifies an ordered list of schema files that describe your database.
+# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
+schema_paths = []
+
+[db.seed]
+# If enabled, seeds the database after migrations during a db reset.
+enabled = true
+# Specifies an ordered list of seed files to load during db reset.
+# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
+sql_paths = ["./seed.sql"]
+
+[db.network_restrictions]
+# Enable management of network restrictions.
+enabled = false
+# List of IPv4 CIDR blocks allowed to connect to the database.
+# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
+allowed_cidrs = ["0.0.0.0/0"]
+# List of IPv6 CIDR blocks allowed to connect to the database.
+# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
+allowed_cidrs_v6 = ["::/0"]
+
+# Uncomment to reject non-secure connections to the database.
+# [db.ssl_enforcement]
+# enabled = true
+
+[realtime]
+enabled = true
+# Bind realtime via either IPv4 or IPv6. (default: IPv4)
+# ip_version = "IPv6"
+# The maximum length in bytes of HTTP request headers. (default: 4096)
+# max_header_length = 4096
+
+[studio]
+enabled = true
+# Port to use for Supabase Studio.
+port = 54323
+# External URL of the API server that frontend connects to.
+api_url = "http://127.0.0.1"
+# OpenAI API Key to use for Supabase AI in the Supabase Studio.
+openai_api_key = "env(OPENAI_API_KEY)"
+
+# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
+# are monitored, and you can view the emails that would have been sent from the web interface.
+[inbucket]
+enabled = true
+# Port to use for the email testing server web interface.
+port = 54324
+# Uncomment to expose additional ports for testing user applications that send emails.
+# smtp_port = 54325
+# pop3_port = 54326
+# admin_email = "admin@email.com"
+# sender_name = "Admin"
+
+[storage]
+enabled = true
+# The maximum file size allowed (e.g. "5MB", "500KB").
+file_size_limit = "50MiB"
+
+# Uncomment to configure local storage buckets
+# [storage.buckets.images]
+# public = false
+# file_size_limit = "50MiB"
+# allowed_mime_types = ["image/png", "image/jpeg"]
+# objects_path = "./images"
+
+# Allow connections via S3 compatible clients
+[storage.s3_protocol]
+enabled = true
+
+# Image transformation API is available to Supabase Pro plan.
+# [storage.image_transformation]
+# enabled = true
+
+# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
+# This feature is only available on the hosted platform.
+[storage.analytics]
+enabled = false
+max_namespaces = 5
+max_tables = 10
+max_catalogs = 2
+
+# Analytics Buckets is available to Supabase Pro plan.
+# [storage.analytics.buckets.my-warehouse]
+
+# Store vector embeddings in S3 for large and durable datasets
+# This feature is only available on the hosted platform.
+[storage.vector]
+enabled = false
+max_buckets = 10
+max_indexes = 5
+
+# Vector Buckets is available to Supabase Pro plan.
+# [storage.vector.buckets.documents-openai]
+
+[auth]
+enabled = true
+# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
+# in emails.
+site_url = "http://127.0.0.1:3000"
+# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
+additional_redirect_urls = ["https://127.0.0.1:3000"]
+# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
+jwt_expiry = 3600
+# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1).
+# jwt_issuer = ""
+# Path to JWT signing key. DO NOT commit your signing keys file to git.
+# signing_keys_path = "./signing_keys.json"
+# If disabled, the refresh token will never expire.
+enable_refresh_token_rotation = true
+# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
+# Requires enable_refresh_token_rotation = true.
+refresh_token_reuse_interval = 10
+# Allow/disallow new user signups to your project.
+enable_signup = true
+# Allow/disallow anonymous sign-ins to your project.
+enable_anonymous_sign_ins = false
+# Allow/disallow testing manual linking of accounts
+enable_manual_linking = false
+# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
+minimum_password_length = 6
+# Passwords that do not meet the following requirements will be rejected as weak. Supported values
+# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
+password_requirements = ""
+
+[auth.rate_limit]
+# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
+email_sent = 2
+# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
+sms_sent = 30
+# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
+anonymous_users = 30
+# Number of sessions that can be refreshed in a 5 minute interval per IP address.
+token_refresh = 150
+# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
+sign_in_sign_ups = 30
+# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
+token_verifications = 30
+# Number of Web3 logins that can be made in a 5 minute interval per IP address.
+web3 = 30
+
+# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
+# [auth.captcha]
+# enabled = true
+# provider = "hcaptcha"
+# secret = ""
+
+[auth.email]
+# Allow/disallow new user signups via email to your project.
+enable_signup = true
+# If enabled, a user will be required to confirm any email change on both the old, and new email
+# addresses. If disabled, only the new email is required to confirm.
+double_confirm_changes = true
+# If enabled, users need to confirm their email address before signing in.
+enable_confirmations = false
+# If enabled, users will need to reauthenticate or have logged in recently to change their password.
+secure_password_change = false
+# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
+max_frequency = "1s"
+# Number of characters used in the email OTP.
+otp_length = 6
+# Number of seconds before the email OTP expires (defaults to 1 hour).
+otp_expiry = 3600
+
+# Use a production-ready SMTP server
+# [auth.email.smtp]
+# enabled = true
+# host = "smtp.sendgrid.net"
+# port = 587
+# user = "apikey"
+# pass = "env(SENDGRID_API_KEY)"
+# admin_email = "admin@email.com"
+# sender_name = "Admin"
+
+# Uncomment to customize email template
+# [auth.email.template.invite]
+# subject = "You have been invited"
+# content_path = "./supabase/templates/invite.html"
+
+# Uncomment to customize notification email template
+# [auth.email.notification.password_changed]
+# enabled = true
+# subject = "Your password has been changed"
+# content_path = "./templates/password_changed_notification.html"
+
+[auth.sms]
+# Allow/disallow new user signups via SMS to your project.
+enable_signup = false
+# If enabled, users need to confirm their phone number before signing in.
+enable_confirmations = false
+# Template for sending OTP to users
+template = "Your code is {{ .Code }}"
+# Controls the minimum amount of time that must pass before sending another sms otp.
+max_frequency = "5s"
+
+# Use pre-defined map of phone number to OTP for testing.
+# [auth.sms.test_otp]
+# 4152127777 = "123456"
+
+# Configure logged in session timeouts.
+# [auth.sessions]
+# Force log out after the specified duration.
+# timebox = "24h"
+# Force log out if the user has been inactive longer than the specified duration.
+# inactivity_timeout = "8h"
+
+# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
+# [auth.hook.before_user_created]
+# enabled = true
+# uri = "pg-functions://postgres/auth/before-user-created-hook"
+
+# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
+# [auth.hook.custom_access_token]
+# enabled = true
+# uri = "pg-functions:////"
+
+# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
+[auth.sms.twilio]
+enabled = false
+account_sid = ""
+message_service_sid = ""
+# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
+auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
+
+# Multi-factor-authentication is available to Supabase Pro plan.
+[auth.mfa]
+# Control how many MFA factors can be enrolled at once per user.
+max_enrolled_factors = 10
+
+# Control MFA via App Authenticator (TOTP)
+[auth.mfa.totp]
+enroll_enabled = false
+verify_enabled = false
+
+# Configure MFA via Phone Messaging
+[auth.mfa.phone]
+enroll_enabled = false
+verify_enabled = false
+otp_length = 6
+template = "Your code is {{ .Code }}"
+max_frequency = "5s"
+
+# Configure MFA via WebAuthn
+# [auth.mfa.web_authn]
+# enroll_enabled = true
+# verify_enabled = true
+
+# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
+# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
+# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
+[auth.external.apple]
+enabled = false
+client_id = ""
+# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
+secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
+# Overrides the default auth redirectUrl.
+redirect_uri = ""
+# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
+# or any other third-party OIDC providers.
+url = ""
+# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
+skip_nonce_check = false
+# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
+email_optional = false
+
+# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
+# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
+[auth.web3.solana]
+enabled = false
+
+# Use Firebase Auth as a third-party provider alongside Supabase Auth.
+[auth.third_party.firebase]
+enabled = false
+# project_id = "my-firebase-project"
+
+# Use Auth0 as a third-party provider alongside Supabase Auth.
+[auth.third_party.auth0]
+enabled = false
+# tenant = "my-auth0-tenant"
+# tenant_region = "us"
+
+# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
+[auth.third_party.aws_cognito]
+enabled = false
+# user_pool_id = "my-user-pool-id"
+# user_pool_region = "us-east-1"
+
+# Use Clerk as a third-party provider alongside Supabase Auth.
+[auth.third_party.clerk]
+enabled = false
+# Obtain from https://clerk.com/setup/supabase
+# domain = "example.clerk.accounts.dev"
+
+# OAuth server configuration
+[auth.oauth_server]
+# Enable OAuth server functionality
+enabled = false
+# Path for OAuth consent flow UI
+authorization_url_path = "/oauth/consent"
+# Allow dynamic client registration
+allow_dynamic_registration = false
+
+[edge_runtime]
+enabled = true
+# Supported request policies: `oneshot`, `per_worker`.
+# `per_worker` (default) — enables hot reload during local development.
+# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
+policy = "per_worker"
+# Port to attach the Chrome inspector for debugging edge functions.
+inspector_port = 8083
+# The Deno major version to use.
+deno_version = 2
+
+# [edge_runtime.secrets]
+# secret_key = "env(SECRET_VALUE)"
+
+[analytics]
+enabled = true
+port = 54327
+# Configure one of the supported backends: `postgres`, `bigquery`.
+backend = "postgres"
+
+# Experimental features may be deprecated any time
+[experimental]
+# Configures Postgres storage engine to use OrioleDB (S3)
+orioledb_version = ""
+# Configures S3 bucket URL, eg. .s3-.amazonaws.com
+s3_host = "env(S3_HOST)"
+# Configures S3 bucket region, eg. us-east-1
+s3_region = "env(S3_REGION)"
+# Configures AWS_ACCESS_KEY_ID for S3 bucket
+s3_access_key = "env(S3_ACCESS_KEY)"
+# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
+s3_secret_key = "env(S3_SECRET_KEY)"
diff --git a/test/extensions/extensions_test.exs b/test/extensions/extensions_test.exs
new file mode 100644
index 000000000..ba02da309
--- /dev/null
+++ b/test/extensions/extensions_test.exs
@@ -0,0 +1,54 @@
+defmodule Realtime.ExtensionsTest do
+ use ExUnit.Case, async: true
+
+ alias Realtime.Extensions
+
+ describe "db_settings/1" do
+ test "returns default and required for postgres_cdc_rls" do
+ result = Extensions.db_settings("postgres_cdc_rls")
+
+ assert %{default: default, required: required} = result
+ assert is_map(default)
+ assert is_list(required)
+ end
+
+ test "default contains expected keys" do
+ %{default: default} = Extensions.db_settings("postgres_cdc_rls")
+
+ assert Map.has_key?(default, "poll_interval_ms")
+ assert Map.has_key?(default, "poll_max_changes")
+ assert Map.has_key?(default, "poll_max_record_bytes")
+ assert Map.has_key?(default, "publication")
+ assert Map.has_key?(default, "slot_name")
+ end
+
+ test "required contains expected fields" do
+ %{required: required} = Extensions.db_settings("postgres_cdc_rls")
+
+ field_names = Enum.map(required, fn {name, _validator, _encrypt} -> name end)
+
+ assert "db_host" in field_names
+ assert "db_port" in field_names
+ assert "db_name" in field_names
+ assert "db_user" in field_names
+ assert "db_password" in field_names
+ end
+
+ test "optional contains the runtime credentials" do
+ %{required: required, optional: optional} = Extensions.db_settings("postgres_cdc_rls")
+ required_names = Enum.map(required, fn {name, _validator, _encrypt} -> name end)
+ optional_names = Enum.map(optional, fn {name, _validator, _encrypt} -> name end)
+
+ assert "db_user_realtime" in optional_names
+ assert "db_pass_realtime" in optional_names
+
+ refute "db_user_realtime" in required_names
+ refute "db_pass_realtime" in required_names
+ end
+
+ test "returns empty default for unknown extension type" do
+ result = Extensions.db_settings("unknown_extension")
+ assert %{default: %{}, required: [], optional: []} = result
+ end
+ end
+end
diff --git a/test/extensions/postgres_cdc_rls/db_settings_test.exs b/test/extensions/postgres_cdc_rls/db_settings_test.exs
new file mode 100644
index 000000000..49d6de918
--- /dev/null
+++ b/test/extensions/postgres_cdc_rls/db_settings_test.exs
@@ -0,0 +1,50 @@
+defmodule Extensions.PostgresCdcRls.DbSettingsTest do
+ use ExUnit.Case, async: true
+
+ alias Extensions.PostgresCdcRls.DbSettings
+
+ describe "default/0" do
+ test "returns a map with expected keys and values" do
+ default = DbSettings.default()
+
+ assert default["poll_interval_ms"] == 100
+ assert default["poll_max_changes"] == 100
+ assert default["poll_max_record_bytes"] == 1_048_576
+ assert default["publication"] == "supabase_realtime"
+ assert default["slot_name"] == "supabase_realtime_replication_slot"
+ end
+ end
+
+ describe "required/0" do
+ test "returns a list of tuples" do
+ required = DbSettings.required()
+
+ assert is_list(required)
+ assert length(required) > 0
+
+ for {name, validator, required_flag} <- required do
+ assert is_binary(name)
+ assert is_function(validator, 1)
+ assert is_boolean(required_flag)
+ end
+ end
+
+ test "db_host is required" do
+ required = DbSettings.required()
+ assert {"db_host", _, true} = List.keyfind!(required, "db_host", 0)
+ end
+
+ test "region is not required" do
+ required = DbSettings.required()
+ assert {"region", _, false} = List.keyfind!(required, "region", 0)
+ end
+
+ test "validators accept binary values" do
+ required = DbSettings.required()
+
+ for {_name, validator, _required} <- required do
+ assert validator.("some_value") == true
+ end
+ end
+ end
+end
diff --git a/test/extensions/postgres_cdc_rls/message_dispatcher_test.exs b/test/extensions/postgres_cdc_rls/message_dispatcher_test.exs
new file mode 100644
index 000000000..3761f41d5
--- /dev/null
+++ b/test/extensions/postgres_cdc_rls/message_dispatcher_test.exs
@@ -0,0 +1,110 @@
+defmodule Extensions.PostgresCdcRls.MessageDispatcherTest do
+ use ExUnit.Case, async: true
+
+ alias Extensions.PostgresCdcRls.MessageDispatcher
+ alias Phoenix.Socket.Broadcast
+
+ defmodule FakeSerializer do
+ def fastlane!(msg), do: {:encoded, msg}
+ end
+
+ describe "dispatch/3" do
+ test "dispatches to fastlane subscribers with matching sub_ids using new api" do
+ parent = self()
+
+ fastlane_pid =
+ spawn(fn ->
+ receive do
+ msg -> send(parent, {:received, msg})
+ end
+ end)
+
+ sub_ids = MapSet.new(["sub_1"])
+ ids = [{"sub_1", 1}]
+
+ subscriptions = [
+ {self(), {:subscriber_fastlane, fastlane_pid, FakeSerializer, ids, "realtime:topic", true}}
+ ]
+
+ payload = Jason.encode!(%{data: "test"})
+
+ assert :ok = MessageDispatcher.dispatch(subscriptions, self(), {"INSERT", payload, sub_ids})
+
+ assert_receive {:received, {:encoded, %Broadcast{topic: "realtime:topic", event: "postgres_changes"}}}
+ end
+
+ test "dispatches to fastlane subscribers with matching sub_ids using old api" do
+ parent = self()
+
+ fastlane_pid =
+ spawn(fn ->
+ receive do
+ msg -> send(parent, {:received, msg})
+ end
+ end)
+
+ sub_ids = MapSet.new(["sub_1"])
+ ids = [{"sub_1", 1}]
+
+ subscriptions = [
+ {self(), {:subscriber_fastlane, fastlane_pid, FakeSerializer, ids, "realtime:topic", false}}
+ ]
+
+ payload = Jason.encode!(%{data: "test"})
+
+ assert :ok = MessageDispatcher.dispatch(subscriptions, self(), {"INSERT", payload, sub_ids})
+
+ assert_receive {:received, {:encoded, %Broadcast{topic: "realtime:topic", event: "INSERT"}}}
+ end
+
+ test "does not dispatch when sub_ids do not match" do
+ parent = self()
+
+ fastlane_pid =
+ spawn(fn ->
+ receive do
+ msg -> send(parent, {:received, msg})
+ after
+ 1000 -> :ok
+ end
+ end)
+
+ sub_ids = MapSet.new(["sub_2"])
+ ids = [{"sub_1", 1}]
+
+ subscriptions = [
+ {self(), {:subscriber_fastlane, fastlane_pid, FakeSerializer, ids, "realtime:topic", true}}
+ ]
+
+ assert :ok = MessageDispatcher.dispatch(subscriptions, self(), {"INSERT", "payload", sub_ids})
+
+ refute_receive {:received, _}
+ end
+
+ test "caches encoded messages across multiple subscribers" do
+ parent = self()
+
+ pids =
+ for _ <- 1..2 do
+ spawn(fn ->
+ receive do
+ msg -> send(parent, {:received, msg})
+ end
+ end)
+ end
+
+ sub_ids = MapSet.new(["sub_1"])
+ ids = [{"sub_1", 1}]
+
+ subscriptions =
+ Enum.map(pids, fn pid ->
+ {self(), {:subscriber_fastlane, pid, FakeSerializer, ids, "realtime:topic", true}}
+ end)
+
+ assert :ok = MessageDispatcher.dispatch(subscriptions, self(), {"INSERT", "payload", sub_ids})
+
+ assert_receive {:received, {:encoded, %Broadcast{}}}
+ assert_receive {:received, {:encoded, %Broadcast{}}}
+ end
+ end
+end
diff --git a/test/extensions/postgres_cdc_rls/replications_test.exs b/test/extensions/postgres_cdc_rls/replications_test.exs
new file mode 100644
index 000000000..b61f8e73c
--- /dev/null
+++ b/test/extensions/postgres_cdc_rls/replications_test.exs
@@ -0,0 +1,202 @@
+defmodule Extensions.PostgresCdcRls.ReplicationsTest do
+ use Realtime.DataCase, async: false
+
+ alias Extensions.PostgresCdcRls.Replications
+ alias Extensions.PostgresCdcRls.Subscriptions
+ alias Realtime.Database
+
+ setup do
+ tenant = Containers.checkout_tenant(run_migrations: true)
+ {:ok, conn} = Database.connect(tenant, "realtime_rls", :stop)
+ Integrations.setup_postgres_changes(conn)
+ %{conn: conn, tenant: tenant}
+ end
+
+ defp drop_slot_on_exit(tenant, slot_name) do
+ on_exit(fn ->
+ {:ok, conn} = Database.connect(tenant, "realtime_rls", :stop)
+ Postgrex.query(conn, "select pg_drop_replication_slot($1)", [slot_name])
+ GenServer.stop(conn)
+ end)
+ end
+
+ describe "prepare_replication/2" do
+ test "creates a replication slot", %{conn: conn, tenant: tenant} do
+ slot_name = "test_slot_#{System.unique_integer([:positive])}"
+ drop_slot_on_exit(tenant, slot_name)
+
+ assert {:ok, %Postgrex.Result{}} = Replications.prepare_replication(conn, slot_name)
+
+ assert {:ok, %Postgrex.Result{num_rows: 1}} =
+ Postgrex.query(conn, "select 1 from pg_replication_slots where slot_name = $1", [slot_name])
+ end
+
+ test "is idempotent when slot already exists", %{conn: conn, tenant: tenant} do
+ slot_name = "test_slot_#{System.unique_integer([:positive])}"
+ drop_slot_on_exit(tenant, slot_name)
+
+ assert {:ok, _} = Replications.prepare_replication(conn, slot_name)
+ assert {:ok, _} = Replications.prepare_replication(conn, slot_name)
+ end
+ end
+
+ describe "terminate_backend/2" do
+ test "returns slot_not_found when slot does not exist", %{conn: conn} do
+ assert {:error, :slot_not_found} = Replications.terminate_backend(conn, "nonexistent_slot")
+ end
+
+ test "returns error when connection is in a failed transaction", %{tenant: tenant} do
+ {:ok, bad_conn} = Realtime.Database.connect(tenant, "realtime_rls", :stop)
+
+ Postgrex.transaction(bad_conn, fn trans_conn ->
+ # Put the transaction in failed state
+ Postgrex.query(trans_conn, "SELECT 1/0", [])
+ # Subsequent queries return {:error, %Postgrex.Error{}} due to failed transaction
+ assert {:error, %Postgrex.Error{}} = Replications.terminate_backend(trans_conn, "any_slot")
+ # Return error to trigger rollback
+ {:error, :rollback}
+ end)
+
+ GenServer.stop(bad_conn)
+ end
+
+ test "returns slot_not_found when slot exists but has no active backend", %{conn: conn, tenant: tenant} do
+ slot_name = "test_slot_#{System.unique_integer([:positive])}"
+ drop_slot_on_exit(tenant, slot_name)
+
+ # Use a permanent (non-temporary) slot via a separate connection to avoid
+ # connection state issues that temporary slots cause on the same connection
+ {:ok, slot_conn} = Realtime.Database.connect(tenant, "realtime_rls", :stop)
+ Postgrex.query!(slot_conn, "select pg_create_logical_replication_slot($1, 'pgoutput')", [slot_name])
+ GenServer.stop(slot_conn)
+
+ assert {:error, :slot_not_found} = Replications.terminate_backend(conn, slot_name)
+ end
+ end
+
+ describe "get_pg_stat_activity_diff/2" do
+ test "returns error when pid is not in pg_stat_activity", %{conn: conn} do
+ assert {:error, :pid_not_found} = Replications.get_pg_stat_activity_diff(conn, 0)
+ end
+
+ test "returns diff when pid is found in pg_stat_activity", %{conn: conn} do
+ {:ok, %Postgrex.Result{rows: [[backend_pid]]}} = Postgrex.query(conn, "SELECT pg_backend_pid()", [])
+
+ result = Replications.get_pg_stat_activity_diff(conn, backend_pid)
+
+ assert {:ok, diff} = result
+ assert is_integer(diff)
+ end
+ end
+
+ describe "list_changes/5" do
+ @publication "supabase_realtime_test"
+
+ test "slot empty: returns only the sentinel row with slot_changes_count of 0", %{conn: conn, tenant: tenant} do
+ slot_name = "test_slot_#{System.unique_integer([:positive])}"
+ drop_slot_on_exit(tenant, slot_name)
+
+ {:ok, _} = Replications.prepare_replication(conn, slot_name)
+
+ assert {:ok, %Postgrex.Result{rows: rows}} =
+ Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576)
+
+ assert [sentinel] = rows
+ [nil, nil, nil, "[]", "{}", "{}", nil, nil, nil, slot_changes_count] = sentinel
+ assert slot_changes_count == 0
+ end
+
+ test "slot has changes visible to subscriber: returns real row and slot_changes_count of 1", %{
+ conn: conn,
+ tenant: tenant
+ } do
+ slot_name = "test_slot_#{System.unique_integer([:positive])}"
+ drop_slot_on_exit(tenant, slot_name)
+
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{"event" => "*", "schema" => "public", "table" => "test"})
+
+ Subscriptions.create(
+ conn,
+ @publication,
+ [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}],
+ self(),
+ self()
+ )
+
+ {:ok, _} = Replications.prepare_replication(conn, slot_name)
+
+ Postgrex.query!(conn, "INSERT INTO public.test (details) VALUES ('hello')", [])
+
+ assert {:ok, %Postgrex.Result{rows: rows}} =
+ Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576)
+
+ assert [row] = rows
+
+ assert [
+ "INSERT",
+ "public",
+ "test",
+ _columns,
+ _record,
+ _old_record,
+ _commit_timestamp,
+ _sub_ids,
+ _errors,
+ slot_changes_count
+ ] = row
+
+ assert slot_changes_count == 1
+ end
+
+ test "slot has changes but subscriber does not match the INSERT: returns only the sentinel row with slot_changes_count of 1",
+ %{
+ conn: conn,
+ tenant: tenant
+ } do
+ slot_name = "test_slot_#{System.unique_integer([:positive])}"
+ drop_slot_on_exit(tenant, slot_name)
+
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{"event" => "UPDATE", "schema" => "public", "table" => "test"})
+
+ Subscriptions.create(
+ conn,
+ @publication,
+ [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}],
+ self(),
+ self()
+ )
+
+ {:ok, _} = Replications.prepare_replication(conn, slot_name)
+
+ Postgrex.query!(conn, "INSERT INTO public.test (details) VALUES ('hello')", [])
+
+ assert {:ok, %Postgrex.Result{rows: rows}} =
+ Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576)
+
+ assert [sentinel] = rows
+ [nil, nil, nil, "[]", "{}", "{}", nil, nil, nil, slot_changes_count] = sentinel
+ assert slot_changes_count == 1
+ end
+
+ test "slot has changes but no subscribers: returns only the sentinel row with slot_changes_count of 1", %{
+ conn: conn,
+ tenant: tenant
+ } do
+ slot_name = "test_slot_#{System.unique_integer([:positive])}"
+ drop_slot_on_exit(tenant, slot_name)
+
+ {:ok, _} = Replications.prepare_replication(conn, slot_name)
+
+ Postgrex.query!(conn, "INSERT INTO public.test (details) VALUES ('hello'), ('hithere')", [])
+
+ assert {:ok, %Postgrex.Result{rows: rows}} =
+ Replications.list_changes(conn, slot_name, @publication, 100, 1_048_576)
+
+ assert [sentinel] = rows
+ [nil, nil, nil, "[]", "{}", "{}", nil, nil, nil, slot_changes_count] = sentinel
+ assert slot_changes_count == 2
+ end
+ end
+end
diff --git a/test/extensions/postgres_cdc_rls/worker_supervisor_test.exs b/test/extensions/postgres_cdc_rls/worker_supervisor_test.exs
new file mode 100644
index 000000000..d8fc33cf9
--- /dev/null
+++ b/test/extensions/postgres_cdc_rls/worker_supervisor_test.exs
@@ -0,0 +1,158 @@
+defmodule Extensions.PostgresCdcRls.WorkerSupervisorTest do
+ use Realtime.DataCase, async: false
+
+ alias Extensions.PostgresCdcRls.WorkerSupervisor
+ alias Extensions.PostgresCdcRls.ReplicationPoller
+ alias Extensions.PostgresCdcRls.SubscriptionManager
+
+ setup do
+ tenant = Containers.checkout_tenant(run_migrations: true)
+ extension = hd(tenant.extensions).settings
+
+ args =
+ extension
+ |> Map.put("id", tenant.external_id)
+ |> Map.put("region", extension["region"])
+
+ %{args: args, tenant: tenant}
+ end
+
+ describe "start_link/1" do
+ test "starts the supervisor with all children", %{args: args} do
+ pid = start_link_supervised!({WorkerSupervisor, args})
+
+ assert Process.alive?(pid)
+
+ children = Supervisor.which_children(pid)
+ child_ids = Enum.map(children, fn {id, _pid, _type, _modules} -> id end)
+
+ assert ReplicationPoller in child_ids
+ assert SubscriptionManager in child_ids
+ end
+
+ test "creates ETS tables for subscribers", %{args: args} do
+ pid = start_link_supervised!({WorkerSupervisor, args})
+
+ children = Supervisor.which_children(pid)
+
+ replication_poller_pid =
+ Enum.find_value(children, fn
+ {ReplicationPoller, pid, _, _} when is_pid(pid) -> pid
+ _ -> nil
+ end)
+
+ assert replication_poller_pid != nil
+ assert Process.alive?(replication_poller_pid)
+ end
+
+ test "raises exception when tenant is not in cache" do
+ args = %{
+ "id" => "nonexistent_tenant_#{System.unique_integer()}",
+ "region" => "us-east-1",
+ "db_host" => "localhost",
+ "db_name" => "realtime",
+ "db_user" => "user",
+ "db_password" => "pass",
+ "db_port" => "5432"
+ }
+
+ {pid, ref} = spawn_monitor(fn -> WorkerSupervisor.start_link(args) end)
+ assert_receive {:DOWN, ^ref, :process, ^pid, {%Realtime.PostgresCdc.Exception{}, _}}
+ end
+ end
+
+ describe "supervisor registration" do
+ test "registers in syn under the tenant scope", %{args: args, tenant: tenant} do
+ start_link_supervised!({WorkerSupervisor, args})
+
+ scope = Realtime.Syn.PostgresCdc.scope(tenant.external_id)
+ assert {pid, _meta} = :syn.lookup(scope, tenant.external_id)
+ assert is_pid(pid)
+ end
+ end
+
+ describe "restart behaviour" do
+ test "abnormal exit of ReplicationPoller restarts only itself", %{args: args} do
+ sup = start_link_supervised!({WorkerSupervisor, args})
+
+ poller = child_pid(sup, ReplicationPoller)
+ manager = child_pid(sup, SubscriptionManager)
+
+ Process.exit(poller, :kill)
+
+ # rest_for_one restarts the poller and everything after it (the manager)
+ new_poller = wait_for_restart(sup, ReplicationPoller, poller)
+
+ assert new_poller != poller
+ assert child_pid(sup, SubscriptionManager) == manager
+ assert Process.alive?(sup)
+ end
+
+ test "abnormal exit of SubscriptionManager restarts only itself", %{args: args} do
+ sup = start_link_supervised!({WorkerSupervisor, args})
+
+ poller = child_pid(sup, ReplicationPoller)
+ manager = child_pid(sup, SubscriptionManager)
+
+ Process.exit(manager, :kill)
+
+ # rest_for_one: the manager is last, so the poller is left untouched
+ new_manager = wait_for_restart(sup, SubscriptionManager, manager)
+
+ assert new_manager != manager
+ assert child_pid(sup, ReplicationPoller) == poller
+ assert Process.alive?(sup)
+ end
+ end
+
+ describe "shutdown behaviour" do
+ test "{:shutdown, _} from ReplicationPoller stops the supervisor", %{args: args} do
+ # start_supervised! (not the linking variant) so the supervisor's :shutdown
+ # exit does not propagate to the test process.
+ sup = start_supervised!({WorkerSupervisor, args})
+ ref = Process.monitor(sup)
+
+ poller = child_pid(sup, ReplicationPoller)
+ Process.exit(poller, {:shutdown, :max_retries_reached})
+
+ assert_receive {:DOWN, ^ref, :process, ^sup, :shutdown}, 2000
+ end
+
+ test "{:shutdown, _} from SubscriptionManager stops the supervisor", %{args: args} do
+ sup = start_supervised!({WorkerSupervisor, args})
+ ref = Process.monitor(sup)
+
+ manager = child_pid(sup, SubscriptionManager)
+ Process.exit(manager, {:shutdown, :test})
+
+ assert_receive {:DOWN, ^ref, :process, ^sup, :shutdown}, 2000
+ end
+ end
+
+ defp child_pid(sup, id) do
+ Enum.find_value(Supervisor.which_children(sup), fn
+ {^id, pid, _type, _modules} when is_pid(pid) -> pid
+ _ -> nil
+ end)
+ end
+
+ defp wait_for_restart(sup, id, old_pid, timeout \\ 2000) do
+ deadline = System.monotonic_time(:millisecond) + timeout
+ do_wait_for_restart(sup, id, old_pid, deadline)
+ end
+
+ defp do_wait_for_restart(sup, id, old_pid, deadline) do
+ case child_pid(sup, id) do
+ pid when is_pid(pid) and pid != old_pid ->
+ pid
+
+ _ ->
+ if System.monotonic_time(:millisecond) >= deadline do
+ flunk("child #{inspect(id)} was not restarted within the timeout")
+ else
+ Process.sleep(20)
+ do_wait_for_restart(sup, id, old_pid, deadline)
+ end
+ end
+ end
+end
diff --git a/test/integration/distributed_realtime_channel_test.exs b/test/integration/distributed_realtime_channel_test.exs
new file mode 100644
index 000000000..faed00a27
--- /dev/null
+++ b/test/integration/distributed_realtime_channel_test.exs
@@ -0,0 +1,48 @@
+defmodule Realtime.Integration.DistributedRealtimeChannelTest do
+ # Use of Clustered
+ use RealtimeWeb.ConnCase,
+ async: false,
+ parameterize: [%{serializer: Phoenix.Socket.V1.JSONSerializer}, %{serializer: RealtimeWeb.Socket.V2Serializer}]
+
+ alias Phoenix.Socket.Message
+
+ alias Realtime.Tenants.Connect
+ alias Realtime.Integration.WebsocketClient
+
+ setup do
+ tenant = Containers.checkout_tenant_unboxed(run_migrations: true)
+
+ {:ok, node} = Clustered.start()
+ region = Realtime.Tenants.region(tenant)
+ {:ok, db_conn} = :erpc.call(node, Connect, :connect, [tenant.external_id, region])
+ assert Connect.ready?(tenant.external_id)
+
+ assert node(db_conn) == node
+ %{tenant: tenant, topic: random_string()}
+ end
+
+ describe "distributed broadcast" do
+ @tag mode: :distributed
+ test "it works", %{tenant: tenant, topic: topic, serializer: serializer} do
+ {:ok, token} =
+ generate_token(tenant, %{exp: System.system_time(:second) + 1000, role: "authenticated", sub: random_string()})
+
+ {:ok, remote_socket} =
+ WebsocketClient.connect(self(), uri(tenant, serializer, 4012), serializer, [{"x-api-key", token}])
+
+ {:ok, socket} = WebsocketClient.connect(self(), uri(tenant, serializer), serializer, [{"x-api-key", token}])
+
+ config = %{broadcast: %{self: false}, private: false}
+ topic = "realtime:#{topic}"
+
+ :ok = WebsocketClient.join(remote_socket, topic, %{config: config})
+ :ok = WebsocketClient.join(socket, topic, %{config: config})
+
+ # Send through one socket and receive through the other (self: false)
+ payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
+ :ok = WebsocketClient.send_event(remote_socket, topic, "broadcast", payload)
+
+ assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 2000
+ end
+ end
+end
diff --git a/test/integration/measure_traffic_test.exs b/test/integration/measure_traffic_test.exs
new file mode 100644
index 000000000..e225da89e
--- /dev/null
+++ b/test/integration/measure_traffic_test.exs
@@ -0,0 +1,232 @@
+defmodule Realtime.Integration.MeasureTrafficTest do
+ use RealtimeWeb.ConnCase, async: false
+
+ alias Phoenix.Socket.Message
+ alias Realtime.Integration.WebsocketClient
+ alias Realtime.Tenants.ReplicationConnection
+
+ setup [:checkout_tenant_and_connect]
+
+ def handle_telemetry(event, measurements, metadata, name) do
+ tenant = metadata[:tenant]
+ [key] = Enum.take(event, -1)
+ value = Map.get(measurements, :sum) || Map.get(measurements, :value) || Map.get(measurements, :size) || 0
+
+ Agent.update(name, fn state ->
+ state =
+ Map.put_new(
+ state,
+ tenant,
+ %{
+ joins: 0,
+ events: 0,
+ db_events: 0,
+ presence_events: 0,
+ output_bytes: 0,
+ input_bytes: 0
+ }
+ )
+
+ update_in(state, [metadata[:tenant], key], fn v -> (v || 0) + value end)
+ end)
+ end
+
+ defp get_count(event, tenant) do
+ [key] = Enum.take(event, -1)
+
+ :"TestCounter_#{tenant}"
+ |> Agent.get(fn state -> get_in(state, [tenant, key]) || 0 end)
+ end
+
+ describe "measure traffic" do
+ setup %{tenant: tenant} do
+ events = [
+ [:realtime, :channel, :output_bytes],
+ [:realtime, :channel, :input_bytes]
+ ]
+
+ name = :"TestCounter_#{tenant.external_id}"
+
+ {:ok, _} =
+ start_supervised(%{
+ id: 1,
+ start: {Agent, :start_link, [fn -> %{} end, [name: name]]}
+ })
+
+ RateCounterHelper.stop(tenant.external_id)
+ on_exit(fn -> :telemetry.detach({__MODULE__, tenant.external_id}) end)
+ :telemetry.attach_many({__MODULE__, tenant.external_id}, events, &__MODULE__.handle_telemetry/4, name)
+
+ measure_traffic_interval_in_ms = Application.get_env(:realtime, :measure_traffic_interval_in_ms)
+ Application.put_env(:realtime, :measure_traffic_interval_in_ms, 10)
+ :persistent_term.put({RealtimeWeb.UserSocket, :measure_traffic_interval_in_ms}, 10)
+
+ on_exit(fn ->
+ Application.put_env(:realtime, :measure_traffic_interval_in_ms, measure_traffic_interval_in_ms)
+ :persistent_term.put({RealtimeWeb.UserSocket, :measure_traffic_interval_in_ms}, measure_traffic_interval_in_ms)
+ end)
+
+ :ok
+ end
+
+ test "measure traffic for broadcast events", %{tenant: tenant} do
+ {socket, _} = get_connection(tenant)
+ config = %{broadcast: %{self: true}}
+ topic = "realtime:any"
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ # Wait for join to complete
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 1000
+
+ for _ <- 1..5 do
+ WebsocketClient.send_event(socket, topic, "broadcast", %{
+ "event" => "TEST",
+ "payload" => %{"msg" => 1},
+ "type" => "broadcast"
+ })
+
+ assert_receive %Message{
+ event: "broadcast",
+ payload: %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"},
+ topic: ^topic
+ },
+ 500
+ end
+
+ # Wait for RateCounter to run
+ RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id)
+ Process.sleep(100)
+
+ output_bytes = get_count([:realtime, :channel, :output_bytes], tenant.external_id)
+ input_bytes = get_count([:realtime, :channel, :input_bytes], tenant.external_id)
+
+ assert output_bytes > 0
+ assert input_bytes > 0
+ end
+
+ test "measure traffic for presence events", %{tenant: tenant} do
+ {socket, _} = get_connection(tenant)
+ config = %{broadcast: %{self: true}, presence: %{enabled: true}}
+ topic = "realtime:any"
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ # Wait for join to complete
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 1000
+
+ for _ <- 1..5 do
+ WebsocketClient.send_event(socket, topic, "presence", %{
+ "event" => "TRACK",
+ "payload" => %{name: "realtime_presence_#{:rand.uniform(1000)}", t: 1814.7000000029802},
+ "type" => "presence"
+ })
+ end
+
+ # Wait for RateCounter to run
+ RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id)
+ Process.sleep(100)
+
+ output_bytes = get_count([:realtime, :channel, :output_bytes], tenant.external_id)
+ input_bytes = get_count([:realtime, :channel, :input_bytes], tenant.external_id)
+
+ assert output_bytes > 0, "Expected output_bytes to be greater than 0, got #{output_bytes}"
+ assert input_bytes > 0, "Expected input_bytes to be greater than 0, got #{input_bytes}"
+ end
+
+ test "measure traffic for postgres changes events", %{tenant: tenant, db_conn: db_conn} do
+ Integrations.setup_postgres_changes(db_conn)
+ {socket, _} = get_connection(tenant)
+ config = %{broadcast: %{self: true}, postgres_changes: [%{event: "*", schema: "public"}]}
+ topic = "realtime:any"
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ # Wait for join to complete
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 1000
+
+ # Wait for postgres_changes subscription to be ready
+ assert_receive %Message{
+ event: "system",
+ payload: %{
+ "channel" => "any",
+ "extension" => "postgres_changes",
+ "status" => "ok"
+ },
+ topic: ^topic
+ },
+ 8000
+
+ for _ <- 1..5 do
+ Postgrex.query!(db_conn, "INSERT INTO test (details) VALUES ($1)", [random_string()])
+ end
+
+ for _ <- 1..5 do
+ assert_receive %Message{
+ event: "postgres_changes",
+ payload: %{"data" => %{"schema" => "public", "table" => "test", "type" => "INSERT"}},
+ topic: ^topic
+ },
+ 500
+ end
+
+ # Wait for RateCounter to run
+ RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id)
+ Process.sleep(100)
+
+ output_bytes = get_count([:realtime, :channel, :output_bytes], tenant.external_id)
+ input_bytes = get_count([:realtime, :channel, :input_bytes], tenant.external_id)
+
+ assert output_bytes > 0, "Expected output_bytes to be greater than 0, got #{output_bytes}"
+ assert input_bytes > 0, "Expected input_bytes to be greater than 0, got #{input_bytes}"
+ end
+
+ test "measure traffic for db events", %{tenant: tenant, db_conn: db_conn} do
+ {socket, _} = get_connection(tenant)
+ config = %{broadcast: %{self: true}, db: %{enabled: true}}
+ topic = "realtime:any"
+ channel_name = "any"
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ # Wait for join to complete
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 1000
+
+ assert ReplicationConnection.ready?(tenant.external_id)
+
+ for _ <- 1..5 do
+ event = random_string()
+ value = random_string()
+
+ Postgrex.query!(
+ db_conn,
+ "SELECT realtime.send (json_build_object ('value', $1 :: text)::jsonb, $2 :: text, $3 :: text, FALSE::bool);",
+ [value, event, channel_name]
+ )
+
+ assert_receive %Message{
+ event: "broadcast",
+ payload: %{
+ "event" => ^event,
+ "payload" => %{"value" => ^value},
+ "type" => "broadcast"
+ },
+ topic: ^topic,
+ join_ref: nil,
+ ref: nil
+ },
+ 2000
+ end
+
+ # Wait for RateCounter to run
+ RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id)
+ Process.sleep(100)
+
+ output_bytes = get_count([:realtime, :channel, :output_bytes], tenant.external_id)
+ input_bytes = get_count([:realtime, :channel, :input_bytes], tenant.external_id)
+
+ assert output_bytes > 0, "Expected output_bytes to be greater than 0, got #{output_bytes}"
+ assert input_bytes > 0, "Expected input_bytes to be greater than 0, got #{input_bytes}"
+ end
+ end
+end
diff --git a/test/integration/region_aware_migrations_test.exs b/test/integration/region_aware_migrations_test.exs
new file mode 100644
index 000000000..8f18629ba
--- /dev/null
+++ b/test/integration/region_aware_migrations_test.exs
@@ -0,0 +1,75 @@
+defmodule Realtime.Integration.RegionAwareMigrationsTest do
+ use Realtime.DataCase, async: false
+ use Mimic
+
+ alias Containers
+ alias Realtime.Tenants
+ alias Realtime.Tenants.Migrations
+
+ setup do
+ {:ok, port} = Containers.checkout()
+
+ settings = [
+ %{
+ "type" => "postgres_cdc_rls",
+ "settings" => %{
+ "db_host" => "127.0.0.1",
+ "db_name" => "postgres",
+ "db_user" => "supabase_realtime_admin",
+ "db_password" => "postgres",
+ "db_port" => "#{port}",
+ "poll_interval" => 100,
+ "poll_max_changes" => 100,
+ "poll_max_record_bytes" => 1_048_576,
+ "region" => "ap-southeast-2",
+ "publication" => "supabase_realtime_test",
+ "ssl_enforced" => false
+ }
+ }
+ ]
+
+ tenant = tenant_fixture(%{extensions: settings})
+ region = Application.get_env(:realtime, :region)
+
+ {:ok, node} =
+ Clustered.start(nil,
+ extra_config: [
+ {:realtime, :region, Tenants.region(tenant)},
+ {:realtime, :master_region, region}
+ ]
+ )
+
+ Process.sleep(100)
+
+ %{tenant: tenant, node: node}
+ end
+
+ test "run_migrations routes to node in tenant's region with expected arguments", %{tenant: tenant, node: node} do
+ assert tenant.migrations_ran == 0
+
+ Realtime.GenRpc
+ |> Mimic.expect(:call, fn
+ called_node, Realtime.Nodes, func, args, opts ->
+ call_original(Realtime.GenRpc, :call, [called_node, Realtime.Nodes, func, args, opts])
+
+ called_node, Migrations, func, args, opts ->
+ assert called_node == node
+ assert func == :start_migration
+ assert opts[:tenant_id] == tenant.external_id
+
+ arg = hd(args)
+ assert arg.tenant_external_id == tenant.external_id
+ assert arg.migrations_ran == tenant.migrations_ran
+ assert arg.settings == hd(tenant.extensions).settings
+
+ assert opts[:timeout] == 50_000
+
+ call_original(Realtime.GenRpc, :call, [node, Migrations, func, args, opts])
+ end)
+
+ assert :ok = Migrations.run_migrations(tenant)
+ Process.sleep(1000)
+ tenant = Realtime.Repo.reload!(tenant)
+ refute tenant.migrations_ran == 0
+ end
+end
diff --git a/test/integration/region_aware_routing_test.exs b/test/integration/region_aware_routing_test.exs
new file mode 100644
index 000000000..ed1de1fb1
--- /dev/null
+++ b/test/integration/region_aware_routing_test.exs
@@ -0,0 +1,289 @@
+defmodule Realtime.Integration.RegionAwareRoutingTest do
+ use Realtime.DataCase, async: false
+ use Mimic
+
+ import Ecto.Query
+
+ alias Realtime.Api
+ alias Realtime.Api.FeatureFlag
+ alias Realtime.Api.Tenant
+ alias Realtime.GenRpc
+ alias Realtime.Nodes
+
+ setup do
+ original_master_region = Application.get_env(:realtime, :master_region)
+
+ on_exit(fn ->
+ Application.put_env(:realtime, :master_region, original_master_region)
+ end)
+
+ Application.put_env(:realtime, :master_region, "eu-west-2")
+
+ {:ok, master_node} =
+ Clustered.start(nil,
+ extra_config: [
+ {:realtime, :region, "eu-west-2"},
+ {:realtime, :master_region, "eu-west-2"}
+ ]
+ )
+
+ Process.sleep(100)
+
+ %{master_node: master_node}
+ end
+
+ test "create_tenant automatically routes to master region", %{master_node: master_node} do
+ external_id = "test_routing_#{System.unique_integer([:positive])}"
+
+ attrs = %{
+ "external_id" => external_id,
+ "name" => external_id,
+ "jwt_secret" => "secret",
+ "public_key" => "public",
+ "extensions" => [],
+ "postgres_cdc_default" => "postgres_cdc_rls",
+ "max_concurrent_users" => 200,
+ "max_events_per_second" => 100
+ }
+
+ Mimic.expect(Realtime.GenRpc, :call, fn node, mod, func, args, opts ->
+ assert node == master_node
+ assert mod == Realtime.Api
+ assert func == :create_tenant
+ assert opts[:tenant_id] == external_id
+
+ call_original(GenRpc, :call, [node, mod, func, args, opts])
+ end)
+
+ result = Api.create_tenant(attrs)
+
+ assert {:ok, %Tenant{} = tenant} = result
+ assert tenant.external_id == external_id
+
+ assert Realtime.Repo.get_by(Tenant, external_id: external_id)
+ end
+
+ test "update_tenant automatically routes to master region", %{master_node: master_node} do
+ # Create tenant on master node first
+ tenant_attrs = %{
+ "external_id" => "test_update_#{System.unique_integer([:positive])}",
+ "name" => "original",
+ "jwt_secret" => "secret",
+ "public_key" => "public",
+ "extensions" => [],
+ "postgres_cdc_default" => "postgres_cdc_rls",
+ "max_concurrent_users" => 200,
+ "max_events_per_second" => 100
+ }
+
+ Realtime.GenRpc
+ |> Mimic.expect(:call, fn node, mod, func, args, opts ->
+ assert node == master_node
+ assert mod == Realtime.Api
+ assert func == :create_tenant
+ assert opts[:tenant_id] == tenant_attrs["external_id"]
+
+ call_original(GenRpc, :call, [node, mod, func, args, opts])
+ end)
+ |> Mimic.expect(:call, fn node, mod, func, args, opts ->
+ assert node == master_node
+ assert mod == Realtime.Api
+ assert func == :update_tenant_by_external_id
+ assert opts[:tenant_id] == tenant_attrs["external_id"]
+
+ call_original(GenRpc, :call, [node, mod, func, args, opts])
+ end)
+
+ tenant = tenant_fixture(tenant_attrs)
+
+ new_name = "updated_via_routing"
+ result = Api.update_tenant_by_external_id(tenant.external_id, %{name: new_name})
+
+ assert {:ok, %Tenant{} = updated} = result
+ assert updated.name == new_name
+
+ reloaded = Realtime.Repo.get(Tenant, tenant.id)
+ assert reloaded.name == new_name
+ end
+
+ test "delete_tenant_by_external_id automatically routes to master region", %{master_node: master_node} do
+ # Create tenant on master node first
+ tenant_attrs = %{
+ "external_id" => "test_delete_#{System.unique_integer([:positive])}",
+ "name" => "to_delete",
+ "jwt_secret" => "secret",
+ "public_key" => "public",
+ "extensions" => [],
+ "postgres_cdc_default" => "postgres_cdc_rls",
+ "max_concurrent_users" => 200,
+ "max_events_per_second" => 100
+ }
+
+ Realtime.GenRpc
+ |> Mimic.expect(:call, fn node, mod, func, args, opts ->
+ assert node == master_node
+ assert mod == Realtime.Api
+ assert func == :create_tenant
+ assert opts[:tenant_id] == tenant_attrs["external_id"]
+
+ call_original(GenRpc, :call, [node, mod, func, args, opts])
+ end)
+ |> Mimic.expect(:call, fn node, mod, func, args, opts ->
+ assert node == master_node
+ assert mod == Realtime.Api
+ assert func == :delete_tenant_by_external_id
+ assert opts[:tenant_id] == tenant_attrs["external_id"]
+
+ call_original(GenRpc, :call, [node, mod, func, args, opts])
+ end)
+
+ tenant = tenant_fixture(tenant_attrs)
+
+ result = Api.delete_tenant_by_external_id(tenant.external_id)
+
+ assert result == true
+
+ refute Realtime.Repo.get(Tenant, tenant.id)
+ end
+
+ test "update_migrations_ran automatically routes to master region", %{master_node: master_node} do
+ # Create tenant on master node first
+ tenant_attrs = %{
+ "external_id" => "test_migrations_#{System.unique_integer([:positive])}",
+ "name" => "migrations_test",
+ "jwt_secret" => "secret",
+ "public_key" => "public",
+ "extensions" => [],
+ "postgres_cdc_default" => "postgres_cdc_rls",
+ "max_concurrent_users" => 200,
+ "max_events_per_second" => 100,
+ "migrations_ran" => 0
+ }
+
+ Realtime.GenRpc
+ |> Mimic.expect(:call, fn node, mod, func, args, opts ->
+ assert node == master_node
+ assert mod == Realtime.Api
+ assert func == :create_tenant
+ assert opts[:tenant_id] == tenant_attrs["external_id"]
+
+ call_original(GenRpc, :call, [node, mod, func, args, opts])
+ end)
+ |> Mimic.expect(:call, fn node, mod, func, args, opts ->
+ assert node == master_node
+ assert mod == Realtime.Api
+ assert func == :update_migrations_ran
+ assert opts[:tenant_id] == tenant_attrs["external_id"]
+
+ call_original(GenRpc, :call, [node, mod, func, args, opts])
+ end)
+
+ tenant = tenant_fixture(tenant_attrs)
+
+ new_migrations_ran = 5
+ result = Api.update_migrations_ran(tenant.external_id, new_migrations_ran)
+
+ assert {:ok, updated} = result
+ assert updated.migrations_ran == new_migrations_ran
+
+ reloaded = Realtime.Repo.get(Tenant, tenant.id)
+ assert reloaded.migrations_ran == new_migrations_ran
+ end
+
+ test "returns error when Nodes.node_from_region returns {:error, :not_available}" do
+ external_id = "test_error_node_unavailable_#{System.unique_integer([:positive])}"
+
+ attrs = %{
+ "external_id" => external_id,
+ "name" => external_id,
+ "jwt_secret" => "secret",
+ "public_key" => "public",
+ "extensions" => [],
+ "postgres_cdc_default" => "postgres_cdc_rls",
+ "max_concurrent_users" => 200,
+ "max_events_per_second" => 100
+ }
+
+ Mimic.expect(Nodes, :node_from_region, fn _region, _key -> {:error, :not_available} end)
+ result = Api.create_tenant(attrs)
+ assert {:error, :not_available} = result
+ end
+
+ test "returns error when GenRpc.call returns {:error, :rpc_error, reason}" do
+ external_id = "test_error_rpc_error_#{System.unique_integer([:positive])}"
+ rpc_error_reason = :timeout
+
+ attrs = %{
+ "external_id" => external_id,
+ "name" => external_id,
+ "jwt_secret" => "secret",
+ "public_key" => "public",
+ "extensions" => [],
+ "postgres_cdc_default" => "postgres_cdc_rls",
+ "max_concurrent_users" => 200,
+ "max_events_per_second" => 100
+ }
+
+ Mimic.expect(GenRpc, :call, fn _node, _mod, _func, _args, _opts -> {:error, :rpc_error, rpc_error_reason} end)
+ result = Api.create_tenant(attrs)
+ assert {:error, ^rpc_error_reason} = result
+ end
+
+ test "upsert_feature_flag automatically routes to master region", %{master_node: master_node} do
+ flag_name = "test_routing_flag_#{System.unique_integer([:positive])}"
+ on_exit(fn -> Realtime.Repo.delete_all(from f in FeatureFlag, where: f.name == ^flag_name) end)
+
+ Mimic.expect(GenRpc, :call, fn node, mod, func, args, opts ->
+ assert node == master_node
+ assert mod == Realtime.Api
+ assert func == :upsert_feature_flag
+ assert opts == []
+
+ call_original(GenRpc, :call, [node, mod, func, args, opts])
+ end)
+
+ assert {:ok, %FeatureFlag{name: ^flag_name, enabled: true}} =
+ Api.upsert_feature_flag(%{name: flag_name, enabled: true})
+
+ assert Realtime.Repo.get_by(FeatureFlag, name: flag_name)
+ end
+
+ test "upsert_feature_flag surfaces error", %{master_node: master_node} do
+ # validation will fail
+ flag_name = ""
+ on_exit(fn -> Realtime.Repo.delete_all(from f in FeatureFlag, where: f.name == ^flag_name) end)
+
+ Mimic.expect(GenRpc, :call, fn node, mod, func, args, opts ->
+ assert node == master_node
+ assert mod == Realtime.Api
+ assert func == :upsert_feature_flag
+ assert opts == []
+
+ call_original(GenRpc, :call, [node, mod, func, args, opts])
+ end)
+
+ assert {:error, %Ecto.Changeset{errors: [name: {"can't be blank", [validation: :required]}]}} =
+ Api.upsert_feature_flag(%{name: flag_name, enabled: true})
+ end
+
+ test "delete_feature_flag automatically routes to master region", %{master_node: master_node} do
+ flag_name = "test_routing_delete_#{System.unique_integer([:positive])}"
+
+ GenRpc
+ |> Mimic.expect(:call, fn node, mod, func, args, opts ->
+ assert node == master_node
+ assert func == :upsert_feature_flag
+ call_original(GenRpc, :call, [node, mod, func, args, opts])
+ end)
+ |> Mimic.expect(:call, fn node, mod, func, args, opts ->
+ assert node == master_node
+ assert func == :delete_feature_flag
+ assert opts == []
+ call_original(GenRpc, :call, [node, mod, func, args, opts])
+ end)
+
+ {:ok, flag} = Api.upsert_feature_flag(%{name: flag_name, enabled: true})
+ assert {:ok, _} = Api.delete_feature_flag(flag)
+ refute Realtime.Repo.get_by(FeatureFlag, name: flag_name)
+ end
+end
diff --git a/test/integration/rt_channel/authorization_test.exs b/test/integration/rt_channel/authorization_test.exs
new file mode 100644
index 000000000..42e154be7
--- /dev/null
+++ b/test/integration/rt_channel/authorization_test.exs
@@ -0,0 +1,163 @@
+defmodule Realtime.Integration.RtChannel.AuthorizationTest do
+ use RealtimeWeb.ConnCase,
+ async: true,
+ parameterize: [
+ %{serializer: Phoenix.Socket.V1.JSONSerializer},
+ %{serializer: RealtimeWeb.Socket.V2Serializer}
+ ]
+
+ import ExUnit.CaptureLog
+ import Generators
+
+ alias Phoenix.Socket.Message
+ alias Realtime.Integration.WebsocketClient
+
+ @moduletag :capture_log
+
+ setup [:checkout_tenant_and_connect]
+
+ describe "private only channels" do
+ setup [:rls_context]
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
+ test "user with only private channels enabled will not be able to join public channels", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ change_tenant_configuration(tenant, :private_only, true)
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: false}
+ topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{
+ event: "phx_reply",
+ payload: %{
+ "response" => %{
+ "reason" => "PrivateOnly: This project only allows private channels"
+ },
+ "status" => "error"
+ }
+ },
+ 500
+ end
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
+ test "user with only private channels enabled will be able to join private channels", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ change_tenant_configuration(tenant, :private_only, true)
+
+ Process.sleep(100)
+
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: true}
+ topic = "realtime:#{topic}"
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+ end
+ end
+
+ describe "RLS policy enforcement" do
+ setup [:rls_context]
+
+ @tag policies: [:read_matching_user_role, :write_matching_user_role], role: "anon"
+ test "role policies are respected when accessing the channel", %{tenant: tenant, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer, role: "anon")
+ config = %{broadcast: %{self: true}, private: true, presence: %{enabled: false}}
+ topic = random_string()
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500
+
+ {socket, _} = get_connection(tenant, serializer, role: "potato")
+ topic = random_string()
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config})
+ refute_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500
+ end
+
+ @tag policies: [:authenticated_read_matching_user_sub, :authenticated_write_matching_user_sub],
+ sub: Ecto.UUID.generate()
+ test "sub policies are respected when accessing the channel", %{tenant: tenant, sub: sub, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated", claims: %{sub: sub})
+ config = %{broadcast: %{self: true}, private: true, presence: %{enabled: false}}
+ topic = random_string()
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500
+
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated", claims: %{sub: Ecto.UUID.generate()})
+ topic = random_string()
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config})
+ refute_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500
+ end
+
+ @tag role: "authenticated", policies: [:broken_read_presence, :broken_write_presence]
+ test "handle failing rls policy", %{tenant: tenant, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: true}
+ topic = random_string()
+ realtime_topic = "realtime:#{topic}"
+
+ log =
+ capture_log(fn ->
+ WebsocketClient.join(socket, realtime_topic, %{config: config})
+
+ msg = "Unauthorized: You do not have permissions to read from this Channel topic: #{topic}"
+
+ assert_receive %Message{
+ event: "phx_reply",
+ payload: %{
+ "response" => %{
+ "reason" => ^msg
+ },
+ "status" => "error"
+ }
+ },
+ 500
+
+ refute_receive %Message{event: "phx_reply"}
+ refute_receive %Message{event: "presence_state"}
+ end)
+
+ assert log =~ "RlsPolicyError"
+ end
+ end
+
+ describe "topic validation" do
+ test "handle empty topic by closing the socket", %{tenant: tenant, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic = "realtime:"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config})
+
+ assert_receive %Message{
+ event: "phx_reply",
+ payload: %{
+ "response" => %{
+ "reason" => "TopicNameRequired: You must provide a topic name"
+ },
+ "status" => "error"
+ }
+ },
+ 500
+
+ refute_receive %Message{event: "phx_reply"}
+ refute_receive %Message{event: "presence_state"}
+ end
+ end
+end
diff --git a/test/integration/rt_channel/billable_events_test.exs b/test/integration/rt_channel/billable_events_test.exs
new file mode 100644
index 000000000..68f387896
--- /dev/null
+++ b/test/integration/rt_channel/billable_events_test.exs
@@ -0,0 +1,272 @@
+defmodule Realtime.Integration.RtChannel.BillableEventsTest do
+ use RealtimeWeb.ConnCase,
+ async: true,
+ parameterize: [
+ %{serializer: Phoenix.Socket.V1.JSONSerializer},
+ %{serializer: RealtimeWeb.Socket.V2Serializer}
+ ]
+
+ import Generators
+
+ alias Phoenix.Socket.Message
+ alias Postgrex
+ alias Realtime.Database
+ alias Realtime.Integration.WebsocketClient
+ alias Realtime.Tenants
+
+ @moduletag :capture_log
+
+ setup [:checkout_tenant_connect_and_setup_postgres_changes]
+
+ setup %{tenant: tenant} do
+ events = [
+ [:realtime, :rate_counter, :channel, :joins],
+ [:realtime, :rate_counter, :channel, :events],
+ [:realtime, :rate_counter, :channel, :db_events],
+ [:realtime, :rate_counter, :channel, :presence_events]
+ ]
+
+ name = :"TestCounter_#{tenant.external_id}"
+
+ {:ok, _} =
+ start_supervised(%{
+ id: 1,
+ start: {Agent, :start_link, [fn -> %{} end, [name: name]]}
+ })
+
+ RateCounterHelper.stop(tenant.external_id)
+ on_exit(fn -> :telemetry.detach({__MODULE__, tenant.external_id}) end)
+ :telemetry.attach_many({__MODULE__, tenant.external_id}, events, &__MODULE__.handle_telemetry/4, name)
+
+ :ok
+ end
+
+ def handle_telemetry(event, measurements, metadata, name) do
+ tenant = metadata[:tenant]
+ [key] = Enum.take(event, -1)
+ value = Map.get(measurements, :sum) || Map.get(measurements, :value) || Map.get(measurements, :size) || 0
+
+ Agent.update(name, fn state ->
+ state =
+ Map.put_new(
+ state,
+ tenant,
+ %{
+ joins: 0,
+ events: 0,
+ db_events: 0,
+ presence_events: 0,
+ output_bytes: 0,
+ input_bytes: 0
+ }
+ )
+
+ update_in(state, [metadata[:tenant], key], fn v -> (v || 0) + value end)
+ end)
+ end
+
+ describe "join events" do
+ test "join events", %{tenant: tenant, serializer: serializer} do
+ external_id = tenant.external_id
+ {socket, _} = get_connection(tenant, serializer)
+ config = %{broadcast: %{self: true}, postgres_changes: [%{event: "*", schema: "public"}]}
+ topic = "realtime:any"
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ # Join events
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+ assert_receive %Message{topic: ^topic, event: "system"}, 5000
+
+ # Wait for RateCounter to run
+ RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id)
+
+ # Expected billed
+ # 1 joins due to two sockets
+ # 0 presence events
+ # 0 db events as no postgres changes used
+ # 0 events broadcast is not used
+ assert 1 = get_count([:realtime, :rate_counter, :channel, :joins], external_id)
+ assert 0 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id)
+ assert 0 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id)
+ assert 0 = get_count([:realtime, :rate_counter, :channel, :events], external_id)
+ end
+ end
+
+ describe "broadcast events" do
+ test "broadcast events", %{tenant: tenant, serializer: serializer} do
+ external_id = tenant.external_id
+ {socket1, _} = get_connection(tenant, serializer)
+ config = %{broadcast: %{self: true}}
+ topic = "realtime:any"
+
+ WebsocketClient.join(socket1, topic, %{config: config})
+
+ # Join events
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+ # Add second client so we can test the "multiplication" of billable events
+ {socket2, _} = get_connection(tenant, serializer)
+ WebsocketClient.join(socket2, topic, %{config: config})
+
+ # Join events
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+ # Broadcast event
+ payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
+
+ for _ <- 1..5 do
+ WebsocketClient.send_event(socket1, topic, "broadcast", payload)
+ # both sockets
+ assert_receive %Message{topic: ^topic, event: "broadcast", payload: ^payload}
+ assert_receive %Message{topic: ^topic, event: "broadcast", payload: ^payload}
+ end
+
+ refute_receive _any
+
+ # Wait for RateCounter to run
+ RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id)
+
+ # Expected billed
+ # 2 joins due to two sockets
+ # 0 presence events
+ # 0 db events as no postgres changes used
+ # 15 events as 5 events sent, 5 events received on client 1 and 5 events received on client 2
+ assert 2 = get_count([:realtime, :rate_counter, :channel, :joins], external_id)
+ assert 0 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id)
+ assert 0 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id)
+ assert 15 = get_count([:realtime, :rate_counter, :channel, :events], external_id)
+ end
+ end
+
+ describe "presence events" do
+ test "presence events", %{tenant: tenant, serializer: serializer} do
+ external_id = tenant.external_id
+ {socket, _} = get_connection(tenant, serializer)
+ config = %{broadcast: %{self: true}, presence: %{enabled: true}}
+ topic = "realtime:any"
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ # Join events
+ assert_receive %Message{event: "phx_reply", topic: ^topic}, 1000
+
+ payload = %{
+ type: "presence",
+ event: "TRACK",
+ payload: %{name: "realtime_presence_1", t: 1814.7000000029802}
+ }
+
+ WebsocketClient.send_event(socket, topic, "presence", payload)
+ assert_receive %Message{event: "presence_diff", payload: %{"joins" => _, "leaves" => %{}}, topic: ^topic}
+
+ # Presence events
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+
+ payload = %{
+ type: "presence",
+ event: "TRACK",
+ payload: %{name: "realtime_presence_2", t: 1814.7000000029802}
+ }
+
+ WebsocketClient.send_event(socket, topic, "presence", payload)
+ assert_receive %Message{event: "presence_diff", payload: %{"joins" => _, "leaves" => %{}}, topic: ^topic}
+ assert_receive %Message{event: "presence_diff", payload: %{"joins" => _, "leaves" => %{}}, topic: ^topic}
+
+ # Wait for RateCounter to run
+ RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id)
+
+ # Expected billed
+ # 2 joins due to two sockets
+ # 7 presence events
+ # 0 db events as no postgres changes used
+ # 0 events as no broadcast used
+ assert 2 = get_count([:realtime, :rate_counter, :channel, :joins], external_id)
+ assert 7 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id)
+ assert 0 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id)
+ assert 0 = get_count([:realtime, :rate_counter, :channel, :events], external_id)
+ end
+ end
+
+ describe "postgres changes events" do
+ test "postgres changes events", %{tenant: tenant, serializer: serializer} do
+ external_id = tenant.external_id
+ {socket, _} = get_connection(tenant, serializer)
+ config = %{broadcast: %{self: true}, postgres_changes: [%{event: "*", schema: "public"}]}
+ topic = "realtime:any"
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ # Join events
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+ assert_receive %Message{topic: ^topic, event: "system"}, 5000
+
+ # Add second user to test the "multiplication" of billable events
+ {socket, _} = get_connection(tenant, serializer)
+ WebsocketClient.join(socket, topic, %{config: config})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+ assert_receive %Message{topic: ^topic, event: "system"}, 5000
+
+ tenant = Tenants.get_tenant_by_external_id(tenant.external_id)
+ {:ok, conn} = Database.connect(tenant, "realtime_test", :stop)
+
+ # Postgres Change events
+ for _ <- 1..5, do: Postgrex.query!(conn, "insert into test (details) values ('test')", [])
+
+ for _ <- 1..10 do
+ assert_receive %Message{
+ topic: ^topic,
+ event: "postgres_changes",
+ payload: %{"data" => %{"schema" => "public", "table" => "test", "type" => "INSERT"}}
+ },
+ 5000
+ end
+
+ # Wait for RateCounter to run
+ RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id)
+
+ # Expected billed
+ # 2 joins due to two sockets
+ # 0 presence events due to two sockets
+ # 10 db events due to 5 inserts events sent to client 1 and 5 inserts events sent to client 2
+ # 0 events as no broadcast used
+ assert 2 = get_count([:realtime, :rate_counter, :channel, :joins], external_id)
+ assert 0 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id)
+ # (5 for each websocket)
+ assert 10 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id)
+ assert 0 = get_count([:realtime, :rate_counter, :channel, :events], external_id)
+ end
+
+ test "postgres changes error events", %{tenant: tenant, serializer: serializer} do
+ external_id = tenant.external_id
+ {socket, _} = get_connection(tenant, serializer)
+ config = %{broadcast: %{self: true}, postgres_changes: [%{event: "*", schema: "none"}]}
+ topic = "realtime:any"
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ # Join events
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+ assert_receive %Message{topic: ^topic, event: "system"}, 5000
+
+ # Wait for RateCounter to run
+ RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id)
+
+ # Expected billed
+ # 1 joins due to one socket
+ # 0 presence events due to one socket
+ # 0 db events
+ # 0 events as no broadcast used
+ assert 1 = get_count([:realtime, :rate_counter, :channel, :joins], external_id)
+ assert 0 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id)
+ assert 0 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id)
+ assert 0 = get_count([:realtime, :rate_counter, :channel, :events], external_id)
+ end
+ end
+
+ defp get_count(event, tenant) do
+ [key] = Enum.take(event, -1)
+ Agent.get(:"TestCounter_#{tenant}", fn state -> get_in(state, [tenant, key]) || 0 end)
+ end
+end
diff --git a/test/integration/rt_channel/broadcast_test.exs b/test/integration/rt_channel/broadcast_test.exs
new file mode 100644
index 000000000..3a4351263
--- /dev/null
+++ b/test/integration/rt_channel/broadcast_test.exs
@@ -0,0 +1,557 @@
+defmodule Realtime.Integration.RtChannel.BroadcastTest do
+ use RealtimeWeb.ConnCase,
+ async: true,
+ parameterize: [
+ %{serializer: Phoenix.Socket.V1.JSONSerializer},
+ %{serializer: RealtimeWeb.Socket.V2Serializer}
+ ]
+
+ import ExUnit.CaptureLog
+ import Generators
+
+ alias Phoenix.Socket.Message
+ alias Postgrex
+ alias Realtime.Database
+ alias Realtime.Integration.WebsocketClient
+ alias Realtime.Tenants.Connect
+ alias Realtime.Tenants.ReplicationConnection
+
+ @moduletag :capture_log
+
+ setup [:checkout_tenant_and_connect]
+
+ describe "public broadcast" do
+ setup [:rls_context]
+
+ test "public broadcast", %{tenant: tenant, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer)
+ config = %{broadcast: %{self: true}, private: false}
+ topic = "realtime:any"
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+
+ payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
+ WebsocketClient.send_event(socket, topic, "broadcast", payload)
+
+ assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500
+ end
+
+ test "broadcast to another tenant does not get mixed up", %{tenant: tenant, serializer: serializer} do
+ other_tenant = Containers.checkout_tenant(run_migrations: true)
+
+ Realtime.Tenants.Cache.update_cache(other_tenant)
+
+ {socket, _} = get_connection(tenant, serializer)
+ config = %{broadcast: %{self: false}, private: false}
+ topic = "realtime:any"
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ {other_socket, _} = get_connection(other_tenant, serializer)
+ WebsocketClient.join(other_socket, topic, %{config: config})
+
+ # Both sockets joined
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+
+ payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
+ WebsocketClient.send_event(socket, topic, "broadcast", payload)
+
+ # No message received
+ refute_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500
+ end
+
+ @tag policies: []
+ test "lack of connection to database error does not impact public channels", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ topic = "realtime:#{topic}"
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ WebsocketClient.join(socket, topic, %{config: %{broadcast: %{self: true}, private: false}})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+
+ {service_role_socket, _} = get_connection(tenant, serializer, role: "service_role")
+ WebsocketClient.join(service_role_socket, topic, %{config: %{broadcast: %{self: false}, private: false}})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+
+ log =
+ capture_log(fn ->
+ :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: nil} end)
+ payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
+ WebsocketClient.send_event(service_role_socket, topic, "broadcast", payload)
+ assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500
+ end)
+
+ refute log =~ "UnableToHandleBroadcast"
+ end
+ end
+
+ describe "private broadcast" do
+ setup [:rls_context]
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
+ test "private broadcast with valid channel with permissions sends message", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: true}
+ topic = "realtime:#{topic}"
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+
+ payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
+ WebsocketClient.send_event(socket, topic, "broadcast", payload)
+
+ assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}
+ end
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence],
+ serializer: RealtimeWeb.Socket.V2Serializer
+ test "private broadcast with binary payload and ack returns reply and delivers self-broadcast", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: RealtimeWeb.Socket.V2Serializer = serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true, ack: true}, private: true}
+ full_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, full_topic, %{config: config})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^full_topic}, 500
+
+ binary = <<0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x11, 0x22, 0x33>>
+ event = "my-binary-event"
+
+ WebsocketClient.send_user_broadcast(socket, full_topic, event, binary, encoding: :binary)
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^full_topic}, 500
+
+ assert_receive %Message{
+ event: "broadcast",
+ topic: ^full_topic,
+ payload: %{
+ "event" => ^event,
+ "payload" => {:binary, ^binary},
+ "type" => "broadcast"
+ }
+ },
+ 1000
+ end
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence],
+ topic: "topic"
+ test "private broadcast with valid channel a colon character sends message and won't intercept in public channels",
+ %{topic: topic, tenant: tenant, serializer: serializer} do
+ {anon_socket, _} = get_connection(tenant, serializer, role: "anon")
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ valid_topic = "realtime:#{topic}"
+ malicious_topic = "realtime:private:#{topic}"
+
+ WebsocketClient.join(socket, valid_topic, %{config: %{broadcast: %{self: true}, private: true}})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^valid_topic}, 300
+
+ WebsocketClient.join(anon_socket, malicious_topic, %{config: %{broadcast: %{self: true}, private: false}})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^malicious_topic}, 300
+
+ payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
+ WebsocketClient.send_event(socket, valid_topic, "broadcast", payload)
+
+ assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^valid_topic}, 500
+ refute_receive %Message{event: "broadcast"}
+ end
+
+ @tag policies: [:authenticated_read_broadcast_and_presence]
+ test "private broadcast with valid channel no write permissions won't send message but will receive message", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ config = %{broadcast: %{self: true}, private: true}
+ topic = "realtime:#{topic}"
+
+ {service_role_socket, _} = get_connection(tenant, serializer, role: "service_role")
+ WebsocketClient.join(service_role_socket, topic, %{config: config})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ WebsocketClient.join(socket, topic, %{config: config})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+
+ payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
+
+ WebsocketClient.send_event(socket, topic, "broadcast", payload)
+ refute_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500
+
+ WebsocketClient.send_event(service_role_socket, topic, "broadcast", payload)
+ assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500
+ assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500
+ end
+
+ @tag policies: []
+ test "private broadcast with valid channel and no read permissions won't join", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ config = %{private: true}
+ expected = "Unauthorized: You do not have permissions to read from this Channel topic: #{topic}"
+
+ topic = "realtime:#{topic}"
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+
+ log =
+ capture_log(fn ->
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{
+ topic: ^topic,
+ event: "phx_reply",
+ payload: %{
+ "response" => %{
+ "reason" => ^expected
+ },
+ "status" => "error"
+ }
+ },
+ 300
+
+ refute_receive %Message{event: "phx_reply", topic: ^topic}, 300
+ end)
+
+ assert log =~ expected
+ end
+
+ @tag policies: [:authenticated_read_broadcast_and_presence]
+ test "handles lack of connection to database error on private channels", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ topic = "realtime:#{topic}"
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ WebsocketClient.join(socket, topic, %{config: %{broadcast: %{self: true}, private: true}})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+
+ {service_role_socket, _} = get_connection(tenant, serializer, role: "service_role")
+ WebsocketClient.join(service_role_socket, topic, %{config: %{broadcast: %{self: false}, private: true}})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+
+ log =
+ capture_log(fn ->
+ :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: nil} end)
+ payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
+ WebsocketClient.send_event(service_role_socket, topic, "broadcast", payload)
+ # Waiting more than 15 seconds as this is the amount of time we will wait for the Connection to be ready
+ refute_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 16000
+ end)
+
+ assert log =~ "UnableToHandleBroadcast"
+ end
+ end
+
+ describe "trigger-based broadcast changes" do
+ setup [:rls_context, :setup_trigger]
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
+ test "broadcast insert event changes on insert in table with trigger", %{
+ tenant: tenant,
+ topic: topic,
+ db_conn: db_conn,
+ table_name: table_name,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: true}
+ topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ assert ReplicationConnection.ready?(tenant.external_id)
+
+ value = random_string()
+ Postgrex.query!(db_conn, "INSERT INTO #{table_name} (details) VALUES ($1)", [value])
+
+ record = %{"details" => value, "id" => 1}
+
+ assert_receive %Message{
+ event: "broadcast",
+ payload: %{
+ "event" => "INSERT",
+ "payload" => %{
+ "old_record" => nil,
+ "operation" => "INSERT",
+ "record" => ^record,
+ "schema" => "public",
+ "table" => ^table_name
+ },
+ "type" => "broadcast"
+ },
+ topic: ^topic
+ },
+ 1000
+ end
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence],
+ requires_data: true,
+ requires_pg_140006: true
+ test "broadcast update event changes on update in table with trigger", %{
+ tenant: tenant,
+ topic: topic,
+ db_conn: db_conn,
+ table_name: table_name,
+ serializer: serializer
+ } do
+ value = random_string()
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: true}
+ topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ new_value = random_string()
+
+ assert ReplicationConnection.ready?(tenant.external_id)
+
+ Postgrex.query!(db_conn, "INSERT INTO #{table_name} (details) VALUES ($1)", [value])
+ Postgrex.query!(db_conn, "UPDATE #{table_name} SET details = $1 WHERE details = $2", [new_value, value])
+
+ old_record = %{"details" => value, "id" => 1}
+ record = %{"details" => new_value, "id" => 1}
+
+ assert_receive %Message{
+ event: "broadcast",
+ payload: %{
+ "event" => "UPDATE",
+ "payload" => %{
+ "old_record" => ^old_record,
+ "operation" => "UPDATE",
+ "record" => ^record,
+ "schema" => "public",
+ "table" => ^table_name
+ },
+ "type" => "broadcast"
+ },
+ topic: ^topic
+ },
+ 1000
+ end
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence],
+ requires_pg_140006: true
+ test "broadcast delete event changes on delete in table with trigger", %{
+ tenant: tenant,
+ topic: topic,
+ db_conn: db_conn,
+ table_name: table_name,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: true}
+ topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ value = random_string()
+
+ assert ReplicationConnection.ready?(tenant.external_id)
+
+ Postgrex.query!(db_conn, "INSERT INTO #{table_name} (details) VALUES ($1)", [value])
+ Postgrex.query!(db_conn, "DELETE FROM #{table_name} WHERE details = $1", [value])
+
+ record = %{"details" => value, "id" => 1}
+
+ assert_receive %Message{
+ event: "broadcast",
+ payload: %{
+ "event" => "DELETE",
+ "payload" => %{
+ "old_record" => ^record,
+ "operation" => "DELETE",
+ "record" => nil,
+ "schema" => "public",
+ "table" => ^table_name
+ },
+ "type" => "broadcast"
+ },
+ topic: ^topic
+ },
+ 1000
+ end
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
+ test "broadcast event when function 'send' is called with private topic", %{
+ tenant: tenant,
+ topic: topic,
+ db_conn: db_conn,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: true}
+ full_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, full_topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ value = random_string()
+ event = random_string()
+
+ assert ReplicationConnection.ready?(tenant.external_id)
+
+ Postgrex.query!(
+ db_conn,
+ "SELECT realtime.send (jsonb_build_object ('value', $1 :: text), $2 :: text, $3 :: text, TRUE::bool);",
+ [value, event, topic]
+ )
+
+ assert_receive %Message{
+ event: "broadcast",
+ payload: %{
+ "event" => ^event,
+ "payload" => %{"value" => ^value},
+ "type" => "broadcast"
+ },
+ topic: ^full_topic,
+ join_ref: nil,
+ ref: nil
+ },
+ 1000
+ end
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
+ test "broadcast event when function 'send_binary' is called", %{
+ tenant: tenant,
+ topic: topic,
+ db_conn: db_conn,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: true}
+ full_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, full_topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ binary = <<0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF, 0x01, 0x02>>
+ event = random_string()
+
+ assert ReplicationConnection.ready?(tenant.external_id)
+
+ Postgrex.query!(
+ db_conn,
+ "SELECT realtime.send_binary($1::bytea, $2::text, $3::text, TRUE::bool);",
+ [binary, event, topic]
+ )
+
+ case serializer do
+ RealtimeWeb.Socket.V2Serializer ->
+ assert_receive %Message{
+ event: "broadcast",
+ payload: %{
+ "event" => ^event,
+ "payload" => {:binary, ^binary},
+ "type" => "broadcast",
+ "meta" => %{"id" => _}
+ },
+ topic: ^full_topic
+ },
+ 1000
+
+ Phoenix.Socket.V1.JSONSerializer ->
+ # V1 cannot represent binary payloads; the broadcast is dropped for this socket.
+ refute_receive %Message{event: "broadcast"}, 500
+ end
+ end
+
+ test "broadcast event when function 'send' is called with public topic", %{
+ tenant: tenant,
+ topic: topic,
+ db_conn: db_conn,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: false}
+ full_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, full_topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ value = random_string()
+ event = random_string()
+
+ assert ReplicationConnection.ready?(tenant.external_id)
+
+ Postgrex.query!(
+ db_conn,
+ "SELECT realtime.send (json_build_object ('value', $1 :: text)::jsonb, $2 :: text, $3 :: text, FALSE::bool);",
+ [value, event, topic]
+ )
+
+ assert_receive %Message{
+ event: "broadcast",
+ payload: %{
+ "event" => ^event,
+ "payload" => %{"value" => ^value},
+ "type" => "broadcast"
+ },
+ topic: ^full_topic
+ },
+ 1000
+ end
+ end
+
+ defp setup_trigger(%{tenant: tenant, topic: topic}) do
+ {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
+ random_name = String.downcase("test_#{random_string()}")
+ query = "CREATE TABLE #{random_name} (id serial primary key, details text)"
+ Postgrex.query!(db_conn, query, [])
+
+ query = """
+ CREATE OR REPLACE FUNCTION broadcast_changes_for_table_#{random_name}_trigger ()
+ RETURNS TRIGGER
+ AS $$
+ DECLARE
+ topic text;
+ BEGIN
+ topic = '#{topic}';
+ PERFORM
+ realtime.broadcast_changes (topic, TG_OP, TG_OP, TG_TABLE_NAME, TG_TABLE_SCHEMA, NEW, OLD, TG_LEVEL);
+ RETURN NULL;
+ END;
+ $$
+ LANGUAGE plpgsql;
+ """
+
+ Postgrex.query!(db_conn, query, [])
+
+ query = """
+ CREATE TRIGGER broadcast_changes_for_#{random_name}_table
+ AFTER INSERT OR UPDATE OR DELETE ON #{random_name}
+ FOR EACH ROW
+ EXECUTE FUNCTION broadcast_changes_for_table_#{random_name}_trigger ();
+ """
+
+ Postgrex.query!(db_conn, query, [])
+
+ on_exit(fn ->
+ {:ok, cleanup_conn} = Database.connect(tenant, "realtime_test", :stop)
+ Postgrex.query!(cleanup_conn, "DROP TABLE #{random_name} CASCADE", [])
+ GenServer.stop(cleanup_conn)
+ end)
+
+ %{table_name: random_name, db_conn: db_conn}
+ end
+end
diff --git a/test/integration/rt_channel/connection_lifecycle_test.exs b/test/integration/rt_channel/connection_lifecycle_test.exs
new file mode 100644
index 000000000..3f7de0625
--- /dev/null
+++ b/test/integration/rt_channel/connection_lifecycle_test.exs
@@ -0,0 +1,412 @@
+defmodule Realtime.Integration.RtChannel.ConnectionLifecycleTest do
+ use RealtimeWeb.ConnCase,
+ async: true,
+ parameterize: [
+ %{serializer: Phoenix.Socket.V1.JSONSerializer},
+ %{serializer: RealtimeWeb.Socket.V2Serializer}
+ ]
+
+ import ExUnit.CaptureLog
+ import Generators
+
+ alias Phoenix.Socket.Message
+ alias Realtime.Integration.WebsocketClient
+ alias Realtime.Tenants
+ alias RealtimeWeb.UserSocket
+
+ @moduletag :capture_log
+
+ @service_restart_close_code 1012
+ @normal_close_code 1000
+
+ setup [:checkout_tenant_and_connect]
+
+ describe "socket connect - tenant not found" do
+ test "logs TenantNotFound and rejects connection for unknown external_id", %{serializer: serializer} do
+ external_id = "nonexistent-#{System.unique_integer([:positive])}"
+ fake_tenant = %{external_id: external_id}
+ # Our code does not store values that are not Tenant structs
+ # but we do it here to avoid an Ecto.Sandbox issue due to the async tests
+ # Because Cachex.fetch will try to call the DB when there is no cached information
+ Cachex.put(Realtime.Tenants.Cache, {:get_tenant_by_external_id, external_id}, {:error, :not_found})
+
+ log =
+ capture_log(fn ->
+ assert {:error, _} =
+ WebsocketClient.connect(self(), uri(fake_tenant, serializer), serializer, [
+ {"x-api-key", "some-token"}
+ ])
+ end)
+
+ assert log =~ "TenantNotFound"
+ end
+ end
+
+ describe "socket connect - missing api key" do
+ test "logs MissingAPIKey and rejects connection when no token provided", %{tenant: tenant, serializer: serializer} do
+ log =
+ capture_log(fn ->
+ assert {:error, _} = WebsocketClient.connect(self(), uri(tenant, serializer), serializer, [])
+ end)
+
+ assert log =~ "MissingAPIKey"
+ end
+ end
+
+ describe "socket disconnect - tenant suspension" do
+ setup [:rls_context]
+
+ test "tenant already suspended", %{tenant: tenant, serializer: serializer} do
+ log =
+ capture_log(fn ->
+ change_tenant_configuration(tenant, :suspend, true)
+ {:error, %Mint.WebSocket.UpgradeFailureError{}} = get_connection(tenant, serializer, role: "anon")
+ refute_receive _any
+ end)
+
+ assert log =~ "RealtimeDisabledForTenant"
+ end
+ end
+
+ describe "socket disconnect - configuration changes" do
+ setup [:rls_context]
+
+ test "on jwks the socket closes and sends a system message", %{tenant: tenant, topic: topic, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{jwt_jwks: %{keys: ["potato"]}})
+
+ assert_receive {:close_code, @service_restart_close_code}, 1000
+
+ assert_process_down(socket)
+ end
+
+ test "on jwt_secret the socket closes and sends a system message", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{jwt_secret: "potato"})
+
+ assert_receive {:close_code, @service_restart_close_code}, 1000
+
+ assert_process_down(socket)
+ end
+
+ test "on private_only the socket closes and sends a system message", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{private_only: true})
+
+ assert_receive {:close_code, @service_restart_close_code}, 1000
+
+ assert_process_down(socket)
+ end
+
+ test "on other param changes the socket won't close and no message is sent", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{max_concurrent_users: 100})
+
+ refute_receive %Message{
+ topic: ^realtime_topic,
+ event: "system",
+ payload: %{
+ "extension" => "system",
+ "message" => "Server requested disconnect",
+ "status" => "ok"
+ }
+ },
+ 500
+
+ assert :ok = WebsocketClient.send_heartbeat(socket)
+ refute_receive {:close_code, @service_restart_close_code}
+ end
+ end
+
+ describe "socket disconnect - token expiry" do
+ setup [:rls_context]
+
+ test "invalid JWT with expired token", %{tenant: tenant, serializer: serializer} do
+ log =
+ capture_log(fn ->
+ get_connection(tenant, serializer,
+ role: "authenticated",
+ claims: %{:exp => System.system_time(:second) - 1000},
+ params: %{log_level: :info}
+ )
+ end)
+
+ assert log =~ "InvalidJWTToken: Token has expired"
+ end
+ end
+
+ describe "socket disconnect" do
+ setup [:rls_context]
+
+ test "on disconnect called, socket is killed", %{
+ tenant: tenant,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: false}
+
+ topics =
+ for i <- 1..10 do
+ topic = "realtime:#{serializer}:#{i}"
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 500
+ topic
+ end
+
+ assert :ok = WebsocketClient.send_heartbeat(socket)
+ # heartbeat reply
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: "phoenix"}, 500
+
+ UserSocket.disconnect(tenant.external_id)
+
+ for topic <- topics do
+ assert_receive %Message{
+ topic: ^topic,
+ event: "system",
+ payload: %{
+ "extension" => "system",
+ "message" => "Server requested disconnect",
+ "status" => "ok"
+ }
+ },
+ 5000
+ end
+
+ assert_receive {:close_code, @service_restart_close_code}, 1000
+ refute_receive _any
+
+ assert_process_down(socket, 1000)
+ end
+ end
+
+ describe "socket disconnect - tenant deleted during session" do
+ setup [:rls_context]
+
+ test "sends disconnect to socket when tenant not found during channel join", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ Cachex.put(Realtime.Tenants.Cache, {:get_tenant_by_external_id, tenant.external_id}, {:error, :not_found})
+
+ realtime_topic_2 = "realtime:#{random_string()}"
+ WebsocketClient.join(socket, realtime_topic_2, %{config: config})
+
+ assert_receive {:close_code, @normal_close_code}, 1000
+
+ assert_process_down(socket, 1000)
+ end
+ end
+
+ describe "rate limits - concurrent users" do
+ setup [:rls_context]
+
+ test "max_concurrent_users limit respected", %{tenant: tenant, serializer: serializer} do
+ Tenants.get_tenant_by_external_id(tenant.external_id)
+ change_tenant_configuration(tenant, :max_concurrent_users, 1)
+
+ {socket1, _} = get_connection(tenant, serializer, role: "authenticated")
+ {socket2, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: false}
+ topic1 = "realtime:#{random_string()}"
+ topic2 = "realtime:#{random_string()}"
+ WebsocketClient.join(socket1, topic1, %{config: config})
+ WebsocketClient.join(socket1, topic2, %{config: config})
+
+ assert_receive %Message{
+ event: "phx_reply",
+ topic: ^topic1,
+ payload: %{"response" => %{"postgres_changes" => []}, "status" => "ok"}
+ },
+ 500
+
+ assert_receive %Message{
+ event: "phx_reply",
+ topic: ^topic2,
+ payload: %{"response" => %{"postgres_changes" => []}, "status" => "ok"}
+ },
+ 500
+
+ topic3 = "realtime:#{random_string()}"
+ WebsocketClient.join(socket2, topic3, %{config: config})
+
+ assert_receive %Message{
+ event: "phx_reply",
+ topic: ^topic3,
+ payload: %{
+ "response" => %{
+ "reason" => "ConnectionRateLimitReached: Too many connected users"
+ },
+ "status" => "error"
+ }
+ },
+ 500
+
+ Realtime.Tenants.Cache.update_cache(%{tenant | max_concurrent_users: 2})
+
+ WebsocketClient.join(socket2, topic3, %{config: config})
+
+ assert_receive %Message{
+ event: "phx_reply",
+ topic: ^topic3,
+ payload: %{"response" => %{"postgres_changes" => []}, "status" => "ok"}
+ },
+ 500
+ end
+ end
+
+ describe "rate limits - events per second" do
+ setup [:rls_context]
+
+ test "max_events_per_second limit respected", %{tenant: tenant, serializer: serializer} do
+ RateCounterHelper.stop(tenant.external_id)
+
+ log =
+ capture_log(fn ->
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true, ack: false}, private: false, presence: %{enabled: false}}
+ realtime_topic = "realtime:#{random_string()}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500
+
+ for _ <- 1..1000, Process.alive?(socket) do
+ WebsocketClient.send_event(socket, realtime_topic, "broadcast", %{})
+ assert_receive %Message{event: "broadcast", topic: ^realtime_topic}, 500
+ end
+
+ RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id)
+
+ WebsocketClient.send_event(socket, realtime_topic, "broadcast", %{})
+
+ assert_receive %Message{event: "phx_close"}, 1000
+ end)
+
+ assert log =~ "MessagePerSecondRateLimitReached"
+ end
+ end
+
+ describe "rate limits - channels per client" do
+ setup [:rls_context]
+
+ test "max_channels_per_client limit respected", %{tenant: tenant, serializer: serializer} do
+ change_tenant_configuration(tenant, :max_channels_per_client, 1)
+
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic_1 = "realtime:#{random_string()}"
+ realtime_topic_2 = "realtime:#{random_string()}"
+
+ WebsocketClient.join(socket, realtime_topic_1, %{config: config})
+ WebsocketClient.join(socket, realtime_topic_2, %{config: config})
+
+ assert_receive %Message{
+ event: "phx_reply",
+ payload: %{"response" => %{"postgres_changes" => []}, "status" => "ok"},
+ topic: ^realtime_topic_1
+ },
+ 500
+
+ assert_receive %Message{
+ event: "phx_reply",
+ payload: %{
+ "status" => "error",
+ "response" => %{
+ "reason" => "ChannelRateLimitReached: Too many channels"
+ }
+ },
+ topic: ^realtime_topic_2
+ },
+ 500
+
+ refute_receive %Message{event: "phx_reply", topic: ^realtime_topic_2}, 500
+
+ Realtime.Tenants.Cache.update_cache(%{tenant | max_channels_per_client: 2})
+
+ WebsocketClient.join(socket, realtime_topic_2, %{config: config})
+
+ assert_receive %Message{
+ event: "phx_reply",
+ payload: %{"response" => %{"postgres_changes" => []}, "status" => "ok"},
+ topic: ^realtime_topic_2
+ },
+ 500
+ end
+ end
+
+ describe "rate limits - joins per second" do
+ setup [:rls_context]
+
+ test "max_joins_per_second limit respected", %{tenant: tenant, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic = "realtime:#{random_string()}"
+
+ log =
+ capture_log(fn ->
+ for _ <- 1..1500 do
+ WebsocketClient.join(socket, realtime_topic, %{config: config})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500
+ end
+
+ RateCounterHelper.tick_tenant_rate_counters!(tenant.external_id)
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config})
+ assert_process_down(socket)
+ end)
+
+ assert log =~
+ "project=#{tenant.external_id} external_id=#{tenant.external_id} [critical] ClientJoinRateLimitReached: Too many joins per second"
+
+ assert length(String.split(log, "ClientJoinRateLimitReached")) <= 3
+ end
+ end
+end
diff --git a/test/integration/rt_channel/postgres_changes_test.exs b/test/integration/rt_channel/postgres_changes_test.exs
new file mode 100644
index 000000000..f11907d96
--- /dev/null
+++ b/test/integration/rt_channel/postgres_changes_test.exs
@@ -0,0 +1,916 @@
+defmodule Realtime.Integration.RtChannel.PostgresChangesTest do
+ use RealtimeWeb.ConnCase,
+ async: true,
+ parameterize: [
+ %{serializer: Phoenix.Socket.V1.JSONSerializer},
+ %{serializer: RealtimeWeb.Socket.V2Serializer}
+ ]
+
+ import ExUnit.CaptureLog
+ import Generators
+
+ alias Extensions.PostgresCdcRls
+ alias Phoenix.Socket.Message
+ alias Postgrex
+ alias Realtime.Database
+ alias Realtime.Integration.WebsocketClient
+
+ @moduletag :capture_log
+
+ setup [:checkout_tenant_connect_and_setup_postgres_changes]
+
+ describe "insert" do
+ test "handle insert", %{tenant: tenant, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer)
+ topic = "realtime:any"
+ config = %{postgres_changes: [%{event: "INSERT", schema: "public"}]}
+
+ WebsocketClient.join(socket, topic, %{config: config})
+ sub_id = :erlang.phash2(%{"event" => "INSERT", "schema" => "public"})
+
+ assert_receive %Message{
+ event: "phx_reply",
+ payload: %{
+ "response" => %{
+ "postgres_changes" => [
+ %{"event" => "INSERT", "id" => ^sub_id, "schema" => "public"}
+ ]
+ },
+ "status" => "ok"
+ },
+ topic: ^topic
+ },
+ 200
+
+ assert_receive %Message{
+ event: "system",
+ payload: %{
+ "channel" => "any",
+ "extension" => "postgres_changes",
+ "message" => "Subscribed to PostgreSQL",
+ "status" => "ok"
+ },
+ ref: nil,
+ topic: ^topic
+ },
+ 8000
+
+ {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
+
+ %{rows: [[id]]} =
+ Postgrex.query!(conn, "insert into test (details) values ('test') returning id", [])
+
+ assert_receive %Message{
+ event: "postgres_changes",
+ payload: %{
+ "data" => %{
+ "columns" => [
+ %{"name" => "id", "type" => "int4"},
+ %{"name" => "details", "type" => "text"},
+ %{"name" => "binary_data", "type" => "bytea"}
+ ],
+ "commit_timestamp" => _ts,
+ "errors" => nil,
+ "record" => %{"details" => "test", "id" => ^id},
+ "schema" => "public",
+ "table" => "test",
+ "type" => "INSERT"
+ },
+ "ids" => [^sub_id]
+ },
+ ref: nil,
+ topic: "realtime:any"
+ },
+ 500
+ end
+ end
+
+ describe "bytea column" do
+ test "handle insert with bytea data without double-encoding", %{
+ tenant: tenant,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer)
+ topic = "realtime:any"
+ config = %{postgres_changes: [%{event: "INSERT", schema: "public"}]}
+
+ WebsocketClient.join(socket, topic, %{config: config})
+ sub_id = :erlang.phash2(%{"event" => "INSERT", "schema" => "public"})
+
+ assert_receive %Message{
+ event: "phx_reply",
+ payload: %{"status" => "ok"},
+ topic: ^topic
+ },
+ 200
+
+ assert_receive %Message{
+ event: "system",
+ payload: %{
+ "channel" => "any",
+ "extension" => "postgres_changes",
+ "message" => "Subscribed to PostgreSQL",
+ "status" => "ok"
+ },
+ ref: nil,
+ topic: ^topic
+ },
+ 8000
+
+ {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
+
+ binary_value = <<1, 2, 3, 4, 5>>
+
+ %{rows: [[_id]]} =
+ Postgrex.query!(
+ conn,
+ "insert into test (details, binary_data) values ('test', $1) returning id",
+ [binary_value]
+ )
+
+ assert_receive %Message{
+ event: "postgres_changes",
+ payload: %{
+ "data" => %{
+ "record" => record,
+ "type" => "INSERT"
+ },
+ "ids" => [^sub_id]
+ },
+ ref: nil,
+ topic: "realtime:any"
+ },
+ 500
+
+ # The bytea value should be the hex string as provided by wal2json
+ assert record["binary_data"] == "0102030405"
+ end
+ end
+
+ describe "update" do
+ test "handle update", %{tenant: tenant, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer)
+ topic = "realtime:any"
+ config = %{postgres_changes: [%{event: "UPDATE", schema: "public"}]}
+
+ WebsocketClient.join(socket, topic, %{config: config})
+ sub_id = :erlang.phash2(%{"event" => "UPDATE", "schema" => "public"})
+
+ assert_receive %Message{
+ event: "phx_reply",
+ payload: %{
+ "response" => %{
+ "postgres_changes" => [
+ %{"event" => "UPDATE", "id" => ^sub_id, "schema" => "public"}
+ ]
+ },
+ "status" => "ok"
+ },
+ ref: "1",
+ topic: ^topic
+ },
+ 200
+
+ assert_receive %Message{
+ event: "system",
+ payload: %{
+ "channel" => "any",
+ "extension" => "postgres_changes",
+ "message" => "Subscribed to PostgreSQL",
+ "status" => "ok"
+ },
+ ref: nil,
+ topic: ^topic
+ },
+ 8000
+
+ {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
+
+ %{rows: [[id]]} =
+ Postgrex.query!(conn, "insert into test (details) values ('test') returning id", [])
+
+ Postgrex.query!(conn, "update test set details = 'test' where id = #{id}", [])
+
+ assert_receive %Message{
+ event: "postgres_changes",
+ payload: %{
+ "data" => %{
+ "columns" => [
+ %{"name" => "id", "type" => "int4"},
+ %{"name" => "details", "type" => "text"},
+ %{"name" => "binary_data", "type" => "bytea"}
+ ],
+ "commit_timestamp" => _ts,
+ "errors" => nil,
+ "old_record" => %{"id" => ^id},
+ "record" => %{"details" => "test", "id" => ^id},
+ "schema" => "public",
+ "table" => "test",
+ "type" => "UPDATE"
+ },
+ "ids" => [^sub_id]
+ },
+ ref: nil,
+ topic: "realtime:any"
+ },
+ 500
+ end
+ end
+
+ describe "delete" do
+ test "handle delete", %{tenant: tenant, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer)
+ topic = "realtime:any"
+ config = %{postgres_changes: [%{event: "DELETE", schema: "public"}]}
+
+ WebsocketClient.join(socket, topic, %{config: config})
+ sub_id = :erlang.phash2(%{"event" => "DELETE", "schema" => "public"})
+
+ assert_receive %Message{
+ event: "phx_reply",
+ payload: %{
+ "response" => %{
+ "postgres_changes" => [
+ %{"event" => "DELETE", "id" => ^sub_id, "schema" => "public"}
+ ]
+ },
+ "status" => "ok"
+ },
+ ref: "1",
+ topic: ^topic
+ },
+ 200
+
+ assert_receive %Message{
+ event: "system",
+ payload: %{
+ "channel" => "any",
+ "extension" => "postgres_changes",
+ "message" => "Subscribed to PostgreSQL",
+ "status" => "ok"
+ },
+ ref: nil,
+ topic: ^topic
+ },
+ 8000
+
+ {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
+
+ %{rows: [[id]]} =
+ Postgrex.query!(conn, "insert into test (details) values ('test') returning id", [])
+
+ Postgrex.query!(conn, "delete from test where id = #{id}", [])
+
+ assert_receive %Message{
+ event: "postgres_changes",
+ payload: %{
+ "data" => %{
+ "columns" => [
+ %{"name" => "id", "type" => "int4"},
+ %{"name" => "details", "type" => "text"},
+ %{"name" => "binary_data", "type" => "bytea"}
+ ],
+ "commit_timestamp" => _ts,
+ "errors" => nil,
+ "old_record" => %{"id" => ^id},
+ "schema" => "public",
+ "table" => "test",
+ "type" => "DELETE"
+ },
+ "ids" => [^sub_id]
+ },
+ ref: nil,
+ topic: "realtime:any"
+ },
+ 500
+ end
+ end
+
+ describe "wildcard" do
+ test "handle wildcard", %{tenant: tenant, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer)
+ topic = "realtime:any"
+ config = %{postgres_changes: [%{event: "*", schema: "public"}]}
+
+ WebsocketClient.join(socket, topic, %{config: config})
+ sub_id = :erlang.phash2(%{"event" => "*", "schema" => "public"})
+
+ assert_receive %Message{
+ event: "phx_reply",
+ payload: %{
+ "response" => %{
+ "postgres_changes" => [
+ %{"event" => "*", "id" => ^sub_id, "schema" => "public"}
+ ]
+ },
+ "status" => "ok"
+ },
+ ref: "1",
+ topic: ^topic
+ },
+ 200
+
+ assert_receive %Message{
+ event: "system",
+ payload: %{
+ "channel" => "any",
+ "extension" => "postgres_changes",
+ "message" => "Subscribed to PostgreSQL",
+ "status" => "ok"
+ },
+ ref: nil,
+ topic: ^topic
+ },
+ 8000
+
+ {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
+
+ %{rows: [[id]]} =
+ Postgrex.query!(conn, "insert into test (details) values ('test') returning id", [])
+
+ assert_receive %Message{
+ event: "postgres_changes",
+ payload: %{
+ "data" => %{
+ "columns" => [
+ %{"name" => "id", "type" => "int4"},
+ %{"name" => "details", "type" => "text"},
+ %{"name" => "binary_data", "type" => "bytea"}
+ ],
+ "commit_timestamp" => _ts,
+ "errors" => nil,
+ "record" => %{"id" => ^id},
+ "schema" => "public",
+ "table" => "test",
+ "type" => "INSERT"
+ },
+ "ids" => [^sub_id]
+ },
+ ref: nil,
+ topic: "realtime:any"
+ },
+ 500
+
+ Postgrex.query!(conn, "update test set details = 'test' where id = #{id}", [])
+
+ assert_receive %Message{
+ event: "postgres_changes",
+ payload: %{
+ "data" => %{
+ "columns" => [
+ %{"name" => "id", "type" => "int4"},
+ %{"name" => "details", "type" => "text"},
+ %{"name" => "binary_data", "type" => "bytea"}
+ ],
+ "commit_timestamp" => _ts,
+ "errors" => nil,
+ "old_record" => %{"id" => ^id},
+ "record" => %{"details" => "test", "id" => ^id},
+ "schema" => "public",
+ "table" => "test",
+ "type" => "UPDATE"
+ },
+ "ids" => [^sub_id]
+ },
+ ref: nil,
+ topic: "realtime:any"
+ },
+ 500
+
+ Postgrex.query!(conn, "delete from test where id = #{id}", [])
+
+ assert_receive %Message{
+ event: "postgres_changes",
+ payload: %{
+ "data" => %{
+ "columns" => [
+ %{"name" => "id", "type" => "int4"},
+ %{"name" => "details", "type" => "text"},
+ %{"name" => "binary_data", "type" => "bytea"}
+ ],
+ "commit_timestamp" => _ts,
+ "errors" => nil,
+ "old_record" => %{"id" => ^id},
+ "schema" => "public",
+ "table" => "test",
+ "type" => "DELETE"
+ },
+ "ids" => [^sub_id]
+ },
+ ref: nil,
+ topic: "realtime:any"
+ },
+ 500
+ end
+ end
+
+ describe "AND filter composition" do
+ test "delivers row matching all filters", %{tenant: tenant, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer)
+ topic = "realtime:any"
+
+ # details=eq.match AND id=gt.0 — all rows have id > 0 (auto-increment from 1),
+ # so the second condition is always true, making details=eq.match the effective selector.
+ filter = "details=eq.match,id=gt.0"
+
+ config = %{
+ postgres_changes: [%{event: "INSERT", schema: "public", table: "test", filter: filter}]
+ }
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{
+ event: "phx_reply",
+ payload: %{"status" => "ok"},
+ topic: ^topic
+ },
+ 200
+
+ assert_receive %Message{
+ event: "system",
+ payload: %{
+ "channel" => "any",
+ "extension" => "postgres_changes",
+ "message" => "Subscribed to PostgreSQL",
+ "status" => "ok"
+ },
+ ref: nil,
+ topic: ^topic
+ },
+ 8000
+
+ {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
+
+ %{rows: [[matching_id]]} =
+ Postgrex.query!(conn, "insert into test (details) values ('match') returning id", [])
+
+ assert_receive %Message{
+ event: "postgres_changes",
+ payload: %{
+ "data" => %{
+ "record" => %{"id" => ^matching_id, "details" => "match"},
+ "type" => "INSERT"
+ }
+ },
+ ref: nil,
+ topic: ^topic
+ },
+ 500
+ end
+
+ test "ignores row matching only one filter", %{tenant: tenant, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer)
+ topic = "realtime:any"
+
+ # details=eq.match AND id=gt.0 — all rows have id > 0 (auto-increment from 1),
+ # so the second condition is always true, making details=eq.match the effective selector.
+ filter = "details=eq.match,id=gt.0"
+
+ config = %{
+ postgres_changes: [%{event: "INSERT", schema: "public", table: "test", filter: filter}]
+ }
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{
+ event: "phx_reply",
+ payload: %{"status" => "ok"},
+ topic: ^topic
+ },
+ 200
+
+ assert_receive %Message{
+ event: "system",
+ payload: %{
+ "channel" => "any",
+ "extension" => "postgres_changes",
+ "message" => "Subscribed to PostgreSQL",
+ "status" => "ok"
+ },
+ ref: nil,
+ topic: ^topic
+ },
+ 8000
+
+ {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
+
+ # Row matching only the second filter (id>0) but not the first (details!='match') — should be ignored
+ Postgrex.query!(conn, "insert into test (details) values ('no-match') returning id", [])
+
+ refute_receive %Message{
+ event: "postgres_changes",
+ payload: %{"data" => %{"type" => "INSERT"}},
+ topic: ^topic
+ },
+ 500
+ end
+ end
+
+ describe "select column filtering" do
+ test "subscribe with select filters payload columns — INSERT", %{
+ tenant: tenant,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer)
+ topic = "realtime:any"
+
+ config = %{
+ postgres_changes: [
+ %{event: "INSERT", schema: "public", table: "test", select: ["details"]}
+ ]
+ }
+
+ WebsocketClient.join(socket, topic, %{config: config})
+ sub_id = :erlang.phash2(%{"event" => "INSERT", "schema" => "public", "table" => "test", "select" => ["details"]})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic},
+ 200
+
+ assert_receive %Message{
+ event: "system",
+ payload: %{
+ "extension" => "postgres_changes",
+ "message" => "Subscribed to PostgreSQL",
+ "status" => "ok"
+ },
+ topic: ^topic
+ },
+ 8000
+
+ {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
+
+ %{rows: [[id]]} =
+ Postgrex.query!(conn, "insert into test (details) values ('hello') returning id", [])
+
+ assert_receive %Message{
+ event: "postgres_changes",
+ payload: %{
+ "data" => %{
+ "columns" => columns,
+ "record" => record,
+ "type" => "INSERT"
+ },
+ "ids" => [^sub_id]
+ },
+ topic: ^topic
+ },
+ 500
+
+ # PK always included even when not in select
+ assert record["id"] == id
+ assert record["details"] == "hello"
+ # binary_data not in select — must be absent
+ refute Map.has_key?(record, "binary_data")
+ # columns metadata only shows selected + PK columns
+ column_names = Enum.map(columns, & &1["name"])
+ assert "id" in column_names
+ assert "details" in column_names
+ refute "binary_data" in column_names
+ end
+
+ test "subscribe with select filters payload columns — UPDATE", %{
+ tenant: tenant,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer)
+ topic = "realtime:any"
+
+ config = %{
+ postgres_changes: [
+ %{event: "UPDATE", schema: "public", table: "test", select: ["details"]}
+ ]
+ }
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic},
+ 200
+
+ assert_receive %Message{
+ event: "system",
+ payload: %{"extension" => "postgres_changes", "status" => "ok"},
+ topic: ^topic
+ },
+ 8000
+
+ {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
+
+ %{rows: [[id]]} =
+ Postgrex.query!(conn, "insert into test (details) values ('before') returning id", [])
+
+ Postgrex.query!(conn, "update test set details = 'after' where id = #{id}", [])
+
+ assert_receive %Message{
+ event: "postgres_changes",
+ payload: %{
+ "data" => %{
+ "record" => record,
+ "old_record" => old_record,
+ "type" => "UPDATE"
+ }
+ },
+ topic: ^topic
+ },
+ 500
+
+ # new record: only selected + PK
+ assert record["id"] == id
+ assert record["details"] == "after"
+ refute Map.has_key?(record, "binary_data")
+
+ # old_record: only selected + PK
+ assert old_record["id"] == id
+ refute Map.has_key?(old_record, "binary_data")
+ end
+
+ test "subscribe with select filters payload columns — DELETE", %{
+ tenant: tenant,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer)
+ topic = "realtime:any"
+
+ config = %{
+ postgres_changes: [
+ %{event: "DELETE", schema: "public", table: "test", select: ["details"]}
+ ]
+ }
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic},
+ 200
+
+ assert_receive %Message{
+ event: "system",
+ payload: %{"extension" => "postgres_changes", "status" => "ok"},
+ topic: ^topic
+ },
+ 8000
+
+ {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
+
+ %{rows: [[id]]} =
+ Postgrex.query!(conn, "insert into test (details) values ('bye') returning id", [])
+
+ Postgrex.query!(conn, "delete from test where id = #{id}", [])
+
+ assert_receive %Message{
+ event: "postgres_changes",
+ payload: %{
+ "data" => %{
+ "old_record" => old_record,
+ "type" => "DELETE"
+ }
+ },
+ topic: ^topic
+ },
+ 500
+
+ # old_record filtered to selected + PK
+ assert old_record["id"] == id
+ refute Map.has_key?(old_record, "binary_data")
+ end
+
+ test "subscribe without select receives full payload — backward compat", %{
+ tenant: tenant,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer)
+ topic = "realtime:any"
+ config = %{postgres_changes: [%{event: "INSERT", schema: "public", table: "test"}]}
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic},
+ 200
+
+ assert_receive %Message{
+ event: "system",
+ payload: %{"extension" => "postgres_changes", "status" => "ok"},
+ topic: ^topic
+ },
+ 8000
+
+ {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
+
+ %{rows: [[id]]} =
+ Postgrex.query!(conn, "insert into test (details) values ('full') returning id", [])
+
+ assert_receive %Message{
+ event: "postgres_changes",
+ payload: %{
+ "data" => %{
+ "columns" => columns,
+ "record" => record,
+ "type" => "INSERT"
+ }
+ },
+ topic: ^topic
+ },
+ 500
+
+ # All columns present
+ assert record["id"] == id
+ assert record["details"] == "full"
+ column_names = Enum.map(columns, & &1["name"])
+ assert "id" in column_names
+ assert "details" in column_names
+ assert "binary_data" in column_names
+ end
+
+ test "select with filter only delivers matching rows with filtered columns", %{
+ tenant: tenant,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer)
+ topic = "realtime:any"
+
+ config = %{
+ postgres_changes: [
+ %{
+ event: "INSERT",
+ schema: "public",
+ table: "test",
+ filter: "details=eq.match",
+ select: ["details"]
+ }
+ ]
+ }
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic},
+ 200
+
+ assert_receive %Message{
+ event: "system",
+ payload: %{"extension" => "postgres_changes", "status" => "ok"},
+ topic: ^topic
+ },
+ 8000
+
+ {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
+
+ # Non-matching row — should not be received
+ Postgrex.query!(conn, "insert into test (details) values ('no-match') returning id", [])
+
+ refute_receive %Message{event: "postgres_changes", topic: ^topic}, 300
+
+ # Matching row
+ %{rows: [[id]]} =
+ Postgrex.query!(conn, "insert into test (details) values ('match') returning id", [])
+
+ assert_receive %Message{
+ event: "postgres_changes",
+ payload: %{
+ "data" => %{
+ "record" => record,
+ "type" => "INSERT"
+ }
+ },
+ topic: ^topic
+ },
+ 500
+
+ assert record["id"] == id
+ assert record["details"] == "match"
+ refute Map.has_key?(record, "binary_data")
+ end
+
+ test "payload size is reduced when using select — performance proxy", %{
+ tenant: tenant,
+ serializer: serializer
+ } do
+ large_value = String.duplicate("x", 2048)
+
+ # Subscriber with select — only id (start this first to boot the CDC manager)
+ {socket_select, _} = get_connection(tenant, serializer)
+ topic_select = "realtime:select"
+
+ WebsocketClient.join(socket_select, topic_select, %{
+ config: %{postgres_changes: [%{event: "INSERT", schema: "public", table: "test", select: ["id"]}]}
+ })
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic_select},
+ 200
+
+ assert_receive %Message{
+ event: "system",
+ payload: %{"extension" => "postgres_changes", "status" => "ok"},
+ topic: ^topic_select
+ },
+ 8000
+
+ # Manager is now running — add the large_text column
+ {:ok, _, setup_conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
+ Postgrex.query!(setup_conn, "alter table test add column if not exists large_text text", [])
+
+ # Subscriber without select — full payload
+ {socket_full, _} = get_connection(tenant, serializer)
+ topic_full = "realtime:full"
+
+ WebsocketClient.join(socket_full, topic_full, %{
+ config: %{postgres_changes: [%{event: "INSERT", schema: "public", table: "test"}]}
+ })
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic_full},
+ 200
+
+ assert_receive %Message{
+ event: "system",
+ payload: %{"extension" => "postgres_changes", "status" => "ok"},
+ topic: ^topic_full
+ },
+ 8000
+
+ {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
+ Postgrex.query!(conn, "insert into test (details, large_text) values ('hi', $1)", [large_value])
+
+ assert_receive %Message{
+ event: "postgres_changes",
+ payload: %{"data" => full_data},
+ topic: ^topic_full
+ } = full_msg,
+ 500
+
+ assert_receive %Message{
+ event: "postgres_changes",
+ payload: %{"data" => select_data},
+ topic: ^topic_select
+ } = select_msg,
+ 500
+
+ full_size = full_msg |> :erlang.term_to_binary() |> byte_size()
+ select_size = select_msg |> :erlang.term_to_binary() |> byte_size()
+
+ assert select_size < full_size
+ assert Map.has_key?(full_data["record"], "large_text")
+ refute Map.has_key?(select_data["record"], "large_text")
+ end
+ end
+
+ describe "error handling" do
+ test "error subscribing", %{tenant: tenant, serializer: serializer} do
+ {:ok, conn} = Database.connect(tenant, "realtime_test")
+
+ {:ok, _} =
+ Database.transaction(conn, fn db_conn ->
+ Postgrex.query!(db_conn, "drop publication if exists supabase_realtime_test")
+ end)
+
+ {socket, _} = get_connection(tenant, serializer)
+ topic = "realtime:any"
+ config = %{postgres_changes: [%{event: "INSERT", schema: "public"}]}
+
+ log =
+ capture_log(fn ->
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{
+ event: "system",
+ payload: %{
+ "channel" => "any",
+ "extension" => "postgres_changes",
+ "message" =>
+ "Unable to subscribe to changes with given parameters. Please check Realtime is enabled for the given connect parameters: [event: INSERT, schema: public, table: *, filters: [], select: nil]",
+ "status" => "error"
+ },
+ ref: nil,
+ topic: ^topic
+ },
+ 8000
+ end)
+
+ assert log =~ "RealtimeDisabledForConfiguration"
+ assert log =~ "Unable to subscribe to changes with given parameters"
+ end
+
+ test "handle nil postgres changes params as empty param changes", %{
+ tenant: tenant,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer)
+ topic = "realtime:any"
+ config = %{postgres_changes: [nil]}
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic},
+ 200
+
+ refute_receive %Message{
+ event: "system",
+ payload: %{
+ "channel" => "any",
+ "extension" => "postgres_changes",
+ "message" => "Subscribed to PostgreSQL",
+ "status" => "ok"
+ },
+ ref: nil,
+ topic: ^topic
+ },
+ 1000
+ end
+ end
+end
diff --git a/test/integration/rt_channel/presence_test.exs b/test/integration/rt_channel/presence_test.exs
new file mode 100644
index 000000000..d4c125a10
--- /dev/null
+++ b/test/integration/rt_channel/presence_test.exs
@@ -0,0 +1,316 @@
+defmodule Realtime.Integration.RtChannel.PresenceTest do
+ use RealtimeWeb.ConnCase,
+ async: true,
+ parameterize: [
+ %{serializer: Phoenix.Socket.V1.JSONSerializer},
+ %{serializer: RealtimeWeb.Socket.V2Serializer}
+ ]
+
+ import ExUnit.CaptureLog
+ import Generators
+
+ alias Phoenix.Socket.Message
+ alias Realtime.Integration.WebsocketClient
+ alias Realtime.Tenants.Connect
+
+ @moduletag :capture_log
+
+ setup [:checkout_tenant_and_connect]
+
+ describe "public presence" do
+ setup [:rls_context]
+
+ test "public presence", %{tenant: tenant, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer)
+ config = %{presence: %{key: "", enabled: true}, private: false}
+ topic = "realtime:any"
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+ assert_receive %Message{event: "presence_state", payload: %{}, topic: ^topic}, 500
+
+ payload = %{
+ type: "presence",
+ event: "TRACK",
+ payload: %{name: "realtime_presence_96", t: 1814.7000000029802}
+ }
+
+ WebsocketClient.send_event(socket, topic, "presence", payload)
+
+ assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic}
+
+ join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd()
+ assert get_in(join_payload, ["name"]) == payload.payload.name
+ assert get_in(join_payload, ["t"]) == payload.payload.t
+ end
+
+ test "presence enabled if param enabled is set in configuration for public channels", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, topic, %{config: %{private: false, presence: %{enabled: true}}})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+ assert_receive %Message{event: "presence_state"}, 500
+ end
+
+ test "presence disabled if param 'enabled' is set to false in configuration for public channels", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, topic, %{config: %{private: false, presence: %{enabled: false}}})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+ refute_receive %Message{event: "presence_state"}, 500
+ end
+
+ test "presence automatically enabled when user sends track message for public channel", %{
+ tenant: tenant,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer)
+ config = %{presence: %{key: "", enabled: false}, private: false}
+ topic = "realtime:any"
+
+ WebsocketClient.join(socket, topic, %{config: config})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+ refute_receive %Message{event: "presence_state"}, 500
+
+ payload = %{
+ type: "presence",
+ event: "TRACK",
+ payload: %{name: "realtime_presence_96", t: 1814.7000000029802}
+ }
+
+ WebsocketClient.send_event(socket, topic, "presence", payload)
+
+ assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic}
+
+ join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd()
+ assert get_in(join_payload, ["name"]) == payload.payload.name
+ assert get_in(join_payload, ["t"]) == payload.payload.t
+ end
+ end
+
+ describe "private presence" do
+ setup [:rls_context]
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
+ test "private presence with read and write permissions will be able to track and receive presence changes",
+ %{tenant: tenant, topic: topic, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{presence: %{key: "", enabled: true}, private: true}
+ topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, topic, %{config: config})
+ assert_receive %Message{event: "presence_state", payload: %{}, topic: ^topic}, 500
+
+ payload = %{
+ type: "presence",
+ event: "TRACK",
+ payload: %{name: "realtime_presence_96", t: 1814.7000000029802}
+ }
+
+ WebsocketClient.send_event(socket, topic, "presence", payload)
+ refute_receive %Message{event: "phx_leave", topic: ^topic}
+ assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic}, 500
+ join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd()
+ assert get_in(join_payload, ["name"]) == payload.payload.name
+ assert get_in(join_payload, ["t"]) == payload.payload.t
+ end
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence],
+ mode: :distributed
+ test "private presence with read and write permissions will be able to track and receive presence changes using a remote node",
+ %{tenant: tenant, topic: topic, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{presence: %{key: "", enabled: true}, private: true}
+ topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, topic, %{config: config})
+ assert_receive %Message{event: "presence_state", payload: %{}, topic: ^topic}, 500
+
+ payload = %{
+ type: "presence",
+ event: "TRACK",
+ payload: %{name: "realtime_presence_96", t: 1814.7000000029802}
+ }
+
+ WebsocketClient.send_event(socket, topic, "presence", payload)
+ refute_receive %Message{event: "phx_leave", topic: ^topic}
+ assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic}, 500
+ join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd()
+ assert get_in(join_payload, ["name"]) == payload.payload.name
+ assert get_in(join_payload, ["t"]) == payload.payload.t
+ end
+
+ @tag policies: [:authenticated_read_broadcast_and_presence]
+ test "private presence with read permissions will be able to receive presence changes but won't be able to track",
+ %{tenant: tenant, topic: topic, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ {secondary_socket, _} = get_connection(tenant, serializer, role: "service_role")
+ config = fn key -> %{presence: %{key: key, enabled: true}, private: true} end
+ topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, topic, %{config: config.("authenticated")})
+
+ payload = %{
+ type: "presence",
+ event: "TRACK",
+ payload: %{name: "realtime_presence_96", t: 1814.7000000029802}
+ }
+
+ # This will be ignored
+ WebsocketClient.send_event(socket, topic, "presence", payload)
+
+ assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "ok"}}, 500
+ assert_receive %Message{event: "presence_state", payload: %{}, ref: nil, topic: ^topic}
+ refute_receive %Message{event: "presence_diff", payload: _, ref: _, topic: ^topic}
+
+ payload = %{
+ type: "presence",
+ event: "TRACK",
+ payload: %{name: "realtime_presence_97", t: 1814.7000000029802}
+ }
+
+ # This will be tracked
+ WebsocketClient.join(secondary_socket, topic, %{config: config.("service_role")})
+ WebsocketClient.send_event(secondary_socket, topic, "presence", payload)
+
+ assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "ok"}}, 500
+ assert_receive %Message{topic: ^topic, event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}}
+ assert_receive %Message{event: "presence_state", payload: %{}, ref: nil, topic: ^topic}
+
+ join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd()
+ assert get_in(join_payload, ["name"]) == payload.payload.name
+ assert get_in(join_payload, ["t"]) == payload.payload.t
+
+ assert_receive %Message{topic: ^topic, event: "presence_diff"} = res
+
+ assert join_payload =
+ res
+ |> Map.from_struct()
+ |> get_in([:payload, "joins", "service_role", "metas"])
+ |> hd()
+
+ assert get_in(join_payload, ["name"]) == payload.payload.name
+ assert get_in(join_payload, ["t"]) == payload.payload.t
+ end
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
+ test "presence enabled if param enabled is set in configuration for private channels", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, topic, %{config: %{private: true, presence: %{enabled: true}}})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+ assert_receive %Message{event: "presence_state"}, 500
+ end
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
+ test "presence disabled if param 'enabled' is set to false in configuration for private channels", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, topic, %{config: %{private: true, presence: %{enabled: false}}})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+ refute_receive %Message{event: "presence_state"}, 500
+ end
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
+ test "presence automatically enabled when user sends track message for private channel",
+ %{tenant: tenant, topic: topic, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{presence: %{key: "", enabled: false}, private: true}
+ topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, topic, %{config: config})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+ refute_receive %Message{event: "presence_state"}, 500
+
+ payload = %{
+ type: "presence",
+ event: "TRACK",
+ payload: %{name: "realtime_presence_96", t: 1814.7000000029802}
+ }
+
+ WebsocketClient.send_event(socket, topic, "presence", payload)
+
+ assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic}, 500
+ join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd()
+ assert get_in(join_payload, ["name"]) == payload.payload.name
+ assert get_in(join_payload, ["t"]) == payload.payload.t
+ end
+ end
+
+ describe "database connection errors" do
+ setup [:rls_context]
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
+ test "handles lack of connection to database error on private channels", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ topic = "realtime:#{topic}"
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ WebsocketClient.join(socket, topic, %{config: %{private: true, presence: %{enabled: true}}})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+ assert_receive %Message{event: "presence_state"}
+
+ log =
+ capture_log(fn ->
+ :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: nil} end)
+ payload = %{type: "presence", event: "TRACK", payload: %{name: "realtime_presence_96", t: 1814.7000000029802}}
+ WebsocketClient.send_event(socket, topic, "presence", payload)
+
+ refute_receive %Message{event: "presence_diff"}, 500
+ # Waiting more than 5 seconds as this is the amount of time we will wait for the Connection to be ready
+ refute_receive %Message{event: "phx_leave", topic: ^topic}, 16000
+ end)
+
+ assert log =~ ~r/external_id=#{tenant.external_id}.*UnableToHandlePresence/
+ end
+
+ @tag policies: []
+ test "lack of connection to database error does not impact public channels", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ topic = "realtime:#{topic}"
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ WebsocketClient.join(socket, topic, %{config: %{private: false, presence: %{enabled: true}}})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
+ assert_receive %Message{event: "presence_state"}
+
+ log =
+ capture_log(fn ->
+ :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: nil} end)
+ payload = %{type: "presence", event: "TRACK", payload: %{name: "realtime_presence_96", t: 1814.7000000029802}}
+ WebsocketClient.send_event(socket, topic, "presence", payload)
+
+ assert_receive %Message{event: "presence_diff"}, 500
+ refute_receive %Message{event: "phx_leave", topic: ^topic}
+ end)
+
+ refute log =~ ~r/external_id=#{tenant.external_id}.*UnableToHandlePresence/
+ end
+ end
+end
diff --git a/test/integration/rt_channel/token_handling_test.exs b/test/integration/rt_channel/token_handling_test.exs
new file mode 100644
index 000000000..d6d63037e
--- /dev/null
+++ b/test/integration/rt_channel/token_handling_test.exs
@@ -0,0 +1,460 @@
+defmodule Realtime.Integration.RtChannel.TokenHandlingTest do
+ use RealtimeWeb.ConnCase,
+ async: true,
+ parameterize: [%{serializer: Phoenix.Socket.V1.JSONSerializer}, %{serializer: RealtimeWeb.Socket.V2Serializer}]
+
+ import ExUnit.CaptureLog
+ import Generators
+
+ alias Phoenix.Socket.Message
+ alias Realtime.Database
+ alias Realtime.Integration.WebsocketClient
+
+ @moduletag :capture_log
+
+ setup [:checkout_tenant_and_connect]
+
+ describe "token validation" do
+ setup [:rls_context]
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
+ test "badly formatted jwt token", %{tenant: tenant, serializer: serializer} do
+ log =
+ capture_log(fn ->
+ WebsocketClient.connect(self(), uri(tenant, serializer), serializer, [{"x-api-key", "bad_token"}])
+ end)
+
+ assert log =~ "MalformedJWT: The token provided is not a valid JWT"
+ end
+
+ test "invalid JWT with expired token", %{tenant: tenant, serializer: serializer} do
+ log =
+ capture_log(fn ->
+ get_connection(tenant, serializer,
+ role: "authenticated",
+ claims: %{:exp => System.system_time(:second) - 1000},
+ params: %{log_level: :info}
+ )
+ end)
+
+ assert log =~ "InvalidJWTToken: Token has expired"
+ end
+
+ test "token required the role key", %{tenant: tenant, serializer: serializer} do
+ {:ok, token} = token_no_role(tenant)
+
+ assert {:error, %{status_code: 403}} =
+ WebsocketClient.connect(self(), uri(tenant, serializer), serializer, [{"x-api-key", token}])
+ end
+
+ test "handles connection with valid api-header but ignorable access_token payload", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ realtime_topic = "realtime:#{topic}"
+
+ log =
+ capture_log(fn ->
+ {:ok, token} =
+ generate_token(tenant, %{
+ exp: System.system_time(:second) + 1000,
+ role: "authenticated",
+ sub: random_string()
+ })
+
+ {:ok, socket} = WebsocketClient.connect(self(), uri(tenant, serializer), serializer, [{"x-api-key", token}])
+
+ WebsocketClient.join(socket, realtime_topic, %{
+ config: %{broadcast: %{self: true}, private: false},
+ access_token: "sb_#{random_string()}"
+ })
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+ end)
+
+ refute log =~ "MalformedJWT: The token provided is not a valid JWT"
+ end
+
+ test "missing claims close connection", %{tenant: tenant, topic: topic, serializer: serializer} do
+ {socket, access_token} = get_connection(tenant, serializer, role: "authenticated")
+
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ {:ok, token} = generate_token(tenant, %{:exp => System.system_time(:second) + 2000})
+
+ # Update token to be a near expiring token
+ WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => token})
+
+ assert_receive %Message{
+ event: "system",
+ payload: %{
+ "extension" => "system",
+ "message" => "Fields `role` and `exp` are required in JWT",
+ "status" => "error"
+ }
+ },
+ 500
+
+ assert_receive %Message{event: "phx_close"}
+ end
+ end
+
+ describe "access token refresh" do
+ setup [:rls_context]
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
+ test "on new access_token and channel is private policies are reevaluated for read policy",
+ %{tenant: tenant, topic: topic, serializer: serializer} do
+ {socket, access_token} = get_connection(tenant, serializer, role: "authenticated")
+
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{
+ config: %{broadcast: %{self: true}, private: true},
+ access_token: access_token
+ })
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ {:ok, new_token} = token_valid(tenant, "anon")
+
+ WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => new_token})
+
+ error_message = "You do not have permissions to read from this Channel topic: #{topic}"
+
+ assert_receive %Message{
+ event: "system",
+ payload: %{"channel" => ^topic, "extension" => "system", "message" => ^error_message, "status" => "error"},
+ topic: ^realtime_topic
+ }
+
+ assert_receive %Message{event: "phx_close", topic: ^realtime_topic}
+ end
+
+ @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
+ test "on new access_token and channel is private policies are reevaluated for write policy", %{
+ topic: topic,
+ tenant: tenant,
+ serializer: serializer
+ } do
+ {socket, access_token} = get_connection(tenant, serializer, role: "authenticated")
+ realtime_topic = "realtime:#{topic}"
+ config = %{broadcast: %{self: true}, private: true}
+ WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ # Checks first send which will set write policy to true
+ payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
+ WebsocketClient.send_event(socket, realtime_topic, "broadcast", payload)
+
+ assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^realtime_topic}, 500
+
+ # RLS policies changed to only allow read
+ {:ok, db_conn} = Database.connect(tenant, "realtime_test")
+ clean_table(db_conn, "realtime", "messages")
+ create_rls_policies(db_conn, [:authenticated_read_broadcast_and_presence], %{topic: topic})
+
+ # Set new token to recheck policies
+ {:ok, new_token} =
+ generate_token(tenant, %{exp: System.system_time(:second) + 1000, role: "authenticated", sub: random_string()})
+
+ WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => new_token})
+
+ # Send message to be ignored
+ payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
+ WebsocketClient.send_event(socket, realtime_topic, "broadcast", payload)
+
+ refute_receive %Message{
+ event: "broadcast",
+ payload: ^payload,
+ topic: ^realtime_topic
+ },
+ 1500
+ end
+
+ test "on new access_token and channel is public policies are not reevaluated", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ {socket, access_token} = get_connection(tenant, serializer, role: "authenticated")
+ {:ok, new_token} = token_valid(tenant, "anon")
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => new_token})
+
+ refute_receive %Message{}
+ end
+
+ test "on empty string access_token the socket sends an error message", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ {socket, access_token} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => ""})
+
+ assert_receive %Message{
+ topic: ^realtime_topic,
+ event: "system",
+ payload: %{
+ "extension" => "system",
+ "message" => msg,
+ "status" => "error"
+ }
+ }
+
+ assert_receive %Message{event: "phx_close"}
+ assert msg =~ "The token provided is not a valid JWT"
+ end
+
+ test "on expired access_token the socket sends an error message", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ sub = random_string()
+
+ {socket, access_token} = get_connection(tenant, serializer, role: "authenticated", claims: %{sub: sub})
+
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ {:ok, token} = generate_token(tenant, %{:exp => System.system_time(:second) - 1000, sub: sub})
+
+ log =
+ capture_log(fn ->
+ WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => token})
+
+ assert_receive %Message{
+ topic: ^realtime_topic,
+ event: "system",
+ payload: %{"extension" => "system", "message" => "Token has expired " <> _, "status" => "error"}
+ }
+
+ assert_receive %Message{event: "phx_close", topic: ^realtime_topic}
+ end)
+
+ assert log =~ "ChannelShutdown: Token has expired"
+ end
+
+ test "ChannelShutdown include sub if available in jwt claims", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ exp = System.system_time(:second) + 10_000
+
+ {socket, access_token} =
+ get_connection(tenant, serializer, role: "authenticated", claims: %{exp: exp}, params: %{log_level: :warning})
+
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic = "realtime:#{topic}"
+ sub = random_string()
+ WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500
+
+ {:ok, token} = generate_token(tenant, %{:exp => System.system_time(:second) - 1000, sub: sub})
+
+ log =
+ capture_log([level: :warning], fn ->
+ WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => token})
+
+ assert_receive %Message{event: "system"}, 1000
+ assert_receive %Message{event: "phx_close", topic: ^realtime_topic}
+ end)
+
+ assert log =~ "ChannelShutdown"
+ assert log =~ "sub=#{sub}"
+ end
+
+ test "on sb prefixed access_token the socket ignores the message and respects JWT expiry time", %{
+ tenant: tenant,
+ topic: topic,
+ serializer: serializer
+ } do
+ sub = random_string()
+
+ {socket, access_token} =
+ get_connection(tenant, serializer,
+ role: "authenticated",
+ claims: %{sub: sub, exp: System.system_time(:second) + 5}
+ )
+
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ WebsocketClient.send_event(socket, realtime_topic, "access_token", %{
+ "access_token" => "sb_publishable_-fake_key"
+ })
+
+ # Check if the new token does not trigger a shutdown
+ refute_receive %Message{event: "system", topic: ^realtime_topic}, 100
+
+ # Await to check if channel respects token expiry time
+ assert_receive %Message{
+ event: "system",
+ payload: %{"extension" => "system", "message" => msg, "status" => "error"},
+ topic: ^realtime_topic
+ },
+ 5000
+
+ assert_receive %Message{event: "phx_close", topic: ^realtime_topic}
+ assert msg =~ "Token has expired"
+ end
+ end
+
+ describe "token expiry" do
+ setup [:rls_context]
+
+ test "checks token periodically", %{tenant: tenant, topic: topic, serializer: serializer} do
+ {socket, access_token} = get_connection(tenant, serializer, role: "authenticated")
+
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ {:ok, token} =
+ generate_token(tenant, %{:exp => System.system_time(:second) + 2, role: "authenticated"})
+
+ # Update token to be a near expiring token
+ WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => token})
+
+ # Awaits to see if connection closes automatically
+ assert_receive %Message{
+ event: "system",
+ payload: %{"extension" => "system", "message" => msg, "status" => "error"}
+ },
+ 3000
+
+ assert_receive %Message{event: "phx_close"}
+
+ assert msg =~ "Token has expired"
+ end
+
+ test "token expires in between joins", %{tenant: tenant, topic: topic, serializer: serializer} do
+ {socket, access_token} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ {:ok, access_token} =
+ generate_token(tenant, %{:exp => System.system_time(:second) + 1, role: "authenticated"})
+
+ # token expires in between joins so it needs to be handled by the channel and not the socket
+ Process.sleep(1000)
+ realtime_topic = "realtime:#{topic}"
+
+ log =
+ capture_log(fn ->
+ WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
+
+ assert_receive %Message{
+ event: "phx_reply",
+ payload: %{
+ "status" => "error",
+ "response" => %{"reason" => reason}
+ },
+ topic: ^realtime_topic
+ },
+ 500
+
+ assert reason =~ "InvalidJWTToken: Token has expired"
+ end)
+
+ assert_receive %Message{event: "phx_close"}
+ assert log =~ "#{tenant.external_id}"
+ end
+
+ test "token loses claims in between joins", %{tenant: tenant, topic: topic, serializer: serializer} do
+ {socket, access_token} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ {:ok, access_token} = generate_token(tenant, %{:exp => System.system_time(:second) + 10})
+
+ # token breaks claims in between joins so it needs to be handled by the channel and not the socket
+ realtime_topic = "realtime:#{topic}"
+ WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
+
+ assert_receive %Message{
+ event: "phx_reply",
+ payload: %{
+ "status" => "error",
+ "response" => %{
+ "reason" => "InvalidJWTToken: Fields `role` and `exp` are required in JWT"
+ }
+ },
+ topic: ^realtime_topic
+ },
+ 500
+
+ assert_receive %Message{event: "phx_close"}
+ end
+
+ test "token is badly formatted in between joins", %{tenant: tenant, topic: topic, serializer: serializer} do
+ {socket, access_token} = get_connection(tenant, serializer, role: "authenticated")
+ config = %{broadcast: %{self: true}, private: false}
+ realtime_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
+
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+
+ # token becomes a string in between joins so it needs to be handled by the channel and not the socket
+ WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: "potato"})
+
+ assert_receive %Message{
+ event: "phx_reply",
+ payload: %{
+ "status" => "error",
+ "response" => %{
+ "reason" => "MalformedJWT: The token provided is not a valid JWT"
+ }
+ },
+ topic: ^realtime_topic
+ },
+ 500
+
+ assert_receive %Message{event: "phx_close"}
+ end
+ end
+end
diff --git a/test/integration/rt_channel/wal_bloat_test.exs b/test/integration/rt_channel/wal_bloat_test.exs
new file mode 100644
index 000000000..a75942287
--- /dev/null
+++ b/test/integration/rt_channel/wal_bloat_test.exs
@@ -0,0 +1,184 @@
+defmodule Realtime.Integration.RtChannel.WalBloatTest do
+ use RealtimeWeb.ConnCase,
+ async: false,
+ parameterize: [
+ %{serializer: Phoenix.Socket.V1.JSONSerializer},
+ %{serializer: RealtimeWeb.Socket.V2Serializer}
+ ]
+
+ import Generators
+
+ alias Phoenix.Socket.Message
+ alias Postgrex
+ alias Realtime.Database
+ alias Realtime.Integration.WebsocketClient
+ alias Realtime.Tenants.Connect
+ alias Realtime.Tenants.ReplicationConnection
+
+ @moduletag :capture_log
+
+ setup [:checkout_tenant_and_connect]
+
+ describe "WAL bloat handling" do
+ setup %{tenant: tenant} do
+ topic = random_string()
+ {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
+
+ %{rows: [[max_wal_size]]} = Postgrex.query!(db_conn, "SHOW max_wal_size", [])
+ %{rows: [[wal_keep_size]]} = Postgrex.query!(db_conn, "SHOW wal_keep_size", [])
+ %{rows: [[max_slot_wal_keep_size]]} = Postgrex.query!(db_conn, "SHOW max_slot_wal_keep_size", [])
+
+ assert max_wal_size == "32MB"
+ assert wal_keep_size == "32MB"
+ assert max_slot_wal_keep_size == "32MB"
+
+ Postgrex.query!(db_conn, "CREATE TABLE IF NOT EXISTS wal_test (id INT, data TEXT)", [])
+
+ Postgrex.query!(
+ db_conn,
+ """
+ CREATE OR REPLACE FUNCTION wal_test_trigger_func() RETURNS TRIGGER AS $$
+ BEGIN
+ PERFORM realtime.send(json_build_object ('value', 'test' :: text)::jsonb, 'test', '#{topic}', false);
+ RETURN NULL;
+ END;
+ $$ LANGUAGE plpgsql;
+ """,
+ []
+ )
+
+ Postgrex.query!(db_conn, "DROP TRIGGER IF EXISTS wal_test_trigger ON wal_test", [])
+
+ Postgrex.query!(
+ db_conn,
+ """
+ CREATE TRIGGER wal_test_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON wal_test
+ FOR EACH ROW
+ EXECUTE FUNCTION wal_test_trigger_func()
+ """,
+ []
+ )
+
+ GenServer.stop(db_conn)
+
+ on_exit(fn ->
+ {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
+
+ Postgrex.query!(db_conn, "DROP TABLE IF EXISTS wal_test CASCADE", [])
+ GenServer.stop(db_conn)
+ end)
+
+ %{topic: topic}
+ end
+
+ @tag timeout: :timer.minutes(3)
+ test "track PID changes during WAL bloat creation", %{tenant: tenant, topic: topic, serializer: serializer} do
+ {socket, _} = get_connection(tenant, serializer, role: "authenticated")
+ full_topic = "realtime:#{topic}"
+
+ WebsocketClient.join(socket, full_topic, %{config: %{broadcast: %{self: true}, private: false}})
+ assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
+ assert Connect.ready?(tenant.external_id)
+
+ {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
+ original_connect_pid = Connect.whereis(tenant.external_id)
+ # Replication now starts asynchronously, so wait for the slot to be active before
+ # reading the replication pid (it would otherwise race and return nil).
+ await_replication_slot_active(db_conn, 30, 500)
+ original_db_pid = active_replication_slot_pid!(db_conn)
+ original_replication_pid = ReplicationConnection.whereis(tenant.external_id)
+
+ replication_ref = Process.monitor(original_replication_pid)
+
+ generate_wal_bloat(tenant)
+ terminate_bloat_connections(db_conn)
+
+ assert_receive {:DOWN, ^replication_ref, :process, ^original_replication_pid, _}, 60_000
+
+ assert Connect.ready?(tenant.external_id)
+ {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
+ new_db_pid = await_replication_slot_active(db_conn, 60, 1000)
+
+ assert new_db_pid != original_db_pid
+ assert ^original_connect_pid = Connect.whereis(tenant.external_id)
+ assert original_replication_pid != ReplicationConnection.whereis(tenant.external_id)
+
+ payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
+ WebsocketClient.send_event(socket, full_topic, "broadcast", payload)
+ assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^full_topic}, 500
+
+ Postgrex.query!(db_conn, "INSERT INTO wal_test VALUES (1, 'test')", [])
+
+ assert_receive %Message{
+ event: "broadcast",
+ payload: %{
+ "event" => "test",
+ "payload" => %{"value" => "test"},
+ "type" => "broadcast"
+ },
+ join_ref: nil,
+ ref: nil,
+ topic: ^full_topic
+ },
+ 5000
+ end
+ end
+
+ defp active_replication_slot_pid!(db_conn) do
+ %{rows: [[pid]]} =
+ Postgrex.query!(
+ db_conn,
+ "SELECT active_pid FROM pg_replication_slots WHERE active_pid IS NOT NULL AND slot_name = 'supabase_realtime_messages_replication_slot_'",
+ []
+ )
+
+ pid
+ end
+
+ defp await_replication_slot_active(db_conn, retries, interval_ms) do
+ Enum.reduce_while(1..retries, nil, fn _, _ ->
+ case Postgrex.query!(
+ db_conn,
+ "SELECT active_pid FROM pg_replication_slots WHERE active_pid IS NOT NULL AND slot_name = 'supabase_realtime_messages_replication_slot_'",
+ []
+ ) do
+ %{rows: [[pid]]} ->
+ {:halt, pid}
+
+ _ ->
+ Process.sleep(interval_ms)
+ {:cont, nil}
+ end
+ end)
+ |> then(fn
+ nil -> flunk("Replication slot did not become active within #{retries}s")
+ pid -> pid
+ end)
+ end
+
+ defp generate_wal_bloat(tenant) do
+ 1..5
+ |> Enum.map(fn _ ->
+ Task.async(fn ->
+ {:ok, conn} = Database.connect(tenant, "realtime_bloat", :stop)
+
+ Postgrex.transaction(conn, fn tx ->
+ Postgrex.query(tx, "INSERT INTO wal_test SELECT generate_series(1, 100000), repeat('x', 2000)", [])
+ {:error, "test"}
+ end)
+
+ Process.exit(conn, :normal)
+ end)
+ end)
+ |> Task.await_many(20_000)
+ end
+
+ defp terminate_bloat_connections(db_conn) do
+ Postgrex.query!(
+ db_conn,
+ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE application_name = 'realtime_bloat'",
+ []
+ )
+ end
+end
diff --git a/test/integration/rt_channel_test.exs b/test/integration/rt_channel_test.exs
deleted file mode 100644
index 806a5ad7e..000000000
--- a/test/integration/rt_channel_test.exs
+++ /dev/null
@@ -1,2414 +0,0 @@
-defmodule Realtime.Integration.RtChannelTest do
- # async: false due to the fact that multiple operations against the same tenant and usage of mocks
- # Also using dev_tenant due to distributed test
- alias Realtime.Api
- use RealtimeWeb.ConnCase, async: false
- use Mimic
- import ExUnit.CaptureLog
- import Generators
-
- setup :set_mimic_global
-
- require Logger
-
- alias Extensions.PostgresCdcRls
-
- alias Phoenix.Socket.Message
- alias Phoenix.Socket.V1
-
- alias Postgrex
-
- alias Realtime.Api.Tenant
- alias Realtime.Database
- alias Realtime.Integration.WebsocketClient
- alias Realtime.RateCounter
- alias Realtime.Tenants
- alias Realtime.Tenants.Authorization
- alias Realtime.Tenants.Connect
-
- alias RealtimeWeb.RealtimeChannel.Tracker
- alias RealtimeWeb.SocketDisconnect
-
- @moduletag :capture_log
- @port 4003
- @serializer V1.JSONSerializer
-
- Application.put_env(:phoenix, TestEndpoint,
- https: false,
- http: [port: @port],
- debug_errors: false,
- server: true,
- pubsub_server: __MODULE__,
- secret_key_base: String.duplicate("a", 64)
- )
-
- setup_all do
- capture_log(fn -> start_supervised!(TestEndpoint) end)
- start_supervised!({Phoenix.PubSub, name: __MODULE__})
- :ok
- end
-
- setup [:mode]
-
- describe "postgres changes" do
- setup %{tenant: tenant} do
- {:ok, conn} = Database.connect(tenant, "realtime_test")
-
- Database.transaction(conn, fn db_conn ->
- queries = [
- "drop table if exists public.test",
- "drop publication if exists supabase_realtime_test",
- "create sequence if not exists test_id_seq;",
- """
- create table if not exists "public"."test" (
- "id" int4 not null default nextval('test_id_seq'::regclass),
- "details" text,
- primary key ("id"));
- """,
- "grant all on table public.test to anon;",
- "grant all on table public.test to postgres;",
- "grant all on table public.test to authenticated;",
- "create publication supabase_realtime_test for all tables"
- ]
-
- Enum.each(queries, &Postgrex.query!(db_conn, &1, []))
- end)
-
- :ok
- end
-
- test "error subscribing", %{tenant: tenant} do
- {:ok, conn} = Database.connect(tenant, "realtime_test")
-
- # Let's drop the publication to cause an error
- Database.transaction(conn, fn db_conn ->
- Postgrex.query!(db_conn, "drop publication if exists supabase_realtime_test")
- end)
-
- {socket, _} = get_connection(tenant)
- topic = "realtime:any"
- config = %{postgres_changes: [%{event: "INSERT", schema: "public"}]}
-
- log =
- capture_log(fn ->
- WebsocketClient.join(socket, topic, %{config: config})
-
- assert_receive %Message{
- event: "system",
- payload: %{
- "channel" => "any",
- "extension" => "postgres_changes",
- "message" =>
- "{:error, \"Unable to subscribe to changes with given parameters. Please check Realtime is enabled for the given connect parameters: [event: INSERT, schema: public]\"}",
- "status" => "error"
- },
- ref: nil,
- topic: ^topic
- },
- 8000
- end)
-
- assert log =~ "RealtimeDisabledForConfiguration"
- assert log =~ "Unable to subscribe to changes with given parameters"
- end
-
- test "handle insert", %{tenant: tenant} do
- {socket, _} = get_connection(tenant)
- topic = "realtime:any"
- config = %{postgres_changes: [%{event: "INSERT", schema: "public"}]}
-
- WebsocketClient.join(socket, topic, %{config: config})
- sub_id = :erlang.phash2(%{"event" => "INSERT", "schema" => "public"})
-
- assert_receive %Message{
- event: "phx_reply",
- payload: %{
- "response" => %{
- "postgres_changes" => [
- %{"event" => "INSERT", "id" => ^sub_id, "schema" => "public"}
- ]
- },
- "status" => "ok"
- },
- topic: ^topic
- },
- 200
-
- assert_receive %Phoenix.Socket.Message{event: "presence_state", payload: %{}, topic: ^topic}, 500
-
- assert_receive %Message{
- event: "system",
- payload: %{
- "channel" => "any",
- "extension" => "postgres_changes",
- "message" => "Subscribed to PostgreSQL",
- "status" => "ok"
- },
- ref: nil,
- topic: ^topic
- },
- 8000
-
- {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
- %{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", [])
-
- assert_receive %Message{
- event: "postgres_changes",
- payload: %{
- "data" => %{
- "columns" => [
- %{"name" => "id", "type" => "int4"},
- %{"name" => "details", "type" => "text"}
- ],
- "commit_timestamp" => _ts,
- "errors" => nil,
- "record" => %{"details" => "test", "id" => ^id},
- "schema" => "public",
- "table" => "test",
- "type" => "INSERT"
- },
- "ids" => [^sub_id]
- },
- ref: nil,
- topic: "realtime:any"
- },
- 500
- end
-
- test "handle update", %{tenant: tenant} do
- {socket, _} = get_connection(tenant)
- topic = "realtime:any"
- config = %{postgres_changes: [%{event: "UPDATE", schema: "public"}]}
-
- WebsocketClient.join(socket, topic, %{config: config})
- sub_id = :erlang.phash2(%{"event" => "UPDATE", "schema" => "public"})
-
- assert_receive %Message{
- event: "phx_reply",
- payload: %{
- "response" => %{
- "postgres_changes" => [
- %{"event" => "UPDATE", "id" => ^sub_id, "schema" => "public"}
- ]
- },
- "status" => "ok"
- },
- ref: "1",
- topic: ^topic
- },
- 200
-
- assert_receive %Phoenix.Socket.Message{event: "presence_state", payload: %{}, topic: ^topic}, 500
-
- assert_receive %Message{
- event: "system",
- payload: %{
- "channel" => "any",
- "extension" => "postgres_changes",
- "message" => "Subscribed to PostgreSQL",
- "status" => "ok"
- },
- ref: nil,
- topic: ^topic
- },
- 8000
-
- {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
- %{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", [])
-
- Postgrex.query!(conn, "update test set details = 'test' where id = #{id}", [])
-
- assert_receive %Message{
- event: "postgres_changes",
- payload: %{
- "data" => %{
- "columns" => [
- %{"name" => "id", "type" => "int4"},
- %{"name" => "details", "type" => "text"}
- ],
- "commit_timestamp" => _ts,
- "errors" => nil,
- "old_record" => %{"id" => ^id},
- "record" => %{"details" => "test", "id" => ^id},
- "schema" => "public",
- "table" => "test",
- "type" => "UPDATE"
- },
- "ids" => [^sub_id]
- },
- ref: nil,
- topic: "realtime:any"
- },
- 500
- end
-
- test "handle delete", %{tenant: tenant} do
- {socket, _} = get_connection(tenant)
- topic = "realtime:any"
- config = %{postgres_changes: [%{event: "DELETE", schema: "public"}]}
-
- WebsocketClient.join(socket, topic, %{config: config})
- sub_id = :erlang.phash2(%{"event" => "DELETE", "schema" => "public"})
-
- assert_receive %Message{
- event: "phx_reply",
- payload: %{
- "response" => %{
- "postgres_changes" => [
- %{"event" => "DELETE", "id" => ^sub_id, "schema" => "public"}
- ]
- },
- "status" => "ok"
- },
- ref: "1",
- topic: ^topic
- },
- 200
-
- assert_receive %Phoenix.Socket.Message{event: "presence_state", payload: %{}, topic: ^topic}, 500
-
- assert_receive %Message{
- event: "system",
- payload: %{
- "channel" => "any",
- "extension" => "postgres_changes",
- "message" => "Subscribed to PostgreSQL",
- "status" => "ok"
- },
- ref: nil,
- topic: ^topic
- },
- 8000
-
- {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
- %{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", [])
- Postgrex.query!(conn, "delete from test where id = #{id}", [])
-
- assert_receive %Message{
- event: "postgres_changes",
- payload: %{
- "data" => %{
- "columns" => [
- %{"name" => "id", "type" => "int4"},
- %{"name" => "details", "type" => "text"}
- ],
- "commit_timestamp" => _ts,
- "errors" => nil,
- "old_record" => %{"id" => ^id},
- "schema" => "public",
- "table" => "test",
- "type" => "DELETE"
- },
- "ids" => [^sub_id]
- },
- ref: nil,
- topic: "realtime:any"
- },
- 500
- end
-
- test "handle wildcard", %{tenant: tenant} do
- {socket, _} = get_connection(tenant)
- topic = "realtime:any"
- config = %{postgres_changes: [%{event: "*", schema: "public"}]}
-
- WebsocketClient.join(socket, topic, %{config: config})
- sub_id = :erlang.phash2(%{"event" => "*", "schema" => "public"})
-
- assert_receive %Message{
- event: "phx_reply",
- payload: %{
- "response" => %{
- "postgres_changes" => [
- %{"event" => "*", "id" => ^sub_id, "schema" => "public"}
- ]
- },
- "status" => "ok"
- },
- ref: "1",
- topic: ^topic
- },
- 200
-
- assert_receive %Phoenix.Socket.Message{event: "presence_state", payload: %{}, topic: ^topic}, 500
-
- assert_receive %Message{
- event: "system",
- payload: %{
- "channel" => "any",
- "extension" => "postgres_changes",
- "message" => "Subscribed to PostgreSQL",
- "status" => "ok"
- },
- ref: nil,
- topic: ^topic
- },
- 8000
-
- {:ok, _, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
- %{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", [])
-
- assert_receive %Message{
- event: "postgres_changes",
- payload: %{
- "data" => %{
- "columns" => [
- %{"name" => "id", "type" => "int4"},
- %{"name" => "details", "type" => "text"}
- ],
- "commit_timestamp" => _ts,
- "errors" => nil,
- "record" => %{"id" => ^id},
- "schema" => "public",
- "table" => "test",
- "type" => "INSERT"
- },
- "ids" => [^sub_id]
- },
- ref: nil,
- topic: "realtime:any"
- },
- 500
-
- Postgrex.query!(conn, "update test set details = 'test' where id = #{id}", [])
-
- assert_receive %Message{
- event: "postgres_changes",
- payload: %{
- "data" => %{
- "columns" => [
- %{"name" => "id", "type" => "int4"},
- %{"name" => "details", "type" => "text"}
- ],
- "commit_timestamp" => _ts,
- "errors" => nil,
- "old_record" => %{"id" => ^id},
- "record" => %{"details" => "test", "id" => ^id},
- "schema" => "public",
- "table" => "test",
- "type" => "UPDATE"
- },
- "ids" => [^sub_id]
- },
- ref: nil,
- topic: "realtime:any"
- },
- 500
-
- Postgrex.query!(conn, "delete from test where id = #{id}", [])
-
- assert_receive %Message{
- event: "postgres_changes",
- payload: %{
- "data" => %{
- "columns" => [
- %{"name" => "id", "type" => "int4"},
- %{"name" => "details", "type" => "text"}
- ],
- "commit_timestamp" => _ts,
- "errors" => nil,
- "old_record" => %{"id" => ^id},
- "schema" => "public",
- "table" => "test",
- "type" => "DELETE"
- },
- "ids" => [^sub_id]
- },
- ref: nil,
- topic: "realtime:any"
- },
- 500
- end
-
- test "handle nil postgres changes params as empty param changes", %{tenant: tenant} do
- {socket, _} = get_connection(tenant)
- topic = "realtime:any"
- config = %{postgres_changes: [nil]}
-
- WebsocketClient.join(socket, topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 200
- assert_receive %Phoenix.Socket.Message{event: "presence_state", payload: %{}, topic: ^topic}, 500
-
- refute_receive %Message{
- event: "system",
- payload: %{
- "channel" => "any",
- "extension" => "postgres_changes",
- "message" => "Subscribed to PostgreSQL",
- "status" => "ok"
- },
- ref: nil,
- topic: ^topic
- },
- 1000
- end
- end
-
- describe "handle broadcast extension" do
- setup [:rls_context]
-
- test "public broadcast", %{tenant: tenant} do
- {socket, _} = get_connection(tenant)
- config = %{broadcast: %{self: true}, private: false}
- topic = "realtime:any"
- WebsocketClient.join(socket, topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{event: "presence_state"}
-
- payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
- WebsocketClient.send_event(socket, topic, "broadcast", payload)
-
- assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500
- end
-
- test "broadcast to another tenant does not get mixed up", %{tenant: tenant} do
- {socket, _} = get_connection(tenant)
- config = %{broadcast: %{self: false}, private: false}
- topic = "realtime:any"
- WebsocketClient.join(socket, topic, %{config: config})
-
- other_tenant = Containers.checkout_tenant(run_migrations: true)
-
- {other_socket, _} = get_connection(other_tenant)
- WebsocketClient.join(other_socket, topic, %{config: config})
-
- # Both sockets joined
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{event: "presence_state"}
- assert_receive %Message{event: "presence_state"}
-
- payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
- WebsocketClient.send_event(socket, topic, "broadcast", payload)
-
- # No message received
- refute_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
- test "private broadcast with valid channel with permissions sends message", %{tenant: tenant, topic: topic} do
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: true}
- topic = "realtime:#{topic}"
- WebsocketClient.join(socket, topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{event: "presence_state"}
-
- payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
- WebsocketClient.send_event(socket, topic, "broadcast", payload)
-
- assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence],
- mode: :distributed
- test "private broadcast with valid channel with permissions sends message using a remote node (phoenix adapter)", %{
- tenant: tenant,
- topic: topic
- } do
- {:ok, token} =
- generate_token(tenant, %{exp: System.system_time(:second) + 1000, role: "authenticated", sub: random_string()})
-
- {:ok, remote_socket} = WebsocketClient.connect(self(), uri(tenant, 4012), @serializer, [{"x-api-key", token}])
- {:ok, socket} = WebsocketClient.connect(self(), uri(tenant), @serializer, [{"x-api-key", token}])
-
- config = %{broadcast: %{self: false}, private: true}
- topic = "realtime:#{topic}"
-
- WebsocketClient.join(remote_socket, topic, %{config: config})
- WebsocketClient.join(socket, topic, %{config: config})
-
- # Send through one socket and receive through the other (self: false)
- payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
- WebsocketClient.send_event(socket, topic, "broadcast", payload)
-
- assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence],
- mode: :distributed
- test "private broadcast with valid channel with permissions sends message using a remote node", %{
- tenant: tenant,
- topic: topic
- } do
- {:ok, token} =
- generate_token(tenant, %{exp: System.system_time(:second) + 1000, role: "authenticated", sub: random_string()})
-
- {:ok, remote_socket} = WebsocketClient.connect(self(), uri(tenant, 4012), @serializer, [{"x-api-key", token}])
- {:ok, socket} = WebsocketClient.connect(self(), uri(tenant), @serializer, [{"x-api-key", token}])
-
- config = %{broadcast: %{self: false}, private: true}
- topic = "realtime:#{topic}"
-
- WebsocketClient.join(remote_socket, topic, %{config: config})
- WebsocketClient.join(socket, topic, %{config: config})
-
- # Send through one socket and receive through the other (self: false)
- payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
- WebsocketClient.send_event(socket, topic, "broadcast", payload)
- assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence],
- topic: "topic"
- test "private broadcast with valid channel a colon character sends message and won't intercept in public channels",
- %{topic: topic, tenant: tenant} do
- {anon_socket, _} = get_connection(tenant, "anon")
- {socket, _} = get_connection(tenant, "authenticated")
- valid_topic = "realtime:#{topic}"
- malicious_topic = "realtime:private:#{topic}"
-
- WebsocketClient.join(socket, valid_topic, %{config: %{broadcast: %{self: true}, private: true}})
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^valid_topic}, 300
- assert_receive %Message{event: "presence_state"}
-
- WebsocketClient.join(anon_socket, malicious_topic, %{config: %{broadcast: %{self: true}, private: false}})
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^malicious_topic}, 300
- assert_receive %Message{event: "presence_state"}
-
- payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
- WebsocketClient.send_event(socket, valid_topic, "broadcast", payload)
-
- assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^valid_topic}, 500
- refute_receive %Message{event: "broadcast"}
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence]
- test "private broadcast with valid channel no write permissions won't send message but will receive message", %{
- tenant: tenant,
- topic: topic
- } do
- config = %{broadcast: %{self: true}, private: true}
- topic = "realtime:#{topic}"
-
- {service_role_socket, _} = get_connection(tenant, "service_role")
- WebsocketClient.join(service_role_socket, topic, %{config: config})
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{event: "presence_state"}
-
- {socket, _} = get_connection(tenant, "authenticated")
- WebsocketClient.join(socket, topic, %{config: config})
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{event: "presence_state"}
-
- payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
-
- WebsocketClient.send_event(socket, topic, "broadcast", payload)
- refute_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500
-
- WebsocketClient.send_event(service_role_socket, topic, "broadcast", payload)
- assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500
- assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500
- end
-
- @tag policies: []
- test "private broadcast with valid channel and no read permissions won't join", %{tenant: tenant, topic: topic} do
- config = %{private: true}
- expected = "Unauthorized: You do not have permissions to read from this Channel topic: #{topic}"
-
- topic = "realtime:#{topic}"
- {socket, _} = get_connection(tenant, "authenticated")
-
- log =
- capture_log(fn ->
- WebsocketClient.join(socket, topic, %{config: config})
-
- assert_receive %Message{
- topic: ^topic,
- event: "phx_reply",
- payload: %{
- "response" => %{
- "reason" => ^expected
- },
- "status" => "error"
- }
- },
- 300
-
- refute_receive %Message{event: "phx_reply", topic: ^topic}, 300
- refute_receive %Message{event: "presence_state"}, 300
- end)
-
- assert log =~ expected
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence]
- test "handles lack of connection to database error on private channels", %{tenant: tenant, topic: topic} do
- topic = "realtime:#{topic}"
- {socket, _} = get_connection(tenant, "authenticated")
- WebsocketClient.join(socket, topic, %{config: %{broadcast: %{self: true}, private: true}})
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{event: "presence_state"}
-
- {service_role_socket, _} = get_connection(tenant, "service_role")
- WebsocketClient.join(service_role_socket, topic, %{config: %{broadcast: %{self: false}, private: true}})
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{event: "presence_state"}
-
- log =
- capture_log(fn ->
- :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: nil} end)
- payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
- WebsocketClient.send_event(service_role_socket, topic, "broadcast", payload)
- # Waiting more than 5 seconds as this is the amount of time we will wait for the Connection to be ready
- refute_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 6000
- end)
-
- assert log =~ "UnableToHandleBroadcast"
- end
-
- @tag policies: []
- test "lack of connection to database error does not impact public channels", %{tenant: tenant, topic: topic} do
- topic = "realtime:#{topic}"
- {socket, _} = get_connection(tenant, "authenticated")
- WebsocketClient.join(socket, topic, %{config: %{broadcast: %{self: true}, private: false}})
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{event: "presence_state"}
-
- {service_role_socket, _} = get_connection(tenant, "service_role")
- WebsocketClient.join(service_role_socket, topic, %{config: %{broadcast: %{self: false}, private: false}})
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{event: "presence_state"}
-
- log =
- capture_log(fn ->
- :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: nil} end)
- payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
- WebsocketClient.send_event(service_role_socket, topic, "broadcast", payload)
- assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^topic}, 500
- end)
-
- refute log =~ "UnableToHandleBroadcast"
- end
- end
-
- describe "handle presence extension" do
- setup [:rls_context]
-
- test "public presence", %{tenant: tenant} do
- {socket, _} = get_connection(tenant)
- config = %{presence: %{key: "", enabled: true}, private: false}
- topic = "realtime:any"
-
- WebsocketClient.join(socket, topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{event: "presence_state", payload: %{}, topic: ^topic}, 500
-
- payload = %{
- type: "presence",
- event: "TRACK",
- payload: %{name: "realtime_presence_96", t: 1814.7000000029802}
- }
-
- WebsocketClient.send_event(socket, topic, "presence", payload)
-
- assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic}
-
- join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd()
- assert get_in(join_payload, ["name"]) == payload.payload.name
- assert get_in(join_payload, ["t"]) == payload.payload.t
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
- test "private presence with read and write permissions will be able to track and receive presence changes",
- %{tenant: tenant, topic: topic} do
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{presence: %{key: "", enabled: true}, private: true}
- topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, topic, %{config: config})
- assert_receive %Message{event: "presence_state", payload: %{}, topic: ^topic}, 500
-
- payload = %{
- type: "presence",
- event: "TRACK",
- payload: %{name: "realtime_presence_96", t: 1814.7000000029802}
- }
-
- WebsocketClient.send_event(socket, topic, "presence", payload)
- refute_receive %Message{event: "phx_leave", topic: ^topic}
- assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic}, 500
- join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd()
- assert get_in(join_payload, ["name"]) == payload.payload.name
- assert get_in(join_payload, ["t"]) == payload.payload.t
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence],
- mode: :distributed
- test "private presence with read and write permissions will be able to track and receive presence changes using a remote node",
- %{tenant: tenant, topic: topic} do
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{presence: %{key: "", enabled: true}, private: true}
- topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, topic, %{config: config})
- assert_receive %Message{event: "presence_state", payload: %{}, topic: ^topic}, 500
-
- payload = %{
- type: "presence",
- event: "TRACK",
- payload: %{name: "realtime_presence_96", t: 1814.7000000029802}
- }
-
- WebsocketClient.send_event(socket, topic, "presence", payload)
- refute_receive %Message{event: "phx_leave", topic: ^topic}
- assert_receive %Message{event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}, topic: ^topic}, 500
- join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd()
- assert get_in(join_payload, ["name"]) == payload.payload.name
- assert get_in(join_payload, ["t"]) == payload.payload.t
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence]
- test "private presence with read permissions will be able to receive presence changes but won't be able to track",
- %{tenant: tenant, topic: topic} do
- {socket, _} = get_connection(tenant, "authenticated")
- {secondary_socket, _} = get_connection(tenant, "service_role")
- config = fn key -> %{presence: %{key: key, enabled: true}, private: true} end
- topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, topic, %{config: config.("authenticated")})
-
- payload = %{
- type: "presence",
- event: "TRACK",
- payload: %{name: "realtime_presence_96", t: 1814.7000000029802}
- }
-
- # This will be ignored
- WebsocketClient.send_event(socket, topic, "presence", payload)
-
- assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state", payload: %{}, ref: nil, topic: ^topic}
- refute_receive %Message{event: "presence_diff", payload: _, ref: _, topic: ^topic}
-
- payload = %{
- type: "presence",
- event: "TRACK",
- payload: %{name: "realtime_presence_97", t: 1814.7000000029802}
- }
-
- # This will be tracked
- WebsocketClient.join(secondary_socket, topic, %{config: config.("service_role")})
- WebsocketClient.send_event(secondary_socket, topic, "presence", payload)
-
- assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{topic: ^topic, event: "presence_diff", payload: %{"joins" => joins, "leaves" => %{}}}
- assert_receive %Message{event: "presence_state", payload: %{}, ref: nil, topic: ^topic}
-
- join_payload = joins |> Map.values() |> hd() |> get_in(["metas"]) |> hd()
- assert get_in(join_payload, ["name"]) == payload.payload.name
- assert get_in(join_payload, ["t"]) == payload.payload.t
-
- assert_receive %Message{topic: ^topic, event: "presence_diff"} = res
-
- assert join_payload =
- res
- |> Map.from_struct()
- |> get_in([:payload, "joins", "service_role", "metas"])
- |> hd()
-
- assert get_in(join_payload, ["name"]) == payload.payload.name
- assert get_in(join_payload, ["t"]) == payload.payload.t
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
- test "handles lack of connection to database error on private channels", %{tenant: tenant, topic: topic} do
- topic = "realtime:#{topic}"
- {socket, _} = get_connection(tenant, "authenticated")
- WebsocketClient.join(socket, topic, %{config: %{private: true, presence: %{enabled: true}}})
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{event: "presence_state"}
-
- log =
- capture_log(fn ->
- :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: nil} end)
- payload = %{type: "presence", event: "TRACK", payload: %{name: "realtime_presence_96", t: 1814.7000000029802}}
- WebsocketClient.send_event(socket, topic, "presence", payload)
-
- refute_receive %Message{event: "presence_diff"}, 500
- # Waiting more than 5 seconds as this is the amount of time we will wait for the Connection to be ready
- refute_receive %Message{event: "phx_leave", topic: ^topic}, 6000
- end)
-
- assert log =~ "UnableToHandlePresence"
- end
-
- @tag policies: []
- test "lack of connection to database error does not impact public channels", %{tenant: tenant, topic: topic} do
- topic = "realtime:#{topic}"
- {socket, _} = get_connection(tenant, "authenticated")
- WebsocketClient.join(socket, topic, %{config: %{private: false, presence: %{enabled: true}}})
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{event: "presence_state"}
-
- log =
- capture_log(fn ->
- :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: nil} end)
- payload = %{type: "presence", event: "TRACK", payload: %{name: "realtime_presence_96", t: 1814.7000000029802}}
- WebsocketClient.send_event(socket, topic, "presence", payload)
-
- assert_receive %Message{event: "presence_diff"}, 500
- refute_receive %Message{event: "phx_leave", topic: ^topic}
- end)
-
- refute log =~ "UnableToHandlePresence"
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
-
- test "presence enabled if param enabled is set in configuration for private channels", %{
- tenant: tenant,
- topic: topic
- } do
- {socket, _} = get_connection(tenant, "authenticated")
- topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, topic, %{config: %{private: true, presence: %{enabled: true}}})
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
-
- test "presence disabled if param 'enabled' is set to false in configuration for private channels", %{
- tenant: tenant,
- topic: topic
- } do
- {socket, _} = get_connection(tenant, "authenticated")
- topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, topic, %{config: %{private: true, presence: %{enabled: false}}})
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- refute_receive %Message{event: "presence_state"}, 500
- end
-
- test "presence enabled if param enabled is set in configuration for public channels", %{
- tenant: tenant,
- topic: topic
- } do
- {socket, _} = get_connection(tenant, "authenticated")
- topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, topic, %{config: %{private: false, presence: %{enabled: true}}})
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
- end
-
- test "presence disabled if param 'enabled' is set to false in configuration for public channels", %{
- tenant: tenant,
- topic: topic
- } do
- {socket, _} = get_connection(tenant, "authenticated")
- topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, topic, %{config: %{private: false, presence: %{enabled: false}}})
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- refute_receive %Message{event: "presence_state"}, 500
- end
- end
-
- describe "token handling" do
- setup [:rls_context]
-
- @tag policies: [
- :authenticated_read_broadcast_and_presence,
- :authenticated_write_broadcast_and_presence
- ]
- test "badly formatted jwt token", %{tenant: tenant} do
- log =
- capture_log(fn ->
- WebsocketClient.connect(self(), uri(tenant), @serializer, [{"x-api-key", "bad_token"}])
- end)
-
- assert log =~ "MalformedJWT: The token provided is not a valid JWT"
- end
-
- test "invalid JWT with expired token", %{tenant: tenant} do
- log =
- capture_log(fn ->
- get_connection(tenant, "authenticated", %{:exp => System.system_time(:second) - 1000}, %{log_level: :info})
- end)
-
- assert log =~ "InvalidJWTToken: Token has expired"
- end
-
- test "token required the role key", %{tenant: tenant} do
- {:ok, token} = token_no_role(tenant)
-
- assert {:error, %{status_code: 403}} =
- WebsocketClient.connect(self(), uri(tenant), @serializer, [{"x-api-key", token}])
- end
-
- test "handles connection with valid api-header but ignorable access_token payload", %{tenant: tenant, topic: topic} do
- realtime_topic = "realtime:#{topic}"
-
- log =
- capture_log(fn ->
- {:ok, token} =
- generate_token(tenant, %{
- exp: System.system_time(:second) + 1000,
- role: "authenticated",
- sub: random_string()
- })
-
- {:ok, socket} = WebsocketClient.connect(self(), uri(tenant), @serializer, [{"x-api-key", token}])
-
- WebsocketClient.join(socket, realtime_topic, %{
- config: %{broadcast: %{self: true}, private: false},
- access_token: "sb_#{random_string()}"
- })
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
- end)
-
- refute log =~ "MalformedJWT: The token provided is not a valid JWT"
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
- test "on new access_token and channel is private policies are reevaluated for read policy",
- %{tenant: tenant, topic: topic} do
- {socket, access_token} = get_connection(tenant, "authenticated")
-
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{
- config: %{broadcast: %{self: true}, private: true},
- access_token: access_token
- })
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
-
- {:ok, new_token} = token_valid(tenant, "anon")
-
- WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => new_token})
-
- error_message = "You do not have permissions to read from this Channel topic: #{topic}"
-
- assert_receive %Message{
- event: "system",
- payload: %{"channel" => ^topic, "extension" => "system", "message" => ^error_message, "status" => "error"},
- topic: ^realtime_topic
- }
-
- assert_receive %Message{event: "phx_close", topic: ^realtime_topic}
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
- test "on new access_token and channel is private policies are reevaluated for write policy", %{
- topic: topic,
- tenant: tenant
- } do
- {socket, access_token} = get_connection(tenant, "authenticated")
- realtime_topic = "realtime:#{topic}"
- config = %{broadcast: %{self: true}, private: true}
- WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
-
- # Checks first send which will set write policy to true
- payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
- WebsocketClient.send_event(socket, realtime_topic, "broadcast", payload)
-
- assert_receive %Message{event: "broadcast", payload: ^payload, topic: ^realtime_topic}, 500
-
- # RLS policies changed to only allow read
- {:ok, db_conn} = Database.connect(tenant, "realtime_test")
- clean_table(db_conn, "realtime", "messages")
- create_rls_policies(db_conn, [:authenticated_read_broadcast_and_presence], %{topic: topic})
-
- # Set new token to recheck policies
- {:ok, new_token} =
- generate_token(tenant, %{exp: System.system_time(:second) + 1000, role: "authenticated", sub: random_string()})
-
- WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => new_token})
-
- # Send message to be ignored
- payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
- WebsocketClient.send_event(socket, realtime_topic, "broadcast", payload)
-
- refute_receive %Message{
- event: "broadcast",
- payload: ^payload,
- topic: ^realtime_topic
- },
- 1500
- end
-
- test "on new access_token and channel is public policies are not reevaluated", %{tenant: tenant, topic: topic} do
- {socket, access_token} = get_connection(tenant, "authenticated")
- {:ok, new_token} = token_valid(tenant, "anon")
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
-
- WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => new_token})
-
- refute_receive %Message{}
- end
-
- test "on empty string access_token the socket sends an error message", %{tenant: tenant, topic: topic} do
- {socket, access_token} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
-
- WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => ""})
-
- assert_receive %Message{
- topic: ^realtime_topic,
- event: "system",
- payload: %{
- "extension" => "system",
- "message" => msg,
- "status" => "error"
- }
- }
-
- assert_receive %Message{event: "phx_close"}
- assert msg =~ "The token provided is not a valid JWT"
- end
-
- test "on expired access_token the socket sends an error message", %{tenant: tenant, topic: topic} do
- sub = random_string()
-
- {socket, access_token} = get_connection(tenant, "authenticated", %{sub: sub})
-
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
- {:ok, token} = generate_token(tenant, %{:exp => System.system_time(:second) - 1000, sub: sub})
-
- log =
- capture_log([log_level: :warning], fn ->
- WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => token})
-
- assert_receive %Message{
- topic: ^realtime_topic,
- event: "system",
- payload: %{"extension" => "system", "message" => "Token has expired 1000 seconds ago", "status" => "error"}
- }
- end)
-
- assert log =~ "ChannelShutdown: Token has expired 1000 seconds ago"
- end
-
- test "ChannelShutdown include sub if available in jwt claims", %{tenant: tenant, topic: topic} do
- exp = System.system_time(:second) + 10_000
-
- {socket, access_token} = get_connection(tenant, "authenticated", %{exp: exp}, %{log_level: :warning})
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic = "realtime:#{topic}"
- sub = random_string()
- WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
- {:ok, token} = generate_token(tenant, %{:exp => System.system_time(:second) - 1000, sub: sub})
-
- log =
- capture_log([level: :warning], fn ->
- WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => token})
-
- assert_receive %Message{event: "system"}, 1000
- end)
-
- assert log =~ "ChannelShutdown"
- assert log =~ "sub=#{sub}"
- end
-
- test "missing claims close connection", %{tenant: tenant, topic: topic} do
- {socket, access_token} = get_connection(tenant, "authenticated")
-
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
- {:ok, token} = generate_token(tenant, %{:exp => System.system_time(:second) + 2000})
-
- # Update token to be a near expiring token
- WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => token})
-
- assert_receive %Message{
- event: "system",
- payload: %{
- "extension" => "system",
- "message" => "Fields `role` and `exp` are required in JWT",
- "status" => "error"
- }
- },
- 500
-
- assert_receive %Message{event: "phx_close"}
- end
-
- test "checks token periodically", %{tenant: tenant, topic: topic} do
- {socket, access_token} = get_connection(tenant, "authenticated")
-
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
-
- {:ok, token} = generate_token(tenant, %{:exp => System.system_time(:second) + 2, role: "authenticated"})
-
- # Update token to be a near expiring token
- WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => token})
-
- # Awaits to see if connection closes automatically
- assert_receive %Message{
- event: "system",
- payload: %{"extension" => "system", "message" => msg, "status" => "error"}
- },
- 3000
-
- assert_receive %Message{event: "phx_close"}
-
- assert msg =~ "Token has expired"
- end
-
- test "token expires in between joins", %{tenant: tenant, topic: topic} do
- {socket, access_token} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
-
- {:ok, access_token} = generate_token(tenant, %{:exp => System.system_time(:second) + 1, role: "authenticated"})
-
- # token expires in between joins so it needs to be handled by the channel and not the socket
- Process.sleep(1000)
- realtime_topic = "realtime:#{topic}"
-
- log =
- capture_log(fn ->
- WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
-
- assert_receive %Message{
- event: "phx_reply",
- payload: %{
- "status" => "error",
- "response" => %{"reason" => "InvalidJWTToken: Token has expired 0 seconds ago"}
- },
- topic: ^realtime_topic
- },
- 500
- end)
-
- assert_receive %Message{event: "phx_close"}
- assert log =~ "#{tenant.external_id}"
- end
-
- test "token loses claims in between joins", %{tenant: tenant, topic: topic} do
- {socket, access_token} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
-
- {:ok, access_token} = generate_token(tenant, %{:exp => System.system_time(:second) + 10})
-
- # token breaks claims in between joins so it needs to be handled by the channel and not the socket
- realtime_topic = "realtime:#{topic}"
- WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
-
- assert_receive %Message{
- event: "phx_reply",
- payload: %{
- "status" => "error",
- "response" => %{
- "reason" => "InvalidJWTToken: Fields `role` and `exp` are required in JWT"
- }
- },
- topic: ^realtime_topic
- },
- 500
-
- assert_receive %Message{event: "phx_close"}
- end
-
- test "token is badly formatted in between joins", %{tenant: tenant, topic: topic} do
- {socket, access_token} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
-
- # token becomes a string in between joins so it needs to be handled by the channel and not the socket
- WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: "potato"})
-
- assert_receive %Message{
- event: "phx_reply",
- payload: %{
- "status" => "error",
- "response" => %{
- "reason" => "MalformedJWT: The token provided is not a valid JWT"
- }
- },
- topic: ^realtime_topic
- },
- 500
-
- assert_receive %Message{event: "phx_close"}
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
- test "handles RPC error on token refreshed", %{tenant: tenant, topic: topic} do
- Authorization
- |> expect(:get_read_authorizations, fn conn, db_conn, context ->
- call_original(Authorization, :get_read_authorizations, [conn, db_conn, context])
- end)
- |> expect(:get_read_authorizations, fn _, _, _ -> {:error, "RPC Error"} end)
-
- {socket, access_token} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: true}
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
-
- assert_receive %Phoenix.Socket.Message{event: "phx_reply"}, 500
- assert_receive %Phoenix.Socket.Message{event: "presence_state"}, 500
-
- # Update token to force update
- {:ok, access_token} =
- generate_token(tenant, %{:exp => System.system_time(:second) + 1000, role: "authenticated"})
-
- log =
- capture_log([log_level: :warning], fn ->
- WebsocketClient.send_event(socket, realtime_topic, "access_token", %{"access_token" => access_token})
-
- assert_receive %Phoenix.Socket.Message{
- event: "system",
- payload: %{
- "status" => "error",
- "extension" => "system",
- "message" => "Realtime was unable to connect to the project database"
- },
- topic: ^realtime_topic
- },
- 500
-
- assert_receive %Phoenix.Socket.Message{event: "phx_close", topic: ^realtime_topic}
- end)
-
- assert log =~ "Realtime was unable to connect to the project database"
- end
-
- test "on sb prefixed access_token the socket ignores the message and respects JWT expiry time", %{
- tenant: tenant,
- topic: topic
- } do
- sub = random_string()
-
- {socket, access_token} =
- get_connection(tenant, "authenticated", %{sub: sub, exp: System.system_time(:second) + 5})
-
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config, access_token: access_token})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
-
- WebsocketClient.send_event(socket, realtime_topic, "access_token", %{
- "access_token" => "sb_publishable_-fake_key"
- })
-
- # Check if the new token does not trigger a shutdown
- refute_receive %Message{event: "system", topic: ^realtime_topic}, 100
-
- # Await to check if channel respects token expiry time
- assert_receive %Message{
- event: "system",
- payload: %{"extension" => "system", "message" => msg, "status" => "error"},
- topic: ^realtime_topic
- },
- 5000
-
- assert_receive %Message{event: "phx_close", topic: ^realtime_topic}
- msg =~ "Token has expired"
- end
- end
-
- describe "handle broadcast changes" do
- setup [:rls_context, :setup_trigger]
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
- test "broadcast insert event changes on insert in table with trigger", %{
- tenant: tenant,
- topic: topic,
- db_conn: db_conn,
- table_name: table_name
- } do
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: true}
- topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
-
- value = random_string()
- Postgrex.query!(db_conn, "INSERT INTO #{table_name} (details) VALUES ($1)", [value])
-
- record = %{"details" => value, "id" => 1}
-
- assert_receive %Message{
- event: "broadcast",
- payload: %{
- "event" => "INSERT",
- "payload" => %{
- "old_record" => nil,
- "operation" => "INSERT",
- "record" => ^record,
- "schema" => "public",
- "table" => ^table_name
- },
- "type" => "broadcast"
- },
- topic: ^topic
- },
- 1000
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence],
- requires_data: true
- test "broadcast update event changes on update in table with trigger", %{
- tenant: tenant,
- topic: topic,
- db_conn: db_conn,
- table_name: table_name
- } do
- value = random_string()
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: true}
- topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
-
- new_value = random_string()
-
- Postgrex.query!(db_conn, "INSERT INTO #{table_name} (details) VALUES ($1)", [value])
- Postgrex.query!(db_conn, "UPDATE #{table_name} SET details = $1 WHERE details = $2", [new_value, value])
-
- old_record = %{"details" => value, "id" => 1}
- record = %{"details" => new_value, "id" => 1}
-
- assert_receive %Message{
- event: "broadcast",
- payload: %{
- "event" => "UPDATE",
- "payload" => %{
- "old_record" => ^old_record,
- "operation" => "UPDATE",
- "record" => ^record,
- "schema" => "public",
- "table" => ^table_name
- },
- "type" => "broadcast"
- },
- topic: ^topic
- },
- 1000
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
- test "broadcast delete event changes on delete in table with trigger", %{
- tenant: tenant,
- topic: topic,
- db_conn: db_conn,
- table_name: table_name
- } do
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: true}
- topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
-
- value = random_string()
-
- Postgrex.query!(db_conn, "INSERT INTO #{table_name} (details) VALUES ($1)", [value])
- Postgrex.query!(db_conn, "DELETE FROM #{table_name} WHERE details = $1", [value])
-
- record = %{"details" => value, "id" => 1}
-
- assert_receive %Message{
- event: "broadcast",
- payload: %{
- "event" => "DELETE",
- "payload" => %{
- "old_record" => ^record,
- "operation" => "DELETE",
- "record" => nil,
- "schema" => "public",
- "table" => ^table_name
- },
- "type" => "broadcast"
- },
- topic: ^topic
- },
- 1000
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
- test "broadcast event when function 'send' is called with private topic", %{
- tenant: tenant,
- topic: topic,
- db_conn: db_conn
- } do
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: true}
- full_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, full_topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
-
- value = random_string()
- event = random_string()
-
- Postgrex.query!(
- db_conn,
- "SELECT realtime.send (json_build_object ('value', $1 :: text)::jsonb, $2 :: text, $3 :: text, TRUE::bool);",
- [value, event, topic]
- )
-
- assert_receive %Message{
- event: "broadcast",
- payload: %{
- "event" => ^event,
- "payload" => %{"value" => ^value},
- "type" => "broadcast"
- },
- topic: ^full_topic,
- join_ref: nil,
- ref: nil
- },
- 1000
- end
-
- test "broadcast event when function 'send' is called with public topic", %{
- tenant: tenant,
- topic: topic,
- db_conn: db_conn
- } do
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: false}
- full_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, full_topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
-
- value = random_string()
- event = random_string()
-
- Postgrex.query!(
- db_conn,
- "SELECT realtime.send (json_build_object ('value', $1 :: text)::jsonb, $2 :: text, $3 :: text, FALSE::bool);",
- [value, event, topic]
- )
-
- assert_receive %Message{
- event: "broadcast",
- payload: %{
- "event" => ^event,
- "payload" => %{"value" => ^value},
- "type" => "broadcast"
- },
- topic: ^full_topic
- },
- 1000
- end
- end
-
- describe "only private channels" do
- setup [:rls_context]
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
- test "user with only private channels enabled will not be able to join public channels", %{
- tenant: tenant,
- topic: topic
- } do
- change_tenant_configuration(tenant, :private_only, true)
- on_exit(fn -> change_tenant_configuration(tenant, :private_only, false) end)
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: false}
- topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, topic, %{config: config})
-
- assert_receive %Message{
- event: "phx_reply",
- payload: %{
- "response" => %{
- "reason" => "PrivateOnly: This project only allows private channels"
- },
- "status" => "error"
- }
- },
- 500
- end
-
- @tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
- test "user with only private channels enabled will be able to join private channels", %{
- tenant: tenant,
- topic: topic
- } do
- change_tenant_configuration(tenant, :private_only, true)
- on_exit(fn -> change_tenant_configuration(tenant, :private_only, false) end)
-
- Process.sleep(100)
-
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: true}
- topic = "realtime:#{topic}"
- WebsocketClient.join(socket, topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- end
- end
-
- describe "socket disconnect" do
- setup [:rls_context]
-
- test "tenant already suspended", %{topic: _topic} do
- tenant = Containers.checkout_tenant(run_migrations: true)
-
- log =
- capture_log(fn ->
- {:ok, _} = Realtime.Api.update_tenant(tenant, %{suspend: true})
- {:error, %Mint.WebSocket.UpgradeFailureError{}} = get_connection(tenant, "anon")
- refute_receive _any
- end)
-
- assert log =~ "RealtimeDisabledForTenant"
- end
-
- test "on jwks the socket closes and sends a system message", %{tenant: tenant, topic: topic} do
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
- tenant = Tenants.get_tenant_by_external_id(tenant.external_id)
- Realtime.Api.update_tenant(tenant, %{jwt_jwks: %{keys: ["potato"]}})
-
- assert_process_down(socket)
- end
-
- test "on jwt_secret the socket closes and sends a system message", %{tenant: tenant, topic: topic} do
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
-
- tenant = Tenants.get_tenant_by_external_id(tenant.external_id)
- Realtime.Api.update_tenant(tenant, %{jwt_secret: "potato"})
-
- assert_process_down(socket)
- end
-
- test "on private_only the socket closes and sends a system message", %{tenant: tenant, topic: topic} do
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
-
- tenant = Tenants.get_tenant_by_external_id(tenant.external_id)
- Realtime.Api.update_tenant(tenant, %{private_only: true})
-
- assert_process_down(socket)
- end
-
- test "on other param changes the socket won't close and no message is sent", %{tenant: tenant, topic: topic} do
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert_receive %Message{event: "presence_state"}, 500
-
- tenant = Tenants.get_tenant_by_external_id(tenant.external_id)
- Realtime.Api.update_tenant(tenant, %{max_concurrent_users: 100})
-
- refute_receive %Message{
- topic: ^realtime_topic,
- event: "system",
- payload: %{
- "extension" => "system",
- "message" => "Server requested disconnect",
- "status" => "ok"
- }
- },
- 500
-
- Process.sleep(500)
- assert :ok = WebsocketClient.send_heartbeat(socket)
- end
-
- test "invalid JWT with expired token", %{tenant: tenant} do
- log =
- capture_log(fn ->
- get_connection(tenant, "authenticated", %{:exp => System.system_time(:second) - 1000}, %{log_level: :info})
- end)
-
- assert log =~ "InvalidJWTToken: Token has expired"
- end
-
- test "check registry of SocketDisconnect and on distribution called, kill socket", %{tenant: tenant} do
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: false}
-
- for _ <- 1..10 do
- topic = "realtime:#{random_string()}"
- WebsocketClient.join(socket, topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 500
- assert_receive %Message{event: "presence_state", topic: ^topic}, 500
- end
-
- assert :ok = WebsocketClient.send_heartbeat(socket)
-
- SocketDisconnect.distributed_disconnect(tenant)
-
- assert_process_down(socket)
- end
- end
-
- describe "rate limits" do
- setup [:rls_context]
-
- test "max_concurrent_users limit respected", %{tenant: tenant} do
- %{max_concurrent_users: max_concurrent_users} = Tenants.get_tenant_by_external_id(tenant.external_id)
- change_tenant_configuration(tenant, :max_concurrent_users, 1)
-
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic = "realtime:#{random_string()}"
- WebsocketClient.join(socket, realtime_topic, %{config: config})
- WebsocketClient.join(socket, realtime_topic, %{config: config})
-
- assert_receive %Message{
- event: "phx_reply",
- payload: %{
- "response" => %{
- "reason" => "ConnectionRateLimitReached: Too many connected users"
- },
- "status" => "error"
- }
- },
- 500
-
- assert_receive %Message{event: "phx_close"}
-
- change_tenant_configuration(tenant, :max_concurrent_users, max_concurrent_users)
- end
-
- test "max_events_per_second limit respected", %{tenant: tenant} do
- %{max_events_per_second: max_events_per_second} = Tenants.get_tenant_by_external_id(tenant.external_id)
- on_exit(fn -> change_tenant_configuration(tenant, :max_events_per_second, max_events_per_second) end)
- RateCounter.stop(tenant.external_id)
-
- log =
- capture_log(fn ->
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: false, presence: %{enabled: false}}
- realtime_topic = "realtime:#{random_string()}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config})
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500
-
- for _ <- 1..1000, Process.alive?(socket) do
- WebsocketClient.send_event(socket, realtime_topic, "broadcast", %{})
- Process.sleep(10)
- end
-
- # Wait for the rate counter to run logger function
- Process.sleep(1500)
- assert_receive %Message{event: "phx_close"}
- end)
-
- assert log =~ "MessagePerSecondRateLimitReached"
- end
-
- test "max_channels_per_client limit respected", %{tenant: tenant} do
- %{max_events_per_second: max_concurrent_users} = Tenants.get_tenant_by_external_id(tenant.external_id)
- change_tenant_configuration(tenant, :max_channels_per_client, 1)
-
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic_1 = "realtime:#{random_string()}"
- realtime_topic_2 = "realtime:#{random_string()}"
-
- WebsocketClient.join(socket, realtime_topic_1, %{config: config})
- WebsocketClient.join(socket, realtime_topic_2, %{config: config})
-
- assert_receive %Message{
- event: "phx_reply",
- payload: %{"response" => %{"postgres_changes" => []}, "status" => "ok"},
- topic: ^realtime_topic_1
- },
- 500
-
- assert_receive %Message{event: "presence_state", topic: ^realtime_topic_1}, 500
-
- assert_receive %Message{
- event: "phx_reply",
- payload: %{
- "status" => "error",
- "response" => %{
- "reason" => "ChannelRateLimitReached: Too many channels"
- }
- },
- topic: ^realtime_topic_2
- },
- 500
-
- refute_receive %Message{event: "phx_reply", topic: ^realtime_topic_2}, 500
- refute_receive %Message{event: "presence_state", topic: ^realtime_topic_2}, 500
-
- change_tenant_configuration(tenant, :max_channels_per_client, max_concurrent_users)
- end
-
- test "max_joins_per_second limit respected", %{tenant: tenant} do
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic = "realtime:#{random_string()}"
-
- log =
- capture_log(fn ->
- # Burst of joins that won't be blocked as RateCounter tick won't run
- for _ <- 1..300 do
- WebsocketClient.join(socket, realtime_topic, %{config: config})
- end
-
- # Wait for RateCounter tick
- Process.sleep(1000)
- # These ones will be blocked
- for _ <- 1..300 do
- WebsocketClient.join(socket, realtime_topic, %{config: config})
- end
-
- assert_receive %Message{
- event: "phx_reply",
- payload: %{
- "response" => %{
- "reason" => "ClientJoinRateLimitReached: Too many joins per second"
- },
- "status" => "error"
- }
- },
- 2000
- end)
-
- assert log =~
- "project=#{tenant.external_id} external_id=#{tenant.external_id} [critical] ClientJoinRateLimitReached: Too many joins per second"
-
- # Only one log message should be emitted
- # Splitting by the error message returns the error message and the rest of the log only
- assert length(String.split(log, "ClientJoinRateLimitReached")) == 2
- end
- end
-
- describe "authorization handling" do
- setup [:rls_context]
-
- @tag policies: [:read_matching_user_role, :write_matching_user_role], role: "anon"
- test "role policies are respected when accessing the channel", %{tenant: tenant} do
- {socket, _} = get_connection(tenant, "anon")
- config = %{broadcast: %{self: true}, private: true, presence: %{enabled: false}}
- topic = random_string()
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500
-
- {socket, _} = get_connection(tenant, "potato")
- topic = random_string()
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config})
- refute_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500
- end
-
- @tag policies: [:authenticated_read_matching_user_sub, :authenticated_write_matching_user_sub],
- sub: Ecto.UUID.generate()
- test "sub policies are respected when accessing the channel", %{tenant: tenant, sub: sub} do
- {socket, _} = get_connection(tenant, "authenticated", %{sub: sub})
- config = %{broadcast: %{self: true}, private: true, presence: %{enabled: false}}
- topic = random_string()
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500
-
- {socket, _} = get_connection(tenant, "authenticated", %{sub: Ecto.UUID.generate()})
- topic = random_string()
- realtime_topic = "realtime:#{topic}"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config})
- refute_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^realtime_topic}, 500
- end
-
- @tag role: "authenticated",
- policies: [:broken_read_presence, :broken_write_presence]
-
- test "handle failing rls policy", %{tenant: tenant} do
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: true}
- topic = random_string()
- realtime_topic = "realtime:#{topic}"
-
- log =
- capture_log(fn ->
- WebsocketClient.join(socket, realtime_topic, %{config: config})
-
- msg = "Unauthorized: You do not have permissions to read from this Channel topic: #{topic}"
-
- assert_receive %Message{
- event: "phx_reply",
- payload: %{
- "response" => %{
- "reason" => ^msg
- },
- "status" => "error"
- }
- },
- 500
-
- refute_receive %Message{event: "phx_reply"}
- refute_receive %Message{event: "presence_state"}
- end)
-
- assert log =~ "RlsPolicyError"
- end
- end
-
- test "handle empty topic by closing the socket", %{tenant: tenant} do
- {socket, _} = get_connection(tenant, "authenticated")
- config = %{broadcast: %{self: true}, private: false}
- realtime_topic = "realtime:"
-
- WebsocketClient.join(socket, realtime_topic, %{config: config})
-
- assert_receive %Message{
- event: "phx_reply",
- payload: %{
- "response" => %{
- "reason" => "TopicNameRequired: You must provide a topic name"
- },
- "status" => "error"
- }
- },
- 500
-
- refute_receive %Message{event: "phx_reply"}
- refute_receive %Message{event: "presence_state"}
- end
-
- def handle_telemetry(event, %{sum: sum}, metadata, _) do
- tenant = metadata[:tenant]
- [key] = Enum.take(event, -1)
-
- Agent.update(TestCounter, fn state ->
- state = Map.put_new(state, tenant, %{joins: 0, events: 0, db_events: 0, presence_events: 0})
- update_in(state, [metadata[:tenant], key], fn v -> (v || 0) + sum end)
- end)
- end
-
- defp get_count(event, tenant) do
- [key] = Enum.take(event, -1)
-
- Agent.get(TestCounter, fn state -> get_in(state, [tenant, key]) || 0 end)
- end
-
- describe "billable events" do
- setup %{tenant: tenant} do
- events = [
- [:realtime, :rate_counter, :channel, :joins],
- [:realtime, :rate_counter, :channel, :events],
- [:realtime, :rate_counter, :channel, :db_events],
- [:realtime, :rate_counter, :channel, :presence_events]
- ]
-
- {:ok, _} =
- start_supervised(%{
- id: 1,
- start: {Agent, :start_link, [fn -> %{} end, [name: TestCounter]]}
- })
-
- RateCounter.stop(tenant.external_id)
- on_exit(fn -> :telemetry.detach(__MODULE__) end)
- :telemetry.attach_many(__MODULE__, events, &__MODULE__.handle_telemetry/4, [])
-
- {:ok, conn} = Database.connect(tenant, "realtime_test")
-
- # Setup for postgres changes
- Database.transaction(conn, fn db_conn ->
- queries = [
- "drop table if exists public.test",
- "drop publication if exists supabase_realtime_test",
- "create sequence if not exists test_id_seq;",
- """
- create table if not exists "public"."test" (
- "id" int4 not null default nextval('test_id_seq'::regclass),
- "details" text,
- primary key ("id"));
- """,
- "grant all on table public.test to anon;",
- "grant all on table public.test to postgres;",
- "grant all on table public.test to authenticated;",
- "create publication supabase_realtime_test for all tables"
- ]
-
- Enum.each(queries, &Postgrex.query!(db_conn, &1, []))
- end)
-
- :ok
- end
-
- test "join events", %{tenant: tenant} do
- external_id = tenant.external_id
- {socket, _} = get_connection(tenant)
- config = %{broadcast: %{self: true}, postgres_changes: [%{event: "*", schema: "public"}]}
- topic = "realtime:any"
-
- WebsocketClient.join(socket, topic, %{config: config})
-
- # Join events
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{topic: ^topic, event: "presence_state"}
- assert_receive %Message{topic: ^topic, event: "system"}, 5000
-
- # Wait for RateCounter to run
- Process.sleep(2000)
-
- # Expected billed
- # 1 joins due to two sockets
- # 1 presence events due to two sockets
- # 0 db events as no postgres changes used
- # 0 events broadcast is not used
- assert 1 = get_count([:realtime, :rate_counter, :channel, :joins], external_id)
- assert 1 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id)
- assert 0 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id)
- assert 0 = get_count([:realtime, :rate_counter, :channel, :events], external_id)
- end
-
- test "broadcast events", %{tenant: tenant} do
- external_id = tenant.external_id
- {socket, _} = get_connection(tenant)
- config = %{broadcast: %{self: true}}
- topic = "realtime:any"
-
- WebsocketClient.join(socket, topic, %{config: config})
-
- # Join events
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{topic: ^topic, event: "presence_state"}
-
- # Add second client so we can test the "multiplication" of billable events
- {socket, _} = get_connection(tenant)
- WebsocketClient.join(socket, topic, %{config: config})
-
- # Join events
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{topic: ^topic, event: "presence_state"}
-
- # Broadcast event
- payload = %{"event" => "TEST", "payload" => %{"msg" => 1}, "type" => "broadcast"}
-
- for _ <- 1..5 do
- WebsocketClient.send_event(socket, topic, "broadcast", payload)
- assert_receive %Message{topic: ^topic, event: "broadcast", payload: ^payload}
- end
-
- # Wait for RateCounter to run
- Process.sleep(2000)
-
- # Expected billed
- # 2 joins due to two sockets
- # 2 presence events due to two sockets
- # 0 db events as no postgres changes used
- # 15 events as 5 events sent, 5 events received on client 1 and 5 events received on client 2
- assert 2 = get_count([:realtime, :rate_counter, :channel, :joins], external_id)
- assert 2 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id)
- assert 0 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id)
- assert 15 = get_count([:realtime, :rate_counter, :channel, :events], external_id)
- end
-
- test "presence events", %{tenant: tenant} do
- external_id = tenant.external_id
- {socket, _} = get_connection(tenant)
- config = %{broadcast: %{self: true}, presence: %{enabled: true}}
- topic = "realtime:any"
-
- WebsocketClient.join(socket, topic, %{config: config})
-
- # Join events
- assert_receive %Message{event: "phx_reply", topic: ^topic}, 1000
- assert_receive %Message{topic: ^topic, event: "presence_state"}, 1000
-
- payload = %{
- type: "presence",
- event: "TRACK",
- payload: %{name: "realtime_presence_1", t: 1814.7000000029802}
- }
-
- WebsocketClient.send_event(socket, topic, "presence", payload)
- assert_receive %Message{event: "presence_diff", payload: %{"joins" => _, "leaves" => %{}}, topic: ^topic}
-
- # Presence events
- {socket, _} = get_connection(tenant, "authenticated")
- WebsocketClient.join(socket, topic, %{config: config})
-
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{topic: ^topic, event: "presence_state"}
-
- payload = %{
- type: "presence",
- event: "TRACK",
- payload: %{name: "realtime_presence_2", t: 1814.7000000029802}
- }
-
- WebsocketClient.send_event(socket, topic, "presence", payload)
- assert_receive %Message{event: "presence_diff", payload: %{"joins" => _, "leaves" => %{}}, topic: ^topic}
- assert_receive %Message{event: "presence_diff", payload: %{"joins" => _, "leaves" => %{}}, topic: ^topic}
-
- # Wait for RateCounter to run
- Process.sleep(2000)
-
- # Expected billed
- # 2 joins due to two sockets
- # 7 presence events
- # 0 db events as no postgres changes used
- # 0 events as no broadcast used
- assert 2 = get_count([:realtime, :rate_counter, :channel, :joins], external_id)
- assert 7 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id)
- assert 0 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id)
- assert 0 = get_count([:realtime, :rate_counter, :channel, :events], external_id)
- end
-
- test "postgres changes events", %{tenant: tenant} do
- external_id = tenant.external_id
- {socket, _} = get_connection(tenant)
- config = %{broadcast: %{self: true}, postgres_changes: [%{event: "*", schema: "public"}]}
- topic = "realtime:any"
-
- WebsocketClient.join(socket, topic, %{config: config})
-
- # Join events
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{topic: ^topic, event: "presence_state"}, 500
- assert_receive %Message{topic: ^topic, event: "system"}, 5000
-
- # Add second user to test the "multiplication" of billable events
- {socket, _} = get_connection(tenant)
- WebsocketClient.join(socket, topic, %{config: config})
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{topic: ^topic, event: "presence_state"}, 500
- assert_receive %Message{topic: ^topic, event: "system"}, 5000
-
- tenant = Tenants.get_tenant_by_external_id(tenant.external_id)
- {:ok, conn} = Database.connect(tenant, "realtime_test", :stop)
-
- # Postgres Change events
- for _ <- 1..5, do: Postgrex.query!(conn, "insert into test (details) values ('test')", [])
-
- for _ <- 1..5 do
- assert_receive %Message{
- topic: ^topic,
- event: "postgres_changes",
- payload: %{"data" => %{"schema" => "public", "table" => "test", "type" => "INSERT"}}
- },
- 5000
- end
-
- # Wait for RateCounter to run
- Process.sleep(2000)
-
- # Expected billed
- # 2 joins due to two sockets
- # 2 presence events due to two sockets
- # 10 db events due to 5 inserts events sent to client 1 and 5 inserts events sent to client 2
- # 0 events as no broadcast used
- assert 2 = get_count([:realtime, :rate_counter, :channel, :joins], external_id)
- assert 2 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id)
- assert 10 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id)
- assert 0 = get_count([:realtime, :rate_counter, :channel, :events], external_id)
- end
-
- test "postgres changes error events", %{tenant: tenant} do
- external_id = tenant.external_id
- {socket, _} = get_connection(tenant)
- config = %{broadcast: %{self: true}, postgres_changes: [%{event: "*", schema: "none"}]}
- topic = "realtime:any"
-
- WebsocketClient.join(socket, topic, %{config: config})
-
- # Join events
- assert_receive %Message{event: "phx_reply", payload: %{"status" => "ok"}, topic: ^topic}, 300
- assert_receive %Message{topic: ^topic, event: "presence_state"}, 500
- assert_receive %Message{topic: ^topic, event: "system"}, 5000
-
- # Wait for RateCounter to run
- Process.sleep(2000)
-
- # Expected billed
- # 1 joins due to one socket
- # 1 presence events due to one socket
- # 0 db events
- # 0 events as no broadcast used
- assert 1 = get_count([:realtime, :rate_counter, :channel, :joins], external_id)
- assert 1 = get_count([:realtime, :rate_counter, :channel, :presence_events], external_id)
- assert 0 = get_count([:realtime, :rate_counter, :channel, :db_events], external_id)
- assert 0 = get_count([:realtime, :rate_counter, :channel, :events], external_id)
- end
- end
-
- test "tracks and untracks properly channels", %{tenant: tenant} do
- assert [] = Tracker.list_pids()
-
- {socket, _} = get_connection(tenant)
- config = %{broadcast: %{self: true}, private: false, presence: %{enabled: false}}
-
- topics =
- for _ <- 1..10 do
- topic = "realtime:#{random_string()}"
- :ok = WebsocketClient.join(socket, topic, %{config: config})
- assert_receive %Message{topic: ^topic, event: "phx_reply"}, 500
- topic
- end
-
- assert [{_pid, count}] = Tracker.list_pids()
- assert count == length(topics)
-
- for topic <- topics do
- :ok = WebsocketClient.leave(socket, topic, %{})
- assert_receive %Message{topic: ^topic, event: "phx_close"}, 500
- end
-
- # wait to trigger tracker
- assert_process_down(socket, 5000)
- assert [] = Tracker.list_pids()
- end
-
- test "failed connections are present in tracker with counter counter lower than 0 so they are actioned on by tracker",
- %{tenant: tenant} do
- assert [] = Tracker.list_pids()
-
- {socket, _} = get_connection(tenant)
- config = %{broadcast: %{self: true}, private: true, presence: %{enabled: false}}
-
- for _ <- 1..10 do
- topic = "realtime:#{random_string()}"
- :ok = WebsocketClient.join(socket, topic, %{config: config})
- assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "error"}}, 500
- end
-
- assert [{_pid, count}] = Tracker.list_pids()
- assert count == 0
- end
-
- test "failed connections but one succeeds properly tracks",
- %{tenant: tenant} do
- assert [] = Tracker.list_pids()
-
- {socket, _} = get_connection(tenant)
- topic = "realtime:#{random_string()}"
-
- :ok =
- WebsocketClient.join(socket, topic, %{
- config: %{broadcast: %{self: true}, private: false, presence: %{enabled: false}}
- })
-
- assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert [{_pid, count}] = Tracker.list_pids()
- assert count == 1
-
- for _ <- 1..10 do
- topic = "realtime:#{random_string()}"
-
- :ok =
- WebsocketClient.join(socket, topic, %{
- config: %{broadcast: %{self: true}, private: true, presence: %{enabled: false}}
- })
-
- assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "error"}}, 500
- end
-
- topic = "realtime:#{random_string()}"
-
- :ok =
- WebsocketClient.join(socket, topic, %{
- config: %{broadcast: %{self: true}, private: false, presence: %{enabled: false}}
- })
-
- assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "ok"}}, 500
- assert [{_pid, count}] = Tracker.list_pids()
- assert count == 2
- end
-
- defp mode(%{mode: :distributed}) do
- tenant = Api.get_tenant_by_external_id("dev_tenant")
-
- RateCounter.stop(tenant.external_id)
- :ets.delete_all_objects(Tracker.table_name())
-
- Connect.shutdown(tenant.external_id)
- # Sleeping so that syn can forget about this Connect process
- Process.sleep(100)
-
- on_exit(fn ->
- Connect.shutdown(tenant.external_id)
- # Sleeping so that syn can forget about this Connect process
- Process.sleep(100)
- end)
-
- on_exit(fn -> Connect.shutdown(tenant.external_id) end)
- {:ok, node} = Clustered.start()
- region = Tenants.region(tenant)
- {:ok, db_conn} = :erpc.call(node, Connect, :connect, ["dev_tenant", region])
- assert Connect.ready?(tenant.external_id)
-
- assert node(db_conn) == node
- %{db_conn: db_conn, node: node, tenant: tenant}
- end
-
- defp mode(_) do
- tenant = Containers.checkout_tenant(run_migrations: true)
- RateCounter.stop(tenant.external_id)
-
- :ets.delete_all_objects(Tracker.table_name())
- Realtime.Tenants.Connect.shutdown(tenant.external_id)
- # Sleeping so that syn can forget about this Connect process
- Process.sleep(100)
- {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
- assert Connect.ready?(tenant.external_id)
- %{db_conn: db_conn, tenant: tenant}
- end
-
- defp rls_context(%{tenant: tenant} = context) do
- {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
- clean_table(db_conn, "realtime", "messages")
- topic = Map.get(context, :topic, random_string())
- policies = Map.get(context, :policies, nil)
- role = Map.get(context, :role, nil)
- sub = Map.get(context, :sub, nil)
-
- if policies, do: create_rls_policies(db_conn, policies, %{topic: topic, role: role, sub: sub})
-
- %{topic: topic, role: role, sub: sub}
- end
-
- defp setup_trigger(%{tenant: tenant, topic: topic}) do
- {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
- random_name = String.downcase("test_#{random_string()}")
- query = "CREATE TABLE #{random_name} (id serial primary key, details text)"
- Postgrex.query!(db_conn, query, [])
-
- query = """
- CREATE OR REPLACE FUNCTION broadcast_changes_for_table_#{random_name}_trigger ()
- RETURNS TRIGGER
- AS $$
- DECLARE
- topic text;
- BEGIN
- topic = '#{topic}';
- PERFORM
- realtime.broadcast_changes (topic, TG_OP, TG_OP, TG_TABLE_NAME, TG_TABLE_SCHEMA, NEW, OLD, TG_LEVEL);
- RETURN NULL;
- END;
- $$
- LANGUAGE plpgsql;
- """
-
- Postgrex.query!(db_conn, query, [])
-
- query = """
- CREATE TRIGGER broadcast_changes_for_#{random_name}_table
- AFTER INSERT OR UPDATE OR DELETE ON #{random_name}
- FOR EACH ROW
- EXECUTE FUNCTION broadcast_changes_for_table_#{random_name}_trigger ();
- """
-
- Postgrex.query!(db_conn, query, [])
-
- on_exit(fn ->
- {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
- query = "DROP TABLE #{random_name} CASCADE"
- Postgrex.query!(db_conn, query, [])
- end)
-
- %{table_name: random_name}
- end
-
- defp change_tenant_configuration(%Tenant{external_id: external_id}, limit, value) do
- external_id
- |> Realtime.Tenants.get_tenant_by_external_id()
- |> Realtime.Api.Tenant.changeset(%{limit => value})
- |> Realtime.Repo.update!()
-
- Realtime.Tenants.Cache.invalidate_tenant_cache(external_id)
- end
-
- defp assert_process_down(pid, timeout \\ 100) do
- ref = Process.monitor(pid)
- assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, timeout
- end
-end
diff --git a/test/integration/tests.ts b/test/integration/tests.ts
new file mode 100644
index 000000000..036255f17
--- /dev/null
+++ b/test/integration/tests.ts
@@ -0,0 +1,204 @@
+import { RealtimeClient } from "npm:@supabase/supabase-js@latest";
+import { sleep } from "https://deno.land/x/sleep/mod.ts";
+import { describe, it } from "jsr:@std/testing/bdd";
+import { assertEquals } from "jsr:@std/assert";
+import { deadline } from "jsr:@std/async/deadline";
+
+const withDeadline = Promise>(fn: Fn, ms: number): Fn =>
+ ((...args) => deadline(fn(...args), ms)) as Fn;
+
+const url = "http://realtime-dev.localhost:4100/socket";
+const serviceRoleKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjIwNzU3NzYzODIsInJlZiI6IjEyNy4wLjAuMSIsInJvbGUiOiJzZXJ2aWNlX3JvbGUiLCJpYXQiOjE3NjA3NzYzODJ9.nupH8pnrOTgK9Xaq8-D4Ry-yQ-PnlXEagTVywQUJVIE"
+const apiKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjIwNzU2NjE3MjEsInJlZiI6IjEyNy4wLjAuMSIsInJvbGUiOiJhdXRoZW50aWNhdGVkIiwiaWF0IjoxNzYwNjYxNzIxfQ.PxpBoelC9vWQ2OVhmwKBUDEIKgX7MpgSdsnmXw7UdYk";
+
+const realtimeV1 = { vsn: '1.0.0', params: { apikey: apiKey } , heartbeatIntervalMs: 5000, timeout: 5000 };
+const realtimeV2 = { vsn: '2.0.0', params: { apikey: apiKey } , heartbeatIntervalMs: 5000, timeout: 5000 };
+const realtimeServiceRole = { vsn: '2.0.0', logger: console.log, params: { apikey: serviceRoleKey } , heartbeatIntervalMs: 5000, timeout: 5000 };
+
+let clientV1: RealtimeClient | null;
+let clientV2: RealtimeClient | null;
+
+describe("broadcast extension", { sanitizeOps: false, sanitizeResources: false }, () => {
+ it("users with different versions can receive self broadcast", withDeadline(async () => {
+ clientV1 = new RealtimeClient(url, realtimeV1)
+ clientV2 = new RealtimeClient(url, realtimeV2)
+ let resultV1 = null;
+ let resultV2 = null;
+ let event = crypto.randomUUID();
+ let topic = "topic:" + crypto.randomUUID();
+ let expectedPayload = { message: crypto.randomUUID() };
+ const config = { config: { broadcast: { ack: true, self: true } } };
+
+ const channelV1 = clientV1
+ .channel(topic, config)
+ .on("broadcast", { event }, ({ payload }) => (resultV1 = payload))
+ .subscribe();
+
+ const channelV2 = clientV2
+ .channel(topic, config)
+ .on("broadcast", { event }, ({ payload }) => (resultV2 = payload))
+ .subscribe();
+
+ while (channelV1.state != "joined" || channelV2.state != "joined") await sleep(0.2);
+
+ // Send from V1 client - both should receive
+ await channelV1.send({
+ type: "broadcast",
+ event,
+ payload: expectedPayload,
+ });
+
+ while (resultV1 == null || resultV2 == null) await sleep(0.2);
+
+ assertEquals(resultV1, expectedPayload);
+ assertEquals(resultV2, expectedPayload);
+
+ // Reset results for second test
+ resultV1 = null;
+ resultV2 = null;
+ let expectedPayload2 = { message: crypto.randomUUID() };
+
+ // Send from V2 client - both should receive
+ await channelV2.send({
+ type: "broadcast",
+ event,
+ payload: expectedPayload2,
+ });
+
+ while (resultV1 == null || resultV2 == null) await sleep(0.2);
+
+ assertEquals(resultV1, expectedPayload2);
+ assertEquals(resultV2, expectedPayload2);
+
+ await channelV1.unsubscribe();
+ await channelV2.unsubscribe();
+
+ await stopClient(clientV1);
+ await stopClient(clientV2);
+ clientV1 = null;
+ clientV2 = null;
+ }, 5000));
+
+ it("v2 can send/receive binary payload", withDeadline(async () => {
+ clientV2 = new RealtimeClient(url, realtimeV2)
+ let result = null;
+ let event = crypto.randomUUID();
+ let topic = "topic:" + crypto.randomUUID();
+ const expectedPayload = new ArrayBuffer(2);
+ const uint8 = new Uint8Array(expectedPayload); // View the buffer as unsigned 8-bit integers
+ uint8[0] = 125;
+ uint8[1] = 255;
+
+ const config = { config: { broadcast: { ack: true, self: true } } };
+
+ const channelV2 = clientV2
+ .channel(topic, config)
+ .on("broadcast", { event }, ({ payload }) => (result = payload))
+ .subscribe();
+
+ while (channelV2.state != "joined") await sleep(0.2);
+
+ await channelV2.send({
+ type: "broadcast",
+ event,
+ payload: expectedPayload,
+ });
+
+ while (result == null) await sleep(0.2);
+
+ assertEquals(result, expectedPayload);
+
+ await channelV2.unsubscribe();
+
+ await stopClient(clientV2);
+ clientV2 = null;
+ }, 5000));
+
+ it("users with different versions can receive broadcasts from endpoint", withDeadline(async () => {
+ clientV1 = new RealtimeClient(url, realtimeV1)
+ clientV2 = new RealtimeClient(url, realtimeV2)
+ let resultV1 = null;
+ let resultV2 = null;
+ let event = crypto.randomUUID();
+ let topic = "topic:" + crypto.randomUUID();
+ let expectedPayload = { message: crypto.randomUUID() };
+ const config = { config: { broadcast: { ack: true, self: true } } };
+
+ const channelV1 = clientV1
+ .channel(topic, config)
+ .on("broadcast", { event }, ({ payload }) => (resultV1 = payload))
+ .subscribe();
+
+ const channelV2 = clientV2
+ .channel(topic, config)
+ .on("broadcast", { event }, ({ payload }) => (resultV2 = payload))
+ .subscribe();
+
+ while (channelV1.state != "joined" || channelV2.state != "joined") await sleep(0.2);
+
+ // Send from unsubscribed channel - both should receive
+ new RealtimeClient(url, realtimeServiceRole).channel(topic, config).httpSend(event, expectedPayload);
+
+ while (resultV1 == null || resultV2 == null) await sleep(0.2);
+
+ assertEquals(resultV1, expectedPayload);
+ assertEquals(resultV2, expectedPayload);
+
+ await channelV1.unsubscribe();
+ await channelV2.unsubscribe();
+
+ await stopClient(clientV1);
+ await stopClient(clientV2);
+ clientV1 = null;
+ clientV2 = null;
+ }, 5000));
+});
+
+// describe("presence extension", () => {
+// it("user is able to receive presence updates", async () => {
+// let result: any = [];
+// let error = null;
+// let topic = "topic:" + crypto.randomUUID();
+// let keyV1 = "key V1";
+// let keyV2 = "key V2";
+//
+// const configV1 = { config: { presence: { keyV1 } } };
+// const configV2 = { config: { presence: { keyV1 } } };
+//
+// const channelV1 = clientV1
+// .channel(topic, configV1)
+// .on("presence", { event: "join" }, ({ key, newPresences }) =>
+// result.push({ key, newPresences })
+// )
+// .subscribe();
+//
+// const channelV2 = clientV2
+// .channel(topic, configV2)
+// .on("presence", { event: "join" }, ({ key, newPresences }) =>
+// result.push({ key, newPresences })
+// )
+// .subscribe();
+//
+// while (channelV1.state != "joined" || channelV2.state != "joined") await sleep(0.2);
+//
+// const resV1 = await channelV1.track({ key: keyV1 });
+// const resV2 = await channelV2.track({ key: keyV2 });
+//
+// if (resV1 == "timed out" || resV2 == "timed out") error = resV1 || resV2;
+//
+// sleep(2.2);
+//
+// // FIXME write assertions
+// console.log(result)
+// let presences = result[0].newPresences[0];
+// assertEquals(result[0].key, keyV1);
+// assertEquals(presences.message, message);
+// assertEquals(error, null);
+// });
+// });
+
+async function stopClient(client: RealtimeClient | null) {
+ if (client) {
+ await client.removeAllChannels();
+ }
+}
diff --git a/test/integration/tracker_test.exs b/test/integration/tracker_test.exs
new file mode 100644
index 000000000..3f232d4bd
--- /dev/null
+++ b/test/integration/tracker_test.exs
@@ -0,0 +1,96 @@
+defmodule Integration.TrackerTest do
+ # Changing the Tracker ETS table
+ use RealtimeWeb.ConnCase, async: false
+
+ alias RealtimeWeb.RealtimeChannel.Tracker
+ alias Phoenix.Socket.Message
+ alias Realtime.Tenants.Connect
+ alias Realtime.Integration.WebsocketClient
+
+ setup do
+ tenant = Containers.checkout_tenant(run_migrations: true)
+ :ets.delete_all_objects(Tracker.table_name())
+
+ {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
+ assert Connect.ready?(tenant.external_id)
+ %{db_conn: db_conn, tenant: tenant}
+ end
+
+ test "tracks and untracks properly channels", %{tenant: tenant} do
+ {socket, _} = get_connection(tenant)
+ config = %{broadcast: %{self: true}, private: false, presence: %{enabled: false}}
+
+ topics =
+ for _ <- 1..10 do
+ topic = "realtime:#{random_string()}"
+ :ok = WebsocketClient.join(socket, topic, %{config: config})
+ assert_receive %Message{topic: ^topic, event: "phx_reply"}, 500
+ topic
+ end
+
+ for topic <- topics do
+ :ok = WebsocketClient.leave(socket, topic, %{})
+ assert_receive %Message{topic: ^topic, event: "phx_close"}, 500
+ end
+
+ start_supervised!({Tracker, check_interval_in_ms: 100})
+ # wait to trigger tracker
+ assert_process_down(socket, 1000)
+ end
+
+ test "failed connections are present in tracker with counter lower than 0 so they are actioned on by tracker", %{
+ tenant: tenant
+ } do
+ assert [] = Tracker.list_pids()
+
+ {socket, _} = get_connection(tenant)
+ config = %{broadcast: %{self: true}, private: true, presence: %{enabled: false}}
+
+ for _ <- 1..10 do
+ topic = "realtime:#{random_string()}"
+ :ok = WebsocketClient.join(socket, topic, %{config: config})
+ assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "error"}}, 500
+ end
+
+ assert [{_pid, count}] = Tracker.list_pids()
+ assert count == 0
+ end
+
+ test "failed connections but one succeeds properly tracks", %{tenant: tenant} do
+ assert [] = Tracker.list_pids()
+
+ {socket, _} = get_connection(tenant)
+ topic = "realtime:#{random_string()}"
+
+ :ok =
+ WebsocketClient.join(socket, topic, %{
+ config: %{broadcast: %{self: true}, private: false, presence: %{enabled: false}}
+ })
+
+ assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "ok"}}, 500
+ assert [{_pid, count}] = Tracker.list_pids()
+ assert count == 1
+
+ for _ <- 1..10 do
+ topic = "realtime:#{random_string()}"
+
+ :ok =
+ WebsocketClient.join(socket, topic, %{
+ config: %{broadcast: %{self: true}, private: true, presence: %{enabled: false}}
+ })
+
+ assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "error"}}, 500
+ end
+
+ topic = "realtime:#{random_string()}"
+
+ :ok =
+ WebsocketClient.join(socket, topic, %{
+ config: %{broadcast: %{self: true}, private: false, presence: %{enabled: false}}
+ })
+
+ assert_receive %Message{topic: ^topic, event: "phx_reply", payload: %{"status" => "ok"}}, 500
+ assert [{_pid, count}] = Tracker.list_pids()
+ assert count == 2
+ end
+end
diff --git a/test/realtime/adapters/postgres/protocol_test.exs b/test/realtime/adapters/postgres/protocol_test.exs
index 778a96244..3f4a17abc 100644
--- a/test/realtime/adapters/postgres/protocol_test.exs
+++ b/test/realtime/adapters/postgres/protocol_test.exs
@@ -1,6 +1,9 @@
defmodule Realtime.Adapters.Postgres.ProtocolTest do
use ExUnit.Case, async: true
+
alias Realtime.Adapters.Postgres.Protocol
+ alias Realtime.Adapters.Postgres.Protocol.Write
+ alias Realtime.Adapters.Postgres.Protocol.KeepAlive
test "defguard is_write/1" do
require Protocol
@@ -13,4 +16,70 @@ defmodule Realtime.Adapters.Postgres.ProtocolTest do
assert Protocol.is_keep_alive("k")
refute Protocol.is_keep_alive("w")
end
+
+ describe "parse/1" do
+ test "parses a write message" do
+ wal_start = 100
+ wal_end = 200
+ clock = 300
+ message = "some wal data"
+
+ binary = <>
+
+ assert %Write{
+ server_wal_start: ^wal_start,
+ server_wal_end: ^wal_end,
+ server_system_clock: ^clock,
+ message: ^message
+ } = Protocol.parse(binary)
+ end
+
+ test "parses a keep alive message with reply now" do
+ wal_end = 500
+ clock = 600
+
+ binary = <>
+
+ assert %KeepAlive{wal_end: ^wal_end, clock: ^clock, reply: :now} = Protocol.parse(binary)
+ end
+
+ test "parses a keep alive message with reply later" do
+ wal_end = 500
+ clock = 600
+
+ binary = <>
+
+ assert %KeepAlive{wal_end: ^wal_end, clock: ^clock, reply: :later} = Protocol.parse(binary)
+ end
+ end
+
+ describe "standby_status/5" do
+ test "returns binary message with reply now" do
+ [message] = Protocol.standby_status(100, 200, 300, :now, 400)
+
+ assert <> = message
+ end
+
+ test "returns binary message with reply later" do
+ [message] = Protocol.standby_status(100, 200, 300, :later, 400)
+
+ assert <> = message
+ end
+
+ test "uses current_time when clock is nil" do
+ [message] = Protocol.standby_status(100, 200, 300, :now)
+
+ assert <> = message
+ end
+ end
+
+ test "hold/0 returns empty list" do
+ assert Protocol.hold() == []
+ end
+
+ test "current_time/0 returns a positive integer" do
+ time = Protocol.current_time()
+ assert is_integer(time)
+ assert time > 0
+ end
end
diff --git a/test/realtime/api/extensions_test.exs b/test/realtime/api/extensions_test.exs
new file mode 100644
index 000000000..f4ceb1d37
--- /dev/null
+++ b/test/realtime/api/extensions_test.exs
@@ -0,0 +1,177 @@
+defmodule Realtime.Api.ExtensionsTest do
+ use ExUnit.Case, async: true
+
+ alias Realtime.Api.Extensions
+
+ describe "changeset/2 with nil type" do
+ test "skips default settings merge" do
+ changeset = Extensions.changeset(%Extensions{}, %{"settings" => %{"foo" => "bar"}})
+ assert changeset.changes[:settings] == %{"foo" => "bar"}
+ end
+
+ test "validates required fields" do
+ changeset = Extensions.changeset(%Extensions{}, %{})
+ refute changeset.valid?
+ assert {"can't be blank", _} = changeset.errors[:type]
+ assert {"can't be blank", _} = changeset.errors[:settings]
+ end
+ end
+
+ describe "changeset/2 with type" do
+ test "merges default settings for postgres_cdc_rls" do
+ attrs = %{
+ "type" => "postgres_cdc_rls",
+ "settings" => %{
+ "region" => "us-east-1",
+ "db_host" => "localhost",
+ "db_name" => "postgres",
+ "db_user" => "user",
+ "db_port" => "5432",
+ "db_password" => "pass"
+ }
+ }
+
+ changeset = Extensions.changeset(%Extensions{}, attrs)
+ settings = changeset.changes[:settings]
+
+ assert settings["publication"] == "supabase_realtime"
+ assert settings["slot_name"] == "supabase_realtime_replication_slot"
+ assert settings["region"] == "us-east-1"
+ end
+
+ test "encrypts optional runtime credentials when provided" do
+ attrs = %{
+ "type" => "postgres_cdc_rls",
+ "settings" => %{
+ "region" => "us-east-1",
+ "db_host" => "localhost",
+ "db_port" => "5432",
+ "db_name" => "postgres",
+ "db_user" => "supabase_admin",
+ "db_password" => "pass",
+ "db_user_realtime" => "supabase_realtime_admin",
+ "db_pass_realtime" => "realtime-pass"
+ }
+ }
+
+ settings = Extensions.changeset(%Extensions{}, attrs).changes[:settings]
+
+ assert Realtime.Crypto.decrypt!(settings["db_user_realtime"]) == "supabase_realtime_admin"
+ assert Realtime.Crypto.decrypt!(settings["db_pass_realtime"]) == "realtime-pass"
+ end
+
+ test "omits runtime credentials when not provided" do
+ attrs = %{
+ "type" => "postgres_cdc_rls",
+ "settings" => %{
+ "region" => "us-east-1",
+ "db_host" => "localhost",
+ "db_port" => "5432",
+ "db_name" => "postgres",
+ "db_user" => "supabase_admin",
+ "db_password" => "pass"
+ }
+ }
+
+ settings = Extensions.changeset(%Extensions{}, attrs).changes[:settings]
+
+ refute Map.has_key?(settings, "db_user_realtime")
+ refute Map.has_key?(settings, "db_pass_realtime")
+ end
+ end
+
+ describe "validate_required_settings/2" do
+ test "adds error when required field is nil" do
+ required = [{"db_host", &is_binary/1, false}]
+
+ changeset =
+ %Extensions{}
+ |> Ecto.Changeset.cast(%{type: "test", settings: %{}}, [:type, :settings])
+ |> Extensions.validate_required_settings(required)
+
+ refute changeset.valid?
+ assert {"db_host can't be blank", []} = changeset.errors[:settings]
+ end
+
+ test "adds error when checker function fails" do
+ required = [{"db_port", &is_binary/1, false}]
+
+ changeset =
+ %Extensions{}
+ |> Ecto.Changeset.cast(%{type: "test", settings: %{"db_port" => 5432}}, [:type, :settings])
+ |> Extensions.validate_required_settings(required)
+
+ refute changeset.valid?
+ assert {"db_port is invalid", []} = changeset.errors[:settings]
+ end
+
+ test "passes when all required fields are valid" do
+ required = [{"db_host", &is_binary/1, false}]
+
+ changeset =
+ %Extensions{}
+ |> Ecto.Changeset.cast(%{type: "test", settings: %{"db_host" => "localhost"}}, [:type, :settings])
+ |> Extensions.validate_required_settings(required)
+
+ assert changeset.valid?
+ end
+ end
+
+ describe "validate_optional_settings/2" do
+ test "passes when the optional field is absent" do
+ optional = [{"db_user_realtime", &is_binary/1, true}]
+
+ changeset =
+ %Extensions{}
+ |> Ecto.Changeset.cast(%{type: "test", settings: %{"db_host" => "localhost"}}, [:type, :settings])
+ |> Extensions.validate_optional_settings(optional)
+
+ assert changeset.valid?
+ end
+
+ test "adds error when a present optional field is invalid" do
+ optional = [{"db_user_realtime", &is_binary/1, true}]
+
+ changeset =
+ %Extensions{}
+ |> Ecto.Changeset.cast(%{type: "test", settings: %{"db_user_realtime" => 123}}, [:type, :settings])
+ |> Extensions.validate_optional_settings(optional)
+
+ refute changeset.valid?
+ assert {"db_user_realtime is invalid", []} = changeset.errors[:settings]
+ end
+ end
+
+ describe "encrypt_settings/2" do
+ test "encrypts fields flagged for encryption" do
+ changeset =
+ %Extensions{}
+ |> Ecto.Changeset.cast(%{type: "test", settings: %{"db_password" => "secret"}}, [:type, :settings])
+ |> Extensions.encrypt_settings([{"db_password", &is_binary/1, true}])
+
+ settings = Ecto.Changeset.get_change(changeset, :settings)
+ assert settings["db_password"] != "secret"
+ assert Realtime.Crypto.decrypt!(settings["db_password"]) == "secret"
+ end
+
+ test "leaves fields not flagged for encryption untouched" do
+ changeset =
+ %Extensions{}
+ |> Ecto.Changeset.cast(%{type: "test", settings: %{"region" => "us-east-1"}}, [:type, :settings])
+ |> Extensions.encrypt_settings([{"region", &is_binary/1, false}])
+
+ settings = Ecto.Changeset.get_change(changeset, :settings)
+ assert settings["region"] == "us-east-1"
+ end
+
+ test "skips flagged fields that are absent" do
+ changeset =
+ %Extensions{}
+ |> Ecto.Changeset.cast(%{type: "test", settings: %{"db_host" => "localhost"}}, [:type, :settings])
+ |> Extensions.encrypt_settings([{"db_pass_realtime", &is_binary/1, true}])
+
+ settings = Ecto.Changeset.get_change(changeset, :settings)
+ refute Map.has_key?(settings, "db_pass_realtime")
+ end
+ end
+end
diff --git a/test/realtime/api_test.exs b/test/realtime/api_test.exs
index 1c4a816b0..ce0d101a3 100644
--- a/test/realtime/api_test.exs
+++ b/test/realtime/api_test.exs
@@ -4,31 +4,41 @@ defmodule Realtime.ApiTest do
use Mimic
alias Realtime.Api
- alias Realtime.Api.Extensions
+ alias Realtime.Api.Extensions, as: ApiExtensions
+ alias Realtime.Api.FeatureFlag
alias Realtime.Api.Tenant
alias Realtime.Crypto
alias Realtime.GenCounter
+ alias Realtime.GenRpc
+ alias Realtime.Nodes
alias Realtime.RateCounter
alias Realtime.Tenants.Connect
+ alias Extensions.PostgresCdcRls
@db_conf Application.compile_env(:realtime, Realtime.Repo)
- setup do
- tenant1 = Containers.checkout_tenant(run_migrations: true)
- tenant2 = Containers.checkout_tenant(run_migrations: true)
- Api.update_tenant(tenant1, %{max_concurrent_users: 10_000_000})
- Api.update_tenant(tenant2, %{max_concurrent_users: 20_000_000})
-
- %{tenants: Api.list_tenants(), tenant: tenant1}
+ defp create_tenants(_) do
+ tenant1 = tenant_fixture(%{max_concurrent_users: 10_000_000})
+ tenant2 = tenant_fixture(%{max_concurrent_users: 20_000_000})
+ tenant3 = tenant_fixture(%{max_concurrent_users: 30_000_000})
+ %{tenants: [tenant1, tenant2, tenant3]}
end
describe "list_tenants/0" do
+ setup [:create_tenants]
+
test "returns all tenants", %{tenants: tenants} do
- assert Enum.sort(Api.list_tenants()) == Enum.sort(tenants)
+ assert Api.list_tenants()
+
+ Enum.each(tenants, fn tenant ->
+ assert tenant in Api.list_tenants()
+ end)
end
end
describe "list_tenants/1" do
+ setup [:create_tenants]
+
test "list_tenants/1 returns filtered tenants", %{tenants: tenants} do
assert hd(Api.list_tenants(search: hd(tenants).external_id)) == hd(tenants)
@@ -38,6 +48,8 @@ defmodule Realtime.ApiTest do
end
describe "get_tenant!/1" do
+ setup [:create_tenants]
+
test "returns the tenant with given id", %{tenants: [tenant | _]} do
result = tenant.id |> Api.get_tenant!() |> Map.delete(:extensions)
expected = tenant |> Map.delete(:extensions)
@@ -51,6 +63,10 @@ defmodule Realtime.ApiTest do
external_id = random_string()
+ expect(Realtime.Tenants.Cache, :global_cache_update, fn tenant ->
+ assert tenant.external_id == external_id
+ end)
+
valid_attrs = %{
external_id: external_id,
name: external_id,
@@ -85,109 +101,154 @@ defmodule Realtime.ApiTest do
end
test "invalid data returns error changeset" do
+ reject(&Realtime.Tenants.Cache.global_cache_update/1)
assert {:error, %Ecto.Changeset{}} = Api.create_tenant(%{external_id: nil, jwt_secret: nil, name: nil})
end
end
- describe "get_tenant_by_external_id/1" do
+ describe "get_tenant_by_external_id/2" do
+ setup [:create_tenants]
+
test "fetch by external id", %{tenants: [tenant | _]} do
- %Tenant{extensions: [%Extensions{} = extension]} =
+ %Tenant{extensions: [%ApiExtensions{} = extension]} =
Api.get_tenant_by_external_id(tenant.external_id)
assert Map.has_key?(extension.settings, "db_password")
password = extension.settings["db_password"]
assert ^password = "v1QVng3N+pZd/0AEObABwg=="
end
+
+ test "fetch by external id using replica", %{tenants: [tenant | _]} do
+ %Tenant{extensions: [%ApiExtensions{} = extension]} =
+ Api.get_tenant_by_external_id(tenant.external_id, use_replica?: true)
+
+ assert Map.has_key?(extension.settings, "db_password")
+ password = extension.settings["db_password"]
+ assert ^password = "v1QVng3N+pZd/0AEObABwg=="
+ end
+
+ test "fetch by external id using no replica", %{tenants: [tenant | _]} do
+ %Tenant{extensions: [%ApiExtensions{} = extension]} =
+ Api.get_tenant_by_external_id(tenant.external_id, use_replica?: false)
+
+ assert Map.has_key?(extension.settings, "db_password")
+ password = extension.settings["db_password"]
+ assert ^password = "v1QVng3N+pZd/0AEObABwg=="
+ end
end
- describe "update_tenant/2" do
- test "valid data updates the tenant", %{tenant: tenant} do
+ describe "update_tenant_by_external_id/2" do
+ setup [:create_tenants]
+
+ test "valid data updates the tenant using external_id", %{tenants: [tenant | _]} do
update_attrs = %{
external_id: tenant.external_id,
jwt_secret: "some updated jwt_secret",
name: "some updated name"
}
- assert {:ok, %Tenant{} = tenant} = Api.update_tenant(tenant, update_attrs)
+ assert {:ok, %Tenant{} = tenant} = Api.update_tenant_by_external_id(tenant.external_id, update_attrs)
assert tenant.external_id == tenant.external_id
assert tenant.jwt_secret == Crypto.encrypt!("some updated jwt_secret")
assert tenant.name == "some updated name"
end
- test "invalid data returns error changeset", %{tenant: tenant} do
- assert {:error, %Ecto.Changeset{}} = Api.update_tenant(tenant, %{external_id: nil, jwt_secret: nil, name: nil})
+ test "invalid data returns error changeset", %{tenants: [tenant | _]} do
+ assert {:error, %Ecto.Changeset{}} =
+ Api.update_tenant_by_external_id(tenant.external_id, %{external_id: nil, jwt_secret: nil, name: nil})
end
- test "valid data and jwks change will send disconnect event", %{tenant: tenant} do
+ test "valid data and jwks change will send disconnect event", %{tenants: [tenant | _]} do
:ok = Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant.external_id)
- assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{jwt_jwks: %{keys: ["test"]}})
- assert_receive :disconnect, 500
+ assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{jwt_jwks: %{keys: ["test"]}})
+
+ assert %Phoenix.Socket.Broadcast{
+ payload: %{message: "Server requested disconnect", status: "ok", extension: "system"},
+ event: "system",
+ topic: nil
+ }
end
- test "valid data and jwt_secret change will send disconnect event", %{tenant: tenant} do
+ test "valid data and jwt_secret change will send disconnect event", %{tenants: [tenant | _]} do
:ok = Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant.external_id)
- assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{jwt_secret: "potato"})
- assert_receive :disconnect, 500
+ assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{jwt_secret: "potato"})
+
+ assert %Phoenix.Socket.Broadcast{
+ payload: %{message: "Server requested disconnect", status: "ok", extension: "system"},
+ event: "system",
+ topic: nil
+ }
end
- test "valid data and suspend change will send disconnect event", %{tenant: tenant} do
+ test "valid data and suspend change will send disconnect event", %{tenants: [tenant | _]} do
:ok = Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant.external_id)
- assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{suspend: true})
- assert_receive :disconnect, 500
+ assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{suspend: true})
+
+ assert %Phoenix.Socket.Broadcast{
+ payload: %{message: "Server requested disconnect", status: "ok", extension: "system"},
+ event: "system",
+ topic: nil
+ }
end
- test "valid data but not updating jwt_secret or jwt_jwks won't send event", %{tenant: tenant} do
+ test "valid data but not updating jwt_secret or jwt_jwks won't send event", %{tenants: [tenant | _]} do
:ok = Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant.external_id)
- assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{max_events_per_second: 100})
- refute_receive :disconnect, 500
+ assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{max_events_per_second: 100})
+ refute_receive _any
end
- test "valid data and jwt_secret change will restart the database connection", %{tenant: tenant} do
- {:ok, old_pid} = Connect.lookup_or_start_connection(tenant.external_id)
+ test "valid data and jwt_secret change will restart the database connection", %{tenants: [tenant | _]} do
+ expect(Connect, :shutdown, fn external_id ->
+ assert external_id == tenant.external_id
+ :ok
+ end)
- Process.monitor(old_pid)
- assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{jwt_secret: "potato"})
- assert_receive {:DOWN, _, :process, ^old_pid, :shutdown}, 500
- refute Process.alive?(old_pid)
- Process.sleep(100)
- assert {:ok, new_pid} = Connect.lookup_or_start_connection(tenant.external_id)
- assert %Postgrex.Result{} = Postgrex.query!(new_pid, "SELECT 1", [])
+ expect(PostgresCdcRls, :handle_stop, fn external_id, timeout ->
+ assert external_id == tenant.external_id
+ assert timeout == 5_000
+ :ok
+ end)
+
+ assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{jwt_secret: "potato"})
end
- test "valid data and suspend change will restart the database connection", %{tenant: tenant} do
- {:ok, old_pid} = Connect.lookup_or_start_connection(tenant.external_id)
+ test "valid data and suspend change will restart the database connection", %{tenants: [tenant | _]} do
+ expect(Connect, :shutdown, fn external_id ->
+ assert external_id == tenant.external_id
+ :ok
+ end)
+
+ expect(PostgresCdcRls, :handle_stop, fn external_id, timeout ->
+ assert external_id == tenant.external_id
+ assert timeout == 5_000
+ :ok
+ end)
- Process.monitor(old_pid)
- assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{suspend: true})
- assert_receive {:DOWN, _, :process, ^old_pid, :shutdown}, 500
- refute Process.alive?(old_pid)
- Process.sleep(100)
- assert {:error, :tenant_suspended} = Connect.lookup_or_start_connection(tenant.external_id)
+ assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{suspend: true})
end
- test "valid data and tenant data change will not restart the database connection", %{tenant: tenant} do
- {:ok, old_pid} = Connect.lookup_or_start_connection(tenant.external_id)
+ test "valid data and tenant data change will not restart the database connection", %{tenants: [tenant | _]} do
+ reject(&Connect.shutdown/1)
+ reject(&PostgresCdcRls.handle_stop/2)
- assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{max_concurrent_users: 100})
- refute_receive {:DOWN, _, :process, ^old_pid, :shutdown}, 500
- assert Process.alive?(old_pid)
- assert {:ok, new_pid} = Connect.lookup_or_start_connection(tenant.external_id)
- assert old_pid == new_pid
- end
+ expect(Realtime.Tenants.Cache, :global_cache_update, fn tenant ->
+ assert tenant.max_concurrent_users == 101
+ end)
- test "valid data and extensions data change will restart the database connection", %{tenant: tenant} do
- config = Realtime.Database.from_tenant(tenant, "realtime_test", :stop)
+ assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{max_concurrent_users: 101})
+ end
+ test "valid data and extensions data change will restart the database connection", %{tenants: [tenant | _]} do
extensions = [
%{
"type" => "postgres_cdc_rls",
"settings" => %{
"db_host" => "127.0.0.1",
"db_name" => "postgres",
- "db_user" => "supabase_admin",
+ "db_user" => "supabase_realtime_admin",
"db_password" => "postgres",
- "db_port" => "#{config.port}",
+ "db_port" => "5432",
"poll_interval" => 100,
"poll_max_changes" => 100,
"poll_max_record_bytes" => 1_048_576,
@@ -198,32 +259,135 @@ defmodule Realtime.ApiTest do
}
]
- {:ok, old_pid} = Connect.lookup_or_start_connection(tenant.external_id)
- Process.monitor(old_pid)
- assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{extensions: extensions})
- assert_receive {:DOWN, _, :process, ^old_pid, :shutdown}, 500
- refute Process.alive?(old_pid)
- Process.sleep(100)
- assert {:ok, new_pid} = Connect.lookup_or_start_connection(tenant.external_id)
- assert %Postgrex.Result{} = Postgrex.query!(new_pid, "SELECT 1", [])
+ expect(Connect, :shutdown, fn external_id ->
+ assert external_id == tenant.external_id
+ :ok
+ end)
+
+ expect(PostgresCdcRls, :handle_stop, fn external_id, timeout ->
+ assert external_id == tenant.external_id
+ assert timeout == 5_000
+ :ok
+ end)
+
+ assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{extensions: extensions})
end
- test "valid data and change to tenant data will refresh cache", %{tenant: tenant} do
- assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{name: "new_name"})
- assert %Tenant{name: "new_name"} = Realtime.Tenants.Cache.get_tenant_by_external_id(tenant.external_id)
+ test "valid data and jwt_jwks change will restart the database connection", %{tenants: [tenant | _]} do
+ expect(Connect, :shutdown, fn external_id ->
+ assert external_id == tenant.external_id
+ :ok
+ end)
+
+ expect(PostgresCdcRls, :handle_stop, fn external_id, timeout ->
+ assert external_id == tenant.external_id
+ assert timeout == 5_000
+ :ok
+ end)
+
+ assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{jwt_jwks: %{keys: ["test"]}})
end
- test "valid data and no changes to tenant will not refresh cache", %{tenant: tenant} do
- reject(&Realtime.Tenants.Cache.get_tenant_by_external_id/1)
- assert {:ok, %Tenant{}} = Api.update_tenant(tenant, %{name: tenant.name})
+ test "valid data and jwt_secret change will restart DB connection even if handle_stop times out", %{
+ tenants: [tenant | _]
+ } do
+ expect(Connect, :shutdown, fn external_id ->
+ assert external_id == tenant.external_id
+ :ok
+ end)
+
+ expect(PostgresCdcRls, :handle_stop, fn _external_id, _timeout ->
+ # Simulate timeout exit like DynamicSupervisor.stop/3 does
+ exit(:timeout)
+ end)
+
+ # Update should still succeed even if handle_stop times out
+ assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{jwt_secret: "potato"})
end
- end
- describe "delete_tenant/1" do
- test "deletes the tenant" do
- tenant = tenant_fixture()
- assert {:ok, %Tenant{}} = Api.delete_tenant(tenant)
- assert_raise Ecto.NoResultsError, fn -> Api.get_tenant!(tenant.id) end
+ test "valid data and change to tenant data will refresh cache", %{tenants: [tenant | _]} do
+ expect(Realtime.Tenants.Cache, :global_cache_update, fn tenant ->
+ assert tenant.name == "new_name"
+ end)
+
+ assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{name: "new_name"})
+ end
+
+ test "valid data and no changes to tenant will not refresh cache", %{tenants: [tenant | _]} do
+ reject(&Realtime.Tenants.Cache.global_cache_update/1)
+ assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{name: tenant.name})
+ end
+
+ test "change to max_events_per_second publishes update to respective rate counters", %{tenants: [tenant | _]} do
+ expect(RateCounter, :publish_update, fn key ->
+ assert key == Realtime.Tenants.events_per_second_key(tenant.external_id)
+ end)
+
+ expect(RateCounter, :publish_update, fn key ->
+ assert key == Realtime.Tenants.db_events_per_second_key(tenant.external_id)
+ end)
+
+ reject(&RateCounter.publish_update/1)
+
+ assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{max_events_per_second: 123})
+ end
+
+ test "change to max_joins_per_second publishes update to rate counters", %{tenants: [tenant | _]} do
+ expect(RateCounter, :publish_update, fn key ->
+ assert key == Realtime.Tenants.joins_per_second_key(tenant.external_id)
+ end)
+
+ reject(&RateCounter.publish_update/1)
+
+ assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{max_joins_per_second: 123})
+ end
+
+ test "change to max_presence_events_per_second publishes update to rate counters", %{tenants: [tenant | _]} do
+ expect(RateCounter, :publish_update, fn key ->
+ assert key == Realtime.Tenants.presence_events_per_second_key(tenant.external_id)
+ end)
+
+ reject(&RateCounter.publish_update/1)
+
+ assert {:ok, %Tenant{}} =
+ Api.update_tenant_by_external_id(tenant.external_id, %{max_presence_events_per_second: 123})
+ end
+
+ test "change to extensions publishes update to rate counters", %{tenants: [tenant | _]} do
+ extensions = [
+ %{
+ "type" => "postgres_cdc_rls",
+ "settings" => %{
+ "db_host" => "127.0.0.1",
+ "db_name" => "postgres",
+ "db_user" => "supabase_realtime_admin",
+ "db_password" => "postgres",
+ "db_port" => "1234",
+ "poll_interval" => 100,
+ "poll_max_changes" => 100,
+ "poll_max_record_bytes" => 1_048_576,
+ "region" => "us-east-1",
+ "publication" => "supabase_realtime_test",
+ "ssl_enforced" => false
+ }
+ }
+ ]
+
+ expect(RateCounter, :publish_update, fn key ->
+ assert key == Realtime.Tenants.connect_errors_per_second_key(tenant.external_id)
+ end)
+
+ expect(RateCounter, :publish_update, fn key ->
+ assert key == Realtime.Tenants.subscription_errors_per_second_key(tenant.external_id)
+ end)
+
+ expect(RateCounter, :publish_update, fn key ->
+ assert key == Realtime.Tenants.authorization_errors_per_second_key(tenant.external_id)
+ end)
+
+ reject(&RateCounter.publish_update/1)
+
+ assert {:ok, %Tenant{}} = Api.update_tenant_by_external_id(tenant.external_id, %{extensions: extensions})
end
end
@@ -236,11 +400,9 @@ defmodule Realtime.ApiTest do
end
end
- test "list_extensions/1 ", %{tenants: tenants} do
- assert length(Api.list_extensions()) == length(tenants)
- end
-
describe "preload_counters/1" do
+ setup [:create_tenants]
+
test "preloads counters for a given tenant ", %{tenants: [tenant | _]} do
tenant = Repo.reload!(tenant)
assert Api.preload_counters(nil) == nil
@@ -256,6 +418,7 @@ defmodule Realtime.ApiTest do
end
describe "rename_settings_field/2" do
+ @tag skip: "** (Postgrex.Error) ERROR 0A000 (feature_not_supported) cached plan must not change result type"
test "renames setting fields" do
tenant = tenant_fixture()
Api.rename_settings_field("poll_interval_ms", "poll_interval")
@@ -340,4 +503,130 @@ defmodule Realtime.ApiTest do
refute TestRequiresRestartingDbConnection.check(changeset)
end
end
+
+ describe "update_migrations_ran/1" do
+ test "updates migrations_ran to the count of all migrations" do
+ tenant = tenant_fixture(%{migrations_ran: 0})
+
+ expect(Realtime.Tenants.Cache, :global_cache_update, fn tenant ->
+ assert tenant.migrations_ran == 1
+ :ok
+ end)
+
+ assert {:ok, tenant} = Api.update_migrations_ran(tenant.external_id, 1)
+ assert tenant.migrations_ran == 1
+ end
+
+ test "returns {:error, :tenant_not_found} when tenant does not exist" do
+ assert {:error, :tenant_not_found} = Api.update_migrations_ran("removed", 11)
+ end
+ end
+
+ describe "list_feature_flags/0" do
+ test "returns all flags ordered by name" do
+ {:ok, _} = Api.upsert_feature_flag(%{name: "zebra_flag", enabled: false})
+ {:ok, _} = Api.upsert_feature_flag(%{name: "alpha_flag", enabled: true})
+
+ names = Api.list_feature_flags() |> Enum.map(& &1.name)
+ assert "alpha_flag" in names
+ assert "zebra_flag" in names
+ assert Enum.find_index(names, &(&1 == "alpha_flag")) < Enum.find_index(names, &(&1 == "zebra_flag"))
+ end
+ end
+
+ describe "get_feature_flag/1" do
+ test "returns the flag when it exists" do
+ {:ok, flag} = Api.upsert_feature_flag(%{name: "my_flag", enabled: true})
+ assert %FeatureFlag{name: "my_flag"} = Api.get_feature_flag("my_flag")
+ assert Api.get_feature_flag("my_flag").id == flag.id
+ end
+
+ test "returns nil when flag does not exist" do
+ refute Api.get_feature_flag("nonexistent")
+ end
+ end
+
+ describe "upsert_feature_flag/1" do
+ test "inserts a new flag" do
+ assert {:ok, %FeatureFlag{name: "new_flag", enabled: false}} =
+ Api.upsert_feature_flag(%{name: "new_flag", enabled: false})
+ end
+
+ test "updates an existing flag" do
+ {:ok, _} = Api.upsert_feature_flag(%{name: "existing", enabled: false})
+
+ assert {:ok, %FeatureFlag{name: "existing", enabled: true}} =
+ Api.upsert_feature_flag(%{name: "existing", enabled: true})
+
+ assert Api.list_feature_flags() |> Enum.count(&(&1.name == "existing")) == 1
+ end
+
+ test "returns error changeset when name is missing" do
+ assert {:error, changeset} = Api.upsert_feature_flag(%{enabled: false})
+ assert "can't be blank" in errors_on(changeset).name
+ end
+ end
+
+ describe "delete_feature_flag/1" do
+ test "removes the flag" do
+ {:ok, flag} = Api.upsert_feature_flag(%{name: "to_delete", enabled: false})
+ assert {:ok, _} = Api.delete_feature_flag(flag)
+ refute Api.get_feature_flag("to_delete")
+ end
+ end
+
+ describe "non-master region routing" do
+ setup do
+ previous_region = Application.get_env(:realtime, :region)
+ previous_master_region = Application.get_env(:realtime, :master_region)
+
+ Application.put_env(:realtime, :region, "ap-southeast-2")
+ Application.put_env(:realtime, :master_region, "us-east-1")
+
+ on_exit(fn ->
+ Application.put_env(:realtime, :region, previous_region)
+ Application.put_env(:realtime, :master_region, previous_master_region)
+ end)
+
+ fake_master = :"master@127.0.0.1"
+ Mimic.stub(Nodes, :node_from_region, fn "us-east-1", _key -> {:ok, fake_master} end)
+
+ %{master_node: fake_master}
+ end
+
+ test "upsert_feature_flag dispatches to master with empty opts", %{master_node: master_node} do
+ Mimic.expect(GenRpc, :call, fn ^master_node, Api, :upsert_feature_flag, args, opts ->
+ assert args == [%{name: "rpc_flag", enabled: true}]
+ assert opts == []
+ {:ok, %FeatureFlag{name: "rpc_flag", enabled: true}}
+ end)
+
+ assert {:ok, %FeatureFlag{name: "rpc_flag", enabled: true}} =
+ Api.upsert_feature_flag(%{name: "rpc_flag", enabled: true})
+ end
+
+ test "delete_feature_flag dispatches to master with empty opts", %{master_node: master_node} do
+ flag = %FeatureFlag{id: Ecto.UUID.generate(), name: "rpc_delete", enabled: false}
+
+ Mimic.expect(GenRpc, :call, fn ^master_node, Api, :delete_feature_flag, args, opts ->
+ assert args == [flag]
+ assert opts == []
+ {:ok, flag}
+ end)
+
+ assert {:ok, ^flag} = Api.delete_feature_flag(flag)
+ end
+
+ test "create_tenant dispatches to master with tenant_id opt", %{master_node: master_node} do
+ external_id = "rpc_tenant_#{System.unique_integer([:positive])}"
+ attrs = %{"external_id" => external_id, "name" => external_id}
+
+ Mimic.expect(GenRpc, :call, fn ^master_node, Api, :create_tenant, _args, opts ->
+ assert opts == [tenant_id: external_id]
+ {:ok, %Tenant{external_id: external_id}}
+ end)
+
+ assert {:ok, %Tenant{external_id: ^external_id}} = Api.create_tenant(attrs)
+ end
+ end
end
diff --git a/test/realtime/database_distributed_test.exs b/test/realtime/database_distributed_test.exs
new file mode 100644
index 000000000..cb952c861
--- /dev/null
+++ b/test/realtime/database_distributed_test.exs
@@ -0,0 +1,96 @@
+defmodule Realtime.DatabaseDistributedTest do
+ # async: false due to usage of Clustered
+ use Realtime.DataCase, async: false
+
+ import ExUnit.CaptureLog
+
+ alias Realtime.Database
+ alias Realtime.Rpc
+ alias Realtime.Tenants.Connect
+
+ doctest Realtime.Database
+ def handle_telemetry(event, metadata, content, pid: pid), do: send(pid, {event, metadata, content})
+
+ setup do
+ tenant = Containers.checkout_tenant()
+ :telemetry.attach(__MODULE__, [:realtime, :database, :transaction], &__MODULE__.handle_telemetry/4, pid: self())
+
+ on_exit(fn -> :telemetry.detach(__MODULE__) end)
+
+ %{tenant: tenant}
+ end
+
+ @aux_mod (quote do
+ defmodule DatabaseAux do
+ def checker(transaction_conn) do
+ Postgrex.query!(transaction_conn, "SELECT 1", [])
+ end
+
+ def error(transaction_conn) do
+ Postgrex.query!(transaction_conn, "SELECT 1/0", [])
+ end
+
+ def exception(_) do
+ raise RuntimeError, "💣"
+ end
+ end
+ end)
+
+ Code.eval_quoted(@aux_mod)
+
+ describe "transaction/1 in clustered mode" do
+ setup do
+ tenant = Containers.checkout_tenant_unboxed(run_migrations: true)
+ %{distributed_tenant: tenant}
+ end
+
+ test "success call returns output", %{distributed_tenant: tenant} do
+ {:ok, node} = Clustered.start(@aux_mod)
+ {:ok, db_conn} = Rpc.call(node, Connect, :connect, [tenant.external_id, "us-east-1"])
+ assert node(db_conn) == node
+ assert {:ok, %Postgrex.Result{rows: [[1]]}} = Database.transaction(db_conn, &DatabaseAux.checker/1)
+ end
+
+ test "handles database errors", %{distributed_tenant: tenant} do
+ metadata = [external_id: "123", project: "123"]
+ {:ok, node} = Clustered.start(@aux_mod)
+ {:ok, db_conn} = Rpc.call(node, Connect, :connect, [tenant.external_id, "us-east-1"])
+ assert node(db_conn) == node
+
+ assert capture_log(fn ->
+ assert {:error, %Postgrex.Error{}} = Database.transaction(db_conn, &DatabaseAux.error/1, [], metadata)
+ # We have to wait for logs to be relayed to this node
+ Process.sleep(100)
+ end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:"
+ end
+
+ test "handles exception", %{distributed_tenant: tenant} do
+ metadata = [external_id: "123", project: "123"]
+ {:ok, node} = Clustered.start(@aux_mod)
+ {:ok, db_conn} = Rpc.call(node, Connect, :connect, [tenant.external_id, "us-east-1"])
+ assert node(db_conn) == node
+
+ assert capture_log(fn ->
+ assert {:error, %RuntimeError{}} = Database.transaction(db_conn, &DatabaseAux.exception/1, [], metadata)
+ # We have to wait for logs to be relayed to this node
+ Process.sleep(100)
+ end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:"
+ end
+
+ test "db process is not alive anymore" do
+ metadata = [external_id: "123", project: "123", tenant_id: "123"]
+ {:ok, node} = Clustered.start(@aux_mod)
+
+ pid = Rpc.call(node, :erlang, :self, [])
+ assert node(pid) == node
+
+ assert capture_log(fn ->
+ assert {:error, {:exit, {:noproc, {DBConnection.Holder, :checkout, [^pid, []]}}}} =
+ Database.transaction(pid, &DatabaseAux.checker/1, [], metadata)
+
+ # We have to wait for logs to be relayed to this node
+ Process.sleep(100)
+ end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:"
+ end
+ end
+end
diff --git a/test/realtime/database_test.exs b/test/realtime/database_test.exs
index f48de14b6..646ee82fd 100644
--- a/test/realtime/database_test.exs
+++ b/test/realtime/database_test.exs
@@ -1,16 +1,26 @@
defmodule Realtime.DatabaseTest do
- # async: false due to usage of Clustered
- use Realtime.DataCase, async: false
+ use Realtime.DataCase, async: true
import ExUnit.CaptureLog
alias Realtime.Database
- alias Realtime.Rpc
- alias Realtime.Tenants.Connect
doctest Realtime.Database
def handle_telemetry(event, metadata, content, pid: pid), do: send(pid, {event, metadata, content})
+ defp encrypted_settings(extra \\ %{}) do
+ Map.merge(
+ %{
+ "db_host" => Realtime.Crypto.encrypt!("127.0.0.1"),
+ "db_port" => Realtime.Crypto.encrypt!("5432"),
+ "db_name" => Realtime.Crypto.encrypt!("postgres"),
+ "db_user" => Realtime.Crypto.encrypt!("supabase_admin"),
+ "db_password" => Realtime.Crypto.encrypt!("super-pass")
+ },
+ extra
+ )
+ end
+
setup do
tenant = Containers.checkout_tenant()
:telemetry.attach(__MODULE__, [:realtime, :database, :transaction], &__MODULE__.handle_telemetry/4, pid: self())
@@ -27,7 +37,7 @@ defmodule Realtime.DatabaseTest do
"settings" => %{
"db_host" => "127.0.0.1",
"db_name" => "postgres",
- "db_user" => "supabase_admin",
+ "db_user" => "supabase_realtime_admin",
"db_password" => "postgres",
"region" => "us-east-1",
"ssl_enforced" => false,
@@ -42,32 +52,47 @@ defmodule Realtime.DatabaseTest do
%{tenant: tenant}
end
+ test "returns error when tenant is nil" do
+ assert {:error, :tenant_not_found} = Database.check_tenant_connection(nil)
+ end
+
test "connects to a tenant database", %{tenant: tenant} do
- assert {:ok, _} = Database.check_tenant_connection(tenant)
+ assert {:ok, _conn, migrations_ran} = Database.check_tenant_connection(tenant)
+ assert is_integer(migrations_ran)
+ assert migrations_ran >= 0
+ end
+
+ test "returns 0 migrations when realtime.schema_migrations does not exist", %{tenant: tenant} do
+ # by default new containers do not have the schema_migrations table
+ assert {:ok, _conn, 0} = Database.check_tenant_connection(tenant)
+ end
+
+ test "returns migration count when realtime.schema_migrations exists", %{tenant: tenant} do
+ {:ok, conn} = Database.connect(tenant, "realtime_test", :stop)
+
+ Postgrex.query!(conn, "CREATE TABLE IF NOT EXISTS realtime.schema_migrations (version bigint PRIMARY KEY)", [])
+ Postgrex.query!(conn, "INSERT INTO realtime.schema_migrations VALUES (1), (2), (3)", [])
+
+ assert {:ok, check_conn, 3} = Database.check_tenant_connection(tenant)
+ GenServer.stop(check_conn)
+ GenServer.stop(conn)
end
# Connection limit for docker tenant db is 100
@tag db_pool: 50,
- subs_pool_size: 21,
- subcriber_pool_size: 33
+ subs_pool_size: 73
test "restricts connection if tenant database cannot receive more connections based on tenant pool",
%{tenant: tenant} do
assert capture_log(fn ->
assert {:error, :tenant_db_too_many_connections} = Database.check_tenant_connection(tenant)
- end) =~ ~r/Only \d+ available connections\. At least 126 connections are required/
+ end) =~ ~r/Only \d+ available connections\. At least 125 connections are required/
end
end
describe "replication_slot_teardown/1" do
test "removes replication slots with the realtime prefix", %{tenant: tenant} do
{:ok, conn} = Database.connect(tenant, "realtime_test", :stop)
-
- Postgrex.query!(
- conn,
- "SELECT * FROM pg_create_logical_replication_slot('realtime_test_slot', 'pgoutput')",
- []
- )
-
+ Postgrex.query!(conn, "SELECT * FROM pg_create_logical_replication_slot('realtime_test_slot', 'pgoutput')", [])
Database.replication_slot_teardown(tenant)
assert %{rows: []} = Postgrex.query!(conn, "SELECT slot_name FROM pg_replication_slots", [])
end
@@ -77,13 +102,7 @@ defmodule Realtime.DatabaseTest do
test "removes replication slots with a given name and existing connection", %{tenant: tenant} do
name = String.downcase("slot_#{random_string()}")
{:ok, conn} = Database.connect(tenant, "realtime_test", :stop)
-
- Postgrex.query!(
- conn,
- "SELECT * FROM pg_create_logical_replication_slot('#{name}', 'pgoutput')",
- []
- )
-
+ Postgrex.query!(conn, "SELECT * FROM pg_create_logical_replication_slot('#{name}', 'pgoutput')", [])
Database.replication_slot_teardown(conn, name)
Process.sleep(1000)
assert %{rows: []} = Postgrex.query!(conn, "SELECT slot_name FROM pg_replication_slots", [])
@@ -92,13 +111,7 @@ defmodule Realtime.DatabaseTest do
test "removes replication slots with a given name and a tenant", %{tenant: tenant} do
name = String.downcase("slot_#{random_string()}")
{:ok, conn} = Database.connect(tenant, "realtime_test", :stop)
-
- Postgrex.query!(
- conn,
- "SELECT * FROM pg_create_logical_replication_slot('#{name}', 'pgoutput')",
- []
- )
-
+ Postgrex.query!(conn, "SELECT * FROM pg_create_logical_replication_slot('#{name}', 'pgoutput')", [])
Database.replication_slot_teardown(tenant, name)
assert %{rows: []} = Postgrex.query!(conn, "SELECT slot_name FROM pg_replication_slots", [])
end
@@ -111,7 +124,7 @@ defmodule Realtime.DatabaseTest do
"settings" => %{
"db_host" => "127.0.0.1",
"db_name" => "postgres",
- "db_user" => "supabase_admin",
+ "db_user" => "supabase_realtime_admin",
"db_password" => "postgres",
"region" => "us-east-1",
"ssl_enforced" => false,
@@ -126,7 +139,7 @@ defmodule Realtime.DatabaseTest do
end
test "handles transaction errors", %{db_conn: db_conn} do
- assert {:error, %DBConnection.ConnectionError{reason: :error}} =
+ assert {:error, %Postgrex.Error{postgres: %{code: :admin_shutdown}}} =
Database.transaction(db_conn, fn conn ->
Postgrex.query!(conn, "select pg_terminate_backend(pg_backend_pid())", [])
end)
@@ -164,6 +177,12 @@ defmodule Realtime.DatabaseTest do
assert log =~ "project=123 external_id=123 [error] ErrorExecutingTransaction"
end
+ test "handles exit signals in transactions", %{db_conn: db_conn} do
+ assert capture_log(fn ->
+ assert {:error, {:exit, _}} = Database.transaction(db_conn, fn _conn -> exit(:test_exit) end)
+ end) =~ "ErrorExecutingTransaction"
+ end
+
test "run call using RPC", %{db_conn: db_conn} do
assert {:ok, %{rows: [[1]]}} =
Realtime.Rpc.enhanced_call(
@@ -215,120 +234,20 @@ defmodule Realtime.DatabaseTest do
end
end
- @aux_mod (quote do
- defmodule DatabaseAux do
- def checker(transaction_conn) do
- Postgrex.query!(transaction_conn, "SELECT 1", [])
- end
-
- def error(transaction_conn) do
- Postgrex.query!(transaction_conn, "SELECT 1/0", [])
- end
-
- def exception(_) do
- raise RuntimeError, "💣"
- end
- end
- end)
-
- Code.eval_quoted(@aux_mod)
-
- describe "transaction/1 in clustered mode" do
- setup do
- Connect.shutdown("dev_tenant")
- # Waiting for :syn to "unregister" if the Connect process was up
- Process.sleep(100)
- :ok
- end
-
- test "success call returns output" do
- {:ok, node} = Clustered.start(@aux_mod)
- {:ok, db_conn} = Rpc.call(node, Connect, :connect, ["dev_tenant", "us-east-1"])
- assert node(db_conn) == node
- assert {:ok, %Postgrex.Result{rows: [[1]]}} = Database.transaction(db_conn, &DatabaseAux.checker/1)
- end
-
- test "handles database errors" do
- metadata = [external_id: "123", project: "123"]
- {:ok, node} = Clustered.start(@aux_mod)
- {:ok, db_conn} = Rpc.call(node, Connect, :connect, ["dev_tenant", "us-east-1"])
- assert node(db_conn) == node
-
- assert capture_log(fn ->
- assert {:error, %Postgrex.Error{}} = Database.transaction(db_conn, &DatabaseAux.error/1, [], metadata)
- # We have to wait for logs to be relayed to this node
- Process.sleep(100)
- end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:"
- end
-
- test "handles exception" do
- metadata = [external_id: "123", project: "123"]
- {:ok, node} = Clustered.start(@aux_mod)
- {:ok, db_conn} = Rpc.call(node, Connect, :connect, ["dev_tenant", "us-east-1"])
- assert node(db_conn) == node
-
- assert capture_log(fn ->
- assert {:error, %RuntimeError{}} = Database.transaction(db_conn, &DatabaseAux.exception/1, [], metadata)
- # We have to wait for logs to be relayed to this node
- Process.sleep(100)
- end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:"
- end
-
- test "db process is not alive anymore" do
- metadata = [external_id: "123", project: "123", tenant_id: "123"]
- {:ok, node} = Clustered.start(@aux_mod)
- # Grab a remote pid that will not exist. :erpc uses a new process to perform the call.
- # Once it has returned the process is not alive anymore
-
- pid = Rpc.call(node, :erlang, :self, [])
- assert node(pid) == node
-
- assert capture_log(fn ->
- assert {:error, {:exit, {:noproc, {DBConnection.Holder, :checkout, [^pid, []]}}}} =
- Database.transaction(pid, &DatabaseAux.checker/1, [], metadata)
-
- # We have to wait for logs to be relayed to this node
- Process.sleep(100)
- end) =~ "project=123 external_id=123 [error] ErrorExecutingTransaction:"
- end
- end
-
describe "pool_size_by_application_name/2" do
test "returns the number of connections per application name" do
assert Database.pool_size_by_application_name("realtime_connect", %{}) == 1
assert Database.pool_size_by_application_name("realtime_connect", %{"db_pool" => 10}) == 10
assert Database.pool_size_by_application_name("realtime_potato", %{}) == 1
assert Database.pool_size_by_application_name("realtime_rls", %{"db_pool" => 10}) == 1
-
- assert Database.pool_size_by_application_name("realtime_rls", %{"subs_pool_size" => 10}) ==
- 1
-
- assert Database.pool_size_by_application_name("realtime_rls", %{"subcriber_pool_size" => 10}) ==
- 1
-
- assert Database.pool_size_by_application_name("realtime_broadcast_changes", %{
- "db_pool" => 10
- }) == 1
-
- assert Database.pool_size_by_application_name("realtime_broadcast_changes", %{
- "subs_pool_size" => 10
- }) == 1
-
- assert Database.pool_size_by_application_name("realtime_broadcast_changes", %{
- "subcriber_pool_size" => 10
- }) == 1
-
- assert Database.pool_size_by_application_name("realtime_migrations", %{
- "db_pool" => 10
- }) == 2
-
- assert Database.pool_size_by_application_name("realtime_migrations", %{
- "subs_pool_size" => 10
- }) == 2
-
- assert Database.pool_size_by_application_name("realtime_migrations", %{
- "subcriber_pool_size" => 10
- }) == 2
+ assert Database.pool_size_by_application_name("realtime_rls", %{"subs_pool_size" => 10}) == 1
+ assert Database.pool_size_by_application_name("realtime_rls", %{"subcriber_pool_size" => 10}) == 1
+ assert Database.pool_size_by_application_name("realtime_broadcast_changes", %{"db_pool" => 10}) == 1
+ assert Database.pool_size_by_application_name("realtime_broadcast_changes", %{"subs_pool_size" => 10}) == 1
+ assert Database.pool_size_by_application_name("realtime_broadcast_changes", %{"subcriber_pool_size" => 10}) == 1
+ assert Database.pool_size_by_application_name("realtime_migrations", %{"db_pool" => 10}) == 2
+ assert Database.pool_size_by_application_name("realtime_migrations", %{"subs_pool_size" => 10}) == 2
+ assert Database.pool_size_by_application_name("realtime_migrations", %{"subcriber_pool_size" => 10}) == 2
end
end
@@ -347,10 +266,7 @@ defmodule Realtime.DatabaseTest do
# Using ipv6.google.com
assert Realtime.Database.detect_ip_version("ipv6.google.com") == {:ok, :inet6}
-
- # Using 2001:0db8:85a3:0000:0000:8a2e:0370:7334
- assert Realtime.Database.detect_ip_version("2001:0db8:85a3:0000:0000:8a2e:0370:7334") ==
- {:ok, :inet6}
+ assert Realtime.Database.detect_ip_version("2001:0db8:85a3:0000:0000:8a2e:0370:7334") == {:ok, :inet6}
# Using 127.0.0.1
assert Realtime.Database.detect_ip_version("127.0.0.1") == {:ok, :inet}
@@ -360,14 +276,28 @@ defmodule Realtime.DatabaseTest do
end
end
+ describe "from_tenant/3" do
+ test "uses default backoff when not provided", %{tenant: tenant} do
+ {:ok, settings} = Database.from_tenant(tenant, "realtime_test")
+ assert settings.backoff_type == :rand_exp
+ end
+ end
+
describe "from_settings/3" do
+ test "uses default backoff when not provided", %{tenant: tenant} do
+ settings = Realtime.PostgresCdc.filter_settings("postgres_cdc_rls", tenant.extensions)
+ {:ok, result} = Database.from_settings(settings, "realtime_connect")
+ assert result.backoff_type == :rand_exp
+ end
+
test "returns struct with correct setup", %{tenant: tenant} do
application_name = "realtime_connect"
backoff = :stop
{:ok, ip_version} = Database.detect_ip_version("127.0.0.1")
socket_options = [ip_version]
settings = Realtime.PostgresCdc.filter_settings("postgres_cdc_rls", tenant.extensions)
- settings = Database.from_settings(settings, application_name, backoff)
+ username = System.get_env("DB_USER_REALTIME", "supabase_realtime_admin")
+ {:ok, settings} = Database.from_settings(settings, application_name, backoff)
port = settings.port
assert %Realtime.Database{
@@ -377,7 +307,7 @@ defmodule Realtime.DatabaseTest do
hostname: "127.0.0.1",
port: ^port,
database: "postgres",
- username: "supabase_admin",
+ username: ^username,
password: "postgres",
pool_size: 1,
queue_target: 5000,
@@ -386,29 +316,124 @@ defmodule Realtime.DatabaseTest do
} = settings
end
+ test "defaults ssl to true when ssl_enforced is not set" do
+ assert Database.default_ssl_param(%{})
+ assert Database.default_ssl_param(%{"other" => "value"})
+ end
+
test "handles SSL properties", %{tenant: tenant} do
application_name = "realtime_connect"
backoff = :stop
settings = Realtime.PostgresCdc.filter_settings("postgres_cdc_rls", tenant.extensions)
settings = Map.put(settings, "ssl_enforced", true)
- settings = Database.from_settings(settings, application_name, backoff)
+ {:ok, settings} = Database.from_settings(settings, application_name, backoff)
assert settings.ssl == [verify: :verify_none]
settings = Realtime.PostgresCdc.filter_settings("postgres_cdc_rls", tenant.extensions)
settings = Map.put(settings, "ssl_enforced", false)
- settings = Database.from_settings(settings, application_name, backoff)
- assert settings.ssl == false
+ {:ok, settings} = Database.from_settings(settings, application_name, backoff)
+ refute settings.ssl
+ end
+
+ test "runtime connections use db_user_realtime when present" do
+ settings =
+ encrypted_settings(%{
+ "db_user_realtime" => Realtime.Crypto.encrypt!("supabase_realtime_admin"),
+ "db_pass_realtime" => Realtime.Crypto.encrypt!("realtime-pass")
+ })
+
+ assert {:ok, %{username: "supabase_realtime_admin", password: "realtime-pass"}} =
+ Database.from_settings(settings, "realtime_connect", :stop)
+ end
+
+ test "runtime connections fall back to db_user when db_user_realtime is absent" do
+ assert {:ok, %{username: "supabase_admin", password: "super-pass"}} =
+ Database.from_settings(encrypted_settings(), "realtime_connect", :stop)
+ end
+
+ test "realtime_migrations always uses db_user even when db_user_realtime is set" do
+ settings =
+ encrypted_settings(%{
+ "db_user_realtime" => Realtime.Crypto.encrypt!("supabase_realtime_admin"),
+ "db_pass_realtime" => Realtime.Crypto.encrypt!("realtime-pass")
+ })
+
+ assert {:ok, %{username: "supabase_admin", password: "super-pass"}} =
+ Database.from_settings(settings, "realtime_migrations", :stop)
end
end
- defp update_extension(tenant, extension) do
- db_port = Realtime.Crypto.decrypt!(hd(tenant.extensions).settings["db_port"])
+ describe "check_replication_slot_lag/2" do
+ setup %{tenant: tenant} do
+ {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
+ suffix = System.unique_integer([:positive])
+ slot_name = "test_lag_#{suffix}"
+ table_name = "lag_test_#{suffix}"
+
+ Postgrex.query!(db_conn, "SELECT pg_create_logical_replication_slot($1, 'pgoutput')", [slot_name])
+ Postgrex.query!(db_conn, "CREATE TABLE IF NOT EXISTS #{table_name} (id INT, data TEXT)", [])
+
+ on_exit(fn ->
+ case Database.connect(tenant, "realtime_test_cleanup", :stop) do
+ {:ok, conn} ->
+ Postgrex.query(conn, "SELECT pg_drop_replication_slot($1)", [slot_name])
+ Postgrex.query(conn, "DROP TABLE IF EXISTS #{table_name} CASCADE", [])
+ GenServer.stop(conn)
+
+ _ ->
+ :ok
+ end
+ end)
+
+ %{db_conn: db_conn, slot_name: slot_name, table_name: table_name}
+ end
- extensions = [
- put_in(extension, ["settings", "db_port"], db_port)
- ]
+ test "returns :ok when slot lag is below threshold", %{db_conn: db_conn, slot_name: slot_name} do
+ assert :ok == Database.check_replication_slot_lag(db_conn, slot_name)
+ end
+
+ test "returns :ok when slot lag is non-zero but below threshold", %{
+ db_conn: db_conn,
+ slot_name: slot_name,
+ table_name: table_name
+ } do
+ # Generate ~40% of the 32MB max_slot_wal_keep_size (test container value) by inserting
+ # ~50k rows of 200 bytes each — produces roughly 12-13MB of WAL, safely under the 16MB
+ # (50%) shutdown threshold. The slot is inactive so restart_lsn stays pinned.
+ Postgrex.query!(
+ db_conn,
+ "INSERT INTO #{table_name} SELECT generate_series(1, 50000), repeat('x', 200)",
+ []
+ )
- Realtime.Api.update_tenant(tenant, %{extensions: extensions})
+ assert :ok == Database.check_replication_slot_lag(db_conn, slot_name)
+ end
+
+ test "returns {:error, :lag_too_high} when slot is far behind", %{
+ db_conn: db_conn,
+ slot_name: slot_name,
+ table_name: table_name
+ } do
+ # Generate >16MB of WAL (50% of the 32MB max_slot_wal_keep_size in test containers).
+ # The slot is inactive so restart_lsn stays pinned at creation LSN.
+ Postgrex.query!(
+ db_conn,
+ "INSERT INTO #{table_name} SELECT generate_series(1, 100000), repeat('x', 200)",
+ []
+ )
+
+ assert {:error, :lag_too_high} == Database.check_replication_slot_lag(db_conn, slot_name)
+ end
+
+ test "returns :ok for unknown slot", %{db_conn: db_conn} do
+ assert :ok == Database.check_replication_slot_lag(db_conn, "nonexistent_slot_xyz")
+ end
+ end
+
+ defp update_extension(tenant, extension) do
+ db_port = Realtime.Crypto.decrypt!(hd(tenant.extensions).settings["db_port"])
+ extensions = [put_in(extension, ["settings", "db_port"], db_port)]
+ Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{extensions: extensions})
end
end
diff --git a/test/realtime/env_test.exs b/test/realtime/env_test.exs
new file mode 100644
index 000000000..d2008e3aa
--- /dev/null
+++ b/test/realtime/env_test.exs
@@ -0,0 +1,165 @@
+defmodule Realtime.EnvTest do
+ use ExUnit.Case, async: true
+
+ alias Realtime.Env
+
+ setup %{describe: describe, test: test_name} do
+ env = "REALTIME_ENV_TEST_#{describe}_#{test_name}"
+ on_exit(fn -> System.delete_env(env) end)
+ %{env: env}
+ end
+
+ describe "get_integer/2" do
+ test "returns the default when env is unset", %{env: env} do
+ assert Env.get_integer(env, 10) == 10
+ end
+
+ test "returns nil when env is unset and no default is provided", %{env: env} do
+ assert Env.get_integer(env) == nil
+ end
+
+ test "parses integer env values", %{env: env} do
+ System.put_env(env, "42")
+ assert Env.get_integer(env, 0) == 42
+ end
+
+ test "parses negative integer env values", %{env: env} do
+ System.put_env(env, "-7")
+ assert Env.get_integer(env, 0) == -7
+ end
+
+ test "raises on invalid integer env values", %{env: env} do
+ System.put_env(env, "12ms")
+
+ assert_raise ArgumentError, ~r/env #{env} expected a Integer, got "12ms"/, fn ->
+ Env.get_integer(env, 0)
+ end
+ end
+
+ test "raises when the default is not an integer or nil", %{env: env} do
+ assert_raise ArgumentError,
+ ~r/expected either Integer or empty \(nil\) as default value for env #{env}, got "10"/,
+ fn ->
+ Env.get_integer(env, "10")
+ end
+ end
+ end
+
+ describe "get_charlist/2" do
+ test "returns the default when env is unset", %{env: env} do
+ assert Env.get_charlist(env, ~c"abc") == ~c"abc"
+ end
+
+ test "returns env values as charlists", %{env: env} do
+ System.put_env(env, "127.0.0.1")
+ assert Env.get_charlist(env, ~c"0.0.0.0") == ~c"127.0.0.1"
+ end
+
+ test "returns an empty charlist when env is empty", %{env: env} do
+ System.put_env(env, "")
+ assert Env.get_charlist(env, ~c"fallback") == ~c""
+ end
+
+ test "raises when the default is not a charlist", %{env: env} do
+ assert_raise ArgumentError,
+ ~r/expected a charlist as default value for env #{env}, got "abc"/,
+ fn ->
+ Env.get_charlist(env, "abc")
+ end
+ end
+ end
+
+ describe "get_boolean/2" do
+ test "returns the default when env is unset", %{env: env} do
+ assert Env.get_boolean(env, true) == true
+ assert Env.get_boolean(env, false) == false
+ end
+
+ test "parses truthy env values", %{env: env} do
+ System.put_env(env, "true")
+ assert Env.get_boolean(env, false) == true
+
+ System.put_env(env, "1")
+ assert Env.get_boolean(env, false) == true
+ end
+
+ test "parses falsy env values", %{env: env} do
+ System.put_env(env, "false")
+ assert Env.get_boolean(env, true) == false
+
+ System.put_env(env, "0")
+ assert Env.get_boolean(env, true) == false
+ end
+
+ test "normalizes whitespace and case before parsing", %{env: env} do
+ System.put_env(env, " TRUE ")
+ assert Env.get_boolean(env, false) == true
+
+ System.put_env(env, " False ")
+ assert Env.get_boolean(env, true) == false
+ end
+
+ test "raises on invalid boolean env values", %{env: env} do
+ System.put_env(env, "yes")
+
+ assert_raise ArgumentError, ~r/env #{env} expected a boolean or 0\/1 values, got "yes"/, fn ->
+ Env.get_boolean(env, false)
+ end
+ end
+
+ test "raises when the default is not a boolean", %{env: env} do
+ assert_raise ArgumentError,
+ ~r/expected a boolean as default value for env #{env}, got "false"/,
+ fn ->
+ Env.get_boolean(env, "false")
+ end
+ end
+ end
+
+ describe "get_binary/2" do
+ test "returns the env value when present", %{env: env} do
+ System.put_env(env, "configured")
+ assert Env.get_binary(env, "default") == "configured"
+ end
+
+ test "returns the default binary when env is unset", %{env: env} do
+ assert Env.get_binary(env, "default") == "default"
+ end
+
+ test "evaluates lazy defaults when env is unset", %{env: env} do
+ assert Env.get_binary(env, fn -> "computed" end) == "computed"
+ end
+
+ test "does not evaluate lazy defaults when env is set", %{env: env} do
+ System.put_env(env, "configured")
+ assert Env.get_binary(env, fn -> flunk("default function should not be called") end) == "configured"
+ end
+ end
+
+ describe "get_list/2" do
+ test "returns the default when env is unset", %{env: env} do
+ assert Env.get_list(env, ["a", "b"]) == ["a", "b"]
+ end
+
+ test "splits comma-separated env values", %{env: env} do
+ System.put_env(env, "a,b,c")
+ assert Env.get_list(env, []) == ["a", "b", "c"]
+ end
+
+ test "trims whitespace around list entries", %{env: env} do
+ System.put_env(env, " a, b ,c ")
+ assert Env.get_list(env, []) == ["a", "b", "c"]
+ end
+
+ test "preserves empty entries when env is empty", %{env: env} do
+ System.put_env(env, "")
+ assert Env.get_list(env, ["fallback"]) == [""]
+ end
+
+ test "raises with a function clause when the default is not a list", %{env: env} do
+ assert_raise FunctionClauseError, fn ->
+ Env.get_list(env, "not-a-list")
+ end
+ end
+ end
+end
diff --git a/test/realtime/extensions/cdc_rls/cdc_rls_test.exs b/test/realtime/extensions/cdc_rls/cdc_rls_test.exs
index 5f341c134..942a97bc8 100644
--- a/test/realtime/extensions/cdc_rls/cdc_rls_test.exs
+++ b/test/realtime/extensions/cdc_rls/cdc_rls_test.exs
@@ -1,7 +1,6 @@
defmodule Realtime.Extensions.CdcRlsTest do
- # async: false due to usage of dev_tenant
- # Also global mimic mock
- use RealtimeWeb.ChannelCase, async: false
+ # async: false due to global mimic mock
+ use Realtime.DataCase, async: false
use Mimic
import ExUnit.CaptureLog
@@ -9,9 +8,10 @@ defmodule Realtime.Extensions.CdcRlsTest do
setup :set_mimic_global
alias Extensions.PostgresCdcRls
+ alias Extensions.PostgresCdcRls.ReplicationPoller
+ alias Extensions.PostgresCdcRls.Subscriptions
alias PostgresCdcRls.SubscriptionManager
alias Postgrex
- alias Realtime.Api
alias Realtime.Api.Tenant
alias Realtime.Database
alias Realtime.PostgresCdc
@@ -23,77 +23,39 @@ defmodule Realtime.Extensions.CdcRlsTest do
describe "Postgres extensions" do
setup do
tenant = Containers.checkout_tenant(run_migrations: true)
-
- {:ok, conn} = Database.connect(tenant, "realtime_test")
-
- Database.transaction(conn, fn db_conn ->
- queries = [
- "drop table if exists public.test",
- "drop publication if exists supabase_realtime_test",
- "create sequence if not exists test_id_seq;",
- """
- create table if not exists "public"."test" (
- "id" int4 not null default nextval('test_id_seq'::regclass),
- "details" text,
- primary key ("id"));
- """,
- "grant all on table public.test to anon;",
- "grant all on table public.test to postgres;",
- "grant all on table public.test to authenticated;",
- "create publication supabase_realtime_test for all tables"
- ]
-
- Enum.each(queries, &Postgrex.query!(db_conn, &1, []))
- end)
+ {:ok, conn} = Database.connect(tenant, "realtime_test", :stop)
+ Integrations.setup_postgres_changes(conn)
+ GenServer.stop(conn)
%Tenant{extensions: extensions, external_id: external_id} = tenant
postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions)
- args = Map.put(postgres_extension, "id", external_id)
-
- pg_change_params = [
- %{
- id: UUID.uuid1(),
- params: %{"event" => "*", "schema" => "public"},
- channel_pid: self(),
- claims: %{
- "exp" => System.system_time(:second) + 100_000,
- "iat" => 0,
- "ref" => "127.0.0.1",
- "role" => "anon"
- }
- }
- ]
+ args = %{"id" => external_id, "region" => postgres_extension["region"]}
- ids =
- Enum.map(pg_change_params, fn %{id: id, params: params} ->
- {UUID.string_to_binary!(id), :erlang.phash2(params)}
- end)
-
- topic = "realtime:test"
- serializer = Phoenix.Socket.V1.JSONSerializer
-
- subscription_metadata = {:subscriber_fastlane, self(), serializer, ids, topic, external_id, true}
- metadata = [metadata: subscription_metadata]
- :ok = PostgresCdc.subscribe(PostgresCdcRls, pg_change_params, external_id, metadata)
+ pg_change_params = pubsub_subscribe(external_id)
+ RealtimeWeb.Endpoint.subscribe(Realtime.Syn.PostgresCdc.syn_topic(tenant.external_id))
# First time it will return nil
PostgresCdcRls.handle_connect(args)
# Wait for it to start
- Process.sleep(3000)
+ assert_receive %{event: "ready"}, 1000
+
+ on_exit(fn -> PostgresCdcRls.handle_stop(external_id, 10_000) end)
{:ok, response} = PostgresCdcRls.handle_connect(args)
# Now subscribe to the Postgres Changes
- {:ok, _} = PostgresCdcRls.handle_after_connect(response, postgres_extension, pg_change_params)
+ {:ok, _} = PostgresCdcRls.handle_after_connect(response, postgres_extension, pg_change_params, external_id)
- on_exit(fn -> PostgresCdcRls.handle_stop(external_id, 10_000) end)
+ RealtimeWeb.Endpoint.unsubscribe(Realtime.Syn.PostgresCdc.syn_topic(tenant.external_id))
%{tenant: tenant}
end
- @tag skip: "Flaky test. When logger handle_sasl_reports is enabled this test doesn't break"
- test "Check supervisor crash and respawn", %{tenant: tenant} do
+ test "supervisor crash must not respawn", %{tenant: tenant} do
+ scope = Realtime.Syn.PostgresCdc.scope(tenant.external_id)
+
sup =
Enum.reduce_while(1..30, nil, fn _, acc ->
- :syn.lookup(Extensions.PostgresCdcRls, tenant.external_id)
+ scope
+ |> :syn.lookup(tenant.external_id)
|> case do
:undefined ->
Process.sleep(500)
@@ -107,27 +69,22 @@ defmodule Realtime.Extensions.CdcRlsTest do
assert Process.alive?(sup)
Process.monitor(sup)
- RealtimeWeb.Endpoint.subscribe(PostgresCdcRls.syn_topic(tenant.external_id))
+ RealtimeWeb.Endpoint.subscribe(Realtime.Syn.PostgresCdc.syn_topic(tenant.external_id))
Process.exit(sup, :kill)
- assert_receive {:DOWN, _, :process, ^sup, _reason}, 5000
-
- assert_receive %{event: "ready"}, 5000
+ scope_down = Atom.to_string(scope) <> "_down"
- {sup2, _} = :syn.lookup(Extensions.PostgresCdcRls, tenant.external_id)
+ assert_receive {:DOWN, _, :process, ^sup, _reason}, 5000
+ assert_receive %{event: ^scope_down}
+ refute_receive %{event: "ready"}, 1000
- assert(sup != sup2)
- assert Process.alive?(sup2)
+ :undefined = :syn.lookup(Realtime.Syn.PostgresCdc.scope(tenant.external_id), tenant.external_id)
end
test "Subscription manager updates oids", %{tenant: tenant} do
{subscriber_manager_pid, conn} =
Enum.reduce_while(1..25, nil, fn _, acc ->
case PostgresCdcRls.get_manager_conn(tenant.external_id) do
- nil ->
- Process.sleep(200)
- {:cont, acc}
-
{:error, :wait} ->
Process.sleep(200)
{:cont, acc}
@@ -144,16 +101,91 @@ defmodule Realtime.Extensions.CdcRlsTest do
%{oids: oids2} = :sys.get_state(subscriber_manager_pid)
assert !Map.equal?(oids, oids2)
- Postgrex.query!(conn, "create publication supabase_realtime_test for all tables", [])
+ # `for all tables` requires superuser
+ Postgrex.query!(conn, "create publication supabase_realtime_test for table public.test", [])
send(subscriber_manager_pid, :check_oids)
%{oids: oids3} = :sys.get_state(subscriber_manager_pid)
assert !Map.equal?(oids2, oids3)
end
+ test "Replication poller toggles slot when publication tables come and go", %{tenant: tenant} do
+ # setup/0 already received the "ready" event, which fires only after the poller's init/1
+ # (and its Registry.register) has run. :sys.get_state below then blocks until the poller
+ # finishes handle_continue and has its slot prepared.
+ [{poller_pid, _}] = Registry.lookup(ReplicationPoller.Registry, tenant.external_id)
+
+ # Use the SubscriptionManager pub connection to drive publication state from the test —
+ # the poller's own conn is owned by the poller process.
+ {:ok, _manager_pid, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
+
+ %{oids: initial_oids, slot_name: slot_name} = :sys.get_state(poller_pid)
+ refute initial_oids == %{}
+
+ assert %Postgrex.Result{rows: [[1]]} =
+ Postgrex.query!(conn, "SELECT count(*)::int FROM pg_replication_slots WHERE slot_name = $1", [slot_name])
+
+ # Drop the publication: poller should drop its slot and clear oids.
+ Postgrex.query!(conn, "drop publication if exists supabase_realtime_test", [])
+ send(poller_pid, :check_oids)
+ %{oids: oids_after_drop, poll_ref: poll_ref_after_drop} = :sys.get_state(poller_pid)
+ assert oids_after_drop == %{}
+ assert poll_ref_after_drop == nil
+
+ assert %Postgrex.Result{rows: [[0]]} =
+ Postgrex.query!(conn, "SELECT count(*)::int FROM pg_replication_slots WHERE slot_name = $1", [slot_name])
+
+ # Re-create the publication: poller should recreate the slot and repopulate oids.
+ # Use an explicit table (not FOR ALL TABLES, which requires superuser).
+ Postgrex.query!(conn, "create publication supabase_realtime_test for table public.test", [])
+ send(poller_pid, :check_oids)
+ %{oids: oids_after_create} = :sys.get_state(poller_pid)
+ refute oids_after_create == %{}
+
+ assert %Postgrex.Result{rows: [[1]]} =
+ Postgrex.query!(conn, "SELECT count(*)::int FROM pg_replication_slots WHERE slot_name = $1", [slot_name])
+ end
+
+ test "Replication poller toggles slot when tables are removed from the publication", %{tenant: tenant} do
+ [{poller_pid, _}] = Registry.lookup(ReplicationPoller.Registry, tenant.external_id)
+ {:ok, _manager_pid, conn} = PostgresCdcRls.get_manager_conn(tenant.external_id)
+
+ %{oids: initial_oids, slot_name: slot_name} = :sys.get_state(poller_pid)
+ refute initial_oids == %{}
+
+ assert %Postgrex.Result{rows: [[1]]} =
+ Postgrex.query!(conn, "SELECT count(*)::int FROM pg_replication_slots WHERE slot_name = $1", [slot_name])
+
+ # Publication still exists but has no tables (recreated without FOR ALL TABLES
+ # since you can't ALTER ... DROP TABLE on a FOR ALL TABLES publication).
+ Postgrex.query!(conn, "drop publication if exists supabase_realtime_test", [])
+ Postgrex.query!(conn, "create publication supabase_realtime_test", [])
+
+ send(poller_pid, :check_oids)
+ %{oids: oids_after_empty, poll_ref: poll_ref_after_empty} = :sys.get_state(poller_pid)
+ assert oids_after_empty == %{}
+ assert poll_ref_after_empty == nil
+
+ assert %Postgrex.Result{rows: [[0]]} =
+ Postgrex.query!(conn, "SELECT count(*)::int FROM pg_replication_slots WHERE slot_name = $1", [slot_name])
+
+ # Add a table back to the publication: poller should recreate the slot and repopulate oids.
+ Postgrex.query!(conn, "alter publication supabase_realtime_test add table public.test", [])
+
+ send(poller_pid, :check_oids)
+ %{oids: oids_after_add} = :sys.get_state(poller_pid)
+ refute oids_after_add == %{}
+
+ assert %Postgrex.Result{rows: [[1]]} =
+ Postgrex.query!(conn, "SELECT count(*)::int FROM pg_replication_slots WHERE slot_name = $1", [slot_name])
+ end
+
test "Stop tenant supervisor", %{tenant: tenant} do
sup =
Enum.reduce_while(1..10, nil, fn _, acc ->
- case :syn.lookup(Extensions.PostgresCdcRls, tenant.external_id) do
+ tenant.external_id
+ |> Realtime.Syn.PostgresCdc.scope()
+ |> :syn.lookup(tenant.external_id)
+ |> case do
:undefined ->
Process.sleep(500)
{:cont, acc}
@@ -169,16 +201,63 @@ defmodule Realtime.Extensions.CdcRlsTest do
end
end
+ describe "handle_after_connect/4" do
+ setup do
+ tenant = Containers.checkout_tenant(run_migrations: true)
+ %{tenant: tenant}
+ end
+
+ test "rate counter raises exception returns error", %{tenant: tenant} do
+ %Tenant{extensions: extensions, external_id: external_id} = tenant
+ postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions)
+
+ stub(RateCounter, :get, fn _args -> raise "unexpected RateCounter failure" end)
+
+ import ExUnit.CaptureLog
+
+ log =
+ capture_log(fn ->
+ assert {:error, "Too many database timeouts"} =
+ PostgresCdcRls.handle_after_connect({:manager_pid, self()}, postgres_extension, %{}, external_id)
+ end)
+
+ assert log =~ "RateCounterError"
+ end
+
+ test "subscription error rate limit", %{tenant: tenant} do
+ %Tenant{extensions: extensions, external_id: external_id} = tenant
+ postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions)
+
+ stub(Subscriptions, :create, fn _conn, _publication, _subscription_list, _manager, _caller ->
+ {:error, %DBConnection.ConnectionError{}}
+ end)
+
+ # Now try to subscribe to the Postgres Changes
+ for _x <- 1..6 do
+ assert {:error, "Too many database timeouts"} =
+ PostgresCdcRls.handle_after_connect({:manager_pid, self()}, postgres_extension, %{}, external_id)
+ end
+
+ rate = Realtime.Tenants.subscription_errors_per_second_rate(external_id, 4)
+
+ assert {:ok, %RateCounter{id: {:channel, :subscription_errors, ^external_id}, sum: 6, limit: %{triggered: true}}} =
+ RateCounterHelper.tick!(rate)
+
+ # It won't even be called now
+ reject(&Subscriptions.create/5)
+
+ assert {:error, "Too many database timeouts"} =
+ PostgresCdcRls.handle_after_connect({:manager_pid, self()}, postgres_extension, %{}, external_id)
+ end
+ end
+
describe "Region rebalancing" do
setup do
tenant = Containers.checkout_tenant(run_migrations: true)
%Tenant{extensions: extensions, external_id: external_id} = tenant
postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions)
- args =
- postgres_extension
- |> Map.put("id", external_id)
- |> Map.put(:check_region_interval, 100)
+ args = %{"id" => external_id, "region" => postgres_extension["region"], check_region_interval: 100}
%{tenant_id: tenant.external_id, args: args}
end
@@ -208,98 +287,63 @@ defmodule Realtime.Extensions.CdcRlsTest do
end
describe "integration" do
- setup do
- tenant = Api.get_tenant_by_external_id("dev_tenant")
- PostgresCdcRls.handle_stop(tenant.external_id, 10_000)
-
- {:ok, conn} = Database.connect(tenant, "realtime_test")
-
- Database.transaction(conn, fn db_conn ->
- queries = [
- "drop table if exists public.test",
- "drop publication if exists supabase_realtime_test",
- "create sequence if not exists test_id_seq;",
- """
- create table if not exists "public"."test" (
- "id" int4 not null default nextval('test_id_seq'::regclass),
- "details" text,
- primary key ("id"));
- """,
- "grant all on table public.test to anon;",
- "grant all on table public.test to postgres;",
- "grant all on table public.test to authenticated;",
- "create publication supabase_realtime_test for all tables"
- ]
-
- Enum.each(queries, &Postgrex.query!(db_conn, &1, []))
- end)
+ setup [:integration]
- RateCounter.stop(tenant.external_id)
-
- %{tenant: tenant, conn: conn}
- end
-
- test "subscribe inserts", %{tenant: tenant, conn: conn} do
+ test "subscribe inserts only", %{tenant: tenant, conn: conn} do
on_exit(fn -> PostgresCdcRls.handle_stop(tenant.external_id, 10_000) end)
%Tenant{extensions: extensions, external_id: external_id} = tenant
postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions)
- args = Map.put(postgres_extension, "id", external_id)
-
- pg_change_params = [
- %{
- id: UUID.uuid1(),
- params: %{"event" => "*", "schema" => "public"},
- channel_pid: self(),
- claims: %{
- "exp" => System.system_time(:second) + 100_000,
- "iat" => 0,
- "ref" => "127.0.0.1",
- "role" => "anon"
- }
- }
- ]
-
- ids =
- Enum.map(pg_change_params, fn %{id: id, params: params} ->
- {UUID.string_to_binary!(id), :erlang.phash2(params)}
- end)
+ args = %{"id" => external_id, "region" => postgres_extension["region"]}
- topic = "realtime:test"
- serializer = Phoenix.Socket.V1.JSONSerializer
-
- subscription_metadata = {:subscriber_fastlane, self(), serializer, ids, topic, external_id, true}
- metadata = [metadata: subscription_metadata]
- :ok = PostgresCdc.subscribe(PostgresCdcRls, pg_change_params, external_id, metadata)
+ pg_change_params = pubsub_subscribe(external_id, "INSERT")
# First time it will return nil
PostgresCdcRls.handle_connect(args)
# Wait for it to start
- Process.sleep(3000)
+ assert_receive %{event: "ready"}, 3000
{:ok, response} = PostgresCdcRls.handle_connect(args)
+ assert_receive {
+ :telemetry,
+ [:realtime, :rpc],
+ %{latency: _},
+ %{
+ mechanism: :gen_rpc,
+ success: true
+ }
+ }
+
# Now subscribe to the Postgres Changes
- {:ok, _} = PostgresCdcRls.handle_after_connect(response, postgres_extension, pg_change_params)
- assert %Postgrex.Result{rows: [[1]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ Postgrex.query!(conn, "delete from realtime.subscription", [])
+ {:ok, _} = PostgresCdcRls.handle_after_connect(response, postgres_extension, pg_change_params, external_id)
+
+ assert %Postgrex.Result{num_rows: n} = Postgrex.query!(conn, "select id from realtime.subscription", [])
+ assert n >= 1
+
+ Process.sleep(500)
# Insert a record
%{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", [])
+ # Delete the record
+ %{num_rows: 1} = Postgrex.query!(conn, "delete from test", [])
assert_receive {:socket_push, :text, data}, 5000
-
- message =
- data
- |> IO.iodata_to_binary()
- |> Jason.decode!()
+ # No DELETE should be received
+ refute_receive {:socket_push, :text, _data}, 1000
assert %{
"event" => "postgres_changes",
"payload" => %{
"data" => %{
- "columns" => [%{"name" => "id", "type" => "int4"}, %{"name" => "details", "type" => "text"}],
+ "columns" => [
+ %{"name" => "id", "type" => "int4"},
+ %{"name" => "details", "type" => "text"},
+ %{"name" => "binary_data", "type" => "bytea"}
+ ],
"commit_timestamp" => _,
"errors" => nil,
- "record" => %{"details" => "test", "id" => ^id},
+ "record" => %{"details" => "test", "id" => ^id, "binary_data" => nil},
"schema" => "public",
"table" => "test",
"type" => "INSERT"
@@ -308,110 +352,280 @@ defmodule Realtime.Extensions.CdcRlsTest do
},
"ref" => nil,
"topic" => "realtime:test"
- } = message
+ } = Jason.decode!(data)
- # Wait for RateCounter to update
- Process.sleep(2000)
+ rate = Realtime.Tenants.db_events_per_second_rate(tenant)
+
+ assert {:ok, %RateCounter{id: {:channel, :db_events, ^external_id}, bucket: bucket}} =
+ RateCounterHelper.tick!(rate)
+
+ assert Enum.sum(bucket) == 1
+
+ assert_receive {
+ :telemetry,
+ [:realtime, :tenants, :payload, :size],
+ %{size: _},
+ %{tenant: ^external_id, message_type: :postgres_changes}
+ }
+ end
+
+ test "db events rate limit works", %{tenant: tenant, conn: conn} do
+ on_exit(fn -> PostgresCdcRls.handle_stop(tenant.external_id, 10_000) end)
+
+ %Tenant{extensions: extensions, external_id: external_id} = tenant
+ postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions)
+ args = %{"id" => external_id, "region" => postgres_extension["region"]}
+
+ pg_change_params = pubsub_subscribe(external_id)
+
+ # First time it will return nil
+ PostgresCdcRls.handle_connect(args)
+ # Wait for it to start
+ assert_receive %{event: "ready"}, 1000
+ {:ok, response} = PostgresCdcRls.handle_connect(args)
+
+ # Now subscribe to the Postgres Changes
+ Postgrex.query!(conn, "delete from realtime.subscription", [])
+ {:ok, _} = PostgresCdcRls.handle_after_connect(response, postgres_extension, pg_change_params, external_id)
+ assert %Postgrex.Result{rows: [[n]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ assert n >= 1
rate = Realtime.Tenants.db_events_per_second_rate(tenant)
- assert {:ok, %RateCounter{id: {:channel, :db_events, "dev_tenant"}, bucket: bucket}} = RateCounter.get(rate)
- assert 1 in bucket
+ log =
+ capture_log(fn ->
+ # increment artifically the counter to reach the limit
+ tenant.external_id
+ |> Realtime.Tenants.db_events_per_second_key()
+ |> Realtime.GenCounter.add(100_000_000)
+
+ RateCounterHelper.tick!(rate)
+ end)
+
+ assert log =~ "MessagePerSecondRateLimitReached: Too many postgres changes messages per second"
+
+ # Insert a record
+ %{rows: [[_id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", [])
+
+ refute_receive {:socket_push, :text, _}, 5000
+
+ assert {:ok, %RateCounter{id: {:channel, :db_events, ^external_id}, bucket: bucket, limit: %{triggered: true}}} =
+ RateCounterHelper.tick!(rate)
+
+ # Nothing has changed
+ assert Enum.sum(bucket) == 100_000_000
end
+ end
- @aux_mod (quote do
- defmodule Subscriber do
- # Start CDC remotely
- def subscribe(tenant) do
- %Tenant{extensions: extensions, external_id: external_id} = tenant
- postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions)
- args = Map.put(postgres_extension, "id", external_id)
-
- # Boot it
- PostgresCdcRls.start(args)
- # Wait for it to start
- Process.sleep(3000)
- {:ok, manager, conn} = PostgresCdcRls.get_manager_conn(external_id)
- {:ok, {manager, conn}}
- end
+ @aux_mod (quote do
+ defmodule Subscriber do
+ # Start CDC remotely
+ def subscribe(tenant) do
+ %Tenant{extensions: extensions, external_id: external_id} = tenant
+ postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions)
+ args = %{"id" => external_id, "region" => postgres_extension["region"]}
+
+ RealtimeWeb.Endpoint.subscribe(Realtime.Syn.PostgresCdc.syn_topic(tenant.external_id))
+ # First time it will return nil
+ PostgresCdcRls.start(args)
+ # Wait for it to start
+ assert_receive %{event: "ready"}, 3000
+ {:ok, manager, conn} = PostgresCdcRls.get_manager_conn(external_id)
+ {:ok, {manager, conn}}
end
- end)
+ end
+ end)
+ describe "distributed integration" do
+ setup [:distributed_integration]
- test "subscribe inserts distributed mode", %{tenant: tenant, conn: conn} do
+ setup(%{tenant: tenant}) do
{:ok, node} = Clustered.start(@aux_mod)
{:ok, response} = :erpc.call(node, Subscriber, :subscribe, [tenant])
+ on_exit(fn ->
+ try do
+ PostgresCdcRls.handle_stop(tenant.external_id, 5_000)
+ catch
+ _, _ -> :ok
+ end
+ end)
+
+ %{node: node, response: response}
+ end
+
+ test "subscribe distributed mode", %{tenant: tenant, conn: conn, node: node, response: response} do
%Tenant{extensions: extensions, external_id: external_id} = tenant
postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions)
- pg_change_params = [
+ pg_change_params = pubsub_subscribe(external_id)
+
+ Postgrex.query!(conn, "delete from realtime.subscription", [])
+ {:ok, _} = PostgresCdcRls.handle_after_connect(response, postgres_extension, pg_change_params, external_id)
+ assert %Postgrex.Result{rows: [[n]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ assert n >= 1
+
+ # Wait for subscription to be executing
+ Process.sleep(200)
+
+ # Insert a record
+ %{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", [])
+ # Delete the record
+ %{num_rows: 1} = Postgrex.query!(conn, "delete from test", [])
+
+ assert_receive {:socket_push, :text, data1}, 5000
+ assert_receive {:socket_push, :text, data2}, 5000
+
+ events = Enum.map([data1, data2], &Jason.decode!/1)
+
+ assert Enum.any?(events, fn event ->
+ match?(
+ %{
+ "event" => "postgres_changes",
+ "payload" => %{
+ "data" => %{
+ "errors" => nil,
+ "record" => %{"details" => "test", "id" => ^id, "binary_data" => nil},
+ "schema" => "public",
+ "table" => "test",
+ "type" => "INSERT"
+ }
+ },
+ "ref" => nil,
+ "topic" => "realtime:test"
+ },
+ event
+ )
+ end)
+
+ assert Enum.any?(events, fn event ->
+ match?(
+ %{
+ "event" => "postgres_changes",
+ "payload" => %{
+ "data" => %{
+ "errors" => nil,
+ "type" => "DELETE",
+ "old_record" => %{"id" => ^id},
+ "schema" => "public",
+ "table" => "test"
+ }
+ },
+ "ref" => nil,
+ "topic" => "realtime:test"
+ },
+ event
+ )
+ end)
+
+ assert_receive {
+ :telemetry,
+ [:realtime, :rpc],
+ %{latency: _},
%{
- id: UUID.uuid1(),
- params: %{"event" => "*", "schema" => "public"},
- channel_pid: self(),
- claims: %{
- "exp" => System.system_time(:second) + 100_000,
- "iat" => 0,
- "ref" => "127.0.0.1",
- "role" => "anon"
- }
+ mechanism: :gen_rpc,
+ origin_node: _,
+ success: true,
+ target_node: ^node
}
- ]
+ }
+ end
- ids =
- Enum.map(pg_change_params, fn %{id: id, params: params} ->
- {UUID.string_to_binary!(id), :erlang.phash2(params)}
- end)
+ test "subscription error rate limit", %{tenant: tenant, node: node} do
+ %Tenant{extensions: extensions, external_id: external_id} = tenant
+ postgres_extension = PostgresCdc.filter_settings("postgres_cdc_rls", extensions)
- # Subscribe to the topic as a websocket client
- topic = "realtime:test"
- serializer = Phoenix.Socket.V1.JSONSerializer
+ pg_change_params = pubsub_subscribe(external_id)
- subscription_metadata = {:subscriber_fastlane, self(), serializer, ids, topic, external_id, true}
- metadata = [metadata: subscription_metadata]
- :ok = PostgresCdc.subscribe(PostgresCdcRls, pg_change_params, external_id, metadata)
+ # Grab a process that is not alive to cause subscriptions to error out
+ pid = :erpc.call(node, :erlang, :self, [])
- # Now subscribe to the Postgres Changes
- {:ok, _} = PostgresCdcRls.handle_after_connect(response, postgres_extension, pg_change_params)
- assert %Postgrex.Result{rows: [[1]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ # Now subscribe to the Postgres Changes multiple times to reach the rate limit
+ for _ <- 1..6 do
+ assert {:error, "Too many database timeouts"} =
+ PostgresCdcRls.handle_after_connect({pid, pid}, postgres_extension, pg_change_params, external_id)
+ end
- # Insert a record
- %{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", [])
+ rate = Realtime.Tenants.subscription_errors_per_second_rate(external_id, 4)
- assert_receive {:socket_push, :text, data}, 5000
+ assert {:ok, %RateCounter{id: {:channel, :subscription_errors, ^external_id}, limit: %{triggered: true}}} =
+ RateCounterHelper.tick!(rate)
- message =
- data
- |> IO.iodata_to_binary()
- |> Jason.decode!()
+ # It won't even be called now
+ reject(&Realtime.GenRpc.call/5)
- assert %{
- "event" => "postgres_changes",
- "payload" => %{
- "data" => %{
- "columns" => [%{"name" => "id", "type" => "int4"}, %{"name" => "details", "type" => "text"}],
- "commit_timestamp" => _,
- "errors" => nil,
- "record" => %{"details" => "test", "id" => ^id},
- "schema" => "public",
- "table" => "test",
- "type" => "INSERT"
- },
- "ids" => _
- },
- "ref" => nil,
- "topic" => "realtime:test"
- } = message
+ assert {:error, "Too many database timeouts"} =
+ PostgresCdcRls.handle_after_connect({pid, pid}, postgres_extension, pg_change_params, external_id)
+ end
+ end
- # Wait for RateCounter to update
- Process.sleep(2000)
+ defp integration(_) do
+ tenant = Containers.checkout_tenant(run_migrations: true)
+ {:ok, conn} = Database.connect(tenant, "realtime_test")
+ Integrations.setup_postgres_changes(conn)
- rate = Realtime.Tenants.db_events_per_second_rate(tenant)
+ on_exit(fn -> RateCounterHelper.stop(tenant.external_id) end)
+ on_exit(fn -> :telemetry.detach(__MODULE__) end)
- assert {:ok, %RateCounter{id: {:channel, :db_events, "dev_tenant"}, bucket: bucket}} = RateCounter.get(rate)
- assert 1 in bucket
+ :telemetry.attach_many(
+ __MODULE__,
+ [[:realtime, :tenants, :payload, :size], [:realtime, :rpc]],
+ &__MODULE__.handle_telemetry/4,
+ pid: self()
+ )
- :erpc.call(node, PostgresCdcRls, :handle_stop, [tenant.external_id, 10_000])
- end
+ RealtimeWeb.Endpoint.subscribe(Realtime.Syn.PostgresCdc.syn_topic(tenant.external_id))
+
+ %{tenant: tenant, conn: conn}
+ end
+
+ defp distributed_integration(_) do
+ tenant = Containers.checkout_tenant_unboxed(run_migrations: true)
+ {:ok, conn} = Database.connect(tenant, "realtime_test")
+ Integrations.setup_postgres_changes(conn)
+
+ on_exit(fn -> RateCounterHelper.stop(tenant.external_id) end)
+ on_exit(fn -> :telemetry.detach(__MODULE__) end)
+
+ :telemetry.attach_many(
+ __MODULE__,
+ [[:realtime, :tenants, :payload, :size], [:realtime, :rpc]],
+ &__MODULE__.handle_telemetry/4,
+ pid: self()
+ )
+
+ RealtimeWeb.Endpoint.subscribe(Realtime.Syn.PostgresCdc.syn_topic(tenant.external_id))
+
+ %{tenant: tenant, conn: conn}
+ end
+
+ defp pubsub_subscribe(external_id, event \\ "*") do
+ pg_change_params = [
+ %{
+ id: UUID.uuid1(),
+ params: %{"event" => event, "schema" => "public"},
+ channel_pid: self(),
+ claims: %{
+ "exp" => System.system_time(:second) + 100_000,
+ "iat" => 0,
+ "ref" => "127.0.0.1",
+ "role" => "anon"
+ }
+ }
+ ]
+
+ topic = "realtime:test"
+ serializer = Phoenix.Socket.V1.JSONSerializer
+
+ ids =
+ Enum.map(pg_change_params, fn %{id: id, params: params} ->
+ {UUID.string_to_binary!(id), :erlang.phash2(params)}
+ end)
+
+ subscription_metadata = {:subscriber_fastlane, self(), serializer, ids, topic, true}
+ metadata = [metadata: subscription_metadata]
+ :ok = PostgresCdc.subscribe(PostgresCdcRls, pg_change_params, external_id, metadata)
+ pg_change_params
end
+
+ def handle_telemetry(event, measures, metadata, pid: pid), do: send(pid, {:telemetry, event, measures, metadata})
end
diff --git a/test/realtime/extensions/cdc_rls/replication_poller_test.exs b/test/realtime/extensions/cdc_rls/replication_poller_test.exs
index 97d69af62..8ae7f3152 100644
--- a/test/realtime/extensions/cdc_rls/replication_poller_test.exs
+++ b/test/realtime/extensions/cdc_rls/replication_poller_test.exs
@@ -1,8 +1,13 @@
-defmodule ReplicationPollerTest do
- use ExUnit.Case, async: false
+defmodule Realtime.Extensions.PostgresCdcRls.ReplicationPollerTest do
+ # Tweaking application env
+ use Realtime.DataCase, async: false
+ use Mimic
+
+ alias Extensions.PostgresCdcRls.MessageDispatcher
alias Extensions.PostgresCdcRls.ReplicationPoller, as: Poller
- import Poller, only: [generate_record: 1]
+ alias Extensions.PostgresCdcRls.Replications
+ alias Extensions.PostgresCdcRls.Subscriptions
alias Realtime.Adapters.Changes.{
DeletedRecord,
@@ -10,6 +15,572 @@ defmodule ReplicationPollerTest do
UpdatedRecord
}
+ alias Realtime.Database
+ alias Realtime.RateCounter
+
+ alias RealtimeWeb.TenantBroadcaster
+
+ import Poller, only: [generate_record: 1]
+
+ setup :set_mimic_global
+
+ @change_json ~s({"table":"test","type":"INSERT","record":{"id": 34, "details": "test"},"columns":[{"name": "id", "type": "int4"}, {"name": "details", "type": "text"}],"errors":null,"schema":"public","commit_timestamp":"2025-10-13T07:50:28.066Z"})
+
+ describe "poll" do
+ setup do
+ :telemetry.attach_many(
+ __MODULE__,
+ [
+ [:realtime, :replication, :poller, :query, :stop],
+ [:realtime, :replication, :poller, :query, :exception],
+ [:realtime, :replication, :poller, :prepare, :exception],
+ [:realtime, :replication, :poller, :stop],
+ [:realtime, :replication, :poller, :exception],
+ [:realtime, :replication, :poller, :changes, :dispatch],
+ [:realtime, :replication, :poller, :changes, :skip]
+ ],
+ &__MODULE__.handle_telemetry/4,
+ pid: self()
+ )
+
+ on_exit(fn -> :telemetry.detach(__MODULE__) end)
+
+ tenant = Containers.checkout_tenant(run_migrations: true)
+
+ {:ok, tenant} = Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{"max_events_per_second" => 123})
+
+ subscribers_pids_table = :ets.new(__MODULE__, [:public, :bag])
+ subscribers_nodes_table = :ets.new(__MODULE__, [:public, :set])
+
+ args =
+ hd(tenant.extensions).settings
+ |> Map.put("id", tenant.external_id)
+ |> Map.put("subscribers_pids_table", subscribers_pids_table)
+ |> Map.put("subscribers_nodes_table", subscribers_nodes_table)
+
+ # unless specified it will return empty results
+ empty_results = {:ok, %Postgrex.Result{rows: [], num_rows: 0}}
+ stub(Replications, :list_changes, fn _, _, _, _, _ -> empty_results end)
+
+ # Default to a publication with tables so the poller actually polls.
+ # Tests that need an empty publication override this stub explicitly.
+ stub(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{{"public", "test"} => [1234]}} end)
+
+ %{args: args, tenant: tenant}
+ end
+
+ test "handles prepare_replication failure and retries", %{args: args} do
+ tenant_id = args["id"]
+
+ stub(Replications, :prepare_replication, fn _, _ -> {:ok, %Postgrex.Result{}} end)
+ expect(Replications, :prepare_replication, fn _, _ -> {:error, "prepare failed"} end)
+
+ start_link_supervised!({Poller, args})
+
+ assert_receive {
+ :telemetry,
+ [:realtime, :replication, :poller, :query, :stop],
+ %{duration: _},
+ %{tenant: ^tenant_id}
+ },
+ 2000
+ end
+
+ test "gives up and stops when prepare_replication keeps failing", %{args: args} do
+ stub(Replications, :prepare_replication, fn _, _ -> {:error, "prepare failed"} end)
+
+ pid = start_supervised!({Poller, args}, restart: :temporary)
+ ref = Process.monitor(pid)
+
+ # Drive the retry count to the limit, then trigger one more failing prepare
+ :sys.replace_state(pid, fn state -> %{state | retry_count: 6} end)
+ send(pid, :retry)
+
+ assert_receive {:DOWN, ^ref, :process, ^pid, {:shutdown, :max_retries_reached}}, 1000
+ end
+
+ test "a fetch error on the prepare path goes through the retry machinery", %{args: args} do
+ # Start idle so no slot or poll loop is running.
+ expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{}} end)
+
+ pid = start_supervised!({Poller, args}, restart: :temporary)
+ ref = Process.monitor(pid)
+
+ # A fetch error while preparing is treated like a prepare failure: at the retry
+ # limit, one more failing prepare stops the poller.
+ expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:error, :boom} end)
+ :sys.replace_state(pid, fn state -> %{state | retry_count: 6} end)
+ send(pid, :retry)
+
+ assert_receive {:DOWN, ^ref, :process, ^pid, {:shutdown, :max_retries_reached}}, 1000
+ end
+
+ test "terminates replication slot when retry count exceeds threshold", %{args: args} do
+ tenant_id = args["id"]
+
+ slot_in_use_error =
+ {:error,
+ %Postgrex.Error{
+ postgres: %{
+ code: :object_in_use,
+ message: "replication slot is active for PID 12345"
+ }
+ }}
+
+ stub(Replications, :get_pg_stat_activity_diff, fn _conn, _pid -> {:ok, 42} end)
+ stub(Replications, :list_changes, fn _, _, _, _, _ -> slot_in_use_error end)
+ expect(Replications, :terminate_backend, fn _conn, _slot -> {:ok, :terminated} end)
+
+ pid = start_link_supervised!({Poller, args})
+
+ # Wait for the first poll
+ assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 1000
+
+ # Advance retry_count past threshold and send another poll
+ :sys.replace_state(pid, fn state -> %{state | retry_count: 4} end)
+ send(pid, :poll)
+
+ assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 2000
+ end
+
+ test "gives up and stops after max retries", %{args: args} do
+ tenant_id = args["id"]
+ error = {:error, %Postgrex.Error{message: "boom"}}
+ stub(Replications, :list_changes, fn _, _, _, _, _ -> error end)
+
+ pid = start_supervised!({Poller, args}, restart: :temporary)
+ ref = Process.monitor(pid)
+
+ # Drive the retry count to the limit, then trigger one more failing poll
+ :sys.replace_state(pid, fn state -> %{state | retry_count: 6} end)
+ send(pid, :poll)
+
+ assert_receive {:telemetry, [:realtime, :replication, :poller, :stop], %{duration: _},
+ %{tenant: ^tenant_id, reason: {:shutdown, :max_retries_reached}}},
+ 1000
+
+ assert_receive {:DOWN, ^ref, :process, ^pid, {:shutdown, :max_retries_reached}}, 1000
+ end
+
+ test "handles no new changes", %{args: args, tenant: tenant} do
+ tenant_id = args["id"]
+ reject(&TenantBroadcaster.pubsub_direct_broadcast/6)
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+ start_link_supervised!({Poller, args})
+
+ assert_receive {
+ :telemetry,
+ [:realtime, :replication, :poller, :query, :stop],
+ %{duration: _},
+ %{tenant: ^tenant_id}
+ },
+ 500
+
+ rate = Realtime.Tenants.db_events_per_second_rate(tenant)
+
+ assert {:ok,
+ %RateCounter{
+ sum: sum,
+ limit: %{
+ value: 123,
+ measurement: :avg,
+ triggered: false
+ }
+ }} = RateCounterHelper.tick!(rate)
+
+ assert sum == 0
+ end
+
+ test "handles new changes with missing ets table", %{args: args, tenant: tenant} do
+ tenant_id = args["id"]
+
+ :ets.delete(args["subscribers_nodes_table"])
+
+ results =
+ build_result([
+ <<71, 36, 83, 212, 168, 9, 17, 240, 165, 186, 118, 202, 193, 157, 232, 187>>,
+ <<251, 188, 190, 118, 168, 119, 17, 240, 188, 87, 118, 202, 193, 157, 232, 187>>
+ ])
+
+ expect(Replications, :list_changes, fn _, _, _, _, _ -> results end)
+ reject(&TenantBroadcaster.pubsub_direct_broadcast/6)
+
+ # Broadcast to the whole cluster due to missing node information
+ expect(TenantBroadcaster, :pubsub_broadcast, fn ^tenant_id,
+ "realtime:postgres:" <> ^tenant_id,
+ {"INSERT", change_json, _sub_ids},
+ MessageDispatcher,
+ :postgres_changes ->
+ assert Jason.decode!(change_json) == Jason.decode!(@change_json)
+ :ok
+ end)
+
+ start_link_supervised!({Poller, args})
+
+ # First poll with changes
+ assert_receive {
+ :telemetry,
+ [:realtime, :replication, :poller, :query, :stop],
+ %{duration: _},
+ %{tenant: ^tenant_id}
+ },
+ 500
+
+ # Second poll without changes
+ assert_receive {
+ :telemetry,
+ [:realtime, :replication, :poller, :query, :stop],
+ %{duration: _},
+ %{tenant: ^tenant_id}
+ },
+ 500
+
+ rate = Realtime.Tenants.db_events_per_second_rate(tenant)
+ assert {:ok, %RateCounter{sum: sum}} = RateCounterHelper.tick!(rate)
+ assert sum == 2
+ end
+
+ test "handles new changes with no subscription nodes", %{args: args, tenant: tenant} do
+ tenant_id = args["id"]
+
+ results =
+ build_result([
+ <<71, 36, 83, 212, 168, 9, 17, 240, 165, 186, 118, 202, 193, 157, 232, 187>>,
+ <<251, 188, 190, 118, 168, 119, 17, 240, 188, 87, 118, 202, 193, 157, 232, 187>>
+ ])
+
+ expect(Replications, :list_changes, fn _, _, _, _, _ -> results end)
+ reject(&TenantBroadcaster.pubsub_direct_broadcast/6)
+
+ # Broadcast to the whole cluster due to missing node information
+ expect(TenantBroadcaster, :pubsub_broadcast, fn ^tenant_id,
+ "realtime:postgres:" <> ^tenant_id,
+ {"INSERT", change_json, _sub_ids},
+ MessageDispatcher,
+ :postgres_changes ->
+ assert Jason.decode!(change_json) == Jason.decode!(@change_json)
+ :ok
+ end)
+
+ start_link_supervised!({Poller, args})
+
+ # First poll with changes
+ assert_receive {
+ :telemetry,
+ [:realtime, :replication, :poller, :query, :stop],
+ %{duration: _},
+ %{tenant: ^tenant_id}
+ },
+ 500
+
+ # Second poll without changes
+ assert_receive {
+ :telemetry,
+ [:realtime, :replication, :poller, :query, :stop],
+ %{duration: _},
+ %{tenant: ^tenant_id}
+ },
+ 500
+
+ rate = Realtime.Tenants.db_events_per_second_rate(tenant)
+ assert {:ok, %RateCounter{sum: sum}} = RateCounterHelper.tick!(rate)
+ assert sum == 2
+ end
+
+ test "handles new changes with missing subscription nodes", %{args: args, tenant: tenant} do
+ tenant_id = args["id"]
+
+ results =
+ build_result([
+ sub1 = <<71, 36, 83, 212, 168, 9, 17, 240, 165, 186, 118, 202, 193, 157, 232, 187>>,
+ <<251, 188, 190, 118, 168, 119, 17, 240, 188, 87, 118, 202, 193, 157, 232, 187>>
+ ])
+
+ :ets.insert(args["subscribers_nodes_table"], {sub1, node()})
+
+ expect(Replications, :list_changes, fn _, _, _, _, _ -> results end)
+ reject(&TenantBroadcaster.pubsub_direct_broadcast/6)
+
+ # Broadcast to the whole cluster due to missing node information
+ expect(TenantBroadcaster, :pubsub_broadcast, fn ^tenant_id,
+ "realtime:postgres:" <> ^tenant_id,
+ {"INSERT", change_json, _sub_ids},
+ MessageDispatcher,
+ :postgres_changes ->
+ assert Jason.decode!(change_json) == Jason.decode!(@change_json)
+ :ok
+ end)
+
+ start_link_supervised!({Poller, args})
+
+ # First poll with changes
+ assert_receive {
+ :telemetry,
+ [:realtime, :replication, :poller, :query, :stop],
+ %{duration: _},
+ %{tenant: ^tenant_id}
+ },
+ 500
+
+ # Second poll without changes
+ assert_receive {
+ :telemetry,
+ [:realtime, :replication, :poller, :query, :stop],
+ %{duration: _},
+ %{tenant: ^tenant_id}
+ },
+ 500
+
+ rate = Realtime.Tenants.db_events_per_second_rate(tenant)
+ assert {:ok, %RateCounter{sum: sum}} = RateCounterHelper.tick!(rate)
+ assert sum == 2
+ end
+
+ test "handles new changes with subscription nodes information", %{args: args, tenant: tenant} do
+ tenant_id = args["id"]
+
+ results =
+ build_result([
+ sub1 = <<71, 36, 83, 212, 168, 9, 17, 240, 165, 186, 118, 202, 193, 157, 232, 187>>,
+ sub2 = <<251, 188, 190, 118, 168, 119, 17, 240, 188, 87, 118, 202, 193, 157, 232, 187>>,
+ sub3 = <<49, 59, 209, 112, 173, 77, 17, 240, 191, 41, 118, 202, 193, 157, 232, 187>>
+ ])
+
+ # All subscriptions have node information
+ :ets.insert(args["subscribers_nodes_table"], {sub1, node()})
+ :ets.insert(args["subscribers_nodes_table"], {sub2, :"someothernode@127.0.0.1"})
+ :ets.insert(args["subscribers_nodes_table"], {sub3, node()})
+
+ expect(Replications, :list_changes, fn _, _, _, _, _ -> results end)
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ topic = "realtime:postgres:" <> tenant_id
+
+ # # Broadcast to the exact nodes only
+ expect(TenantBroadcaster, :pubsub_direct_broadcast, 2, fn
+ _node, ^tenant_id, ^topic, {"INSERT", change_json, _sub_ids}, MessageDispatcher, :postgres_changes ->
+ assert Jason.decode!(change_json) == Jason.decode!(@change_json)
+ :ok
+ end)
+
+ start_link_supervised!({Poller, args})
+
+ # First poll with changes
+ assert_receive {
+ :telemetry,
+ [:realtime, :replication, :poller, :query, :stop],
+ %{duration: _},
+ %{tenant: ^tenant_id}
+ },
+ 500
+
+ # Second poll without changes
+ assert_receive {
+ :telemetry,
+ [:realtime, :replication, :poller, :query, :stop],
+ %{duration: _},
+ %{tenant: ^tenant_id}
+ },
+ 500
+
+ calls = calls(TenantBroadcaster, :pubsub_direct_broadcast, 6)
+
+ assert Enum.count(calls) == 2
+
+ node_subs = Enum.map(calls, fn [node, _, _, {"INSERT", _change_json, sub_ids}, _, _] -> {node, sub_ids} end)
+
+ assert {node(), MapSet.new([sub1, sub3])} in node_subs
+ assert {:"someothernode@127.0.0.1", MapSet.new([sub2])} in node_subs
+
+ rate = Realtime.Tenants.db_events_per_second_rate(tenant)
+ assert {:ok, %RateCounter{sum: sum}} = RateCounterHelper.tick!(rate)
+ assert sum == 3
+ end
+
+ test "does not poll WAL when publication has no tables", %{args: args} do
+ tenant_id = args["id"]
+
+ expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{}} end)
+ reject(&Replications.list_changes/5)
+
+ start_link_supervised!({Poller, args})
+
+ refute_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 200
+ end
+
+ test "drops replication slot and stops polling when tables vanish", %{args: args} do
+ tenant_id = args["id"]
+
+ expect(Replications, :drop_replication_slot, fn _conn, _slot -> {:ok, :dropped} end)
+
+ pid = start_link_supervised!({Poller, args})
+
+ # First poll happens with the default non-empty stub.
+ assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 500
+
+ expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{}} end)
+ reject(&Replications.list_changes/5)
+
+ send(pid, :check_oids)
+ # Force the GenServer to process :check_oids before we assert.
+ :sys.get_state(pid)
+
+ refute_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 200
+ end
+
+ test "cancels a pending retry when tables vanish so it can't recreate the slot", %{args: args} do
+ tenant_id = args["id"]
+
+ expect(Replications, :drop_replication_slot, fn _conn, _slot -> {:ok, :dropped} end)
+
+ pid = start_link_supervised!({Poller, args})
+
+ # First poll happens with the default non-empty stub.
+ assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 500
+
+ # Simulate a retry already scheduled from a prior list_changes/5 error.
+ :sys.replace_state(pid, fn state ->
+ %{state | retry_ref: Process.send_after(pid, :retry, 50), retry_count: 3}
+ end)
+
+ expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{}} end)
+ reject(&Replications.list_changes/5)
+
+ send(pid, :check_oids)
+
+ # retry_ref is cancelled/cleared and retry_count reset; no :retry fires.
+ assert %{retry_ref: nil, retry_count: 0} = :sys.get_state(pid)
+ refute_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 200
+ end
+
+ test "resumes polling when tables appear via :check_oids", %{args: args} do
+ tenant_id = args["id"]
+
+ expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{}} end)
+
+ pid = start_link_supervised!({Poller, args})
+
+ refute_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 200
+
+ # Tables are added to the publication. Next :check_oids should trigger
+ # prepare_replication + an initial poll.
+ expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{{"public", "test"} => [1234]}} end)
+ send(pid, :check_oids)
+
+ assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 1000
+ end
+
+ test "a successful prepare resets a prior failure streak", %{args: args} do
+ tenant_id = args["id"]
+
+ # Start idle (empty publication) so no slot exists yet.
+ expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{}} end)
+
+ pid = start_link_supervised!({Poller, args})
+
+ refute_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 200
+
+ # Simulate a leftover failure streak from earlier prepare/list_changes errors:
+ # a pending :retry and an inflated retry_count.
+ :sys.replace_state(pid, fn state ->
+ %{state | retry_ref: Process.send_after(pid, :retry, 60_000), retry_count: 5}
+ end)
+
+ # Tables appear: :check_oids re-runs prepare_replication, which succeeds and
+ # must wipe the failure streak.
+ expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{{"public", "test"} => [1234]}} end)
+ send(pid, :check_oids)
+
+ assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 1000
+
+ assert %{retry_ref: nil, retry_count: 0} = :sys.get_state(pid)
+ end
+
+ test "shuts down when slot drop fails so the temp slot is released with the connection",
+ %{args: args} do
+ pid = start_supervised!({Poller, args}, restart: :temporary)
+
+ assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, _}, 500
+
+ expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{}} end)
+ expect(Replications, :drop_replication_slot, fn _, _ -> {:error, :boom} end)
+
+ ref = Process.monitor(pid)
+ send(pid, :check_oids)
+
+ assert_receive {:DOWN, ^ref, :process, ^pid, {:shutdown, :drop_replication_slot_failed}}, 1000
+ end
+
+ test "refreshes oids without touching the slot when the publication stays non-empty", %{args: args} do
+ tenant_id = args["id"]
+
+ # While the publication keeps having tables the slot must never be dropped
+ # or recreated; :check_oids only refreshes the oid map in place.
+ reject(&Replications.drop_replication_slot/2)
+
+ pid = start_link_supervised!({Poller, args})
+
+ # First poll happens with the default non-empty stub.
+ assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 500
+
+ # Publication still has tables but the oid set changed (e.g. a table was added).
+ new_oids = %{{"public", "test"} => [1234], {"public", "other"} => [5678]}
+ expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, new_oids} end)
+
+ send(pid, :check_oids)
+
+ # oids map is refreshed in place and the periodic check stays armed.
+ state = :sys.get_state(pid)
+ assert state.oids == new_oids
+ assert is_reference(state.check_oid_ref)
+ end
+
+ test "keeps oids and the slot when :check_oids fetch errors", %{args: args} do
+ tenant_id = args["id"]
+
+ # A fetch error must never be mistaken for an emptied publication, so the slot
+ # is left intact and the oid map is preserved.
+ reject(&Replications.drop_replication_slot/2)
+
+ pid = start_link_supervised!({Poller, args})
+
+ assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 500
+
+ old_oids = :sys.get_state(pid).oids
+
+ expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:error, :boom} end)
+ send(pid, :check_oids)
+
+ state = :sys.get_state(pid)
+ assert state.oids == old_oids
+ assert is_reference(state.check_oid_ref)
+ end
+
+ test "arms the periodic :check_oids timer when polling starts", %{args: args} do
+ tenant_id = args["id"]
+
+ pid = start_link_supervised!({Poller, args})
+
+ assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 500
+
+ assert is_reference(:sys.get_state(pid).check_oid_ref)
+ end
+
+ test "arms the periodic :check_oids timer even when the publication is empty", %{args: args} do
+ tenant_id = args["id"]
+
+ expect(Subscriptions, :fetch_publication_tables, fn _, _ -> {:ok, %{}} end)
+ reject(&Replications.list_changes/5)
+
+ pid = start_link_supervised!({Poller, args})
+
+ refute_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 200
+
+ # Even idle (no slot), the poller must keep checking for tables to appear.
+ assert is_reference(:sys.get_state(pid).check_oid_ref)
+ end
+ end
+
@columns [
%{"name" => "id", "type" => "int8"},
%{"name" => "details", "type" => "text"},
@@ -19,272 +590,388 @@ defmodule ReplicationPollerTest do
@ts "2021-11-05T17:20:51.52406+00:00"
@subscription_id "417e76fd-9bc5-4b3e-bd5d-a031389c4a6b"
+ @subscription_ids MapSet.new(["417e76fd-9bc5-4b3e-bd5d-a031389c4a6b"])
+
+ @old_record %{"id" => 12}
+ @record %{"details" => "test", "id" => 12, "user_id" => 1}
describe "generate_record/1" do
test "INSERT" do
- record = [
- {"wal",
- %{
- "columns" => @columns,
- "commit_timestamp" => @ts,
- "record" => %{"details" => "test", "id" => 12, "user_id" => 1},
- "schema" => "public",
- "table" => "todos",
- "type" => "INSERT"
- }},
- {"is_rls_enabled", false},
- {"subscription_ids", [@subscription_id]},
- {"errors", []}
+ wal_record = [
+ "INSERT",
+ "public",
+ "todos",
+ Jason.encode!(@columns),
+ Jason.encode!(@record),
+ nil,
+ @ts,
+ [@subscription_id],
+ [],
+ 1
]
- expected = %NewRecord{
- columns: @columns,
- commit_timestamp: @ts,
- schema: "public",
- table: "todos",
- type: "INSERT",
- subscription_ids: MapSet.new([@subscription_id]),
- record: %{"details" => "test", "id" => 12, "user_id" => 1},
- errors: nil
- }
+ assert %NewRecord{
+ columns: columns,
+ commit_timestamp: @ts,
+ schema: "public",
+ table: "todos",
+ type: "INSERT",
+ subscription_ids: @subscription_ids,
+ record: record,
+ errors: nil
+ } = generate_record(wal_record)
- assert expected == generate_record(record)
+ # Encode then decode to get rid of the fragment
+ assert record |> Jason.encode!() |> Jason.decode!() == @record
+ assert columns |> Jason.encode!() |> Jason.decode!() == @columns
end
test "UPDATE" do
- record = [
- {"wal",
- %{
- "columns" => @columns,
- "commit_timestamp" => @ts,
- "old_record" => %{"id" => 12},
- "record" => %{"details" => "test1", "id" => 12, "user_id" => 1},
- "schema" => "public",
- "table" => "todos",
- "type" => "UPDATE"
- }},
- {"is_rls_enabled", false},
- {"subscription_ids", [@subscription_id]},
- {"errors", []}
+ wal_record = [
+ "UPDATE",
+ "public",
+ "todos",
+ Jason.encode!(@columns),
+ Jason.encode!(@record),
+ Jason.encode!(@old_record),
+ @ts,
+ [@subscription_id],
+ [],
+ 1
]
- expected = %UpdatedRecord{
- columns: @columns,
- commit_timestamp: @ts,
- schema: "public",
- table: "todos",
- type: "UPDATE",
- subscription_ids: MapSet.new([@subscription_id]),
- old_record: %{"id" => 12},
- record: %{"details" => "test1", "id" => 12, "user_id" => 1},
- errors: nil
- }
+ assert %UpdatedRecord{
+ columns: columns,
+ commit_timestamp: @ts,
+ schema: "public",
+ table: "todos",
+ type: "UPDATE",
+ subscription_ids: @subscription_ids,
+ record: record,
+ old_record: old_record,
+ errors: nil
+ } = generate_record(wal_record)
- assert expected == generate_record(record)
+ # Encode then decode to get rid of the fragment
+ assert record |> Jason.encode!() |> Jason.decode!() == @record
+ assert old_record |> Jason.encode!() |> Jason.decode!() == @old_record
+ assert columns |> Jason.encode!() |> Jason.decode!() == @columns
end
test "DELETE" do
- record = [
- {"wal",
- %{
- "columns" => @columns,
- "commit_timestamp" => @ts,
- "old_record" => %{"id" => 15},
- "schema" => "public",
- "table" => "todos",
- "type" => "DELETE"
- }},
- {"is_rls_enabled", false},
- {"subscription_ids", [@subscription_id]},
- {"errors", []}
+ wal_record = [
+ "DELETE",
+ "public",
+ "todos",
+ Jason.encode!(@columns),
+ nil,
+ Jason.encode!(@old_record),
+ @ts,
+ [@subscription_id],
+ [],
+ 1
]
- expected = %DeletedRecord{
- columns: @columns,
- commit_timestamp: @ts,
- schema: "public",
- table: "todos",
- type: "DELETE",
- subscription_ids: MapSet.new([@subscription_id]),
- old_record: %{"id" => 15},
- errors: nil
- }
+ assert %DeletedRecord{
+ columns: columns,
+ commit_timestamp: @ts,
+ schema: "public",
+ table: "todos",
+ type: "DELETE",
+ subscription_ids: @subscription_ids,
+ old_record: old_record,
+ errors: nil
+ } = generate_record(wal_record)
- assert expected == generate_record(record)
+ # Encode then decode to get rid of the fragment
+ assert old_record |> Jason.encode!() |> Jason.decode!() == @old_record
+ assert columns |> Jason.encode!() |> Jason.decode!() == @columns
end
test "INSERT, large payload error present" do
- record = [
- {"wal",
- %{
- "columns" => @columns,
- "commit_timestamp" => @ts,
- "record" => %{"details" => "test", "id" => 12, "user_id" => 1},
- "schema" => "public",
- "table" => "todos",
- "type" => "INSERT"
- }},
- {"is_rls_enabled", false},
- {"subscription_ids", [@subscription_id]},
- {"errors", ["Error 413: Payload Too Large"]}
+ wal_record = [
+ "INSERT",
+ "public",
+ "todos",
+ Jason.encode!(@columns),
+ Jason.encode!(@record),
+ nil,
+ @ts,
+ [@subscription_id],
+ ["Error 413: Payload Too Large"],
+ 1
]
- expected = %NewRecord{
- columns: @columns,
- commit_timestamp: @ts,
- schema: "public",
- table: "todos",
- type: "INSERT",
- subscription_ids: MapSet.new([@subscription_id]),
- record: %{"details" => "test", "id" => 12, "user_id" => 1},
- errors: ["Error 413: Payload Too Large"]
- }
+ assert %NewRecord{
+ columns: columns,
+ commit_timestamp: @ts,
+ schema: "public",
+ table: "todos",
+ type: "INSERT",
+ subscription_ids: @subscription_ids,
+ record: record,
+ errors: ["Error 413: Payload Too Large"]
+ } = generate_record(wal_record)
- assert expected == generate_record(record)
+ # Encode then decode to get rid of the fragment
+ assert record |> Jason.encode!() |> Jason.decode!() == @record
+ assert columns |> Jason.encode!() |> Jason.decode!() == @columns
end
test "INSERT, other errors present" do
- record = [
- {"wal",
- %{
- "schema" => "public",
- "table" => "todos",
- "type" => "INSERT"
- }},
- {"is_rls_enabled", false},
- {"subscription_ids", [@subscription_id]},
- {"errors", ["Error..."]}
+ wal_record = [
+ "INSERT",
+ "public",
+ "todos",
+ Jason.encode!(@columns),
+ Jason.encode!(@record),
+ nil,
+ @ts,
+ [@subscription_id],
+ ["Error..."],
+ 1
]
- expected = %NewRecord{
- columns: [],
- commit_timestamp: nil,
- schema: "public",
- table: "todos",
- type: "INSERT",
- subscription_ids: MapSet.new([@subscription_id]),
- record: %{},
- errors: ["Error..."]
- }
+ assert %NewRecord{
+ columns: columns,
+ commit_timestamp: @ts,
+ schema: "public",
+ table: "todos",
+ type: "INSERT",
+ subscription_ids: @subscription_ids,
+ record: record,
+ errors: ["Error..."]
+ } = generate_record(wal_record)
- assert expected == generate_record(record)
+ # Encode then decode to get rid of the fragment
+ assert record |> Jason.encode!() |> Jason.decode!() == @record
+ assert columns |> Jason.encode!() |> Jason.decode!() == @columns
end
test "UPDATE, large payload error present" do
- record = [
- {"wal",
- %{
- "columns" => @columns,
- "commit_timestamp" => @ts,
- "old_record" => %{"details" => "prev test", "id" => 12, "user_id" => 1},
- "record" => %{"details" => "test", "id" => 12, "user_id" => 1},
- "schema" => "public",
- "table" => "todos",
- "type" => "UPDATE"
- }},
- {"is_rls_enabled", false},
- {"subscription_ids", [@subscription_id]},
- {"errors", ["Error 413: Payload Too Large"]}
+ wal_record = [
+ "UPDATE",
+ "public",
+ "todos",
+ Jason.encode!(@columns),
+ Jason.encode!(@record),
+ Jason.encode!(@old_record),
+ @ts,
+ [@subscription_id],
+ ["Error 413: Payload Too Large"],
+ 1
]
- expected = %UpdatedRecord{
- columns: @columns,
- commit_timestamp: @ts,
- schema: "public",
- table: "todos",
- type: "UPDATE",
- subscription_ids: MapSet.new([@subscription_id]),
- old_record: %{"details" => "prev test", "id" => 12, "user_id" => 1},
- record: %{"details" => "test", "id" => 12, "user_id" => 1},
- errors: ["Error 413: Payload Too Large"]
- }
+ assert %UpdatedRecord{
+ columns: columns,
+ commit_timestamp: @ts,
+ schema: "public",
+ table: "todos",
+ type: "UPDATE",
+ subscription_ids: @subscription_ids,
+ record: record,
+ old_record: old_record,
+ errors: ["Error 413: Payload Too Large"]
+ } = generate_record(wal_record)
- assert expected == generate_record(record)
+ # Encode then decode to get rid of the fragment
+ assert record |> Jason.encode!() |> Jason.decode!() == @record
+ assert old_record |> Jason.encode!() |> Jason.decode!() == @old_record
+ assert columns |> Jason.encode!() |> Jason.decode!() == @columns
end
test "UPDATE, other errors present" do
- record = [
- {"wal",
- %{
- "schema" => "public",
- "table" => "todos",
- "type" => "UPDATE"
- }},
- {"is_rls_enabled", false},
- {"subscription_ids", [@subscription_id]},
- {"errors", ["Error..."]}
+ wal_record = [
+ "UPDATE",
+ "public",
+ "todos",
+ Jason.encode!(@columns),
+ Jason.encode!(@record),
+ Jason.encode!(@old_record),
+ @ts,
+ [@subscription_id],
+ ["Error..."],
+ 1
]
- expected = %UpdatedRecord{
- columns: [],
- commit_timestamp: nil,
- schema: "public",
- table: "todos",
- type: "UPDATE",
- subscription_ids: MapSet.new([@subscription_id]),
- old_record: %{},
- record: %{},
- errors: ["Error..."]
- }
+ assert %UpdatedRecord{
+ columns: columns,
+ commit_timestamp: @ts,
+ schema: "public",
+ table: "todos",
+ type: "UPDATE",
+ subscription_ids: @subscription_ids,
+ record: record,
+ old_record: old_record,
+ errors: ["Error..."]
+ } = generate_record(wal_record)
- assert expected == generate_record(record)
+ # Encode then decode to get rid of the fragment
+ assert record |> Jason.encode!() |> Jason.decode!() == @record
+ assert old_record |> Jason.encode!() |> Jason.decode!() == @old_record
+ assert columns |> Jason.encode!() |> Jason.decode!() == @columns
end
test "DELETE, large payload error present" do
- record = [
- {"wal",
- %{
- "columns" => @columns,
- "commit_timestamp" => @ts,
- "old_record" => %{"details" => "test", "id" => 12, "user_id" => 1},
- "schema" => "public",
- "table" => "todos",
- "type" => "DELETE"
- }},
- {"is_rls_enabled", false},
- {"subscription_ids", [@subscription_id]},
- {"errors", ["Error 413: Payload Too Large"]}
+ wal_record = [
+ "DELETE",
+ "public",
+ "todos",
+ Jason.encode!(@columns),
+ nil,
+ Jason.encode!(@old_record),
+ @ts,
+ [@subscription_id],
+ ["Error 413: Payload Too Large"],
+ 1
]
- expected = %DeletedRecord{
- columns: @columns,
- commit_timestamp: @ts,
- schema: "public",
- table: "todos",
- type: "DELETE",
- subscription_ids: MapSet.new([@subscription_id]),
- old_record: %{"details" => "test", "id" => 12, "user_id" => 1},
- errors: ["Error 413: Payload Too Large"]
- }
+ assert %DeletedRecord{
+ columns: columns,
+ commit_timestamp: @ts,
+ schema: "public",
+ table: "todos",
+ type: "DELETE",
+ subscription_ids: @subscription_ids,
+ old_record: old_record,
+ errors: ["Error 413: Payload Too Large"]
+ } = generate_record(wal_record)
- assert expected == generate_record(record)
+ # Encode then decode to get rid of the fragment
+ assert old_record |> Jason.encode!() |> Jason.decode!() == @old_record
+ assert columns |> Jason.encode!() |> Jason.decode!() == @columns
end
test "DELETE, other errors present" do
- record = [
- {"wal",
- %{
- "schema" => "public",
- "table" => "todos",
- "type" => "DELETE"
- }},
- {"is_rls_enabled", false},
- {"subscription_ids", [@subscription_id]},
- {"errors", ["Error..."]}
+ wal_record = [
+ "DELETE",
+ "public",
+ "todos",
+ Jason.encode!(@columns),
+ nil,
+ Jason.encode!(@old_record),
+ @ts,
+ [@subscription_id],
+ ["Error..."],
+ 1
]
- expected = %DeletedRecord{
- columns: [],
- commit_timestamp: nil,
- schema: "public",
- table: "todos",
- type: "DELETE",
- subscription_ids: MapSet.new([@subscription_id]),
- old_record: %{},
- errors: ["Error..."]
- }
+ assert %DeletedRecord{
+ columns: columns,
+ commit_timestamp: @ts,
+ schema: "public",
+ table: "todos",
+ type: "DELETE",
+ subscription_ids: @subscription_ids,
+ old_record: old_record,
+ errors: ["Error..."]
+ } = generate_record(wal_record)
- assert expected == generate_record(record)
+ # Encode then decode to get rid of the fragment
+ assert old_record |> Jason.encode!() |> Jason.decode!() == @old_record
+ assert columns |> Jason.encode!() |> Jason.decode!() == @columns
+ end
+ end
+
+ describe "generate_record/1 JSON encoding" do
+ test "subscription_ids is excluded from JSON encoding for INSERT" do
+ wal_record = [
+ "INSERT",
+ "public",
+ "todos",
+ Jason.encode!(@columns),
+ Jason.encode!(@record),
+ nil,
+ @ts,
+ [@subscription_id],
+ [],
+ 1
+ ]
+
+ record = generate_record(wal_record)
+ encoded = Jason.decode!(Jason.encode!(record))
+
+ refute Map.has_key?(encoded, "subscription_ids")
+ assert encoded["type"] == "INSERT"
+ assert encoded["schema"] == "public"
+ assert encoded["table"] == "todos"
+ end
+
+ test "subscription_ids is excluded from JSON encoding for UPDATE" do
+ wal_record = [
+ "UPDATE",
+ "public",
+ "todos",
+ Jason.encode!(@columns),
+ Jason.encode!(@record),
+ Jason.encode!(@old_record),
+ @ts,
+ [@subscription_id],
+ [],
+ 1
+ ]
+
+ record = generate_record(wal_record)
+ encoded = Jason.decode!(Jason.encode!(record))
+
+ refute Map.has_key?(encoded, "subscription_ids")
+ assert encoded["type"] == "UPDATE"
+ end
+
+ test "subscription_ids is excluded from JSON encoding for DELETE" do
+ wal_record = [
+ "DELETE",
+ "public",
+ "todos",
+ Jason.encode!(@columns),
+ nil,
+ Jason.encode!(@old_record),
+ @ts,
+ [@subscription_id],
+ [],
+ 1
+ ]
+
+ record = generate_record(wal_record)
+ encoded = Jason.decode!(Jason.encode!(record))
+
+ refute Map.has_key?(encoded, "subscription_ids")
+ assert encoded["type"] == "DELETE"
+ end
+ end
+
+ describe "get_pg_stat_activity_diff/2" do
+ setup do
+ tenant = Containers.checkout_tenant(run_migrations: true)
+ {:ok, conn} = Database.connect(tenant, "realtime_rls", :stop)
+ %{conn: conn}
+ end
+
+ test "returns error when pid is not in pg_stat_activity", %{conn: conn} do
+ assert {:error, :pid_not_found} = Replications.get_pg_stat_activity_diff(conn, 0)
+ end
+ end
+
+ describe "error handling" do
+ setup do
+ tenant = Containers.checkout_tenant(run_migrations: true)
+
+ args =
+ hd(tenant.extensions).settings
+ |> Map.put("id", tenant.external_id)
+ |> Map.put("subscribers_pids_table", :ets.new(__MODULE__, [:public, :bag]))
+ |> Map.put("subscribers_nodes_table", :ets.new(__MODULE__, [:public, :set]))
+
+ %{args: args}
+ end
+
+ test "stops cleanly when database connection fails", %{args: args} do
+ expect(Realtime.Database, :connect_db, fn _settings -> {:error, :econnrefused} end)
+
+ pid = start_supervised!({Poller, args}, restart: :temporary)
+ ref = Process.monitor(pid)
+
+ assert_receive {:DOWN, ^ref, :process, ^pid, {:shutdown, :econnrefused}}, 1000
end
end
@@ -305,4 +992,42 @@ defmodule ReplicationPollerTest do
assert Poller.slot_name_suffix() == ""
end
end
+
+ def handle_telemetry(event, measures, metadata, pid: pid), do: send(pid, {:telemetry, event, measures, metadata})
+
+ defp build_result(subscription_ids) do
+ {:ok,
+ %Postgrex.Result{
+ command: :select,
+ columns: [
+ "type",
+ "schema",
+ "table",
+ "columns",
+ "record",
+ "old_record",
+ "commit_timestamp",
+ "subscription_ids",
+ "errors",
+ "slot_changes_count"
+ ],
+ rows: [
+ [
+ "INSERT",
+ "public",
+ "test",
+ "[{\"name\": \"id\", \"type\": \"int4\"}, {\"name\": \"details\", \"type\": \"text\"}]",
+ "{\"id\": 34, \"details\": \"test\"}",
+ nil,
+ "2025-10-13T07:50:28.066Z",
+ subscription_ids,
+ [],
+ 1
+ ]
+ ],
+ num_rows: 1,
+ connection_id: 123,
+ messages: []
+ }}
+ end
end
diff --git a/test/realtime/extensions/cdc_rls/replications_test.exs b/test/realtime/extensions/cdc_rls/replications_test.exs
new file mode 100644
index 000000000..0b96b9bf9
--- /dev/null
+++ b/test/realtime/extensions/cdc_rls/replications_test.exs
@@ -0,0 +1,205 @@
+defmodule Realtime.Extensions.PostgresCdcRls.ReplicationsTest do
+ use Realtime.DataCase, async: true
+
+ alias Extensions.PostgresCdcRls.Replications
+ alias Extensions.PostgresCdcRls.Subscriptions
+ alias Realtime.Database
+
+ setup do
+ tenant = Containers.checkout_tenant(run_migrations: true)
+ {:ok, conn} = Database.connect(tenant, "realtime_rls", :stop)
+ %{conn: conn}
+ end
+
+ describe "terminate_backend/2" do
+ test "returns slot_not_found when slot does not exist", %{conn: conn} do
+ assert {:error, :slot_not_found} =
+ Replications.terminate_backend(conn, "nonexistent_slot_#{:rand.uniform(999_999)}")
+ end
+
+ test "returns slot_not_found when slot exists but has no active backend", %{conn: conn} do
+ slot_name = "test_inactive_slot_#{:rand.uniform(999_999)}"
+
+ Postgrex.query!(conn, "SELECT pg_create_logical_replication_slot($1, 'wal2json')", [slot_name])
+
+ try do
+ # No replication session is reading from it, so active_pid is nil
+ assert {:error, :slot_not_found} = Replications.terminate_backend(conn, slot_name)
+ after
+ Postgrex.query(conn, "SELECT pg_drop_replication_slot($1)", [slot_name])
+ end
+ end
+ end
+
+ describe "get_pg_stat_activity_diff/2" do
+ test "returns error when pid is not in pg_stat_activity", %{conn: conn} do
+ assert {:error, :pid_not_found} = Replications.get_pg_stat_activity_diff(conn, 0)
+ end
+
+ test "returns diff when pid is found in pg_stat_activity", %{conn: conn} do
+ # Get the PID of the current connection from pg_stat_activity
+ %{rows: [[db_pid]]} = Postgrex.query!(conn, "SELECT pg_backend_pid()", [])
+
+ # Update the application name so we can find this connection
+ Postgrex.query!(conn, "SET application_name = 'realtime_rls'", [])
+
+ assert {:ok, diff} = Replications.get_pg_stat_activity_diff(conn, db_pid)
+ assert is_integer(diff)
+ end
+ end
+
+ describe "list_changes/5" do
+ test "returns rows from the publication slot", %{conn: conn} do
+ slot_name = "test_list_slot_#{:rand.uniform(999_999)}"
+ publication = "supabase_realtime_test"
+
+ Postgrex.query!(conn, "SELECT pg_create_logical_replication_slot($1, 'wal2json')", [slot_name])
+
+ try do
+ assert {:ok, %Postgrex.Result{columns: columns}} =
+ Replications.list_changes(conn, slot_name, publication, 100, 1_048_576)
+
+ assert "type" in columns
+ after
+ Postgrex.query(conn, "SELECT pg_drop_replication_slot($1)", [slot_name])
+ end
+ end
+ end
+
+ describe "drop_replication_slot/2" do
+ test "returns slot_not_found when slot does not exist", %{conn: conn} do
+ assert {:error, :slot_not_found} =
+ Replications.drop_replication_slot(conn, "nonexistent_slot_#{:rand.uniform(999_999)}")
+ end
+
+ test "drops an existing inactive slot", %{conn: conn} do
+ slot_name = "test_drop_slot_#{:rand.uniform(999_999)}"
+ Postgrex.query!(conn, "SELECT pg_create_logical_replication_slot($1, 'wal2json')", [slot_name])
+
+ assert {:ok, :dropped} = Replications.drop_replication_slot(conn, slot_name)
+
+ %{rows: [[count]]} =
+ Postgrex.query!(conn, "SELECT count(*)::int FROM pg_replication_slots WHERE slot_name = $1", [slot_name])
+
+ assert count == 0
+ end
+ end
+
+ describe "prepare_replication/2" do
+ test "creates a replication slot when it does not exist", %{conn: conn} do
+ slot_name = "test_prep_slot_#{:rand.uniform(999_999)}"
+ assert {:ok, %Postgrex.Result{}} = Replications.prepare_replication(conn, slot_name)
+ end
+
+ test "is idempotent when slot already exists", %{conn: conn} do
+ slot_name = "test_idempotent_slot_#{:rand.uniform(999_999)}"
+ assert {:ok, _} = Replications.prepare_replication(conn, slot_name)
+ assert {:ok, _} = Replications.prepare_replication(conn, slot_name)
+ end
+ end
+
+ describe "list_changes for schemas and tables with special characters" do
+ setup %{conn: conn} do
+ {:ok, _} = Integrations.setup_postgres_changes(conn)
+ :ok
+ end
+
+ defp run_list_changes(conn, schema, table) do
+ pub = "supabase_realtime_test"
+ slot = "lc_#{:rand.uniform(9_999_999)}"
+
+ # quote identifiers
+ %{rows: [[quoted_schema, qualified]]} =
+ Postgrex.query!(
+ conn,
+ "SELECT format('%I', $1::text), format('%I.%I', $1::text, $2::text)",
+ [schema, table]
+ )
+
+ Postgrex.query!(conn, "CREATE SCHEMA IF NOT EXISTS #{quoted_schema}", [])
+ Postgrex.query!(conn, "DROP TABLE IF EXISTS #{qualified}", [])
+ Postgrex.query!(conn, "CREATE TABLE #{qualified} (name text PRIMARY KEY)", [])
+ Postgrex.query!(conn, "GRANT ALL ON TABLE #{qualified} TO anon", [])
+ Postgrex.query!(conn, "GRANT ALL ON TABLE #{qualified} TO authenticated", [])
+ Postgrex.query!(conn, "ALTER PUBLICATION #{pub} ADD TABLE #{qualified}", [])
+
+ {:ok, _} = Replications.prepare_replication(conn, slot)
+
+ {:ok, sub_params} =
+ Subscriptions.parse_subscription_params(%{"schema" => schema, "table" => table})
+
+ params_list = [
+ %{claims: %{"role" => "anon"}, id: Ecto.UUID.generate(), subscription_params: sub_params}
+ ]
+
+ assert {:ok, _} = Subscriptions.create(conn, pub, params_list, self(), self())
+
+ Postgrex.query!(conn, "INSERT INTO #{qualified} VALUES ('list_changes_test')", [])
+
+ try do
+ Replications.list_changes(conn, slot, pub, 100, 1_048_576)
+ after
+ Postgrex.query(conn, "SELECT pg_drop_replication_slot($1)", [slot])
+ Postgrex.query(conn, "DROP TABLE IF EXISTS #{qualified}", [])
+
+ if schema != "public",
+ do: Postgrex.query(conn, "DROP SCHEMA IF EXISTS #{quoted_schema} CASCADE", [])
+ end
+ end
+
+ defp insert_row_for({:ok, %Postgrex.Result{rows: rows}}, expected_table) do
+ Enum.find(rows, fn
+ ["INSERT", _schema, ^expected_table, _cols, record | _] ->
+ record == ~s|{"name": "list_changes_test"}|
+
+ _ ->
+ false
+ end)
+ end
+
+ test "space", %{conn: conn} do
+ result = run_list_changes(conn, "public", "my table")
+ assert insert_row_for(result, "my table")
+ end
+
+ test "comma", %{conn: conn} do
+ result = run_list_changes(conn, "public", "my,table")
+ assert insert_row_for(result, "my,table")
+ end
+
+ test "dot", %{conn: conn} do
+ result = run_list_changes(conn, "public", "my.table")
+ assert insert_row_for(result, "my.table")
+ end
+
+ test "tab", %{conn: conn} do
+ result = run_list_changes(conn, "public", "tab\there")
+ assert insert_row_for(result, "tab\there")
+ end
+
+ test "double-quote", %{conn: conn} do
+ result = run_list_changes(conn, "public", ~s|my"table|)
+ assert insert_row_for(result, ~s|my"table|)
+ end
+
+ test "backslash", %{conn: conn} do
+ result = run_list_changes(conn, "public", "my\\table")
+ assert insert_row_for(result, "my\\table")
+ end
+
+ test "emoji", %{conn: conn} do
+ result = run_list_changes(conn, "public", "[my_table] 🟠")
+ assert insert_row_for(result, "[my_table] 🟠")
+ end
+
+ test "schema and table with spaces", %{conn: conn} do
+ result = run_list_changes(conn, "my schema", "my table")
+ assert insert_row_for(result, "my table")
+ end
+
+ test "schema and table with special cases", %{conn: conn} do
+ result = run_list_changes(conn, ~s|test "schema|, ~s|test " with 'quotes'|)
+ assert insert_row_for(result, ~s|test " with 'quotes'|)
+ end
+ end
+end
diff --git a/test/realtime/extensions/cdc_rls/subscription_manager_distributed_test.exs b/test/realtime/extensions/cdc_rls/subscription_manager_distributed_test.exs
new file mode 100644
index 000000000..d903c86b1
--- /dev/null
+++ b/test/realtime/extensions/cdc_rls/subscription_manager_distributed_test.exs
@@ -0,0 +1,69 @@
+defmodule Realtime.Extensions.CdcRls.SubscriptionManagerDistributedTest do
+ # Usage of Clustered
+ use ExUnit.Case, async: false
+ import ExUnit.CaptureLog
+
+ alias Extensions.PostgresCdcRls.SubscriptionManager
+
+ setup do
+ {:ok, peer, remote_node} = Clustered.start_disconnected()
+ true = Node.connect(remote_node)
+ {:ok, peer: peer, remote_node: remote_node}
+ end
+
+ describe "not_alive_pids_dist/1" do
+ test "returns empty list for all alive PIDs", %{remote_node: remote_node} do
+ assert SubscriptionManager.not_alive_pids_dist(%{}) == []
+
+ pid1 = spawn(fn -> Process.sleep(5000) end)
+ pid2 = spawn(fn -> Process.sleep(5000) end)
+ pid3 = spawn(fn -> Process.sleep(5000) end)
+ pid4 = Node.spawn(remote_node, Process, :sleep, [5000])
+
+ assert SubscriptionManager.not_alive_pids_dist(%{
+ node() => MapSet.new([pid1, pid2, pid3]),
+ remote_node => MapSet.new([pid4])
+ }) ==
+ []
+ end
+
+ test "returns list of dead PIDs", %{remote_node: remote_node} do
+ pid1 = spawn(fn -> Process.sleep(5000) end)
+ pid2 = spawn(fn -> Process.sleep(5000) end)
+ pid3 = spawn(fn -> Process.sleep(5000) end)
+ pid4 = Node.spawn(remote_node, Process, :sleep, [5000])
+ pid5 = Node.spawn(remote_node, Process, :sleep, [5000])
+
+ Process.exit(pid2, :kill)
+ Process.exit(pid5, :kill)
+
+ assert SubscriptionManager.not_alive_pids_dist(%{
+ node() => MapSet.new([pid1, pid2, pid3]),
+ remote_node => MapSet.new([pid4, pid5])
+ }) == [pid2, pid5]
+ end
+
+ test "handles rpc error", %{remote_node: remote_node, peer: peer} do
+ pid1 = spawn(fn -> Process.sleep(5000) end)
+ pid2 = spawn(fn -> Process.sleep(5000) end)
+ pid3 = spawn(fn -> Process.sleep(5000) end)
+ pid4 = Node.spawn(remote_node, Process, :sleep, [5000])
+ pid5 = Node.spawn(remote_node, Process, :sleep, [5000])
+
+ Process.exit(pid2, :kill)
+
+ # Stop the other node
+ :peer.stop(peer)
+
+ log =
+ capture_log(fn ->
+ assert SubscriptionManager.not_alive_pids_dist(%{
+ node() => MapSet.new([pid1, pid2, pid3]),
+ remote_node => MapSet.new([pid4, pid5])
+ }) == [pid2]
+ end)
+
+ assert log =~ "UnableToCheckProcessesOnRemoteNode"
+ end
+ end
+end
diff --git a/test/realtime/extensions/cdc_rls/subscription_manager_test.exs b/test/realtime/extensions/cdc_rls/subscription_manager_test.exs
new file mode 100644
index 000000000..36b3a2f52
--- /dev/null
+++ b/test/realtime/extensions/cdc_rls/subscription_manager_test.exs
@@ -0,0 +1,631 @@
+defmodule Realtime.Extensions.CdcRls.SubscriptionManagerTest do
+ # async: false due to global Mimic stubs
+ use Realtime.DataCase, async: false
+ use Mimic
+
+ alias Extensions.PostgresCdcRls
+ alias Extensions.PostgresCdcRls.SubscriptionManager
+ alias Extensions.PostgresCdcRls.Subscriptions
+ alias Realtime.Database
+ alias Realtime.GenRpc
+ alias Realtime.Tenants.Rebalancer
+
+ import ExUnit.CaptureLog
+ import UUID, only: [uuid1: 0, string_to_binary!: 1]
+
+ setup do
+ tenant = Containers.checkout_tenant(run_migrations: true)
+ {:ok, db_conn} = Realtime.Database.connect(tenant, "realtime_test", :stop)
+ Integrations.setup_postgres_changes(db_conn)
+ GenServer.stop(db_conn)
+ Realtime.Tenants.Cache.update_cache(tenant)
+
+ subscribers_pids_table = :ets.new(__MODULE__, [:public, :bag])
+ subscribers_nodes_table = :ets.new(__MODULE__, [:public, :set])
+
+ args = %{
+ "id" => tenant.external_id,
+ "subscribers_nodes_table" => subscribers_nodes_table,
+ "subscribers_pids_table" => subscribers_pids_table
+ }
+
+ publication = "supabase_realtime_test"
+
+ # register this process with syn as if this was the WorkersSupervisor
+
+ scope = Realtime.Syn.PostgresCdc.scope(tenant.external_id)
+ :syn.register(scope, tenant.external_id, self(), %{region: "us-east-1", manager: nil, subs_pool: nil})
+
+ {:ok, pid} = SubscriptionManager.start_link(args)
+ # This serves so that we know that handle_continue has finished
+ :sys.get_state(pid)
+ %{args: args, pid: pid, publication: publication}
+ end
+
+ describe "subscription" do
+ test "subscription", %{pid: pid, args: args, publication: publication} do
+ {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"])
+ {uuid, bin_uuid, pg_change_params} = pg_change_params()
+
+ subscriber = self()
+
+ assert {:ok, [%Postgrex.Result{command: :insert}]} =
+ Subscriptions.create(conn, publication, [pg_change_params], pid, subscriber)
+
+ # Wait for subscription manager to process the :subscribed message
+ :sys.get_state(pid)
+
+ node = node()
+
+ assert [{^subscriber, ^uuid, _ref, ^node}] = :ets.tab2list(args["subscribers_pids_table"])
+
+ assert :ets.tab2list(args["subscribers_nodes_table"]) == [{bin_uuid, node}]
+ end
+
+ test "subscriber died", %{pid: pid, args: args, publication: publication} do
+ {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"])
+ self = self()
+
+ subscriber =
+ spawn(fn ->
+ receive do
+ :stop -> :ok
+ end
+ end)
+
+ {uuid1, bin_uuid1, pg_change_params1} = pg_change_params()
+ {uuid2, bin_uuid2, pg_change_params2} = pg_change_params()
+ {uuid3, bin_uuid3, pg_change_params3} = pg_change_params()
+
+ assert {:ok, _} =
+ Subscriptions.create(conn, publication, [pg_change_params1, pg_change_params2], pid, subscriber)
+
+ assert {:ok, _} = Subscriptions.create(conn, publication, [pg_change_params3], pid, self())
+
+ # Wait for subscription manager to process the :subscribed message
+ :sys.get_state(pid)
+
+ node = node()
+
+ assert :ets.info(args["subscribers_pids_table"], :size) == 3
+
+ assert [{^subscriber, ^uuid1, _, ^node}, {^subscriber, ^uuid2, _, ^node}] =
+ :ets.lookup(args["subscribers_pids_table"], subscriber)
+
+ assert [{^self, ^uuid3, _ref, ^node}] = :ets.lookup(args["subscribers_pids_table"], self)
+
+ assert :ets.info(args["subscribers_nodes_table"], :size) == 3
+ assert [{^bin_uuid1, ^node}] = :ets.lookup(args["subscribers_nodes_table"], bin_uuid1)
+ assert [{^bin_uuid2, ^node}] = :ets.lookup(args["subscribers_nodes_table"], bin_uuid2)
+ assert [{^bin_uuid3, ^node}] = :ets.lookup(args["subscribers_nodes_table"], bin_uuid3)
+
+ send(subscriber, :stop)
+ # Wait for subscription manager to receive the :DOWN message
+ Process.sleep(200)
+
+ # Only the subscription we have not stopped should remain
+
+ assert [{^self, ^uuid3, _ref, ^node}] = :ets.tab2list(args["subscribers_pids_table"])
+ assert [{^bin_uuid3, ^node}] = :ets.tab2list(args["subscribers_nodes_table"])
+ end
+ end
+
+ describe "subscription deletion" do
+ test "subscription is deleted when process goes away", %{pid: pid, args: args, publication: publication} do
+ {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"])
+ {_uuid, _bin_uuid, pg_change_params} = pg_change_params()
+
+ subscriber =
+ spawn(fn ->
+ receive do
+ :stop -> :ok
+ end
+ end)
+
+ %Postgrex.Result{rows: [[baseline]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+
+ assert {:ok, [%Postgrex.Result{command: :insert}]} =
+ Subscriptions.create(conn, publication, [pg_change_params], pid, subscriber)
+
+ # Wait for subscription manager to process the :subscribed message
+ :sys.get_state(pid)
+
+ assert :ets.info(args["subscribers_pids_table"], :size) == 1
+ assert :ets.info(args["subscribers_nodes_table"], :size) == 1
+
+ %Postgrex.Result{rows: [[after_create]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ assert after_create > baseline
+
+ send(subscriber, :stop)
+ # Wait for subscription manager to receive the :DOWN message
+ Process.sleep(200)
+
+ assert :ets.info(args["subscribers_pids_table"], :size) == 0
+ assert :ets.info(args["subscribers_nodes_table"], :size) == 0
+
+ # Force check delete queue on manager
+ send(pid, :check_delete_queue)
+ :sys.get_state(pid)
+
+ assert %Postgrex.Result{rows: [[^baseline]]} =
+ Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ end
+ end
+
+ describe "warm restart (re-adopt)" do
+ test "keeps DB rows and re-monitors surviving subscribers", %{pid: pid, args: args, publication: publication} do
+ {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"])
+ {uuid, bin_uuid, pg_change_params} = pg_change_params()
+
+ subscriber = spawn(fn -> receive do: (:stop -> :ok) end)
+
+ assert {:ok, _} = Subscriptions.create(conn, publication, [pg_change_params], pid, subscriber)
+ :sys.get_state(pid)
+
+ [{^subscriber, ^uuid, old_ref, _node}] = :ets.lookup(args["subscribers_pids_table"], subscriber)
+
+ # Warm restart: the ETS tables are owned by the test (acting as WorkerSupervisor), so they
+ # survive while only the manager restarts.
+ new_pid = restart_manager(pid, args)
+ {:ok, ^new_pid, conn2} = PostgresCdcRls.get_manager_conn(args["id"])
+
+ # DB rows are untouched
+ assert %{rows: [[count]]} =
+ Postgrex.query!(conn2, "select count(*) from realtime.subscription where subscription_id = $1::uuid", [
+ bin_uuid
+ ])
+
+ assert count > 0
+
+ # The subscriber is re-adopted with a fresh monitor
+ assert [{^subscriber, ^uuid, new_ref, _node}] = :ets.lookup(args["subscribers_pids_table"], subscriber)
+ assert new_ref != old_ref
+
+ # The fresh monitor actually works: killing the subscriber cleans it up
+ send(subscriber, :stop)
+ Process.monitor(subscriber)
+ assert_receive {:DOWN, _, :process, ^subscriber, _}, 100
+ :sys.get_state(new_pid)
+
+ assert :ets.lookup(args["subscribers_pids_table"], subscriber) == []
+ assert :ets.lookup(args["subscribers_nodes_table"], bin_uuid) == []
+ end
+
+ test "does not touch the DB: orphan rows are left untouched", %{pid: pid, args: args, publication: publication} do
+ {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"])
+
+ # A live subscription (present in both ETS and the DB)
+ live = spawn_link(fn -> receive do: (:stop -> :ok) end)
+ {_uuid_a, bin_a, params_a} = pg_change_params()
+ assert {:ok, _} = Subscriptions.create(conn, publication, [params_a], pid, live)
+ :sys.get_state(pid)
+
+ # A DB orphan: a real row whose ETS entries we drop (mimics a {:subscribed} dropped during
+ # downtime). The warm path must leave it alone — orphan cleanup is not on the restart path.
+ orphan = spawn_link(fn -> receive do: (:stop -> :ok) end)
+ {_uuid_b, bin_b, params_b} = pg_change_params()
+ assert {:ok, _} = Subscriptions.create(conn, publication, [params_b], pid, orphan)
+ :sys.get_state(pid)
+ :ets.delete(args["subscribers_pids_table"], orphan)
+ :ets.delete(args["subscribers_nodes_table"], bin_b)
+
+ new_pid = restart_manager(pid, args)
+ {:ok, ^new_pid, conn2} = PostgresCdcRls.get_manager_conn(args["id"])
+
+ # Both the live subscription and the orphan rows still exist — no reconcile / wipe happened
+ assert %{rows: [[count_a]]} =
+ Postgrex.query!(conn2, "select count(*) from realtime.subscription where subscription_id = $1::uuid", [
+ bin_a
+ ])
+
+ assert %{rows: [[count_b]]} =
+ Postgrex.query!(conn2, "select count(*) from realtime.subscription where subscription_id = $1::uuid", [
+ bin_b
+ ])
+
+ assert count_a > 0
+ assert count_b > 0
+ end
+
+ test "the new manager monitors exactly the surviving subscribers", %{
+ pid: pid,
+ args: args,
+ publication: publication
+ } do
+ {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"])
+
+ sub1 = spawn(fn -> receive do: (:stop -> :ok) end)
+ sub2 = spawn_link(fn -> receive do: (:stop -> :ok) end)
+ {_u1, _b1, params1} = pg_change_params()
+ {_u2, _b2, params2} = pg_change_params()
+ assert {:ok, _} = Subscriptions.create(conn, publication, [params1], pid, sub1)
+ assert {:ok, _} = Subscriptions.create(conn, publication, [params2], pid, sub2)
+ :sys.get_state(pid)
+
+ # The original manager monitors both subscribers
+ assert MapSet.subset?(MapSet.new([sub1, sub2]), monitored_pids(pid))
+
+ new_pid = restart_manager(pid, args)
+
+ # After the warm restart the new manager monitors both surviving subscribers, and the old
+ # manager (now dead) no longer holds any monitors
+ assert MapSet.subset?(MapSet.new([sub1, sub2]), monitored_pids(new_pid))
+ refute Process.alive?(pid)
+
+ # The fresh monitors are wired to the new manager: when a subscriber dies, the new manager
+ # drops both its monitor and its ETS entry
+ send(sub1, :stop)
+ Process.monitor(sub1)
+ assert_receive {:DOWN, _, :process, ^sub1, _}, 100
+
+ :sys.get_state(new_pid)
+ monitored = monitored_pids(new_pid)
+ refute MapSet.member?(monitored, sub1)
+ assert MapSet.member?(monitored, sub2)
+ assert :ets.lookup(args["subscribers_pids_table"], sub1) == []
+ end
+ end
+
+ describe "cold start (empty ETS)" do
+ test "deletes all subscriptions from the DB", %{pid: pid, args: args, publication: publication} do
+ {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"])
+
+ subscriber = spawn_link(fn -> receive do: (:stop -> :ok) end)
+ {_uuid, _bin, params} = pg_change_params()
+ assert {:ok, _} = Subscriptions.create(conn, publication, [params], pid, subscriber)
+ :sys.get_state(pid)
+
+ assert %{rows: [[seeded]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ assert seeded > 0
+
+ # Simulate a cold start: a fresh WorkerSupervisor hands the manager brand new, empty ETS
+ # tables. The DB rows from the previous run must be wiped.
+ GenServer.stop(pid)
+ empty_pids = :ets.new(__MODULE__, [:public, :bag])
+ empty_nodes = :ets.new(__MODULE__, [:public, :set])
+
+ cold_args = %{
+ args
+ | "subscribers_pids_table" => empty_pids,
+ "subscribers_nodes_table" => empty_nodes
+ }
+
+ {:ok, new_pid} = SubscriptionManager.start_link(cold_args)
+ :sys.get_state(new_pid)
+
+ {:ok, ^new_pid, conn2} = PostgresCdcRls.get_manager_conn(args["id"])
+ assert %{rows: [[0]]} = Postgrex.query!(conn2, "select count(*) from realtime.subscription", [])
+ end
+ end
+
+ describe "check no users" do
+ test "exit is sent to manager", %{pid: pid} do
+ :sys.replace_state(pid, fn state -> %{state | no_users_ts: 0} end)
+
+ send(pid, :check_no_users)
+
+ assert_receive {:system, {^pid, _}, {:terminate, :shutdown}}
+ end
+ end
+
+ describe "message handling" do
+ setup :set_mimic_global
+
+ test "re-subscribes all subscribers when publication oids change", %{pid: pid, args: args} do
+ # Force state to have different oids so the new_oids branch is triggered when
+ # fetch_publication_tables returns the real oids from the database
+ :sys.replace_state(pid, fn state -> %{state | oids: %{fake: :oids_that_dont_match}} end)
+ :ets.insert(args["subscribers_pids_table"], {self(), UUID.uuid1(), make_ref(), node()})
+
+ send(pid, :check_oids)
+
+ assert_receive :postgres_subscribe, 1000
+ :sys.get_state(pid)
+ # Ensure the state is updated before we check the ETS tables
+ assert :ets.tab2list(args["subscribers_pids_table"]) == []
+ assert :ets.tab2list(args["subscribers_nodes_table"]) == []
+ end
+
+ test "keeps subscribers and oids when :check_oids fetch errors", %{pid: pid, args: args} do
+ old_oids = :sys.get_state(pid).oids
+ :ets.insert(args["subscribers_pids_table"], {self(), UUID.uuid1(), make_ref(), node()})
+
+ # A fetch error must not be mistaken for a publication change: subscribers stay
+ # put and no re-subscribe is triggered.
+ stub(Subscriptions, :fetch_publication_tables, fn _conn, _publication -> {:error, :boom} end)
+
+ send(pid, :check_oids)
+ state = :sys.get_state(pid)
+
+ refute_receive :postgres_subscribe, 200
+ assert state.oids == old_oids
+ assert match?([{_, _, _, _}], :ets.tab2list(args["subscribers_pids_table"]))
+ end
+
+ test "logs error when subscription deletion fails during check_delete_queue", %{
+ pid: pid,
+ args: args,
+ publication: publication
+ } do
+ {:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"])
+ {_uuid, _bin_uuid, pg_change_params} = pg_change_params()
+
+ subscriber = spawn(fn -> receive do: (:stop -> :ok) end)
+ Subscriptions.create(conn, publication, [pg_change_params], pid, subscriber)
+ :sys.get_state(pid)
+
+ stub(Subscriptions, :delete_multi, fn _conn, _ids -> {:error, :delete_failed} end)
+
+ send(subscriber, :stop)
+ Process.sleep(100)
+
+ log =
+ capture_log(fn ->
+ send(pid, :check_delete_queue)
+ :sys.get_state(pid)
+ end)
+
+ assert log =~ "SubscriptionDeletionFailed"
+ end
+
+ test "schedules next region check when rebalancer returns ok", %{pid: pid} do
+ # In a single-node test environment, nodes are equal → Rebalancer returns :ok
+ current_nodes = MapSet.new(Node.list())
+ send(pid, {:check_region, current_nodes})
+ :sys.get_state(pid)
+
+ assert Process.alive?(pid)
+ end
+
+ test "calls handle_stop when wrong region detected", %{pid: pid} do
+ stub(Rebalancer, :check, fn _prev, _curr, _id -> {:error, :wrong_region} end)
+ stub(PostgresCdcRls, :handle_stop, fn _id, _timeout -> :ok end)
+
+ send(pid, {:check_region, MapSet.new()})
+ :sys.get_state(pid)
+
+ assert Process.alive?(pid)
+ end
+ end
+
+ test "handles empty delete queue without crashing", %{pid: pid} do
+ send(pid, :check_delete_queue)
+ state = :sys.get_state(pid)
+ assert :queue.is_empty(state.delete_queue.queue)
+ end
+
+ test "handles unhandled messages without crashing", %{pid: pid} do
+ state_before = :sys.get_state(pid)
+ send(pid, :totally_unexpected_message)
+ state_after = :sys.get_state(pid)
+ assert state_before.id == state_after.id
+ end
+
+ describe "error handling" do
+ setup :set_mimic_global
+
+ test "stops cleanly when database connection fails", %{args: args} do
+ stub(Database, :connect_db, fn _settings -> {:error, :econnrefused} end)
+
+ pid = start_supervised!({SubscriptionManager, args}, restart: :temporary)
+ ref = Process.monitor(pid)
+
+ assert_receive {:DOWN, ^ref, :process, ^pid, {:shutdown, :econnrefused}}, 1000
+ end
+ end
+
+ describe "phantom subscriber cleanup" do
+ test "check_active_pids queues dead pids for deletion", %{
+ pid: pid,
+ args: args
+ } do
+ subscribers_pids_table = args["subscribers_pids_table"]
+ subscribers_nodes_table = args["subscribers_nodes_table"]
+
+ dead_pid = spawn(fn -> :ok end)
+ ref = Process.monitor(dead_pid)
+ receive do: ({:DOWN, ^ref, :process, ^dead_pid, _} -> :ok)
+ u = uuid1()
+ bin_u = string_to_binary!(u)
+
+ :ets.insert(subscribers_pids_table, {dead_pid, u, make_ref(), node()})
+ :ets.insert(subscribers_nodes_table, {bin_u, node()})
+
+ send(pid, :check_active_pids)
+ state = :sys.get_state(pid)
+
+ assert not :queue.is_empty(state.delete_queue.queue)
+ end
+ end
+
+ describe "subscribers_by_node/1" do
+ test "groups subscriber pids by node" do
+ subscribers_pids_table = :ets.new(:table, [:public, :bag])
+
+ test_data = [
+ {:pid1, "id1", :ref, :node1},
+ {:pid1, "id1.2", :ref, :node1},
+ {:pid2, "id2", :ref, :node2}
+ ]
+
+ :ets.insert(subscribers_pids_table, test_data)
+
+ assert SubscriptionManager.subscribers_by_node(subscribers_pids_table) == %{
+ node1: MapSet.new([:pid1]),
+ node2: MapSet.new([:pid2])
+ }
+ end
+ end
+
+ describe "not_alive_pids/1" do
+ test "returns empty list for empty input" do
+ assert SubscriptionManager.not_alive_pids(MapSet.new()) == []
+ end
+
+ test "returns empty list for all alive PIDs" do
+ pid1 = spawn(fn -> Process.sleep(5000) end)
+ pid2 = spawn(fn -> Process.sleep(5000) end)
+ pid3 = spawn(fn -> Process.sleep(5000) end)
+ assert SubscriptionManager.not_alive_pids(MapSet.new([pid1, pid2, pid3])) == []
+ end
+
+ test "returns list of dead PIDs" do
+ pid1 = spawn(fn -> Process.sleep(5000) end)
+ pid2 = spawn(fn -> Process.sleep(5000) end)
+ pid3 = spawn(fn -> Process.sleep(5000) end)
+ Process.exit(pid2, :kill)
+ assert SubscriptionManager.not_alive_pids(MapSet.new([pid1, pid2, pid3])) == [pid2]
+ end
+ end
+
+ describe "pop_not_alive_pids/4" do
+ test "one subscription per channel" do
+ subscribers_pids_table = :ets.new(:table, [:public, :bag])
+ subscribers_nodes_table = :ets.new(:table, [:public, :set])
+
+ uuid1 = uuid1()
+ uuid2 = uuid1()
+ uuid3 = uuid1()
+
+ pids_test_data = [
+ {:pid1, uuid1, :ref, :node1},
+ {:pid1, uuid2, :ref, :node1},
+ {:pid2, uuid3, :ref, :node2}
+ ]
+
+ :ets.insert(subscribers_pids_table, pids_test_data)
+
+ nodes_test_data = [
+ {string_to_binary!(uuid1), :node1},
+ {string_to_binary!(uuid2), :node1},
+ {string_to_binary!(uuid3), :node2}
+ ]
+
+ :ets.insert(subscribers_nodes_table, nodes_test_data)
+
+ not_alive =
+ Enum.sort(
+ SubscriptionManager.pop_not_alive_pids([:pid1], subscribers_pids_table, subscribers_nodes_table, "id")
+ )
+
+ expected = Enum.sort([string_to_binary!(uuid1), string_to_binary!(uuid2)])
+ assert not_alive == expected
+
+ assert :ets.tab2list(subscribers_pids_table) == [{:pid2, uuid3, :ref, :node2}]
+ assert :ets.tab2list(subscribers_nodes_table) == [{string_to_binary!(uuid3), :node2}]
+ end
+
+ test "two subscriptions per channel" do
+ subscribers_pids_table = :ets.new(:table, [:public, :bag])
+ subscribers_nodes_table = :ets.new(:table, [:public, :set])
+
+ uuid1 = uuid1()
+ uuid2 = uuid1()
+
+ test_data = [
+ {:pid1, uuid1, :ref, :node1},
+ {:pid2, uuid2, :ref, :node2}
+ ]
+
+ :ets.insert(subscribers_pids_table, test_data)
+
+ nodes_test_data = [
+ {string_to_binary!(uuid1), :node1},
+ {string_to_binary!(uuid2), :node2}
+ ]
+
+ :ets.insert(subscribers_nodes_table, nodes_test_data)
+
+ assert SubscriptionManager.pop_not_alive_pids([:pid1], subscribers_pids_table, subscribers_nodes_table, "id") == [
+ string_to_binary!(uuid1)
+ ]
+
+ assert :ets.tab2list(subscribers_pids_table) == [{:pid2, uuid2, :ref, :node2}]
+ assert :ets.tab2list(subscribers_nodes_table) == [{string_to_binary!(uuid2), :node2}]
+ end
+
+ test "returns empty list when pid not found in table" do
+ subscribers_pids_table = :ets.new(:table, [:public, :bag])
+ subscribers_nodes_table = :ets.new(:table, [:public, :set])
+
+ assert SubscriptionManager.pop_not_alive_pids(
+ [:nonexistent_pid],
+ subscribers_pids_table,
+ subscribers_nodes_table,
+ "tenant_id"
+ ) == []
+ end
+ end
+
+ describe "not_alive_pids_dist/1" do
+ setup :set_mimic_global
+
+ test "handles remote node RPC error gracefully" do
+ remote_node = :some_remote@node
+
+ stub(GenRpc, :call, fn ^remote_node, SubscriptionManager, :not_alive_pids, _pids, _opts ->
+ {:error, :rpc_error, :timeout}
+ end)
+
+ log =
+ capture_log(fn ->
+ result = SubscriptionManager.not_alive_pids_dist(%{remote_node => MapSet.new([self()])})
+ assert result == []
+ end)
+
+ assert log =~ "UnableToCheckProcessesOnRemoteNode"
+ end
+
+ test "returns pids from remote node when RPC succeeds" do
+ remote_node = :some_remote@node
+ dead_pid = self()
+
+ stub(GenRpc, :call, fn ^remote_node, SubscriptionManager, :not_alive_pids, [pids_set], _opts ->
+ MapSet.to_list(pids_set)
+ end)
+
+ result = SubscriptionManager.not_alive_pids_dist(%{remote_node => MapSet.new([dead_pid])})
+ assert dead_pid in result
+ end
+
+ test "checks local pids directly without RPC" do
+ dead_pid = spawn(fn -> :ok end)
+ ref = Process.monitor(dead_pid)
+ receive do: ({:DOWN, ^ref, :process, ^dead_pid, _} -> :ok)
+
+ result = SubscriptionManager.not_alive_pids_dist(%{node() => MapSet.new([dead_pid])})
+ assert dead_pid in result
+ end
+ end
+
+ # Simulates a SubscriptionManager-only restart: the ETS tables in `args` are owned by the test
+ # process (acting as the WorkerSupervisor) and survive, so the new manager re-adopts them.
+ defp restart_manager(pid, args) do
+ GenServer.stop(pid)
+ {:ok, new_pid} = SubscriptionManager.start_link(args)
+ :sys.get_state(new_pid)
+ new_pid
+ end
+
+ # The set of processes `pid` is currently monitoring.
+ defp monitored_pids(pid) do
+ {:monitors, monitors} = Process.info(pid, :monitors)
+ for {:process, monitored} <- monitors, into: MapSet.new(), do: monitored
+ end
+
+ defp pg_change_params do
+ uuid = UUID.uuid1()
+
+ pg_change_params = %{
+ id: uuid,
+ subscription_params: {"*", "public", "*", [], nil},
+ claims: %{
+ "exp" => System.system_time(:second) + 100_000,
+ "iat" => 0,
+ "role" => "anon"
+ }
+ }
+
+ {uuid, UUID.string_to_binary!(uuid), pg_change_params}
+ end
+end
diff --git a/test/realtime/extensions/cdc_rls/subscriptions_checker_test.exs b/test/realtime/extensions/cdc_rls/subscriptions_checker_test.exs
deleted file mode 100644
index bfbb4bd7a..000000000
--- a/test/realtime/extensions/cdc_rls/subscriptions_checker_test.exs
+++ /dev/null
@@ -1,80 +0,0 @@
-defmodule SubscriptionsCheckerTest do
- use ExUnit.Case, async: true
- alias Extensions.PostgresCdcRls.SubscriptionsChecker, as: Checker
-
- test "subscribers_by_node/1" do
- tid = :ets.new(:table, [:public, :bag])
-
- test_data = [
- {:pid1, "id1", :ref, :node1},
- {:pid1, "id1.2", :ref, :node1},
- {:pid2, "id2", :ref, :node2}
- ]
-
- :ets.insert(tid, test_data)
-
- assert Checker.subscribers_by_node(tid) == %{
- node1: MapSet.new([:pid1]),
- node2: MapSet.new([:pid2])
- }
- end
-
- describe "not_alive_pids/1" do
- test "returns empty list for empty input" do
- assert Checker.not_alive_pids(MapSet.new()) == []
- end
-
- test "returns empty list for all alive PIDs" do
- pid1 = spawn(fn -> Process.sleep(5000) end)
- pid2 = spawn(fn -> Process.sleep(5000) end)
- pid3 = spawn(fn -> Process.sleep(5000) end)
- assert Checker.not_alive_pids(MapSet.new([pid1, pid2, pid3])) == []
- end
-
- test "returns list of dead PIDs" do
- pid1 = spawn(fn -> Process.sleep(5000) end)
- pid2 = spawn(fn -> Process.sleep(5000) end)
- pid3 = spawn(fn -> Process.sleep(5000) end)
- Process.exit(pid2, :kill)
- assert Checker.not_alive_pids(MapSet.new([pid1, pid2, pid3])) == [pid2]
- end
- end
-
- describe "pop_not_alive_pids/2" do
- test "one subscription per channel" do
- tid = :ets.new(:table, [:public, :bag])
-
- uuid1 = UUID.uuid1()
- uuid2 = UUID.uuid1()
-
- test_data = [
- {:pid1, uuid1, :ref, :node1},
- {:pid1, uuid2, :ref, :node1},
- {:pid2, "uuid", :ref, :node2}
- ]
-
- :ets.insert(tid, test_data)
-
- not_alive = Enum.sort(Checker.pop_not_alive_pids([:pid1], tid, "id"))
- expected = Enum.sort([UUID.string_to_binary!(uuid1), UUID.string_to_binary!(uuid2)])
- assert not_alive == expected
-
- assert :ets.tab2list(tid) == [{:pid2, "uuid", :ref, :node2}]
- end
-
- test "two subscriptions per channel" do
- tid = :ets.new(:table, [:public, :bag])
-
- uuid1 = UUID.uuid1()
-
- test_data = [
- {:pid1, uuid1, :ref, :node1},
- {:pid2, "uuid", :ref, :node2}
- ]
-
- :ets.insert(tid, test_data)
- assert Checker.pop_not_alive_pids([:pid1], tid, "id") == [UUID.string_to_binary!(uuid1)]
- assert :ets.tab2list(tid) == [{:pid2, "uuid", :ref, :node2}]
- end
- end
-end
diff --git a/test/realtime/extensions/cdc_rls/subscriptions_test.exs b/test/realtime/extensions/cdc_rls/subscriptions_test.exs
index cb53b72ed..e51c43bf7 100644
--- a/test/realtime/extensions/cdc_rls/subscriptions_test.exs
+++ b/test/realtime/extensions/cdc_rls/subscriptions_test.exs
@@ -1,121 +1,829 @@
-defmodule Realtime.Extensionsubscriptions.CdcRlsSubscriptionsTest do
+defmodule Realtime.Extensions.PostgresCdcRls.SubscriptionsTest do
use RealtimeWeb.ChannelCase, async: true
- doctest Extensions.PostgresCdcRls.Subscriptions
+
+ doctest Extensions.PostgresCdcRls.Subscriptions, import: true
+
+ import ExUnit.CaptureLog
alias Extensions.PostgresCdcRls.Subscriptions
alias Realtime.Database
- alias Realtime.Tenants
setup do
- tenant = Tenants.get_tenant_by_external_id("dev_tenant")
+ tenant = Containers.checkout_tenant(run_migrations: true)
+
+ {:ok, db_settings} = Database.from_tenant(tenant, "realtime_rls")
{:ok, conn} =
- tenant
- |> Database.from_tenant("realtime_rls")
+ db_settings
|> Map.from_struct()
|> Keyword.new()
|> Postgrex.start_link()
- %{conn: conn}
- end
-
- test "create", %{conn: conn} do
+ Integrations.setup_postgres_changes(conn)
Subscriptions.delete_all(conn)
-
assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
- params_list = [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), params: %{"event" => "*", "schema" => "public"}}]
-
- assert {:ok, [%Postgrex.Result{}]} =
- Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self())
-
- Process.sleep(500)
+ %{conn: conn, tenant: tenant}
+ end
- params_list = [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), params: %{"schema" => "public", "table" => "test"}}]
+ describe "subscribing with row filters" do
+ test "user can combine two range conditions to create a bounded filter" do
+ assert {:ok, {"*", "public", "test", filters, _}} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => "id=gt.0,id=lt.100"
+ })
+
+ assert [{"id", "gt", "0"}, {"id", "lt", "100"}] = Enum.sort(filters)
+ end
+
+ test "user gets a clear error when one filter in a multi-filter expression is unsupported" do
+ assert {:error, msg} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => "id=gt.0,id=like.100"
+ })
+
+ assert msg =~ "Error parsing `filter` params"
+ end
+
+ test "user can omit the filter value entirely to subscribe to all rows" do
+ assert {:ok, {"*", "public", "test", [], _}} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => ""
+ })
+ end
+
+ test "user can filter by a single equality condition" do
+ assert {:ok, {"*", "public", "test", [{"id", "eq", "5"}], _}} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => "id=eq.5"
+ })
+ end
+
+ test "user can combine an in-list filter with an equality filter" do
+ assert {:ok, {"*", "public", "test", filters, _}} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => "id=in.(1,2,3),details=eq.active"
+ })
+
+ assert [{"details", "eq", "active"}, {"id", "in", "{1,2,3}"}] = Enum.sort(filters)
+ end
+
+ test "user can use an in-list filter with multi-word string values alongside another filter" do
+ assert {:ok, {"*", "public", "test", filters, _}} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => "name=in.(red,blue),quantity=gt.0"
+ })
+
+ assert [{"name", "in", "{red,blue}"}, {"quantity", "gt", "0"}] = filters
+ end
+
+ test "user can place an in-list filter after a range filter" do
+ assert {:ok, {"*", "public", "test", filters, _}} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => "quantity=gt.0,name=in.(red,blue)"
+ })
+
+ assert [{"quantity", "gt", "0"}, {"name", "in", "{red,blue}"}] = filters
+ end
+
+ test "user can combine two in-list filters each with multiple values" do
+ assert {:ok, {"*", "public", "test", filters, _}} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => "name=in.(red,blue,green),status=in.(active,inactive)"
+ })
+
+ assert [{"name", "in", "{red,blue,green}"}, {"status", "in", "{active,inactive}"}] = filters
+ end
+
+ test "user can use filter values that contain a closing parenthesis character" do
+ assert {:ok, {"*", "public", "test", filters, _}} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => "a=eq.x),b=eq.y),c=eq.z"
+ })
+
+ assert [{"a", "eq", "x)"}, {"b", "eq", "y)"}, {"c", "eq", "z"}] = filters
+ end
+
+ test "user gets a clear error when the filter string ends with a stray comma" do
+ assert {:error, msg} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => "id=gt.0,"
+ })
+
+ assert msg =~ "empty segments"
+ end
+
+ test "user gets a clear error when the filter string starts with a stray comma" do
+ assert {:error, msg} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => ",id=gt.0"
+ })
+
+ assert msg =~ "empty segments"
+ end
+
+ test "user gets a clear error when two commas appear back-to-back in a filter string" do
+ assert {:error, msg} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => "a=eq.1,,b=eq.2"
+ })
+
+ assert msg =~ "empty segments"
+ end
+
+ test "whitespace-only filter string is treated the same as no filter" do
+ assert {:ok, {"*", "public", "test", [], _}} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => " "
+ })
+ end
+ end
- assert {:ok, [%Postgrex.Result{}]} =
- Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self())
+ describe "subscribing to table changes" do
+ test "user can subscribe to all events on all tables in a schema", %{conn: conn} do
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{"event" => "*", "schema" => "public"})
+
+ params_list = [
+ %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}
+ ]
+
+ assert {:ok, [%Postgrex.Result{}]} =
+ Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self())
+
+ assert %Postgrex.Result{rows: rows} =
+ Postgrex.query!(conn, "select filters, action_filter from realtime.subscription", [])
+
+ assert rows != []
+ assert Enum.all?(rows, &match?([[], "*"], &1))
+ end
+
+ test "create with filter on valid column succeeds", %{conn: conn} do
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => "id=eq.123"
+ })
+
+ params_list = [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}]
+
+ assert {:ok, [%Postgrex.Result{}]} =
+ Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self())
+
+ assert %Postgrex.Result{
+ rows: [
+ [
+ "test",
+ [{"id", "eq", "123"}],
+ "*"
+ ]
+ ]
+ } =
+ Postgrex.query!(
+ conn,
+ "select entity::text, filters, action_filter from realtime.subscription",
+ []
+ )
+ end
+
+ test "subscription works when role lacks usage permission", %{conn: conn, tenant: tenant} do
+ {:ok, admin_settings} = Database.from_tenant(tenant, "realtime_test", :stop)
+
+ {:ok, admin_conn} =
+ Postgrex.start_link(
+ hostname: admin_settings.hostname,
+ port: admin_settings.port,
+ database: admin_settings.database,
+ username: "supabase_admin",
+ password: admin_settings.password
+ )
+
+ Postgrex.query!(admin_conn, "CREATE SCHEMA IF NOT EXISTS vault", [])
+ Postgrex.query!(admin_conn, "REVOKE USAGE ON SCHEMA vault FROM supabase_realtime_admin", [])
+
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => "id=eq.1"
+ })
+
+ params_list = [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}]
+
+ assert {:ok, [%Postgrex.Result{}]} =
+ Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self())
+ end
+
+ test "user can subscribe to only INSERT events", %{conn: conn} do
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{"event" => "INSERT", "schema" => "public"})
+
+ params_list = [
+ %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}
+ ]
+
+ assert {:ok, [%Postgrex.Result{}]} =
+ Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self())
+
+ assert %Postgrex.Result{rows: rows} =
+ Postgrex.query!(conn, "select filters, action_filter from realtime.subscription", [])
+
+ assert rows != []
+ assert Enum.all?(rows, &match?([[], "INSERT"], &1))
+ end
+
+ test "user can subscribe to a specific table", %{conn: conn} do
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{"schema" => "public", "table" => "test"})
+
+ subscription_list = [
+ %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}
+ ]
+
+ assert {:ok, [%Postgrex.Result{}]} =
+ Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self())
+
+ %Postgrex.Result{rows: [[1]]} =
+ Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ end
+
+ test "create works for a table whose name contains a backslash", %{conn: conn} do
+ Postgrex.query!(conn, ~s|CREATE TABLE "my\\table" (id int)|, [])
+ Postgrex.query!(conn, ~s|GRANT ALL ON "my\\table" TO anon|, [])
+ Postgrex.query!(conn, ~s|ALTER PUBLICATION supabase_realtime_test ADD TABLE "my\\table"|, [])
+
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{"schema" => "public", "table" => "my\\table"})
+
+ subscription_list = [
+ %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}
+ ]
+
+ assert {:ok, [%Postgrex.Result{num_rows: 1}]} =
+ Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self())
+ end
+
+ test "user gets an error when Realtime is not enabled for the publication", %{conn: conn} do
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{"schema" => "public", "table" => "test"})
+
+ subscription_list = [
+ %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}
+ ]
+
+ Postgrex.query!(conn, "drop publication if exists supabase_realtime_test", [])
+
+ assert {:error,
+ {:subscription_insert_failed,
+ "Unable to subscribe to changes with given parameters. Please check Realtime is enabled for the given connect parameters: [event: *, schema: public, table: test, filters: [], select: nil]"}} =
+ Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self())
+
+ %Postgrex.Result{rows: [[0]]} =
+ Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ end
+
+ test "user gets an error when subscribing to a table that does not exist", %{conn: conn} do
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "doesnotexist"
+ })
+
+ subscription_list = [
+ %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}
+ ]
+
+ assert {:error,
+ {:subscription_insert_failed,
+ "Unable to subscribe to changes with given parameters. Please check Realtime is enabled for the given connect parameters: [event: *, schema: public, table: doesnotexist, filters: [], select: nil]"}} =
+ Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self())
+
+ %Postgrex.Result{rows: [[0]]} =
+ Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ end
+
+ test "user gets an error when filtering on a column that does not exist", %{conn: conn} do
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => "subject=eq.hey"
+ })
+
+ subscription_list = [
+ %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}
+ ]
+
+ assert {:error,
+ {:subscription_insert_failed,
+ "Unable to subscribe to changes with given parameters. An exception happened so please check your connect parameters: [event: *, schema: public, table: test, filters: [{\"subject\", \"eq\", \"hey\"}], select: nil]. Exception: ERROR P0001 (raise_exception) invalid column for filter subject"}} =
+ Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self())
+
+ %Postgrex.Result{rows: [[0]]} =
+ Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ end
+
+ test "user gets an error when filter value is incompatible with column type", %{conn: conn} do
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => "id=eq.hey"
+ })
+
+ subscription_list = [
+ %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}
+ ]
+
+ assert {:error,
+ {:subscription_insert_failed,
+ "Unable to subscribe to changes with given parameters. An exception happened so please check your connect parameters: [event: *, schema: public, table: test, filters: [{\"id\", \"eq\", \"hey\"}], select: nil]. Exception: ERROR 22P02 (invalid_text_representation) invalid input syntax for type integer: \"hey\""}} =
+ Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self())
+
+ %Postgrex.Result{rows: [[0]]} =
+ Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ end
+
+ test "subscription creation fails gracefully when database connection is dead" do
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{"schema" => "public", "table" => "test"})
+
+ subscription_list = [
+ %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}
+ ]
+
+ conn = spawn(fn -> :ok end)
+
+ assert {:error, {:exit, _}} =
+ Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self())
+ end
+
+ test "subscription creation fails gracefully when the connection pool is exhausted", %{
+ conn: conn
+ } do
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{"schema" => "public", "table" => "test"})
+
+ Task.start(fn -> Postgrex.query!(conn, "SELECT pg_sleep(11)", []) end)
+
+ subscription_list = [
+ %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}
+ ]
+
+ assert {:error, %DBConnection.ConnectionError{reason: :queue_timeout}} =
+ Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self())
+ end
+
+ test "user gets an error when table param is not a string" do
+ {:error, msg} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => %{"actually a" => "map"}
+ })
+
+ assert msg =~ "No subscription params provided"
+ end
+
+ test "user gets an error when schema param is not a string" do
+ {:error, msg} =
+ Subscriptions.parse_subscription_params(%{
+ "table" => "images",
+ "schema" => %{"actually a" => "map"}
+ })
+
+ assert msg =~ "No subscription params provided"
+ end
+
+ test "user gets an error when filter param is not a string" do
+ {:error, msg} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "images",
+ "filter" => [123]
+ })
+
+ assert msg =~ "No subscription params provided"
+ end
+
+ test "user can combine AND row filters which are all stored in the subscription", %{
+ conn: conn
+ } do
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => "id=gt.0,id=lt.100"
+ })
+
+ params_list = [
+ %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}
+ ]
+
+ assert {:ok, [%Postgrex.Result{}]} =
+ Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self())
+
+ assert %Postgrex.Result{rows: [[filters]]} =
+ Postgrex.query!(conn, "select filters from realtime.subscription", [])
+
+ assert [_, _] = filters
+ end
+ end
- Process.sleep(500)
+ describe "delete_all/1" do
+ test "delete_all", %{conn: conn} do
+ create_subscriptions(conn, 10)
+ assert :ok = Subscriptions.delete_all(conn)
+ assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ end
- params_list = [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), params: %{}}]
+ test "returns ok when connection is unavailable" do
+ conn = spawn(fn -> :ok end)
+ assert :ok = Subscriptions.delete_all(conn)
+ end
- assert {:error,
- "No subscription params provided. Please provide at least a `schema` or `table` to subscribe to: %{}"} =
- Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self())
+ test "logs error when subscription table is dropped", %{conn: conn} do
+ Postgrex.query!(conn, "drop table if exists realtime.subscription cascade", [])
- Process.sleep(500)
+ log = capture_log(fn -> Subscriptions.delete_all(conn) end)
+ assert log =~ "SubscriptionDeletionFailed"
+ end
+ end
- params_list = [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), params: %{"user_token" => "potato"}}]
+ describe "delete/2" do
+ test "returns error when subscription table is dropped", %{conn: conn} do
+ Postgrex.query!(conn, "drop table if exists realtime.subscription cascade", [])
- assert {:error,
- "No subscription params provided. Please provide at least a `schema` or `table` to subscribe to: "} =
- Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self())
+ assert {:error, %Postgrex.Error{}} = Subscriptions.delete(conn, UUID.string_to_binary!(UUID.uuid1()))
+ end
- Process.sleep(500)
+ test "delete", %{conn: conn} do
+ id = UUID.uuid1()
+ bin_id = UUID.string_to_binary!(id)
- params_list = [%{claims: %{"role" => "anon"}, id: UUID.uuid1(), params: %{"auth_token" => "potato"}}]
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => "id=eq.hey"
+ })
- assert {:error,
- "No subscription params provided. Please provide at least a `schema` or `table` to subscribe to: "} =
- Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self())
+ subscription_list = [%{claims: %{"role" => "anon"}, id: id, subscription_params: subscription_params}]
+ Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self())
- Process.sleep(500)
+ assert {:ok, %Postgrex.Result{}} = Subscriptions.delete(conn, bin_id)
+ assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ end
- %Postgrex.Result{rows: [[num]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
- assert num != 0
+ test "returns error when connection is unavailable" do
+ conn = spawn(fn -> :ok end)
+ assert {:error, _} = Subscriptions.delete(conn, UUID.uuid1())
+ end
end
- test "delete_all", %{conn: conn} do
- create_subscriptions(conn, 10)
- assert {:ok, %Postgrex.Result{}} = Subscriptions.delete_all(conn)
- assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ describe "delete_multi/2" do
+ test "delete_multi", %{conn: conn} do
+ Subscriptions.delete_all(conn)
+ id1 = UUID.uuid1()
+ id2 = UUID.uuid1()
+
+ bin_id2 = UUID.string_to_binary!(id2)
+ bin_id1 = UUID.string_to_binary!(id1)
+
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "filter" => "id=eq.123"
+ })
+
+ subscription_list = [
+ %{claims: %{"role" => "anon"}, id: id1, subscription_params: subscription_params},
+ %{claims: %{"role" => "anon"}, id: id2, subscription_params: subscription_params}
+ ]
+
+ assert {:ok, _} = Subscriptions.create(conn, "supabase_realtime_test", subscription_list, self(), self())
+
+ assert %Postgrex.Result{rows: [[2]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ assert {:ok, %Postgrex.Result{}} = Subscriptions.delete_multi(conn, [bin_id1, bin_id2])
+ assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ end
end
- test "delete", %{conn: conn} do
- Subscriptions.delete_all(conn)
- id = UUID.uuid1()
- bin_id = UUID.string_to_binary!(id)
+ describe "delete_all_if_table_exists/1" do
+ test "delete_all_if_table_exists", %{conn: conn} do
+ Subscriptions.delete_all(conn)
+ create_subscriptions(conn, 10)
+
+ assert :ok = Subscriptions.delete_all_if_table_exists(conn)
+ assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ end
+
+ test "logs error when trigger raises on delete", %{conn: conn, tenant: tenant} do
+ create_subscriptions(conn, 3)
+
+ Postgrex.query!(
+ conn,
+ """
+ create or replace function realtime.evil_delete_trigger()
+ returns trigger language plpgsql as $$
+ begin raise exception 'evil trigger'; end;
+ $$;
+ """,
+ []
+ )
+
+ Postgrex.query!(
+ conn,
+ """
+ create trigger evil_delete_trigger
+ before delete on realtime.subscription
+ for each row execute function realtime.evil_delete_trigger();
+ """,
+ []
+ )
+
+ on_exit(fn ->
+ {:ok, db_settings} = Database.from_tenant(tenant, "realtime_rls")
+
+ {:ok, cleanup_conn} =
+ db_settings
+ |> Map.from_struct()
+ |> Keyword.new()
+ |> Postgrex.start_link()
+
+ Postgrex.query(cleanup_conn, "drop trigger if exists evil_delete_trigger on realtime.subscription", [])
+ Postgrex.query(cleanup_conn, "drop function if exists realtime.evil_delete_trigger()", [])
+ GenServer.stop(cleanup_conn)
+ end)
- params_list = [%{id: id, claims: %{"role" => "anon"}, params: %{"event" => "*"}}]
- Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self())
- Process.sleep(500)
+ log = capture_log(fn -> Subscriptions.delete_all_if_table_exists(conn) end)
+ assert log =~ "SubscriptionCleanupFailed"
+ end
- assert {:ok, %Postgrex.Result{}} = Subscriptions.delete(conn, bin_id)
- assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ test "logs error when connection is dead" do
+ conn = spawn(fn -> :ok end)
+ log = capture_log(fn -> Subscriptions.delete_all_if_table_exists(conn) end)
+ assert log =~ "SubscriptionCleanupFailed"
+ end
end
- test "delete_multi", %{conn: conn} do
- Subscriptions.delete_all(conn)
- id1 = UUID.uuid1()
- id2 = UUID.uuid1()
-
- bin_id2 = UUID.string_to_binary!(id2)
- bin_id1 = UUID.string_to_binary!(id1)
+ describe "fetch_publication_tables/2" do
+ test "returns {:ok, tables} for an existing publication", %{conn: conn} do
+ assert {:ok, tables} = Subscriptions.fetch_publication_tables(conn, "supabase_realtime_test")
+ assert tables[{"*"}] != nil
+ end
- params_list = [
- %{claims: %{"role" => "anon"}, id: id1, params: %{"event" => "*"}},
- %{claims: %{"role" => "anon"}, id: id2, params: %{"event" => "*"}}
- ]
+ test "returns {:ok, %{}} for a publication with no tables", %{conn: conn} do
+ assert {:ok, %{}} = Subscriptions.fetch_publication_tables(conn, "non_existing_publication")
+ end
- Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self())
- Process.sleep(500)
-
- assert {:ok, %Postgrex.Result{}} = Subscriptions.delete_multi(conn, [bin_id1, bin_id2])
- assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ test "returns {:error, _} when the query fails", %{conn: conn} do
+ GenServer.stop(conn)
+ assert {:error, _reason} = Subscriptions.fetch_publication_tables(conn, "supabase_realtime_test")
+ end
end
- test "maybe_delete_all", %{conn: conn} do
- Subscriptions.delete_all(conn)
- create_subscriptions(conn, 10)
-
- assert {:ok, %Postgrex.Result{}} = Subscriptions.maybe_delete_all(conn)
- assert %Postgrex.Result{rows: [[0]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ describe "existing subscriptions without column selection continue to receive full payloads" do
+ test "omitting select returns all columns (no behavior change for existing clients)" do
+ assert {:ok, {"*", "public", "messages", [], nil}} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "messages"
+ })
+ end
+
+ test "passing an empty select list is treated as no column selection" do
+ assert {:ok, {"*", "public", "messages", [], nil}} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "messages",
+ "select" => []
+ })
+ end
+
+ test "subscription without select stores NULL in the database (no column restriction)", %{
+ conn: conn
+ } do
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{"schema" => "public", "table" => "test"})
+
+ params_list = [
+ %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}
+ ]
+
+ assert {:ok, [%Postgrex.Result{}]} =
+ Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self())
+
+ assert %Postgrex.Result{rows: [[nil]]} =
+ Postgrex.query!(conn, "select selected_columns from realtime.subscription", [])
+ end
+
+ test "apply_rls returns all columns in the payload when no column selection is set", %{
+ conn: conn
+ } do
+ sub_id = UUID.uuid1()
+ slot_name = "test_apply_rls_no_select_#{:rand.uniform(999_999)}"
+
+ Postgrex.query!(
+ conn,
+ "insert into realtime.subscription (subscription_id, entity, claims) values ($1::text::uuid, 'public.test'::regclass, $2)",
+ [sub_id, %{"role" => "anon"}]
+ )
+
+ Postgrex.query!(conn, "SELECT pg_create_logical_replication_slot($1, 'wal2json')", [slot_name])
+
+ try do
+ Postgrex.query!(conn, "insert into test (details) values ('hello')", [])
+
+ %{rows: rows} =
+ Postgrex.query!(
+ conn,
+ "select wal, subscription_ids from realtime.list_changes($1, $2, 100, 1048576)",
+ ["supabase_realtime_test", slot_name]
+ )
+
+ # apply_rls stores subscription_ids as binary UUIDs
+ bin_sub_id = UUID.string_to_binary!(sub_id)
+ matching = Enum.find(rows, fn [_wal, sub_ids] -> bin_sub_id in (sub_ids || []) end)
+ assert matching != nil, "Expected sub_id in list_changes result. rows=#{inspect(rows)}"
+ [wal_result, _] = matching
+ assert Map.has_key?(wal_result["record"], "id")
+ assert Map.has_key?(wal_result["record"], "details")
+ after
+ Postgrex.query(conn, "SELECT pg_drop_replication_slot($1)", [slot_name])
+ end
+ end
end
- test "fetch_publication_tables", %{conn: conn} do
- tables = Subscriptions.fetch_publication_tables(conn, "supabase_realtime_test")
- assert tables[{"*"}] != nil
+ describe "subscribing with column selection (select param)" do
+ test "user can pass a list of column names to limit the payload" do
+ assert {:ok, {"*", "public", "messages", [], ["id", "details"]}} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "messages",
+ "select" => ["id", "details"]
+ })
+ end
+
+ test "passing a string to select is rejected with a clear error message" do
+ assert {:error, msg} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "messages",
+ "select" => "id,details"
+ })
+
+ assert msg =~ "`select`"
+ end
+
+ test "non-binary entries in a select list are silently dropped" do
+ assert {:ok, {"*", "public", "messages", [], ["id", "details"]}} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "messages",
+ "select" => ["id", 123, "details", nil]
+ })
+ end
+
+ test "passing any string value to select is rejected" do
+ assert {:error, msg} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "messages",
+ "select" => ""
+ })
+
+ assert msg =~ "`select`"
+ end
+
+ test "user can combine column selection with a row filter" do
+ assert {:ok, {"*", "public", "messages", [{"id", "eq", "5"}], ["id", "details"]}} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "messages",
+ "filter" => "id=eq.5",
+ "select" => ["id", "details"]
+ })
+ end
+
+ test "selected columns are stored in normalized (sorted) order in the database", %{
+ conn: conn
+ } do
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "select" => ["details", "id"]
+ })
+
+ params_list = [
+ %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}
+ ]
+
+ assert {:ok, [%Postgrex.Result{}]} =
+ Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self())
+
+ assert %Postgrex.Result{rows: [[selected_columns]]} =
+ Postgrex.query!(conn, "select selected_columns from realtime.subscription", [])
+
+ assert ["details", "id"] = Enum.sort(selected_columns)
+ end
+
+ test "two subscriptions on the same table with different column selections are stored as separate rows",
+ %{conn: conn} do
+ id = UUID.uuid1()
+
+ {:ok, params1} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "select" => ["id"]
+ })
+
+ {:ok, params2} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "select" => ["id", "details"]
+ })
+
+ params_list = [
+ %{claims: %{"role" => "anon"}, id: id, subscription_params: params1},
+ %{claims: %{"role" => "anon"}, id: id, subscription_params: params2}
+ ]
+
+ assert {:ok, [%Postgrex.Result{}, %Postgrex.Result{}]} =
+ Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self())
+
+ assert %Postgrex.Result{rows: [[2]]} =
+ Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
+ end
+
+ test "user gets an error when select references a column that does not exist", %{conn: conn} do
+ {:ok, subscription_params} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "test",
+ "select" => ["nonexistent_column"]
+ })
+
+ params_list = [
+ %{claims: %{"role" => "anon"}, id: UUID.uuid1(), subscription_params: subscription_params}
+ ]
+
+ assert {:error, {:subscription_insert_failed, msg}} =
+ Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self())
+
+ assert msg =~ "invalid column for select nonexistent_column"
+ end
+
+ test "user gets an error when using select with a schema-only (wildcard table) subscription" do
+ assert {:error, msg} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "select" => ["id"]
+ })
+
+ assert msg =~ "wildcard"
+ end
+
+ test "user gets an error when using select with an explicit wildcard table" do
+ assert {:error, msg} =
+ Subscriptions.parse_subscription_params(%{
+ "schema" => "public",
+ "table" => "*",
+ "select" => ["id"]
+ })
+
+ assert msg =~ "wildcard"
+ end
end
defp create_subscriptions(conn, num) do
@@ -131,13 +839,12 @@ defmodule Realtime.Extensionsubscriptions.CdcRlsSubscriptionsTest do
"role" => "anon"
},
id: UUID.uuid1(),
- params: %{"event" => "*", "schema" => "public"}
+ subscription_params: {"*", "public", "*", [], nil}
}
| acc
]
end)
Subscriptions.create(conn, "supabase_realtime_test", params_list, self(), self())
- Process.sleep(500)
end
end
diff --git a/test/realtime/feature_flags_test.exs b/test/realtime/feature_flags_test.exs
new file mode 100644
index 000000000..b2d069c8e
--- /dev/null
+++ b/test/realtime/feature_flags_test.exs
@@ -0,0 +1,71 @@
+defmodule Realtime.FeatureFlagsTest do
+ use Realtime.DataCase, async: false
+
+ alias Realtime.Api
+ alias Realtime.FeatureFlags
+ alias Realtime.FeatureFlags.Cache
+ alias Realtime.Tenants.Cache, as: TenantsCache
+
+ setup do
+ Cachex.clear(Cache)
+ Cachex.clear(TenantsCache)
+ :ok
+ end
+
+ describe "enabled?/1" do
+ test "returns false when flag does not exist" do
+ refute FeatureFlags.enabled?("missing_flag")
+ end
+
+ test "returns false when flag is disabled" do
+ {:ok, _} = Api.upsert_feature_flag(%{name: "off_flag", enabled: false})
+ refute FeatureFlags.enabled?("off_flag")
+ end
+
+ test "returns true when flag is enabled" do
+ {:ok, _} = Api.upsert_feature_flag(%{name: "on_flag", enabled: true})
+ assert FeatureFlags.enabled?("on_flag")
+ end
+ end
+
+ describe "enabled?/2" do
+ test "returns false when flag does not exist" do
+ refute FeatureFlags.enabled?("missing_flag", "tenant_1")
+ end
+
+ test "returns false when flag is disabled and tenant has no entry (follows global)" do
+ {:ok, _} = Api.upsert_feature_flag(%{name: "off_flag", enabled: false})
+ tenant = tenant_fixture(%{feature_flags: %{}})
+ refute FeatureFlags.enabled?("off_flag", tenant.external_id)
+ end
+
+ test "returns true when flag is disabled globally but tenant has it explicitly enabled" do
+ {:ok, _} = Api.upsert_feature_flag(%{name: "tenant_override_flag", enabled: false})
+ tenant = tenant_fixture(%{feature_flags: %{"tenant_override_flag" => true}})
+ assert FeatureFlags.enabled?("tenant_override_flag", tenant.external_id)
+ end
+
+ test "returns global value when flag is enabled but tenant does not exist" do
+ {:ok, _} = Api.upsert_feature_flag(%{name: "enabled_flag", enabled: true})
+ assert FeatureFlags.enabled?("enabled_flag", "nonexistent_tenant")
+ end
+
+ test "returns true when flag is enabled and tenant has no entry (follows global)" do
+ {:ok, _} = Api.upsert_feature_flag(%{name: "partial_flag", enabled: true})
+ tenant = tenant_fixture(%{feature_flags: %{}})
+ assert FeatureFlags.enabled?("partial_flag", tenant.external_id)
+ end
+
+ test "returns true when flag is enabled and tenant has it explicitly enabled" do
+ {:ok, _} = Api.upsert_feature_flag(%{name: "tenant_flag", enabled: true})
+ tenant = tenant_fixture(%{feature_flags: %{"tenant_flag" => true}})
+ assert FeatureFlags.enabled?("tenant_flag", tenant.external_id)
+ end
+
+ test "returns false when flag is enabled but tenant has it explicitly disabled" do
+ {:ok, _} = Api.upsert_feature_flag(%{name: "disabled_for_tenant", enabled: true})
+ tenant = tenant_fixture(%{feature_flags: %{"disabled_for_tenant" => false}})
+ refute FeatureFlags.enabled?("disabled_for_tenant", tenant.external_id)
+ end
+ end
+end
diff --git a/test/realtime/gen_rpc_pub_sub/worker_test.exs b/test/realtime/gen_rpc_pub_sub/worker_test.exs
new file mode 100644
index 000000000..880fa5132
--- /dev/null
+++ b/test/realtime/gen_rpc_pub_sub/worker_test.exs
@@ -0,0 +1,71 @@
+defmodule Realtime.GenRpcPubSub.WorkerTest do
+ use ExUnit.Case, async: true
+ alias Realtime.GenRpcPubSub.Worker
+ alias Realtime.GenRpc
+ alias Realtime.Nodes
+
+ use Mimic
+
+ @topic "test_topic"
+
+ setup do
+ worker = start_link_supervised!({Worker, {Realtime.PubSub, __MODULE__}})
+ %{worker: worker}
+ end
+
+ describe "forward to local" do
+ test "local broadcast", %{worker: worker} do
+ :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, @topic)
+ send(worker, Worker.forward_to_local(@topic, "le message", Phoenix.PubSub))
+
+ assert_receive "le message"
+ refute_receive _any
+ end
+ end
+
+ describe "forward to region" do
+ setup %{worker: worker} do
+ GenRpc
+ |> stub()
+ |> allow(self(), worker)
+
+ Nodes
+ |> stub()
+ |> allow(self(), worker)
+
+ :ok
+ end
+
+ test "local broadcast + forward to other nodes", %{worker: worker} do
+ parent = self()
+ expect(Nodes, :region_nodes, fn "us-east-1" -> [node(), :node_us_2, :node_us_3] end)
+
+ expect(GenRpc, :abcast, fn [:node_us_2, :node_us_3],
+ Realtime.GenRpcPubSub.WorkerTest,
+ {:ftl, "test_topic", "le message", Phoenix.PubSub},
+ [] ->
+ send(parent, :abcast_called)
+ :ok
+ end)
+
+ :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, @topic)
+ send(worker, Worker.forward_to_region(@topic, "le message", Phoenix.PubSub))
+
+ assert_receive "le message"
+ assert_receive :abcast_called
+ refute_receive _any
+ end
+
+ test "local broadcast and no other nodes", %{worker: worker} do
+ expect(Nodes, :region_nodes, fn "us-east-1" -> [node()] end)
+
+ reject(GenRpc, :abcast, 4)
+
+ :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, @topic)
+ send(worker, Worker.forward_to_region(@topic, "le message", Phoenix.PubSub))
+
+ assert_receive "le message"
+ refute_receive _any
+ end
+ end
+end
diff --git a/test/realtime/gen_rpc_pub_sub_test.exs b/test/realtime/gen_rpc_pub_sub_test.exs
new file mode 100644
index 000000000..c273f1484
--- /dev/null
+++ b/test/realtime/gen_rpc_pub_sub_test.exs
@@ -0,0 +1,120 @@
+Application.put_env(:phoenix_pubsub, :test_adapter, {Realtime.GenRpcPubSub, []})
+Code.require_file("../../deps/phoenix_pubsub/test/shared/pubsub_test.exs", __DIR__)
+
+defmodule Realtime.GenRpcPubSubTest do
+ # Application env being changed
+ use ExUnit.Case, async: false
+
+ test "it sets off_heap message_queue_data flag on the workers" do
+ assert Realtime.PubSubElixir.Realtime.PubSub.Adapter_1
+ |> Process.whereis()
+ |> Process.info(:message_queue_data) == {:message_queue_data, :off_heap}
+ end
+
+ test "it sets fullsweep_after flag on the workers" do
+ assert Realtime.PubSubElixir.Realtime.PubSub.Adapter_1
+ |> Process.whereis()
+ |> Process.info(:fullsweep_after) == {:fullsweep_after, 20}
+ end
+
+ @aux_mod (quote do
+ defmodule Subscriber do
+ # Relay messages to testing node
+ def subscribe(subscriber, topic) do
+ spawn(fn ->
+ RealtimeWeb.Endpoint.subscribe(topic)
+ 2 = length(Realtime.Nodes.region_nodes("us-east-1"))
+ 2 = length(Realtime.Nodes.region_nodes("ap-southeast-2"))
+ send(subscriber, {:ready, Application.get_env(:realtime, :region)})
+
+ loop = fn f ->
+ receive do
+ msg -> send(subscriber, {:relay, node(), msg})
+ end
+
+ f.(f)
+ end
+
+ loop.(loop)
+ end)
+ end
+ end
+ end)
+
+ Code.eval_quoted(@aux_mod)
+
+ @topic "gen-rpc-pub-sub-test-topic"
+
+ describe "regional broadcasting" do
+ setup do
+ previous_region = Application.get_env(:realtime, :region)
+ Application.put_env(:realtime, :region, "us-east-1")
+ on_exit(fn -> Application.put_env(:realtime, :region, previous_region) end)
+
+ :ok
+ end
+
+ test "all messages are received" do
+ # start 1 node in us-east-1 to test my region broadcasting
+ # start 2 nodes in ap-southeast-2 to test other region broadcasting
+
+ us_node = :us_node
+ ap2_nodeX = :ap2_nodeX
+ ap2_nodeY = :ap2_nodeY
+
+ # Avoid port collision
+ gen_rpc_port = Application.fetch_env!(:gen_rpc, :tcp_server_port)
+
+ client_config_per_node = %{
+ node() => gen_rpc_port,
+ :"#{us_node}@127.0.0.1" => 16970,
+ :"#{ap2_nodeX}@127.0.0.1" => 16971,
+ :"#{ap2_nodeY}@127.0.0.1" => 16972
+ }
+
+ extra_config = [{:gen_rpc, :client_config_per_node, {:internal, client_config_per_node}}]
+
+ on_exit(fn -> Application.put_env(:gen_rpc, :client_config_per_node, {:internal, %{}}) end)
+ Application.put_env(:gen_rpc, :client_config_per_node, {:internal, client_config_per_node})
+
+ us_extra_config =
+ [{:realtime, :region, "us-east-1"}, {:gen_rpc, :tcp_server_port, 16970}] ++ extra_config
+
+ {:ok, _} = Clustered.start(@aux_mod, name: us_node, extra_config: us_extra_config, phoenix_port: 4014)
+
+ ap2_nodeX_extra_config =
+ [{:realtime, :region, "ap-southeast-2"}, {:gen_rpc, :tcp_server_port, 16971}] ++ extra_config
+
+ {:ok, _} = Clustered.start(@aux_mod, name: ap2_nodeX, extra_config: ap2_nodeX_extra_config, phoenix_port: 4015)
+
+ ap2_nodeY_extra_config =
+ [{:realtime, :region, "ap-southeast-2"}, {:gen_rpc, :tcp_server_port, 16972}] ++ extra_config
+
+ {:ok, _} = Clustered.start(@aux_mod, name: ap2_nodeY, extra_config: ap2_nodeY_extra_config, phoenix_port: 4016)
+
+ # Ensuring that syn had enough time to propagate to all nodes the group information
+ Process.sleep(3000)
+
+ RealtimeWeb.Endpoint.subscribe(@topic)
+ :erpc.multicall(Node.list(), Subscriber, :subscribe, [self(), @topic])
+
+ assert length(Realtime.Nodes.region_nodes("us-east-1")) == 2
+ assert length(Realtime.Nodes.region_nodes("ap-southeast-2")) == 2
+
+ assert_receive {:ready, "us-east-1"}
+ assert_receive {:ready, "ap-southeast-2"}
+ assert_receive {:ready, "ap-southeast-2"}
+
+ message = %Phoenix.Socket.Broadcast{topic: @topic, event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]}
+ Phoenix.PubSub.broadcast(Realtime.PubSub, @topic, message)
+
+ assert_receive ^message
+
+ # Remote nodes received the broadcast
+ assert_receive {:relay, :"us_node@127.0.0.1", ^message}, 5000
+ assert_receive {:relay, :"ap2_nodeX@127.0.0.1", ^message}, 1000
+ assert_receive {:relay, :"ap2_nodeY@127.0.0.1", ^message}, 1000
+ refute_receive _any
+ end
+ end
+end
diff --git a/test/realtime/gen_rpc_test.exs b/test/realtime/gen_rpc_test.exs
index dd837aaf8..511aafc14 100644
--- a/test/realtime/gen_rpc_test.exs
+++ b/test/realtime/gen_rpc_test.exs
@@ -28,7 +28,6 @@ defmodule Realtime.GenRpcTest do
origin_node: ^current_node,
target_node: ^current_node,
success: true,
- tenant: "123",
mechanism: :gen_rpc
}}
end
@@ -43,7 +42,6 @@ defmodule Realtime.GenRpcTest do
origin_node: ^current_node,
target_node: ^current_node,
success: false,
- tenant: "123",
mechanism: :gen_rpc
}}
end
@@ -57,7 +55,6 @@ defmodule Realtime.GenRpcTest do
origin_node: ^current_node,
target_node: ^node,
success: true,
- tenant: "123",
mechanism: :gen_rpc
}}
end
@@ -72,7 +69,6 @@ defmodule Realtime.GenRpcTest do
origin_node: ^current_node,
target_node: ^node,
success: false,
- tenant: "123",
mechanism: :gen_rpc
}}
end
@@ -87,14 +83,13 @@ defmodule Realtime.GenRpcTest do
end)
assert log =~
- "project=123 external_id=123 [error] ErrorOnRpcCall: %{error: :timeout, mod: Process, func: :sleep, target: :\"main@127.0.0.1\"}"
+ "project=123 external_id=123 [error] ErrorOnRpcCall: %{error: :timeout, mod: Process, func: :sleep, target: :\"#{current_node}\"}"
assert_receive {[:realtime, :rpc], %{latency: _},
%{
origin_node: ^current_node,
target_node: ^current_node,
success: false,
- tenant: 123,
mechanism: :gen_rpc
}}
end
@@ -116,7 +111,6 @@ defmodule Realtime.GenRpcTest do
origin_node: ^current_node,
target_node: ^node,
success: false,
- tenant: 123,
mechanism: :gen_rpc
}}
end
@@ -131,7 +125,6 @@ defmodule Realtime.GenRpcTest do
origin_node: ^current_node,
target_node: ^current_node,
success: false,
- tenant: "123",
mechanism: :gen_rpc
}}
end
@@ -146,7 +139,6 @@ defmodule Realtime.GenRpcTest do
origin_node: ^current_node,
target_node: ^node,
success: false,
- tenant: "123",
mechanism: :gen_rpc
}}
end
@@ -168,10 +160,115 @@ defmodule Realtime.GenRpcTest do
origin_node: ^current_node,
target_node: ^node,
success: false,
- tenant: 123,
mechanism: :gen_rpc
}}
end
+
+ test "bad node" do
+ node = :"unknown@1.1.1.1"
+
+ log =
+ capture_log(fn ->
+ assert GenRpc.call(node, Map, :fetch, [%{a: 1}, :a], tenant_id: 123) == {:error, :rpc_error, :badnode}
+ end)
+
+ assert log =~
+ ~r/project=123 external_id=123 \[error\] ErrorOnRpcCall: %{+error: :badnode, mod: Map, func: :fetch, target: :"#{node}"/
+ end
+ end
+
+ describe "abcast/4" do
+ test "abcast to registered process", %{node: node} do
+ name =
+ System.unique_integer()
+ |> to_string()
+ |> String.to_atom()
+
+ :erlang.register(name, self())
+
+ # Use erpc to make the other node abcast to this one
+ :erpc.call(node, GenRpc, :abcast, [[node()], name, "a message", []])
+
+ assert_receive "a message"
+ refute_receive _any
+ end
+
+ test "abcast to registered process on the local node" do
+ name =
+ System.unique_integer()
+ |> to_string()
+ |> String.to_atom()
+
+ :erlang.register(name, self())
+
+ assert GenRpc.abcast([node()], name, "a message", []) == :ok
+
+ assert_receive "a message"
+ refute_receive _any
+ end
+
+ @tag extra_config: [{:gen_rpc, :tcp_server_port, 9999}]
+ test "tcp error" do
+ Logger.put_process_level(self(), :debug)
+
+ log =
+ capture_log(fn ->
+ assert GenRpc.abcast(Node.list(), :some_process_name, "a message", []) == :ok
+ # We have to wait for gen_rpc logs to show up
+ Process.sleep(100)
+ end)
+
+ assert log =~ "failed_to_connect_server"
+
+ refute_receive _any
+ end
+ end
+
+ describe "cast/5" do
+ test "apply on a local node" do
+ parent = self()
+
+ assert GenRpc.cast(node(), Kernel, :send, [parent, :sent]) == :ok
+
+ assert_receive :sent
+ refute_receive _any
+ end
+
+ test "apply on a remote node", %{node: node} do
+ parent = self()
+
+ assert GenRpc.cast(node, Kernel, :send, [parent, :sent]) == :ok
+
+ assert_receive :sent
+ refute_receive _any
+ end
+
+ test "bad node does nothing" do
+ node = :"unknown@1.1.1.1"
+
+ parent = self()
+
+ assert GenRpc.cast(node, Kernel, :send, [parent, :sent]) == :ok
+
+ refute_receive _any
+ end
+
+ @tag extra_config: [{:gen_rpc, :tcp_server_port, 9999}]
+ test "tcp error", %{node: node} do
+ parent = self()
+ Logger.put_process_level(self(), :debug)
+
+ log =
+ capture_log(fn ->
+ assert GenRpc.cast(node, Kernel, :send, [parent, :sent]) == :ok
+ # We have to wait for gen_rpc logs to show up
+ Process.sleep(100)
+ end)
+
+ assert log =~ "failed_to_connect_server"
+
+ refute_receive _any
+ end
end
describe "multicast/4" do
@@ -197,7 +294,7 @@ defmodule Realtime.GenRpcTest do
Process.sleep(100)
end)
- assert log =~ "[error] event=connect_to_remote_server"
+ assert log =~ "failed_to_connect_server"
assert_receive :sent
refute_receive _any
@@ -214,7 +311,7 @@ defmodule Realtime.GenRpcTest do
current_node = node()
assert GenRpc.multicall(Map, :fetch, [%{a: 1}, :a], tenant_id: "123") == [
- {:"main@127.0.0.1", {:ok, 1}},
+ {current_node, {:ok, 1}},
{node, {:ok, 1}}
]
@@ -223,7 +320,6 @@ defmodule Realtime.GenRpcTest do
origin_node: ^current_node,
target_node: ^node,
success: true,
- tenant: "123",
mechanism: :gen_rpc
}}
@@ -232,7 +328,6 @@ defmodule Realtime.GenRpcTest do
origin_node: ^current_node,
target_node: ^current_node,
success: true,
- tenant: "123",
mechanism: :gen_rpc
}}
end
@@ -243,13 +338,13 @@ defmodule Realtime.GenRpcTest do
log =
capture_log(fn ->
assert GenRpc.multicall(Process, :sleep, [500], timeout: 100, tenant_id: 123) == [
- {:"main@127.0.0.1", {:error, :rpc_error, :timeout}},
+ {current_node, {:error, :rpc_error, :timeout}},
{node, {:error, :rpc_error, :timeout}}
]
end)
assert log =~
- "project=123 external_id=123 [error] ErrorOnRpcCall: %{error: :timeout, mod: Process, func: :sleep, target: :\"main@127.0.0.1\"}"
+ "project=123 external_id=123 [error] ErrorOnRpcCall: %{error: :timeout, mod: Process, func: :sleep, target: :\"#{current_node}\"}"
assert log =~
~r/project=123 external_id=123 \[error\] ErrorOnRpcCall: %{\s+error: :timeout,\s+mod: Process,\s+func: :sleep,\s+target:\s+:"#{node}"/
@@ -259,7 +354,6 @@ defmodule Realtime.GenRpcTest do
origin_node: ^current_node,
target_node: ^node,
success: false,
- tenant: 123,
mechanism: :gen_rpc
}}
@@ -268,7 +362,6 @@ defmodule Realtime.GenRpcTest do
origin_node: ^current_node,
target_node: ^current_node,
success: false,
- tenant: 123,
mechanism: :gen_rpc
}}
end
@@ -280,7 +373,7 @@ defmodule Realtime.GenRpcTest do
log =
capture_log(fn ->
assert GenRpc.multicall(Map, :fetch, [%{a: 1}, :a], tenant_id: 123) == [
- {:"main@127.0.0.1", {:ok, 1}},
+ {node(), {:ok, 1}},
{node, {:error, :rpc_error, :econnrefused}}
]
end)
@@ -293,7 +386,6 @@ defmodule Realtime.GenRpcTest do
origin_node: ^current_node,
target_node: ^node,
success: false,
- tenant: 123,
mechanism: :gen_rpc
}}
@@ -302,7 +394,6 @@ defmodule Realtime.GenRpcTest do
origin_node: ^current_node,
target_node: ^current_node,
success: true,
- tenant: 123,
mechanism: :gen_rpc
}}
end
diff --git a/test/realtime/log_filter_test.exs b/test/realtime/log_filter_test.exs
new file mode 100644
index 000000000..2fcec358f
--- /dev/null
+++ b/test/realtime/log_filter_test.exs
@@ -0,0 +1,84 @@
+defmodule Realtime.LogFilterTest do
+ use ExUnit.Case, async: true
+
+ alias Realtime.LogFilter
+
+ describe "filter/2 - gen_statem crash reports" do
+ test "stops DBConnection.ConnectionError crashes" do
+ event = gen_statem_event(%DBConnection.ConnectionError{message: "tcp connect: connection refused"})
+ assert :stop = LogFilter.filter(event, [])
+ end
+
+ test "passes through gen_statem crashes for other reasons" do
+ event = gen_statem_event(:some_other_reason)
+ assert ^event = LogFilter.filter(event, [])
+ end
+
+ test "passes through non-gen_statem reports" do
+ event = %{msg: {:report, %{label: {:supervisor, :child_terminated}}}, meta: %{}}
+ assert ^event = LogFilter.filter(event, [])
+ end
+ end
+
+ describe "filter/2 - DBConnection.Connection log calls" do
+ test "stops messages from DBConnection.Connection" do
+ event = db_connection_log_event("Postgrex.Protocol failed to connect: connection refused")
+ assert :stop = LogFilter.filter(event, [])
+ end
+
+ test "passes through messages from other modules" do
+ event = %{msg: {:string, "some log"}, meta: %{mfa: {SomeOtherModule, :some_fun, 1}}}
+ assert ^event = LogFilter.filter(event, [])
+ end
+
+ test "passes through messages with no mfa metadata" do
+ event = %{msg: {:string, "some log"}, meta: %{}}
+ assert ^event = LogFilter.filter(event, [])
+ end
+ end
+
+ describe "filter/2 - Ranch connection killed reports" do
+ test "stops Ranch reports when connection was killed" do
+ event = ranch_event(RealtimeWeb.Endpoint.HTTP, :cowboy_clear, self(), :killed)
+ assert :stop = LogFilter.filter(event, [])
+ end
+
+ test "passes through Ranch reports when connection exited for other reasons" do
+ event = ranch_event(RealtimeWeb.Endpoint.HTTP, :cowboy_clear, self(), :some_error)
+ assert ^event = LogFilter.filter(event, [])
+ end
+ end
+
+ describe "setup/0" do
+ test "installs the primary filter" do
+ LogFilter.setup()
+ %{filters: filters} = :logger.get_primary_config()
+ assert List.keymember?(filters, :connection_noise, 0)
+ end
+
+ test "is idempotent when called multiple times" do
+ LogFilter.setup()
+ assert :ok = LogFilter.setup()
+ end
+ end
+
+ defp gen_statem_event(reason) do
+ %{
+ msg: {:report, %{label: {:gen_statem, :terminate}, name: self(), reason: {:error, reason, []}}},
+ meta: %{pid: self(), time: System.system_time()}
+ }
+ end
+
+ @ranch_format "Ranch listener ~p had connection process started with ~p:start_link/3 at ~p exit with reason: ~0p~n"
+
+ defp ranch_event(ref, protocol, pid, reason) do
+ %{msg: {:format, @ranch_format, [ref, protocol, pid, reason]}, meta: %{pid: self()}}
+ end
+
+ defp db_connection_log_event(message) do
+ %{
+ msg: {:string, message},
+ meta: %{mfa: {DBConnection.Connection, :handle_event, 4}, pid: self()}
+ }
+ end
+end
diff --git a/test/realtime/logs_test.exs b/test/realtime/logs_test.exs
index feee48ac6..3882a6750 100644
--- a/test/realtime/logs_test.exs
+++ b/test/realtime/logs_test.exs
@@ -1,6 +1,52 @@
defmodule Realtime.LogsTest do
use ExUnit.Case
+ import ExUnit.CaptureLog
+
+ alias Realtime.Logs
+
+ describe "to_log/1" do
+ test "returns binary as-is" do
+ assert Logs.to_log("hello") == "hello"
+ end
+
+ test "inspects non-binary values" do
+ assert Logs.to_log(%{key: "value"}) == inspect(%{key: "value"}, pretty: true)
+ assert Logs.to_log(123) == "123"
+ assert Logs.to_log([:a, :b]) == inspect([:a, :b], pretty: true)
+ end
+ end
+
+ describe "log_error/2" do
+ test "logs error with code and message" do
+ defmodule LogErrorTest do
+ use Realtime.Logs
+
+ def do_log do
+ log_error("TestCode", "something broke")
+ end
+ end
+
+ log = capture_log(fn -> LogErrorTest.do_log() end)
+ assert log =~ "TestCode: something broke"
+ end
+ end
+
+ describe "log_warning/2" do
+ test "logs warning with code and message" do
+ defmodule LogWarningTest do
+ use Realtime.Logs
+
+ def do_log do
+ log_warning("WarnCode", "something suspicious")
+ end
+ end
+
+ log = capture_log(fn -> LogWarningTest.do_log() end)
+ assert log =~ "WarnCode: something suspicious"
+ end
+ end
+
describe "Jason.Encoder implementation" do
test "encodes DBConnection.ConnectionError" do
error = %DBConnection.ConnectionError{
@@ -31,5 +77,15 @@ defmodule Realtime.LogsTest do
assert encoded =~ "table: \"users\""
assert encoded =~ "code: \"42P01\""
end
+
+ test "encodes Tuple with error logging" do
+ log =
+ capture_log(fn ->
+ encoded = Jason.encode!({:error, "test"})
+ assert encoded =~ "error: \"unable to parse response\""
+ end)
+
+ assert log =~ "UnableToEncodeJson"
+ end
end
end
diff --git a/test/realtime/messages_test.exs b/test/realtime/messages_test.exs
index 3bef9a5e0..5590adca9 100644
--- a/test/realtime/messages_test.exs
+++ b/test/realtime/messages_test.exs
@@ -1,10 +1,11 @@
defmodule Realtime.MessagesTest do
- use Realtime.DataCase, async: true
+ # usage of Clustered
+ use Realtime.DataCase, async: false
alias Realtime.Api.Message
alias Realtime.Database
alias Realtime.Messages
- alias Realtime.Repo
+ alias Realtime.Tenants.Repo
setup do
tenant = Containers.checkout_tenant(run_migrations: true)
@@ -13,35 +14,248 @@ defmodule Realtime.MessagesTest do
date_start = Date.utc_today() |> Date.add(-10)
date_end = Date.utc_today()
create_messages_partitions(conn, date_start, date_end)
+
+ on_exit(fn -> :telemetry.detach(__MODULE__) end)
+
+ :telemetry.attach(
+ __MODULE__,
+ [:realtime, :tenants, :replay],
+ &__MODULE__.handle_telemetry/4,
+ pid: self()
+ )
+
%{conn: conn, tenant: tenant, date_start: date_start, date_end: date_end}
end
- test "delete_old_messages/1 deletes messages older than 72 hours", %{
- conn: conn,
- tenant: tenant,
- date_start: date_start,
- date_end: date_end
- } do
- utc_now = NaiveDateTime.utc_now()
- limit = NaiveDateTime.add(utc_now, -72, :hour)
-
- messages =
- for date <- Date.range(date_start, date_end) do
- inserted_at = date |> NaiveDateTime.new!(Time.new!(0, 0, 0))
- message_fixture(tenant, %{inserted_at: inserted_at})
+ describe "replay/5" do
+ test "invalid replay params", %{tenant: tenant} do
+ assert Messages.replay(self(), tenant.external_id, "a topic", "not a number", 123) ==
+ {:error, :invalid_replay_params}
+
+ assert Messages.replay(self(), tenant.external_id, "a topic", 123, "not a number") ==
+ {:error, :invalid_replay_params}
+
+ assert Messages.replay(self(), tenant.external_id, "a topic", 253_402_300_800_000, 10) ==
+ {:error, :invalid_replay_params}
+ end
+
+ test "empty replay", %{conn: conn} do
+ assert Messages.replay(conn, "tenant_id", "test", 0, 10) == {:ok, [], MapSet.new()}
+ end
+
+ test "replay respects limit", %{conn: conn, tenant: tenant} do
+ external_id = tenant.external_id
+
+ m1 =
+ message_fixture(tenant, %{
+ "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :minute),
+ "event" => "new",
+ "extension" => "broadcast",
+ "topic" => "test",
+ "private" => true,
+ "payload" => %{"value" => "new"}
+ })
+
+ message_fixture(tenant, %{
+ "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-2, :minute),
+ "event" => "old",
+ "extension" => "broadcast",
+ "topic" => "test",
+ "private" => true,
+ "payload" => %{"value" => "old"}
+ })
+
+ assert Messages.replay(conn, external_id, "test", 0, 1) == {:ok, [m1], MapSet.new([m1.id])}
+
+ assert_receive {
+ :telemetry,
+ [:realtime, :tenants, :replay],
+ %{latency: _},
+ %{tenant: ^external_id}
+ }
+ end
+
+ test "replay private topic only", %{conn: conn, tenant: tenant} do
+ privatem =
+ message_fixture(tenant, %{
+ "private" => true,
+ "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :minute),
+ "event" => "new",
+ "extension" => "broadcast",
+ "topic" => "test",
+ "payload" => %{"value" => "new"}
+ })
+
+ message_fixture(tenant, %{
+ "private" => false,
+ "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-2, :minute),
+ "event" => "old",
+ "extension" => "broadcast",
+ "topic" => "test",
+ "payload" => %{"value" => "old"}
+ })
+
+ assert Messages.replay(conn, tenant.external_id, "test", 0, 10) == {:ok, [privatem], MapSet.new([privatem.id])}
+ end
+
+ test "replay extension=broadcast", %{conn: conn, tenant: tenant} do
+ privatem =
+ message_fixture(tenant, %{
+ "private" => true,
+ "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :minute),
+ "event" => "new",
+ "extension" => "broadcast",
+ "topic" => "test",
+ "payload" => %{"value" => "new"}
+ })
+
+ message_fixture(tenant, %{
+ "private" => true,
+ "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-2, :minute),
+ "event" => "old",
+ "extension" => "presence",
+ "topic" => "test",
+ "payload" => %{"value" => "old"}
+ })
+
+ assert Messages.replay(conn, tenant.external_id, "test", 0, 10) == {:ok, [privatem], MapSet.new([privatem.id])}
+ end
+
+ test "replay respects since", %{conn: conn, tenant: tenant} do
+ m1 =
+ message_fixture(tenant, %{
+ "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-2, :minute),
+ "event" => "first",
+ "extension" => "broadcast",
+ "topic" => "test",
+ "private" => true,
+ "payload" => %{"value" => "first"}
+ })
+
+ m2 =
+ message_fixture(tenant, %{
+ "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :minute),
+ "event" => "second",
+ "extension" => "broadcast",
+ "topic" => "test",
+ "private" => true,
+ "payload" => %{"value" => "second"}
+ })
+
+ message_fixture(tenant, %{
+ "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-10, :minute),
+ "event" => "old",
+ "extension" => "broadcast",
+ "topic" => "test",
+ "private" => true,
+ "payload" => %{"value" => "old"}
+ })
+
+ since = DateTime.utc_now() |> DateTime.add(-3, :minute) |> DateTime.to_unix(:millisecond)
+
+ assert Messages.replay(conn, tenant.external_id, "test", since, 10) == {:ok, [m1, m2], MapSet.new([m1.id, m2.id])}
+ end
+
+ test "replay respects hard max limit of 25", %{conn: conn, tenant: tenant} do
+ for _i <- 1..30 do
+ message_fixture(tenant, %{
+ "inserted_at" => NaiveDateTime.utc_now(),
+ "event" => "event",
+ "extension" => "broadcast",
+ "topic" => "test",
+ "private" => true,
+ "payload" => %{"value" => "message"}
+ })
end
- assert length(messages) == 11
+ assert {:ok, messages, set} = Messages.replay(conn, tenant.external_id, "test", 0, 30)
+ assert length(messages) == 25
+ assert MapSet.size(set) == 25
+ end
+
+ test "replay respects hard min limit of 1", %{conn: conn, tenant: tenant} do
+ message_fixture(tenant, %{
+ "inserted_at" => NaiveDateTime.utc_now(),
+ "event" => "event",
+ "extension" => "broadcast",
+ "topic" => "test",
+ "private" => true,
+ "payload" => %{"value" => "message"}
+ })
+
+ assert {:ok, messages, set} = Messages.replay(conn, tenant.external_id, "test", 0, 0)
+ assert length(messages) == 1
+ assert MapSet.size(set) == 1
+ end
+
+ test "distributed replay", %{conn: conn, tenant: tenant} do
+ m =
+ message_fixture(tenant, %{
+ "inserted_at" => NaiveDateTime.utc_now(),
+ "event" => "event",
+ "extension" => "broadcast",
+ "topic" => "test",
+ "private" => true,
+ "payload" => %{"value" => "message"}
+ })
+
+ {:ok, node} = Clustered.start()
+
+ # Call remote node passing the database connection that is local to this node
+ assert :erpc.call(node, Messages, :replay, [conn, tenant.external_id, "test", 0, 30]) ==
+ {:ok, [m], MapSet.new([m.id])}
+ end
- to_keep =
- Enum.reject(
- messages,
- &(NaiveDateTime.compare(limit, &1.inserted_at) == :gt)
- )
+ test "distributed replay error", %{tenant: tenant} do
+ message_fixture(tenant, %{
+ "inserted_at" => NaiveDateTime.utc_now(),
+ "event" => "event",
+ "extension" => "broadcast",
+ "topic" => "test",
+ "private" => true,
+ "payload" => %{"value" => "message"}
+ })
- assert :ok = Messages.delete_old_messages(conn)
- {:ok, current} = Repo.all(conn, from(m in Message), Message)
+ {:ok, node} = Clustered.start()
- assert Enum.sort(current) == Enum.sort(to_keep)
+ # Call remote node passing the database connection that is local to this node
+ pid = spawn(fn -> :ok end)
+
+ assert :erpc.call(node, Messages, :replay, [pid, tenant.external_id, "test", 0, 30]) ==
+ {:error, :failed_to_replay_messages}
+ end
end
+
+ describe "delete_old_messages/1" do
+ test "delete_old_messages/1 deletes messages older than 72 hours", %{
+ conn: conn,
+ tenant: tenant,
+ date_start: date_start,
+ date_end: date_end
+ } do
+ utc_now = NaiveDateTime.utc_now()
+ limit = NaiveDateTime.add(utc_now, -72, :hour)
+
+ messages =
+ for date <- Date.range(date_start, date_end) do
+ inserted_at = date |> NaiveDateTime.new!(Time.new!(0, 0, 0))
+ message_fixture(tenant, %{inserted_at: inserted_at})
+ end
+
+ assert length(messages) == 11
+
+ to_keep =
+ Enum.reject(
+ messages,
+ &(NaiveDateTime.compare(NaiveDateTime.beginning_of_day(limit), &1.inserted_at) == :gt)
+ )
+
+ assert :ok = Messages.delete_old_messages(conn)
+ {:ok, current} = Repo.all(conn, from(m in Message), Message)
+
+ assert Enum.sort(current) == Enum.sort(to_keep)
+ end
+ end
+
+ def handle_telemetry(event, measures, metadata, pid: pid), do: send(pid, {:telemetry, event, measures, metadata})
end
diff --git a/test/realtime/metrics_cleaner_test.exs b/test/realtime/metrics_cleaner_test.exs
index fbe9d8515..3eb5ef1ac 100644
--- a/test/realtime/metrics_cleaner_test.exs
+++ b/test/realtime/metrics_cleaner_test.exs
@@ -1,45 +1,261 @@
defmodule Realtime.MetricsCleanerTest do
- # async: false due to potentially polluting metrics with other tenant metrics from other tests
- use Realtime.DataCase, async: false
+ use Realtime.DataCase, async: true
alias Realtime.MetricsCleaner
alias Realtime.Tenants.Connect
+ alias Forum.Census
- setup do
- interval = Application.get_env(:realtime, :metrics_cleaner_schedule_timer_in_ms)
- Application.put_env(:realtime, :metrics_cleaner_schedule_timer_in_ms, 100)
- tenant = Containers.checkout_tenant(run_migrations: true)
+ describe "metrics cleanup - vacant websockets" do
+ test "cleans up metrics for users that have been disconnected" do
+ :telemetry.execute(
+ [:realtime, :connections],
+ %{connected: 1, connected_cluster: 10, limit: 100},
+ %{tenant: "occupied-tenant"}
+ )
+
+ :telemetry.execute(
+ [:realtime, :connections],
+ %{connected: 0, connected_cluster: 20, limit: 100},
+ %{tenant: "vacant-tenant1"}
+ )
+
+ :telemetry.execute(
+ [:realtime, :connections],
+ %{connected: 0, connected_cluster: 20, limit: 100},
+ %{tenant: "vacant-tenant2"}
+ )
+
+ pid1 = spawn_link(fn -> Process.sleep(:infinity) end)
+ pid2 = spawn_link(fn -> Process.sleep(:infinity) end)
+ pid3 = spawn_link(fn -> Process.sleep(:infinity) end)
+
+ Census.join(:users, "occupied-tenant", pid1)
+ Census.join(:users, "vacant-tenant1", pid2)
+ Census.join(:users, "vacant-tenant2", pid3)
+
+ metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary()
+
+ assert String.contains?(metrics, "tenant=\"occupied-tenant\"")
+ assert String.contains?(metrics, "tenant=\"vacant-tenant1\"")
+ assert String.contains?(metrics, "tenant=\"vacant-tenant2\"")
+
+ start_supervised!(
+ {MetricsCleaner, [metrics_cleaner_schedule_timer_in_ms: 100, vacant_metric_threshold_in_seconds: 1]}
+ )
+
+ # Now let's disconnect vacant tenants
+ Census.leave(:users, "vacant-tenant1", pid2)
+ Census.leave(:users, "vacant-tenant2", pid3)
+
+ # Wait for clean up to run
+ Process.sleep(200)
+
+ # Nothing changes yet (threshold not reached)
+ metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary()
+
+ assert String.contains?(metrics, "tenant=\"occupied-tenant\"")
+ assert String.contains?(metrics, "tenant=\"vacant-tenant1\"")
+ assert String.contains?(metrics, "tenant=\"vacant-tenant2\"")
+
+ # Wait for threshold to pass and cleanup to run
+ Process.sleep(2200)
+
+ # vacant tenant metrics are now gone
+ metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary()
- on_exit(fn ->
- Application.put_env(:realtime, :metrics_cleaner_schedule_timer_in_ms, interval)
- end)
+ assert String.contains?(metrics, "tenant=\"occupied-tenant\"")
+ refute String.contains?(metrics, "tenant=\"vacant-tenant1\"")
+ refute String.contains?(metrics, "tenant=\"vacant-tenant2\"")
+ end
+
+ test "does not clean up metrics if websockets reconnect before threshold" do
+ :telemetry.execute(
+ [:realtime, :connections],
+ %{connected: 1, connected_cluster: 10, limit: 100},
+ %{tenant: "reconnect-tenant"}
+ )
+
+ pid = spawn_link(fn -> Process.sleep(:infinity) end)
+
+ Census.join(:users, "reconnect-tenant", pid)
+
+ metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary()
+ assert String.contains?(metrics, "tenant=\"reconnect-tenant\"")
+
+ start_supervised!(
+ {MetricsCleaner, [metrics_cleaner_schedule_timer_in_ms: 100, vacant_metric_threshold_in_seconds: 1]}
+ )
+
+ # Disconnect
+ Census.leave(:users, "reconnect-tenant", pid)
+ Process.sleep(500)
+
+ # Reconnect before threshold
+ pid2 = spawn_link(fn -> Process.sleep(:infinity) end)
+ Census.join(:users, "reconnect-tenant", pid2)
- %{tenant: tenant}
+ # Wait for cleanup to run
+ Process.sleep(2200)
+
+ # Metrics should still be present
+ metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary()
+ assert String.contains?(metrics, "tenant=\"reconnect-tenant\"")
+ end
end
- describe "metrics cleanup" do
- test "cleans up metrics for users that have been disconnected", %{tenant: %{external_id: external_id}} do
- start_supervised!(MetricsCleaner)
- {:ok, _} = Connect.lookup_or_start_connection(external_id)
- # Wait for promex to collect the metrics
- Process.sleep(6000)
+ describe "metrics cleanup - disconnected tenants" do
+ test "cleans up metrics for tenants that have been unregistered" do
+ :telemetry.execute(
+ [:realtime, :connections],
+ %{connected: 1, connected_cluster: 10, limit: 100},
+ %{tenant: "connected-tenant"}
+ )
+
+ :telemetry.execute(
+ [:realtime, :connections],
+ %{connected: 0, connected_cluster: 20, limit: 100},
+ %{tenant: "disconnected-tenant1"}
+ )
- Realtime.Telemetry.execute(
+ :telemetry.execute(
[:realtime, :connections],
- %{connected: 10, connected_cluster: 10, limit: 100},
- %{tenant: external_id}
+ %{connected: 0, connected_cluster: 20, limit: 100},
+ %{tenant: "disconnected-tenant2"}
)
- assert Realtime.PromEx.Metrics
- |> :ets.select([{{{:_, %{tenant: :"$1"}}, :_}, [], [:"$1"]}])
- |> Enum.any?(&(&1 == external_id))
+ metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary()
+
+ assert String.contains?(metrics, "tenant=\"connected-tenant\"")
+ assert String.contains?(metrics, "tenant=\"disconnected-tenant1\"")
+ assert String.contains?(metrics, "tenant=\"disconnected-tenant2\"")
+
+ start_supervised!(
+ {MetricsCleaner, [metrics_cleaner_schedule_timer_in_ms: 100, vacant_metric_threshold_in_seconds: 1]}
+ )
- Connect.shutdown(external_id)
+ # Simulate tenant registration (connected)
+ :telemetry.execute([:syn, Connect, :registered], %{}, %{name: "connected-tenant"})
+
+ # Simulate tenant unregistration (disconnected)
+ :telemetry.execute([:syn, Connect, :unregistered], %{}, %{name: "disconnected-tenant1"})
+ :telemetry.execute([:syn, Connect, :unregistered], %{}, %{name: "disconnected-tenant2"})
+
+ # Wait for clean up to run
Process.sleep(200)
- refute Realtime.PromEx.Metrics
- |> :ets.select([{{{:_, %{tenant: :"$1"}}, :_}, [], [:"$1"]}])
- |> Enum.any?(&(&1 == external_id))
+ # Nothing changes yet (threshold not reached)
+ metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary()
+
+ assert String.contains?(metrics, "tenant=\"connected-tenant\"")
+ assert String.contains?(metrics, "tenant=\"disconnected-tenant1\"")
+ assert String.contains?(metrics, "tenant=\"disconnected-tenant2\"")
+
+ # Wait for threshold to pass and cleanup to run
+ Process.sleep(2200)
+
+ # disconnected tenant metrics are now gone
+ metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary()
+
+ assert String.contains?(metrics, "tenant=\"connected-tenant\"")
+ refute String.contains?(metrics, "tenant=\"disconnected-tenant1\"")
+ refute String.contains?(metrics, "tenant=\"disconnected-tenant2\"")
+ end
+
+ test "does not clean up metrics if tenant reconnects before threshold" do
+ :telemetry.execute(
+ [:realtime, :connections],
+ %{connected: 1, connected_cluster: 10, limit: 100},
+ %{tenant: "reconnect-tenant"}
+ )
+
+ metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary()
+ assert String.contains?(metrics, "tenant=\"reconnect-tenant\"")
+
+ start_supervised!(
+ {MetricsCleaner, [metrics_cleaner_schedule_timer_in_ms: 100, vacant_metric_threshold_in_seconds: 1]}
+ )
+
+ # Simulate tenant unregistration
+ :telemetry.execute([:syn, Connect, :unregistered], %{}, %{name: "reconnect-tenant"})
+ Process.sleep(500)
+
+ # Re-register before threshold
+ :telemetry.execute([:syn, Connect, :registered], %{}, %{name: "reconnect-tenant"})
+
+ # Wait for cleanup to run
+ Process.sleep(2200)
+
+ # Metrics should still be present
+ metrics = Realtime.TenantPromEx.get_metrics() |> IO.iodata_to_binary()
+ assert String.contains?(metrics, "tenant=\"reconnect-tenant\"")
+ end
+ end
+
+ describe "handle_info/2 unexpected message" do
+ test "logs error for unexpected messages" do
+ import ExUnit.CaptureLog
+
+ pid =
+ start_supervised!(
+ {MetricsCleaner, [metrics_cleaner_schedule_timer_in_ms: 60_000, vacant_metric_threshold_in_seconds: 600]}
+ )
+
+ log =
+ capture_log(fn ->
+ send(pid, :something_unexpected)
+ Process.sleep(100)
+ end)
+
+ assert log =~ "Unexpected message"
+ assert log =~ "something_unexpected"
+ end
+ end
+
+ describe "handle_forum_event/4" do
+ test "inserts and deletes from ETS table" do
+ table = :ets.new(:test_forum, [:set, :public])
+
+ MetricsCleaner.handle_forum_event(
+ [:forum, :users, :group, :vacant],
+ %{},
+ %{group: "test-tenant"},
+ table
+ )
+
+ assert [{"test-tenant", _timestamp}] = :ets.lookup(table, "test-tenant")
+
+ MetricsCleaner.handle_forum_event(
+ [:forum, :users, :group, :occupied],
+ %{},
+ %{group: "test-tenant"},
+ table
+ )
+
+ assert [] = :ets.lookup(table, "test-tenant")
+ end
+ end
+
+ describe "handle_syn_event/4" do
+ test "inserts and deletes from ETS table" do
+ table = :ets.new(:test_syn, [:set, :public])
+
+ MetricsCleaner.handle_syn_event(
+ [:syn, Connect, :unregistered],
+ %{},
+ %{name: "test-tenant"},
+ table
+ )
+
+ assert [{"test-tenant", _timestamp}] = :ets.lookup(table, "test-tenant")
+
+ MetricsCleaner.handle_syn_event(
+ [:syn, Connect, :registered],
+ %{},
+ %{name: "test-tenant"},
+ table
+ )
+
+ assert [] = :ets.lookup(table, "test-tenant")
end
end
end
diff --git a/test/realtime/metrics_pusher_test.exs b/test/realtime/metrics_pusher_test.exs
new file mode 100644
index 000000000..5392a929d
--- /dev/null
+++ b/test/realtime/metrics_pusher_test.exs
@@ -0,0 +1,226 @@
+defmodule Realtime.MetricsPusherTest do
+ use Realtime.DataCase, async: true
+ import ExUnit.CaptureLog
+
+ alias Realtime.MetricsPusher
+ alias Plug.Conn
+
+ setup {Req.Test, :verify_on_exit!}
+
+ # Helper function to start MetricsPusher and allow it to use Req.Test
+ defp start_and_allow_pusher(opts) do
+ opts = Keyword.put(opts, :interval, :timer.minutes(5))
+ pid = start_supervised!({MetricsPusher, opts})
+ Req.Test.allow(MetricsPusher, self(), pid)
+ send(pid, :push)
+ {:ok, pid}
+ end
+
+ describe "start_link/1" do
+ test "does not start when URL is missing" do
+ opts = [enabled: true]
+ assert :ignore = MetricsPusher.start_link(opts)
+ end
+
+ test "sends request successfully" do
+ opts = [
+ url: "https://example.com:8428/api/v1/import/prometheus",
+ user: "realtime",
+ auth: "hunter2",
+ compress: true,
+ timeout: 5000
+ ]
+
+ :telemetry.execute([:realtime, :channel, :input_bytes], %{size: 1024}, %{tenant: "test_tenant"})
+
+ parent = self()
+
+ # Expect 2 requests: one for global metrics, one for tenant metrics
+ Req.Test.expect(MetricsPusher, 2, fn conn ->
+ assert conn.method == "POST"
+ assert conn.scheme == :https
+ assert conn.host == "example.com"
+ assert conn.port == 8428
+ assert conn.request_path == "/api/v1/import/prometheus"
+ assert Conn.get_req_header(conn, "authorization") == ["Basic #{Base.encode64("realtime:hunter2")}"]
+ assert Conn.get_req_header(conn, "content-encoding") == ["gzip"]
+ assert Conn.get_req_header(conn, "content-type") == ["text/plain"]
+
+ body = Req.Test.raw_body(conn)
+ decompressed_body = :zlib.gunzip(body)
+
+ # Collect decompressed bodies so we can assert that one has global metrics
+ # and the other has tenant metrics.
+ send(parent, {:req_called, decompressed_body})
+ Req.Test.text(conn, "")
+ end)
+
+ {:ok, _pid} = start_and_allow_pusher(opts)
+
+ # Receive both request bodies
+ assert_receive {:req_called, body1}, 300
+ assert_receive {:req_called, body2}, 300
+
+ global_metric = ~r/beam_stats_run_queue_count/
+ tenant_metric = ~r/realtime_channel_input_bytes/
+
+ # One request must contain a global-only metric, the other a tenant-only metric.
+ assert (Regex.match?(global_metric, body1) and Regex.match?(tenant_metric, body2)) or
+ (Regex.match?(global_metric, body2) and Regex.match?(tenant_metric, body1))
+ end
+
+ test "sends request successfully without auth header" do
+ opts = [
+ url: "http://localhost:8428/api/v1/import/prometheus",
+ compress: true,
+ timeout: 5000
+ ]
+
+ parent = self()
+
+ Req.Test.expect(MetricsPusher, 2, fn conn ->
+ assert Conn.get_req_header(conn, "authorization") == []
+
+ send(parent, :req_called)
+ Req.Test.text(conn, "")
+ end)
+
+ {:ok, _pid} = start_and_allow_pusher(opts)
+ assert_receive :req_called, 300
+ assert_receive :req_called, 300
+ end
+
+ test "sends request body untouched when compress=false" do
+ opts = [
+ url: "http://localhost:8428/api/v1/import/prometheus",
+ user: "hunter2",
+ auth: "realtime",
+ compress: false,
+ timeout: 5000
+ ]
+
+ parent = self()
+
+ Req.Test.expect(MetricsPusher, 2, fn conn ->
+ assert Conn.get_req_header(conn, "content-encoding") == []
+ assert Conn.get_req_header(conn, "content-type") == ["text/plain"]
+
+ send(parent, :req_called)
+ Req.Test.text(conn, "")
+ end)
+
+ {:ok, _pid} = start_and_allow_pusher(opts)
+ assert_receive :req_called, 300
+ assert_receive :req_called, 300
+ end
+
+ test "when request receives non 2XX response" do
+ opts = [
+ url: "https://example.com:8428/api/v1/import/prometheus",
+ auth: "hunter2",
+ compress: true,
+ timeout: 5000
+ ]
+
+ parent = self()
+
+ log =
+ capture_log(fn ->
+ Req.Test.expect(MetricsPusher, 2, fn conn ->
+ send(parent, :req_called)
+ Conn.send_resp(conn, 500, "")
+ end)
+
+ {:ok, pid} = start_and_allow_pusher(opts)
+ assert_receive :req_called, 300
+ assert_receive :req_called, 300
+ assert Process.alive?(pid)
+ # Wait enough for the log to be captured
+ Process.sleep(100)
+ end)
+
+ assert log =~ "MetricsPusher: Failed to push"
+ assert log =~ "metrics to"
+ assert log =~ "error_code=MetricsPusherFailed"
+ end
+
+ test "when an error is raised" do
+ opts = [
+ url: "https://example.com:8428/api/v1/import/prometheus",
+ timeout: 5000
+ ]
+
+ parent = self()
+
+ log =
+ capture_log(fn ->
+ Req.Test.expect(MetricsPusher, 2, fn _conn ->
+ send(parent, :req_called)
+ raise RuntimeError, "unexpected error"
+ end)
+
+ {:ok, pid} = start_and_allow_pusher(opts)
+ assert_receive :req_called, 300
+ assert_receive :req_called, 300
+ assert Process.alive?(pid)
+ # Wait enough for the log to be captured
+ Process.sleep(100)
+ end)
+
+ assert log =~ "MetricsPusher: Exception during"
+ assert log =~ "push: %RuntimeError{message: \"unexpected error\"}"
+ assert log =~ "error_code=MetricsPusherException"
+ end
+
+ test "appends extra_label query params to URL" do
+ opts = [
+ url: "http://localhost:8428/api/v1/import/prometheus",
+ compress: false,
+ timeout: 5000,
+ extra_labels: [{"region", "us-east-1"}, {"env", "prod"}]
+ ]
+
+ parent = self()
+
+ Req.Test.expect(MetricsPusher, 2, fn conn ->
+ send(parent, {:req_called, conn.query_string})
+ Req.Test.text(conn, "")
+ end)
+
+ {:ok, _pid} = start_and_allow_pusher(opts)
+ assert_receive {:req_called, query_string}, 300
+ assert_receive {:req_called, _}, 300
+
+ decoded_params = query_string |> String.split("&") |> Enum.map(&URI.decode_www_form/1)
+ assert "extra_label=region=us-east-1" in decoded_params
+ assert "extra_label=env=prod" in decoded_params
+ end
+
+ test "logs unexpected messages and stays alive" do
+ parent = self()
+
+ Req.Test.expect(MetricsPusher, 2, fn conn ->
+ send(parent, :push_happened)
+ Req.Test.text(conn, "")
+ end)
+
+ {:ok, pid} =
+ start_and_allow_pusher(
+ url: "http://localhost:8428/api/v1/import/prometheus",
+ timeout: 5000
+ )
+
+ assert_receive :push_happened, 500
+ assert_receive :push_happened, 500
+
+ log =
+ capture_log(fn ->
+ send(pid, :unexpected_message)
+ Process.sleep(50)
+ assert Process.alive?(pid)
+ end)
+
+ assert log =~ "MetricsPusher received unexpected message: :unexpected_message"
+ end
+ end
+end
diff --git a/test/realtime/monitoring/distributed_metrics_test.exs b/test/realtime/monitoring/distributed_metrics_test.exs
index 491083973..49fe4af6f 100644
--- a/test/realtime/monitoring/distributed_metrics_test.exs
+++ b/test/realtime/monitoring/distributed_metrics_test.exs
@@ -15,7 +15,7 @@ defmodule Realtime.DistributedMetricsTest do
^node => %{
pid: _pid,
port: _port,
- queue_size: {:ok, 0},
+ queue_size: {:ok, _},
state: :up,
inet_stats: [
recv_oct: _,
diff --git a/test/realtime/monitoring/erl_sys_mon_test.exs b/test/realtime/monitoring/erl_sys_mon_test.exs
index b1e122d58..b14f79b58 100644
--- a/test/realtime/monitoring/erl_sys_mon_test.exs
+++ b/test/realtime/monitoring/erl_sys_mon_test.exs
@@ -5,16 +5,53 @@ defmodule Realtime.Monitoring.ErlSysMonTest do
describe "system monitoring" do
test "logs system monitor events" do
- start_supervised!({ErlSysMon, config: [{:long_message_queue, {1, 10}}]})
-
- assert capture_log(fn ->
- Task.async(fn ->
- Enum.map(1..1000, &send(self(), &1))
- # Wait for ErlSysMon to notice
- Process.sleep(4000)
- end)
- |> Task.await()
- end) =~ "Realtime.ErlSysMon message:"
+ start_supervised!({ErlSysMon, config: [{:long_message_queue, {1, 100}}]})
+
+ log =
+ capture_log(fn ->
+ Task.async(fn ->
+ Process.register(self(), TestProcess)
+ Enum.map(1..1000, &send(self(), &1))
+ # Wait for ErlSysMon to notice
+ Process.sleep(4000)
+ end)
+ |> Task.await()
+ end)
+
+ assert log =~ "Realtime.ErlSysMon message:"
+ assert log =~ "$initial_call\", {Realtime.Monitoring.ErlSysMonTest"
+ assert log =~ "ancestors\", [#{inspect(self())}]"
+ assert log =~ "registered_name: TestProcess"
+ assert log =~ "message_queue_len: "
+ assert log =~ "total_heap_size: "
+ end
+
+ test "logs non-pid monitor messages" do
+ {:ok, pid} = ErlSysMon.start_link(config: [])
+
+ log =
+ capture_log(fn ->
+ send(pid, {:unexpected, "message"})
+ Process.sleep(100)
+ end)
+
+ assert log =~ "Realtime.ErlSysMon message:"
+ assert log =~ "unexpected"
+ end
+
+ test "handles monitor event for dead process without crashing" do
+ {:ok, pid} = ErlSysMon.start_link(config: [])
+
+ dead_pid = spawn(fn -> :ok end)
+ Process.sleep(50)
+
+ log =
+ capture_log(fn ->
+ send(pid, {:monitor, dead_pid, :long_gc, %{timeout: 500}})
+ Process.sleep(100)
+ end)
+
+ assert log =~ "Realtime.ErlSysMon message:"
end
end
end
diff --git a/test/realtime/monitoring/gen_rpc_metrics_test.exs b/test/realtime/monitoring/gen_rpc_metrics_test.exs
index 722bc8c02..1c25b8c0d 100644
--- a/test/realtime/monitoring/gen_rpc_metrics_test.exs
+++ b/test/realtime/monitoring/gen_rpc_metrics_test.exs
@@ -60,20 +60,17 @@ defmodule Realtime.GenRpcMetricsTest do
assert local_metrics[:connections] == remote_metrics[:connections]
- assert local_metrics[:send_avg] == remote_metrics[:recv_avg]
- assert local_metrics[:recv_avg] == remote_metrics[:send_avg]
+ assert_in_delta local_metrics[:send_avg], remote_metrics[:recv_avg], 200
+ assert_in_delta local_metrics[:recv_avg], remote_metrics[:send_avg], 200
- assert local_metrics[:send_oct] == remote_metrics[:recv_oct]
- assert local_metrics[:recv_oct] == remote_metrics[:send_oct]
+ assert_in_delta local_metrics[:send_oct], remote_metrics[:recv_oct], 1000
+ assert_in_delta local_metrics[:recv_oct], remote_metrics[:send_oct], 1000
- assert local_metrics[:send_cnt] == remote_metrics[:recv_cnt]
- assert local_metrics[:recv_cnt] == remote_metrics[:send_cnt]
+ assert_in_delta local_metrics[:send_cnt], remote_metrics[:recv_cnt], 10
+ assert_in_delta local_metrics[:recv_cnt], remote_metrics[:send_cnt], 10
- assert local_metrics[:send_max] == remote_metrics[:recv_max]
- assert local_metrics[:recv_max] == remote_metrics[:send_max]
-
- assert local_metrics[:send_max] == remote_metrics[:recv_max]
- assert local_metrics[:recv_max] == remote_metrics[:send_max]
+ assert_in_delta local_metrics[:send_max], remote_metrics[:recv_max], 1000
+ assert_in_delta local_metrics[:recv_max], remote_metrics[:send_max], 1000
end
end
end
diff --git a/test/realtime/monitoring/latency_test.exs b/test/realtime/monitoring/latency_test.exs
index 8e43f0d06..379e5f212 100644
--- a/test/realtime/monitoring/latency_test.exs
+++ b/test/realtime/monitoring/latency_test.exs
@@ -3,30 +3,59 @@ defmodule Realtime.LatencyTest do
use Realtime.DataCase, async: false
alias Realtime.Latency
+ describe "pong/0" do
+ test "returns pong with region" do
+ assert {:ok, {:pong, region}} = Latency.pong()
+ assert is_binary(region)
+ end
+ end
+
+ describe "pong/1" do
+ test "returns pong after sleeping for the given latency" do
+ assert {:ok, {:pong, _region}} = Latency.pong(0)
+ end
+ end
+
+ describe "handle_info/2" do
+ test "unexpected message does not crash the server" do
+ pid = Process.whereis(Latency)
+ send(pid, :unexpected_message)
+ assert Process.alive?(pid)
+ end
+ end
+
+ describe "handle_cast/2" do
+ test "ping cast triggers a ping and does not crash" do
+ pid = Process.whereis(Latency)
+ GenServer.cast(pid, {:ping, 0, 5_000, 5_000})
+ assert Process.alive?(pid)
+ end
+ end
+
describe "ping/3" do
setup do
- Node.stop()
+ for node <- Node.list(), do: Node.disconnect(node)
:ok
end
- @tag skip: "Clustered tests creating flakiness, requires time to analyse"
- test "emulate a healthy remote node" do
- assert [{%Task{}, {:ok, %{response: {:ok, {:pong, "not_set"}}}}}] = Latency.ping()
+ test "returns pong from healthy remote node" do
+ {:ok, _node} = Clustered.start()
+ results = Latency.ping()
+ assert Enum.all?(results, fn {%Task{}, result} -> match?({:ok, %{response: {:ok, {:pong, _}}}}, result) end)
end
- @tag skip: "Clustered tests creating flakiness, requires time to analyse"
- test "emulate a slow but healthy remote node" do
- assert [{%Task{}, {:ok, %{response: {:ok, {:pong, "not_set"}}}}}] = Latency.ping(5_000, 10_000, 30_000)
+ test "returns pong from slow but healthy remote node" do
+ {:ok, _node} = Clustered.start()
+ results = Latency.ping(100, 10_000, 30_000)
+ assert Enum.all?(results, fn {%Task{}, result} -> match?({:ok, %{response: {:ok, {:pong, _}}}}, result) end)
end
- @tag skip: "Clustered tests creating flakiness, requires time to analyse"
- test "emulate an unhealthy remote node" do
- assert [{%Task{}, {:ok, %{response: {:badrpc, :timeout}}}}] = Latency.ping(5_000, 1_000)
+ test "returns error when remote node exceeds timer timeout" do
+ assert [{%Task{}, {:ok, %{response: {:error, :rpc_error, _}}}}] = Latency.ping(500, 100)
end
- @tag skip: "Clustered tests creating flakiness, requires time to analyse"
- test "no response from our Task for a remote node at all" do
- assert [{%Task{}, nil}] = Latency.ping(10_000, 5_000, 2_000)
+ test "returns nil when task does not yield before yield timeout" do
+ assert [{%Task{}, nil}] = Latency.ping(1_000, 500, 100)
end
end
end
diff --git a/test/realtime/monitoring/peep/partitioned_tables_test.exs b/test/realtime/monitoring/peep/partitioned_tables_test.exs
new file mode 100644
index 000000000..88918dee8
--- /dev/null
+++ b/test/realtime/monitoring/peep/partitioned_tables_test.exs
@@ -0,0 +1,158 @@
+Application.put_env(:peep, :test_storages, [
+ {Realtime.Monitoring.Peep.PartitionedTables, [tables: 4]},
+ {Realtime.Monitoring.Peep.PartitionedTables, [tables: 4, routing_tag: :tenant_id]},
+ {Realtime.Monitoring.Peep.PartitionedTables, [tables: 1]}
+])
+
+Code.require_file("../../../../deps/peep/test/shared/storage_test.exs", __DIR__)
+
+defmodule Realtime.Monitoring.Peep.PartitionedTablesTest do
+ use ExUnit.Case, async: true
+
+ alias Realtime.Monitoring.Peep.PartitionedTables
+ alias Telemetry.Metrics
+
+ describe "get_all_metrics" do
+ test "collects metrics from all tables" do
+ counter = Metrics.counter("all_metrics.test.counter")
+ last_value = Metrics.last_value("all_metrics.test.gauge")
+
+ n_tables = 4
+ tenant_a = "tenant-alpha"
+ tenant_b = "tenant-beta"
+
+ assert :erlang.phash2(tenant_a, n_tables) != :erlang.phash2(tenant_b, n_tables)
+
+ name = :"test_all_metrics_#{System.unique_integer([:positive])}"
+
+ {:ok, _} =
+ Peep.start_link(
+ name: name,
+ metrics: [counter, last_value],
+ storage: {PartitionedTables, [tables: n_tables, routing_tag: :tenant_id]}
+ )
+
+ tags_a = %{tenant_id: tenant_a}
+ tags_b = %{tenant_id: tenant_b}
+
+ for _ <- 1..3, do: Peep.insert_metric(name, counter, 1, tags_a)
+ for _ <- 1..7, do: Peep.insert_metric(name, counter, 1, tags_b)
+ for _ <- 1..11, do: Peep.insert_metric(name, counter, 1, %{})
+ Peep.insert_metric(name, last_value, 42, tags_a)
+ Peep.insert_metric(name, last_value, 99, tags_b)
+ Peep.insert_metric(name, last_value, 111, %{})
+
+ all = Peep.get_all_metrics(name)
+
+ assert all[counter][tags_a] == 3
+ assert all[counter][tags_b] == 7
+ assert all[counter][%{}] == 11
+ assert all[last_value][tags_a] == 42
+ assert all[last_value][tags_b] == 99
+ assert all[last_value][%{}] == 111
+ end
+ end
+
+ describe "routing tag" do
+ test "routes different tag values to different tables" do
+ n_tables = 4
+ routing_tag = :tenant_id
+ {tids, ^routing_tag} = PartitionedTables.new(tables: n_tables, routing_tag: routing_tag)
+
+ counter = Metrics.counter("routing.test.counter")
+ id = :erlang.phash2(counter)
+
+ tenant_a = "tenant-alpha"
+ tenant_b = "tenant-beta"
+
+ index_a = :erlang.phash2(tenant_a, n_tables)
+ index_b = :erlang.phash2(tenant_b, n_tables)
+
+ # Ensure the two tenants map to different tables for this test to be meaningful
+ assert index_a != index_b,
+ "tenant-alpha and tenant-beta must hash to different table indices"
+
+ tags_a = %{tenant_id: tenant_a}
+ tags_b = %{tenant_id: tenant_b}
+
+ for _ <- 1..10 do
+ PartitionedTables.insert_metric({tids, routing_tag}, id, counter, 1, tags_a)
+ PartitionedTables.insert_metric({tids, routing_tag}, id, counter, 1, tags_b)
+ end
+
+ # Each tenant's data is in its own table
+ assert :ets.lookup(elem(tids, index_a), {id, tags_a}) != []
+ assert :ets.lookup(elem(tids, index_b), {id, tags_b}) != []
+
+ # Cross-table: each tenant's key must NOT exist in the other's table
+ assert :ets.lookup(elem(tids, index_a), {id, tags_b}) == []
+ assert :ets.lookup(elem(tids, index_b), {id, tags_a}) == []
+ end
+
+ test "falls back to first table when routing tag is absent from tags" do
+ n_tables = 4
+ routing_tag = :tenant_id
+ {tids, ^routing_tag} = PartitionedTables.new(tables: n_tables, routing_tag: routing_tag)
+
+ counter = Metrics.counter("fallback.test.counter")
+ id = :erlang.phash2(counter)
+ tags = %{env: :prod}
+
+ PartitionedTables.insert_metric({tids, routing_tag}, id, counter, 1, tags)
+
+ assert :ets.lookup(elem(tids, 0), {id, tags}) != []
+
+ for i <- 1..(n_tables - 1) do
+ assert :ets.lookup(elem(tids, i), {id, tags}) == []
+ end
+ end
+
+ test "prune_tags targets only the relevant table for patterns with routing tag" do
+ n_tables = 4
+ routing_tag = :tenant_id
+ {tids, ^routing_tag} = PartitionedTables.new(tables: n_tables, routing_tag: routing_tag)
+
+ counter = Metrics.counter("prune.targeted.test.counter")
+ id = :erlang.phash2(counter)
+
+ tenant_a = "tenant-alpha"
+ tenant_b = "tenant-beta"
+
+ index_a = :erlang.phash2(tenant_a, n_tables)
+ index_b = :erlang.phash2(tenant_b, n_tables)
+ assert index_a != index_b
+
+ tags_a = %{tenant_id: tenant_a}
+ tags_b = %{tenant_id: tenant_b}
+
+ PartitionedTables.insert_metric({tids, routing_tag}, id, counter, 1, tags_a)
+ PartitionedTables.insert_metric({tids, routing_tag}, id, counter, 1, tags_b)
+
+ # Prune only tenant A using a targeted pattern
+ :ok = PartitionedTables.prune_tags({tids, routing_tag}, [tags_a])
+
+ assert :ets.lookup(elem(tids, index_a), {id, tags_a}) == []
+ assert :ets.lookup(elem(tids, index_b), {id, tags_b}) != []
+ end
+
+ test "prune_tags targets table 0 when pattern has no routing tag" do
+ n_tables = 4
+ routing_tag = :tenant_id
+ {tids, ^routing_tag} = PartitionedTables.new(tables: n_tables, routing_tag: routing_tag)
+
+ counter = Metrics.counter("prune.broadcast.test.counter")
+ id = :erlang.phash2(counter)
+
+ # Tags with no routing key → written to table 0
+ tags = %{env: :prod}
+ PartitionedTables.insert_metric({tids, routing_tag}, id, counter, 1, tags)
+
+ assert :ets.lookup(elem(tids, 0), {id, tags}) != []
+
+ # Pattern without routing tag → deletes from table 0 only
+ :ok = PartitionedTables.prune_tags({tids, routing_tag}, [%{env: :prod}])
+
+ assert :ets.lookup(elem(tids, 0), {id, tags}) == []
+ end
+ end
+end
diff --git a/test/realtime/monitoring/peep/partitioned_test.exs b/test/realtime/monitoring/peep/partitioned_test.exs
new file mode 100644
index 000000000..47be695c8
--- /dev/null
+++ b/test/realtime/monitoring/peep/partitioned_test.exs
@@ -0,0 +1,6 @@
+Application.put_env(:peep, :test_storages, [
+ {Realtime.Monitoring.Peep.Partitioned, 3},
+ {Realtime.Monitoring.Peep.Partitioned, 1}
+])
+
+Code.require_file("../../../../deps/peep/test/shared/storage_test.exs", __DIR__)
diff --git a/test/realtime/monitoring/prom_ex/plugins/channels_test.exs b/test/realtime/monitoring/prom_ex/plugins/channels_test.exs
new file mode 100644
index 000000000..b006ab387
--- /dev/null
+++ b/test/realtime/monitoring/prom_ex/plugins/channels_test.exs
@@ -0,0 +1,42 @@
+defmodule Realtime.PromEx.Plugins.ChannelsTest do
+ use Realtime.DataCase, async: false
+
+ alias Realtime.PromEx.Plugins.Channels
+ alias RealtimeWeb.RealtimeChannel.Logging
+
+ defmodule MetricsTest do
+ use PromEx, otp_app: :realtime_test_channels
+ @impl true
+ def plugins do
+ [Channels]
+ end
+ end
+
+ setup_all do
+ start_supervised!(MetricsTest)
+ :ok
+ end
+
+ test "counts channel errors with tenant tag in prometheus" do
+ tenant_id = random_string()
+ socket = %{assigns: %{log_level: :error, tenant: tenant_id, access_token: "test_token"}}
+ error = "TestError"
+
+ previous_value = metric_value("realtime_channel_error", code: error, tenant: tenant_id) || 0
+ Logging.maybe_log_error(socket, error, "test error")
+ assert metric_value("realtime_channel_error", code: error, tenant: tenant_id) == previous_value + 1
+ end
+
+ test "does not count warnings in the error metric" do
+ tenant_id = random_string()
+ socket = %{assigns: %{log_level: :error, tenant: tenant_id, access_token: "test_token"}}
+ error = "TestWarning"
+
+ Logging.maybe_log_warning(socket, error, "test warning")
+ assert metric_value("realtime_channel_error", code: error, tenant: tenant_id) == nil
+ end
+
+ defp metric_value(metric, expected_tags) do
+ MetricsHelper.search(PromEx.get_metrics(MetricsTest), metric, expected_tags)
+ end
+end
diff --git a/test/realtime/monitoring/prom_ex/plugins/distributed_test.exs b/test/realtime/monitoring/prom_ex/plugins/distributed_test.exs
index ff4c4f098..731873066 100644
--- a/test/realtime/monitoring/prom_ex/plugins/distributed_test.exs
+++ b/test/realtime/monitoring/prom_ex/plugins/distributed_test.exs
@@ -23,55 +23,41 @@ defmodule Realtime.PromEx.Plugins.DistributedTest do
describe "pooling metrics" do
setup do
- metrics =
- PromEx.get_metrics(MetricsTest)
- |> String.split("\n", trim: true)
-
- %{metrics: metrics}
+ %{metrics: PromEx.get_metrics(MetricsTest)}
end
test "send_pending_bytes", %{metrics: metrics, node: node} do
- pattern = ~r/dist_send_pending_bytes{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/
- assert metric_value(metrics, pattern) == 0
+ assert metric_value(metrics, "dist_send_pending_bytes", origin_node: node(), target_node: node) == 0
end
test "send_count", %{metrics: metrics, node: node} do
- pattern = ~r/dist_send_count{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/
- assert metric_value(metrics, pattern) > 0
+ value = metric_value(metrics, "dist_send_count", origin_node: node(), target_node: node)
+ assert is_integer(value)
+ assert value > 0
end
test "send_bytes", %{metrics: metrics, node: node} do
- pattern = ~r/dist_send_bytes{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/
- assert metric_value(metrics, pattern) > 0
+ value = metric_value(metrics, "dist_send_bytes", origin_node: node(), target_node: node)
+ assert is_integer(value)
+ assert value > 0
end
test "recv_count", %{metrics: metrics, node: node} do
- pattern = ~r/dist_recv_count{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/
- assert metric_value(metrics, pattern) > 0
+ value = metric_value(metrics, "dist_recv_count", origin_node: node(), target_node: node)
+ assert is_integer(value)
+ assert value > 0
end
test "recv_bytes", %{metrics: metrics, node: node} do
- pattern = ~r/dist_recv_bytes{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/
- assert metric_value(metrics, pattern) > 0
+ value = metric_value(metrics, "dist_recv_bytes", origin_node: node(), target_node: node)
+ assert is_integer(value)
+ assert value > 0
end
test "queue_size", %{metrics: metrics, node: node} do
- pattern = ~r/dist_queue_size{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/
- assert is_integer(metric_value(metrics, pattern))
+ assert is_integer(metric_value(metrics, "dist_queue_size", origin_node: node(), target_node: node))
end
end
- defp metric_value(metrics, pattern) do
- metrics
- |> Enum.find_value(
- "0",
- fn item ->
- case Regex.run(pattern, item, capture: ["number"]) do
- [number] -> number
- _ -> false
- end
- end
- )
- |> String.to_integer()
- end
+ defp metric_value(metrics, metric, expected_tags), do: MetricsHelper.search(metrics, metric, expected_tags)
end
diff --git a/test/realtime/monitoring/prom_ex/plugins/gen_rpc_test.exs b/test/realtime/monitoring/prom_ex/plugins/gen_rpc_test.exs
index 25d8fae16..5396aae6b 100644
--- a/test/realtime/monitoring/prom_ex/plugins/gen_rpc_test.exs
+++ b/test/realtime/monitoring/prom_ex/plugins/gen_rpc_test.exs
@@ -23,55 +23,42 @@ defmodule Realtime.PromEx.Plugins.GenRpcTest do
describe "pooling metrics" do
setup do
- metrics =
- PromEx.get_metrics(MetricsTest)
- |> String.split("\n", trim: true)
-
- %{metrics: metrics}
+ %{metrics: PromEx.get_metrics(MetricsTest)}
end
test "send_pending_bytes", %{metrics: metrics, node: node} do
- pattern = ~r/gen_rpc_send_pending_bytes{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/
- assert metric_value(metrics, pattern) == 0
+ assert metric_value(metrics, "gen_rpc_send_pending_bytes", origin_node: node(), target_node: node) == 0
end
test "send_count", %{metrics: metrics, node: node} do
- pattern = ~r/gen_rpc_send_count{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/
- assert metric_value(metrics, pattern) > 0
+ value = metric_value(metrics, "gen_rpc_send_count", origin_node: node(), target_node: node)
+ assert is_integer(value)
+ assert value > 0
end
test "send_bytes", %{metrics: metrics, node: node} do
- pattern = ~r/gen_rpc_send_bytes{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/
- assert metric_value(metrics, pattern) > 0
+ value = metric_value(metrics, "gen_rpc_send_bytes", origin_node: node(), target_node: node)
+ assert is_integer(value)
+ assert value > 0
end
test "recv_count", %{metrics: metrics, node: node} do
- pattern = ~r/gen_rpc_recv_count{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/
- assert metric_value(metrics, pattern) > 0
+ value = metric_value(metrics, "gen_rpc_recv_count", origin_node: node(), target_node: node)
+ assert is_integer(value)
+ assert value > 0
end
test "recv_bytes", %{metrics: metrics, node: node} do
- pattern = ~r/gen_rpc_recv_bytes{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/
- assert metric_value(metrics, pattern) > 0
+ value = metric_value(metrics, "gen_rpc_recv_bytes", origin_node: node(), target_node: node)
+ assert is_integer(value)
+ assert value > 0
end
test "queue_size", %{metrics: metrics, node: node} do
- pattern = ~r/gen_rpc_queue_size_bytes{origin_node=\"#{node()}\",target_node=\"#{node}\"}\s(?\d+)/
- assert metric_value(metrics, pattern) == 0
+ value = metric_value(metrics, "gen_rpc_queue_size_bytes", origin_node: node(), target_node: node)
+ assert is_integer(value)
end
end
- defp metric_value(metrics, pattern) do
- metrics
- |> Enum.find_value(
- "0",
- fn item ->
- case Regex.run(pattern, item, capture: ["number"]) do
- [number] -> number
- _ -> false
- end
- end
- )
- |> String.to_integer()
- end
+ defp metric_value(metrics, metric, expected_tags), do: MetricsHelper.search(metrics, metric, expected_tags)
end
diff --git a/test/realtime/monitoring/prom_ex/plugins/migrations_test.exs b/test/realtime/monitoring/prom_ex/plugins/migrations_test.exs
new file mode 100644
index 000000000..70333af97
--- /dev/null
+++ b/test/realtime/monitoring/prom_ex/plugins/migrations_test.exs
@@ -0,0 +1,126 @@
+defmodule Realtime.PromEx.Plugins.MigrationsTest do
+ use Realtime.DataCase, async: false
+
+ alias Realtime.PromEx.Plugins.Migrations
+ alias Realtime.Telemetry
+
+ defmodule MetricsTest do
+ use PromEx, otp_app: :realtime_test_migrations
+
+ @impl true
+ def plugins, do: [Migrations]
+ end
+
+ setup_all do
+ start_supervised!(MetricsTest)
+ :ok
+ end
+
+ defp metric_value(metric, expected_tags \\ nil) do
+ MetricsTest
+ |> PromEx.get_metrics()
+ |> MetricsHelper.search(metric, expected_tags)
+ end
+
+ test "records migration duration histogram on stop" do
+ start_time =
+ Telemetry.start([:realtime, :tenants, :migrations], %{
+ external_id: "tenant",
+ hostname: "localhost",
+ platform_region: "sa-east-1"
+ })
+
+ Telemetry.stop(
+ [:realtime, :tenants, :migrations],
+ start_time,
+ %{external_id: "tenant", hostname: "localhost", platform_region: "sa-east-1", migrations_executed: 3}
+ )
+
+ assert metric_value("realtime_tenants_migrations_duration_milliseconds_count", platform_region: "sa-east-1") == 1
+
+ assert metric_value("realtime_tenants_migrations_duration_milliseconds_bucket",
+ platform_region: "sa-east-1",
+ le: "100.0"
+ ) > 0
+ end
+
+ test "skips duration histogram when migrations_executed is 0" do
+ before =
+ metric_value("realtime_tenants_migrations_duration_milliseconds_count", platform_region: "sa-east-1") || 0
+
+ start_time =
+ Telemetry.start([:realtime, :tenants, :migrations], %{
+ external_id: "tenant",
+ hostname: "localhost",
+ platform_region: "sa-east-1"
+ })
+
+ Telemetry.stop(
+ [:realtime, :tenants, :migrations],
+ start_time,
+ %{external_id: "tenant", hostname: "localhost", platform_region: "sa-east-1", migrations_executed: 0}
+ )
+
+ assert (metric_value("realtime_tenants_migrations_duration_milliseconds_count", platform_region: "sa-east-1") || 0) ==
+ before
+ end
+
+ test "tags Postgrex errors with the SQLSTATE atom" do
+ metric = "realtime_tenants_migrations_exceptions_total"
+ start_time = Telemetry.start([:realtime, :tenants, :migrations], %{external_id: "tenant", hostname: "localhost"})
+
+ Telemetry.exception(
+ [:realtime, :tenants, :migrations],
+ start_time,
+ :error,
+ %Postgrex.Error{postgres: %{code: :undefined_column}},
+ [],
+ %{external_id: "tenant", error_code: :undefined_column}
+ )
+
+ assert metric_value(metric, error_code: "undefined_column") == 1
+ end
+
+ test "tags connection errors with error_code=connection_error" do
+ metric = "realtime_tenants_migrations_exceptions_total"
+ start_time = Telemetry.start([:realtime, :tenants, :migrations], %{external_id: "tenant", hostname: "localhost"})
+
+ Telemetry.exception(
+ [:realtime, :tenants, :migrations],
+ start_time,
+ :error,
+ %DBConnection.ConnectionError{message: "ssl send: closed"},
+ [],
+ %{external_id: "tenant", error_code: :connection_error}
+ )
+
+ assert metric_value(metric, error_code: "connection_error") == 1
+ end
+
+ test "counts reconciliations" do
+ start_time = Telemetry.start([:realtime, :tenants, :migrations, :reconcile], %{external_id: "tenant"})
+
+ Telemetry.stop(
+ [:realtime, :tenants, :migrations, :reconcile],
+ start_time,
+ %{external_id: "tenant", cached_migrations_ran: 60, database_migrations_ran: 65}
+ )
+
+ assert metric_value("realtime_tenants_migrations_reconcile_total") == 1
+ end
+
+ test "counts reconcile exceptions" do
+ start_time = Telemetry.start([:realtime, :tenants, :migrations, :reconcile], %{external_id: "tenant"})
+
+ Telemetry.exception(
+ [:realtime, :tenants, :migrations, :reconcile],
+ start_time,
+ :error,
+ %RuntimeError{message: "boom"},
+ [],
+ %{external_id: "tenant"}
+ )
+
+ assert metric_value("realtime_tenants_migrations_reconcile_exceptions_total") == 1
+ end
+end
diff --git a/test/realtime/monitoring/prom_ex/plugins/phoenix_test.exs b/test/realtime/monitoring/prom_ex/plugins/phoenix_test.exs
index a73e6e2f5..8f1d7d5be 100644
--- a/test/realtime/monitoring/prom_ex/plugins/phoenix_test.exs
+++ b/test/realtime/monitoring/prom_ex/plugins/phoenix_test.exs
@@ -1,6 +1,7 @@
defmodule Realtime.PromEx.Plugins.PhoenixTest do
use Realtime.DataCase, async: false
alias Realtime.PromEx.Plugins
+ alias Realtime.Integration.WebsocketClient
defmodule MetricsTest do
use PromEx, otp_app: :realtime_test_phoenix
@@ -10,34 +11,79 @@ defmodule Realtime.PromEx.Plugins.PhoenixTest do
end
end
+ setup_all do
+ start_supervised!(MetricsTest)
+ :ok
+ end
+
+ setup do
+ %{tenant: Containers.checkout_tenant(run_migrations: true)}
+ end
+
describe "pooling metrics" do
- setup do
- start_supervised!(MetricsTest)
- :ok
+ test "number of connections", %{tenant: tenant} do
+ {:ok, token} = token_valid(tenant, "anon", %{})
+
+ {:ok, _} =
+ WebsocketClient.connect(
+ self(),
+ uri(tenant, Phoenix.Socket.V1.JSONSerializer),
+ Phoenix.Socket.V1.JSONSerializer,
+ [{"x-api-key", token}]
+ )
+
+ {:ok, _} =
+ WebsocketClient.connect(
+ self(),
+ uri(tenant, Phoenix.Socket.V1.JSONSerializer),
+ Phoenix.Socket.V1.JSONSerializer,
+ [{"x-api-key", token}]
+ )
+
+ Process.sleep(200)
+ assert metric_value("phoenix_connections_total") >= 2
end
+ end
+
+ describe "event metrics" do
+ test "socket connected", %{tenant: tenant} do
+ {:ok, token} = token_valid(tenant, "anon", %{})
- test "number of connections" do
- # Trigger a connection by making a request to the endpoint
- url = RealtimeWeb.Endpoint.url() <> "/healthcheck"
- Req.get!(url)
+ {:ok, _} =
+ WebsocketClient.connect(
+ self(),
+ uri(tenant, Phoenix.Socket.V1.JSONSerializer),
+ Phoenix.Socket.V1.JSONSerializer,
+ [{"x-api-key", token}]
+ )
+
+ {:ok, _} =
+ WebsocketClient.connect(
+ self(),
+ uri(tenant, RealtimeWeb.Socket.V2Serializer),
+ RealtimeWeb.Socket.V2Serializer,
+ [{"x-api-key", token}]
+ )
Process.sleep(200)
- assert metric_value() > 0
+
+ assert metric_value("phoenix_socket_connected_duration_milliseconds_count",
+ endpoint: "RealtimeWeb.Endpoint",
+ result: "ok",
+ serializer: "Elixir.Phoenix.Socket.V1.JSONSerializer",
+ transport: "websocket"
+ ) >= 1
+
+ assert metric_value("phoenix_socket_connected_duration_milliseconds_count",
+ endpoint: "RealtimeWeb.Endpoint",
+ result: "ok",
+ serializer: "Elixir.RealtimeWeb.Socket.V2Serializer",
+ transport: "websocket"
+ ) >= 1
end
end
- defp metric_value() do
- PromEx.get_metrics(MetricsTest)
- |> String.split("\n", trim: true)
- |> Enum.find_value(
- "0",
- fn item ->
- case Regex.run(~r/phoenix_connections_total\s(?\d+)/, item, capture: ["number"]) do
- [number] -> number
- _ -> false
- end
- end
- )
- |> String.to_integer()
+ defp metric_value(metric, expected_tags \\ nil) do
+ MetricsHelper.search(PromEx.get_metrics(MetricsTest), metric, expected_tags)
end
end
diff --git a/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs b/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs
index 164c8d2eb..70ad301a4 100644
--- a/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs
+++ b/test/realtime/monitoring/prom_ex/plugins/tenant_test.exs
@@ -1,18 +1,26 @@
defmodule Realtime.PromEx.Plugins.TenantTest do
- alias Realtime.Tenants.Authorization.Policies
use Realtime.DataCase, async: false
+ alias Forum.Census
+ alias Realtime.GenCounter
alias Realtime.PromEx.Plugins.Tenant
+ alias Realtime.PromEx.Plugins.TenantGlobal
+ alias Realtime.RateCounter
alias Realtime.Rpc
- alias Realtime.UsersCounter
- alias Realtime.Tenants.Authorization.Policies
alias Realtime.Tenants.Authorization
+ alias Realtime.Tenants.Authorization.Policies
+ alias Realtime.Tenants.Authorization.Policies
defmodule MetricsTest do
use PromEx, otp_app: :realtime_test_phoenix
@impl true
- def plugins, do: [{Tenant, poll_rate: 50}]
+ def plugins, do: [{Tenant, poll_rate: 50}, {TenantGlobal, poll_rate: 50}]
+ end
+
+ setup_all do
+ start_supervised!(MetricsTest)
+ :ok
end
def handle_telemetry(event, metadata, content, pid: pid), do: send(pid, {event, metadata, content})
@@ -20,49 +28,57 @@ defmodule Realtime.PromEx.Plugins.TenantTest do
@aux_mod (quote do
defmodule FakeUserCounter do
def fake_add(external_id) do
- :ok = UsersCounter.add(spawn(fn -> Process.sleep(2000) end), external_id)
+ pid = spawn(fn -> Process.sleep(2000) end)
+ :ok = Census.join(:users, external_id, pid)
end
def fake_db_event(external_id) do
- external_id
- |> Realtime.Tenants.db_events_per_second_rate()
- |> Realtime.RateCounter.new()
+ rate = Realtime.Tenants.db_events_per_second_rate(external_id, 100)
- external_id
- |> Realtime.Tenants.db_events_per_second_key()
- |> Realtime.GenCounter.add()
+ rate
+ |> tap(&RateCounter.new(&1))
+ |> tap(&GenCounter.add(&1.id))
+ |> RateCounterHelper.tick!()
end
def fake_event(external_id) do
- external_id
- |> Realtime.Tenants.events_per_second_rate(123)
- |> Realtime.RateCounter.new()
+ rate = Realtime.Tenants.events_per_second_rate(external_id, 123)
- external_id
- |> Realtime.Tenants.events_per_second_key()
- |> Realtime.GenCounter.add()
+ rate
+ |> tap(&RateCounter.new(&1))
+ |> tap(&GenCounter.add(&1.id))
+ |> RateCounterHelper.tick!()
end
def fake_presence_event(external_id) do
- external_id
- |> Realtime.Tenants.presence_events_per_second_rate(123)
- |> Realtime.RateCounter.new()
+ rate = Realtime.Tenants.presence_events_per_second_rate(external_id, 123)
- external_id
- |> Realtime.Tenants.presence_events_per_second_key()
- |> Realtime.GenCounter.add()
+ rate
+ |> tap(&RateCounter.new(&1))
+ |> tap(&GenCounter.add(&1.id))
+ |> RateCounterHelper.tick!()
end
def fake_broadcast_from_database(external_id) do
Realtime.Telemetry.execute(
[:realtime, :tenants, :broadcast_from_database],
%{
- latency_committed_at: 10,
- latency_inserted_at: 1
+ # millisecond
+ latency_committed_at: 9,
+ # microsecond
+ latency_inserted_at: 9000
},
%{tenant: external_id}
)
end
+
+ def fake_input_bytes(external_id) do
+ Realtime.Telemetry.execute([:realtime, :channel, :input_bytes], %{size: 10}, %{tenant: external_id})
+ end
+
+ def fake_output_bytes(external_id) do
+ Realtime.Telemetry.execute([:realtime, :channel, :output_bytes], %{size: 10}, %{tenant: external_id})
+ end
end
end)
@@ -75,7 +91,8 @@ defmodule Realtime.PromEx.Plugins.TenantTest do
on_exit(fn -> :telemetry.detach(__MODULE__) end)
- {:ok, node} = Clustered.start(@aux_mod)
+ {:ok, _} = Realtime.Tenants.Connect.lookup_or_start_connection(tenant.external_id)
+ {:ok, node} = Clustered.start(@aux_mod, extra_config: [{:realtime, :users_scope_broadcast_interval_in_ms, 50}])
%{tenant: tenant, node: node}
end
@@ -83,18 +100,22 @@ defmodule Realtime.PromEx.Plugins.TenantTest do
tenant: %{external_id: external_id},
node: node
} do
- UsersCounter.add(self(), external_id)
+ :ok = Census.join(:users, external_id, self())
# Add bad tenant id
- UsersCounter.add(self(), random_string())
+ bad_tenant_id = random_string()
+ :ok = Census.join(:users, bad_tenant_id, self())
_ = Rpc.call(node, FakeUserCounter, :fake_add, [external_id])
+
Process.sleep(500)
Tenant.execute_tenant_metrics()
assert_receive {[:realtime, :connections], %{connected: 1, limit: 200, connected_cluster: 2},
- %{tenant: ^external_id}}
+ %{tenant: ^external_id}},
+ 500
- refute_receive :_
+ refute_receive {[:realtime, :connections], %{connected: 1, limit: 200, connected_cluster: 2},
+ %{tenant: ^bad_tenant_id}}
end
end
@@ -113,47 +134,59 @@ defmodule Realtime.PromEx.Plugins.TenantTest do
role: "anon"
})
- start_supervised!(MetricsTest)
-
%{authorization_context: authorization_context, db_conn: db_conn, tenant: tenant}
end
test "event exists after counter added", %{tenant: %{external_id: external_id}} do
- pattern =
- ~r/realtime_channel_events{tenant="#{external_id}"}\s(?\d+)/
+ metric_value = metric_value("realtime_channel_events", tenant: external_id) || 0
+ FakeUserCounter.fake_event(external_id)
+
+ Process.sleep(100)
+ assert metric_value("realtime_channel_events", tenant: external_id) == metric_value + 1
+ end
+
+ test "global event exists after counter added", %{tenant: %{external_id: external_id}} do
+ metric_value = metric_value("realtime_channel_global_events") || 0
- metric_value = metric_value(pattern)
FakeUserCounter.fake_event(external_id)
- Process.sleep(200)
- assert metric_value(pattern) == metric_value + 1
+ Process.sleep(100)
+ assert metric_value("realtime_channel_global_events") == metric_value + 1
end
test "db_event exists after counter added", %{tenant: %{external_id: external_id}} do
- pattern =
- ~r/realtime_channel_db_events{tenant="#{external_id}"}\s(?\d+)/
+ metric_value = metric_value("realtime_channel_db_events", tenant: external_id) || 0
+ FakeUserCounter.fake_db_event(external_id)
+ Process.sleep(100)
+ assert metric_value("realtime_channel_db_events", tenant: external_id) == metric_value + 1
+ end
+
+ test "global db_event exists after counter added", %{tenant: %{external_id: external_id}} do
+ metric_value = metric_value("realtime_channel_global_db_events") || 0
- metric_value = metric_value(pattern)
FakeUserCounter.fake_db_event(external_id)
- Process.sleep(200)
- assert metric_value(pattern) == metric_value + 1
+ Process.sleep(100)
+ assert metric_value("realtime_channel_global_db_events") == metric_value + 1
end
test "presence_event exists after counter added", %{tenant: %{external_id: external_id}} do
- pattern =
- ~r/realtime_channel_presence_events{tenant="#{external_id}"}\s(?\d+)/
+ metric_value = metric_value("realtime_channel_presence_events", tenant: external_id) || 0
- metric_value = metric_value(pattern)
FakeUserCounter.fake_presence_event(external_id)
- Process.sleep(200)
- assert metric_value(pattern) == metric_value + 1
+ Process.sleep(100)
+ assert metric_value("realtime_channel_presence_events", tenant: external_id) == metric_value + 1
end
- test "metric read_authorization_check exists after check", context do
- pattern =
- ~r/realtime_tenants_read_authorization_check_count{tenant="#{context.tenant.external_id}"}\s(?\d+)/
+ test "global presence_event exists after counter added", %{tenant: %{external_id: external_id}} do
+ metric_value = metric_value("realtime_channel_global_presence_events") || 0
+ FakeUserCounter.fake_presence_event(external_id)
+ Process.sleep(100)
+ assert metric_value("realtime_channel_global_presence_events") == metric_value + 1
+ end
- metric_value = metric_value(pattern)
+ test "metric read_authorization_check exists after check", context do
+ metric = "realtime_tenants_read_authorization_check_count"
+ metric_value = metric_value(metric, tenant: context.tenant.external_id) || 0
{:ok, _} =
Authorization.get_read_authorizations(
@@ -164,19 +197,17 @@ defmodule Realtime.PromEx.Plugins.TenantTest do
Process.sleep(200)
- assert metric_value(pattern) == metric_value + 1
+ assert metric_value(metric, tenant: context.tenant.external_id) == metric_value + 1
- bucket_pattern =
- ~r/realtime_tenants_read_authorization_check_bucket{tenant="#{context.tenant.external_id}",le="250"}\s(?\d+)/
-
- assert metric_value(bucket_pattern) > 0
+ assert metric_value("realtime_tenants_read_authorization_check_bucket",
+ tenant: context.tenant.external_id,
+ le: "250.0"
+ ) > 0
end
test "metric write_authorization_check exists after check", context do
- pattern =
- ~r/realtime_tenants_write_authorization_check_count{tenant="#{context.tenant.external_id}"}\s(?\d+)/
-
- metric_value = metric_value(pattern)
+ metric = "realtime_tenants_write_authorization_check_count"
+ metric_value = metric_value(metric, tenant: context.tenant.external_id) || 0
{:ok, _} =
Authorization.get_write_authorizations(
@@ -188,96 +219,215 @@ defmodule Realtime.PromEx.Plugins.TenantTest do
# Wait enough time for the poll rate to be triggered at least once
Process.sleep(200)
- assert metric_value(pattern) == metric_value + 1
+ assert metric_value(metric, tenant: context.tenant.external_id) == metric_value + 1
+
+ assert metric_value("realtime_tenants_write_authorization_check_bucket",
+ tenant: context.tenant.external_id,
+ le: "250.0"
+ ) > 0
+ end
+
+ test "metric replay exists after check", context do
+ external_id = context.tenant.external_id
+ metric = "realtime_tenants_replay_count"
+ metric_value = metric_value(metric, tenant: external_id) || 0
- bucket_pattern =
- ~r/realtime_tenants_write_authorization_check_bucket{tenant="#{context.tenant.external_id}",le="250"}\s(?\d+)/
+ assert {:ok, _, _} = Realtime.Messages.replay(context.db_conn, external_id, "test", 0, 1)
+
+ # Wait enough time for the poll rate to be triggered at least once
+ Process.sleep(200)
- assert metric_value(bucket_pattern) > 0
+ assert metric_value(metric, tenant: external_id) == metric_value + 1
+
+ assert metric_value("realtime_tenants_replay_bucket", tenant: external_id, le: "250.0") > 0
end
test "metric realtime_tenants_broadcast_from_database_latency_committed_at exists after check", context do
- pattern =
- ~r/realtime_tenants_broadcast_from_database_latency_committed_at_count{tenant="#{context.tenant.external_id}"}\s(?\d+)/
+ external_id = context.tenant.external_id
+ metric = "realtime_tenants_broadcast_from_database_latency_committed_at_count"
+ metric_value = metric_value(metric, tenant: external_id) || 0
- metric_value = metric_value(pattern)
FakeUserCounter.fake_broadcast_from_database(context.tenant.external_id)
Process.sleep(200)
- assert metric_value(pattern) == metric_value + 1
-
- bucket_pattern =
- ~r/realtime_tenants_broadcast_from_database_latency_committed_at_bucket{tenant="#{context.tenant.external_id}",le="10"}\s(?\d+)/
+ assert metric_value(metric, tenant: external_id) == metric_value + 1
- assert metric_value(bucket_pattern) > 0
+ assert metric_value("realtime_tenants_broadcast_from_database_latency_committed_at_bucket",
+ tenant: external_id,
+ le: "10.0"
+ ) > 0
end
test "metric realtime_tenants_broadcast_from_database_latency_inserted_at exists after check", context do
- pattern =
- ~r/realtime_tenants_broadcast_from_database_latency_inserted_at_count{tenant="#{context.tenant.external_id}"}\s(?\d+)/
-
- metric_value = metric_value(pattern)
+ external_id = context.tenant.external_id
+ metric = "realtime_tenants_broadcast_from_database_latency_inserted_at_count"
+ metric_value = metric_value(metric, tenant: external_id) || 0
FakeUserCounter.fake_broadcast_from_database(context.tenant.external_id)
Process.sleep(200)
- assert metric_value(pattern) == metric_value + 1
-
- bucket_pattern =
- ~r/realtime_tenants_broadcast_from_database_latency_inserted_at_bucket{tenant="#{context.tenant.external_id}",le="5"}\s(?\d+)/
+ assert metric_value(metric, tenant: external_id) == metric_value + 1
- assert metric_value(bucket_pattern) > 0
+ assert metric_value("realtime_tenants_broadcast_from_database_latency_inserted_at_bucket",
+ tenant: external_id,
+ le: "10.0"
+ ) > 0
end
test "tenant metric payload size", context do
external_id = context.tenant.external_id
+ metric = "realtime_tenants_payload_size_count"
+ metric_value = metric_value(metric, message_type: "presence", tenant: external_id) || 0
- pattern =
- ~r/realtime_tenants_payload_size_count{tenant="#{external_id}"}\s(?\d+)/
+ message = %{topic: "a topic", event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]}
+ RealtimeWeb.TenantBroadcaster.pubsub_broadcast(external_id, "a topic", message, Phoenix.PubSub, :presence)
- metric_value = metric_value(pattern)
+ Process.sleep(200)
+ assert metric_value(metric, message_type: "presence", tenant: external_id) == metric_value + 1
+
+ assert metric_value("realtime_tenants_payload_size_bucket", tenant: external_id, le: "250") > 0
+ end
+
+ test "global metric payload size", context do
+ external_id = context.tenant.external_id
+
+ metric = "realtime_payload_size_count"
+ metric_value = metric_value(metric, message_type: "broadcast") || 0
message = %{topic: "a topic", event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]}
- RealtimeWeb.TenantBroadcaster.pubsub_broadcast(external_id, "a topic", message, Phoenix.PubSub)
+ RealtimeWeb.TenantBroadcaster.pubsub_broadcast(external_id, "a topic", message, Phoenix.PubSub, :broadcast)
Process.sleep(200)
- assert metric_value(pattern) == metric_value + 1
+ assert metric_value(metric, message_type: "broadcast") == metric_value + 1
- bucket_pattern =
- ~r/realtime_tenants_payload_size_bucket{tenant="#{external_id}",le="100"}\s(?\d+)/
-
- assert metric_value(bucket_pattern) > 0
+ assert metric_value("realtime_payload_size_bucket", le: "250.0") > 0
end
- test "global metric payload size", context do
+ test "channel input bytes", context do
external_id = context.tenant.external_id
- pattern = ~r/realtime_payload_size_count\s(?\d+)/
+ FakeUserCounter.fake_input_bytes(external_id)
+ FakeUserCounter.fake_input_bytes(external_id)
+
+ Process.sleep(200)
+ assert metric_value("realtime_channel_input_bytes", tenant: external_id) == 20
+ end
- metric_value = metric_value(pattern)
+ test "channel output bytes", context do
+ external_id = context.tenant.external_id
- message = %{topic: "a topic", event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]}
- RealtimeWeb.TenantBroadcaster.pubsub_broadcast(external_id, "a topic", message, Phoenix.PubSub)
+ FakeUserCounter.fake_output_bytes(external_id)
+ FakeUserCounter.fake_output_bytes(external_id)
Process.sleep(200)
- assert metric_value(pattern) == metric_value + 1
+ assert metric_value("realtime_channel_output_bytes", tenant: external_id) == 20
+ end
+ end
+
+ describe "subscription pooler metrics" do
+ setup do
+ tenant = Containers.checkout_tenant()
+ on_exit(fn -> Peep.prune_tags(MetricsTest.__metrics_collector_name__(), [%{tenant: tenant.external_id}]) end)
+ %{tenant: tenant}
+ end
+
+ test "subscribers gauge reports the latest value", %{tenant: %{external_id: external_id}} do
+ Realtime.Telemetry.execute([:realtime, :subscriptions, :manager, :subscribers], %{count: 7}, %{
+ tenant: external_id
+ })
+
+ assert metric_value("realtime_subscriptions_manager_subscribers", tenant: external_id) == 7
+ end
+
+ test "poller stop counter increments tagged by reason", %{tenant: %{external_id: external_id}} do
+ Realtime.Telemetry.execute([:realtime, :replication, :poller, :stop], %{duration: 1}, %{
+ tenant: external_id,
+ reason: {:shutdown, :max_retries_reached}
+ })
+
+ assert metric_value(
+ "realtime_replication_poller_stop_total",
+ tenant: external_id,
+ reason: "max_retries_reached"
+ ) == 1
+ end
+
+ test "poller exception counter increments on crash", %{tenant: %{external_id: external_id}} do
+ Realtime.Telemetry.execute([:realtime, :replication, :poller, :exception], %{duration: 1}, %{tenant: external_id})
+
+ assert metric_value("realtime_replication_poller_exception_total", tenant: external_id) == 1
+ end
+
+ test "query exception counter increments tagged by reason", %{tenant: %{external_id: external_id}} do
+ Realtime.Telemetry.execute([:realtime, :replication, :poller, :query, :exception], %{}, %{
+ tenant: external_id,
+ reason: :object_in_use
+ })
+
+ assert metric_value("realtime_replication_poller_query_exception_total",
+ tenant: external_id,
+ reason: "object_in_use"
+ ) == 1
+ end
+
+ test "prepare exception counter increments", %{tenant: %{external_id: external_id}} do
+ Realtime.Telemetry.execute([:realtime, :replication, :poller, :prepare, :exception], %{}, %{
+ tenant: external_id,
+ reason: :some_error
+ })
+
+ assert metric_value("realtime_replication_poller_prepare_exception_total", tenant: external_id) == 1
+ end
+
+ test "changes dispatch sum increments by dispatched count", %{tenant: %{external_id: external_id}} do
+ Realtime.Telemetry.execute([:realtime, :replication, :poller, :changes, :dispatch], %{count: 5}, %{
+ tenant: external_id
+ })
+
+ assert metric_value("realtime_replication_poller_changes_dispatch", tenant: external_id) == 5
+ end
+
+ test "changes skip sum increments by skipped count tagged by reason", %{tenant: %{external_id: external_id}} do
+ Realtime.Telemetry.execute([:realtime, :replication, :poller, :changes, :skip], %{count: 3}, %{
+ tenant: external_id,
+ reason: :rate_limited
+ })
+
+ assert metric_value("realtime_replication_poller_changes_skip", tenant: external_id, reason: "rate_limited") == 3
+ end
+
+ test "dead pid sum increments tagged by phantom reason", %{tenant: %{external_id: external_id}} do
+ Realtime.Telemetry.execute([:realtime, :subscriptions, :manager, :dead_pid], %{quantity: 1}, %{
+ tenant: external_id,
+ reason: :phantom
+ })
+
+ assert metric_value("realtime_subscriptions_manager_dead_pid", tenant: external_id, reason: "phantom") == 1
+ end
+
+ test "dead pid sum increments tagged by not_found reason", %{tenant: %{external_id: external_id}} do
+ Realtime.Telemetry.execute([:realtime, :subscriptions, :manager, :dead_pid], %{quantity: 1}, %{
+ tenant: external_id,
+ reason: :not_found
+ })
+
+ assert metric_value("realtime_subscriptions_manager_dead_pid", tenant: external_id, reason: "not_found") == 1
+ end
+ end
+
+ describe "execute_global_connection_metrics/0" do
+ test "emits global connection counts without a tenant tag" do
+ pid = spawn_link(fn -> Process.sleep(:infinity) end)
+ :ok = Census.join(:users, "global-test-tenant", pid)
+
+ TenantGlobal.execute_global_connection_metrics()
- bucket_pattern = ~r/realtime_payload_size_bucket{le="100"}\s(?\d+)/
+ Process.sleep(100)
- assert metric_value(bucket_pattern) > 0
+ assert metric_value("realtime_connections_global_connected") >= 0
+ assert metric_value("realtime_connections_global_connected_cluster") >= 0
end
end
- defp metric_value(pattern) do
- PromEx.get_metrics(MetricsTest)
- |> String.split("\n", trim: true)
- |> Enum.find_value(
- "0",
- fn item ->
- case Regex.run(pattern, item, capture: ["number"]) do
- [number] -> number
- _ -> false
- end
- end
- )
- |> String.to_integer()
+ defp metric_value(metric, expected_tags \\ nil) do
+ MetricsHelper.search(PromEx.get_metrics(MetricsTest), metric, expected_tags)
end
end
diff --git a/test/realtime/monitoring/prom_ex/plugins/tenants_test.exs b/test/realtime/monitoring/prom_ex/plugins/tenants_test.exs
index 080fd3cfb..f747daac2 100644
--- a/test/realtime/monitoring/prom_ex/plugins/tenants_test.exs
+++ b/test/realtime/monitoring/prom_ex/plugins/tenants_test.exs
@@ -10,7 +10,7 @@ defmodule Realtime.PromEx.Plugins.TenantsTest do
use PromEx, otp_app: :realtime_test_tenants
@impl true
def plugins do
- [{Tenants, poll_rate: 100}]
+ [{Tenants, poll_rate: 50}]
end
end
@@ -20,118 +20,107 @@ defmodule Realtime.PromEx.Plugins.TenantsTest do
def exception, do: raise(RuntimeError)
end
- setup do
- local_tenant = Containers.checkout_tenant(run_migrations: true)
+ setup_all do
start_supervised!(MetricsTest)
- {:ok, %{tenant: local_tenant}}
+ :ok
end
describe "event_metrics erpc" do
- test "success" do
- pattern = ~r/realtime_rpc_count{mechanism=\"erpc\",success="true",tenant="123"}\s(?\d+)/
+ setup do
+ %{tenant: random_string()}
+ end
+
+ test "global success", %{tenant: tenant} do
+ metric = "realtime_global_rpc_count"
# Enough time for the poll rate to be triggered at least once
Process.sleep(200)
- previous_value = metric_value(pattern)
- assert {:ok, "success"} = Rpc.enhanced_call(node(), Test, :success, [], tenant_id: "123")
+ previous_value = metric_value(metric, mechanism: "erpc", success: true) || 0
+ assert {:ok, "success"} = Rpc.enhanced_call(node(), Test, :success, [], tenant_id: tenant)
Process.sleep(200)
- assert metric_value(pattern) == previous_value + 1
+ assert metric_value(metric, mechanism: "erpc", success: true) == previous_value + 1
end
- test "failure" do
- pattern = ~r/realtime_rpc_count{mechanism=\"erpc\",success="false",tenant="123"}\s(?\d+)/
+ test "global failure", %{tenant: tenant} do
+ metric = "realtime_global_rpc_count"
# Enough time for the poll rate to be triggered at least once
Process.sleep(200)
- previous_value = metric_value(pattern)
- assert {:error, "failure"} = Rpc.enhanced_call(node(), Test, :failure, [], tenant_id: "123")
+ previous_value = metric_value(metric, mechanism: "erpc", success: false) || 0
+ assert {:error, "failure"} = Rpc.enhanced_call(node(), Test, :failure, [], tenant_id: tenant)
Process.sleep(200)
- assert metric_value(pattern) == previous_value + 1
+ assert metric_value(metric, mechanism: "erpc", success: false) == previous_value + 1
end
- test "exception" do
- pattern = ~r/realtime_rpc_count{mechanism=\"erpc\",success="false",tenant="123"}\s(?\d+)/
+ test "global exception", %{tenant: tenant} do
+ metric = "realtime_global_rpc_count"
# Enough time for the poll rate to be triggered at least once
Process.sleep(200)
- previous_value = metric_value(pattern)
+ previous_value = metric_value(metric, mechanism: "erpc", success: false) || 0
assert {:error, :rpc_error, %RuntimeError{message: "runtime error"}} =
- Rpc.enhanced_call(node(), Test, :exception, [], tenant_id: "123")
+ Rpc.enhanced_call(node(), Test, :exception, [], tenant_id: tenant)
Process.sleep(200)
- assert metric_value(pattern) == previous_value + 1
+ assert metric_value(metric, mechanism: "erpc", success: false) == previous_value + 1
end
end
- test "event_metrics rpc" do
- pattern = ~r/realtime_rpc_count{mechanism=\"rpc\",success="",tenant="123"}\s(?\d+)/
- # Enough time for the poll rate to be triggered at least once
- Process.sleep(200)
- previous_value = metric_value(pattern)
- assert {:ok, "success"} = Rpc.call(node(), Test, :success, [], tenant_id: "123")
- Process.sleep(200)
- assert metric_value(pattern) == previous_value + 1
- end
-
describe "event_metrics gen_rpc" do
- test "success" do
- pattern = ~r/realtime_rpc_count{mechanism=\"gen_rpc\",success="true",tenant="123"}\s(?\d+)/
+ setup do
+ %{tenant: random_string()}
+ end
+
+ test "global success", %{tenant: tenant} do
+ metric = "realtime_global_rpc_count"
# Enough time for the poll rate to be triggered at least once
Process.sleep(200)
- previous_value = metric_value(pattern)
- assert GenRpc.multicall(Test, :success, [], tenant_id: "123") == [{node(), {:ok, "success"}}]
+ previous_value = metric_value(metric, mechanism: "gen_rpc", success: true) || 0
+ assert GenRpc.multicall(Test, :success, [], tenant_id: tenant) == [{node(), {:ok, "success"}}]
Process.sleep(200)
- assert metric_value(pattern) == previous_value + 1
+ assert metric_value(metric, mechanism: "gen_rpc", success: true) == previous_value + 1
end
- test "failure" do
- pattern = ~r/realtime_rpc_count{mechanism=\"gen_rpc\",success="false",tenant="123"}\s(?\d+)/
+ test "global failure", %{tenant: tenant} do
+ metric = "realtime_global_rpc_count"
# Enough time for the poll rate to be triggered at least once
Process.sleep(200)
- previous_value = metric_value(pattern)
- assert GenRpc.multicall(Test, :failure, [], tenant_id: "123") == [{node(), {:error, "failure"}}]
+ previous_value = metric_value(metric, mechanism: "gen_rpc", success: false) || 0
+ assert GenRpc.multicall(Test, :failure, [], tenant_id: tenant) == [{node(), {:error, "failure"}}]
Process.sleep(200)
- assert metric_value(pattern) == previous_value + 1
+ assert metric_value(metric, mechanism: "gen_rpc", success: false) == previous_value + 1
end
- test "exception" do
- pattern = ~r/realtime_rpc_count{mechanism=\"gen_rpc\",success="false",tenant="123"}\s(?\d+)/
+ test "global exception", %{tenant: tenant} do
+ metric = "realtime_global_rpc_count"
# Enough time for the poll rate to be triggered at least once
Process.sleep(200)
- previous_value = metric_value(pattern)
-
+ previous_value = metric_value(metric, mechanism: "gen_rpc", success: false) || 0
node = node()
assert assert [{^node, {:error, :rpc_error, {:EXIT, {%RuntimeError{message: "runtime error"}, _stacktrace}}}}] =
- GenRpc.multicall(Test, :exception, [], tenant_id: "123")
+ GenRpc.multicall(Test, :exception, [], tenant_id: tenant)
Process.sleep(200)
- assert metric_value(pattern) == previous_value + 1
+ assert metric_value(metric, mechanism: "gen_rpc", success: false) == previous_value + 1
end
end
describe "pooling metrics" do
+ setup do
+ local_tenant = Containers.checkout_tenant(run_migrations: true)
+ {:ok, %{tenant: local_tenant}}
+ end
+
test "conneted based on Connect module information for local node only", %{tenant: tenant} do
- pattern = ~r/realtime_tenants_connected\s(?\d+)/
# Enough time for the poll rate to be triggered at least once
Process.sleep(200)
- previous_value = metric_value(pattern)
+ previous_value = metric_value("realtime_tenants_connected")
{:ok, _} = Connect.lookup_or_start_connection(tenant.external_id)
Process.sleep(200)
- assert metric_value(pattern) == previous_value + 1
+ assert metric_value("realtime_tenants_connected") == previous_value + 1
end
end
- defp metric_value(pattern) do
- PromEx.get_metrics(MetricsTest)
- |> String.split("\n", trim: true)
- |> Enum.find_value(
- "0",
- fn item ->
- case Regex.run(pattern, item, capture: ["number"]) do
- [number] -> number
- _ -> false
- end
- end
- )
- |> String.to_integer()
+ defp metric_value(metric, expected_tags \\ nil) do
+ MetricsHelper.search(PromEx.get_metrics(MetricsTest), metric, expected_tags)
end
end
diff --git a/test/realtime/monitoring/prom_ex_test.exs b/test/realtime/monitoring/prom_ex_test.exs
index 849536543..a466e5efd 100644
--- a/test/realtime/monitoring/prom_ex_test.exs
+++ b/test/realtime/monitoring/prom_ex_test.exs
@@ -5,7 +5,7 @@ defmodule Realtime.PromExTest do
describe "get_metrics/0" do
test "builds metrics in prometheus format which includes host region and id" do
- metrics = PromEx.get_metrics()
+ metrics = PromEx.get_metrics() |> IO.iodata_to_binary()
assert String.contains?(
metrics,
@@ -16,27 +16,7 @@ defmodule Realtime.PromExTest do
assert String.contains?(
metrics,
- "beam_system_schedulers_online_info{host=\"nohost\",region=\"us-east-1\",id=\"nohost\"}"
- )
- end
- end
-
- describe "get_compressed_metrics/0" do
- test "builds metrics compressed using zlib" do
- compressed_metrics = PromEx.get_compressed_metrics()
-
- metrics = :zlib.uncompress(compressed_metrics)
-
- assert String.contains?(
- metrics,
- "# HELP beam_system_schedulers_online_info The number of scheduler threads that are online."
- )
-
- assert String.contains?(metrics, "# TYPE beam_system_schedulers_online_info gauge")
-
- assert String.contains?(
- metrics,
- "beam_system_schedulers_online_info{host=\"nohost\",region=\"us-east-1\",id=\"nohost\"}"
+ "beam_system_schedulers_online_info{host=\"nohost\",id=\"nohost\",region=\"us-east-1\"}"
)
end
end
diff --git a/test/realtime/monitoring/prometheus_test.exs b/test/realtime/monitoring/prometheus_test.exs
new file mode 100644
index 000000000..980fa7d34
--- /dev/null
+++ b/test/realtime/monitoring/prometheus_test.exs
@@ -0,0 +1,434 @@
+# Based on https://github.com/rkallos/peep/blob/708546ed069aebdf78ac1f581130332bd2e8b5b1/test/prometheus_test.exs
+defmodule Realtime.Monitoring.PrometheusTest do
+ use ExUnit.Case, async: true
+
+ alias Realtime.Monitoring.Prometheus
+ alias Telemetry.Metrics
+
+ defmodule StorageCounter do
+ @moduledoc false
+ use Agent
+
+ def start() do
+ Agent.start(fn -> 0 end, name: __MODULE__)
+ end
+
+ def fresh_id() do
+ Agent.get_and_update(__MODULE__, fn i -> {:"#{i}", i + 1} end)
+ end
+ end
+
+ # Test struct that doesn't implement String.Chars
+ defmodule TestError do
+ defstruct [:reason, :code]
+ end
+
+ setup_all do
+ StorageCounter.start()
+ :ok
+ end
+
+ @impls [:default, {Realtime.Monitoring.Peep.Partitioned, 4}, :striped]
+
+ for impl <- @impls do
+ test "#{inspect(impl)} - counter formatting" do
+ counter = Metrics.counter("prometheus.test.counter", description: "a counter")
+ name = StorageCounter.fresh_id()
+
+ opts = [
+ name: name,
+ metrics: [counter],
+ storage: unquote(impl)
+ ]
+
+ {:ok, _pid} = Peep.start_link(opts)
+
+ Peep.insert_metric(name, counter, 1, %{foo: :bar, baz: "quux"})
+
+ expected = [
+ "# HELP prometheus_test_counter a counter",
+ "# TYPE prometheus_test_counter counter",
+ ~s(prometheus_test_counter{baz="quux",foo="bar"} 1)
+ ]
+
+ assert export(name) == lines_to_string(expected)
+ end
+
+ describe "#{inspect(impl)} - sum" do
+ test "sum formatting" do
+ name = StorageCounter.fresh_id()
+ sum = Metrics.sum("prometheus.test.sum", description: "a sum")
+
+ opts = [
+ name: name,
+ metrics: [sum],
+ storage: unquote(impl)
+ ]
+
+ {:ok, _pid} = Peep.start_link(opts)
+
+ Peep.insert_metric(name, sum, 5, %{foo: :bar, baz: "quux"})
+ Peep.insert_metric(name, sum, 3, %{foo: :bar, baz: "quux"})
+
+ expected = [
+ "# HELP prometheus_test_sum a sum",
+ "# TYPE prometheus_test_sum counter",
+ ~s(prometheus_test_sum{baz="quux",foo="bar"} 8)
+ ]
+
+ assert export(name) == lines_to_string(expected)
+ end
+
+ test "custom type" do
+ name = StorageCounter.fresh_id()
+
+ sum =
+ Metrics.sum("prometheus.test.sum",
+ description: "a sum",
+ reporter_options: [prometheus_type: "gauge"]
+ )
+
+ opts = [
+ name: name,
+ metrics: [sum],
+ storage: unquote(impl)
+ ]
+
+ {:ok, _pid} = Peep.start_link(opts)
+
+ Peep.insert_metric(name, sum, 5, %{foo: :bar, baz: "quux"})
+ Peep.insert_metric(name, sum, 3, %{foo: :bar, baz: "quux"})
+
+ expected = [
+ "# HELP prometheus_test_sum a sum",
+ "# TYPE prometheus_test_sum gauge",
+ ~s(prometheus_test_sum{baz="quux",foo="bar"} 8)
+ ]
+
+ assert export(name) == lines_to_string(expected)
+ end
+ end
+
+ describe "#{inspect(impl)} - last_value" do
+ test "formatting" do
+ name = StorageCounter.fresh_id()
+ last_value = Metrics.last_value("prometheus.test.gauge", description: "a last_value")
+
+ opts = [
+ name: name,
+ metrics: [last_value],
+ storage: unquote(impl)
+ ]
+
+ {:ok, _pid} = Peep.start_link(opts)
+
+ Peep.insert_metric(name, last_value, 5, %{blee: :bloo, flee: "floo"})
+
+ expected = [
+ "# HELP prometheus_test_gauge a last_value",
+ "# TYPE prometheus_test_gauge gauge",
+ ~s(prometheus_test_gauge{blee="bloo",flee="floo"} 5)
+ ]
+
+ assert export(name) == lines_to_string(expected)
+ end
+
+ test "custom type" do
+ name = StorageCounter.fresh_id()
+
+ last_value =
+ Metrics.last_value("prometheus.test.gauge",
+ description: "a last_value",
+ reporter_options: [prometheus_type: :sum]
+ )
+
+ opts = [
+ name: name,
+ metrics: [last_value],
+ storage: unquote(impl)
+ ]
+
+ {:ok, _pid} = Peep.start_link(opts)
+
+ Peep.insert_metric(name, last_value, 5, %{blee: :bloo, flee: "floo"})
+
+ expected = [
+ "# HELP prometheus_test_gauge a last_value",
+ "# TYPE prometheus_test_gauge sum",
+ ~s(prometheus_test_gauge{blee="bloo",flee="floo"} 5)
+ ]
+
+ assert export(name) == lines_to_string(expected)
+ end
+ end
+
+ test "#{inspect(impl)} - dist formatting" do
+ name = StorageCounter.fresh_id()
+
+ dist =
+ Metrics.distribution("prometheus.test.distribution",
+ description: "a distribution",
+ reporter_options: [max_value: 1000]
+ )
+
+ opts = [
+ name: name,
+ metrics: [dist],
+ storage: unquote(impl)
+ ]
+
+ {:ok, _pid} = Peep.start_link(opts)
+
+ expected = []
+ assert export(name) == lines_to_string(expected)
+
+ Peep.insert_metric(name, dist, 1, %{glee: :gloo})
+
+ expected = [
+ "# HELP prometheus_test_distribution a distribution",
+ "# TYPE prometheus_test_distribution histogram",
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.222222"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.493827"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.825789"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="2.23152"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="2.727413"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="3.333505"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="4.074283"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="4.97968"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="6.086275"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="7.438781"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="9.091843"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="11.112253"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="13.581642"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="16.599785"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="20.288626"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="24.79721"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="30.307701"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="37.042745"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="45.274466"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="55.335459"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="67.632227"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="82.661611"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="101.030858"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="123.48216"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="150.92264"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="184.461004"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="225.452339"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="275.552858"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="336.786827"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="411.628344"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="503.101309"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="614.9016"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="751.5464"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="918.556711"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1122.680424"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="+Inf"} 1),
+ ~s(prometheus_test_distribution_sum{glee="gloo"} 1),
+ ~s(prometheus_test_distribution_count{glee="gloo"} 1)
+ ]
+
+ assert export(name) == lines_to_string(expected)
+
+ for i <- 2..2000 do
+ Peep.insert_metric(name, dist, i, %{glee: :gloo})
+ end
+
+ expected = [
+ "# HELP prometheus_test_distribution a distribution",
+ "# TYPE prometheus_test_distribution histogram",
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.222222"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.493827"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.825789"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="2.23152"} 2),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="2.727413"} 2),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="3.333505"} 3),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="4.074283"} 4),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="4.97968"} 4),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="6.086275"} 6),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="7.438781"} 7),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="9.091843"} 9),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="11.112253"} 11),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="13.581642"} 13),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="16.599785"} 16),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="20.288626"} 20),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="24.79721"} 24),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="30.307701"} 30),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="37.042745"} 37),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="45.274466"} 45),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="55.335459"} 55),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="67.632227"} 67),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="82.661611"} 82),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="101.030858"} 101),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="123.48216"} 123),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="150.92264"} 150),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="184.461004"} 184),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="225.452339"} 225),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="275.552858"} 275),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="336.786827"} 336),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="411.628344"} 411),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="503.101309"} 503),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="614.9016"} 614),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="751.5464"} 751),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="918.556711"} 918),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1122.680424"} 1122),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="+Inf"} 2000),
+ ~s(prometheus_test_distribution_sum{glee="gloo"} 2001000),
+ ~s(prometheus_test_distribution_count{glee="gloo"} 2000)
+ ]
+
+ assert export(name) == lines_to_string(expected)
+ end
+
+ test "#{inspect(impl)} - dist formatting pow10" do
+ name = StorageCounter.fresh_id()
+
+ dist =
+ Metrics.distribution("prometheus.test.distribution",
+ description: "a distribution",
+ reporter_options: [
+ max_value: 1000,
+ peep_bucket_calculator: Peep.Buckets.PowersOfTen
+ ]
+ )
+
+ opts = [
+ name: name,
+ metrics: [dist],
+ storage: unquote(impl)
+ ]
+
+ {:ok, _pid} = Peep.start_link(opts)
+
+ expected = []
+ assert export(name) == lines_to_string(expected)
+
+ Peep.insert_metric(name, dist, 1, %{glee: :gloo})
+
+ expected = [
+ "# HELP prometheus_test_distribution a distribution",
+ "# TYPE prometheus_test_distribution histogram",
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="10.0"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="100.0"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e3"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e4"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e5"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e6"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e7"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e8"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e9"} 1),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="+Inf"} 1),
+ ~s(prometheus_test_distribution_sum{glee="gloo"} 1),
+ ~s(prometheus_test_distribution_count{glee="gloo"} 1)
+ ]
+
+ assert export(name) == lines_to_string(expected)
+
+ f = fn ->
+ for i <- 1..2000 do
+ Peep.insert_metric(name, dist, i, %{glee: :gloo})
+ end
+ end
+
+ 1..20 |> Enum.map(fn _ -> Task.async(f) end) |> Task.await_many()
+
+ expected =
+ [
+ "# HELP prometheus_test_distribution a distribution",
+ "# TYPE prometheus_test_distribution histogram",
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="10.0"} 181),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="100.0"} 1981),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e3"} 19981),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e4"} 40001),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e5"} 40001),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e6"} 40001),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e7"} 40001),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e8"} 40001),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="1.0e9"} 40001),
+ ~s(prometheus_test_distribution_bucket{glee="gloo",le="+Inf"} 40001),
+ ~s(prometheus_test_distribution_sum{glee="gloo"} 40020001),
+ ~s(prometheus_test_distribution_count{glee="gloo"} 40001)
+ ]
+
+ assert export(name) == lines_to_string(expected)
+ end
+
+ test "#{inspect(impl)} - regression: label escaping" do
+ name = StorageCounter.fresh_id()
+
+ counter =
+ Metrics.counter(
+ "prometheus.test.counter",
+ description: "a counter"
+ )
+
+ opts = [
+ name: name,
+ metrics: [counter],
+ storage: unquote(impl)
+ ]
+
+ {:ok, _pid} = Peep.start_link(opts)
+
+ Peep.insert_metric(name, counter, 1, %{atom: "\"string\""})
+ Peep.insert_metric(name, counter, 1, %{"\"string\"" => :atom})
+ Peep.insert_metric(name, counter, 1, %{"\"string\"" => "\"string\""})
+ Peep.insert_metric(name, counter, 1, %{"string" => "string\n"})
+
+ expected = [
+ "# HELP prometheus_test_counter a counter",
+ "# TYPE prometheus_test_counter counter",
+ ~s(prometheus_test_counter{atom="\\\"string\\\""} 1),
+ ~s(prometheus_test_counter{\"string\"="atom"} 1),
+ ~s(prometheus_test_counter{\"string\"="\\\"string\\\""} 1),
+ ~s(prometheus_test_counter{string="string\\n"} 1)
+ ]
+
+ assert export(name) == lines_to_string(expected)
+ end
+
+ test "#{inspect(impl)} - regression: handle structs without String.Chars" do
+ name = StorageCounter.fresh_id()
+
+ counter =
+ Metrics.counter(
+ "prometheus.test.counter",
+ description: "a counter"
+ )
+
+ opts = [
+ name: name,
+ metrics: [counter],
+ storage: unquote(impl)
+ ]
+
+ {:ok, _pid} = Peep.start_link(opts)
+
+ # Create a struct that doesn't implement String.Chars
+ error_struct = %TestError{reason: :tcp_closed, code: 1001}
+
+ Peep.insert_metric(name, counter, 1, %{error: error_struct})
+
+ result = export(name)
+
+ # Should not crash and should contain the inspected struct representation
+ assert result =~ "prometheus_test_counter"
+ assert result =~ "TestError"
+ assert result =~ "tcp_closed"
+ end
+ end
+
+ defp export(name) do
+ Peep.get_all_metrics(name)
+ |> Prometheus.export()
+ |> IO.iodata_to_binary()
+ end
+
+ defp lines_to_string(lines) do
+ lines
+ |> Enum.map(&[&1, ?\n])
+ |> Enum.concat(["# EOF\n"])
+ |> IO.iodata_to_binary()
+ end
+end
diff --git a/test/realtime/nodes_test.exs b/test/realtime/nodes_test.exs
index ba3b6be0e..fc3203504 100644
--- a/test/realtime/nodes_test.exs
+++ b/test/realtime/nodes_test.exs
@@ -1,9 +1,82 @@
defmodule Realtime.NodesTest do
- use Realtime.DataCase, async: true
+ # async: false due to use of Clustered and tweaking Application env
+ use Realtime.DataCase, async: false
use Mimic
alias Realtime.Nodes
alias Realtime.Tenants
+ defp spawn_fake_node(region, node) do
+ parent = self()
+
+ fun = fn ->
+ :syn.join(RegionNodes, region, self(), node: node)
+ send(parent, :joined)
+
+ receive do
+ :ok -> :ok
+ end
+ end
+
+ {:ok, _pid} = start_supervised({Task, fun}, id: {region, node})
+ assert_receive :joined
+ end
+
+ describe "all_node_regions/0" do
+ test "returns all regions with nodes" do
+ spawn_fake_node("us-east-1", :node_1)
+ spawn_fake_node("ap-2", :node_2)
+ spawn_fake_node("ap-2", :node_3)
+
+ assert Nodes.all_node_regions() |> Enum.sort() == ["ap-2", "us-east-1"]
+ end
+
+ test "with no other nodes, returns my region only" do
+ assert Nodes.all_node_regions() == ["us-east-1"]
+ end
+ end
+
+ describe "region_nodes/1" do
+ test "nil region returns empty list" do
+ assert Nodes.region_nodes(nil) == []
+ end
+
+ test "returns nodes from region" do
+ region = "ap-southeast-2"
+ spawn_fake_node(region, :node_1)
+ spawn_fake_node(region, :node_2)
+
+ spawn_fake_node("eu-west-2", :node_3)
+
+ assert Nodes.region_nodes(region) == [:node_1, :node_2]
+ assert Nodes.region_nodes("eu-west-2") == [:node_3]
+ end
+
+ test "on non-existing region, returns empty list" do
+ assert Nodes.region_nodes("non-existing-region") == []
+ end
+ end
+
+ describe "node_from_region/2" do
+ test "nil region returns error" do
+ assert {:error, :not_available} = Nodes.node_from_region(nil, :any_key)
+ end
+
+ test "empty region returns error" do
+ assert {:error, :not_available} = Nodes.node_from_region("empty-region", :any_key)
+ end
+
+ test "returns the same node given the same key" do
+ region = "ap-southeast-3"
+ spawn_fake_node(region, :node_1)
+ spawn_fake_node(region, :node_2)
+
+ spawn_fake_node("eu-west-3", :node_3)
+
+ assert {:ok, :node_2} = Nodes.node_from_region(region, :key1)
+ assert {:ok, :node_2} = Nodes.node_from_region(region, :key1)
+ end
+ end
+
describe "get_node_for_tenant/1" do
setup do
tenant = Containers.checkout_tenant()
@@ -16,7 +89,7 @@ defmodule Realtime.NodesTest do
reject(&:syn.members/2)
end
- test "on existing tenant id, returns the node for the region using syn", %{
+ test "on existing tenant id, returns a node from the region using load-aware picking", %{
tenant: tenant,
region: region
} do
@@ -29,26 +102,24 @@ defmodule Realtime.NodesTest do
]
end)
- index = :erlang.phash2(tenant.external_id, length(expected_nodes))
-
- expected_node = Enum.fetch!(expected_nodes, index)
expected_region = Tenants.region(tenant)
assert {:ok, node, region} = Nodes.get_node_for_tenant(tenant)
- assert node == expected_node
assert region == expected_region
+ assert node in expected_nodes
end
- test "on existing tenant id, and a single node for a given region, returns default", %{
+ test "on existing tenant id, and a single node for a given region, returns single node", %{
tenant: tenant,
region: region
} do
expect(:syn, :members, fn RegionNodes, ^region -> [{self(), [node: :tenant@closest1]}] end)
+
assert {:ok, node, region} = Nodes.get_node_for_tenant(tenant)
expected_region = Tenants.region(tenant)
- assert node == node()
+ assert node == :tenant@closest1
assert region == expected_region
end
@@ -62,4 +133,248 @@ defmodule Realtime.NodesTest do
assert region == expected_region
end
end
+
+ describe "platform_region_translator/1" do
+ test "returns nil for nil input" do
+ assert Nodes.platform_region_translator(nil) == nil
+ end
+
+ test "uses default mapping when no custom mapping configured" do
+ original = Application.get_env(:realtime, :region_mapping)
+ on_exit(fn -> Application.put_env(:realtime, :region_mapping, original) end)
+
+ Application.put_env(:realtime, :region_mapping, nil)
+
+ assert Nodes.platform_region_translator("eu-north-1") == "eu-west-2"
+ assert Nodes.platform_region_translator("us-west-2") == "us-west-1"
+ assert Nodes.platform_region_translator("unknown-region") == nil
+ end
+
+ test "uses custom mapping when configured without falling back to defaults" do
+ original = Application.get_env(:realtime, :region_mapping)
+ on_exit(fn -> Application.put_env(:realtime, :region_mapping, original) end)
+
+ custom_mapping = %{
+ "custom-region-1" => "us-east-1",
+ "eu-north-1" => "custom-target"
+ }
+
+ Application.put_env(:realtime, :region_mapping, custom_mapping)
+
+ # Custom mappings work
+ assert Nodes.platform_region_translator("custom-region-1") == "us-east-1"
+ assert Nodes.platform_region_translator("eu-north-1") == "custom-target"
+
+ # Unmapped regions return nil (no fallback to defaults)
+ assert Nodes.platform_region_translator("us-west-2") == nil
+ end
+ end
+
+ describe "node_load/1" do
+ test "returns {:error, :not_enough_data} for local node with insufficient uptime" do
+ assert {:error, :not_enough_data} = Nodes.node_load(node())
+ end
+ end
+
+ describe "node_load/1 with sufficient uptime" do
+ setup do
+ Cachex.clear(Realtime.Nodes.Cache)
+ Application.put_env(:realtime, :node_balance_uptime_threshold_in_ms, 0)
+
+ on_exit(fn ->
+ Application.put_env(:realtime, :node_balance_uptime_threshold_in_ms, 999_999_999_999)
+ end)
+ end
+
+ test "returns cpu load for local node" do
+ load = Nodes.node_load(node())
+
+ assert is_integer(load)
+ assert load >= 0
+ end
+
+ test "returns cpu load for remote node" do
+ {:ok, remote_node} = Clustered.start()
+
+ load = Nodes.node_load(remote_node)
+
+ assert is_integer(load)
+ assert load >= 0
+ end
+
+ test "remote node can also get its own load" do
+ {:ok, remote_node} = Clustered.start()
+
+ load = :rpc.call(remote_node, Nodes, :node_load, [remote_node])
+
+ assert is_integer(load)
+ assert load >= 0
+ end
+
+ test "caches remote node load and sets expiration" do
+ {:ok, remote_node} = Clustered.start(nil, extra_config: [{:realtime, :node_balance_uptime_threshold_in_ms, 0}])
+
+ assert {:ok, false} = Cachex.exists?(Realtime.Nodes.Cache, remote_node)
+
+ load1 = Nodes.node_load(remote_node)
+ assert is_integer(load1)
+
+ assert {:ok, true} = Cachex.exists?(Realtime.Nodes.Cache, remote_node)
+ assert {:ok, ttl} = Cachex.ttl(Realtime.Nodes.Cache, remote_node)
+ assert is_integer(ttl) and ttl > 0 and ttl <= 60_000
+
+ reject(&Realtime.GenRpc.call/5)
+
+ load2 = Nodes.node_load(remote_node)
+ assert load1 == load2
+ end
+
+ test "does not cache rpc errors for remote node" do
+ fake_node = :fake_remote_node
+
+ expect(Realtime.GenRpc, :call, fn _, _, _, _, _ -> {:error, :rpc_error, :badrpc} end)
+
+ assert {:ok, false} = Cachex.exists?(Realtime.Nodes.Cache, fake_node)
+
+ result = Nodes.node_load(fake_node)
+ assert result == {:error, :rpc_error, :badrpc}
+
+ assert {:ok, false} = Cachex.exists?(Realtime.Nodes.Cache, fake_node)
+ end
+
+ test "caches {:error, :not_enough_data} for remote node with insufficient uptime" do
+ {:ok, remote_node} =
+ Clustered.start(nil, extra_config: [{:realtime, :node_balance_uptime_threshold_in_ms, 999_999_999_999}])
+
+ assert {:ok, false} = Cachex.exists?(Realtime.Nodes.Cache, remote_node)
+
+ result = Nodes.node_load(remote_node)
+ assert result == {:error, :not_enough_data}
+
+ assert {:ok, true} = Cachex.exists?(Realtime.Nodes.Cache, remote_node)
+
+ reject(&Realtime.GenRpc.call/5)
+
+ assert {:error, :not_enough_data} = Nodes.node_load(remote_node)
+ end
+ end
+
+ describe "launch_node/3 load-aware but not enough uptime" do
+ test "returns the one node from the region when one node is available" do
+ region = "clustered-test-region"
+ spawn_fake_node(region, :remote_node)
+
+ result = Nodes.launch_node(region, node(), "test-tenant-123")
+
+ assert result == :remote_node
+ end
+
+ test "returns default node when no region nodes available" do
+ result = Nodes.launch_node("empty-region", node(), "test-tenant-123")
+
+ assert result == node()
+ end
+
+ test "same tenant_id picks same nodes" do
+ region = "deterministic-region"
+ spawn_fake_node(region, :node_a)
+ spawn_fake_node(region, :node_b)
+ spawn_fake_node(region, :node_c)
+
+ tenant_id = "test-tenant-456"
+
+ # Call 10 times, should always return same node with hashed tenant ID
+ results = for _ <- 1..10, do: Nodes.launch_node(region, node(), tenant_id)
+
+ assert length(Enum.uniq(results)) == 1
+ end
+
+ test "different tenant_ids distribute across nodes" do
+ region = "distribution-region"
+ spawn_fake_node(region, :node_a)
+ spawn_fake_node(region, :node_b)
+ spawn_fake_node(region, :node_c)
+
+ # Generate 30 different tenant IDs
+ tenant_ids = for i <- 1..30, do: "tenant-#{i}"
+
+ results =
+ Enum.map(tenant_ids, fn id ->
+ Nodes.launch_node(region, node(), id)
+ end)
+
+ # Should distribute across multiple nodes (at least 2) using the hashed tenant IDs
+ assert length(Enum.uniq(results)) >= 2
+ end
+ end
+
+ describe "launch_node/3 with load-aware node picking enabled" do
+ setup do
+ Application.put_env(:realtime, :node_balance_uptime_threshold_in_ms, 0)
+
+ on_exit(fn ->
+ Application.put_env(:realtime, :node_balance_uptime_threshold_in_ms, 999_999_999_999)
+ end)
+ end
+
+ test "picks deterministic node when one node has insufficient data" do
+ region = "uptime-test-region"
+ spawn_fake_node(region, :node_a)
+ spawn_fake_node(region, :node_b)
+
+ stub(Nodes, :node_load, fn
+ :node_a -> {:error, :not_enough_data}
+ :node_b -> 100
+ end)
+
+ results = for _ <- 1..10, do: Nodes.launch_node(region, node(), "test-tenant-123")
+
+ # Deterministic with hashed tenant ID
+ assert length(Enum.uniq(results)) == 1
+ assert Enum.uniq(results) == [:node_b]
+ end
+
+ test "compares load between nodes and picks the least loaded deterministically" do
+ {:ok, remote_node} = Clustered.start(nil, [{:realtime, :node_balance_uptime_threshold_in_ms, 0}])
+
+ region = "load-test-region"
+ spawn_fake_node(region, node())
+ spawn_fake_node(region, remote_node)
+
+ local_load = Nodes.node_load(node())
+ remote_load = Nodes.node_load(remote_node)
+
+ assert is_integer(local_load) and local_load >= 0
+ assert is_integer(remote_load) and remote_load >= 0
+
+ results = for _ <- 1..10, do: Nodes.launch_node(region, node(), "test-tenant-789")
+
+ # Should be deterministic - all same node within time bucket
+ assert length(Enum.uniq(results)) == 1
+ assert Enum.all?(results, &(&1 in [node(), remote_node]))
+ end
+ end
+
+ describe "short_node_id_from_name/1" do
+ test "extracts short ID from fly.io-style IPv6 node name" do
+ assert Nodes.short_node_id_from_name(:"realtime-prod@fdaa:0:cc:a7b:b385:83c3:cfe3:2") ==
+ "83c3cfe3"
+ end
+
+ test "returns full name for localhost node" do
+ assert Nodes.short_node_id_from_name(:"pink@127.0.0.1") == "pink@127.0.0.1"
+ end
+
+ test "returns host for standard domain-style node names" do
+ assert Nodes.short_node_id_from_name(:"realtime@host.name.internal") == "host.name.internal"
+ end
+
+ test "returns host for simple IP node" do
+ assert Nodes.short_node_id_from_name(:"pink@10.0.1.1") == "10.0.1.1"
+ end
+
+ test "returns host for nonode@nohost" do
+ assert Nodes.short_node_id_from_name(:nonode@nohost) == "nohost"
+ end
+ end
end
diff --git a/test/realtime/postgres_decoder_test.exs b/test/realtime/postgres_decoder_test.exs
index 9516e5e9a..b8bbe2723 100644
--- a/test/realtime/postgres_decoder_test.exs
+++ b/test/realtime/postgres_decoder_test.exs
@@ -2,24 +2,23 @@ defmodule Realtime.PostgresDecoderTest do
use ExUnit.Case, async: true
alias Realtime.Adapters.Postgres.Decoder
- alias Decoder.Messages.{
- Begin,
- Commit,
- Origin,
- Relation,
- Relation.Column,
- Insert,
- Update,
- Delete,
- Truncate,
- Type
- }
+ alias Decoder.Messages.Begin
+ alias Decoder.Messages.Commit
+ alias Decoder.Messages.Insert
+ alias Decoder.Messages.Origin
+ alias Decoder.Messages.Relation
+ alias Decoder.Messages.Relation.Column
+ alias Decoder.Messages.Type
+ alias Decoder.Messages.Unsupported
test "decodes begin messages" do
{:ok, expected_dt_no_microseconds, 0} = DateTime.from_iso8601("2019-07-18T17:02:35Z")
expected_dt = DateTime.add(expected_dt_no_microseconds, 726_322, :microsecond)
- assert Decoder.decode_message(<<66, 0, 0, 0, 2, 167, 244, 168, 128, 0, 2, 48, 246, 88, 88, 213, 242, 0, 0, 2, 107>>) ==
+ assert Decoder.decode_message(
+ <<66, 0, 0, 0, 2, 167, 244, 168, 128, 0, 2, 48, 246, 88, 88, 213, 242, 0, 0, 2, 107>>,
+ %{}
+ ) ==
%Begin{commit_timestamp: expected_dt, final_lsn: {2, 2_817_828_992}, xid: 619}
end
@@ -28,7 +27,8 @@ defmodule Realtime.PostgresDecoderTest do
expected_dt = DateTime.add(expected_dt_no_microseconds, 726_322, :microsecond)
assert Decoder.decode_message(
- <<67, 0, 0, 0, 0, 2, 167, 244, 168, 128, 0, 0, 0, 2, 167, 244, 168, 176, 0, 2, 48, 246, 88, 88, 213, 242>>
+ <<67, 0, 0, 0, 0, 2, 167, 244, 168, 128, 0, 0, 0, 2, 167, 244, 168, 176, 0, 2, 48, 246, 88, 88, 213, 242>>,
+ %{}
) == %Commit{
flags: [],
lsn: {2, 2_817_828_992},
@@ -38,7 +38,7 @@ defmodule Realtime.PostgresDecoderTest do
end
test "decodes origin messages" do
- assert Decoder.decode_message(<<79, 0, 0, 0, 2, 167, 244, 168, 128>> <> "Elmer Fud") ==
+ assert Decoder.decode_message(<<79, 0, 0, 0, 2, 167, 244, 168, 128>> <> "Elmer Fud", %{}) ==
%Origin{
origin_commit_lsn: {2, 2_817_828_992},
name: "Elmer Fud"
@@ -48,7 +48,8 @@ defmodule Realtime.PostgresDecoderTest do
test "decodes relation messages" do
assert Decoder.decode_message(
<<82, 0, 0, 96, 0, 112, 117, 98, 108, 105, 99, 0, 102, 111, 111, 0, 100, 0, 2, 0, 98, 97, 114, 0, 0, 0, 0,
- 25, 255, 255, 255, 255, 1, 105, 100, 0, 0, 0, 0, 23, 255, 255, 255, 255>>
+ 25, 255, 255, 255, 255, 1, 105, 100, 0, 0, 0, 0, 23, 255, 255, 255, 255>>,
+ %{}
) == %Relation{
id: 24_576,
namespace: "public",
@@ -74,7 +75,8 @@ defmodule Realtime.PostgresDecoderTest do
test "decodes type messages" do
assert Decoder.decode_message(
<<89, 0, 0, 128, 52, 112, 117, 98, 108, 105, 99, 0, 101, 120, 97, 109, 112, 108, 101, 95, 116, 121, 112,
- 101, 0>>
+ 101, 0>>,
+ %{}
) ==
%Type{
id: 32_820,
@@ -83,110 +85,135 @@ defmodule Realtime.PostgresDecoderTest do
}
end
- describe "truncate messages" do
- test "decodes messages" do
- assert Decoder.decode_message(<<84, 0, 0, 0, 1, 0, 0, 0, 96, 0>>) ==
- %Truncate{
- number_of_relations: 1,
- options: [],
- truncated_relations: [24_576]
- }
- end
-
- test "decodes messages with cascade option" do
- assert Decoder.decode_message(<<84, 0, 0, 0, 1, 1, 0, 0, 96, 0>>) ==
- %Truncate{
- number_of_relations: 1,
- options: [:cascade],
- truncated_relations: [24_576]
- }
+ describe "data message (TupleData) decoder" do
+ setup do
+ relation = %{
+ id: 24_576,
+ namespace: "public",
+ name: "foo",
+ columns: [
+ %Column{name: "id", type: "uuid"},
+ %Column{name: "bar", type: "text"}
+ ]
+ }
+
+ %{relation: relation}
end
- test "decodes messages with restart identity option" do
- assert Decoder.decode_message(<<84, 0, 0, 0, 1, 2, 0, 0, 96, 0>>) ==
- %Truncate{
- number_of_relations: 1,
- options: [:restart_identity],
- truncated_relations: [24_576]
- }
- end
- end
+ test "decodes insert messages", %{relation: relation} do
+ uuid = UUID.uuid4()
+ string = Generators.random_string()
+
+ data =
+ "I" <>
+ <> <>
+ "N" <>
+ <<2::integer-16>> <>
+ "b" <>
+ <<16::integer-32>> <>
+ UUID.string_to_binary!(uuid) <>
+ "b" <>
+ <> <>
+ string
- describe "data message (TupleData) decoder" do
- test "decodes insert messages" do
assert Decoder.decode_message(
- <<73, 0, 0, 96, 0, 78, 0, 2, 116, 0, 0, 0, 3, 98, 97, 122, 116, 0, 0, 0, 3, 53, 54, 48>>
+ data,
+ %{relation.id => relation}
) == %Insert{
- relation_id: 24_576,
- tuple_data: {"baz", "560"}
- }
- end
-
- test "decodes insert messages with null values" do
- assert Decoder.decode_message(<<73, 0, 0, 96, 0, 78, 0, 2, 110, 116, 0, 0, 0, 3, 53, 54, 48>>) == %Insert{
- relation_id: 24_576,
- tuple_data: {nil, "560"}
- }
- end
-
- test "decodes insert messages with unchanged toasted values" do
- assert Decoder.decode_message(<<73, 0, 0, 96, 0, 78, 0, 2, 117, 116, 0, 0, 0, 3, 53, 54, 48>>) == %Insert{
- relation_id: 24_576,
- tuple_data: {:unchanged_toast, "560"}
+ relation_id: relation.id,
+ tuple_data: {uuid, string}
}
end
- test "decodes update messages with default replica identity setting" do
- assert Decoder.decode_message(
- <<85, 0, 0, 96, 0, 78, 0, 2, 116, 0, 0, 0, 7, 101, 120, 97, 109, 112, 108, 101, 116, 0, 0, 0, 3, 53, 54,
- 48>>
- ) == %Update{
- relation_id: 24_576,
- changed_key_tuple_data: nil,
- old_tuple_data: nil,
- tuple_data: {"example", "560"}
- }
- end
+ test "ignores unknown relations", %{relation: relation} do
+ uuid = UUID.uuid4()
+ string = Generators.random_string()
+
+ data =
+ "I" <>
+ <<679::integer-32>> <>
+ "N" <>
+ <<2::integer-16>> <>
+ "b" <>
+ <<16::integer-32>> <>
+ UUID.string_to_binary!(uuid) <>
+ "b" <>
+ <> <>
+ string
- test "decodes update messages with FULL replica identity setting" do
assert Decoder.decode_message(
- <<85, 0, 0, 96, 0, 79, 0, 2, 116, 0, 0, 0, 3, 98, 97, 122, 116, 0, 0, 0, 3, 53, 54, 48, 78, 0, 2, 116, 0,
- 0, 0, 7, 101, 120, 97, 109, 112, 108, 101, 116, 0, 0, 0, 3, 53, 54, 48>>
- ) == %Update{
- relation_id: 24_576,
- changed_key_tuple_data: nil,
- old_tuple_data: {"baz", "560"},
- tuple_data: {"example", "560"}
- }
+ data,
+ %{relation.id => relation}
+ ) == %Unsupported{}
end
- test "decodes update messages with USING INDEX replica identity setting" do
- assert Decoder.decode_message(
- <<85, 0, 0, 96, 0, 75, 0, 2, 116, 0, 0, 0, 3, 98, 97, 122, 110, 78, 0, 2, 116, 0, 0, 0, 7, 101, 120, 97,
- 109, 112, 108, 101, 116, 0, 0, 0, 3, 53, 54, 48>>
- ) == %Update{
- relation_id: 24_576,
- changed_key_tuple_data: {"baz", nil},
- old_tuple_data: nil,
- tuple_data: {"example", "560"}
+ test "decodes insert messages with null values", %{relation: relation} do
+ string = Generators.random_string()
+
+ data =
+ "I" <>
+ <> <>
+ "N" <>
+ <<2::integer-16>> <>
+ "n" <>
+ "b" <>
+ <> <>
+ string
+
+ assert Decoder.decode_message(data, %{relation.id => relation}) == %Insert{
+ relation_id: relation.id,
+ tuple_data: {nil, string}
}
end
- test "decodes DELETE messages with USING INDEX replica identity setting" do
- assert Decoder.decode_message(
- <<68, 0, 0, 96, 0, 75, 0, 2, 116, 0, 0, 0, 7, 101, 120, 97, 109, 112, 108, 101, 110>>
- ) == %Delete{
- relation_id: 24_576,
- changed_key_tuple_data: {"example", nil}
+ test "decodes insert messages with bytea column" do
+ relation = %{
+ id: 24_576,
+ namespace: "realtime",
+ name: "messages",
+ columns: [
+ %Column{name: "id", type: "uuid"},
+ %Column{name: "binary_payload", type: "bytea"}
+ ]
+ }
+
+ uuid = UUID.uuid4()
+ bytes = <<0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF, 0x01>>
+
+ data =
+ "I" <>
+ <> <>
+ "N" <>
+ <<2::integer-16>> <>
+ "b" <>
+ <<16::integer-32>> <>
+ UUID.string_to_binary!(uuid) <>
+ "b" <>
+ <> <>
+ bytes
+
+ assert Decoder.decode_message(data, %{relation.id => relation}) == %Insert{
+ relation_id: relation.id,
+ tuple_data: {uuid, bytes}
}
end
- test "decodes DELETE messages with FULL replica identity setting" do
- assert Decoder.decode_message(
- <<68, 0, 0, 96, 0, 79, 0, 2, 116, 0, 0, 0, 3, 98, 97, 122, 116, 0, 0, 0, 3, 53, 54, 48>>
- ) == %Delete{
- relation_id: 24_576,
- old_tuple_data: {"baz", "560"}
+ test "decodes insert messages with unchanged toasted values", %{relation: relation} do
+ string = Generators.random_string()
+
+ data =
+ "I" <>
+ <> <>
+ "N" <>
+ <<2::integer-16>> <>
+ "u" <>
+ "b" <>
+ <> <>
+ string
+
+ assert Decoder.decode_message(data, %{relation.id => relation}) == %Insert{
+ relation_id: relation.id,
+ tuple_data: {:unchanged_toast, string}
}
end
end
diff --git a/test/realtime/rate_counter/rate_counter_test.exs b/test/realtime/rate_counter/rate_counter_test.exs
index 6d3f57401..3e62fd915 100644
--- a/test/realtime/rate_counter/rate_counter_test.exs
+++ b/test/realtime/rate_counter/rate_counter_test.exs
@@ -22,7 +22,7 @@ defmodule Realtime.RateCounterTest do
max_bucket_len: 60,
tick: 1000,
tick_ref: _,
- idle_shutdown: 900_000,
+ idle_shutdown: 300_000,
idle_shutdown_ref: _,
telemetry: %{emit: false},
limit: %{log: false}
@@ -62,7 +62,7 @@ defmodule Realtime.RateCounterTest do
max_bucket_len: 60,
tick: 10,
tick_ref: _,
- idle_shutdown: 900_000,
+ idle_shutdown: 300_000,
idle_shutdown_ref: _,
telemetry: %{
emit: true,
@@ -174,7 +174,7 @@ defmodule Realtime.RateCounterTest do
log =
capture_log(fn ->
- GenCounter.add(args.id, 50)
+ GenCounter.add(args.id, 6)
Process.sleep(300)
end)
@@ -185,7 +185,7 @@ defmodule Realtime.RateCounterTest do
# Splitting by the error message returns the error message and the rest of the log only
assert length(String.split(log, "ErrorMessage: Reason")) == 2
- Process.sleep(300)
+ Process.sleep(400)
assert {:ok, %RateCounter{limit: %{triggered: false}}} = RateCounter.get(args)
end
@@ -197,7 +197,7 @@ defmodule Realtime.RateCounterTest do
id: id,
opts: [
tick: 100,
- max_bucket_len: 3,
+ max_bucket_len: 5,
limit: [
value: 49,
measurement: :sum,
@@ -215,7 +215,7 @@ defmodule Realtime.RateCounterTest do
avg: +0.0,
sum: 0,
bucket: _,
- max_bucket_len: 3,
+ max_bucket_len: 5,
telemetry: %{emit: false},
limit: %{
log: true,
@@ -228,7 +228,7 @@ defmodule Realtime.RateCounterTest do
log =
capture_log(fn ->
GenCounter.add(args.id, 100)
- Process.sleep(100)
+ Process.sleep(120)
end)
assert {:ok, %RateCounter{sum: sum, limit: %{triggered: true}}} = RateCounter.get(args)
@@ -239,7 +239,7 @@ defmodule Realtime.RateCounterTest do
# Splitting by the error message returns the error message and the rest of the log only
assert length(String.split(log, "ErrorMessage: Reason")) == 2
- Process.sleep(400)
+ Process.sleep(600)
assert {:ok, %RateCounter{sum: 0, limit: %{triggered: false}}} = RateCounter.get(args)
end
@@ -260,10 +260,10 @@ defmodule Realtime.RateCounterTest do
test "rate counters shut themselves down when no activity occurs on the GenCounter" do
args = %Args{id: {:domain, :metric, Ecto.UUID.generate()}}
- {:ok, pid} = RateCounter.new(args, idle_shutdown: 5)
+ {:ok, pid} = RateCounter.new(args, idle_shutdown: 100)
Process.monitor(pid)
- assert_receive {:DOWN, _ref, :process, ^pid, :normal}, 25
+ assert_receive {:DOWN, _ref, :process, ^pid, :normal}, 200
# Cache has not expired yet
assert {:ok, %RateCounter{}} = Cachex.get(RateCounter, args.id)
Process.sleep(2000)
@@ -301,6 +301,78 @@ defmodule Realtime.RateCounterTest do
end
end
+ describe "avg normalization" do
+ test "avg represents events per second regardless of tick interval" do
+ # 1-second tick: add 10 events → avg should be ~10 events/second
+ id_1s = {:domain, :metric, Ecto.UUID.generate()}
+ args_1s = %Args{id: id_1s, opts: [tick: 1_000, max_bucket_len: 1]}
+ RateCounterHelper.new!(args_1s)
+
+ GenCounter.add(id_1s, 10)
+ {:ok, state_1s} = RateCounterHelper.tick!(args_1s)
+ assert_in_delta state_1s.avg, 10.0, 0.01
+
+ # 5-second tick: add 50 events (= 10 per second) → avg should also be ~10 events/second
+ id_5s = {:domain, :metric, Ecto.UUID.generate()}
+ args_5s = %Args{id: id_5s, opts: [tick: 5_000, max_bucket_len: 1]}
+ RateCounterHelper.new!(args_5s)
+
+ GenCounter.add(id_5s, 50)
+ {:ok, state_5s} = RateCounterHelper.tick!(args_5s)
+ assert_in_delta state_5s.avg, 10.0, 0.01
+ end
+
+ test "avg limit triggers and unsets correctly with a non-1-second tick" do
+ id = {:domain, :metric, Ecto.UUID.generate()}
+
+ args = %Args{
+ id: id,
+ opts: [
+ tick: 5_000,
+ max_bucket_len: 1,
+ limit: [
+ value: 10,
+ measurement: :avg,
+ log_fn: fn ->
+ Logger.warning("RateLimitReached", external_id: "tenant123", project: "tenant123")
+ end
+ ]
+ ]
+ }
+
+ RateCounterHelper.new!(args)
+
+ # 60 events over a 5-second tick = 12 events/second, above the 10/s limit
+ log =
+ capture_log(fn ->
+ GenCounter.add(id, 60)
+ RateCounterHelper.tick!(args)
+ end)
+
+ assert {:ok, %RateCounter{avg: avg, limit: %{triggered: true}}} = RateCounter.get(args)
+ assert_in_delta avg, 12.0, 0.01
+ assert log =~ "RateLimitReached"
+
+ # 40 events over a 5-second tick = 8 events/second, below the 10/s limit
+ GenCounter.add(id, 40)
+ RateCounterHelper.tick!(args)
+ assert {:ok, %RateCounter{avg: avg, limit: %{triggered: false}}} = RateCounter.get(args)
+ assert_in_delta avg, 8.0, 0.01
+ end
+ end
+
+ describe "publish_update/1" do
+ test "cause shutdown with update message from update topic" do
+ args = %Args{id: {:domain, :metric, Ecto.UUID.generate()}}
+ {:ok, pid} = RateCounter.new(args)
+
+ Process.monitor(pid)
+ RateCounter.publish_update(args.id)
+
+ assert_receive {:DOWN, _ref, :process, ^pid, :normal}
+ end
+ end
+
describe "get/1" do
test "gets the state of a rate counter" do
args = %Args{id: {:domain, :metric, Ecto.UUID.generate()}}
@@ -316,37 +388,5 @@ defmodule Realtime.RateCounterTest do
end
end
- describe "stop/1" do
- test "stops rate counters for a given entity" do
- entity_id = Ecto.UUID.generate()
- fake_terms = Enum.map(1..10, fn _ -> {:domain, :"metric_#{random_string()}", Ecto.UUID.generate()} end)
- terms = Enum.map(1..10, fn _ -> {:domain, :"metric_#{random_string()}", entity_id} end)
-
- for term <- terms do
- args = %Args{id: term}
- {:ok, _} = RateCounter.new(args)
- assert {:ok, %RateCounter{}} = RateCounter.get(args)
- end
-
- for term <- fake_terms do
- args = %Args{id: term}
- {:ok, _} = RateCounter.new(args)
- assert {:ok, %RateCounter{}} = RateCounter.get(args)
- end
-
- assert :ok = RateCounter.stop(entity_id)
- # Wait for processes to shut down and Registry to update
- Process.sleep(100)
-
- for term <- terms do
- assert [] = Registry.lookup(Realtime.Registry.Unique, {RateCounter, :rate_counter, term})
- end
-
- for term <- fake_terms do
- assert [{_pid, _value}] = Registry.lookup(Realtime.Registry.Unique, {RateCounter, :rate_counter, term})
- end
- end
- end
-
def handle_telemetry(event, measures, metadata, pid: pid), do: send(pid, {event, measures, metadata})
end
diff --git a/test/realtime/repo_replica_test.exs b/test/realtime/repo_replica_test.exs
index a3734d31b..e794f060f 100644
--- a/test/realtime/repo_replica_test.exs
+++ b/test/realtime/repo_replica_test.exs
@@ -1,14 +1,23 @@
defmodule Realtime.Repo.ReplicaTest do
- use ExUnit.Case
+ # application env being changed
+ use ExUnit.Case, async: false
alias Realtime.Repo.Replica
setup do
previous_platform = Application.get_env(:realtime, :platform)
previous_region = Application.get_env(:realtime, :region)
+ previous_master_region = Application.get_env(:realtime, :master_region)
+ previous_main_replica = Application.get_env(:realtime, Replica)
on_exit(fn ->
Application.put_env(:realtime, :platform, previous_platform)
Application.put_env(:realtime, :region, previous_region)
+ Application.put_env(:realtime, :master_region, previous_master_region)
+ Application.delete_env(:realtime, Replica)
+
+ if previous_main_replica do
+ Application.put_env(:realtime, Replica, previous_main_replica)
+ end
end)
end
@@ -16,12 +25,20 @@ defmodule Realtime.Repo.ReplicaTest do
for {region, mod} <- Replica.replicas_aws() do
setup do
Application.put_env(:realtime, :platform, :aws)
+ Application.put_env(:realtime, :master_region, "special-region")
+ :ok
end
test "handles #{region} region" do
Application.put_env(:realtime, :region, unquote(region))
replica_asserts(unquote(mod), Replica.replica())
end
+
+ test "defaults to Realtime.Repo if region is equal to master region on #{region}" do
+ Application.put_env(:realtime, :region, unquote(region))
+ Application.put_env(:realtime, :master_region, unquote(region))
+ replica_asserts(Realtime.Repo, Replica.replica())
+ end
end
test "defaults to Realtime.Repo if region is not configured" do
@@ -34,6 +51,8 @@ defmodule Realtime.Repo.ReplicaTest do
for {region, mod} <- Replica.replicas_fly() do
setup do
Application.put_env(:realtime, :platform, :fly)
+ Application.put_env(:realtime, :master_region, "special-region")
+ :ok
end
test "handles #{region} region" do
@@ -48,6 +67,53 @@ defmodule Realtime.Repo.ReplicaTest do
end
end
+ describe "main replica module configuration" do
+ setup do
+ Application.put_env(:realtime, Replica, hostname: "test-replica-host")
+ :ok
+ end
+
+ test "uses main replica module when configured on AWS platform" do
+ Application.put_env(:realtime, :platform, :aws)
+ Application.put_env(:realtime, :region, "us-west-1")
+ Application.put_env(:realtime, :master_region, "us-east-1")
+
+ replica_asserts(Replica, Replica.replica())
+ end
+
+ test "uses main replica module when configured on Fly platform" do
+ Application.put_env(:realtime, :platform, :fly)
+ Application.put_env(:realtime, :region, "sea")
+ Application.put_env(:realtime, :master_region, "sjc")
+
+ replica_asserts(Replica, Replica.replica())
+ end
+
+ test "still defaults to Realtime.Repo when region matches master region" do
+ Application.put_env(:realtime, :platform, :aws)
+ Application.put_env(:realtime, :region, "us-west-1")
+ Application.put_env(:realtime, :master_region, "us-west-1")
+
+ replica_asserts(Realtime.Repo, Replica.replica())
+ end
+
+ test "uses main replica module when region is unknown" do
+ Application.put_env(:realtime, :platform, :aws)
+ Application.put_env(:realtime, :region, "unknown-region")
+ Application.put_env(:realtime, :master_region, "us-east-1")
+
+ replica_asserts(Replica, Replica.replica())
+ end
+
+ test "uses main replica module without platform configuration" do
+ Application.delete_env(:realtime, :platform)
+ Application.put_env(:realtime, :region, "us-west-1")
+ Application.put_env(:realtime, :master_region, "us-east-1")
+
+ replica_asserts(Replica, Replica.replica())
+ end
+ end
+
defp replica_asserts(mod, replica) do
assert mod == replica
assert [Ecto.Repo] == replica.__info__(:attributes) |> Keyword.get(:behaviour)
diff --git a/test/realtime/rpc_test.exs b/test/realtime/rpc_test.exs
index 221cd781b..9c83d7064 100644
--- a/test/realtime/rpc_test.exs
+++ b/test/realtime/rpc_test.exs
@@ -81,8 +81,7 @@ defmodule Realtime.RpcTest do
func: :test_success,
origin_node: ^origin_node,
target_node: ^node,
- success: true,
- tenant: "123"
+ success: true
}}
end
@@ -100,8 +99,7 @@ defmodule Realtime.RpcTest do
func: :test_raise,
origin_node: ^origin_node,
target_node: ^node,
- success: false,
- tenant: "123"
+ success: false
}}
end
diff --git a/test/realtime/signal_handler_test.exs b/test/realtime/signal_handler_test.exs
index e694f0a7a..37df34fec 100644
--- a/test/realtime/signal_handler_test.exs
+++ b/test/realtime/signal_handler_test.exs
@@ -4,7 +4,7 @@ defmodule Realtime.SignalHandlerTest do
alias Realtime.SignalHandler
defmodule FakeHandler do
- def handle_event(:sigterm, _state), do: send(self(), :ok)
+ def handle_event(signal, _state), do: send(self(), signal)
end
setup do
@@ -20,7 +20,36 @@ defmodule Realtime.SignalHandlerTest do
assert capture_log(fn -> SignalHandler.handle_event(:sigterm, state) end) =~
"SignalHandler: :sigterm received"
- assert_receive :ok
+ assert_receive :sigterm
+ end
+
+ test "sets shutdown_in_progress on sigterm" do
+ {:ok, state} = SignalHandler.init({%{handler_mod: FakeHandler}, :ok})
+
+ capture_log(fn -> SignalHandler.handle_event(:sigterm, state) end)
+
+ assert Application.get_env(:realtime, :shutdown_in_progress) == true
+ end
+
+ test "does not set shutdown_in_progress on non-sigterm signals" do
+ Application.put_env(:realtime, :shutdown_in_progress, false)
+ {:ok, state} = SignalHandler.init({%{handler_mod: FakeHandler}, :ok})
+
+ capture_log(fn -> SignalHandler.handle_event(:sigusr1, state) end)
+
+ refute Application.get_env(:realtime, :shutdown_in_progress)
+ end
+ end
+
+ describe "gen_event callbacks" do
+ test "handle_info delegates to erl_signal_handler" do
+ {:ok, state} = SignalHandler.init({%{handler_mod: FakeHandler}, :ok})
+ assert {:ok, _state} = SignalHandler.handle_info(:some_info, state)
+ end
+
+ test "handle_call delegates to erl_signal_handler" do
+ {:ok, state} = SignalHandler.init({%{handler_mod: FakeHandler}, :ok})
+ assert {:ok, _reply, _state} = SignalHandler.handle_call(:some_call, state)
end
end
diff --git a/test/realtime/syn_handler_test.exs b/test/realtime/syn_handler_test.exs
index 2b27cf322..35664f178 100644
--- a/test/realtime/syn_handler_test.exs
+++ b/test/realtime/syn_handler_test.exs
@@ -13,8 +13,15 @@ defmodule Realtime.SynHandlerTest do
defmodule FakeConnect do
use GenServer
+ def start_link([tenant_id, region, opts]) do
+ name = {Connect, tenant_id, %{conn: nil, region: region}}
+ gen_opts = [name: {:via, :syn, name}]
+ GenServer.start_link(FakeConnect, [tenant_id, opts], gen_opts)
+ end
+
def init([tenant_id, opts]) do
- :syn.update_registry(Connect, tenant_id, fn _pid, meta -> %{meta | conn: "fake_conn"} end)
+ conn = Keyword.get(opts, :conn, "remote_conn")
+ :syn.update_registry(Connect, tenant_id, fn _pid, meta -> %{meta | conn: conn} end)
if opts[:trap_exit], do: Process.flag(:trap_exit, true)
@@ -28,125 +35,184 @@ defmodule Realtime.SynHandlerTest do
Code.eval_quoted(@aux_mod)
- defp assert_process_down(pid, reason \\ nil, timeout \\ 100) do
- ref = Process.monitor(pid)
+ # > :"main@127.0.0.11" < :"atest@127.0.0.1"
+ # false
+ # iex(2)> :erlang.phash2("tenant123", 2)
+ # 0
+ # iex(3)> :erlang.phash2("tenant999", 2)
+ # 1
+ describe "integration test with a Connect conflict name=atest" do
+ setup do
+ {:ok, pid, node} =
+ Clustered.start_disconnected(@aux_mod, name: :atest, extra_config: [{:realtime, :region, "ap-southeast-2"}])
- if reason do
- assert_receive {:DOWN, ^ref, :process, ^pid, ^reason}, timeout
- else
- assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, timeout
+ %{peer_pid: pid, node: node}
+ end
+
+ @tag tenant_id: "tenant999"
+ test "tenant hash = 1", %{node: node, peer_pid: peer_pid, tenant_id: tenant_id} do
+ assert :erlang.phash2(tenant_id, 2) == 1
+ local_pid = start_supervised!({FakeConnect, [tenant_id, "us-east-1", [conn: "local_conn"]]})
+ {:ok, remote_pid} = :peer.call(peer_pid, FakeConnect, :start_link, [[tenant_id, "ap-southeast-2", []]])
+ on_exit(fn -> Process.exit(remote_pid, :brutal_kill) end)
+
+ log =
+ capture_log(fn ->
+ # Connect to peer node to cause a conflict on syn
+ true = Node.connect(node)
+ # Give some time for the conflict resolution to happen on the other node
+ Process.sleep(500)
+
+ # Both nodes agree
+ assert {^remote_pid, %{region: "ap-southeast-2", conn: "remote_conn"}} =
+ :peer.call(peer_pid, :syn, :lookup, [Connect, tenant_id])
+
+ assert {^remote_pid, %{region: "ap-southeast-2", conn: "remote_conn"}} = :syn.lookup(Connect, tenant_id)
+
+ assert :peer.call(peer_pid, Process, :alive?, [remote_pid])
+
+ refute Process.alive?(local_pid)
+ end)
+
+ assert log =~ "stop local process: #{inspect(local_pid)}"
+ assert log =~ "Successfully stopped #{inspect(local_pid)}"
+
+ assert log =~
+ "Elixir.Realtime.Tenants.Connect terminated due to syn conflict resolution: \"#{tenant_id}\" #{inspect(local_pid)}"
+ end
+
+ @tag tenant_id: "tenant123"
+ test "tenant hash = 0", %{node: node, peer_pid: peer_pid, tenant_id: tenant_id} do
+ assert :erlang.phash2(tenant_id, 2) == 0
+ {:ok, remote_pid} = :peer.call(peer_pid, FakeConnect, :start_link, [[tenant_id, "ap-southeast-2", []]])
+ local_pid = start_supervised!({FakeConnect, [tenant_id, "us-east-1", [conn: "local_conn"]]})
+ on_exit(fn -> Process.exit(remote_pid, :kill) end)
+
+ log =
+ capture_log(fn ->
+ # Connect to peer node to cause a conflict on syn
+ true = Node.connect(node)
+ # Give some time for the conflict resolution to happen on the other node
+ Process.sleep(500)
+
+ # Both nodes agree
+ assert {^local_pid, %{region: "us-east-1", conn: "local_conn"}} = :syn.lookup(Connect, tenant_id)
+
+ assert {^local_pid, %{region: "us-east-1", conn: "local_conn"}} =
+ :peer.call(peer_pid, :syn, :lookup, [Connect, tenant_id])
+
+ refute :peer.call(peer_pid, Process, :alive?, [remote_pid])
+
+ assert Process.alive?(local_pid)
+ end)
+
+ assert log =~ "remote process will be stopped: #{inspect(remote_pid)}"
end
end
- describe "integration test with a Connect conflict" do
+ # > :"main@127.0.0.11" < :"test@127.0.0.1"
+ # true
+ # iex(2)> :erlang.phash2("tenant123", 2)
+ # 0
+ # iex(3)> :erlang.phash2("tenant999", 2)
+ # 1
+ describe "integration test with a Connect conflict name=test" do
setup do
- ensure_connect_down("dev_tenant")
- {:ok, pid, node} = Clustered.start_disconnected(@aux_mod, extra_config: [{:realtime, :region, "ap-southeast-2"}])
- Endpoint.subscribe("connect:dev_tenant")
+ {:ok, pid, node} =
+ Clustered.start_disconnected(@aux_mod, name: :test, extra_config: [{:realtime, :region, "ap-southeast-2"}])
+
%{peer_pid: pid, node: node}
end
- test "local node started first", %{node: node, peer_pid: peer_pid} do
- external_id = "dev_tenant"
- # start connect locally first
- {:ok, db_conn} = Connect.lookup_or_start_connection(external_id)
- assert Connect.ready?(external_id)
- connect = Connect.whereis(external_id)
- assert node(connect) == node()
-
- # Now let's force the remote node to start the fake Connect process
- name = {Connect, external_id, %{conn: nil, region: "ap-southeast-2"}}
- opts = [name: {:via, :syn, name}]
- {:ok, remote_pid} = :peer.call(peer_pid, GenServer, :start_link, [FakeConnect, [external_id, []], opts])
+ @tag tenant_id: "tenant999"
+ test "tenant hash = 1", %{node: node, peer_pid: peer_pid, tenant_id: tenant_id} do
+ assert :erlang.phash2(tenant_id, 2) == 1
+ Endpoint.subscribe("connect:#{tenant_id}")
+ local_pid = start_supervised!({FakeConnect, [tenant_id, "us-east-1", [conn: "local_conn"]]})
+
+ {:ok, remote_pid} = :peer.call(peer_pid, FakeConnect, :start_link, [[tenant_id, "ap-southeast-2", []]])
+
on_exit(fn -> Process.exit(remote_pid, :brutal_kill) end)
log =
capture_log(fn ->
- Endpoint.subscribe("connect:dev_tenant")
# Connect to peer node to cause a conflict on syn
true = Node.connect(node)
# Give some time for the conflict resolution to happen on the other node
Process.sleep(500)
# Both nodes agree
- assert {^connect, %{region: "us-east-1", conn: ^db_conn}} = :syn.lookup(Connect, external_id)
+ assert {^local_pid, %{region: "us-east-1", conn: "local_conn"}} = :syn.lookup(Connect, tenant_id)
- assert {^connect, %{region: "us-east-1", conn: ^db_conn}} =
- :peer.call(peer_pid, :syn, :lookup, [Connect, external_id])
+ assert {^local_pid, %{region: "us-east-1", conn: "local_conn"}} =
+ :peer.call(peer_pid, :syn, :lookup, [Connect, tenant_id])
refute :peer.call(peer_pid, Process, :alive?, [remote_pid])
- assert Process.alive?(connect)
+ assert Process.alive?(local_pid)
end)
assert log =~ "remote process will be stopped: #{inspect(remote_pid)}"
end
- test "remote node started first", %{node: node, peer_pid: peer_pid} do
- external_id = "dev_tenant"
+ @tag tenant_id: "tenant123"
+ test "tenant hash = 0", %{node: node, peer_pid: peer_pid, tenant_id: tenant_id} do
+ assert :erlang.phash2(tenant_id, 2) == 0
# Start remote process first
- name = {Connect, external_id, %{conn: nil, region: "ap-southeast-2"}}
- opts = [name: {:via, :syn, name}]
- {:ok, remote_pid} = :peer.call(peer_pid, GenServer, :start_link, [FakeConnect, [external_id, []], opts])
+ {:ok, remote_pid} = :peer.call(peer_pid, FakeConnect, :start_link, [[tenant_id, "ap-southeast-2", []]])
+
on_exit(fn -> Process.exit(remote_pid, :kill) end)
# start connect locally later
- {:ok, _db_conn} = Connect.lookup_or_start_connection(external_id)
- assert Connect.ready?(external_id)
- connect = Connect.whereis(external_id)
- assert node(connect) == node()
+ local_pid = start_supervised!({FakeConnect, [tenant_id, "us-east-1", [conn: "local_conn"]]})
log =
capture_log(fn ->
# Connect to peer node to cause a conflict on syn
true = Node.connect(node)
- assert_process_down(connect)
- assert_receive %{event: "connect_down"}
+ # Give some time for the conflict resolution to happen on the other node
+ Process.sleep(500)
# Both nodes agree
- assert {^remote_pid, %{region: "ap-southeast-2", conn: "fake_conn"}} =
- :peer.call(peer_pid, :syn, :lookup, [Connect, external_id])
+ assert {^remote_pid, %{region: "ap-southeast-2", conn: "remote_conn"}} =
+ :peer.call(peer_pid, :syn, :lookup, [Connect, tenant_id])
- assert {^remote_pid, %{region: "ap-southeast-2", conn: "fake_conn"}} = :syn.lookup(Connect, external_id)
+ assert {^remote_pid, %{region: "ap-southeast-2", conn: "remote_conn"}} = :syn.lookup(Connect, tenant_id)
assert :peer.call(peer_pid, Process, :alive?, [remote_pid])
- refute Process.alive?(connect)
+ refute Process.alive?(local_pid)
end)
- assert log =~ "stop local process: #{inspect(connect)}"
- assert log =~ "Successfully stopped #{inspect(connect)}"
+ assert log =~ "stop local process: #{inspect(local_pid)}"
+ assert log =~ "Successfully stopped #{inspect(local_pid)}"
assert log =~
- "Elixir.Realtime.Tenants.Connect terminated due to syn conflict resolution: \"dev_tenant\" #{inspect(connect)}"
+ "Elixir.Realtime.Tenants.Connect terminated due to syn conflict resolution: \"#{tenant_id}\" #{inspect(local_pid)}"
end
- test "remote node started first but timed out stopping", %{node: node, peer_pid: peer_pid} do
- external_id = "dev_tenant"
+ @tag tenant_id: "tenant123"
+ test "tenant hash = 0 but timed out stopping", %{node: node, peer_pid: peer_pid, tenant_id: tenant_id} do
+ assert :erlang.phash2(tenant_id, 2) == 0
# Start remote process first
- name = {Connect, external_id, %{conn: nil, region: "ap-southeast-2"}}
- opts = [name: {:via, :syn, name}]
- {:ok, remote_pid} = :peer.call(peer_pid, GenServer, :start_link, [FakeConnect, [external_id, []], opts])
- on_exit(fn -> Process.exit(remote_pid, :brutal_kill) end)
+ {:ok, remote_pid} = :peer.call(peer_pid, FakeConnect, :start_link, [[tenant_id, "ap-southeast-2", []]])
+
+ on_exit(fn -> Process.exit(remote_pid, :kill) end)
- {:ok, local_pid} =
- start_supervised(%{
- id: self(),
- start: {GenServer, :start_link, [FakeConnect, [external_id, [trap_exit: true]], opts]}
- })
+ # start connect locally later
+ local_pid = start_supervised!({FakeConnect, [tenant_id, "us-east-1", [conn: "local_conn", trap_exit: true]]})
log =
capture_log(fn ->
# Connect to peer node to cause a conflict on syn
true = Node.connect(node)
assert_process_down(local_pid, :killed, 6000)
- assert_receive %{event: "connect_down"}
# Both nodes agree
- assert {^remote_pid, %{region: "ap-southeast-2", conn: "fake_conn"}} =
- :peer.call(peer_pid, :syn, :lookup, [Connect, external_id])
+ assert {^remote_pid, %{region: "ap-southeast-2", conn: "remote_conn"}} =
+ :peer.call(peer_pid, :syn, :lookup, [Connect, tenant_id])
- assert {^remote_pid, %{region: "ap-southeast-2", conn: "fake_conn"}} = :syn.lookup(Connect, external_id)
+ assert {^remote_pid, %{region: "ap-southeast-2", conn: "remote_conn"}} = :syn.lookup(Connect, tenant_id)
assert :peer.call(peer_pid, Process, :alive?, [remote_pid])
@@ -157,7 +223,34 @@ defmodule Realtime.SynHandlerTest do
assert log =~ "Timed out while waiting for process #{inspect(local_pid)} to stop. Sending kill exit signal"
assert log =~
- "Elixir.Realtime.Tenants.Connect terminated due to syn conflict resolution: \"dev_tenant\" #{inspect(local_pid)}"
+ "Elixir.Realtime.Tenants.Connect terminated due to syn conflict resolution: \"#{tenant_id}\" #{inspect(local_pid)}"
+ end
+ end
+
+ describe "on_process_registered/5" do
+ test "emits telemetry event for process registration" do
+ pid = self()
+ meta = %{some: :meta}
+ reason = :normal
+
+ # Attach a test handler to capture the telemetry event
+ test_pid = self()
+ handler_id = [:test, :syn_handler, :registered]
+
+ :telemetry.attach(
+ handler_id,
+ [:syn, @mod, :registered],
+ fn event, measurements, metadata, _config ->
+ send(test_pid, {:telemetry_event, event, measurements, metadata})
+ end,
+ nil
+ )
+
+ on_exit(fn -> :telemetry.detach(handler_id) end)
+
+ assert SynHandler.on_process_registered(@mod, @name, pid, meta, reason) == :ok
+
+ assert_receive {:telemetry_event, [:syn, @mod, :registered], %{}, %{name: @name}}
end
end
@@ -166,34 +259,82 @@ defmodule Realtime.SynHandlerTest do
RealtimeWeb.Endpoint.subscribe("#{@topic}:#{@name}")
end
+ test "emits telemetry event for process unregistration" do
+ reason = :normal
+ pid = self()
+
+ # Attach a test handler to capture the telemetry event
+ test_pid = self()
+ handler_id = [:test, :syn_handler, :unregistered]
+
+ :telemetry.attach(
+ handler_id,
+ [:syn, @mod, :unregistered],
+ fn event, measurements, metadata, _config ->
+ send(test_pid, {:telemetry_event, event, measurements, metadata})
+ end,
+ nil
+ )
+
+ on_exit(fn -> :telemetry.detach(handler_id) end)
+
+ capture_log(fn ->
+ assert SynHandler.on_process_unregistered(@mod, @name, pid, %{}, reason) == :ok
+ end)
+
+ assert_receive {:telemetry_event, [:syn, @mod, :unregistered], %{}, %{name: @name}}
+
+ topic = "#{@topic}:#{@name}"
+ event = "#{@topic}_down"
+ assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: ^event, payload: %{reason: ^reason, pid: ^pid}}
+ end
+
test "it handles :syn_conflict_resolution reason" do
reason = :syn_conflict_resolution
+ pid = self()
log =
capture_log(fn ->
- assert SynHandler.on_process_unregistered(@mod, @name, self(), %{}, reason) == :ok
+ assert SynHandler.on_process_unregistered(@mod, @name, pid, %{}, reason) == :ok
end)
topic = "#{@topic}:#{@name}"
event = "#{@topic}_down"
assert log =~ "#{@mod} terminated due to syn conflict resolution: #{inspect(@name)} #{inspect(self())}"
- assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: ^event, payload: nil}
+ assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: ^event, payload: %{reason: ^reason, pid: ^pid}}
end
test "it handles other reasons" do
reason = :other_reason
+ pid = self()
log =
capture_log(fn ->
- assert SynHandler.on_process_unregistered(@mod, @name, self(), %{}, reason) == :ok
+ assert SynHandler.on_process_unregistered(@mod, @name, pid, %{}, reason) == :ok
end)
topic = "#{@topic}:#{@name}"
event = "#{@topic}_down"
refute log =~ "#{@mod} terminated: #{inspect(@name)} #{node()}"
- assert_receive %Phoenix.Socket.Broadcast{topic: ^topic, event: ^event, payload: nil}, 500
+
+ assert_receive %Phoenix.Socket.Broadcast{
+ topic: ^topic,
+ event: ^event,
+ payload: %{reason: ^reason, pid: ^pid}
+ },
+ 500
+ end
+ end
+
+ defp assert_process_down(pid, reason, timeout) do
+ ref = Process.monitor(pid)
+
+ if reason do
+ assert_receive {:DOWN, ^ref, :process, ^pid, ^reason}, timeout
+ else
+ assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, timeout
end
end
end
diff --git a/test/realtime/telemetry/logger_test.exs b/test/realtime/telemetry/logger_test.exs
index 640cfc7e2..28ececf37 100644
--- a/test/realtime/telemetry/logger_test.exs
+++ b/test/realtime/telemetry/logger_test.exs
@@ -26,4 +26,10 @@ defmodule Realtime.Telemetry.LoggerTest do
end) =~ "Billing metrics: [:realtime, :connections]"
end
end
+
+ describe "handle_info/2" do
+ test "ignores unexpected messages" do
+ assert {:noreply, []} = TelemetryLogger.handle_info(:unexpected, [])
+ end
+ end
end
diff --git a/test/realtime/tenants/authorization_remote_test.exs b/test/realtime/tenants/authorization_remote_test.exs
index 53efe44ec..8ecfd1fcd 100644
--- a/test/realtime/tenants/authorization_remote_test.exs
+++ b/test/realtime/tenants/authorization_remote_test.exs
@@ -1,13 +1,10 @@
defmodule Realtime.Tenants.AuthorizationRemoteTest do
# async: false due to usage of Clustered
- # Also using dev_tenant due to distributed test
use RealtimeWeb.ConnCase, async: false
use Mimic
import ExUnit.CaptureLog
- require Phoenix.ChannelTest
-
alias Realtime.Database
alias Realtime.Tenants
alias Realtime.Tenants.Authorization
@@ -16,7 +13,7 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do
alias Realtime.Tenants.Authorization.Policies.PresencePolicies
alias Realtime.Tenants.Connect
- setup [:rls_context]
+ setup [:remote_rls_context]
describe "get_authorizations" do
@tag role: "authenticated",
@@ -78,8 +75,6 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do
@tag role: "anon",
policies: []
test "db process is down", context do
- # Grab a remote pid that will not exist in the near future. erpc uses a new process to perform the call.
- # Once it has returned the process is not alive anymore
db_conn = :erpc.call(context.node, :erlang, :self, [])
{:error, :increase_connection_pool} =
@@ -100,8 +95,8 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do
Authorization.get_read_authorizations(%Policies{}, pid, context.authorization_context)
end
- # Waiting for RateCounter to limit
- Process.sleep(1100)
+ rate_counter = Realtime.Tenants.authorization_errors_per_second_rate(context.tenant)
+ RateCounterHelper.tick!(rate_counter)
for _ <- 1..10 do
{:error, :increase_connection_pool} =
@@ -110,9 +105,6 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do
end)
assert log =~ "IncreaseConnectionPool: Too many database timeouts"
-
- # Only one log message should be emitted
- # Splitting by the error message returns the error message and the rest of the log only
assert length(String.split(log, "IncreaseConnectionPool: Too many database timeouts")) == 2
end
@@ -127,8 +119,8 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do
Authorization.get_write_authorizations(%Policies{}, pid, context.authorization_context)
end
- # Waiting for RateCounter to limit
- Process.sleep(1100)
+ rate_counter = Realtime.Tenants.authorization_errors_per_second_rate(context.tenant)
+ RateCounterHelper.tick!(rate_counter)
for _ <- 1..10 do
{:error, :increase_connection_pool} =
@@ -137,9 +129,6 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do
end)
assert log =~ "IncreaseConnectionPool: Too many database timeouts"
-
- # Only one log message should be emitted
- # Splitting by the error message returns the error message and the rest of the log only
assert length(String.split(log, "IncreaseConnectionPool: Too many database timeouts")) == 2
end
end
@@ -184,8 +173,8 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do
end)
Task.await_many([t1, t2], 20_000)
- # Wait for RateCounter to log
- Process.sleep(1000)
+ rate_counter = Realtime.Tenants.authorization_errors_per_second_rate(context.tenant)
+ RateCounterHelper.tick!(rate_counter)
end)
external_id = context.tenant.external_id
@@ -236,12 +225,8 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do
end
end
- defp rls_context(context) do
- tenant = Realtime.Tenants.get_tenant_by_external_id("dev_tenant")
- Connect.shutdown("dev_tenant")
- # Waiting for :syn to unregister
- Process.sleep(100)
- Realtime.RateCounter.stop("dev_tenant")
+ defp remote_rls_context(context) do
+ tenant = Containers.checkout_tenant_unboxed(run_migrations: true)
{:ok, local_db_conn} = Database.connect(tenant, "realtime_test", :stop)
topic = random_string()
@@ -249,26 +234,22 @@ defmodule Realtime.Tenants.AuthorizationRemoteTest do
clean_table(local_db_conn, "realtime", "messages")
claims = %{sub: random_string(), role: context.role, exp: Joken.current_time() + 1_000}
- signer = Joken.Signer.create("HS256", "secret")
-
- jwt = Joken.generate_and_sign!(%{}, claims, signer)
authorization_context =
Authorization.build_authorization_params(%{
tenant_id: tenant.external_id,
topic: topic,
- jwt: jwt,
claims: claims,
headers: [{"header-1", "value-1"}],
role: claims.role
})
- Realtime.Tenants.Migrations.create_partitions(local_db_conn)
+ Realtime.Tenants.create_messages_partitions(local_db_conn)
create_rls_policies(local_db_conn, context.policies, %{topic: topic})
{:ok, node} = Clustered.start()
region = Tenants.region(tenant)
- {:ok, db_conn} = :erpc.call(node, Connect, :connect, ["dev_tenant", region])
+ {:ok, db_conn} = :erpc.call(node, Connect, :connect, [tenant.external_id, region])
assert node(db_conn) == node
diff --git a/test/realtime/tenants/authorization_test.exs b/test/realtime/tenants/authorization_test.exs
index 724e6e933..bf31dc9e9 100644
--- a/test/realtime/tenants/authorization_test.exs
+++ b/test/realtime/tenants/authorization_test.exs
@@ -2,19 +2,17 @@ defmodule Realtime.Tenants.AuthorizationTest do
use RealtimeWeb.ConnCase, async: true
use Mimic
- require Phoenix.ChannelTest
-
import ExUnit.CaptureLog
alias Realtime.Api.Message
alias Realtime.Database
- alias Realtime.Repo
+ alias Realtime.Tenants.Repo
alias Realtime.Tenants.Authorization
alias Realtime.Tenants.Authorization.Policies
alias Realtime.Tenants.Authorization.Policies.BroadcastPolicies
alias Realtime.Tenants.Authorization.Policies.PresencePolicies
- setup [:rls_context]
+ setup [:checkout_tenant_and_connect, :rls_context]
describe "get_authorizations/3" do
@tag role: "authenticated",
@@ -51,19 +49,34 @@ defmodule Realtime.Tenants.AuthorizationTest do
@tag role: "authenticated",
policies: [:read_matching_user_role]
test "user role is exposed", context do
- # policy role is checking for "authenticated"
- # set_config is setting request.jwt.claim.role to authenticated as well
assert {:ok, %Policies{broadcast: %BroadcastPolicies{read: true, write: nil}}} =
Authorization.get_read_authorizations(%Policies{}, context.db_conn, context.authorization_context)
authorization_context = %{context.authorization_context | role: "anon"}
- # policy role is checking for "authenticated"
- # set_config is setting request.jwt.claim.role to anon
assert {:ok, %Policies{broadcast: %BroadcastPolicies{read: false, write: nil}}} =
Authorization.get_read_authorizations(%Policies{}, context.db_conn, authorization_context)
end
+ @tag role: "authenticated",
+ policies: [:authenticated_read_broadcast, :authenticated_write_broadcast]
+ test "skips presence RLS check when presence is disabled", context do
+ {:ok, policies} =
+ Authorization.get_read_authorizations(%Policies{}, context.db_conn, context.authorization_context,
+ presence_enabled?: false
+ )
+
+ {:ok, policies} =
+ Authorization.get_write_authorizations(policies, context.db_conn, context.authorization_context,
+ presence_enabled?: false
+ )
+
+ assert %Policies{
+ broadcast: %BroadcastPolicies{read: true, write: true},
+ presence: %PresencePolicies{read: false, write: false}
+ } == policies
+ end
+
@tag role: "anon",
policies: [
:authenticated_read_broadcast_and_presence,
@@ -105,9 +118,8 @@ defmodule Realtime.Tenants.AuthorizationTest do
Authorization.get_read_authorizations(%Policies{}, pid, context.authorization_context)
end
- # Waiting for RateCounter to limit
- Process.sleep(1100)
- # The next auth requests will not call the database due to being rate limited
+ rate_counter = Realtime.Tenants.authorization_errors_per_second_rate(context.tenant)
+ RateCounterHelper.tick!(rate_counter)
reject(&Database.transaction/4)
for _ <- 1..10 do
@@ -117,10 +129,7 @@ defmodule Realtime.Tenants.AuthorizationTest do
end)
assert log =~ "IncreaseConnectionPool: Too many database timeouts"
-
- # Only one log message should be emitted
- # Splitting by the error message returns the error message and the rest of the log only
- assert length(String.split(log, "IncreaseConnectionPool: Too many database timeouts")) == 2
+ assert length(String.split(log, "IncreaseConnectionPool: Too many database timeouts")) <= 3
end
@tag role: "anon", policies: []
@@ -135,9 +144,8 @@ defmodule Realtime.Tenants.AuthorizationTest do
Authorization.get_write_authorizations(%Policies{}, pid, context.authorization_context)
end
- # Waiting for RateCounter to limit
- Process.sleep(1100)
- # The next auth requests will not call the database due to being rate limited
+ rate_counter = Realtime.Tenants.authorization_errors_per_second_rate(context.tenant)
+ RateCounterHelper.tick!(rate_counter)
reject(&Database.transaction/4)
for _ <- 1..10 do
@@ -147,9 +155,6 @@ defmodule Realtime.Tenants.AuthorizationTest do
end)
assert log =~ "IncreaseConnectionPool: Too many database timeouts"
-
- # Only one log message should be emitted
- # Splitting by the error message returns the error message and the rest of the log only
assert length(String.split(log, "IncreaseConnectionPool: Too many database timeouts")) == 2
end
end
@@ -192,8 +197,8 @@ defmodule Realtime.Tenants.AuthorizationTest do
end)
Task.await_many([t1, t2], 20_000)
- # Wait for RateCounter log
- Process.sleep(1000)
+ rate_counter = Realtime.Tenants.authorization_errors_per_second_rate(context.tenant)
+ RateCounterHelper.tick!(rate_counter)
end)
external_id = context.tenant.external_id
@@ -240,6 +245,101 @@ defmodule Realtime.Tenants.AuthorizationTest do
end
end
+ describe "database error classification" do
+ @tag role: "anon", policies: []
+ test "invalid_parameter_value Postgrex error is classified as rls_policy_error", context do
+ stub(Database, :transaction, fn _, _, _, _ ->
+ {:error,
+ %Postgrex.Error{postgres: %{code: :invalid_parameter_value, message: "role \"super_admin\" does not exist"}}}
+ end)
+
+ assert {:error, :rls_policy_error, %Postgrex.Error{}} =
+ Authorization.get_read_authorizations(%Policies{}, context.db_conn, context.authorization_context)
+
+ assert {:error, :rls_policy_error, %Postgrex.Error{}} =
+ Authorization.get_write_authorizations(%Policies{}, context.db_conn, context.authorization_context)
+ end
+
+ @tag role: "anon", policies: []
+ test "query_canceled is classified as query_canceled", context do
+ query_canceled = %Postgrex.Error{
+ postgres: %{code: :query_canceled, message: "canceling statement due to user request"}
+ }
+
+ stub(Database, :transaction, fn _, _, _, _ ->
+ {:error, query_canceled}
+ end)
+
+ assert {:error, :query_canceled, %Postgrex.Error{}} =
+ Authorization.get_read_authorizations(%Policies{}, context.db_conn, context.authorization_context)
+
+ assert {:error, :query_canceled, %Postgrex.Error{}} =
+ Authorization.get_write_authorizations(%Policies{}, context.db_conn, context.authorization_context)
+ end
+
+ @tag role: "anon", policies: []
+ test "check_violation on messages is classified as missing_partition", context do
+ check_violation = %Postgrex.Error{
+ postgres: %{
+ code: :check_violation,
+ table: "messages",
+ message: "no partition of relation \"messages\" found for row"
+ }
+ }
+
+ stub(Database, :transaction, fn _, _, _, _ ->
+ {:error, check_violation}
+ end)
+
+ assert {:error, :missing_partition} =
+ Authorization.get_read_authorizations(%Policies{}, context.db_conn, context.authorization_context)
+
+ assert {:error, :missing_partition} =
+ Authorization.get_write_authorizations(%Policies{}, context.db_conn, context.authorization_context)
+ end
+
+ @tag role: "anon", policies: []
+ test "DBConnection.ConnectionError is classified as tenant_database_unavailable", context do
+ stub(Database, :transaction, fn _, _, _, _ ->
+ {:error, %DBConnection.ConnectionError{message: "ssl recv: closed", severity: :error, reason: :error}}
+ end)
+
+ assert {:error, :tenant_database_unavailable} =
+ Authorization.get_read_authorizations(%Policies{}, context.db_conn, context.authorization_context)
+
+ assert {:error, :tenant_database_unavailable} =
+ Authorization.get_write_authorizations(%Policies{}, context.db_conn, context.authorization_context)
+ end
+
+ @tag role: "anon", policies: []
+ test "ssl recv: closed ConnectionError from Repo on read is classified as tenant_database_unavailable", context do
+ conn_error = %DBConnection.ConnectionError{message: "ssl recv: closed", severity: :error, reason: :closed}
+ stub(Repo, :insert_all_entries, fn _, _, _ -> {:error, conn_error} end)
+
+ assert {:error, :tenant_database_unavailable} =
+ Authorization.get_read_authorizations(%Policies{}, context.db_conn, context.authorization_context)
+ end
+
+ @tag role: "anon", policies: []
+ test "ssl recv: closed ConnectionError from Repo on write is classified as tenant_database_unavailable", context do
+ conn_error = %DBConnection.ConnectionError{message: "ssl recv: closed", severity: :error, reason: :closed}
+ stub(Repo, :insert, fn _, _, _, _ -> {:error, conn_error} end)
+
+ assert {:error, :tenant_database_unavailable} =
+ Authorization.get_write_authorizations(%Policies{}, context.db_conn, context.authorization_context)
+ end
+
+ @tag role: "anon", policies: []
+ test "ssl recv: closed ConnectionError from Repo.all on read is classified as tenant_database_unavailable",
+ context do
+ conn_error = %DBConnection.ConnectionError{message: "ssl recv: closed", severity: :error, reason: :closed}
+ stub(Repo, :all, fn _, _, _ -> {:error, conn_error} end)
+
+ assert {:error, :tenant_database_unavailable} =
+ Authorization.get_read_authorizations(%Policies{}, context.db_conn, context.authorization_context)
+ end
+ end
+
describe "telemetry" do
@tag role: "authenticated",
policies: [
@@ -277,40 +377,6 @@ defmodule Realtime.Tenants.AuthorizationTest do
end
end
- def rls_context(context) do
- tenant = Containers.checkout_tenant(run_migrations: true)
- # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues
- Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant})
-
- {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
- topic = context[:topic] || random_string()
-
- create_rls_policies(db_conn, context.policies, %{topic: topic, sub: context[:sub], role: context.role})
-
- claims = %{"sub" => context[:sub] || random_string(), "role" => context.role, "exp" => Joken.current_time() + 1_000}
-
- authorization_context =
- Authorization.build_authorization_params(%{
- tenant_id: tenant.external_id,
- topic: topic,
- claims: claims,
- headers: [{"header-1", "value-1"}],
- role: claims["role"],
- sub: claims["sub"]
- })
-
- Realtime.Tenants.Migrations.create_partitions(db_conn)
-
- on_exit(fn -> Process.exit(db_conn, :kill) end)
-
- %{
- tenant: tenant,
- topic: topic,
- db_conn: db_conn,
- authorization_context: authorization_context
- }
- end
-
defp update_db_pool_size(tenant, db_pool) do
extension = hd(tenant.extensions)
@@ -318,9 +384,8 @@ defmodule Realtime.Tenants.AuthorizationTest do
extensions = [Map.from_struct(%{extension | :settings => settings})]
- {:ok, tenant} = Realtime.Api.update_tenant(tenant, %{extensions: extensions})
+ {:ok, tenant} = Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{extensions: extensions})
- # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues
- Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant})
+ Realtime.Tenants.Cache.update_cache(tenant)
end
end
diff --git a/test/realtime/tenants/batch_broadcast_test.exs b/test/realtime/tenants/batch_broadcast_test.exs
new file mode 100644
index 000000000..b5f6d6ddc
--- /dev/null
+++ b/test/realtime/tenants/batch_broadcast_test.exs
@@ -0,0 +1,539 @@
+defmodule Realtime.Tenants.BatchBroadcastTest do
+ use RealtimeWeb.ConnCase, async: true
+ use Mimic
+
+ alias Realtime.Database
+ alias Realtime.GenCounter
+ alias Realtime.RateCounter
+ alias Realtime.Tenants
+ alias Realtime.Tenants.BatchBroadcast
+ alias Realtime.Tenants.Authorization
+ alias Realtime.Tenants.Authorization.Policies
+ alias Realtime.Tenants.Authorization.Policies.BroadcastPolicies
+ alias Realtime.Tenants.Connect
+
+ alias RealtimeWeb.TenantBroadcaster
+
+ setup do
+ tenant = Containers.checkout_tenant(run_migrations: true)
+ Realtime.Tenants.Cache.update_cache(tenant)
+ {:ok, tenant: tenant}
+ end
+
+ describe "public message broadcasting" do
+ test "broadcasts multiple public messages successfully", %{tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ topic1 = random_string()
+ topic2 = random_string()
+
+ messages = %{
+ messages: [
+ %{topic: topic1, payload: %{"data" => "test1"}, event: "event1"},
+ %{topic: topic2, payload: %{"data" => "test2"}, event: "event2"},
+ %{topic: topic1, payload: %{"data" => "test3"}, event: "event3"}
+ ]
+ }
+
+ expect(GenCounter, :add, 3, fn ^broadcast_events_key -> :ok end)
+ expect(TenantBroadcaster, :pubsub_broadcast, 3, fn _, _, _, _, _ -> :ok end)
+
+ assert :ok = BatchBroadcast.broadcast(nil, tenant, messages, false)
+ end
+
+ test "public messages do not have private prefix in topic", %{tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ topic = random_string()
+
+ messages = %{
+ messages: [%{topic: topic, payload: %{"data" => "test"}, event: "event1"}]
+ }
+
+ expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end)
+
+ expect(TenantBroadcaster, :pubsub_broadcast, fn _, topic, _, _, _ ->
+ refute String.contains?(topic, "-private")
+ end)
+
+ assert :ok = BatchBroadcast.broadcast(nil, tenant, messages, false)
+ end
+ end
+
+ describe "message ID metadata" do
+ test "includes message ID in metadata when provided", %{tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ topic = random_string()
+
+ messages = %{
+ messages: [%{id: "msg-123", topic: topic, payload: %{"data" => "test"}, event: "event1"}]
+ }
+
+ expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end)
+
+ expect(TenantBroadcaster, :pubsub_broadcast, fn _, _, broadcast, _, _ ->
+ assert %Phoenix.Socket.Broadcast{
+ payload: %{
+ "payload" => %{"data" => "test"},
+ "event" => "event1",
+ "type" => "broadcast",
+ "meta" => %{"id" => "msg-123"}
+ }
+ } = broadcast
+ end)
+
+ assert :ok = BatchBroadcast.broadcast(nil, tenant, messages, false)
+ end
+ end
+
+ describe "super user broadcasting" do
+ test "bypasses authorization for private messages with super_user flag", %{tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ topic1 = random_string()
+ topic2 = random_string()
+
+ messages = %{
+ messages: [
+ %{topic: topic1, payload: %{"data" => "test1"}, event: "event1", private: true},
+ %{topic: topic2, payload: %{"data" => "test2"}, event: "event2", private: true}
+ ]
+ }
+
+ expect(GenCounter, :add, 2, fn ^broadcast_events_key -> :ok end)
+ expect(TenantBroadcaster, :pubsub_broadcast, 2, fn _, _, _, _, _ -> :ok end)
+
+ assert :ok = BatchBroadcast.broadcast(nil, tenant, messages, true)
+ end
+
+ test "private messages have private prefix in topic", %{tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ topic = random_string()
+
+ messages = %{
+ messages: [%{topic: topic, payload: %{"data" => "test"}, event: "event1", private: true}]
+ }
+
+ expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end)
+
+ expect(TenantBroadcaster, :pubsub_broadcast, fn _, topic, _, _, _ ->
+ assert String.contains?(topic, "-private")
+ end)
+
+ assert :ok = BatchBroadcast.broadcast(nil, tenant, messages, true)
+ end
+ end
+
+ describe "private message authorization" do
+ test "broadcasts private messages with valid authorization", %{tenant: tenant} do
+ topic = random_string()
+ sub = random_string()
+ role = "authenticated"
+
+ auth_params = %{
+ tenant_id: tenant.external_id,
+ topic: topic,
+ headers: [{"header-1", "value-1"}],
+ claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000},
+ role: role,
+ sub: sub
+ }
+
+ messages = %{messages: [%{topic: topic, payload: %{"data" => "test"}, event: "event1", private: true}]}
+
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+
+ expect(GenCounter, :add, 1, fn ^broadcast_events_key -> :ok end)
+
+ Authorization
+ |> expect(:build_authorization_params, fn params -> params end)
+ |> expect(:get_write_authorizations, fn _, _ -> {:ok, %Policies{broadcast: %BroadcastPolicies{write: true}}} end)
+
+ expect(TenantBroadcaster, :pubsub_broadcast, 1, fn _, _, _, _, _ -> :ok end)
+
+ assert :ok = BatchBroadcast.broadcast(auth_params, tenant, messages, false)
+ end
+
+ test "skips private messages without authorization", %{tenant: tenant} do
+ topic = random_string()
+ sub = random_string()
+ role = "anon"
+
+ auth_params = %{
+ tenant_id: tenant.external_id,
+ topic: topic,
+ headers: [{"header-1", "value-1"}],
+ claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000},
+ role: role,
+ sub: sub
+ }
+
+ Authorization
+ |> expect(:build_authorization_params, 1, fn params -> params end)
+ |> expect(:get_write_authorizations, 1, fn _, _ ->
+ {:ok, %Policies{broadcast: %BroadcastPolicies{write: false}}}
+ end)
+
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ messages = %{
+ messages: [%{topic: topic, payload: %{"data" => "test"}, event: "event1", private: true}]
+ }
+
+ assert :ok = BatchBroadcast.broadcast(auth_params, tenant, messages, false)
+
+ assert calls(&TenantBroadcaster.pubsub_broadcast/5) == []
+ end
+
+ test "broadcasts only authorized topics in mixed authorization batch", %{tenant: tenant} do
+ topic = random_string()
+ sub = random_string()
+ role = "authenticated"
+
+ auth_params = %{
+ tenant_id: tenant.external_id,
+ headers: [{"header-1", "value-1"}],
+ claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000},
+ role: role,
+ sub: sub
+ }
+
+ messages = %{
+ messages: [
+ %{topic: topic, payload: %{"data" => "test1"}, event: "event1", private: true},
+ %{topic: random_string(), payload: %{"data" => "test2"}, event: "event2", private: true}
+ ]
+ }
+
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+
+ expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end)
+
+ Authorization
+ |> expect(:build_authorization_params, 2, fn params -> params end)
+ |> expect(:get_write_authorizations, 2, fn
+ _, %{topic: ^topic} -> %Policies{broadcast: %BroadcastPolicies{write: true}}
+ _, _ -> %Policies{broadcast: %BroadcastPolicies{write: false}}
+ end)
+
+ # Only one topic will actually be broadcasted
+ expect(TenantBroadcaster, :pubsub_broadcast, 1, fn _, _, %Phoenix.Socket.Broadcast{topic: ^topic}, _, _ ->
+ :ok
+ end)
+
+ assert :ok = BatchBroadcast.broadcast(auth_params, tenant, messages, false)
+ end
+
+ test "groups messages by topic and checks authorization once per topic", %{tenant: tenant} do
+ topic_1 = random_string()
+ topic_2 = random_string()
+ sub = random_string()
+ role = "authenticated"
+
+ auth_params = %{
+ tenant_id: tenant.external_id,
+ headers: [{"header-1", "value-1"}],
+ claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000},
+ role: role,
+ sub: sub
+ }
+
+ messages = %{
+ messages: [
+ %{topic: topic_1, payload: %{"data" => "test1"}, event: "event1", private: true},
+ %{topic: topic_2, payload: %{"data" => "test2"}, event: "event2", private: true},
+ %{topic: topic_1, payload: %{"data" => "test3"}, event: "event3", private: true}
+ ]
+ }
+
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+
+ expect(GenCounter, :add, 3, fn ^broadcast_events_key -> :ok end)
+
+ Authorization
+ |> expect(:build_authorization_params, 2, fn params -> params end)
+ |> expect(:get_write_authorizations, 2, fn _, _ ->
+ {:ok, %Policies{broadcast: %BroadcastPolicies{write: true}}}
+ end)
+
+ expect(TenantBroadcaster, :pubsub_broadcast, 3, fn _, _, _, _, _ -> :ok end)
+
+ assert :ok = BatchBroadcast.broadcast(auth_params, tenant, messages, false)
+ end
+
+ test "handles missing auth params for private messages", %{tenant: tenant} do
+ events_per_second_rate = Tenants.events_per_second_rate(tenant)
+
+ RateCounter
+ |> stub(:new, fn _ -> {:ok, nil} end)
+ |> stub(:get, fn ^events_per_second_rate -> {:ok, %RateCounter{avg: 0}} end)
+
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+ reject(&Connect.lookup_or_start_connection/1)
+
+ messages = %{
+ messages: [%{topic: "topic1", payload: %{"data" => "test"}, event: "event1", private: true}]
+ }
+
+ assert :ok = BatchBroadcast.broadcast(nil, tenant, messages, false)
+
+ assert calls(&TenantBroadcaster.pubsub_broadcast/5) == []
+ end
+ end
+
+ describe "mixed public and private messages" do
+ setup %{tenant: tenant} do
+ {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
+ %{db_conn: db_conn}
+ end
+
+ test "broadcasts both public and private messages together", %{tenant: tenant, db_conn: db_conn} do
+ topic = random_string()
+ sub = random_string()
+ role = "authenticated"
+
+ create_rls_policies(db_conn, [:authenticated_write_broadcast], %{topic: topic})
+
+ auth_params = %{
+ tenant_id: tenant.external_id,
+ topic: topic,
+ headers: [{"header-1", "value-1"}],
+ claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000},
+ role: role,
+ sub: sub
+ }
+
+ events_per_second_rate = Tenants.events_per_second_rate(tenant)
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+
+ RateCounter
+ |> stub(:new, fn _ -> {:ok, nil} end)
+ |> stub(:get, fn
+ ^events_per_second_rate ->
+ {:ok, %RateCounter{avg: 0}}
+
+ _ ->
+ {:ok,
+ %RateCounter{
+ avg: 0,
+ limit: %{log: true, value: 10, measurement: :sum, triggered: false, log_fn: fn -> :ok end}
+ }}
+ end)
+
+ expect(GenCounter, :add, 3, fn ^broadcast_events_key -> :ok end)
+ expect(Connect, :lookup_or_start_connection, fn _ -> {:ok, db_conn} end)
+
+ Authorization
+ |> expect(:build_authorization_params, fn params -> params end)
+ |> expect(:get_write_authorizations, fn _, _ ->
+ {:ok, %Policies{broadcast: %BroadcastPolicies{write: true}}}
+ end)
+
+ expect(TenantBroadcaster, :pubsub_broadcast, 3, fn _, _, _, _, _ -> :ok end)
+
+ messages = %{
+ messages: [
+ %{topic: "public1", payload: %{"data" => "public"}, event: "event1", private: false},
+ %{topic: topic, payload: %{"data" => "private"}, event: "event2", private: true},
+ %{topic: "public2", payload: %{"data" => "public2"}, event: "event3"}
+ ]
+ }
+
+ assert :ok = BatchBroadcast.broadcast(auth_params, tenant, messages, false)
+
+ broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/5)
+ assert length(broadcast_calls) == 3
+ end
+ end
+
+ describe "Plug.Conn integration" do
+ test "accepts and converts Plug.Conn to auth params", %{tenant: tenant} do
+ topic = random_string()
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ messages = %{messages: [%{topic: topic, payload: %{"data" => "test"}, event: "event1"}]}
+
+ expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end)
+ expect(TenantBroadcaster, :pubsub_broadcast, 1, fn _, _, _, _, _ -> :ok end)
+
+ conn =
+ build_conn()
+ |> Map.put(:assigns, %{
+ claims: %{"sub" => "user123", "role" => "authenticated"},
+ role: "authenticated",
+ sub: "user123"
+ })
+ |> Map.put(:req_headers, [{"authorization", "Bearer token"}])
+
+ assert :ok = BatchBroadcast.broadcast(conn, tenant, messages, false)
+ end
+ end
+
+ describe "message validation" do
+ test "returns changeset error when topic is missing", %{tenant: tenant} do
+ messages = %{messages: [%{payload: %{"data" => "test"}, event: "event1"}]}
+
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ result = BatchBroadcast.broadcast(nil, tenant, messages, false)
+ assert {:error, %Ecto.Changeset{valid?: false}} = result
+ end
+
+ test "returns changeset error when payload is missing", %{tenant: tenant} do
+ topic = random_string()
+ messages = %{messages: [%{topic: topic, event: "event1"}]}
+
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ result = BatchBroadcast.broadcast(nil, tenant, messages, false)
+ assert {:error, %Ecto.Changeset{valid?: false}} = result
+ end
+
+ test "returns changeset error when event is missing", %{tenant: tenant} do
+ topic = random_string()
+ messages = %{messages: [%{topic: topic, payload: %{"data" => "test"}}]}
+
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+ result = BatchBroadcast.broadcast(nil, tenant, messages, false)
+ assert {:error, %Ecto.Changeset{valid?: false}} = result
+ end
+
+ test "returns changeset error when messages array is empty", %{tenant: tenant} do
+ messages = %{messages: []}
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+ result = BatchBroadcast.broadcast(nil, tenant, messages, false)
+ assert {:error, %Ecto.Changeset{valid?: false}} = result
+ end
+ end
+
+ describe "rate limiting" do
+ test "rejects broadcast when rate limit is exceeded", %{tenant: tenant} do
+ events_per_second_rate = Tenants.events_per_second_rate(tenant)
+ topic = random_string()
+ messages = %{messages: [%{topic: topic, payload: %{"data" => "test"}, event: "event1"}]}
+
+ RateCounter
+ |> stub(:new, fn _ -> {:ok, nil} end)
+ |> stub(:get, fn ^events_per_second_rate -> {:ok, %RateCounter{avg: tenant.max_events_per_second + 1}} end)
+
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ result = BatchBroadcast.broadcast(nil, tenant, messages, false)
+ assert {:error, :too_many_requests, "You have exceeded your rate limit"} = result
+ end
+
+ test "rejects broadcast when batch would exceed rate limit", %{tenant: tenant} do
+ events_per_second_rate = Tenants.events_per_second_rate(tenant)
+
+ messages = %{
+ messages:
+ Enum.map(1..10, fn _ ->
+ %{topic: random_string(), payload: %{"data" => "test"}, event: random_string()}
+ end)
+ }
+
+ RateCounter
+ |> stub(:new, fn _ -> {:ok, nil} end)
+ |> stub(:get, fn ^events_per_second_rate ->
+ {:ok, %RateCounter{avg: tenant.max_events_per_second - 5}}
+ end)
+
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ result = BatchBroadcast.broadcast(nil, tenant, messages, false)
+
+ assert {:error, :too_many_requests, "Too many messages to broadcast, please reduce the batch size"} = result
+ end
+
+ test "allows broadcast at rate limit boundary", %{tenant: tenant} do
+ events_per_second_rate = Tenants.events_per_second_rate(tenant)
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ current_rate = tenant.max_events_per_second - 2
+
+ messages = %{
+ messages: [
+ %{topic: random_string(), payload: %{"data" => "test1"}, event: "event1"},
+ %{topic: random_string(), payload: %{"data" => "test2"}, event: "event2"}
+ ]
+ }
+
+ RateCounter
+ |> stub(:new, fn _ -> {:ok, nil} end)
+ |> stub(:get, fn ^events_per_second_rate ->
+ {:ok, %RateCounter{avg: current_rate}}
+ end)
+
+ expect(GenCounter, :add, 2, fn ^broadcast_events_key -> :ok end)
+ expect(TenantBroadcaster, :pubsub_broadcast, 2, fn _, _, _, _, _ -> :ok end)
+
+ assert :ok = BatchBroadcast.broadcast(nil, tenant, messages, false)
+ end
+
+ test "rejects broadcast when payload size exceeds tenant limit", %{tenant: tenant} do
+ messages = %{
+ messages: [
+ %{
+ topic: random_string(),
+ payload: %{"data" => random_string(tenant.max_payload_size_in_kb * 1000 + 1)},
+ event: "event1"
+ }
+ ]
+ }
+
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ result = BatchBroadcast.broadcast(nil, tenant, messages, false)
+
+ assert {:error,
+ %Ecto.Changeset{
+ valid?: false,
+ changes: %{messages: [%{errors: [payload: {"Payload size exceeds tenant limit", []}]}]}
+ }} = result
+ end
+ end
+
+ describe "error handling" do
+ test "returns error when tenant is nil" do
+ messages = %{messages: [%{topic: "topic1", payload: %{"data" => "test"}, event: "event1"}]}
+ assert {:error, :tenant_not_found} = BatchBroadcast.broadcast(nil, nil, messages, false)
+ end
+
+ test "does not broadcast when tenant is suspended", %{tenant: tenant} do
+ tenant = %{tenant | suspend: true}
+ messages = %{messages: [%{topic: "topic1", payload: %{"data" => "test"}, event: "event1"}]}
+
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ assert {:error, :forbidden, "Tenant is suspended"} = BatchBroadcast.broadcast(nil, tenant, messages, false)
+ assert calls(&TenantBroadcaster.pubsub_broadcast/5) == []
+ end
+
+ test "gracefully handles database connection errors for private messages", %{tenant: tenant} do
+ topic = random_string()
+ sub = random_string()
+ role = "authenticated"
+
+ auth_params = %{
+ tenant_id: tenant.external_id,
+ headers: [{"header-1", "value-1"}],
+ claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000},
+ role: role,
+ sub: sub
+ }
+
+ events_per_second_rate = Tenants.events_per_second_rate(tenant)
+
+ RateCounter
+ |> stub(:new, fn _ -> {:ok, nil} end)
+ |> stub(:get, fn ^events_per_second_rate -> {:ok, %RateCounter{avg: 0}} end)
+
+ expect(Connect, :lookup_or_start_connection, fn _ -> {:error, :connection_failed} end)
+
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ messages = %{
+ messages: [%{topic: topic, payload: %{"data" => "test"}, event: "event1", private: true}]
+ }
+
+ assert :ok = BatchBroadcast.broadcast(auth_params, tenant, messages, false)
+
+ assert calls(&TenantBroadcaster.pubsub_broadcast/5) == []
+ end
+ end
+end
diff --git a/test/realtime/tenants/cache_test.exs b/test/realtime/tenants/cache_test.exs
index 1889c94ef..087e05f9f 100644
--- a/test/realtime/tenants/cache_test.exs
+++ b/test/realtime/tenants/cache_test.exs
@@ -1,24 +1,59 @@
defmodule Realtime.Tenants.CacheTest do
- alias Realtime.Rpc
- # async: false due to the usage of dev_realtime tenant
use Realtime.DataCase, async: false
alias Realtime.Api
- alias Realtime.Tenants.Cache
+ alias Realtime.Rpc
alias Realtime.Tenants
+ alias Realtime.Tenants.Cache
setup do
{:ok, tenant: tenant_fixture()}
end
+ describe "fetch_tenant_by_external_id/1" do
+ test "returns {:ok, tenant} when tenant exists", %{tenant: tenant} do
+ assert {:ok, %Api.Tenant{external_id: external_id}} =
+ Cache.fetch_tenant_by_external_id(tenant.external_id)
+
+ assert external_id == tenant.external_id
+ end
+
+ test "returns cached result on subsequent calls", %{tenant: tenant} do
+ external_id = tenant.external_id
+ assert {:ok, %Api.Tenant{name: "tenant"}} = Cache.fetch_tenant_by_external_id(external_id)
+
+ changeset = Api.Tenant.changeset(tenant, %{name: "new name"})
+ Repo.update!(changeset)
+
+ assert {:ok, %Api.Tenant{name: "tenant"}} = Cache.fetch_tenant_by_external_id(external_id)
+ end
+
+ test "returns {:error, :tenant_not_found} when tenant does not exist" do
+ assert {:error, :tenant_not_found} = Cache.fetch_tenant_by_external_id("nonexistent-id")
+ end
+
+ test "does not cache when tenant is not found" do
+ Cache.fetch_tenant_by_external_id("nonexistent-id")
+ assert {:ok, false} = Cachex.exists?(Cache, {:get_tenant_by_external_id, "nonexistent-id"})
+ end
+ end
+
describe "get_tenant_by_external_id/1" do
test "tenants cache returns a cached result", %{tenant: tenant} do
external_id = tenant.external_id
assert %Api.Tenant{name: "tenant"} = Cache.get_tenant_by_external_id(external_id)
- Api.update_tenant(tenant, %{name: "new name"})
+
+ changeset = Api.Tenant.changeset(tenant, %{name: "new name"})
+ Repo.update!(changeset)
assert %Api.Tenant{name: "new name"} = Tenants.get_tenant_by_external_id(external_id)
assert %Api.Tenant{name: "tenant"} = Cache.get_tenant_by_external_id(external_id)
end
+
+ test "does not cache when tenant is not found" do
+ assert Cache.get_tenant_by_external_id("not found") == nil
+
+ assert Cachex.exists?(Cache, {:get_tenant_by_external_id, "not found"}) == {:ok, false}
+ end
end
describe "invalidate_tenant_cache/1" do
@@ -38,43 +73,130 @@ defmodule Realtime.Tenants.CacheTest do
end
end
+ describe "update_cache/1" do
+ test "updates the cache given a tenant", %{tenant: tenant} do
+ external_id = tenant.external_id
+ assert %Api.Tenant{name: "tenant"} = Cache.get_tenant_by_external_id(external_id)
+ # Update a tenant
+ updated_tenant = %{tenant | name: "updated name"}
+ # Update cache
+ Cache.update_cache(updated_tenant)
+ assert %Api.Tenant{name: "updated name"} = Cache.get_tenant_by_external_id(external_id)
+ end
+ end
+
describe "distributed_invalidate_tenant_cache/1" do
setup do
{:ok, node} = Clustered.start()
- %{node: node}
+
+ tenant =
+ Ecto.Adapters.SQL.Sandbox.unboxed_run(Realtime.Repo, fn ->
+ tenant_fixture()
+ end)
+
+ on_exit(fn ->
+ Ecto.Adapters.SQL.Sandbox.unboxed_run(Realtime.Repo, fn ->
+ Realtime.Api.delete_tenant_by_external_id(tenant.external_id)
+ end)
+ end)
+
+ %{node: node, tenant: tenant}
end
- test "invalidates the cache given a tenant_id", %{node: node} do
- external_id = "dev_tenant"
- %Api.Tenant{name: expected_name} = tenant = Tenants.get_tenant_by_external_id(external_id)
+ test "invalidates the cache given a tenant_id", %{node: node, tenant: tenant} do
+ external_id = tenant.external_id
+ expected_name = tenant.name
+ dummy_name = random_string()
+ dummy_tenant = %{tenant | name: dummy_name}
+
+ assert {:ok, true} = Cache.update_cache(dummy_tenant)
+
+ assert {:ok, %Api.Tenant{name: ^dummy_name}} =
+ Cachex.get(Cache, {:get_tenant_by_external_id, external_id})
+
+ seed_remote_cache(node, external_id, dummy_tenant)
+
+ assert :ok = Cache.distributed_invalidate_tenant_cache(external_id)
+
+ assert_eventually(fn ->
+ %Api.Tenant{name: ^expected_name} = Cache.get_tenant_by_external_id(external_id)
+
+ %Api.Tenant{name: ^expected_name} =
+ Rpc.enhanced_call(node, Cache, :get_tenant_by_external_id, [external_id])
+ end)
+ end
+ end
+
+ describe "global_cache_update/1" do
+ setup do
+ {:ok, node} = Clustered.start()
+
+ tenant =
+ Ecto.Adapters.SQL.Sandbox.unboxed_run(Realtime.Repo, fn ->
+ tenant_fixture()
+ end)
+
+ on_exit(fn ->
+ Ecto.Adapters.SQL.Sandbox.unboxed_run(Realtime.Repo, fn ->
+ Realtime.Api.delete_tenant_by_external_id(tenant.external_id)
+ end)
+ end)
+
+ %{node: node, tenant: tenant}
+ end
+ test "update the cache given a tenant_id", %{node: node, tenant: tenant} do
+ external_id = tenant.external_id
+ expected_name = tenant.name
dummy_name = random_string()
+ dummy_tenant = %{tenant | name: dummy_name}
- # Ensure cache has the values
- Cachex.put!(
- Realtime.Tenants.Cache,
- {{:get_tenant_by_external_id, 1}, [external_id]},
- {:cached, %{tenant | name: dummy_name}}
- )
+ assert {:ok, true} = Cache.update_cache(dummy_tenant)
- Rpc.enhanced_call(node, Cachex, :put!, [
- Realtime.Tenants.Cache,
- {{:get_tenant_by_external_id, 1}, [external_id]},
- {:cached, %{tenant | name: dummy_name}}
- ])
+ assert {:ok, %Api.Tenant{name: ^dummy_name}} =
+ Cachex.get(Cache, {:get_tenant_by_external_id, external_id})
- # Cache showing old value
- assert %Api.Tenant{name: ^dummy_name} = Cache.get_tenant_by_external_id(external_id)
- assert %Api.Tenant{name: ^dummy_name} = Rpc.enhanced_call(node, Cache, :get_tenant_by_external_id, [external_id])
+ seed_remote_cache(node, external_id, dummy_tenant)
- # Invalidate cache
- assert true = Cache.distributed_invalidate_tenant_cache(external_id)
+ assert :ok = Cache.global_cache_update(tenant)
- # Cache showing new value
- assert %Api.Tenant{name: ^expected_name} = Cache.get_tenant_by_external_id(external_id)
+ assert_eventually(fn ->
+ {:ok, %Api.Tenant{name: ^expected_name}} =
+ Cachex.get(Cache, {:get_tenant_by_external_id, external_id})
- assert %Api.Tenant{name: ^expected_name} =
- Rpc.enhanced_call(node, Cache, :get_tenant_by_external_id, [external_id])
+ {:ok, %Api.Tenant{name: ^expected_name}} =
+ Rpc.enhanced_call(node, Cachex, :get, [Cache, {:get_tenant_by_external_id, external_id}])
+ end)
end
end
+
+ defp seed_remote_cache(node, external_id, tenant, attempts \\ 20) do
+ Rpc.enhanced_call(node, Cache, :update_cache, [tenant])
+
+ case Rpc.enhanced_call(node, Cachex, :get, [Cache, {:get_tenant_by_external_id, external_id}]) do
+ {:ok, %Api.Tenant{external_id: ^external_id, name: name}} when name == tenant.name ->
+ :ok
+
+ _other when attempts > 0 ->
+ Process.sleep(50)
+ seed_remote_cache(node, external_id, tenant, attempts - 1)
+
+ other ->
+ flunk("Failed to seed remote cache after retries, last result: #{inspect(other)}")
+ end
+ end
+
+ defp assert_eventually(fun, attempts \\ 50, interval \\ 100)
+
+ defp assert_eventually(fun, 0, _interval) do
+ fun.()
+ end
+
+ defp assert_eventually(fun, attempts, interval) do
+ fun.()
+ rescue
+ _ ->
+ Process.sleep(interval)
+ assert_eventually(fun, attempts - 1, interval)
+ end
end
diff --git a/test/realtime/tenants/connect/get_tenant_test.exs b/test/realtime/tenants/connect/get_tenant_test.exs
new file mode 100644
index 000000000..588e838f7
--- /dev/null
+++ b/test/realtime/tenants/connect/get_tenant_test.exs
@@ -0,0 +1,16 @@
+defmodule Realtime.Tenants.Connect.GetTenantTest do
+ use Realtime.DataCase, async: true
+
+ alias Realtime.Tenants.Connect.GetTenant
+
+ describe "run/1" do
+ test "returns tenant when found" do
+ tenant = Containers.checkout_tenant()
+ assert {:ok, %{tenant: %Realtime.Api.Tenant{}}} = GetTenant.run(%{tenant_id: tenant.external_id})
+ end
+
+ test "returns error when tenant not found" do
+ assert {:error, :tenant_not_found} = GetTenant.run(%{tenant_id: "nonexistent_tenant_id"})
+ end
+ end
+end
diff --git a/test/realtime/tenants/connect/reconcile_migrations_test.exs b/test/realtime/tenants/connect/reconcile_migrations_test.exs
new file mode 100644
index 000000000..04944db2c
--- /dev/null
+++ b/test/realtime/tenants/connect/reconcile_migrations_test.exs
@@ -0,0 +1,46 @@
+defmodule Realtime.Tenants.Connect.ReconcileMigrationsTest do
+ use Realtime.DataCase, async: true
+
+ alias Realtime.Api
+ alias Realtime.Tenants.Connect.ReconcileMigrations
+ alias Realtime.Tenants.Migrations
+
+ setup do
+ tenant = Containers.checkout_tenant(run_migrations: true)
+ %{tenant: tenant}
+ end
+
+ describe "run/1" do
+ test "does nothing when migrations_ran matches database count", %{tenant: tenant} do
+ acc = %{tenant: tenant, migrations_ran_on_database: tenant.migrations_ran}
+
+ assert {:ok, %{tenant: returned_tenant}} = ReconcileMigrations.run(acc)
+ assert returned_tenant.migrations_ran == tenant.migrations_ran
+ end
+
+ test "updates tenant when database has fewer migrations than cached count", %{tenant: tenant} do
+ stale_count = tenant.migrations_ran - 5
+ acc = %{tenant: tenant, migrations_ran_on_database: stale_count}
+
+ assert {:ok, %{tenant: updated_tenant}} = ReconcileMigrations.run(acc)
+ assert updated_tenant.migrations_ran == stale_count
+ end
+
+ test "updates tenant when database has more migrations than cached count", %{tenant: tenant} do
+ {:ok, tenant} =
+ Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{migrations_ran: 0})
+
+ total = Enum.count(Migrations.migrations())
+ acc = %{tenant: tenant, migrations_ran_on_database: total}
+
+ assert {:ok, %{tenant: updated_tenant}} = ReconcileMigrations.run(acc)
+ assert updated_tenant.migrations_ran == total
+ end
+
+ test "returns :tenant_not_found when tenant has been removed", %{tenant: tenant} do
+ assert Api.delete_tenant_by_external_id(tenant.external_id)
+ acc = %{tenant: tenant, migrations_ran_on_database: 11}
+ assert {:error, :tenant_not_found} = ReconcileMigrations.run(acc)
+ end
+ end
+end
diff --git a/test/realtime/tenants/connect/register_process_test.exs b/test/realtime/tenants/connect/register_process_test.exs
index d4227996f..02cc33391 100644
--- a/test/realtime/tenants/connect/register_process_test.exs
+++ b/test/realtime/tenants/connect/register_process_test.exs
@@ -7,7 +7,7 @@ defmodule Realtime.Tenants.Connect.RegisterProcessTest do
setup do
tenant = Containers.checkout_tenant(run_migrations: true)
# Warm cache to avoid Cachex and Ecto.Sandbox ownership issues
- Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant})
+ Realtime.Tenants.Cache.update_cache(tenant)
{:ok, conn} = Database.connect(tenant, "realtime_test")
%{tenant_id: tenant.external_id, db_conn_pid: conn}
end
diff --git a/test/realtime/tenants/connect_test.exs b/test/realtime/tenants/connect_test.exs
index 290fb1c8d..0b12ff9bd 100644
--- a/test/realtime/tenants/connect_test.exs
+++ b/test/realtime/tenants/connect_test.exs
@@ -50,7 +50,50 @@ defmodule Realtime.Tenants.ConnectTest do
end
end
+ describe "list_tenants/0" do
+ test "lists all tenants with active connections", %{tenant: tenant1} do
+ tenant2 = Containers.checkout_tenant(run_migrations: true)
+ assert {:ok, _} = Connect.lookup_or_start_connection(tenant1.external_id)
+ assert {:ok, _} = Connect.lookup_or_start_connection(tenant2.external_id)
+
+ list_tenants = Connect.list_tenants() |> MapSet.new()
+ tenants = MapSet.new([tenant1.external_id, tenant2.external_id])
+
+ assert MapSet.subset?(tenants, list_tenants)
+ end
+ end
+
describe "handle cold start" do
+ test "multiple processes connecting calling Connect.connect", %{tenant: tenant} do
+ parent = self()
+
+ # Let's slow down Connect.connect so that multiple RPC calls are executed
+ stub(Connect, :connect, fn x, y, z ->
+ :timer.sleep(1000)
+ call_original(Connect, :connect, [x, y, z])
+ end)
+
+ connect = fn -> send(parent, Connect.lookup_or_start_connection(tenant.external_id)) end
+ # Let's call enough times to potentially trigger the Connect RateCounter
+
+ for _ <- 1..50, do: spawn(connect)
+
+ assert_receive({:ok, pid}, 2000)
+
+ for _ <- 1..49, do: assert_receive({:ok, ^pid})
+
+ # Does not trigger rate limit as connections eventually succeeded
+
+ {:ok, rate_counter} =
+ tenant.external_id
+ |> Tenants.connect_errors_per_second_rate()
+ |> Realtime.RateCounter.get()
+
+ assert rate_counter.sum == 0
+ assert rate_counter.avg == 0.0
+ assert rate_counter.limit.triggered == false
+ end
+
test "multiple proccesses succeed together", %{tenant: tenant} do
parent = self()
@@ -78,12 +121,55 @@ defmodule Realtime.Tenants.ConnectTest do
assert_receive {:ok, ^pid}
end
- test "more than 5 seconds passed error out", %{tenant: tenant} do
+ test "more than 15 seconds passed error out", %{tenant: tenant} do
parent = self()
# Let's slow down Connect starting
expect(Database, :check_tenant_connection, fn t ->
- :timer.sleep(5500)
+ Process.sleep(15500)
+ call_original(Database, :check_tenant_connection, [t])
+ end)
+
+ connect = fn -> send(parent, Connect.lookup_or_start_connection(tenant.external_id)) end
+
+ spawn(connect)
+ spawn(connect)
+
+ {:error, :initializing} = Connect.lookup_or_start_connection(tenant.external_id)
+ # The above call waited 15 seconds
+ assert_receive {:error, :initializing}
+ assert_receive {:error, :initializing}
+
+ # This one will succeed
+ {:ok, _pid} = Connect.lookup_or_start_connection(tenant.external_id)
+ end
+
+ test "too many db connections", %{tenant: tenant} do
+ extension = %{
+ "type" => "postgres_cdc_rls",
+ "settings" => %{
+ "db_host" => "127.0.0.1",
+ "db_name" => "postgres",
+ "db_user" => "supabase_realtime_admin",
+ "db_password" => "postgres",
+ "poll_interval" => 100,
+ "poll_max_changes" => 100,
+ "poll_max_record_bytes" => 1_048_576,
+ "region" => "us-east-1",
+ "ssl_enforced" => false,
+ "db_pool" => 100,
+ "subcriber_pool_size" => 100,
+ "subs_pool_size" => 100
+ }
+ }
+
+ {:ok, tenant} = update_extension(tenant, extension)
+
+ parent = self()
+
+ # Let's slow down Connect starting
+ expect(Database, :check_tenant_connection, fn t ->
+ :timer.sleep(1000)
call_original(Database, :check_tenant_connection, [t])
end)
@@ -97,12 +183,13 @@ defmodule Realtime.Tenants.ConnectTest do
spawn(connect)
spawn(connect)
- {:error, :tenant_database_unavailable} = Connect.lookup_or_start_connection(tenant.external_id)
+ # This one should block and wait for the first Connect
+ {:error, :tenant_db_too_many_connections} = Connect.lookup_or_start_connection(tenant.external_id)
- # Only one will succeed the others timed out waiting
- assert_receive {:error, :tenant_database_unavailable}
- assert_receive {:error, :tenant_database_unavailable}
- assert_receive {:ok, _pid}, 7000
+ assert_receive {:error, :tenant_db_too_many_connections}
+ assert_receive {:error, :tenant_db_too_many_connections}
+ assert_receive {:error, :tenant_db_too_many_connections}
+ refute_receive _any
end
end
@@ -113,11 +200,9 @@ defmodule Realtime.Tenants.ConnectTest do
log =
capture_log(fn ->
assert {:ok, db_conn} = Connect.lookup_or_start_connection(external_id, check_connect_region_interval: 100)
-
expect(Rebalancer, :check, 1, fn _, _, ^external_id -> {:error, :wrong_region} end)
reject(&Rebalancer.check/3)
-
- assert_process_down(db_conn, 500, {:shutdown, :rebalancing})
+ assert_process_down(db_conn, 1000, {:shutdown, :rebalancing})
end)
assert log =~ "Rebalancing Tenant database connection"
@@ -253,10 +338,9 @@ defmodule Realtime.Tenants.ConnectTest do
{:ok, db_conn} = Connect.lookup_or_start_connection(external_id, check_connected_user_interval: 10)
region = Tenants.region(tenant)
assert {_pid, %{conn: ^db_conn, region: ^region}} = :syn.lookup(Connect, external_id)
+ Forum.Census.leave(:users, external_id, self())
Process.sleep(1000)
- :syn.leave(:users, external_id, self())
- Process.sleep(1000)
- assert :undefined = :syn.lookup(Connect, external_id)
+ refute Forum.Census.local_member?(:users, external_id, self())
refute Process.alive?(db_conn)
Connect.shutdown(external_id)
end
@@ -267,37 +351,32 @@ defmodule Realtime.Tenants.ConnectTest do
assert {:error, :tenant_suspended} = Connect.lookup_or_start_connection(tenant.external_id)
end
- test "handles tenant suspension and unsuspension in a reactive way", %{tenant: tenant} do
- assert {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
- assert Connect.ready?(tenant.external_id)
-
- Realtime.Tenants.suspend_tenant_by_external_id(tenant.external_id)
- assert_process_down(db_conn)
- # Wait for syn to unregister and Cachex to be invalided
- Process.sleep(500)
-
- assert {:error, :tenant_suspended} = Connect.lookup_or_start_connection(tenant.external_id)
- refute Process.alive?(db_conn)
-
- Realtime.Tenants.unsuspend_tenant_by_external_id(tenant.external_id)
- Process.sleep(50)
- assert {:ok, _} = Connect.lookup_or_start_connection(tenant.external_id)
- Connect.shutdown(tenant.external_id)
- end
-
- test "handles tenant suspension only on targetted suspended user", %{tenant: tenant1} do
- tenant2 = Containers.checkout_tenant(run_migrations: true)
-
- assert {:ok, db_conn} = Connect.lookup_or_start_connection(tenant1.external_id)
+ test "tenant not able to connect if database has not enough connections", %{
+ tenant: tenant
+ } do
+ extension = %{
+ "type" => "postgres_cdc_rls",
+ "settings" => %{
+ "db_host" => "127.0.0.1",
+ "db_name" => "postgres",
+ "db_user" => "supabase_realtime_admin",
+ "db_password" => "postgres",
+ "poll_interval" => 100,
+ "poll_max_changes" => 100,
+ "poll_max_record_bytes" => 1_048_576,
+ "region" => "us-east-1",
+ "ssl_enforced" => false,
+ "db_pool" => 100,
+ "subcriber_pool_size" => 100,
+ "subs_pool_size" => 100
+ }
+ }
- log =
- capture_log(fn ->
- Realtime.Tenants.suspend_tenant_by_external_id(tenant2.external_id)
- Process.sleep(50)
- end)
+ {:ok, tenant} = update_extension(tenant, extension)
- refute log =~ "Tenant was suspended"
- assert Process.alive?(db_conn)
+ assert capture_log(fn ->
+ assert {:error, :tenant_db_too_many_connections} = Connect.lookup_or_start_connection(tenant.external_id)
+ end) =~ ~r/Only \d+ available connections\. At least \d+ connections are required/
end
test "properly handles of failing calls by avoid creating too many connections", %{tenant: tenant} do
@@ -306,7 +385,7 @@ defmodule Realtime.Tenants.ConnectTest do
"settings" => %{
"db_host" => "127.0.0.1",
"db_name" => "postgres",
- "db_user" => "supabase_admin",
+ "db_user" => "supabase_realtime_admin",
"db_password" => "postgres",
"poll_interval" => 100,
"poll_max_changes" => 100,
@@ -338,61 +417,137 @@ defmodule Realtime.Tenants.ConnectTest do
refute Process.alive?(pid)
end
+ test "reconciles migrations_ran when database count differs from cached value", %{tenant: tenant} do
+ total_migrations = Enum.count(Realtime.Tenants.Migrations.migrations())
+ stale_count = tenant.migrations_ran - 5
+ parent = self()
+
+ expect(Database, :check_tenant_connection, fn t ->
+ {:ok, conn, _actual_count} = call_original(Database, :check_tenant_connection, [t])
+ {:ok, conn, stale_count}
+ end)
+
+ expect(Realtime.Tenants.Migrations, :run_migrations, fn tenant ->
+ send(parent, {:migrations_ran_at_run, tenant.migrations_ran})
+ call_original(Realtime.Tenants.Migrations, :run_migrations, [tenant])
+ end)
+
+ assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
+ assert Connect.ready?(tenant.external_id)
+
+ assert_receive {:migrations_ran_at_run, ^stale_count}
+
+ updated_tenant = Tenants.get_tenant_by_external_id(tenant.external_id)
+ assert updated_tenant.migrations_ran == total_migrations
+ end
+
test "starts broadcast handler and does not fail on existing connection", %{tenant: tenant} do
assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
assert Connect.ready?(tenant.external_id)
- replication_connection_before = ReplicationConnection.whereis(tenant.external_id)
+ replication_connection_before = assert_pid(fn -> ReplicationConnection.whereis(tenant.external_id) end)
assert Process.alive?(replication_connection_before)
+ assert {:ok, replication_conn_pid_before} = assert_replication_status(tenant.external_id)
+
assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
- replication_connection_after = ReplicationConnection.whereis(tenant.external_id)
+ replication_connection_after = assert_pid(fn -> ReplicationConnection.whereis(tenant.external_id) end)
assert Process.alive?(replication_connection_after)
assert replication_connection_before == replication_connection_after
+
+ assert {:ok, replication_conn_pid_after} = assert_replication_status(tenant.external_id)
+ assert replication_conn_pid_before == replication_conn_pid_after
end
- test "on replication connection postgres pid being stopped, also kills the Connect module", %{tenant: tenant} do
+ test "on replication connection postgres pid being stopped, Connect module recovers it", %{tenant: tenant} do
assert {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
assert Connect.ready?(tenant.external_id)
- replication_connection_pid = ReplicationConnection.whereis(tenant.external_id)
+ replication_connection_pid = assert_pid(fn -> ReplicationConnection.whereis(tenant.external_id) end)
+ Process.monitor(replication_connection_pid)
+
assert Process.alive?(replication_connection_pid)
pid = Connect.whereis(tenant.external_id)
+ assert {:ok, replication_conn_before} = assert_replication_status(tenant.external_id)
+
Postgrex.query!(
db_conn,
"SELECT pg_terminate_backend(pid) from pg_stat_activity where application_name='realtime_replication_connection'",
[]
)
- assert_process_down(replication_connection_pid)
- assert_process_down(pid)
+ assert_receive {:DOWN, _, :process, ^replication_connection_pid, _}
+
+ Process.sleep(100)
+ assert {:error, :not_connected} = Connect.replication_status(tenant.external_id)
+
+ new_replication_connection_pid = assert_pid(fn -> ReplicationConnection.whereis(tenant.external_id) end, 60)
+
+ assert replication_connection_pid != new_replication_connection_pid
+ assert Process.alive?(new_replication_connection_pid)
+ assert Process.alive?(pid)
+
+ assert {:ok, replication_conn_after} = assert_replication_status(tenant.external_id, 60)
+ assert replication_conn_before != replication_conn_after
end
- test "on replication connection exit, also kills the Connect module", %{tenant: tenant} do
+ test "on replication connection exit, Connect module recovers it", %{tenant: tenant} do
assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
assert Connect.ready?(tenant.external_id)
- replication_connection_pid = ReplicationConnection.whereis(tenant.external_id)
+ replication_connection_pid = assert_pid(fn -> ReplicationConnection.whereis(tenant.external_id) end)
+ Process.monitor(replication_connection_pid)
assert Process.alive?(replication_connection_pid)
pid = Connect.whereis(tenant.external_id)
+
+ assert {:ok, replication_conn_before} = assert_replication_status(tenant.external_id)
+
Process.exit(replication_connection_pid, :kill)
+ assert_receive {:DOWN, _, :process, ^replication_connection_pid, _}
- assert_process_down(replication_connection_pid)
- assert_process_down(pid)
+ Process.sleep(1000)
+ assert {:error, :not_connected} = Connect.replication_status(tenant.external_id)
+
+ new_replication_connection_pid = assert_pid(fn -> ReplicationConnection.whereis(tenant.external_id) end)
+
+ assert replication_connection_pid != new_replication_connection_pid
+ assert Process.alive?(new_replication_connection_pid)
+ assert Process.alive?(pid)
+
+ assert {:ok, replication_conn_after} = assert_replication_status(tenant.external_id, 60)
+ assert replication_conn_before != replication_conn_after
+ end
+
+ test "defers and keeps the tenant alive when replication connection times out", %{tenant: tenant} do
+ expect(ReplicationConnection, :start, fn _tenant, _pid ->
+ {:error, :replication_connection_timeout}
+ end)
+
+ log =
+ capture_log(fn ->
+ assert {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
+ pid = Connect.whereis(tenant.external_id)
+ assert wait_until(fn -> :sys.get_state(pid).replication_recovery_started_at != nil end)
+ refute_process_down(db_conn)
+ end)
+
+ assert log =~ "ReplicationConnectionTimeout"
+ assert log =~ "Replication connection timed out during initialization"
end
test "handles max_wal_senders by logging the correct operational code", %{tenant: tenant} do
- opts = tenant |> Database.from_tenant("realtime_test", :stop) |> Database.opts()
+ {:ok, settings} = Database.from_tenant(tenant, "realtime_test", :stop)
+ opts = Database.opts(settings)
+ parent = self()
- # This creates a loop of errors that occupies all WAL senders and lets us test the error handling
pids =
for i <- 0..4 do
replication_slot_opts =
%PostgresReplication{
connection_opts: opts,
- table: :all,
+ table: "test",
output_plugin: "pgoutput",
output_plugin_options: [proto_version: "1", publication_names: "test_#{i}_publication"],
handler_module: Replication.TestHandler,
@@ -402,6 +557,7 @@ defmodule Realtime.Tenants.ConnectTest do
spawn(fn ->
{:ok, pid} = PostgresReplication.start_link(replication_slot_opts)
+ send(parent, {:replication_ready, i})
receive do
:stop -> Process.exit(pid, :kill)
@@ -409,6 +565,8 @@ defmodule Realtime.Tenants.ConnectTest do
end)
end
+ for i <- 0..4, do: assert_receive({:replication_ready, ^i}, 5000)
+
on_exit(fn ->
Enum.each(pids, &send(&1, :stop))
Process.sleep(2000)
@@ -417,7 +575,9 @@ defmodule Realtime.Tenants.ConnectTest do
log =
capture_log(fn ->
assert {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
- assert_process_down(db_conn)
+ pid = Connect.whereis(tenant.external_id)
+ assert wait_until(fn -> :sys.get_state(pid).replication_recovery_started_at != nil end)
+ refute_process_down(db_conn)
end)
assert log =~ "ReplicationMaxWalSendersReached"
@@ -429,34 +589,62 @@ defmodule Realtime.Tenants.ConnectTest do
assert capture_log(fn -> assert {:error, :rpc_error, _} = Connect.lookup_or_start_connection("tenant") end) =~
"project=tenant external_id=tenant [error] ErrorOnRpcCall"
end
- end
- describe "shutdown/1" do
- test "shutdowns all associated connections", %{tenant: tenant} do
- assert {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
- assert Process.alive?(db_conn)
- assert Connect.ready?(tenant.external_id)
- connect_pid = Connect.whereis(tenant.external_id)
- replication_connection_pid = ReplicationConnection.whereis(tenant.external_id)
- assert Process.alive?(connect_pid)
- assert Process.alive?(replication_connection_pid)
+ test "rate limit connect when too many connections against bad database", %{tenant: tenant} do
+ extension = %{
+ "type" => "postgres_cdc_rls",
+ "settings" => %{
+ "db_host" => "127.0.0.1",
+ "db_name" => "postgres",
+ "db_user" => "supabase_realtime_admin",
+ "db_password" => "postgres",
+ "poll_interval" => 100,
+ "poll_max_changes" => 100,
+ "poll_max_record_bytes" => 1_048_576,
+ "region" => "us-east-1",
+ "ssl_enforced" => true
+ }
+ }
- Connect.shutdown(tenant.external_id)
- assert_process_down(connect_pid)
- assert_process_down(replication_connection_pid)
+ {:ok, tenant} = update_extension(tenant, extension)
+
+ log =
+ capture_log(fn ->
+ res =
+ for _ <- 1..10 do
+ Process.sleep(250)
+ Connect.lookup_or_start_connection(tenant.external_id)
+ end
+
+ assert Enum.any?(res, fn {_, res} -> res == :connect_rate_limit_reached end)
+ end)
+
+ assert log =~ "DatabaseConnectionRateLimitReached: Too many connection attempts against the tenant database"
end
- test "if tenant does not exist, does nothing" do
- assert :ok = Connect.shutdown("none")
+ test "rate limit connect will not trigger if connection is successful", %{tenant: tenant} do
+ log =
+ capture_log(fn ->
+ res =
+ for _ <- 1..20 do
+ Process.sleep(500)
+ Connect.lookup_or_start_connection(tenant.external_id)
+ end
+
+ refute Enum.any?(res, fn {_, res} -> res == :tenant_db_too_many_connections end)
+ end)
+
+ refute log =~ "DatabaseConnectionRateLimitReached: Too many connection attempts against the tenant database"
end
- test "tenant not able to connect if database has not enough connections", %{tenant: tenant} do
+ test "rate limit connect does not trigger for non-connection-attempt errors like db pool exhaustion",
+ %{tenant: tenant} do
extension = %{
"type" => "postgres_cdc_rls",
"settings" => %{
"db_host" => "127.0.0.1",
"db_name" => "postgres",
- "db_user" => "supabase_admin",
+ "db_user" => "supabase_realtime_admin",
"db_password" => "postgres",
"poll_interval" => 100,
"poll_max_changes" => 100,
@@ -470,8 +658,206 @@ defmodule Realtime.Tenants.ConnectTest do
}
{:ok, tenant} = update_extension(tenant, extension)
+ parent = self()
+
+ expect(Database, :check_tenant_connection, fn t ->
+ :timer.sleep(1000)
+ call_original(Database, :check_tenant_connection, [t])
+ end)
+
+ connect = fn -> send(parent, Connect.lookup_or_start_connection(tenant.external_id)) end
+
+ spawn(connect)
+ :timer.sleep(100)
+ spawn(connect)
+ spawn(connect)
+
+ assert {:error, :tenant_db_too_many_connections} =
+ Connect.lookup_or_start_connection(tenant.external_id)
+
+ assert_receive {:error, :tenant_db_too_many_connections}
+ assert_receive {:error, :tenant_db_too_many_connections}
+ assert_receive {:error, :tenant_db_too_many_connections}
+ refute_receive _any
- assert {:error, :tenant_db_too_many_connections} = Connect.lookup_or_start_connection(tenant.external_id)
+ # Only 1 call_external_node failure should count toward the rate limit.
+ rate_args = Tenants.connect_errors_per_second_rate(tenant.external_id)
+ assert Realtime.GenCounter.get(rate_args.id) == 1
+ end
+ end
+
+ describe "shutdown/1" do
+ test "shutdowns all associated connections", %{tenant: tenant} do
+ assert {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
+ assert Process.alive?(db_conn)
+ assert Connect.ready?(tenant.external_id)
+ connect_pid = Connect.whereis(tenant.external_id)
+ replication_connection_pid = assert_pid(fn -> ReplicationConnection.whereis(tenant.external_id) end)
+ assert Process.alive?(connect_pid)
+ assert Process.alive?(replication_connection_pid)
+
+ assert {_, %{conn: ^db_conn}} = :syn.lookup(Connect, tenant.external_id)
+ assert {:ok, _replication_conn_pid} = assert_replication_status(tenant.external_id)
+
+ Connect.shutdown(tenant.external_id)
+ assert_process_down(connect_pid)
+ assert_process_down(replication_connection_pid)
+ end
+
+ test "if tenant does not exist, does nothing" do
+ assert :ok = Connect.shutdown("none")
+ end
+ end
+
+ describe "backoff configuration" do
+ test "backoff is configured with correct min/max/type values", %{tenant: tenant} do
+ assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
+ pid = Connect.whereis(tenant.external_id)
+ state = :sys.get_state(pid)
+ assert state.backoff.min == :timer.seconds(5)
+ assert state.backoff.max == :timer.minutes(5)
+ assert state.backoff.type == :rand_exp
+ end
+ end
+
+ describe "replication recovery" do
+ test "recovery reschedules without stopping when pg_stat_activity shows existing walsender", %{tenant: tenant} do
+ assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
+ assert Connect.ready?(tenant.external_id)
+
+ pid = Connect.whereis(tenant.external_id)
+
+ # The real replication connection is active, so pg_stat_activity returns num_rows: 1 naturally
+ send(pid, :recover_replication_connection)
+ Process.sleep(100)
+
+ assert Process.alive?(pid)
+ end
+
+ test "recovery stops when elapsed time exceeds 2-hour window", %{tenant: tenant} do
+ assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
+ assert Connect.ready?(tenant.external_id)
+ # Replication starts asynchronously; wait for it to settle so the async result handler
+ # doesn't clobber the state we inject below.
+ assert {:ok, _} = assert_replication_status(tenant.external_id)
+
+ pid = Connect.whereis(tenant.external_id)
+ ref = Process.monitor(pid)
+
+ past_ts = System.monotonic_time(:millisecond) - :timer.hours(3)
+ :sys.replace_state(pid, fn state -> %{state | replication_recovery_started_at: past_ts} end)
+
+ log =
+ capture_log(fn ->
+ send(pid, :recover_replication_connection)
+ assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, 1000
+ end)
+
+ assert log =~ "Replication recovery window exceeded"
+ end
+
+ test "recovery preserves replication_recovery_started_at across multiple crashes", %{tenant: tenant} do
+ assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
+ assert Connect.ready?(tenant.external_id)
+ # Replication starts asynchronously; wait for it to settle so the async result handler
+ # doesn't clobber the state we inject below.
+ assert {:ok, _} = assert_replication_status(tenant.external_id)
+
+ pid = Connect.whereis(tenant.external_id)
+ original_ts = System.monotonic_time(:millisecond) - 1000
+
+ ref = make_ref()
+
+ :sys.replace_state(pid, fn state ->
+ %{
+ state
+ | replication_connection_reference: ref,
+ replication_connection_pid: self(),
+ replication_recovery_started_at: original_ts
+ }
+ end)
+
+ send(pid, {:DOWN, ref, :process, self(), :simulated_crash})
+ Process.sleep(100)
+
+ state = :sys.get_state(pid)
+ assert state.replication_recovery_started_at == original_ts
+
+ Connect.shutdown(tenant.external_id)
+ end
+
+ test "recovery resets replication_recovery_started_at on successful reconnection", %{tenant: tenant} do
+ assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
+ assert Connect.ready?(tenant.external_id)
+
+ pid = Connect.whereis(tenant.external_id)
+
+ replication_pid = assert_pid(fn -> ReplicationConnection.whereis(tenant.external_id) end)
+ Process.monitor(replication_pid)
+ Process.exit(replication_pid, :kill)
+ assert_receive {:DOWN, _, :process, ^replication_pid, _}, 1000
+
+ Process.sleep(100)
+ assert {:error, :not_connected} = Connect.replication_status(tenant.external_id)
+
+ assert {:ok, _} = assert_replication_status(tenant.external_id)
+
+ state = :sys.get_state(pid)
+ assert state.replication_recovery_started_at == nil
+ assert Process.alive?(pid)
+
+ Connect.shutdown(tenant.external_id)
+ end
+
+ test "defers and recovers instead of terminating when slot is in use at startup", %{tenant: tenant} do
+ {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
+ slot_name = ReplicationConnection.replication_slot_name("realtime", "messages")
+
+ # Simulate a previous replication session still holding the slot during a
+ # restart/rebalance race so the initial replication start fails.
+ Postgrex.query!(db_conn, "SELECT pg_create_logical_replication_slot($1, 'test_decoding')", [slot_name])
+
+ log =
+ capture_log(fn ->
+ assert {:ok, _} = Connect.lookup_or_start_connection(tenant.external_id)
+ pid = Connect.whereis(tenant.external_id)
+ assert wait_until(fn -> :sys.get_state(pid).replication_recovery_started_at != nil end)
+ end)
+
+ pid = Connect.whereis(tenant.external_id)
+ assert is_pid(pid)
+ assert log =~ "StartReplicationFailed"
+
+ # Connect stays alive with the recovery window open instead of shutting down.
+ refute_process_down(pid)
+ state = :sys.get_state(pid)
+ assert state.replication_connection_pid == nil
+ assert state.replication_recovery_started_at != nil
+
+ # Free the slot; the scheduled retry should reconnect on its own and clear
+ # the recovery window.
+ Postgrex.query!(db_conn, "SELECT pg_drop_replication_slot($1)", [slot_name])
+
+ assert {:ok, _} = assert_replication_status(tenant.external_id)
+ assert :sys.get_state(pid).replication_recovery_started_at == nil
+
+ Connect.shutdown(tenant.external_id)
+ end
+ end
+
+ describe "get_status/1 degraded state" do
+ test "returns {:ok, conn} when replication_conn is nil in syn", %{tenant: tenant} do
+ assert {:ok, _db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
+ assert Connect.ready?(tenant.external_id)
+
+ tenant_id = tenant.external_id
+
+ :syn.update_registry(Connect, tenant_id, fn _pid, meta -> %{meta | replication_conn: nil} end)
+
+ assert {:ok, conn} = Connect.get_status(tenant_id)
+ assert is_pid(conn)
+
+ Connect.shutdown(tenant_id)
end
end
@@ -519,6 +905,48 @@ defmodule Realtime.Tenants.ConnectTest do
put_in(extension, ["settings", "db_port"], db_port)
]
- Realtime.Api.update_tenant(tenant, %{extensions: extensions})
+ Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{extensions: extensions})
+ end
+
+ defp assert_pid(call, attempts \\ 30)
+
+ defp assert_pid(_call, 0) do
+ raise "Timeout waiting for pid"
+ end
+
+ defp assert_pid(call, attempts) do
+ case call.() do
+ pid when is_pid(pid) ->
+ pid
+
+ _ ->
+ Process.sleep(500)
+ assert_pid(call, attempts - 1)
+ end
+ end
+
+ defp wait_until(fun, attempts \\ 50)
+ defp wait_until(_fun, 0), do: false
+
+ defp wait_until(fun, attempts) do
+ if fun.() do
+ true
+ else
+ Process.sleep(50)
+ wait_until(fun, attempts - 1)
+ end
+ end
+
+ defp assert_replication_status(tenant_id, attempts \\ 30)
+
+ defp assert_replication_status(tenant_id, 0) do
+ Connect.replication_status(tenant_id)
+ end
+
+ defp assert_replication_status(tenant_id, attempts) do
+ case Connect.replication_status(tenant_id) do
+ {:ok, _} = result -> result
+ _ -> Process.sleep(500) && assert_replication_status(tenant_id, attempts - 1)
+ end
end
end
diff --git a/test/realtime/tenants/janitor/maintenance_task_test.exs b/test/realtime/tenants/janitor/maintenance_task_test.exs
index f4c51436e..7b988e445 100644
--- a/test/realtime/tenants/janitor/maintenance_task_test.exs
+++ b/test/realtime/tenants/janitor/maintenance_task_test.exs
@@ -4,42 +4,71 @@ defmodule Realtime.Tenants.Janitor.MaintenanceTaskTest do
alias Realtime.Tenants.Janitor.MaintenanceTask
alias Realtime.Api.Message
alias Realtime.Database
- alias Realtime.Repo
+ alias Realtime.Tenants.Repo
setup do
tenant = Containers.checkout_tenant(run_migrations: true)
# Warm cache to avoid Cachex and Ecto.Sandbox ownership issues
- Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant})
+ Realtime.Tenants.Cache.update_cache(tenant)
%{tenant: tenant}
end
- test "cleans messages older than 72 hours and creates partitions", %{tenant: tenant} do
- utc_now = NaiveDateTime.utc_now()
- limit = NaiveDateTime.add(utc_now, -72, :hour)
+ describe "run/1" do
+ setup %{tenant: tenant} do
+ {:ok, conn} = Database.connect(tenant, "realtime_test", :stop)
- messages =
- for days <- -5..0 do
- inserted_at = NaiveDateTime.add(utc_now, days, :day)
- message_fixture(tenant, %{inserted_at: inserted_at})
- end
- |> MapSet.new()
+ date_start = Date.utc_today() |> Date.add(-10)
+ date_end = Date.utc_today()
+ create_messages_partitions(conn, date_start, date_end)
- to_keep =
- messages
- |> Enum.reject(&(NaiveDateTime.compare(limit, &1.inserted_at) == :gt))
- |> MapSet.new()
+ %{conn: conn}
+ end
- assert MaintenanceTask.run(tenant.external_id) == :ok
+ test "cleans messages older than 72 hours", %{tenant: tenant, conn: conn} do
+ utc_now = NaiveDateTime.utc_now()
+ limit = NaiveDateTime.add(utc_now, -72, :hour)
- {:ok, conn} = Database.connect(tenant, "realtime_test", :stop)
- {:ok, res} = Repo.all(conn, from(m in Message), Message)
+ messages =
+ for days <- -5..0 do
+ inserted_at = NaiveDateTime.add(utc_now, days, :day)
+ message_fixture(tenant, %{inserted_at: inserted_at})
+ end
+ |> MapSet.new()
- verify_partitions(conn)
+ to_keep =
+ messages
+ |> Enum.reject(&(NaiveDateTime.compare(NaiveDateTime.beginning_of_day(limit), &1.inserted_at) == :gt))
+ |> MapSet.new()
- current = MapSet.new(res)
+ assert MaintenanceTask.run(tenant.external_id) == :ok
- assert MapSet.difference(current, to_keep) |> MapSet.size() == 0
+ {:ok, res} = Repo.all(conn, from(m in Message), Message)
+ current = MapSet.new(res)
+
+ assert MapSet.difference(current, to_keep) |> MapSet.size() == 0
+ end
+
+ test "creates the current messages partitions and drops the old ones", %{tenant: tenant, conn: conn} do
+ assert MaintenanceTask.run(tenant.external_id) == :ok
+
+ today = Date.utc_today()
+ dates = Date.range(Date.add(today, -3), Date.add(today, 3))
+
+ %{rows: rows} =
+ Postgrex.query!(
+ conn,
+ "SELECT tablename from pg_catalog.pg_tables where schemaname = 'realtime' and tablename like 'messages_%'",
+ []
+ )
+
+ partitions = MapSet.new(rows, fn [name] -> name end)
+
+ expected_names =
+ MapSet.new(dates, fn date -> "messages_#{date |> Date.to_iso8601() |> String.replace("-", "_")}" end)
+
+ assert MapSet.equal?(partitions, expected_names)
+ end
end
test "exits if fails to remove old messages" do
@@ -49,7 +78,7 @@ defmodule Realtime.Tenants.Janitor.MaintenanceTaskTest do
"settings" => %{
"db_host" => "127.0.0.1",
"db_name" => "postgres",
- "db_user" => "supabase_admin",
+ "db_user" => "supabase_realtime_admin",
"db_password" => "postgres",
"db_port" => "11111",
"poll_interval" => 100,
@@ -63,7 +92,7 @@ defmodule Realtime.Tenants.Janitor.MaintenanceTaskTest do
tenant = tenant_fixture(%{extensions: extensions})
# Warm cache to avoid Cachex and Ecto.Sandbox ownership issues
- Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant})
+ Realtime.Tenants.Cache.update_cache(tenant)
Process.flag(:trap_exit, true)
@@ -77,25 +106,4 @@ defmodule Realtime.Tenants.Janitor.MaintenanceTaskTest do
assert_receive {:EXIT, ^pid, :killed}
assert_receive {:DOWN, ^ref, :process, ^pid, :killed}
end
-
- defp verify_partitions(conn) do
- today = Date.utc_today()
- yesterday = Date.add(today, -1)
- future = Date.add(today, 3)
- dates = Date.range(yesterday, future)
-
- %{rows: rows} =
- Postgrex.query!(
- conn,
- "SELECT tablename from pg_catalog.pg_tables where schemaname = 'realtime' and tablename like 'messages_%'",
- []
- )
-
- partitions = MapSet.new(rows, fn [name] -> name end)
-
- expected_names =
- MapSet.new(dates, fn date -> "messages_#{date |> Date.to_iso8601() |> String.replace("-", "_")}" end)
-
- assert MapSet.equal?(partitions, expected_names)
- end
end
diff --git a/test/realtime/tenants/janitor_test.exs b/test/realtime/tenants/janitor_test.exs
index 4ac1a0eda..7ead28e97 100644
--- a/test/realtime/tenants/janitor_test.exs
+++ b/test/realtime/tenants/janitor_test.exs
@@ -6,9 +6,9 @@ defmodule Realtime.Tenants.JanitorTest do
alias Realtime.Api.Message
alias Realtime.Database
- alias Realtime.Repo
alias Realtime.Tenants.Janitor
alias Realtime.Tenants.Connect
+ alias Realtime.Tenants.Repo
setup do
:ets.delete_all_objects(Connect)
@@ -24,13 +24,21 @@ defmodule Realtime.Tenants.JanitorTest do
Enum.map(
[tenant1, tenant2],
fn tenant ->
- tenant = Repo.preload(tenant, :extensions)
+ tenant = Realtime.Repo.preload(tenant, :extensions)
Connect.lookup_or_start_connection(tenant.external_id)
Process.sleep(500)
tenant
end
)
+ date_start = Date.utc_today() |> Date.add(-10)
+ date_end = Date.utc_today()
+
+ Enum.map(tenants, fn tenant ->
+ {:ok, conn} = Database.connect(tenant, "realtime_test", :stop)
+ create_messages_partitions(conn, date_start, date_end)
+ end)
+
start_supervised!(
{Task.Supervisor,
name: Realtime.Tenants.Janitor.TaskSupervisor, max_children: 5, max_seconds: 500, max_restarts: 1}
@@ -62,7 +70,7 @@ defmodule Realtime.Tenants.JanitorTest do
to_keep =
messages
- |> Enum.reject(&(NaiveDateTime.compare(limit, &1.inserted_at) == :gt))
+ |> Enum.reject(&(NaiveDateTime.compare(NaiveDateTime.beginning_of_day(limit), &1.inserted_at) == :gt))
|> MapSet.new()
start_supervised!(Janitor)
@@ -105,7 +113,7 @@ defmodule Realtime.Tenants.JanitorTest do
to_keep =
messages
- |> Enum.reject(&(NaiveDateTime.compare(limit, &1.inserted_at) == :gt))
+ |> Enum.reject(&(NaiveDateTime.compare(NaiveDateTime.beginning_of_day(limit), &1.inserted_at) == :gt))
|> MapSet.new()
start_supervised!(Janitor)
@@ -134,7 +142,7 @@ defmodule Realtime.Tenants.JanitorTest do
"settings" => %{
"db_host" => "127.0.0.1",
"db_name" => "postgres",
- "db_user" => "supabase_admin",
+ "db_user" => "supabase_realtime_admin",
"db_password" => "postgres",
"db_port" => "1111",
"poll_interval" => 100,
@@ -162,7 +170,7 @@ defmodule Realtime.Tenants.JanitorTest do
defp verify_partitions(conn) do
today = Date.utc_today()
- yesterday = Date.add(today, -1)
+ yesterday = Date.add(today, -3)
future = Date.add(today, 3)
dates = Date.range(yesterday, future)
diff --git a/test/realtime/tenants/migrations_test.exs b/test/realtime/tenants/migrations_test.exs
index 60f290beb..1cf1bebbc 100644
--- a/test/realtime/tenants/migrations_test.exs
+++ b/test/realtime/tenants/migrations_test.exs
@@ -1,10 +1,17 @@
defmodule Realtime.Tenants.MigrationsTest do
- alias Realtime.Tenants.Cache
# Can't use async: true because Cachex does not work well with Ecto Sandbox
use Realtime.DataCase, async: false
+ use Mimic
+ alias Realtime.Api
+ alias Realtime.Tenants.Cache
alias Realtime.Tenants.Migrations
+ setup do
+ Cachex.clear(Realtime.FeatureFlags.Cache)
+ :ok
+ end
+
describe "run_migrations/1" do
test "migrations for a given tenant only run once" do
tenant = Containers.checkout_tenant()
@@ -33,4 +40,151 @@ defmodule Realtime.Tenants.MigrationsTest do
assert Migrations.run_migrations(tenant) == :noop
end
end
+
+ describe "run_migrations_async/1" do
+ test "returns immediately and runs migrations in the background" do
+ tenant = Containers.checkout_tenant()
+
+ assert Migrations.run_migrations_async(tenant) == :ok
+
+ assert eventually(fn ->
+ Cache.get_tenant_by_external_id(tenant.external_id).migrations_ran ==
+ Enum.count(Migrations.migrations())
+ end)
+ end
+
+ test "does not run if tenant has migrations_ran equal to count of all migrations" do
+ tenant = tenant_fixture(%{migrations_ran: Enum.count(Migrations.migrations())})
+ assert Migrations.run_migrations_async(tenant) == :noop
+ end
+ end
+
+ describe "run_migrations?/1" do
+ test "returns true if migrations_ran is lower than existing migrations" do
+ tenant = tenant_fixture(%{migrations_ran: 0})
+ assert Migrations.run_migrations?(tenant)
+
+ tenant = tenant_fixture(%{migrations_ran: Enum.count(Migrations.migrations()) - 1})
+ assert Migrations.run_migrations?(tenant)
+ end
+
+ test "returns false if migrations_ran is count of all migrations" do
+ tenant = tenant_fixture(%{migrations_ran: Enum.count(Migrations.migrations())})
+ refute Migrations.run_migrations?(tenant)
+ end
+ end
+
+ describe "migrations/1" do
+ test "excludes SetupSupabaseRealtimeAdmin when the feature flag is disabled" do
+ {:ok, _} = Api.upsert_feature_flag(%{name: "use_supabase_realtime_admin", enabled: false})
+
+ modules = Enum.map(Migrations.migrations(), fn {_v, m} -> m end)
+ refute Migrations.SetupSupabaseRealtimeAdmin in modules
+ end
+
+ test "excludes SetupSupabaseRealtimeAdmin when the tenant override is disabled" do
+ tenant = Containers.checkout_tenant()
+ {:ok, _} = Api.upsert_feature_flag(%{name: "use_supabase_realtime_admin", enabled: true})
+ {:ok, _} = Realtime.FeatureFlags.set_tenant_flag("use_supabase_realtime_admin", tenant.external_id, false)
+
+ Process.sleep(100)
+ Cache.invalidate_tenant_cache(tenant.external_id)
+
+ modules = Enum.map(Migrations.migrations(tenant.external_id), fn {_v, m} -> m end)
+ refute Migrations.SetupSupabaseRealtimeAdmin in modules
+ end
+
+ test "includes SetupSupabaseRealtimeAdmin when the feature flag is enabled" do
+ {:ok, _} = Api.upsert_feature_flag(%{name: "use_supabase_realtime_admin", enabled: true})
+
+ modules = Enum.map(Migrations.migrations(), fn {_v, m} -> m end)
+ assert Migrations.SetupSupabaseRealtimeAdmin in modules
+ end
+
+ test "includes SetupSupabaseRealtimeAdmin when the tenant override is enabled" do
+ tenant = Containers.checkout_tenant()
+ {:ok, _} = Api.upsert_feature_flag(%{name: "use_supabase_realtime_admin", enabled: false})
+ {:ok, _} = Realtime.FeatureFlags.set_tenant_flag("use_supabase_realtime_admin", tenant.external_id, true)
+
+ Process.sleep(100)
+ Cache.invalidate_tenant_cache(tenant.external_id)
+
+ modules = Enum.map(Migrations.migrations(tenant.external_id), fn {_v, m} -> m end)
+ assert Migrations.SetupSupabaseRealtimeAdmin in modules
+ end
+ end
+
+ describe "telemetry" do
+ setup :set_mimic_global
+
+ setup do
+ events = [
+ [:realtime, :tenants, :migrations, :start],
+ [:realtime, :tenants, :migrations, :stop],
+ [:realtime, :tenants, :migrations, :exception]
+ ]
+
+ :telemetry.attach_many(__MODULE__, events, &__MODULE__.handle_telemetry/4, pid: self())
+ on_exit(fn -> :telemetry.detach(__MODULE__) end)
+
+ :ok
+ end
+
+ test "emits start event metadata" do
+ tenant = Containers.checkout_tenant()
+ external_id = tenant.external_id
+
+ assert Migrations.run_migrations(tenant) == :ok
+
+ assert_receive {:telemetry, [:realtime, :tenants, :migrations, :start], %{system_time: _},
+ %{external_id: ^external_id, hostname: hostname}}
+
+ assert is_binary(hostname)
+ end
+
+ test "emits stop event with metadata" do
+ tenant = Containers.checkout_tenant()
+ external_id = tenant.external_id
+
+ assert Migrations.run_migrations(tenant) == :ok
+
+ total = Enum.count(Migrations.migrations())
+
+ assert_receive {:telemetry, [:realtime, :tenants, :migrations, :stop], %{duration: duration},
+ %{external_id: ^external_id, hostname: hostname, migrations_executed: ^total}}
+
+ assert is_binary(hostname)
+ assert is_integer(duration) and duration >= 0
+ end
+
+ test "emits exception event tagged with postgrex error on postgres errors" do
+ tenant = Containers.checkout_tenant()
+ external_id = tenant.external_id
+
+ error = %Postgrex.Error{postgres: %{code: :undefined_column}}
+ expect(Ecto.Migrator, :run, fn _, _, _, _ -> raise error end)
+
+ Migrations.run_migrations(tenant)
+
+ assert_receive {:telemetry, [:realtime, :tenants, :migrations, :exception], %{duration: _},
+ %{external_id: ^external_id, error_code: :undefined_column, kind: :error, reason: ^error}}
+ end
+
+ test "tags connection errors with connection_error code" do
+ tenant = Containers.checkout_tenant()
+ external_id = tenant.external_id
+
+ error = %DBConnection.ConnectionError{message: "ssl send: closed"}
+ expect(Ecto.Migrator, :run, fn _, _, _, _ -> raise error end)
+
+ Migrations.run_migrations(tenant)
+
+ assert_receive {:telemetry, [:realtime, :tenants, :migrations, :exception], _,
+ %{external_id: ^external_id, error_code: :connection_error}}
+ end
+ end
+
+ def handle_telemetry(event, measurements, metadata, pid: pid) do
+ send(pid, {:telemetry, event, measurements, metadata})
+ end
end
diff --git a/test/realtime/tenants/rebalancer_test.exs b/test/realtime/tenants/rebalancer_test.exs
index ac8e1ea36..d91e7e675 100644
--- a/test/realtime/tenants/rebalancer_test.exs
+++ b/test/realtime/tenants/rebalancer_test.exs
@@ -9,7 +9,7 @@ defmodule Realtime.Tenants.RebalancerTest do
setup do
tenant = Containers.checkout_tenant(run_migrations: true)
# Warm cache to avoid Cachex and Ecto.Sandbox ownership issues
- Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant})
+ Realtime.Tenants.Cache.update_cache(tenant)
%{tenant: tenant}
end
diff --git a/test/realtime/tenants/replication_connection/watchdog_test.exs b/test/realtime/tenants/replication_connection/watchdog_test.exs
new file mode 100644
index 000000000..c122010e6
--- /dev/null
+++ b/test/realtime/tenants/replication_connection/watchdog_test.exs
@@ -0,0 +1,233 @@
+defmodule Realtime.Tenants.ReplicationConnection.WatchdogTest do
+ use ExUnit.Case, async: true
+
+ use Mimic
+
+ import ExUnit.CaptureLog
+
+ alias Realtime.Database
+ alias Realtime.Tenants.Connect
+ alias Realtime.Tenants.ReplicationConnection.Watchdog
+
+ defmodule FakeReplicationConnection do
+ def child_spec(opts) do
+ %{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}, type: :worker, restart: :temporary, shutdown: 500}
+ end
+
+ def start_link(opts \\ []), do: :gen_statem.start_link(__MODULE__, opts, [])
+
+ def callback_mode, do: :state_functions
+
+ def init(opts) do
+ respond_to_health_checks = Keyword.get(opts, :respond_to_health_checks, true)
+ delay_ms = Keyword.get(opts, :delay_ms, 0)
+
+ data = %{
+ respond_to_health_checks: respond_to_health_checks,
+ delay_ms: delay_ms,
+ health_check_count: 0
+ }
+
+ {:ok, :idle, data}
+ end
+
+ def idle({:call, from}, :health_check, %{respond_to_health_checks: true, delay_ms: delay_ms} = data) do
+ if delay_ms > 0 do
+ Process.sleep(delay_ms)
+ end
+
+ :gen_statem.reply(from, :ok)
+ {:keep_state, %{data | health_check_count: data.health_check_count + 1}}
+ end
+
+ def idle({:call, _from}, :health_check, %{respond_to_health_checks: false} = data) do
+ # Don't reply - this will cause a timeout
+ {:keep_state, %{data | health_check_count: data.health_check_count + 1}}
+ end
+
+ def idle({:call, from}, :get_health_check_count, data) do
+ :gen_statem.reply(from, data.health_check_count)
+ {:keep_state, data}
+ end
+
+ def idle({:call, from}, :set_no_respond, data) do
+ :gen_statem.reply(from, :ok)
+ {:keep_state, %{data | respond_to_health_checks: false}}
+ end
+
+ def get_health_check_count(pid), do: :gen_statem.call(pid, :get_health_check_count)
+
+ def set_no_respond(pid), do: :gen_statem.call(pid, :set_no_respond)
+ end
+
+ test "performs periodic health checks successfully" do
+ fake_pid = start_link_supervised!(FakeReplicationConnection)
+
+ watchdog_pid =
+ start_supervised!(
+ {Watchdog, parent_pid: fake_pid, tenant_id: "test-tenant", watchdog_interval: 50, watchdog_timeout: 100}
+ )
+
+ # Wait for at least 2 health check cycles
+ Process.sleep(150)
+
+ assert Process.alive?(watchdog_pid)
+ assert Process.alive?(fake_pid)
+
+ # Verify health checks were performed
+ count = FakeReplicationConnection.get_health_check_count(fake_pid)
+ assert count >= 2
+ end
+
+ describe "timeout handling" do
+ test "stops when health check times out" do
+ # Create a fake process that doesn't respond to health checks
+ fake_pid = start_supervised!({FakeReplicationConnection, respond_to_health_checks: false})
+
+ logs =
+ capture_log(fn ->
+ watchdog_pid =
+ start_supervised!(
+ {Watchdog, parent_pid: fake_pid, tenant_id: "test-tenant", watchdog_interval: 50, watchdog_timeout: 100}
+ )
+
+ ref = Process.monitor(watchdog_pid)
+
+ # Wait for the first health check to timeout
+ assert_receive {:DOWN, ^ref, :process, ^watchdog_pid, :watchdog_timeout}, 500
+ refute Process.alive?(watchdog_pid)
+ end)
+
+ assert logs =~ "ReplicationConnectionWatchdogTimeout"
+ assert logs =~ "ReplicationConnection is not responding"
+ end
+
+ test "stops immediately if health check takes longer than timeout" do
+ # Create a fake process with a 200ms delay
+ fake_pid = start_supervised!({FakeReplicationConnection, delay_ms: 200})
+
+ logs =
+ capture_log(fn ->
+ watchdog_pid =
+ start_supervised!(
+ {Watchdog, parent_pid: fake_pid, tenant_id: "timeout-test", watchdog_interval: 50, watchdog_timeout: 100}
+ )
+
+ ref = Process.monitor(watchdog_pid)
+
+ # Should timeout because delay (200ms) > timeout (100ms)
+ assert_receive {:DOWN, ^ref, :process, ^watchdog_pid, :watchdog_timeout}, 500
+ end)
+
+ assert logs =~ "ReplicationConnectionWatchdogTimeout"
+ end
+ end
+
+ describe "dynamic behavior changes" do
+ test "handles transition from healthy to timeout" do
+ # Start with responding, then stop responding
+ fake_pid = start_supervised!(FakeReplicationConnection)
+
+ watchdog_pid =
+ start_supervised!(
+ {Watchdog, parent_pid: fake_pid, tenant_id: "test-tenant", watchdog_interval: 50, watchdog_timeout: 100}
+ )
+
+ # Wait for first successful health check
+ Process.sleep(80)
+ assert Process.alive?(watchdog_pid)
+
+ ref = Process.monitor(watchdog_pid)
+ # Now make the fake process stop responding
+ FakeReplicationConnection.set_no_respond(fake_pid)
+
+ logs =
+ capture_log(fn ->
+ # Should timeout on next health check
+ assert_receive {:DOWN, ^ref, :process, ^watchdog_pid, :watchdog_timeout}, 500
+ end)
+
+ assert logs =~ "ReplicationConnectionWatchdogTimeout"
+ end
+ end
+
+ describe "slot lag monitoring" do
+ setup do
+ fake_pid = start_link_supervised!(FakeReplicationConnection)
+ %{fake_pid: fake_pid}
+ end
+
+ test "continues when slot lag is below threshold", %{fake_pid: fake_pid} do
+ stub(Connect, :get_status, fn _tenant_id -> {:ok, :fake_conn} end)
+ stub(Database, :check_replication_slot_lag, fn _conn, _slot -> :ok end)
+
+ watchdog_pid =
+ start_supervised!(
+ {Watchdog,
+ parent_pid: fake_pid,
+ tenant_id: "lag-test",
+ watchdog_interval: 50,
+ watchdog_timeout: 100,
+ replication_slot_name: "test_slot"}
+ )
+
+ Mimic.allow(Connect, self(), watchdog_pid)
+ Mimic.allow(Database, self(), watchdog_pid)
+
+ Process.sleep(120)
+
+ assert Process.alive?(watchdog_pid)
+ end
+
+ test "stops with :slot_lag_too_high when lag exceeds threshold", %{fake_pid: fake_pid} do
+ stub(Connect, :get_status, fn _tenant_id -> {:ok, :fake_conn} end)
+ stub(Database, :check_replication_slot_lag, fn _conn, _slot -> {:error, :lag_too_high} end)
+
+ logs =
+ capture_log(fn ->
+ watchdog_pid =
+ start_supervised!(
+ {Watchdog,
+ parent_pid: fake_pid,
+ tenant_id: "lag-test",
+ watchdog_interval: 50,
+ watchdog_timeout: 100,
+ replication_slot_name: "test_slot"}
+ )
+
+ Mimic.allow(Connect, self(), watchdog_pid)
+ Mimic.allow(Database, self(), watchdog_pid)
+
+ ref = Process.monitor(watchdog_pid)
+ assert_receive {:DOWN, ^ref, :process, ^watchdog_pid, :slot_lag_too_high}, 500
+ end)
+
+ assert logs =~ "ReplicationSlotLagTooHigh"
+ end
+
+ test "continues when DB connection is unavailable (graceful degradation)", %{fake_pid: fake_pid} do
+ stub(Connect, :get_status, fn _tenant_id -> {:error, :tenant_database_unavailable} end)
+
+ logs =
+ capture_log(fn ->
+ watchdog_pid =
+ start_supervised!(
+ {Watchdog,
+ parent_pid: fake_pid,
+ tenant_id: "lag-test",
+ watchdog_interval: 50,
+ watchdog_timeout: 100,
+ replication_slot_name: "test_slot"}
+ )
+
+ Mimic.allow(Connect, self(), watchdog_pid)
+
+ Process.sleep(120)
+
+ assert Process.alive?(watchdog_pid)
+ end)
+
+ assert logs =~ "ReplicationSlotLagCheckSkipped"
+ end
+ end
+end
diff --git a/test/realtime/tenants/replication_connection_test.exs b/test/realtime/tenants/replication_connection_test.exs
index 783270313..2661e4537 100644
--- a/test/realtime/tenants/replication_connection_test.exs
+++ b/test/realtime/tenants/replication_connection_test.exs
@@ -1,16 +1,19 @@
defmodule Realtime.Tenants.ReplicationConnectionTest do
# Async false due to tweaking application env
use Realtime.DataCase, async: false
- use Mimic
- setup :set_mimic_global
import ExUnit.CaptureLog
alias Realtime.Api.Message
alias Realtime.Database
+ alias Realtime.GenCounter
+ alias Realtime.RateCounter
alias Realtime.Tenants
alias Realtime.Tenants.ReplicationConnection
alias RealtimeWeb.Endpoint
+ alias Realtime.Tenants.Repo
+
+ @replication_slot_name "supabase_realtime_messages_replication_slot_test"
setup do
slot = Application.get_env(:realtime, :slot_name_suffix)
@@ -20,11 +23,10 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do
tenant = Containers.checkout_tenant(run_migrations: true)
{:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
- name = "supabase_realtime_messages_replication_slot_test"
- Postgrex.query(db_conn, "SELECT pg_drop_replication_slot($1)", [name])
- Process.exit(db_conn, :normal)
+ Integrations.setup_postgres_changes(db_conn)
+ Postgrex.query(db_conn, "SELECT pg_drop_replication_slot($1)", [@replication_slot_name])
- %{tenant: tenant}
+ %{tenant: tenant, db_conn: db_conn}
end
describe "temporary process" do
@@ -44,6 +46,36 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do
end
end
+ describe "watchdog kills unresponsive replication" do
+ setup do
+ replication_watchdog_interval = Application.get_env(:realtime, :replication_watchdog_interval)
+ replication_watchdog_timeout = Application.get_env(:realtime, :replication_watchdog_timeout)
+
+ on_exit(fn ->
+ Application.put_env(:realtime, :replication_watchdog_interval, replication_watchdog_interval)
+ Application.put_env(:realtime, :replication_watchdog_timeout, replication_watchdog_timeout)
+ end)
+
+ Application.put_env(:realtime, :replication_watchdog_interval, 100)
+ Application.put_env(:realtime, :replication_watchdog_timeout, 100)
+ end
+
+ test "watchdog kills replication connection that is not responding to health checks", %{tenant: tenant} do
+ assert {:ok, pid} = ReplicationConnection.start(tenant, self())
+
+ log =
+ capture_log(fn ->
+ # Let's make it not reply to health checks
+ :sys.suspend(pid)
+
+ reason = assert_process_down(pid, 400)
+ assert reason == :watchdog_timeout
+ end)
+
+ assert log =~ "ReplicationConnectionWatchdogTimeout"
+ end
+ end
+
describe "replication" do
test "fails if tenant connection is invalid" do
tenant =
@@ -54,7 +86,7 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do
"settings" => %{
"db_host" => "127.0.0.1",
"db_name" => "postgres",
- "db_user" => "supabase_admin",
+ "db_user" => "supabase_realtime_admin",
"db_password" => "postgres",
"db_port" => "9001",
"poll_interval" => 100,
@@ -70,7 +102,7 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do
assert {:error, _} = ReplicationConnection.start(tenant, self())
end
- test "starts a handler for the tenant and broadcasts", %{tenant: tenant} do
+ test "starts a handler for the tenant and broadcasts", %{tenant: tenant, db_conn: db_conn} do
start_link_supervised!(
{ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}},
restart: :transient
@@ -98,8 +130,8 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do
payload = %{
"event" => "INSERT",
+ "meta" => %{"id" => row.id},
"payload" => %{
- "id" => row.id,
"value" => value
},
"type" => "broadcast"
@@ -121,8 +153,7 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do
})
end
- {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
- {:ok, _} = Realtime.Repo.insert_all_entries(db_conn, messages, Message)
+ {:ok, _} = Repo.insert_all_entries(db_conn, messages, Message)
messages_received =
for _ <- 1..total_messages, into: [] do
@@ -139,8 +170,8 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do
"event" => "broadcast",
"payload" => %{
"event" => "INSERT",
+ "meta" => %{"id" => _id},
"payload" => %{
- "id" => _,
"value" => ^value
}
},
@@ -153,6 +184,195 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do
end
end
+ test "starts a handler for the tenant and broadcasts to public channel", %{tenant: tenant, db_conn: db_conn} do
+ start_link_supervised!(
+ {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}},
+ restart: :transient
+ )
+
+ topic = random_string()
+ tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, true)
+ subscribe(tenant_topic, topic)
+
+ total_messages = 5
+ # Works with one insert per transaction
+ for _ <- 1..total_messages do
+ value = random_string()
+
+ row =
+ message_fixture(tenant, %{
+ "topic" => topic,
+ "private" => false,
+ "event" => "INSERT",
+ "payload" => %{"value" => value}
+ })
+
+ assert_receive {:socket_push, :text, data}
+ message = data |> IO.iodata_to_binary() |> Jason.decode!()
+
+ payload = %{
+ "event" => "INSERT",
+ "meta" => %{"id" => row.id},
+ "payload" => %{
+ "value" => value
+ },
+ "type" => "broadcast"
+ }
+
+ assert message == %{"event" => "broadcast", "payload" => payload, "ref" => nil, "topic" => topic}
+ end
+
+ Process.sleep(500)
+ # Works with batch inserts
+ messages =
+ for _ <- 1..total_messages do
+ Message.changeset(%Message{}, %{
+ "topic" => topic,
+ "private" => false,
+ "event" => "INSERT",
+ "extension" => "broadcast",
+ "payload" => %{"value" => random_string()}
+ })
+ end
+
+ {:ok, _} = Repo.insert_all_entries(db_conn, messages, Message)
+
+ messages_received =
+ for _ <- 1..total_messages, into: [] do
+ assert_receive {:socket_push, :text, data}
+ data |> IO.iodata_to_binary() |> Jason.decode!()
+ end
+
+ for row <- messages do
+ assert Enum.count(messages_received, fn message_received ->
+ value = row |> Map.from_struct() |> get_in([:changes, :payload, "value"])
+
+ match?(
+ %{
+ "event" => "broadcast",
+ "payload" => %{
+ "event" => "INSERT",
+ "meta" => %{"id" => _id},
+ "payload" => %{
+ "value" => ^value
+ }
+ },
+ "ref" => nil,
+ "topic" => ^topic
+ },
+ message_received
+ )
+ end) == 1
+ end
+ end
+
+ test "replicates binary with exactly 16 bytes to test UUID conversion error", %{tenant: tenant} do
+ start_link_supervised!(
+ {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}},
+ restart: :transient
+ )
+
+ topic = "db:job_scheduler"
+ tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false)
+ subscribe(tenant_topic, topic)
+ payload = %{"value" => random_string()}
+
+ row =
+ message_fixture(tenant, %{
+ "topic" => topic,
+ "private" => true,
+ "event" => "UPDATE",
+ "extension" => "broadcast",
+ "payload" => payload
+ })
+
+ row_id = row.id
+
+ assert_receive {:socket_push, :text, data}, 2000
+ message = data |> IO.iodata_to_binary() |> Jason.decode!()
+
+ assert %{
+ "event" => "broadcast",
+ "payload" => %{
+ "event" => "UPDATE",
+ "meta" => %{"id" => ^row_id},
+ "payload" => received_payload,
+ "type" => "broadcast"
+ },
+ "ref" => nil,
+ "topic" => ^topic
+ } = message
+
+ assert received_payload == payload
+ end
+
+ test "should not process unsupported relations", %{tenant: tenant, db_conn: db_conn} do
+ # update
+ queries = [
+ "DROP TABLE IF EXISTS public.test",
+ """
+ CREATE TABLE "public"."test" (
+ "id" int4 NOT NULL default nextval('test_id_seq'::regclass),
+ "details" text,
+ PRIMARY KEY ("id"));
+ """
+ ]
+
+ Postgrex.transaction(db_conn, fn conn ->
+ Enum.each(queries, &Postgrex.query!(conn, &1, []))
+ end)
+
+ logs =
+ capture_log(fn ->
+ start_link_supervised!(
+ {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}},
+ restart: :transient
+ )
+
+ assert_replication_started(db_conn, @replication_slot_name)
+ assert_publication_contains_only_messages(db_conn, "supabase_realtime_messages_publication")
+
+ # Add table to publication to test the error handling
+ Postgrex.query!(db_conn, "ALTER PUBLICATION supabase_realtime_messages_publication ADD TABLE public.test", [])
+ %{rows: [[_id]]} = Postgrex.query!(db_conn, "insert into test (details) values ('test') returning id", [])
+
+ topic = "db:job_scheduler"
+ tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false)
+ subscribe(tenant_topic, topic)
+ payload = %{"value" => random_string()}
+
+ row =
+ message_fixture(tenant, %{
+ "topic" => topic,
+ "private" => true,
+ "event" => "UPDATE",
+ "extension" => "broadcast",
+ "payload" => payload
+ })
+
+ row_id = row.id
+
+ assert_receive {:socket_push, :text, data}, 2000
+ message = data |> IO.iodata_to_binary() |> Jason.decode!()
+
+ assert %{
+ "event" => "broadcast",
+ "payload" => %{
+ "event" => "UPDATE",
+ "meta" => %{"id" => ^row_id},
+ "payload" => received_payload,
+ "type" => "broadcast"
+ },
+ "ref" => nil,
+ "topic" => ^topic
+ } = message
+
+ assert received_payload == payload
+ end)
+
+ assert logs =~ "Unexpected relation on schema 'public' and table 'test'"
+ end
+
test "monitored pid stopping brings down ReplicationConnection ", %{tenant: tenant} do
monitored_pid =
spawn(fn ->
@@ -198,13 +418,72 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do
"payload" => %{"value" => "something"}
})
- refute_receive %Phoenix.Socket.Broadcast{}, 500
+ refute_receive _any, 500
end)
assert logs =~ "UnableToBroadcastChanges"
end
- test "payload without id", %{tenant: tenant} do
+ test "message that exceeds payload size is not broadcast and logs error", %{tenant: tenant} do
+ logs =
+ capture_log(fn ->
+ start_supervised!(
+ {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}},
+ restart: :transient
+ )
+
+ topic = random_string()
+ tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false)
+ assert :ok = Endpoint.subscribe(tenant_topic)
+
+ message_fixture(tenant, %{
+ "event" => random_string(),
+ "topic" => topic,
+ "private" => true,
+ "payload" => %{"data" => random_string(tenant.max_payload_size_in_kb * 1000 + 1)}
+ })
+
+ refute_receive _any, 500
+ end)
+
+ assert logs =~ "UnableToBroadcastChanges: :payload_size_exceeded"
+ end
+
+ test "message is not broadcast and logs error when rate limit is exceeded", %{tenant: tenant} do
+ events_per_second_rate = Tenants.events_per_second_rate(tenant)
+
+ # Start with a clean rate counter and push it well above the limit so the
+ # avg stays over the threshold for the full duration of the test.
+ RateCounterHelper.stop(tenant.external_id)
+ {:ok, _} = RateCounter.new(events_per_second_rate)
+ GenCounter.add(events_per_second_rate.id, tenant.max_events_per_second * 60 + 1)
+ {:ok, %{limit: %{triggered: true}}} = RateCounterHelper.tick!(events_per_second_rate)
+
+ logs =
+ capture_log(fn ->
+ start_supervised!(
+ {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}},
+ restart: :transient
+ )
+
+ topic = random_string()
+ tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false)
+ assert :ok = Endpoint.subscribe(tenant_topic)
+
+ message_fixture(tenant, %{
+ "event" => "INSERT",
+ "topic" => topic,
+ "private" => true,
+ "payload" => %{"value" => random_string()}
+ })
+
+ refute_receive _any, 500
+ end)
+
+ assert logs =~ "UnableToBroadcastChanges: :too_many_requests"
+ end
+
+ test "payload without id", %{tenant: tenant, db_conn: db_conn} do
start_link_supervised!(
{ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}},
restart: :transient
@@ -214,33 +493,141 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do
tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false)
subscribe(tenant_topic, topic)
- fixture =
- message_fixture(tenant, %{
- "topic" => topic,
- "private" => true,
- "event" => "INSERT",
- "payload" => %{"value" => "something"}
- })
+ value = "something"
+ event = "INSERT"
+
+ Postgrex.query!(
+ db_conn,
+ "SELECT realtime.send (json_build_object ('value', $1 :: text)::jsonb, $2 :: text, $3 :: text, TRUE::bool);",
+ [value, event, topic]
+ )
+
+ {:ok, [%{id: id}]} = Repo.all(db_conn, from(m in Message), Message)
assert_receive {:socket_push, :text, data}, 500
message = data |> IO.iodata_to_binary() |> Jason.decode!()
assert %{
"event" => "broadcast",
- "payload" => %{"event" => "INSERT", "payload" => payload, "type" => "broadcast"},
+ "payload" => %{
+ "event" => "INSERT",
+ "meta" => %{"id" => ^id},
+ "payload" => payload,
+ "type" => "broadcast"
+ },
"ref" => nil,
"topic" => ^topic
} = message
- id = fixture.id
-
assert payload == %{
"value" => "something",
"id" => id
}
end
- test "payload including id", %{tenant: tenant} do
+ test "binary payload is replicated as UserBroadcast with binary encoding", %{tenant: tenant, db_conn: db_conn} do
+ start_link_supervised!(
+ {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}},
+ restart: :transient
+ )
+
+ topic = random_string()
+ tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false)
+ assert :ok = Endpoint.subscribe(tenant_topic)
+
+ Realtime.Tenants.create_messages_partitions(db_conn)
+
+ binary = <<0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF>>
+ event = "INSERT"
+
+ Postgrex.query!(
+ db_conn,
+ "SELECT realtime.send_binary($1::bytea, $2::text, $3::text, TRUE::bool);",
+ [binary, event, topic]
+ )
+
+ assert_receive %RealtimeWeb.Socket.UserBroadcast{
+ user_event: ^event,
+ user_payload_encoding: :binary,
+ user_payload: ^binary,
+ metadata: %{"id" => _id}
+ },
+ 500
+ end
+
+ test "binary payload that exceeds payload size is not broadcast and logs error", %{tenant: tenant, db_conn: db_conn} do
+ logs =
+ capture_log(fn ->
+ start_supervised!(
+ {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}},
+ restart: :transient
+ )
+
+ topic = random_string()
+ tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false)
+ assert :ok = Endpoint.subscribe(tenant_topic)
+
+ Realtime.Tenants.create_messages_partitions(db_conn)
+
+ binary = :binary.copy(<<0>>, tenant.max_payload_size_in_kb * 1000 + 1000)
+ event = "INSERT"
+
+ Postgrex.query!(
+ db_conn,
+ "SELECT realtime.send_binary($1::bytea, $2::text, $3::text, TRUE::bool);",
+ [binary, event, topic]
+ )
+
+ refute_receive _any, 500
+ end)
+
+ assert logs =~ "UnableToBroadcastChanges: :payload_size_exceeded"
+ end
+
+ test "empty binary payload is replicated as UserBroadcast with binary encoding", %{tenant: tenant, db_conn: db_conn} do
+ start_link_supervised!(
+ {ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}},
+ restart: :transient
+ )
+
+ topic = random_string()
+ tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false)
+ assert :ok = Endpoint.subscribe(tenant_topic)
+
+ Realtime.Tenants.create_messages_partitions(db_conn)
+
+ event = "INSERT"
+
+ Postgrex.query!(
+ db_conn,
+ "SELECT realtime.send_binary($1::bytea, $2::text, $3::text, TRUE::bool);",
+ [<<>>, event, topic]
+ )
+
+ assert_receive %RealtimeWeb.Socket.UserBroadcast{
+ user_event: ^event,
+ user_payload_encoding: :binary,
+ user_payload: <<>>,
+ metadata: %{"id" => _id}
+ },
+ 500
+ end
+
+ test "rejects insert with both payload and binary_payload set", %{db_conn: db_conn} do
+ Realtime.Tenants.create_messages_partitions(db_conn)
+
+ assert {:error, %Postgrex.Error{postgres: %{code: :check_violation, constraint: "messages_payload_exclusive"}}} =
+ Postgrex.query(
+ db_conn,
+ """
+ INSERT INTO realtime.messages (payload, binary_payload, event, topic, private, extension)
+ VALUES ($1::jsonb, $2::bytea, 'evt', $3::text, false, 'broadcast')
+ """,
+ [%{"value" => "x"}, <<1, 2, 3>>, random_string()]
+ )
+ end
+
+ test "payload including id", %{tenant: tenant, db_conn: db_conn} do
start_link_supervised!(
{ReplicationConnection, %ReplicationConnection{tenant_id: tenant.external_id, monitored_pid: self()}},
restart: :transient
@@ -250,21 +637,29 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do
tenant_topic = Tenants.tenant_topic(tenant.external_id, topic, false)
subscribe(tenant_topic, topic)
- payload = %{"value" => "something", "id" => "123456"}
+ id = "123456"
+ value = "something"
+ event = "INSERT"
- message_fixture(tenant, %{
- "topic" => topic,
- "private" => true,
- "event" => "INSERT",
- "payload" => payload
- })
+ Postgrex.query!(
+ db_conn,
+ "SELECT realtime.send (json_build_object ('value', $1 :: text, 'id', $2 :: text)::jsonb, $3 :: text, $4 :: text, TRUE::bool);",
+ [value, id, event, topic]
+ )
+
+ {:ok, [%{id: message_id}]} = Repo.all(db_conn, from(m in Message), Message)
assert_receive {:socket_push, :text, data}, 500
message = data |> IO.iodata_to_binary() |> Jason.decode!()
assert %{
"event" => "broadcast",
- "payload" => %{"event" => "INSERT", "payload" => ^payload, "type" => "broadcast"},
+ "payload" => %{
+ "meta" => %{"id" => ^message_id},
+ "event" => "INSERT",
+ "payload" => %{"value" => "something", "id" => ^id},
+ "type" => "broadcast"
+ },
"ref" => nil,
"topic" => ^topic
} = message
@@ -272,27 +667,23 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do
test "fails on existing replication slot", %{tenant: tenant} do
{:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
- name = "supabase_realtime_messages_replication_slot_test"
+ name = @replication_slot_name
Postgrex.query!(db_conn, "SELECT pg_create_logical_replication_slot($1, 'test_decoding')", [name])
- assert {:error, {:shutdown, "Temporary Replication slot already exists and in use"}} =
+ assert {:error, {:shutdown, :replication_slot_in_use}} =
ReplicationConnection.start(tenant, self())
Postgrex.query!(db_conn, "SELECT pg_drop_replication_slot($1)", [name])
end
test "times out when init takes too long", %{tenant: tenant} do
- expect(ReplicationConnection, :init, 1, fn arg ->
- :timer.sleep(1000)
- call_original(ReplicationConnection, :init, [arg])
- end)
-
- {:error, :timeout} = ReplicationConnection.start(tenant, self(), 100)
+ assert {:error, :replication_connection_timeout} = ReplicationConnection.start(tenant, self(), 0)
end
test "handle standby connections exceeds max_wal_senders", %{tenant: tenant} do
- opts = Database.from_tenant(tenant, "realtime_test", :stop) |> Database.opts()
+ {:ok, settings} = Database.from_tenant(tenant, "realtime_test", :stop)
+ opts = Database.opts(settings)
parent = self()
# This creates a loop of errors that occupies all WAL senders and lets us test the error handling
@@ -301,7 +692,7 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do
replication_slot_opts =
%PostgresReplication{
connection_opts: opts,
- table: :all,
+ table: "test",
output_plugin: "pgoutput",
output_plugin_options: [proto_version: "1", publication_names: "test_#{i}_publication"],
handler_module: Replication.TestHandler,
@@ -331,11 +722,185 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do
assert {:error, :max_wal_senders_reached} = ReplicationConnection.start(tenant, self())
end
+
+ test "handles WAL pressure gracefully", %{tenant: tenant} do
+ {:ok, replication_pid} = ReplicationConnection.start(tenant, self())
+
+ {:ok, conn} = Database.connect(tenant, "realtime_test", :stop)
+ on_exit(fn -> Process.exit(conn, :normal) end)
+
+ large_payload = String.duplicate("x", 10 * 1024 * 1024)
+
+ for i <- 1..5 do
+ message_fixture_with_conn(tenant, conn, %{
+ "topic" => "stress_#{i}",
+ "private" => true,
+ "event" => "INSERT",
+ "payload" => %{"data" => large_payload}
+ })
+ end
+
+ assert Process.alive?(replication_pid)
+ end
+ end
+
+ describe "publication validation steps" do
+ test "if proper tables are included, starts replication", %{tenant: tenant, db_conn: db_conn} do
+ publication_name = "supabase_realtime_messages_publication"
+
+ Postgrex.query!(db_conn, "DROP PUBLICATION IF EXISTS #{publication_name}", [])
+ Postgrex.query!(db_conn, "CREATE PUBLICATION #{publication_name} FOR TABLE realtime.messages", [])
+
+ logs =
+ capture_log(fn ->
+ {:ok, pid} = ReplicationConnection.start(tenant, self())
+
+ assert_replication_started(db_conn, @replication_slot_name)
+ assert Process.alive?(pid)
+ assert_publication_contains_only_messages(db_conn, publication_name)
+
+ Process.exit(pid, :shutdown)
+ end)
+
+ refute logs =~ "Recreating"
+ end
+
+ test "disconnects when the publication cannot be created", %{tenant: tenant, db_conn: db_conn} do
+ publication_name = "supabase_realtime_messages_publication"
+
+ # No publication yet (forces the CREATE PUBLICATION path) and no table to publish,
+ # so `CREATE PUBLICATION ... FOR TABLE realtime.messages` fails with undefined_table.
+ Postgrex.query!(db_conn, "DROP PUBLICATION IF EXISTS #{publication_name}", [])
+ Postgrex.query!(db_conn, "DROP TABLE IF EXISTS realtime.messages CASCADE", [])
+
+ capture_log(fn ->
+ assert {:error, "Error creating publication:" <> _} = ReplicationConnection.start(tenant, self())
+ end)
+ end
+
+ test "disconnects when the publication cannot be recreated", %{tenant: tenant, db_conn: db_conn} do
+ publication_name = "supabase_realtime_messages_publication"
+
+ # Publication exists but with the wrong table, so validation triggers the
+ # `DROP ...; CREATE ...` recreate path. With realtime.messages gone, the CREATE half
+ # of that multi-statement fails, exercising the list-of-results error branch.
+ Postgrex.query!(db_conn, "DROP PUBLICATION IF EXISTS #{publication_name}", [])
+ Postgrex.query!(db_conn, "CREATE TABLE IF NOT EXISTS public.wrong_table (id int)", [])
+ Postgrex.query!(db_conn, "CREATE PUBLICATION #{publication_name} FOR TABLE public.wrong_table", [])
+ Postgrex.query!(db_conn, "DROP TABLE IF EXISTS realtime.messages CASCADE", [])
+
+ logs =
+ capture_log(fn ->
+ assert {:error, "Error creating publication:" <> _} = ReplicationConnection.start(tenant, self())
+ end)
+
+ assert logs =~ "Recreating"
+ end
+
+ test "if includes unexpected tables, recreates publication", %{tenant: tenant, db_conn: db_conn} do
+ publication_name = "supabase_realtime_messages_publication"
+
+ Postgrex.query!(db_conn, "DROP PUBLICATION IF EXISTS #{publication_name}", [])
+ Postgrex.query!(db_conn, "CREATE TABLE IF NOT EXISTS public.wrong_table (id int)", [])
+ Postgrex.query!(db_conn, "CREATE PUBLICATION #{publication_name} FOR TABLE public.wrong_table", [])
+
+ logs =
+ capture_log(fn ->
+ {:ok, pid} = ReplicationConnection.start(tenant, self())
+
+ assert_replication_started(db_conn, @replication_slot_name)
+ assert Process.alive?(pid)
+ assert_publication_contains_only_messages(db_conn, publication_name)
+
+ Process.exit(pid, :shutdown)
+ end)
+
+ assert logs =~ "Recreating"
+ end
+
+ test "recreates publication if it has no tables", %{tenant: tenant, db_conn: db_conn} do
+ publication_name = "supabase_realtime_messages_publication"
+
+ Postgrex.query!(db_conn, "DROP PUBLICATION IF EXISTS #{publication_name}", [])
+ Postgrex.query!(db_conn, "CREATE PUBLICATION #{publication_name}", [])
+
+ logs =
+ capture_log(fn ->
+ {:ok, pid} = ReplicationConnection.start(tenant, self())
+
+ assert_replication_started(db_conn, @replication_slot_name)
+ assert Process.alive?(pid)
+ assert_publication_contains_only_messages(db_conn, publication_name)
+
+ Process.exit(pid, :shutdown)
+ end)
+
+ assert logs =~ "Recreating"
+ end
+
+ test "recreates publication if it has expected tables and unexpected tables under same publication", %{
+ tenant: tenant,
+ db_conn: db_conn
+ } do
+ publication_name = "supabase_realtime_messages_publication"
+
+ Postgrex.query!(db_conn, "DROP PUBLICATION IF EXISTS #{publication_name}", [])
+ Postgrex.query!(db_conn, "CREATE TABLE IF NOT EXISTS public.extra_table (id int)", [])
+
+ Postgrex.query!(
+ db_conn,
+ "CREATE PUBLICATION #{publication_name} FOR TABLE realtime.messages, public.extra_table",
+ []
+ )
+
+ logs =
+ capture_log(fn ->
+ {:ok, pid} = ReplicationConnection.start(tenant, self())
+
+ assert_replication_started(db_conn, @replication_slot_name)
+ assert Process.alive?(pid)
+ assert_publication_contains_only_messages(db_conn, publication_name)
+
+ Process.exit(pid, :shutdown)
+ end)
+
+ assert logs =~ "Recreating"
+ end
+ end
+
+ describe "handle_result/2 for step :start_replication_slot" do
+ test "returns disconnect when error has postgres map with message" do
+ error = %Postgrex.Error{
+ postgres: %{
+ code: :undefined_table,
+ message: "relation \"realtime.messages\" does not exist"
+ }
+ }
+
+ state = %ReplicationConnection{step: :start_replication_slot}
+
+ assert {:disconnect, "Error starting replication: relation \"realtime.messages\" does not exist"} =
+ ReplicationConnection.handle_result(error, state)
+ end
+
+ test "returns disconnect when error has top-level message and no postgres map" do
+ error = %Postgrex.Error{message: "connection closed"}
+ state = %ReplicationConnection{step: :start_replication_slot}
+
+ assert {:disconnect, "Error starting replication: connection closed"} =
+ ReplicationConnection.handle_result(error, state)
+ end
+
+ test "returns disconnect when results list contains a Postgrex.Error" do
+ error = %Postgrex.Error{message: "something went wrong"}
+ state = %ReplicationConnection{step: :start_replication_slot}
+
+ assert {:disconnect, "Error starting replication: something went wrong"} =
+ ReplicationConnection.handle_result([error], state)
+ end
end
describe "whereis/1" do
- @tag skip:
- "We are using a GenServer wrapper so the pid returned is not the same as the ReplicationConnection for now"
test "returns pid if exists", %{tenant: tenant} do
{:ok, pid} = ReplicationConnection.start(tenant, self())
assert ReplicationConnection.whereis(tenant.external_id) == pid
@@ -349,6 +914,39 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do
def handle_telemetry(event, measures, metadata, pid: pid), do: send(pid, {event, measures, metadata})
+ describe "handle_data/2 for KeepAlive" do
+ test "always sends standby_status when reply is :later" do
+ wal_end = 1_000_000
+ # KeepAlive binary: ?k + wal_end(64) + clock(64) + reply(8), reply=0 means :later
+ keep_alive = <>
+ state = %ReplicationConnection{tenant_id: "test", step: :streaming}
+
+ assert {:noreply, message, ^state} = ReplicationConnection.handle_data(keep_alive, state)
+
+ assert [<>] = message
+ assert received == wal_end + 1
+ assert flushed == wal_end + 1
+ assert applied == wal_end + 1
+ # :later maps to reply byte 0
+ assert reply_byte == 0
+ end
+
+ test "sends standby_status when reply is :now" do
+ wal_end = 2_000_000
+ keep_alive = <>
+ state = %ReplicationConnection{tenant_id: "test", step: :streaming}
+
+ assert {:noreply, message, ^state} = ReplicationConnection.handle_data(keep_alive, state)
+
+ assert [<>] = message
+ assert received == wal_end + 1
+ assert flushed == wal_end + 1
+ assert applied == wal_end + 1
+ # :now maps to reply byte 1
+ assert reply_byte == 1
+ end
+ end
+
describe "telemetry events" do
setup do
:telemetry.detach(__MODULE__)
@@ -378,7 +976,7 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do
"payload" => %{"value" => random_string()}
})
- assert_receive {:socket_push, :text, data}
+ assert_receive {:socket_push, :text, data}, 500
message = data |> IO.iodata_to_binary() |> Jason.decode!()
assert %{"event" => "broadcast", "payload" => _, "ref" => nil, "topic" => ^topic} = message
@@ -407,6 +1005,62 @@ defmodule Realtime.Tenants.ReplicationConnectionTest do
defp assert_process_down(pid, timeout \\ 100) do
ref = Process.monitor(pid)
- assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, timeout
+ assert_receive {:DOWN, ^ref, :process, ^pid, reason}, timeout
+ reason
+ end
+
+ defp message_fixture_with_conn(_tenant, conn, override) do
+ create_attrs = %{
+ "topic" => random_string(),
+ "extension" => "broadcast"
+ }
+
+ override = override |> Enum.map(fn {k, v} -> {"#{k}", v} end) |> Map.new()
+
+ {:ok, message} =
+ create_attrs
+ |> Map.merge(override)
+ |> TenantConnection.create_message(conn)
+
+ message
+ end
+
+ defp assert_publication_contains_only_messages(db_conn, publication_name) do
+ %{rows: rows} =
+ Postgrex.query!(
+ db_conn,
+ "SELECT schemaname, tablename FROM pg_publication_tables WHERE pubname = $1",
+ [publication_name]
+ )
+
+ valid_tables =
+ Enum.all?(rows, fn [schema, table] ->
+ schema == "realtime" and (table == "messages" or String.starts_with?(table, "messages_"))
+ end)
+
+ assert valid_tables, "Expected only realtime.messages or its partitions, got: #{inspect(rows)}"
+ end
+
+ defp assert_replication_started(db_conn, slot_name, retries \\ 10, interval_ms \\ 10) do
+ case check_replication_status(db_conn, slot_name, retries, interval_ms) do
+ :ok -> :ok
+ :error -> flunk("Replication slot #{slot_name} did not become active")
+ end
+ end
+
+ defp check_replication_status(_db_conn, _slot_name, 0, _interval_ms), do: :error
+
+ defp check_replication_status(db_conn, slot_name, retries_remaining, interval_ms) do
+ %{rows: rows} =
+ Postgrex.query!(db_conn, "SELECT active FROM pg_replication_slots WHERE slot_name = $1", [slot_name])
+
+ case rows do
+ [[true]] ->
+ :ok
+
+ _ ->
+ Process.sleep(interval_ms)
+ check_replication_status(db_conn, slot_name, retries_remaining - 1, interval_ms)
+ end
end
end
diff --git a/test/realtime/repo_test.exs b/test/realtime/tenants/repo_test.exs
similarity index 99%
rename from test/realtime/repo_test.exs
rename to test/realtime/tenants/repo_test.exs
index 7d6841b01..697274494 100644
--- a/test/realtime/repo_test.exs
+++ b/test/realtime/tenants/repo_test.exs
@@ -1,10 +1,10 @@
-defmodule Realtime.RepoTest do
+defmodule Realtime.Tenants.RepoTest do
use Realtime.DataCase, async: true
import Ecto.Query
alias Realtime.Api.Message
- alias Realtime.Repo
+ alias Realtime.Tenants.Repo
alias Realtime.Database
setup do
diff --git a/test/realtime/tenants/schema_test.exs b/test/realtime/tenants/schema_test.exs
new file mode 100644
index 000000000..e224d4842
--- /dev/null
+++ b/test/realtime/tenants/schema_test.exs
@@ -0,0 +1,344 @@
+defmodule Realtime.Tenants.SchemaTest do
+ @moduledoc false
+
+ use Realtime.DataCase, async: false
+ alias Realtime.Database
+
+ setup do
+ tenant = Containers.checkout_tenant(run_migrations: true)
+ {:ok, settings} = Database.from_tenant(tenant, "realtime_test", :stop)
+
+ opts = settings |> Map.from_struct() |> Keyword.new()
+
+ # simulate postgres dashboard role
+ {:ok, conn} = opts |> Keyword.put(:username, "postgres") |> Postgrex.start_link()
+ {:ok, superuser_conn} = opts |> Keyword.put(:username, "supabase_admin") |> Postgrex.start_link()
+ {:ok, realtime_conn} = opts |> Keyword.put(:username, "supabase_realtime_admin") |> Postgrex.start_link()
+
+ %{conn: conn, superuser_conn: superuser_conn, realtime_conn: realtime_conn, settings: settings}
+ end
+
+ describe "restrictions" do
+ @describetag :requires_supautils_policy_grants
+
+ test "deny create trigger on realtime.messages", %{conn: conn} do
+ Postgrex.query!(
+ conn,
+ "CREATE OR REPLACE FUNCTION public.dummy_function() RETURNS trigger AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql",
+ []
+ )
+
+ assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} =
+ Postgrex.query(
+ conn,
+ "CREATE TRIGGER messages_trigger BEFORE INSERT ON realtime.messages FOR EACH ROW EXECUTE FUNCTION public.dummy_function()",
+ []
+ )
+ end
+
+ test "deny create trigger on realtime.schema_migrations", %{conn: conn} do
+ Postgrex.query!(
+ conn,
+ "CREATE OR REPLACE FUNCTION public.dummy_function() RETURNS trigger AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql",
+ []
+ )
+
+ assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} =
+ Postgrex.query(
+ conn,
+ "CREATE TRIGGER schema_migrations_trigger BEFORE INSERT ON realtime.schema_migrations FOR EACH ROW EXECUTE FUNCTION public.dummy_function()",
+ []
+ )
+ end
+
+ test "deny create trigger on realtime.subscription", %{conn: conn} do
+ Postgrex.query!(
+ conn,
+ """
+ CREATE OR REPLACE FUNCTION public.test_function() RETURNS trigger
+ LANGUAGE plpgsql SECURITY INVOKER AS $$
+ BEGIN
+ RETURN NEW;
+ END $$
+ """,
+ []
+ )
+
+ assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} =
+ Postgrex.query(
+ conn,
+ "CREATE TRIGGER test_trigger AFTER INSERT OR UPDATE OR DELETE ON realtime.subscription FOR EACH ROW EXECUTE FUNCTION public.test_function()",
+ []
+ )
+ end
+
+ test "supabase_realtime_admin cannot grant super to postgres", %{realtime_conn: realtime_conn} do
+ assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} =
+ Postgrex.query(realtime_conn, "ALTER ROLE postgres WITH SUPERUSER", [])
+ end
+
+ test "deny alter function owner to postgres", %{conn: conn} do
+ assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} =
+ Postgrex.query(
+ conn,
+ "ALTER FUNCTION realtime.send(jsonb, text, text, boolean) OWNER TO postgres",
+ []
+ )
+ end
+
+ test "deny create on realtime schema", %{conn: conn} do
+ assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} =
+ Postgrex.query(conn, "CREATE TABLE realtime.new_table (id int)", [])
+ end
+
+ test "postgres is not a member of supabase_realtime_admin", %{conn: conn} do
+ assert %Postgrex.Result{rows: [[false]]} =
+ Postgrex.query!(conn, "SELECT pg_has_role('postgres', 'supabase_realtime_admin', 'MEMBER')", [])
+ end
+
+ test "postgres cannot modify realtime.schema_migrations", %{conn: conn} do
+ assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} =
+ Postgrex.query(
+ conn,
+ "INSERT INTO realtime.schema_migrations (version, inserted_at) VALUES (0, now())",
+ []
+ )
+
+ assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} =
+ Postgrex.query(conn, "DELETE FROM realtime.schema_migrations", [])
+ end
+
+ test "postgres cannot create policy on realtime.schema_migrations", %{conn: conn} do
+ assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} =
+ Postgrex.query(
+ conn,
+ "CREATE POLICY sm_policy ON realtime.schema_migrations FOR SELECT TO authenticated USING (true)",
+ []
+ )
+ end
+ end
+
+ describe "privileges" do
+ test "postgres can grant USAGE on schema realtime to a custom role", %{conn: conn} do
+ Postgrex.query!(conn, "CREATE ROLE role_test", [])
+
+ assert {:ok, _} = Postgrex.query(conn, "GRANT USAGE ON SCHEMA realtime TO role_test", [])
+
+ assert %Postgrex.Result{rows: [[true]]} =
+ Postgrex.query!(conn, "SELECT has_schema_privilege('role_test', 'realtime', 'USAGE')", [])
+
+ Postgrex.query!(conn, "REVOKE USAGE ON SCHEMA realtime FROM role_test", [])
+ Postgrex.query!(conn, "DROP ROLE role_test", [])
+ end
+
+ test "supabase_realtime_admin can create a role", %{realtime_conn: realtime_conn} do
+ role = "role_realtime_admin_create_#{System.unique_integer([:positive])}"
+
+ assert {:ok, _} = Postgrex.query(realtime_conn, "CREATE ROLE #{role}", [])
+
+ assert %Postgrex.Result{rows: [[true]]} =
+ Postgrex.query!(realtime_conn, "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)", [role])
+ end
+
+ test "supabase_realtime_admin has NOINHERIT", %{realtime_conn: realtime_conn} do
+ assert %Postgrex.Result{rows: [[false]]} =
+ Postgrex.query!(
+ realtime_conn,
+ "SELECT rolinherit FROM pg_roles WHERE rolname = 'supabase_realtime_admin'",
+ []
+ )
+ end
+
+ test "supabase_realtime_admin can SET ROLE to granted roles", %{realtime_conn: realtime_conn} do
+ for role <- ~w(anon authenticated service_role) do
+ assert {:ok, _} = Postgrex.query(realtime_conn, "SET ROLE #{role}", [])
+ Postgrex.query!(realtime_conn, "RESET ROLE", [])
+ end
+ end
+
+ test "supabase_realtime_admin can drop a role", %{realtime_conn: realtime_conn} do
+ role = "role_realtime_admin_drop_#{System.unique_integer([:positive])}"
+ Postgrex.query!(realtime_conn, "CREATE ROLE #{role}", [])
+
+ assert {:ok, _} = Postgrex.query(realtime_conn, "DROP ROLE #{role}", [])
+
+ assert %Postgrex.Result{rows: [[false]]} =
+ Postgrex.query!(realtime_conn, "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)", [role])
+ end
+
+ test "insert into realtime.messages", %{conn: conn} do
+ assert {:ok, %Postgrex.Result{num_rows: 1}} =
+ Postgrex.query(
+ conn,
+ "INSERT INTO realtime.messages (payload, event, topic, private, extension) VALUES ($1, $2, $3, $4, $5)",
+ [%{"hello" => "world"}, "test_event", "test_topic", false, "broadcast"]
+ )
+ end
+ end
+
+ describe "ownership" do
+ test "all objects in the realtime schema are owned by supabase_realtime_admin", %{superuser_conn: conn} do
+ query = """
+ SELECT format('table %I.%I', n.nspname, c.relname), r.rolname FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ JOIN pg_roles r ON r.oid = c.relowner
+ WHERE n.nspname = 'realtime' AND c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f')
+ AND c.relname <> 'schema_migrations'
+ AND r.rolname <> 'supabase_realtime_admin'
+ UNION ALL
+ SELECT format('function %I.%I', n.nspname, p.proname), r.rolname FROM pg_proc p
+ JOIN pg_namespace n ON n.oid = p.pronamespace
+ JOIN pg_roles r ON r.oid = p.proowner
+ WHERE n.nspname = 'realtime' AND r.rolname <> 'supabase_realtime_admin'
+ UNION ALL
+ SELECT format('type %I.%I', n.nspname, t.typname), r.rolname FROM pg_type t
+ JOIN pg_namespace n ON n.oid = t.typnamespace
+ JOIN pg_roles r ON r.oid = t.typowner
+ WHERE n.nspname = 'realtime' AND t.typtype IN ('b', 'd', 'e', 'r', 'm')
+ AND t.typname <> '_schema_migrations'
+ AND r.rolname <> 'supabase_realtime_admin'
+ """
+
+ %Postgrex.Result{rows: offenders} = Postgrex.query!(conn, query, [])
+
+ assert offenders == [],
+ "realtime objects not owned by supabase_realtime_admin (add `ALTER ... OWNER TO supabase_realtime_admin` to the migration):\n" <>
+ Enum.map_join(offenders, "\n", fn [object, owner] -> " - #{object} (owned by #{owner})" end)
+ end
+
+ test "realtime schema is owned by supabase_admin", %{superuser_conn: conn} do
+ assert %Postgrex.Result{rows: [["supabase_admin"]]} =
+ Postgrex.query!(
+ conn,
+ "SELECT r.rolname FROM pg_namespace n JOIN pg_roles r ON r.oid = n.nspowner WHERE n.nspname = 'realtime'",
+ []
+ )
+ end
+ end
+
+ describe "realtime.messages policy grants" do
+ test "create and drop SELECT policy", %{conn: conn} do
+ assert {:ok, _} =
+ Postgrex.query(
+ conn,
+ "CREATE POLICY messages_policy_select_test ON realtime.messages FOR SELECT TO authenticated USING (true)",
+ []
+ )
+
+ assert {:ok, _} = Postgrex.query(conn, "DROP POLICY messages_policy_select_test ON realtime.messages", [])
+ end
+
+ test "create and drop INSERT policy", %{conn: conn} do
+ assert {:ok, _} =
+ Postgrex.query(
+ conn,
+ "CREATE POLICY messages_policy_insert_test ON realtime.messages FOR INSERT TO authenticated WITH CHECK (true)",
+ []
+ )
+
+ assert {:ok, _} = Postgrex.query(conn, "DROP POLICY messages_policy_insert_test ON realtime.messages", [])
+ end
+
+ test "create and drop FOR ALL policy", %{conn: conn} do
+ assert {:ok, _} =
+ Postgrex.query(
+ conn,
+ "CREATE POLICY messages_policy ON realtime.messages FOR ALL TO authenticated USING (true) WITH CHECK (true)",
+ []
+ )
+
+ assert {:ok, _} = Postgrex.query(conn, "DROP POLICY messages_policy ON realtime.messages", [])
+ end
+
+ test "alter existing policy", %{conn: conn} do
+ Postgrex.query!(
+ conn,
+ "CREATE POLICY messages_policy_alter_test ON realtime.messages FOR SELECT TO authenticated USING (true)",
+ []
+ )
+
+ assert {:ok, _} =
+ Postgrex.query(
+ conn,
+ "ALTER POLICY messages_policy_alter_test ON realtime.messages USING (auth.role() = 'authenticated')",
+ []
+ )
+
+ Postgrex.query!(conn, "DROP POLICY messages_policy_alter_test ON realtime.messages", [])
+ end
+ end
+
+ describe "realtime.subscription policy grants" do
+ test "create and drop SELECT policy", %{conn: conn} do
+ assert {:ok, _} =
+ Postgrex.query(
+ conn,
+ "CREATE POLICY subscription_policy_select ON realtime.subscription FOR SELECT TO authenticated USING (true)",
+ []
+ )
+
+ assert {:ok, _} = Postgrex.query(conn, "DROP POLICY subscription_policy_select ON realtime.subscription", [])
+ end
+
+ test "create and drop INSERT policy", %{conn: conn} do
+ assert {:ok, _} =
+ Postgrex.query(
+ conn,
+ "CREATE POLICY subscription_policy_insert ON realtime.subscription FOR INSERT TO authenticated WITH CHECK (true)",
+ []
+ )
+
+ assert {:ok, _} = Postgrex.query(conn, "DROP POLICY subscription_policy_insert ON realtime.subscription", [])
+ end
+
+ test "create and drop UPDATE policy", %{conn: conn} do
+ assert {:ok, _} =
+ Postgrex.query(
+ conn,
+ "CREATE POLICY subscription_policy_update ON realtime.subscription FOR UPDATE TO authenticated USING (true)",
+ []
+ )
+
+ assert {:ok, _} = Postgrex.query(conn, "DROP POLICY subscription_policy_update ON realtime.subscription", [])
+ end
+
+ test "create and drop DELETE policy", %{conn: conn} do
+ assert {:ok, _} =
+ Postgrex.query(
+ conn,
+ "CREATE POLICY subscription_policy_delete ON realtime.subscription FOR DELETE TO authenticated USING (true)",
+ []
+ )
+
+ assert {:ok, _} = Postgrex.query(conn, "DROP POLICY subscription_policy_delete ON realtime.subscription", [])
+ end
+
+ test "create and drop FOR ALL policy", %{conn: conn} do
+ assert {:ok, _} =
+ Postgrex.query(
+ conn,
+ "CREATE POLICY subscription_policy_all ON realtime.subscription FOR ALL TO authenticated USING (true) WITH CHECK (true)",
+ []
+ )
+
+ assert {:ok, _} = Postgrex.query(conn, "DROP POLICY subscription_policy_all ON realtime.subscription", [])
+ end
+
+ test "alter existing policy", %{conn: conn} do
+ Postgrex.query!(
+ conn,
+ "CREATE POLICY subscription_policy_alter_test ON realtime.subscription FOR SELECT TO authenticated USING (true)",
+ []
+ )
+
+ assert {:ok, _} =
+ Postgrex.query(
+ conn,
+ "ALTER POLICY subscription_policy_alter_test ON realtime.subscription USING (auth.role() = 'authenticated')",
+ []
+ )
+
+ Postgrex.query!(conn, "DROP POLICY subscription_policy_alter_test ON realtime.subscription", [])
+ end
+ end
+end
diff --git a/test/realtime/tenants/single_broadcast_test.exs b/test/realtime/tenants/single_broadcast_test.exs
new file mode 100644
index 000000000..4fbf83c4c
--- /dev/null
+++ b/test/realtime/tenants/single_broadcast_test.exs
@@ -0,0 +1,438 @@
+defmodule Realtime.Tenants.SingleBroadcastTest do
+ use RealtimeWeb.ConnCase, async: true
+ use Mimic
+
+ alias Realtime.Database
+ alias Realtime.GenCounter
+ alias Realtime.RateCounter
+ alias Realtime.Tenants
+ alias Realtime.Tenants.SingleBroadcast
+ alias Realtime.Tenants.Authorization
+ alias Realtime.Tenants.Authorization.Policies
+ alias Realtime.Tenants.Authorization.Policies.BroadcastPolicies
+ alias Realtime.Tenants.Connect
+
+ alias RealtimeWeb.TenantBroadcaster
+ alias RealtimeWeb.Socket.UserBroadcast
+
+ setup do
+ tenant = Containers.checkout_tenant(run_migrations: true)
+ Realtime.Tenants.Cache.update_cache(tenant)
+ {:ok, tenant: tenant}
+ end
+
+ describe "JSON public message broadcasting" do
+ test "broadcasts JSON public message successfully", %{tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ topic = random_string()
+ tenant_topic = Tenants.tenant_topic(tenant.external_id, topic)
+ event = "test-event"
+ payload = %{"text" => "hello", "user" => "alice"}
+
+ expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end)
+
+ expect(TenantBroadcaster, :pubsub_broadcast, fn _, _, broadcast, _, _ ->
+ assert %UserBroadcast{
+ topic: ^tenant_topic,
+ user_event: ^event,
+ user_payload: json,
+ user_payload_encoding: :json,
+ metadata: nil
+ } = broadcast
+
+ assert IO.iodata_to_binary(json) == Jason.encode!(payload)
+
+ :ok
+ end)
+
+ assert :ok = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, event, false, payload, :json)
+ end
+
+ test "public messages do not have private prefix in topic", %{tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ topic = random_string()
+
+ expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end)
+
+ expect(TenantBroadcaster, :pubsub_broadcast, fn _, tenant_topic, _, _, _ ->
+ refute String.contains?(tenant_topic, "-private")
+ :ok
+ end)
+
+ assert :ok =
+ SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, %{"data" => "test"}, :json)
+ end
+
+ test "JSON payload can be empty map", %{tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ topic = random_string()
+
+ expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end)
+ expect(TenantBroadcaster, :pubsub_broadcast, fn _, _, _, _, _ -> :ok end)
+
+ assert :ok = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, %{}, :json)
+ end
+ end
+
+ describe "Binary public message broadcasting" do
+ test "broadcasts binary message successfully", %{tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ topic = random_string()
+ tenant_topic = Tenants.tenant_topic(tenant.external_id, topic)
+ event = "binary-event"
+ binary = <<1, 2, 3, 4, 5>>
+
+ expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end)
+
+ expect(TenantBroadcaster, :pubsub_broadcast, fn _, _, broadcast, _, _ ->
+ assert %UserBroadcast{
+ topic: ^tenant_topic,
+ user_event: ^event,
+ user_payload: ^binary,
+ user_payload_encoding: :binary,
+ metadata: nil
+ } = broadcast
+
+ :ok
+ end)
+
+ assert :ok = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, event, false, binary, :binary)
+ end
+
+ test "binary payload can be empty", %{tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ topic = random_string()
+
+ expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end)
+ expect(TenantBroadcaster, :pubsub_broadcast, fn _, _, _, _, _ -> :ok end)
+
+ assert :ok = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, <<>>, :binary)
+ end
+
+ test "handles large binary payloads within limit", %{tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ topic = random_string()
+ # Create binary well under the limit to account for erlang term overhead
+ # The max is in KB (1000 bytes per KB), plus 500 byte padding
+ binary = :crypto.strong_rand_bytes(tenant.max_payload_size_in_kb * 1000 - 100)
+
+ expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end)
+ expect(TenantBroadcaster, :pubsub_broadcast, fn _, _, _, _, _ -> :ok end)
+
+ assert :ok = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, binary, :binary)
+ end
+ end
+
+ describe "JSON private message authorization" do
+ test "broadcasts private JSON message with valid authorization", %{tenant: tenant} do
+ topic = random_string()
+ sub = random_string()
+ role = "authenticated"
+ payload = %{"secret" => "data"}
+
+ auth_params =
+ Authorization.build_authorization_params(%{
+ tenant_id: tenant.external_id,
+ headers: [{"header-1", "value-1"}],
+ claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000},
+ role: role,
+ sub: sub
+ })
+
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+
+ expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end)
+
+ expect(Authorization, :get_write_authorizations, fn _, _ ->
+ {:ok, %Policies{broadcast: %BroadcastPolicies{write: true}}}
+ end)
+
+ expect(TenantBroadcaster, :pubsub_broadcast, fn _, tenant_topic, _, _, _ ->
+ assert String.contains?(tenant_topic, "-private")
+ :ok
+ end)
+
+ assert :ok = SingleBroadcast.broadcast(auth_params, tenant, topic, "event", true, payload, :json)
+ end
+
+ test "skips private JSON message without authorization", %{tenant: tenant} do
+ topic = random_string()
+ sub = random_string()
+ role = "anon"
+
+ auth_params =
+ Authorization.build_authorization_params(%{
+ tenant_id: tenant.external_id,
+ headers: [{"header-1", "value-1"}],
+ claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000},
+ role: role,
+ sub: sub
+ })
+
+ expect(Authorization, :get_write_authorizations, fn _, _ ->
+ {:ok, %Policies{broadcast: %BroadcastPolicies{write: false}}}
+ end)
+
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ assert {:error, :forbidden, "Unauthorized"} =
+ SingleBroadcast.broadcast(auth_params, tenant, topic, "event", true, %{"data" => "test"}, :json)
+
+ assert calls(&TenantBroadcaster.pubsub_broadcast/5) == []
+ end
+ end
+
+ describe "Binary private message authorization" do
+ test "broadcasts private binary message with valid authorization", %{tenant: tenant} do
+ topic = random_string()
+ sub = random_string()
+ role = "authenticated"
+ binary = <<255, 254, 253>>
+
+ auth_params =
+ Authorization.build_authorization_params(%{
+ tenant_id: tenant.external_id,
+ headers: [{"header-1", "value-1"}],
+ claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000},
+ role: role,
+ sub: sub
+ })
+
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+
+ expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end)
+
+ expect(Authorization, :get_write_authorizations, fn _, _ ->
+ {:ok, %Policies{broadcast: %BroadcastPolicies{write: true}}}
+ end)
+
+ expect(TenantBroadcaster, :pubsub_broadcast, fn _, tenant_topic, broadcast, _, _ ->
+ assert String.contains?(tenant_topic, "-private")
+
+ assert %UserBroadcast{
+ user_payload: ^binary,
+ user_payload_encoding: :binary
+ } = broadcast
+
+ :ok
+ end)
+
+ assert :ok = SingleBroadcast.broadcast(auth_params, tenant, topic, "event", true, binary, :binary)
+ end
+
+ test "skips private binary message without authorization", %{tenant: tenant} do
+ topic = random_string()
+ sub = random_string()
+ role = "anon"
+
+ auth_params =
+ Authorization.build_authorization_params(%{
+ tenant_id: tenant.external_id,
+ headers: [{"header-1", "value-1"}],
+ claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000},
+ role: role,
+ sub: sub
+ })
+
+ expect(Authorization, :get_write_authorizations, fn _, _ ->
+ {:ok, %Policies{broadcast: %BroadcastPolicies{write: false}}}
+ end)
+
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ assert {:error, :forbidden, "Unauthorized"} =
+ SingleBroadcast.broadcast(auth_params, tenant, topic, "event", true, <<1, 2, 3>>, :binary)
+
+ assert calls(&TenantBroadcaster.pubsub_broadcast/5) == []
+ end
+ end
+
+ describe "message validation" do
+ test "returns changeset error when topic is empty", %{tenant: tenant} do
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ result = SingleBroadcast.broadcast(%Authorization{}, tenant, "", "event", false, %{"data" => "test"}, :json)
+ assert {:error, %Ecto.Changeset{valid?: false}} = result
+ end
+
+ test "returns changeset error when event is empty", %{tenant: tenant} do
+ topic = random_string()
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ result = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "", false, %{"data" => "test"}, :json)
+ assert {:error, %Ecto.Changeset{valid?: false}} = result
+ end
+
+ test "returns changeset error when JSON payload is nil", %{tenant: tenant} do
+ topic = random_string()
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ result = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, nil, :json)
+ assert {:error, %Ecto.Changeset{valid?: false}} = result
+ end
+
+ test "returns changeset error when binary payload is nil", %{tenant: tenant} do
+ topic = random_string()
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ result = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, nil, :binary)
+ assert {:error, %Ecto.Changeset{valid?: false}} = result
+ end
+ end
+
+ describe "suspended tenant" do
+ test "does not broadcast when tenant is suspended", %{tenant: tenant} do
+ tenant = %{tenant | suspend: true}
+ topic = random_string()
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ result = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, %{"data" => "test"}, :json)
+ assert {:error, :forbidden, "Tenant is suspended"} = result
+ assert calls(&TenantBroadcaster.pubsub_broadcast/5) == []
+ end
+ end
+
+ describe "rate limiting" do
+ test "rejects broadcast when rate limit is exceeded", %{tenant: tenant} do
+ events_per_second_rate = Tenants.events_per_second_rate(tenant)
+ topic = random_string()
+
+ RateCounter
+ |> stub(:new, fn _ -> {:ok, nil} end)
+ |> stub(:get, fn ^events_per_second_rate -> {:ok, %RateCounter{avg: tenant.max_events_per_second + 1}} end)
+
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ result = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, %{"data" => "test"}, :json)
+ assert {:error, :too_many_requests, "You have exceeded your rate limit"} = result
+ end
+
+ test "allows broadcast at rate limit boundary", %{tenant: tenant} do
+ events_per_second_rate = Tenants.events_per_second_rate(tenant)
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ current_rate = tenant.max_events_per_second - 1
+
+ RateCounter
+ |> stub(:new, fn _ -> {:ok, nil} end)
+ |> stub(:get, fn ^events_per_second_rate ->
+ {:ok, %RateCounter{avg: current_rate}}
+ end)
+
+ expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end)
+ expect(TenantBroadcaster, :pubsub_broadcast, fn _, _, _, _, _ -> :ok end)
+
+ assert :ok =
+ SingleBroadcast.broadcast(
+ %Authorization{},
+ tenant,
+ random_string(),
+ "event",
+ false,
+ %{"data" => "test"},
+ :json
+ )
+ end
+
+ test "rejects JSON payload when size exceeds tenant limit", %{tenant: tenant} do
+ topic = random_string()
+ large_payload = %{"data" => random_string(tenant.max_payload_size_in_kb * 1000 + 1)}
+
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ result = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, large_payload, :json)
+
+ assert {:error, %Ecto.Changeset{valid?: false, errors: errors}} = result
+ assert {:payload, {"Payload size exceeds tenant limit", []}} in errors
+ end
+
+ test "rejects binary payload when size exceeds tenant limit", %{tenant: tenant} do
+ topic = random_string()
+ large_binary = :crypto.strong_rand_bytes(tenant.max_payload_size_in_kb * 1024 + 1)
+
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ result = SingleBroadcast.broadcast(%Authorization{}, tenant, topic, "event", false, large_binary, :binary)
+
+ assert {:error, %Ecto.Changeset{valid?: false, errors: errors}} = result
+ assert {:payload, {"Payload size exceeds tenant limit", []}} in errors
+ end
+ end
+
+ describe "error handling" do
+ test "database connection errors for private messages returns error", %{tenant: tenant} do
+ topic = random_string()
+ sub = random_string()
+ role = "authenticated"
+
+ auth_params =
+ Authorization.build_authorization_params(%{
+ tenant_id: tenant.external_id,
+ headers: [{"header-1", "value-1"}],
+ claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000},
+ role: role,
+ sub: sub
+ })
+
+ events_per_second_rate = Tenants.events_per_second_rate(tenant)
+
+ RateCounter
+ |> stub(:new, fn _ -> {:ok, nil} end)
+ |> stub(:get, fn ^events_per_second_rate -> {:ok, %RateCounter{avg: 0}} end)
+
+ expect(Connect, :lookup_or_start_connection, fn _ -> {:error, :tenant_database_unavailable} end)
+
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ assert {:error, :unprocessable_entity, "Tenant database unavailable"} =
+ SingleBroadcast.broadcast(auth_params, tenant, topic, "event", true, %{"data" => "test"}, :json)
+
+ assert calls(&TenantBroadcaster.pubsub_broadcast/5) == []
+ end
+ end
+
+ describe "integration with RLS policies" do
+ setup %{tenant: tenant} do
+ {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
+ %{db_conn: db_conn}
+ end
+
+ test "broadcasts private JSON message when RLS policy allows", %{tenant: tenant, db_conn: db_conn} do
+ topic = random_string()
+ sub = random_string()
+ role = "authenticated"
+
+ create_rls_policies(db_conn, [:authenticated_write_broadcast], %{topic: topic})
+
+ auth_params =
+ Authorization.build_authorization_params(%{
+ tenant_id: tenant.external_id,
+ headers: [{"header-1", "value-1"}],
+ claims: %{"sub" => sub, "role" => role, "exp" => Joken.current_time() + 1_000},
+ role: role,
+ sub: sub
+ })
+
+ events_per_second_rate = Tenants.events_per_second_rate(tenant)
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+
+ RateCounter
+ |> stub(:new, fn _ -> {:ok, nil} end)
+ |> stub(:get, fn
+ ^events_per_second_rate -> {:ok, %RateCounter{avg: 0}}
+ _ -> {:ok, %RateCounter{avg: 0}}
+ end)
+
+ expect(GenCounter, :add, fn ^broadcast_events_key -> :ok end)
+ expect(Connect, :lookup_or_start_connection, fn _ -> {:ok, db_conn} end)
+
+ expect(Authorization, :get_write_authorizations, fn _, _ ->
+ {:ok, %Policies{broadcast: %BroadcastPolicies{write: true}}}
+ end)
+
+ expect(TenantBroadcaster, :pubsub_broadcast, fn _, _, _, _, _ -> :ok end)
+
+ assert :ok =
+ SingleBroadcast.broadcast(auth_params, tenant, topic, "event", true, %{"secret" => "data"}, :json)
+ end
+ end
+end
diff --git a/test/realtime/tenants_test.exs b/test/realtime/tenants_test.exs
index aefe0b86c..708654827 100644
--- a/test/realtime/tenants_test.exs
+++ b/test/realtime/tenants_test.exs
@@ -1,8 +1,8 @@
defmodule Realtime.TenantsTest do
# async: false due to cache usage
- alias Realtime.Tenants.Migrations
use Realtime.DataCase, async: false
+ alias Realtime.Database
alias Realtime.GenCounter
alias Realtime.Tenants
doctest Realtime.Tenants
@@ -32,93 +32,6 @@ defmodule Realtime.TenantsTest do
end
end
- describe "suspend_tenant_by_external_id/1" do
- setup do
- tenant = tenant_fixture()
- :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant.external_id)
- %{tenant: tenant}
- end
-
- test "sets suspend flag to true and publishes message", %{tenant: %{external_id: external_id}} do
- {:ok, tenant} = Tenants.suspend_tenant_by_external_id(external_id)
- assert tenant.suspend == true
- assert_receive :suspend_tenant, 500
- end
-
- test "does not publish message if if not targetted tenant", %{tenant: tenant} do
- Tenants.suspend_tenant_by_external_id(tenant_fixture().external_id)
- tenant = Repo.reload!(tenant)
- assert tenant.suspend == false
- refute_receive :suspend_tenant, 500
- end
- end
-
- describe "unsuspend_tenant_by_external_id/1" do
- setup do
- tenant = tenant_fixture(%{suspend: true})
- :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant.external_id)
- %{tenant: tenant}
- end
-
- test "sets suspend flag to false and publishes message", %{tenant: tenant} do
- {:ok, tenant} = Tenants.unsuspend_tenant_by_external_id(tenant.external_id)
- assert tenant.suspend == false
- assert_receive :unsuspend_tenant, 500
- end
-
- test "does not publish message if not targetted tenant", %{tenant: tenant} do
- Tenants.unsuspend_tenant_by_external_id(tenant_fixture().external_id)
- tenant = Repo.reload!(tenant)
- assert tenant.suspend == true
- refute_receive :unsuspend_tenant, 500
- end
- end
-
- describe "run_migrations?/1" do
- test "returns true if migrations_ran is lower than existing migrations" do
- tenant = tenant_fixture(%{migrations_ran: 0})
- assert Tenants.run_migrations?(tenant)
-
- tenant = tenant_fixture(%{migrations_ran: Enum.count(Migrations.migrations()) - 1})
- assert Tenants.run_migrations?(tenant)
- end
-
- test "returns false if migrations_ran is count of all migrations" do
- tenant = tenant_fixture(%{migrations_ran: Enum.count(Migrations.migrations())})
- refute Tenants.run_migrations?(tenant)
- end
- end
-
- describe "update_migrations_ran/1" do
- test "updates migrations_ran to the count of all migrations" do
- tenant = tenant_fixture(%{migrations_ran: 0})
- Tenants.update_migrations_ran(tenant.external_id, 1)
- tenant = Repo.reload!(tenant)
- assert tenant.migrations_ran == 1
- end
- end
-
- describe "broadcast_operation_event/2" do
- setup do
- tenant = tenant_fixture()
- :ok = Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant.external_id)
- %{tenant: tenant}
- end
-
- test "broadcasts events to the targetted tenant", %{tenant: tenant} do
- events = [
- :suspend_tenant,
- :unsuspend_tenant,
- :disconnect
- ]
-
- for event <- events do
- Tenants.broadcast_operation_event(event, tenant.external_id)
- assert_receive ^event
- end
- end
- end
-
describe "region/1" do
test "returns the region of the tenant" do
attrs = %{
@@ -130,7 +43,7 @@ defmodule Realtime.TenantsTest do
"settings" => %{
"db_host" => "127.0.0.1",
"db_name" => "postgres",
- "db_user" => "supabase_admin",
+ "db_user" => "supabase_realtime_admin",
"db_password" => "postgres",
"db_port" => "#{port()}",
"poll_interval" => 100,
@@ -165,4 +78,21 @@ defmodule Realtime.TenantsTest do
assert Tenants.region(tenant) == nil
end
end
+
+ describe "create_messages_partitions/1" do
+ test "running twice keeps the same partitions" do
+ tenant = Containers.checkout_tenant(run_migrations: true)
+ {:ok, conn} = Database.connect(tenant, "realtime_test", :stop)
+
+ assert :ok = Tenants.create_messages_partitions(conn)
+ assert :ok = Tenants.create_messages_partitions(conn)
+
+ assert {:ok, %{rows: [[5]]}} =
+ Postgrex.query(
+ conn,
+ "SELECT count(*) FROM pg_inherits WHERE inhparent = 'realtime.messages'::regclass",
+ []
+ )
+ end
+ end
end
diff --git a/test/realtime/user_counter_test.exs b/test/realtime/user_counter_test.exs
deleted file mode 100644
index d93529764..000000000
--- a/test/realtime/user_counter_test.exs
+++ /dev/null
@@ -1,74 +0,0 @@
-defmodule Realtime.UsersCounterTest do
- use Realtime.DataCase, async: false
- alias Realtime.UsersCounter
- alias Realtime.Rpc
-
- describe "add/1" do
- test "starts counter for tenant" do
- assert UsersCounter.add(self(), random_string()) == :ok
- end
- end
-
- @aux_mod (quote do
- defmodule Aux do
- def ping(),
- do:
- spawn(fn ->
- Process.sleep(3000)
- :pong
- end)
- end
- end)
-
- Code.eval_quoted(@aux_mod)
-
- describe "tenant_users/1" do
- test "returns count of connected clients for tenant on cluster node" do
- tenant_id = random_string()
- expected = generate_load(tenant_id)
- Process.sleep(1000)
- assert UsersCounter.tenant_users(tenant_id) == expected
- end
- end
-
- describe "tenant_users/2" do
- test "returns count of connected clients for tenant on target cluster" do
- tenant_id = random_string()
- generate_load(tenant_id)
- {:ok, node} = Clustered.start(@aux_mod)
- pid = Rpc.call(node, Aux, :ping, [])
- UsersCounter.add(pid, tenant_id)
- assert UsersCounter.tenant_users(node, tenant_id) == 1
- end
- end
-
- defp generate_load(tenant_id, nodes \\ 2, processes \\ 2) do
- for i <- 1..nodes do
- # Avoid port collision
- extra_config = [
- {:gen_rpc, :tcp_server_port, 15970 + i}
- ]
-
- {:ok, node} = Clustered.start(@aux_mod, extra_config: extra_config, phoenix_port: 4012 + i)
-
- for _ <- 1..processes do
- pid = Rpc.call(node, Aux, :ping, [])
-
- for _ <- 1..10 do
- # replicate same pid added multiple times concurrently
- Task.start(fn ->
- UsersCounter.add(pid, tenant_id)
- end)
-
- # noisy neighbors to test handling of bigger loads on concurrent calls
- Task.start(fn ->
- pid = Rpc.call(node, Aux, :ping, [])
- UsersCounter.add(pid, random_string())
- end)
- end
- end
- end
-
- nodes * processes
- end
-end
diff --git a/test/realtime/users_counter_test.exs b/test/realtime/users_counter_test.exs
new file mode 100644
index 000000000..8972b6de0
--- /dev/null
+++ b/test/realtime/users_counter_test.exs
@@ -0,0 +1,155 @@
+defmodule Realtime.UsersCounterTest do
+ use Realtime.DataCase, async: false
+ alias Realtime.UsersCounter
+ alias Realtime.Rpc
+
+ setup_all do
+ tenant_id = random_string()
+ count = generate_load(tenant_id)
+
+ %{tenant_id: tenant_id, count: count, nodes: Node.list()}
+ end
+
+ describe "already_counted?/2" do
+ test "returns true if pid already counted for tenant", %{tenant_id: tenant_id} do
+ pid = self()
+ assert UsersCounter.add(pid, tenant_id) == :ok
+ assert UsersCounter.already_counted?(pid, tenant_id) == true
+ end
+
+ test "returns false if pid not counted for tenant" do
+ assert UsersCounter.already_counted?(self(), random_string()) == false
+ end
+ end
+
+ describe "add/1" do
+ test "starts counter for tenant" do
+ assert UsersCounter.add(self(), random_string()) == :ok
+ end
+ end
+
+ describe "local_tenants/0" do
+ test "returns list of tenant ids with local connections" do
+ tenant_id = random_string()
+ assert UsersCounter.add(self(), tenant_id) == :ok
+
+ tenants = UsersCounter.local_tenants()
+ assert is_list(tenants)
+ assert tenant_id in tenants
+ end
+ end
+
+ @aux_mod (quote do
+ defmodule Aux do
+ def ping() do
+ spawn(fn -> Process.sleep(:infinity) end)
+ end
+
+ def join(pid, group) do
+ UsersCounter.add(pid, group)
+ end
+ end
+ end)
+
+ Code.eval_quoted(@aux_mod)
+
+ describe "tenant_counts/0" do
+ test "map of tenant and number of users", %{tenant_id: tenant_id, count: expected} do
+ assert UsersCounter.add(self(), tenant_id) == :ok
+ Process.sleep(1000)
+ counts = UsersCounter.tenant_counts()
+
+ assert counts[tenant_id] == expected + 1
+ assert map_size(counts) >= 61
+
+ counts = Forum.Census.local_member_counts(:users)
+
+ assert counts[tenant_id] == 1
+ assert map_size(counts) >= 1
+
+ counts = Forum.Census.member_counts(:users)
+
+ assert counts[tenant_id] == expected + 1
+ assert map_size(counts) >= 61
+ end
+ end
+
+ describe "local_tenant_counts/0" do
+ test "map of tenant and number of users for local node only", %{tenant_id: tenant_id} do
+ assert UsersCounter.add(self(), tenant_id) == :ok
+
+ my_counts = UsersCounter.local_tenant_counts()
+ # Only one connection from this test process on this node
+ assert my_counts == %{tenant_id => 1}
+ end
+ end
+
+ describe "tenant_users/1" do
+ test "returns count of connected clients for tenant on cluster node", %{tenant_id: tenant_id, count: expected} do
+ Process.sleep(1000)
+ assert UsersCounter.tenant_users(tenant_id) == expected
+ end
+ end
+
+ defp generate_load(tenant_id) do
+ processes = 2
+
+ gen_rpc_port = Application.fetch_env!(:gen_rpc, :tcp_server_port)
+
+ nodes = %{
+ node() => gen_rpc_port,
+ :"us_node@127.0.0.1" => 16980,
+ :"ap2_nodeX@127.0.0.1" => 16981,
+ :"ap2_nodeY@127.0.0.1" => 16982
+ }
+
+ regions = %{
+ :"us_node@127.0.0.1" => "us-east-1",
+ :"ap2_nodeX@127.0.0.1" => "ap-southeast-2",
+ :"ap2_nodeY@127.0.0.1" => "ap-southeast-2"
+ }
+
+ on_exit(fn -> Application.put_env(:gen_rpc, :client_config_per_node, {:internal, %{}}) end)
+ Application.put_env(:gen_rpc, :client_config_per_node, {:internal, nodes})
+
+ nodes
+ |> Enum.filter(fn {node, _port} -> node != Node.self() end)
+ |> Enum.with_index(1)
+ |> Enum.each(fn {{node, gen_rpc_port}, i} ->
+ # Avoid port collision
+ extra_config = [
+ {:gen_rpc, :tcp_server_port, gen_rpc_port},
+ {:gen_rpc, :client_config_per_node, {:internal, nodes}},
+ {:realtime, :users_scope_broadcast_interval_in_ms, 100},
+ {:realtime, :region, regions[node]}
+ ]
+
+ node_name =
+ node
+ |> to_string()
+ |> String.split("@")
+ |> hd()
+ |> String.to_atom()
+
+ {:ok, node} = Clustered.start(@aux_mod, name: node_name, extra_config: extra_config, phoenix_port: 4012 + i)
+
+ for _ <- 1..processes do
+ pid = Rpc.call(node, Aux, :ping, [])
+
+ for _ <- 1..10 do
+ # replicate same pid added multiple times concurrently
+ Task.start(fn ->
+ Rpc.call(node, Aux, :join, [pid, tenant_id])
+ end)
+
+ # noisy neighbors to test handling of bigger loads on concurrent calls
+ Task.start(fn ->
+ Rpc.call(node, Aux, :join, [pid, random_string()])
+ end)
+ end
+ end
+ end)
+
+ 3 * processes
+ end
+end
diff --git a/test/realtime_web/channels/auth/jwt_verification_test.exs b/test/realtime_web/channels/auth/jwt_verification_test.exs
index b6255ee1f..47e90f8e4 100644
--- a/test/realtime_web/channels/auth/jwt_verification_test.exs
+++ b/test/realtime_web/channels/auth/jwt_verification_test.exs
@@ -148,6 +148,99 @@ defmodule RealtimeWeb.JwtVerificationTest do
assert {:ok, _claims} = JwtVerification.verify(token, @jwt_secret, nil)
end
+ test "rejects token with expired exp encoded as a string" do
+ signer = Joken.Signer.create(@alg, @jwt_secret)
+
+ Mock.freeze()
+ current_time = Mock.current_time()
+ claim_val = to_string(current_time - 60)
+
+ token =
+ Joken.generate_and_sign!(
+ %{"exp" => %Joken.Claim{generate: fn -> claim_val end}},
+ %{},
+ signer
+ )
+
+ assert {:error, [message: ^current_time, claim: "exp", claim_val: ^claim_val]} =
+ JwtVerification.verify(token, @jwt_secret, nil)
+ end
+
+ test "rejects token with future exp encoded as a string" do
+ signer = Joken.Signer.create(@alg, @jwt_secret)
+
+ Mock.freeze()
+ current_time = Mock.current_time()
+ claim_val = to_string(current_time + 1000)
+
+ token =
+ Joken.generate_and_sign!(
+ %{"exp" => %Joken.Claim{generate: fn -> claim_val end}},
+ %{},
+ signer
+ )
+
+ assert {:error, [message: ^current_time, claim: "exp", claim_val: ^claim_val]} =
+ JwtVerification.verify(token, @jwt_secret, nil)
+ end
+
+ test "rejects token with iat encoded as a string" do
+ signer = Joken.Signer.create(@alg, @jwt_secret)
+
+ Mock.freeze()
+ current_time = Mock.current_time()
+ claim_val = to_string(current_time)
+
+ token =
+ Joken.generate_and_sign!(
+ %{
+ "exp" => %Joken.Claim{generate: fn -> current_time + 1000 end},
+ "iat" => %Joken.Claim{generate: fn -> claim_val end}
+ },
+ %{},
+ signer
+ )
+
+ assert {:error, [message: "Invalid token", claim: "iat", claim_val: ^claim_val]} =
+ JwtVerification.verify(token, @jwt_secret, nil)
+ end
+
+ test "accepts token with numeric exp and iat" do
+ signer = Joken.Signer.create(@alg, @jwt_secret)
+
+ Mock.freeze()
+ current_time = Mock.current_time()
+
+ token =
+ Joken.generate_and_sign!(
+ %{
+ "exp" => %Joken.Claim{generate: fn -> current_time + 1000 end},
+ "iat" => %Joken.Claim{generate: fn -> current_time end}
+ },
+ %{},
+ signer
+ )
+
+ assert {:ok, _claims} = JwtVerification.verify(token, @jwt_secret, nil)
+ end
+
+ test "accepts token with exp but without iat" do
+ signer = Joken.Signer.create(@alg, @jwt_secret)
+
+ Mock.freeze()
+ current_time = Mock.current_time()
+
+ token =
+ Joken.generate_and_sign!(
+ %{"exp" => %Joken.Claim{generate: fn -> current_time + 1000 end}},
+ %{},
+ signer
+ )
+
+ assert {:ok, claims} = JwtVerification.verify(token, @jwt_secret, nil)
+ refute Map.has_key?(claims, "iat")
+ end
+
test "when token claims match expected claims from :jwt_claim_validators config" do
Application.put_env(:realtime, :jwt_claim_validators, %{
"iss" => "Tester",
@@ -376,5 +469,62 @@ defmodule RealtimeWeb.JwtVerificationTest do
assert {:error, :error_generating_signer} = JwtVerification.verify(token, jwt_secret, jwks)
end
+
+ test "using Ed25519 JWK" do
+ # Generate Ed25519 key pair
+ {pub, priv} = :crypto.generate_key(:eddsa, :ed25519)
+
+ jwk = %{
+ "kty" => "OKP",
+ "crv" => "Ed25519",
+ "x" => Base.url_encode64(pub, padding: false),
+ "d" => Base.url_encode64(priv, padding: false),
+ "kid" => "ed-key-1"
+ }
+
+ jwks = %{"keys" => [jwk]}
+
+ signer = Joken.Signer.create("Ed25519", jwk, %{"kid" => "ed-key-1"})
+
+ Mock.freeze()
+ current_time = Mock.current_time()
+
+ token =
+ Joken.generate_and_sign!(
+ %{"exp" => %Joken.Claim{generate: fn -> current_time + 100 end}},
+ %{},
+ signer
+ )
+
+ assert {:ok, _claims} = JwtVerification.verify(token, @jwt_secret, jwks)
+ end
+
+ test "returns error for unsupported algorithm with kid and jwks" do
+ header = Base.url_encode64(Jason.encode!(%{"alg" => "PS256", "kid" => "key-1"}), padding: false)
+ claims = Base.url_encode64(Jason.encode!(%{"exp" => 9_999_999_999}), padding: false)
+ token = "#{header}.#{claims}.signature"
+
+ jwks = %{"keys" => [%{"kty" => "RSA", "kid" => "key-1"}]}
+
+ assert {:error, _} = JwtVerification.verify(token, @jwt_secret, jwks)
+ end
+
+ test "falls back to jwt_secret when HS256 kid has no matching JWK" do
+ Mock.freeze()
+ current_time = Mock.current_time()
+
+ signer = Joken.Signer.create("HS256", @jwt_secret)
+
+ token =
+ Joken.generate_and_sign!(
+ %{"exp" => %Joken.Claim{generate: fn -> current_time + 100 end}},
+ %{},
+ signer
+ )
+
+ jwks = %{"keys" => [%{"kty" => "oct", "kid" => "wrong-kid"}]}
+
+ assert {:ok, _claims} = JwtVerification.verify(token, @jwt_secret, jwks)
+ end
end
end
diff --git a/test/realtime_web/channels/payloads/flexible_boolean_test.exs b/test/realtime_web/channels/payloads/flexible_boolean_test.exs
new file mode 100644
index 000000000..cb0704ab4
--- /dev/null
+++ b/test/realtime_web/channels/payloads/flexible_boolean_test.exs
@@ -0,0 +1,72 @@
+defmodule RealtimeWeb.Channels.Payloads.FlexibleBooleanTest do
+ use ExUnit.Case, async: true
+
+ alias RealtimeWeb.Channels.Payloads.FlexibleBoolean
+
+ describe "type/0" do
+ test "returns :boolean" do
+ assert FlexibleBoolean.type() == :boolean
+ end
+ end
+
+ describe "cast/1" do
+ test "casts boolean true as-is" do
+ assert FlexibleBoolean.cast(true) == {:ok, true}
+ end
+
+ test "casts boolean false as-is" do
+ assert FlexibleBoolean.cast(false) == {:ok, false}
+ end
+
+ test "casts string 'true' in any case to boolean true" do
+ assert FlexibleBoolean.cast("true") == {:ok, true}
+ assert FlexibleBoolean.cast("True") == {:ok, true}
+ assert FlexibleBoolean.cast("TRUE") == {:ok, true}
+ assert FlexibleBoolean.cast("tRuE") == {:ok, true}
+ end
+
+ test "casts string 'false' in any case to boolean false" do
+ assert FlexibleBoolean.cast("false") == {:ok, false}
+ assert FlexibleBoolean.cast("False") == {:ok, false}
+ assert FlexibleBoolean.cast("FALSE") == {:ok, false}
+ assert FlexibleBoolean.cast("fAlSe") == {:ok, false}
+ end
+
+ test "returns error for invalid string values" do
+ assert FlexibleBoolean.cast("test") == :error
+ assert FlexibleBoolean.cast("yes") == :error
+ assert FlexibleBoolean.cast("no") == :error
+ assert FlexibleBoolean.cast("1") == :error
+ assert FlexibleBoolean.cast("0") == :error
+ assert FlexibleBoolean.cast("") == :error
+ end
+
+ test "returns error for non-boolean, non-string values" do
+ assert FlexibleBoolean.cast(1) == :error
+ assert FlexibleBoolean.cast(0) == :error
+ assert FlexibleBoolean.cast(nil) == :error
+ assert FlexibleBoolean.cast(%{}) == :error
+ assert FlexibleBoolean.cast([]) == :error
+ end
+ end
+
+ describe "load/1" do
+ test "loads boolean values" do
+ assert FlexibleBoolean.load(true) == {:ok, true}
+ assert FlexibleBoolean.load(false) == {:ok, false}
+ end
+ end
+
+ describe "dump/1" do
+ test "dumps boolean values" do
+ assert FlexibleBoolean.dump(true) == {:ok, true}
+ assert FlexibleBoolean.dump(false) == {:ok, false}
+ end
+
+ test "returns error for non-boolean values" do
+ assert FlexibleBoolean.dump("true") == :error
+ assert FlexibleBoolean.dump(1) == :error
+ assert FlexibleBoolean.dump(nil) == :error
+ end
+ end
+end
diff --git a/test/realtime_web/channels/payloads/join_test.exs b/test/realtime_web/channels/payloads/join_test.exs
index 32bf1b397..6c025b9bd 100644
--- a/test/realtime_web/channels/payloads/join_test.exs
+++ b/test/realtime_web/channels/payloads/join_test.exs
@@ -6,6 +6,7 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do
alias RealtimeWeb.Channels.Payloads.Join
alias RealtimeWeb.Channels.Payloads.Config
alias RealtimeWeb.Channels.Payloads.Broadcast
+ alias RealtimeWeb.Channels.Payloads.Broadcast.Replay
alias RealtimeWeb.Channels.Payloads.Presence
alias RealtimeWeb.Channels.Payloads.PostgresChange
@@ -17,7 +18,7 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do
config = %{
"config" => %{
"private" => false,
- "broadcast" => %{"ack" => false, "self" => false},
+ "broadcast" => %{"ack" => false, "self" => false, "replay" => %{"since" => 1, "limit" => 10}},
"presence" => %{"enabled" => true, "key" => key},
"postgres_changes" => [
%{"event" => "INSERT", "schema" => "public", "table" => "users", "filter" => "id=eq.1"},
@@ -37,8 +38,9 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do
postgres_changes: postgres_changes
} = config
- assert %Broadcast{ack: false, self: false} = broadcast
+ assert %Broadcast{ack: false, self: false, replay: replay} = broadcast
assert %Presence{enabled: true, key: ^key} = presence
+ assert %Replay{since: 1, limit: 10} = replay
assert [
%PostgresChange{event: "INSERT", schema: "public", table: "users", filter: "id=eq.1"},
@@ -56,6 +58,25 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do
assert is_binary(key)
end
+ test "presence key can be number" do
+ config = %{"config" => %{"presence" => %{"enabled" => true, "key" => 123}}}
+
+ assert {:ok, %Join{config: %Config{presence: %Presence{key: key}}}} = Join.validate(config)
+
+ assert key == 123
+ end
+
+ test "invalid replay" do
+ config = %{"config" => %{"broadcast" => %{"replay" => 123}}}
+
+ assert {
+ :error,
+ :invalid_join_payload,
+ %{config: %{broadcast: %{replay: ["unable to parse, expected a map"]}}}
+ } =
+ Join.validate(config)
+ end
+
test "missing enabled presence defaults to true" do
config = %{"config" => %{"presence" => %{}}}
@@ -92,5 +113,202 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do
user_token: ["unable to parse, expected string"]
}
end
+
+ test "handles postgres changes with nil value in array as empty array" do
+ config = %{"config" => %{"postgres_changes" => [nil]}}
+
+ assert {:ok, %Join{config: %Config{postgres_changes: []}}} = Join.validate(config)
+ end
+
+ test "handles postgres changes as nil as empty array" do
+ config = %{"config" => %{"postgres_changes" => nil}}
+
+ assert {:ok, %Join{config: %Config{postgres_changes: []}}} = Join.validate(config)
+ end
+
+ test "accepts string 'true' for boolean fields" do
+ config = %{
+ "config" => %{
+ "private" => "true",
+ "broadcast" => %{"ack" => "true", "self" => "true"},
+ "presence" => %{"enabled" => "true"}
+ }
+ }
+
+ assert {:ok, %Join{config: config_result}} = Join.validate(config)
+
+ assert %Config{
+ private: true,
+ broadcast: %Broadcast{ack: true, self: true},
+ presence: %Presence{enabled: true}
+ } = config_result
+ end
+
+ test "accepts string 'True' for boolean fields" do
+ config = %{
+ "config" => %{
+ "private" => "True",
+ "broadcast" => %{"ack" => "True", "self" => "True"},
+ "presence" => %{"enabled" => "True"}
+ }
+ }
+
+ assert {:ok, %Join{config: config_result}} = Join.validate(config)
+
+ assert %Config{
+ private: true,
+ broadcast: %Broadcast{ack: true, self: true},
+ presence: %Presence{enabled: true}
+ } = config_result
+ end
+
+ test "accepts string 'false' for boolean fields" do
+ config = %{
+ "config" => %{
+ "private" => "false",
+ "broadcast" => %{"ack" => "false", "self" => "false"},
+ "presence" => %{"enabled" => "false"}
+ }
+ }
+
+ assert {:ok, %Join{config: config_result}} = Join.validate(config)
+
+ assert %Config{
+ private: false,
+ broadcast: %Broadcast{ack: false, self: false},
+ presence: %Presence{enabled: false}
+ } = config_result
+ end
+
+ test "accepts string 'False' for boolean fields" do
+ config = %{
+ "config" => %{
+ "private" => "False",
+ "broadcast" => %{"ack" => "False", "self" => "False"},
+ "presence" => %{"enabled" => "False"}
+ }
+ }
+
+ assert {:ok, %Join{config: config_result}} = Join.validate(config)
+
+ assert %Config{
+ private: false,
+ broadcast: %Broadcast{ack: false, self: false},
+ presence: %Presence{enabled: false}
+ } = config_result
+ end
+
+ test "rejects invalid boolean strings" do
+ config = %{
+ "config" => %{
+ "private" => "yes",
+ "broadcast" => %{"ack" => "a", "self" => "b"},
+ "presence" => %{"enabled" => "no"}
+ }
+ }
+
+ assert {:error, :invalid_join_payload, errors} = Join.validate(config)
+
+ assert errors == %{
+ config: %{
+ private: ["unable to parse, expected boolean"],
+ broadcast: %{
+ ack: ["unable to parse, expected boolean"],
+ self: ["unable to parse, expected boolean"]
+ },
+ presence: %{enabled: ["unable to parse, expected boolean"]}
+ }
+ }
+ end
+ end
+
+ describe "presence_enabled?/1" do
+ test "returns enabled value from config" do
+ join = %Join{config: %Config{presence: %Presence{enabled: false}}}
+ refute Join.presence_enabled?(join)
+
+ join = %Join{config: %Config{presence: %Presence{enabled: true}}}
+ assert Join.presence_enabled?(join)
+ end
+
+ test "defaults to true when config is nil" do
+ assert Join.presence_enabled?(%Join{config: nil})
+ end
+
+ test "defaults to true for non-Join struct" do
+ assert Join.presence_enabled?(nil)
+ end
+ end
+
+ describe "presence_key/1" do
+ test "returns UUID when key is empty string" do
+ join = %Join{config: %Config{presence: %Presence{key: ""}}}
+ key = Join.presence_key(join)
+ assert is_binary(key)
+ assert key != ""
+ end
+
+ test "returns the configured key" do
+ join = %Join{config: %Config{presence: %Presence{key: "my_key"}}}
+ assert Join.presence_key(join) == "my_key"
+ end
+
+ test "returns UUID for non-matching struct" do
+ key = Join.presence_key(%Join{config: nil})
+ assert is_binary(key)
+ assert key != ""
+ end
+ end
+
+ describe "ack_broadcast?/1" do
+ test "returns ack value from config" do
+ join = %Join{config: %Config{broadcast: %Broadcast{ack: true}}}
+ assert Join.ack_broadcast?(join)
+
+ join = %Join{config: %Config{broadcast: %Broadcast{ack: false}}}
+ refute Join.ack_broadcast?(join)
+ end
+
+ test "defaults to false when config is nil" do
+ refute Join.ack_broadcast?(%Join{config: nil})
+ end
+ end
+
+ describe "self_broadcast?/1" do
+ test "returns self value from config" do
+ join = %Join{config: %Config{broadcast: %Broadcast{self: true}}}
+ assert Join.self_broadcast?(join)
+
+ join = %Join{config: %Config{broadcast: %Broadcast{self: false}}}
+ refute Join.self_broadcast?(join)
+ end
+
+ test "defaults to false when config is nil" do
+ refute Join.self_broadcast?(%Join{config: nil})
+ end
+ end
+
+ describe "private?/1" do
+ test "returns private value from config" do
+ join = %Join{config: %Config{private: true}}
+ assert Join.private?(join)
+
+ join = %Join{config: %Config{private: false}}
+ refute Join.private?(join)
+ end
+
+ test "defaults to false when config is nil" do
+ refute Join.private?(%Join{config: nil})
+ end
+ end
+
+ describe "error_message/2" do
+ test "returns message with type when type is present" do
+ assert Join.error_message(:field, type: :string) == "unable to parse, expected string"
+ end
+
+ test "returns generic message when type is not present" do
+ assert Join.error_message(:field, []) == "unable to parse"
+ end
end
end
diff --git a/test/realtime_web/channels/realtime_channel/broadcast_handler_test.exs b/test/realtime_web/channels/realtime_channel/broadcast_handler_test.exs
index 2cd7005df..3b6065d9d 100644
--- a/test/realtime_web/channels/realtime_channel/broadcast_handler_test.exs
+++ b/test/realtime_web/channels/realtime_channel/broadcast_handler_test.exs
@@ -1,5 +1,8 @@
defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do
- use Realtime.DataCase, async: true
+ use Realtime.DataCase,
+ async: true,
+ parameterize: [%{serializer: Phoenix.Socket.V1.JSONSerializer}, %{serializer: RealtimeWeb.Socket.V2Serializer}]
+
use Mimic
import Generators
@@ -17,26 +20,27 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do
setup [:initiate_tenant]
+ @payload %{"a" => "b"}
+
describe "handle/3" do
- test "with write true policy, user is able to send message", %{topic: topic, tenant: tenant, db_conn: db_conn} do
+ test "with write true policy, user is able to send message",
+ %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do
socket = socket_fixture(tenant, topic, policies: %Policies{broadcast: %BroadcastPolicies{write: true}})
for _ <- 1..100, reduce: socket do
socket ->
- {:reply, :ok, socket} = BroadcastHandler.handle(%{"a" => "b"}, db_conn, socket)
+ {:reply, :ok, socket} = BroadcastHandler.handle(@payload, db_conn, socket)
socket
end
- Process.sleep(120)
-
for _ <- 1..100 do
topic = "realtime:#{topic}"
assert_receive {:socket_push, :text, data}
- message = data |> IO.iodata_to_binary() |> Jason.decode!()
- assert message == %{"event" => "broadcast", "payload" => %{"a" => "b"}, "ref" => nil, "topic" => topic}
+
+ assert Jason.decode!(data) == message(serializer, topic, @payload)
end
- {:ok, %{avg: avg, bucket: buckets}} = RateCounter.get(Tenants.events_per_second_rate(tenant))
+ {:ok, %{avg: avg, bucket: buckets}} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant))
assert Enum.sum(buckets) == 100
assert avg > 0
end
@@ -50,40 +54,37 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do
socket
end
- Process.sleep(120)
-
refute_received _any
- {:ok, %{avg: avg}} = RateCounter.get(Tenants.events_per_second_rate(tenant))
+ {:ok, %{avg: avg}} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant))
assert avg == 0.0
end
@tag policies: [:authenticated_read_broadcast, :authenticated_write_broadcast]
- test "with nil policy but valid user, is able to send message", %{topic: topic, tenant: tenant, db_conn: db_conn} do
+ test "with nil policy but valid user, is able to send message",
+ %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do
socket = socket_fixture(tenant, topic)
for _ <- 1..100, reduce: socket do
socket ->
- {:reply, :ok, socket} = BroadcastHandler.handle(%{"a" => "b"}, db_conn, socket)
+ {:reply, :ok, socket} = BroadcastHandler.handle(@payload, db_conn, socket)
socket
end
- Process.sleep(120)
-
for _ <- 1..100 do
topic = "realtime:#{topic}"
assert_received {:socket_push, :text, data}
- message = data |> IO.iodata_to_binary() |> Jason.decode!()
- assert message == %{"event" => "broadcast", "payload" => %{"a" => "b"}, "ref" => nil, "topic" => topic}
+ assert Jason.decode!(data) == message(serializer, topic, @payload)
end
- {:ok, %{avg: avg, bucket: buckets}} = RateCounter.get(Tenants.events_per_second_rate(tenant))
+ {:ok, %{avg: avg, bucket: buckets}} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant))
assert Enum.sum(buckets) == 100
assert avg > 0.0
end
@tag policies: [:authenticated_read_matching_user_sub, :authenticated_write_matching_user_sub], sub: UUID.generate()
- test "with valid sub, is able to send message", %{topic: topic, tenant: tenant, db_conn: db_conn, sub: sub} do
+ test "with valid sub, is able to send message",
+ %{topic: topic, tenant: tenant, db_conn: db_conn, sub: sub, serializer: serializer} do
socket =
socket_fixture(tenant, topic,
policies: %Policies{broadcast: %BroadcastPolicies{write: nil, read: true}},
@@ -92,17 +93,14 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do
for _ <- 1..100, reduce: socket do
socket ->
- {:reply, :ok, socket} = BroadcastHandler.handle(%{"a" => "b"}, db_conn, socket)
+ {:reply, :ok, socket} = BroadcastHandler.handle(@payload, db_conn, socket)
socket
end
- Process.sleep(120)
-
for _ <- 1..100 do
topic = "realtime:#{topic}"
assert_received {:socket_push, :text, data}
- message = data |> IO.iodata_to_binary() |> Jason.decode!()
- assert message == %{"event" => "broadcast", "payload" => %{"a" => "b"}, "ref" => nil, "topic" => topic}
+ assert Jason.decode!(data) == message(serializer, topic, @payload)
end
end
@@ -120,13 +118,12 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do
socket
end
- Process.sleep(120)
-
- refute_received {:socket_push, :text, _}
+ refute_receive {:socket_push, :text, _}, 120
end
@tag policies: [:read_matching_user_role, :write_matching_user_role], role: "anon"
- test "with valid role, is able to send message", %{topic: topic, tenant: tenant, db_conn: db_conn} do
+ test "with valid role, is able to send message",
+ %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do
socket =
socket_fixture(tenant, topic,
policies: %Policies{broadcast: %BroadcastPolicies{write: nil, read: true}},
@@ -135,17 +132,14 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do
for _ <- 1..100, reduce: socket do
socket ->
- {:reply, :ok, socket} = BroadcastHandler.handle(%{"a" => "b"}, db_conn, socket)
+ {:reply, :ok, socket} = BroadcastHandler.handle(@payload, db_conn, socket)
socket
end
- Process.sleep(120)
-
for _ <- 1..100 do
topic = "realtime:#{topic}"
assert_received {:socket_push, :text, data}
- message = data |> IO.iodata_to_binary() |> Jason.decode!()
- assert message == %{"event" => "broadcast", "payload" => %{"a" => "b"}, "ref" => nil, "topic" => topic}
+ assert Jason.decode!(data) == message(serializer, topic, @payload)
end
end
@@ -163,9 +157,7 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do
socket
end
- Process.sleep(120)
-
- refute_received {:socket_push, :text, _}
+ refute_receive {:socket_push, :text, _}, 120
end
test "with nil policy and invalid user, won't send message", %{topic: topic, tenant: tenant, db_conn: db_conn} do
@@ -177,16 +169,15 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do
socket
end
- Process.sleep(120)
-
refute_received _any
- {:ok, %{avg: avg}} = RateCounter.get(Tenants.events_per_second_rate(tenant))
+ {:ok, %{avg: avg}} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant))
assert avg == 0.0
end
@tag policies: [:authenticated_read_broadcast, :authenticated_write_broadcast]
- test "validation only runs once on nil and valid policies", %{topic: topic, tenant: tenant, db_conn: db_conn} do
+ test "validation only runs once on nil and valid policies",
+ %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do
socket = socket_fixture(tenant, topic)
expect(Authorization, :get_write_authorizations, 1, fn conn, db_conn, auth_context ->
@@ -197,15 +188,14 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do
for _ <- 1..100, reduce: socket do
socket ->
- {:reply, :ok, socket} = BroadcastHandler.handle(%{"a" => "b"}, db_conn, socket)
+ {:reply, :ok, socket} = BroadcastHandler.handle(@payload, db_conn, socket)
socket
end
for _ <- 1..100 do
topic = "realtime:#{topic}"
assert_receive {:socket_push, :text, data}
- message = data |> IO.iodata_to_binary() |> Jason.decode!()
- assert message == %{"event" => "broadcast", "payload" => %{"a" => "b"}, "ref" => nil, "topic" => topic}
+ assert Jason.decode!(data) == message(serializer, topic, @payload)
end
end
@@ -222,12 +212,10 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do
socket
end
- Process.sleep(100)
-
- refute_received _
+ refute_receive _, 100
end
- test "no ack still sends message", %{topic: topic, tenant: tenant, db_conn: db_conn} do
+ test "no ack still sends message", %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do
socket =
socket_fixture(tenant, topic,
policies: %Policies{broadcast: %BroadcastPolicies{write: true}},
@@ -236,7 +224,7 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do
for _ <- 1..100, reduce: socket do
socket ->
- {:noreply, socket} = BroadcastHandler.handle(%{"a" => "b"}, db_conn, socket)
+ {:noreply, socket} = BroadcastHandler.handle(@payload, db_conn, socket)
socket
end
@@ -245,56 +233,142 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do
for _ <- 1..100 do
topic = "realtime:#{topic}"
assert_received {:socket_push, :text, data}
- message = data |> IO.iodata_to_binary() |> Jason.decode!()
- assert message == %{"event" => "broadcast", "payload" => %{"a" => "b"}, "ref" => nil, "topic" => topic}
+ assert Jason.decode!(data) == message(serializer, topic, @payload)
end
end
- test "public channels are able to send messages", %{topic: topic, tenant: tenant, db_conn: db_conn} do
+ test "public channels are able to send messages",
+ %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do
socket = socket_fixture(tenant, topic, private?: false, policies: nil)
for _ <- 1..100, reduce: socket do
socket ->
- {:reply, :ok, socket} = BroadcastHandler.handle(%{"a" => "b"}, db_conn, socket)
+ {:reply, :ok, socket} = BroadcastHandler.handle(@payload, db_conn, socket)
socket
end
- Process.sleep(120)
-
for _ <- 1..100 do
topic = "realtime:#{topic}"
assert_received {:socket_push, :text, data}
- message = data |> IO.iodata_to_binary() |> Jason.decode!()
- assert message == %{"event" => "broadcast", "payload" => %{"a" => "b"}, "ref" => nil, "topic" => topic}
+ assert Jason.decode!(data) == message(serializer, topic, @payload)
end
- {:ok, %{avg: avg, bucket: buckets}} = RateCounter.get(Tenants.events_per_second_rate(tenant))
+ {:ok, %{avg: avg, bucket: buckets}} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant))
assert Enum.sum(buckets) == 100
assert avg > 0.0
end
- test "public channels are able to send messages and ack", %{topic: topic, tenant: tenant, db_conn: db_conn} do
+ test "public channels are able to send messages and ack",
+ %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do
socket = socket_fixture(tenant, topic, private?: false, policies: nil)
for _ <- 1..100, reduce: socket do
socket ->
- {:reply, :ok, socket} = BroadcastHandler.handle(%{"a" => "b"}, db_conn, socket)
+ {:reply, :ok, socket} = BroadcastHandler.handle(@payload, db_conn, socket)
socket
end
for _ <- 1..100 do
topic = "realtime:#{topic}"
assert_receive {:socket_push, :text, data}
- message = data |> IO.iodata_to_binary() |> Jason.decode!()
- assert message == %{"event" => "broadcast", "payload" => %{"a" => "b"}, "ref" => nil, "topic" => topic}
+ assert Jason.decode!(data) == message(serializer, topic, @payload)
end
- Process.sleep(120)
- {:ok, %{avg: avg, bucket: buckets}} = RateCounter.get(Tenants.events_per_second_rate(tenant))
+ {:ok, %{avg: avg, bucket: buckets}} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant))
assert Enum.sum(buckets) == 100
assert avg > 0.0
end
+ test "V2 json UserBroadcastPush", %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do
+ socket = socket_fixture(tenant, topic, private?: false, policies: nil)
+
+ user_broadcast_payload = %{"a" => "b"}
+ json_encoded_user_broadcast_payload = Jason.encode!(user_broadcast_payload)
+
+ {:reply, :ok, _socket} =
+ BroadcastHandler.handle({"event123", :json, json_encoded_user_broadcast_payload, %{}}, db_conn, socket)
+
+ topic = "realtime:#{topic}"
+ assert_receive {:socket_push, code, data}
+
+ if serializer == RealtimeWeb.Socket.V2Serializer do
+ assert code == :binary
+
+ assert data ==
+ <<
+ # user broadcast = 4
+ 4::size(8),
+ # topic_size
+ byte_size(topic),
+ # user_event_size
+ byte_size("event123"),
+ # metadata_size
+ 0,
+ # json encoding
+ 1::size(8),
+ topic::binary,
+ "event123"
+ >> <> json_encoded_user_broadcast_payload
+ else
+ assert code == :text
+
+ assert Jason.decode!(data) ==
+ message(serializer, topic, %{
+ "event" => "event123",
+ "payload" => user_broadcast_payload,
+ "type" => "broadcast"
+ })
+ end
+ end
+
+ test "V2 binary UserBroadcastPush", %{topic: topic, tenant: tenant, db_conn: db_conn, serializer: serializer} do
+ socket = socket_fixture(tenant, topic, private?: false, policies: nil)
+
+ user_broadcast_payload = <<123, 456, 789>>
+
+ {:reply, :ok, _socket} =
+ BroadcastHandler.handle({"event123", :binary, user_broadcast_payload, %{}}, db_conn, socket)
+
+ topic = "realtime:#{topic}"
+
+ if serializer == RealtimeWeb.Socket.V2Serializer do
+ assert_receive {:socket_push, :binary, data}
+
+ assert data ==
+ <<
+ # user broadcast = 4
+ 4::size(8),
+ # topic_size
+ byte_size(topic),
+ # user_event_size
+ byte_size("event123"),
+ # metadata_size
+ 0,
+ # binary encoding
+ 0::size(8),
+ topic::binary,
+ "event123"
+ >> <> user_broadcast_payload
+ else
+ # Can't receive binary payloads on V1 serializer
+ refute_receive {:socket_push, _code, _data}
+ end
+ end
+
+ test "increase_connection_pool from write authorization does not log UnableToSetPolicies",
+ %{topic: topic, tenant: tenant, db_conn: db_conn} do
+ socket = socket_fixture(tenant, topic)
+
+ stub(Authorization, :get_write_authorizations, fn _, _, _ -> {:error, :increase_connection_pool} end)
+
+ log =
+ capture_log(fn ->
+ {:noreply, _socket} = BroadcastHandler.handle(%{}, db_conn, socket)
+ end)
+
+ refute log =~ "UnableToSetPolicies"
+ end
+
@tag policies: [:broken_write_presence]
test "handle failing rls policy", %{topic: topic, tenant: tenant, db_conn: db_conn} do
socket = socket_fixture(tenant, topic)
@@ -303,14 +377,81 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do
capture_log(fn ->
{:noreply, _socket} = BroadcastHandler.handle(%{}, db_conn, socket)
- # Enough for the RateCounter to calculate the last bucket
- refute_received _, 1200
+ {:ok, %{avg: avg}} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant))
+ assert avg == 0.0
+
+ refute_receive _, 200
end)
assert log =~ "RlsPolicyError"
+ end
- {:ok, %{avg: avg}} = RateCounter.get(Tenants.events_per_second_rate(tenant))
- assert avg == 0.0
+ test "handle payload size excedding limits in private channels", %{topic: topic, tenant: tenant, db_conn: db_conn} do
+ socket =
+ socket_fixture(tenant, topic,
+ policies: %Policies{broadcast: %BroadcastPolicies{write: true}},
+ ack_broadcast: false
+ )
+
+ assert {:noreply, _} =
+ BroadcastHandler.handle(
+ %{"data" => random_string(tenant.max_payload_size_in_kb * 1000 + 1)},
+ db_conn,
+ socket
+ )
+
+ refute_receive {:socket_push, :text, _}, 120
+ end
+
+ test "handle payload size excedding limits in public channels", %{topic: topic, tenant: tenant, db_conn: db_conn} do
+ socket = socket_fixture(tenant, topic, ack_broadcast: false, private?: false)
+
+ assert {:noreply, _} =
+ BroadcastHandler.handle(
+ %{"data" => random_string(tenant.max_payload_size_in_kb * 1000 + 1)},
+ db_conn,
+ socket
+ )
+
+ refute_receive {:socket_push, :text, _}, 120
+ end
+
+ test "handle payload size excedding limits in private channel and if ack it will receive error", %{
+ topic: topic,
+ tenant: tenant,
+ db_conn: db_conn
+ } do
+ socket =
+ socket_fixture(tenant, topic,
+ policies: %Policies{broadcast: %BroadcastPolicies{write: true}},
+ ack_broadcast: true
+ )
+
+ assert {:reply, {:error, :payload_size_exceeded}, _} =
+ BroadcastHandler.handle(
+ %{"data" => random_string(tenant.max_payload_size_in_kb * 1000 + 1)},
+ db_conn,
+ socket
+ )
+
+ refute_receive {:socket_push, :text, _}, 120
+ end
+
+ test "handle payload size excedding limits in public channels and if ack it will receive error", %{
+ topic: topic,
+ tenant: tenant,
+ db_conn: db_conn
+ } do
+ socket = socket_fixture(tenant, topic, ack_broadcast: true, private?: false)
+
+ assert {:reply, {:error, :payload_size_exceeded}, _} =
+ BroadcastHandler.handle(
+ %{"data" => random_string(tenant.max_payload_size_in_kb * 1000 + 1)},
+ db_conn,
+ socket
+ )
+
+ refute_receive {:socket_push, :text, _}, 120
end
end
@@ -318,7 +459,7 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do
tenant = Containers.checkout_tenant(run_migrations: true)
# Warm cache to avoid Cachex and Ecto.Sandbox ownership issues
- Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant})
+ Realtime.Tenants.Cache.update_cache(tenant)
rate = Tenants.events_per_second_rate(tenant)
RateCounter.new(rate, tick: 100)
@@ -331,7 +472,7 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do
fastlane =
RealtimeWeb.RealtimeChannel.MessageDispatcher.fastlane_metadata(
self(),
- Phoenix.Socket.V1.JSONSerializer,
+ context.serializer,
"realtime:#{topic}",
:warning,
"tenant_id"
@@ -389,4 +530,10 @@ defmodule RealtimeWeb.RealtimeChannel.BroadcastHandlerTest do
}
}
end
+
+ defp message(RealtimeWeb.Socket.V2Serializer, topic, payload), do: [nil, nil, topic, "broadcast", payload]
+
+ defp message(Phoenix.Socket.V1.JSONSerializer, topic, payload) do
+ %{"event" => "broadcast", "payload" => payload, "ref" => nil, "topic" => topic}
+ end
end
diff --git a/test/realtime_web/channels/realtime_channel/logging_test.exs b/test/realtime_web/channels/realtime_channel/logging_test.exs
index 92634daef..e96f2edcc 100644
--- a/test/realtime_web/channels/realtime_channel/logging_test.exs
+++ b/test/realtime_web/channels/realtime_channel/logging_test.exs
@@ -1,5 +1,5 @@
defmodule RealtimeWeb.RealtimeChannel.LoggingTest do
- # async: false due to changes in Logger levels
+ # async: false due to changes in Logger levels and shared Cachex state
use Realtime.DataCase, async: false
import ExUnit.CaptureLog
alias RealtimeWeb.RealtimeChannel.Logging
@@ -11,6 +11,7 @@ defmodule RealtimeWeb.RealtimeChannel.LoggingTest do
level = Logger.level()
Logger.configure(level: :info)
tenant = tenant_fixture()
+ Cachex.clear(Realtime.LogThrottle)
on_exit(fn ->
:telemetry.detach(__MODULE__)
@@ -37,6 +38,7 @@ defmodule RealtimeWeb.RealtimeChannel.LoggingTest do
assert log =~ "sub=#{sub}"
assert log =~ "exp=#{exp}"
assert log =~ "iss=#{iss}"
+ assert log =~ "error_code=TestError"
end
end
@@ -57,20 +59,25 @@ defmodule RealtimeWeb.RealtimeChannel.LoggingTest do
assert log =~ "sub=#{sub}"
assert log =~ "exp=#{exp}"
assert log =~ "iss=#{iss}"
+ assert log =~ "error_code=TestWarning"
end
end
- describe "maybe_log_error/3" do
+ describe "maybe_log_error/4" do
test "logs error message when log_level is less or equal to error" do
log_levels = [:debug, :info, :warning, :error]
for log_level <- log_levels do
socket = %{assigns: %{log_level: log_level, tenant: random_string(), access_token: "test_token"}}
- assert capture_log(fn ->
- assert Logging.maybe_log_error(socket, "TestCode", "test message") ==
- {:error, %{reason: "TestCode: test message"}}
- end) =~ "TestCode: test message"
+ log =
+ capture_log(fn ->
+ assert Logging.maybe_log_error(socket, "TestCode", "test message") ==
+ {:error, %{reason: "TestCode: test message"}}
+ end)
+
+ assert log =~ "TestCode: test message"
+ assert log =~ "error_code=TestCode"
assert capture_log(fn ->
assert Logging.maybe_log_error(socket, "TestCode", %{a: "b"}) ==
@@ -96,18 +103,21 @@ defmodule RealtimeWeb.RealtimeChannel.LoggingTest do
end
end
- describe "maybe_log_warning/3" do
- test "logs error message when log_level is less or equal to warning" do
+ describe "maybe_log_warning/4" do
+ test "logs warning message when log_level is less or equal to warning" do
log_levels = [:debug, :info, :warning]
for log_level <- log_levels do
socket = %{assigns: %{log_level: log_level, tenant: random_string(), access_token: "test_token"}}
- assert capture_log(fn ->
- assert Logging.maybe_log_warning(socket, "TestCode", "test message") ==
- {:error, %{reason: "TestCode: test message"}}
- end) =~
- "TestCode: test message"
+ log =
+ capture_log(fn ->
+ assert Logging.maybe_log_warning(socket, "TestCode", "test message") ==
+ {:error, %{reason: "TestCode: test message"}}
+ end)
+
+ assert log =~ "TestCode: test message"
+ assert log =~ "error_code=TestCode"
assert capture_log(fn ->
assert Logging.maybe_log_warning(socket, "TestCode", %{a: "b"}) ==
@@ -151,20 +161,19 @@ defmodule RealtimeWeb.RealtimeChannel.LoggingTest do
end
end
- test "emits telemetry for system errors" do
- socket = %{assigns: %{log_level: :error, tenant: random_string(), access_token: "test_token"}}
-
- for error <- Logging.system_errors() do
- assert Logging.maybe_log_error(socket, error, "test error") ==
- {:error, %{reason: "#{error}: test error"}}
+ test "emits telemetry for errors with tenant metadata" do
+ tenant_id = random_string()
+ socket = %{assigns: %{log_level: :error, tenant: tenant_id, access_token: "test_token"}}
+ error = "TestError"
- assert_receive {[:realtime, :channel, :error], %{code: ^error}, %{code: ^error}}
- end
+ assert Logging.maybe_log_error(socket, error, "test error") == {:error, %{reason: "#{error}: test error"}}
+ assert_receive {[:realtime, :channel, :error], %{count: 1}, %{code: ^error, tenant: ^tenant_id}}
- assert Logging.maybe_log_error(socket, "TestError", "test error") ==
- {:error, %{reason: "TestError: test error"}}
+ assert Logging.maybe_log_warning(socket, error, "test error") == {:error, %{reason: "#{error}: test error"}}
+ refute_receive {[:realtime, :channel, :error], %{count: 1}, %{code: ^error, tenant: ^tenant_id}}
- refute_receive {[:realtime, :channel, :error], :_, :_}
+ assert Logging.maybe_log_info(socket, "test error") == :ok
+ refute_receive {[:realtime, :channel, :error], %{count: 1}, %{code: ^error, tenant: ^tenant_id}}
end
test "logs include JWT claims in metadata", %{tenant: tenant} do
@@ -186,4 +195,85 @@ defmodule RealtimeWeb.RealtimeChannel.LoggingTest do
log = capture_log(fn -> Logging.maybe_log_error(socket, "TestError", "test error") end)
assert log =~ tenant_id
end
+
+ describe "throttle option" do
+ test "logs exactly max_count times within the window but always emits telemetry" do
+ tenant_id = random_string()
+ socket = %{assigns: %{log_level: :error, tenant: tenant_id, access_token: "test_token"}}
+
+ logs =
+ capture_log(fn ->
+ for _ <- 1..5 do
+ Logging.maybe_log_error(socket, "ThrottleCode", "msg", throttle: {3, :timer.seconds(60)})
+ end
+ end)
+
+ assert logs |> String.split("ThrottleCode: msg") |> length() == 4
+
+ for _ <- 1..5 do
+ assert_receive {[:realtime, :channel, :error], %{count: 1}, %{code: "ThrottleCode", tenant: ^tenant_id}}
+ end
+ end
+
+ test "still returns {:error, reason} even when throttled" do
+ tenant_id = random_string()
+ socket = %{assigns: %{log_level: :error, tenant: tenant_id, access_token: "test_token"}}
+
+ for _ <- 1..5 do
+ assert Logging.maybe_log_error(socket, "ThrottleCode", "msg", throttle: {2, :timer.seconds(60)}) ==
+ {:error, %{reason: "ThrottleCode: msg"}}
+ end
+ end
+
+ test "resets after the window expires" do
+ tenant_id = random_string()
+ socket = %{assigns: %{log_level: :error, tenant: tenant_id, access_token: "test_token"}}
+
+ logs_before =
+ capture_log(fn ->
+ for _ <- 1..3, do: Logging.maybe_log_error(socket, "WindowCode", "msg", throttle: {2, 200})
+ end)
+
+ assert logs_before |> String.split("WindowCode: msg") |> length() == 3
+
+ Process.sleep(400)
+
+ logs_after =
+ capture_log(fn ->
+ for _ <- 1..3, do: Logging.maybe_log_error(socket, "WindowCode", "msg", throttle: {2, 200})
+ end)
+
+ assert logs_after |> String.split("WindowCode: msg") |> length() == 3
+ end
+
+ test "different tenant+code pairs have independent counters" do
+ socket_a = %{assigns: %{log_level: :error, tenant: random_string(), access_token: "t"}}
+ socket_b = %{assigns: %{log_level: :error, tenant: random_string(), access_token: "t"}}
+
+ logs =
+ capture_log(fn ->
+ for _ <- 1..3 do
+ Logging.maybe_log_error(socket_a, "CodeA", "msg", throttle: {2, :timer.seconds(60)})
+ Logging.maybe_log_error(socket_b, "CodeB", "msg", throttle: {2, :timer.seconds(60)})
+ end
+ end)
+
+ assert logs |> String.split("CodeA: msg") |> length() == 3
+ assert logs |> String.split("CodeB: msg") |> length() == 3
+ end
+
+ test "callers do not exceed max_count" do
+ tenant_id = random_string()
+ socket = %{assigns: %{log_level: :error, tenant: tenant_id, access_token: "test_token"}}
+
+ logs =
+ capture_log(fn ->
+ for _ <- 1..20 do
+ Logging.maybe_log_error(socket, "ConcurrentCode", "msg", throttle: {5, :timer.seconds(60)})
+ end
+ end)
+
+ assert logs |> String.split("ConcurrentCode: msg") |> length() == 6
+ end
+ end
end
diff --git a/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs b/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs
index 7a9e2eb25..1f1101bc2 100644
--- a/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs
+++ b/test/realtime_web/channels/realtime_channel/message_dispatcher_test.exs
@@ -4,7 +4,10 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do
import ExUnit.CaptureLog
alias Phoenix.Socket.Broadcast
+ alias Phoenix.Socket.V1
alias RealtimeWeb.RealtimeChannel.MessageDispatcher
+ alias RealtimeWeb.Socket.UserBroadcast
+ alias RealtimeWeb.Socket.V2Serializer
defmodule TestSerializer do
def fastlane!(msg) do
@@ -16,18 +19,35 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do
describe "fastlane_metadata/5" do
test "info level" do
assert MessageDispatcher.fastlane_metadata(self(), Serializer, "realtime:topic", :info, "tenant_id") ==
- {:realtime_channel_fastlane, self(), Serializer, "realtime:topic", {:log, "tenant_id"}}
+ {:rc_fastlane, self(), Serializer, "realtime:topic", :info, "tenant_id", MapSet.new()}
end
test "non-info level" do
assert MessageDispatcher.fastlane_metadata(self(), Serializer, "realtime:topic", :warning, "tenant_id") ==
- {:realtime_channel_fastlane, self(), Serializer, "realtime:topic"}
+ {:rc_fastlane, self(), Serializer, "realtime:topic", :warning, "tenant_id", MapSet.new()}
+ end
+
+ test "replayed message ids" do
+ assert MessageDispatcher.fastlane_metadata(
+ self(),
+ Serializer,
+ "realtime:topic",
+ :warning,
+ "tenant_id",
+ MapSet.new([1])
+ ) ==
+ {:rc_fastlane, self(), Serializer, "realtime:topic", :warning, "tenant_id", MapSet.new([1])}
end
end
describe "dispatch/3" do
setup do
- {:ok, _pid} = Agent.start_link(fn -> 0 end, name: TestSerializer)
+ {:ok, _pid} =
+ start_supervised(%{
+ id: TestSerializer,
+ start: {Agent, :start_link, [fn -> 0 end, [name: TestSerializer]]}
+ })
+
:ok
end
@@ -50,12 +70,11 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do
from_pid = :erlang.list_to_pid(~c'<0.2.1>')
subscribers = [
- {subscriber_pid, {:realtime_channel_fastlane, self(), TestSerializer, "realtime:topic", {:log, "tenant123"}}},
- {subscriber_pid, {:realtime_channel_fastlane, self(), TestSerializer, "realtime:topic"}}
+ {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}},
+ {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :warning, "tenant123", MapSet.new()}}
]
msg = %Broadcast{topic: "some:other:topic", event: "event", payload: %{data: "test"}}
- require Logger
log =
capture_log(fn ->
@@ -75,6 +94,224 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do
refute_receive _any
end
+ test "dispatches 'presence_diff' messages to fastlane subscribers" do
+ parent = self()
+
+ subscriber_pid =
+ spawn(fn ->
+ loop = fn loop ->
+ receive do
+ msg ->
+ send(parent, {:subscriber, msg})
+ loop.(loop)
+ end
+ end
+
+ loop.(loop)
+ end)
+
+ from_pid = :erlang.list_to_pid(~c'<0.2.1>')
+
+ subscribers = [
+ {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :info, "tenant456", MapSet.new()}},
+ {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :warning, "tenant456", MapSet.new()}}
+ ]
+
+ msg = %Broadcast{topic: "some:other:topic", event: "presence_diff", payload: %{data: "test"}}
+
+ log =
+ capture_log(fn ->
+ assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok
+ end)
+
+ assert log =~ "Received message on realtime:topic with payload: #{inspect(msg, pretty: true)}"
+
+ assert_receive {:encoded, %Broadcast{event: "presence_diff", payload: %{data: "test"}, topic: "realtime:topic"}}
+ assert_receive {:encoded, %Broadcast{event: "presence_diff", payload: %{data: "test"}, topic: "realtime:topic"}}
+
+ assert Agent.get(TestSerializer, & &1) == 1
+
+ assert Realtime.GenCounter.get(Realtime.Tenants.presence_events_per_second_key("tenant456")) == 2
+
+ refute_receive _any
+ end
+
+ test "does not dispatch messages to fastlane subscribers if they already replayed it" do
+ parent = self()
+
+ subscriber_pid =
+ spawn(fn ->
+ loop = fn loop ->
+ receive do
+ msg ->
+ send(parent, {:subscriber, msg})
+ loop.(loop)
+ end
+ end
+
+ loop.(loop)
+ end)
+
+ from_pid = :erlang.list_to_pid(~c'<0.2.1>')
+ replaeyd_message_ids = MapSet.new(["123"])
+
+ subscribers = [
+ {subscriber_pid,
+ {:rc_fastlane, self(), TestSerializer, "realtime:topic", :info, "tenant123", replaeyd_message_ids}},
+ {subscriber_pid,
+ {:rc_fastlane, self(), TestSerializer, "realtime:topic", :warning, "tenant123", replaeyd_message_ids}}
+ ]
+
+ msg = %Broadcast{
+ topic: "some:other:topic",
+ event: "event",
+ payload: %{"data" => "test", "meta" => %{"id" => "123"}}
+ }
+
+ assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok
+
+ assert Agent.get(TestSerializer, & &1) == 0
+
+ refute_receive _any
+ end
+
+ test "does not dispatch UserBroadcast to fastlane subscribers if they already replayed it" do
+ parent = self()
+
+ subscriber_pid =
+ spawn(fn ->
+ loop = fn loop ->
+ receive do
+ msg ->
+ send(parent, {:subscriber, msg})
+ loop.(loop)
+ end
+ end
+
+ loop.(loop)
+ end)
+
+ from_pid = :erlang.list_to_pid(~c'<0.2.1>')
+ replayed_message_ids = MapSet.new(["abc"])
+
+ subscribers = [
+ {subscriber_pid,
+ {:rc_fastlane, self(), TestSerializer, "realtime:topic", :info, "tenant123", replayed_message_ids}},
+ {subscriber_pid,
+ {:rc_fastlane, self(), TestSerializer, "realtime:topic", :warning, "tenant123", replayed_message_ids}}
+ ]
+
+ msg = %UserBroadcast{
+ topic: "some:other:topic",
+ user_event: "event",
+ user_payload: Jason.encode!(%{data: "test"}),
+ user_payload_encoding: :json,
+ metadata: %{"id" => "abc"}
+ }
+
+ assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok
+
+ assert Agent.get(TestSerializer, & &1) == 0
+
+ refute_receive _any
+ end
+
+ test "payload is not a map" do
+ parent = self()
+
+ subscriber_pid =
+ spawn(fn ->
+ loop = fn loop ->
+ receive do
+ msg ->
+ send(parent, {:subscriber, msg})
+ loop.(loop)
+ end
+ end
+
+ loop.(loop)
+ end)
+
+ from_pid = :erlang.list_to_pid(~c'<0.2.1>')
+
+ subscribers = [
+ {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}},
+ {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic", :warning, "tenant123", MapSet.new()}}
+ ]
+
+ msg = %Broadcast{topic: "some:other:topic", event: "event", payload: "not a map"}
+
+ log =
+ capture_log(fn ->
+ assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok
+ end)
+
+ assert log =~ "Received message on realtime:topic with payload: #{inspect(msg, pretty: true)}"
+
+ assert_receive {:encoded, %Broadcast{event: "event", payload: "not a map", topic: "realtime:topic"}}
+ assert_receive {:encoded, %Broadcast{event: "event", payload: "not a map", topic: "realtime:topic"}}
+
+ assert Agent.get(TestSerializer, & &1) == 1
+
+ assert_receive {:subscriber, :update_rate_counter}
+ assert_receive {:subscriber, :update_rate_counter}
+
+ refute_receive _any
+ end
+
+ test "encodes message separately for each unique serializer and join topic combination" do
+ parent = self()
+
+ subscriber_pid =
+ spawn(fn ->
+ loop = fn loop ->
+ receive do
+ msg ->
+ send(parent, {:subscriber, msg})
+ loop.(loop)
+ end
+ end
+
+ loop.(loop)
+ end)
+
+ from_pid = :erlang.list_to_pid(~c'<0.2.1>')
+
+ # Four subscribers: same serializer, two different join_topics (two each)
+ subscribers = [
+ {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic-a", :info, "tenant123", MapSet.new()}},
+ {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic-a", :info, "tenant123", MapSet.new()}},
+ {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic-b", :info, "tenant123", MapSet.new()}},
+ {subscriber_pid, {:rc_fastlane, self(), TestSerializer, "realtime:topic-b", :info, "tenant123", MapSet.new()}}
+ ]
+
+ msg = %Broadcast{topic: "some:other:topic", event: "event", payload: %{data: "test"}}
+
+ log =
+ capture_log(fn ->
+ assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok
+ end)
+
+ assert log =~ "Received message on realtime:topic-a"
+ assert log =~ "Received message on realtime:topic-b"
+
+ # Serializer called once per unique {serializer, join_topic} pair (2 topics = 2 calls)
+ assert Agent.get(TestSerializer, & &1) == 2
+
+ # Each topic gets encoded with the correct topic rewritten
+ assert_receive {:encoded, %Broadcast{event: "event", topic: "realtime:topic-a"}}
+ assert_receive {:encoded, %Broadcast{event: "event", topic: "realtime:topic-a"}}
+ assert_receive {:encoded, %Broadcast{event: "event", topic: "realtime:topic-b"}}
+ assert_receive {:encoded, %Broadcast{event: "event", topic: "realtime:topic-b"}}
+
+ assert_receive {:subscriber, :update_rate_counter}
+ assert_receive {:subscriber, :update_rate_counter}
+ assert_receive {:subscriber, :update_rate_counter}
+ assert_receive {:subscriber, :update_rate_counter}
+
+ refute_receive _any
+ end
+
test "dispatches messages to non fastlane subscribers" do
from_pid = :erlang.list_to_pid(~c'<0.2.1>')
@@ -93,5 +330,236 @@ defmodule RealtimeWeb.RealtimeChannel.MessageDispatcherTest do
# TestSerializer is not called
assert Agent.get(TestSerializer, & &1) == 0
end
+
+ test "dispatches Broadcast to V1 & V2 Serializers" do
+ parent = self()
+
+ subscriber_pid =
+ spawn(fn ->
+ loop = fn loop ->
+ receive do
+ msg ->
+ send(parent, {:subscriber, msg})
+ loop.(loop)
+ end
+ end
+
+ loop.(loop)
+ end)
+
+ from_pid = :erlang.list_to_pid(~c'<0.2.1>')
+
+ subscribers = [
+ {subscriber_pid, {:rc_fastlane, self(), V1.JSONSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}},
+ {subscriber_pid, {:rc_fastlane, self(), V1.JSONSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}},
+ {subscriber_pid, {:rc_fastlane, self(), V2Serializer, "realtime:topic", :info, "tenant123", MapSet.new()}},
+ {subscriber_pid, {:rc_fastlane, self(), V2Serializer, "realtime:topic", :info, "tenant123", MapSet.new()}}
+ ]
+
+ msg = %Broadcast{topic: "some:other:topic", event: "event", payload: %{data: "test"}}
+
+ log =
+ capture_log(fn ->
+ assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok
+ end)
+
+ assert log =~ "Received message on realtime:topic with payload: #{inspect(msg, pretty: true)}"
+
+ # Receive 2 messages using V1
+ assert_receive {:socket_push, :text, message_v1}
+ assert_receive {:socket_push, :text, ^message_v1}
+
+ assert Jason.decode!(message_v1) == %{
+ "event" => "event",
+ "payload" => %{"data" => "test"},
+ "ref" => nil,
+ "topic" => "realtime:topic"
+ }
+
+ # Receive 2 messages using V2
+ assert_receive {:socket_push, :text, message_v2}
+ assert_receive {:socket_push, :text, ^message_v2}
+
+ # V2 is an array format
+ assert Jason.decode!(message_v2) == [nil, nil, "realtime:topic", "event", %{"data" => "test"}]
+
+ assert_receive {:subscriber, :update_rate_counter}
+ assert_receive {:subscriber, :update_rate_counter}
+ assert_receive {:subscriber, :update_rate_counter}
+ assert_receive {:subscriber, :update_rate_counter}
+
+ refute_receive _any
+ end
+
+ test "dispatches json UserBroadcast to V1 & V2 Serializers" do
+ parent = self()
+
+ subscriber_pid =
+ spawn(fn ->
+ loop = fn loop ->
+ receive do
+ msg ->
+ send(parent, {:subscriber, msg})
+ loop.(loop)
+ end
+ end
+
+ loop.(loop)
+ end)
+
+ from_pid = :erlang.list_to_pid(~c'<0.2.1>')
+
+ subscribers = [
+ {subscriber_pid, {:rc_fastlane, self(), V1.JSONSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}},
+ {subscriber_pid, {:rc_fastlane, self(), V1.JSONSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}},
+ {subscriber_pid, {:rc_fastlane, self(), V2Serializer, "realtime:topic", :info, "tenant123", MapSet.new()}},
+ {subscriber_pid, {:rc_fastlane, self(), V2Serializer, "realtime:topic", :info, "tenant123", MapSet.new()}}
+ ]
+
+ user_payload = Jason.encode!(%{data: "test"})
+
+ msg = %UserBroadcast{
+ topic: "some:other:topic",
+ user_event: "event123",
+ user_payload: user_payload,
+ user_payload_encoding: :json,
+ metadata: %{"id" => "123", "replayed" => true}
+ }
+
+ log =
+ capture_log(fn ->
+ assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok
+ end)
+
+ assert log =~ "Received message on realtime:topic with payload: #{inspect(msg, pretty: true)}"
+
+ # Receive 2 messages using V1
+ assert_receive {:socket_push, :text, message_v1}
+ assert_receive {:socket_push, :text, ^message_v1}
+
+ assert Jason.decode!(message_v1) == %{
+ "event" => "broadcast",
+ "payload" => %{
+ "event" => "event123",
+ "meta" => %{"id" => "123", "replayed" => true},
+ "payload" => %{"data" => "test"},
+ "type" => "broadcast"
+ },
+ "ref" => nil,
+ "topic" => "realtime:topic"
+ }
+
+ # Receive 2 messages using V2
+ assert_receive {:socket_push, :binary, message_v2}
+ assert_receive {:socket_push, :binary, ^message_v2}
+
+ encoded_metadata = Jason.encode!(%{"id" => "123", "replayed" => true})
+ metadata_size = byte_size(encoded_metadata)
+
+ # binary payload structure
+ assert message_v2 ==
+ <<
+ # user broadcast = 4
+ 4::size(8),
+ # topic_size
+ 14,
+ # user_event_size
+ 8,
+ # metadata_size
+ metadata_size,
+ # json encoding
+ 1::size(8),
+ "realtime:topic",
+ "event123"
+ >> <> encoded_metadata <> user_payload
+
+ assert_receive {:subscriber, :update_rate_counter}
+ assert_receive {:subscriber, :update_rate_counter}
+ assert_receive {:subscriber, :update_rate_counter}
+ assert_receive {:subscriber, :update_rate_counter}
+
+ refute_receive _any
+ end
+
+ test "dispatches binary UserBroadcast to V1 & V2 Serializers" do
+ parent = self()
+
+ subscriber_pid =
+ spawn(fn ->
+ loop = fn loop ->
+ receive do
+ msg ->
+ send(parent, {:subscriber, msg})
+ loop.(loop)
+ end
+ end
+
+ loop.(loop)
+ end)
+
+ from_pid = :erlang.list_to_pid(~c'<0.2.1>')
+
+ subscribers = [
+ {subscriber_pid, {:rc_fastlane, self(), V1.JSONSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}},
+ {subscriber_pid, {:rc_fastlane, self(), V1.JSONSerializer, "realtime:topic", :info, "tenant123", MapSet.new()}},
+ {subscriber_pid, {:rc_fastlane, self(), V2Serializer, "realtime:topic", :info, "tenant123", MapSet.new()}},
+ {subscriber_pid, {:rc_fastlane, self(), V2Serializer, "realtime:topic", :info, "tenant123", MapSet.new()}}
+ ]
+
+ user_payload = <<123, 456, 789>>
+
+ msg = %UserBroadcast{
+ topic: "some:other:topic",
+ user_event: "event123",
+ user_payload: user_payload,
+ user_payload_encoding: :binary,
+ metadata: %{"id" => "123", "replayed" => true}
+ }
+
+ log =
+ capture_log(fn ->
+ assert MessageDispatcher.dispatch(subscribers, from_pid, msg) == :ok
+ end)
+
+ assert log =~ "Received message on realtime:topic with payload: #{inspect(msg, pretty: true)}"
+ assert log =~ "User payload encoding is not JSON"
+
+ # Only prints once
+ assert String.split(log, "User payload encoding is not JSON") |> length() == 2
+
+ # No V1 message received as binary payloads are not supported
+ refute_receive {:socket_push, :text, _message_v1}
+
+ # Receive 2 messages using V2
+ assert_receive {:socket_push, :binary, message_v2}
+ assert_receive {:socket_push, :binary, ^message_v2}
+
+ encoded_metadata = Jason.encode!(%{"id" => "123", "replayed" => true})
+ metadata_size = byte_size(encoded_metadata)
+
+ # binary payload structure
+ assert message_v2 ==
+ <<
+ # user broadcast = 4
+ 4::size(8),
+ # topic_size
+ 14,
+ # user_event_size
+ 8,
+ # metadata_size
+ metadata_size,
+ # binary encoding
+ 0::size(8),
+ "realtime:topic",
+ "event123"
+ >> <> encoded_metadata <> user_payload
+
+ assert_receive {:subscriber, :update_rate_counter}
+ assert_receive {:subscriber, :update_rate_counter}
+ assert_receive {:subscriber, :update_rate_counter}
+ assert_receive {:subscriber, :update_rate_counter}
+
+ refute_receive _any
+ end
end
end
diff --git a/test/realtime_web/channels/realtime_channel/presence_handler_test.exs b/test/realtime_web/channels/realtime_channel/presence_handler_test.exs
index e5ecd32ad..18df9f1a0 100644
--- a/test/realtime_web/channels/realtime_channel/presence_handler_test.exs
+++ b/test/realtime_web/channels/realtime_channel/presence_handler_test.exs
@@ -99,26 +99,42 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
end
end
- describe "handle/2" do
+ describe "handle/3" do
+ setup %{tenant: tenant} do
+ on_exit(fn -> :telemetry.detach(__MODULE__) end)
+
+ :telemetry.attach(
+ __MODULE__,
+ [:realtime, :tenants, :payload, :size],
+ &__MODULE__.handle_telemetry/4,
+ %{pid: self(), tenant: tenant}
+ )
+ end
+
test "with true policy and is private, user can track their presence and changes", %{
tenant: tenant,
topic: topic,
db_conn: db_conn
} do
+ external_id = tenant.external_id
key = random_string()
policies = %Policies{presence: %PresencePolicies{read: true, write: true}}
socket =
socket_fixture(tenant, topic, key, policies: policies)
- PresenceHandler.handle(%{"event" => "track"}, db_conn, socket)
+ PresenceHandler.handle(%{"event" => "track", "payload" => %{"A" => "b", "c" => "b"}}, db_conn, socket)
topic = socket.assigns.tenant_topic
assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}}
assert Map.has_key?(joins, key)
+
+ assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 30},
+ %{tenant: ^external_id, message_type: :presence}}
end
test "when tracking already existing user, metadata updated", %{tenant: tenant, topic: topic, db_conn: db_conn} do
+ external_id = tenant.external_id
key = random_string()
policies = %Policies{presence: %PresencePolicies{read: true, write: true}}
socket = socket_fixture(tenant, topic, key, policies: policies)
@@ -134,19 +150,87 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}}
assert Map.has_key?(joins, key)
- refute_receive :_
+
+ assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 6},
+ %{tenant: ^external_id, message_type: :presence}}
+
+ assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 55},
+ %{tenant: ^external_id, message_type: :presence}}
+
+ refute_receive _
+ end
+
+ test "tracking the same payload does nothing", %{tenant: tenant, topic: topic, db_conn: db_conn} do
+ external_id = tenant.external_id
+ key = random_string()
+ policies = %Policies{presence: %PresencePolicies{read: true, write: true}}
+ socket = socket_fixture(tenant, topic, key, policies: policies)
+
+ assert {:ok, socket} = PresenceHandler.handle(%{"event" => "track", "payload" => %{"a" => "b"}}, db_conn, socket)
+
+ assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 18},
+ %{tenant: ^external_id, message_type: :presence}}
+
+ topic = socket.assigns.tenant_topic
+ assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}}
+ assert Map.has_key?(joins, key)
+
+ assert {:ok, _socket} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => %{"a" => "b"}}, db_conn, socket)
+
+ refute_receive _
+ end
+
+ test "tracking, untracking and then tracking the same payload emit events", context do
+ %{tenant: tenant, topic: topic, db_conn: db_conn} = context
+ external_id = tenant.external_id
+ key = random_string()
+ policies = %Policies{presence: %PresencePolicies{read: true, write: true}}
+ socket = socket_fixture(tenant, topic, key, policies: policies)
+
+ assert {:ok, socket} = PresenceHandler.handle(%{"event" => "track", "payload" => %{"a" => "b"}}, db_conn, socket)
+ assert socket.assigns.presence_track_payload == %{"a" => "b"}
+
+ assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 18},
+ %{tenant: ^external_id, message_type: :presence}}
+
+ topic = socket.assigns.tenant_topic
+ assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}}
+ assert %{^key => %{metas: [%{:phx_ref => _, "a" => "b"}]}} = joins
+
+ assert {:ok, socket} = PresenceHandler.handle(%{"event" => "untrack"}, db_conn, socket)
+ assert socket.assigns.presence_track_payload == nil
+
+ assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: %{}, leaves: leaves}}
+ assert %{^key => %{metas: [%{:phx_ref => _, "a" => "b"}]}} = leaves
+
+ assert {:ok, socket} = PresenceHandler.handle(%{"event" => "track", "payload" => %{"a" => "b"}}, db_conn, socket)
+
+ assert socket.assigns.presence_track_payload == %{"a" => "b"}
+
+ assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}}
+ assert %{^key => %{metas: [%{:phx_ref => _, "a" => "b"}]}} = joins
+
+ assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 18},
+ %{tenant: ^external_id, message_type: :presence}}
+
+ refute_receive _
end
test "with false policy and is public, user can track their presence and changes", %{tenant: tenant, topic: topic} do
+ external_id = tenant.external_id
key = random_string()
policies = %Policies{presence: %PresencePolicies{read: false, write: false}}
socket = socket_fixture(tenant, topic, key, policies: policies, private?: false)
- assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "track"}, socket)
+ assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "track"}, nil, socket)
topic = socket.assigns.tenant_topic
assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}}
assert Map.has_key?(joins, key)
+
+ assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 6},
+ %{tenant: ^external_id, message_type: :presence}}
end
test "user can untrack when they want", %{tenant: tenant, topic: topic, db_conn: db_conn} do
@@ -174,7 +258,9 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
reject(&Authorization.get_write_authorizations/3)
key = random_string()
- socket = socket_fixture(tenant, topic, key)
+ # Use high client rate limit to test tenant-level rate limiting
+ client_rate_limit = %{max_calls: 1000, window_ms: 60_000, counter: 0, reset_at: nil}
+ socket = socket_fixture(tenant, topic, key, client_rate_limit: client_rate_limit)
topic = socket.assigns.tenant_topic
for _ <- 1..300, reduce: socket do
@@ -191,6 +277,26 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
end
end
+ test "increase_connection_pool from write authorization returns error and does not log UnableToSetPolicies",
+ %{tenant: tenant, topic: topic, db_conn: db_conn} do
+ stub(Authorization, :get_write_authorizations, fn _, _, _ -> {:error, :increase_connection_pool} end)
+
+ key = random_string()
+ socket = socket_fixture(tenant, topic, key)
+
+ log =
+ capture_log(fn ->
+ assert {:error, :increase_connection_pool} =
+ PresenceHandler.handle(
+ %{"event" => "track", "payload" => %{"metadata" => random_string()}},
+ db_conn,
+ socket
+ )
+ end)
+
+ refute log =~ "UnableToSetPolicies"
+ end
+
@tag policies: [:authenticated_read_broadcast_and_presence, :broken_write_presence]
test "handle failing rls policy", %{tenant: tenant, topic: topic, db_conn: db_conn} do
expect(Authorization, :get_write_authorizations, 1, fn conn, db_conn, auth_context ->
@@ -221,7 +327,12 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
key = random_string()
policies = %Policies{broadcast: %BroadcastPolicies{read: false}}
- socket = socket_fixture(tenant, topic, key, policies: policies, private?: false)
+ # Use high client rate limit to test tenant-level rate limiting
+ client_rate_limit = %{max_calls: 1000, window_ms: 60_000, counter: 0, reset_at: nil}
+
+ socket =
+ socket_fixture(tenant, topic, key, policies: policies, private?: false, client_rate_limit: client_rate_limit)
+
topic = socket.assigns.tenant_topic
for _ <- 1..300, reduce: socket do
@@ -229,6 +340,7 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
assert {:ok, socket} =
PresenceHandler.handle(
%{"event" => "track", "payload" => %{"metadata" => random_string()}},
+ nil,
socket
)
@@ -238,7 +350,13 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
end
test "logs out non recognized events" do
- socket = %Phoenix.Socket{joined: true}
+ tenant = tenant_fixture()
+
+ socket =
+ socket_fixture(tenant, "topic", "presence_key",
+ private?: false,
+ client_rate_limit: %{max_calls: 1000, window_ms: 60_000, counter: 0, reset_at: nil}
+ )
log =
capture_log(fn ->
@@ -248,7 +366,7 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
assert log =~ "UnknownPresenceEvent"
end
- test "socket with presence enabled false will ignore presence events in public channel", %{
+ test "socket with presence enabled false will ignore non-track presence events in public channel", %{
tenant: tenant,
topic: topic
} do
@@ -256,12 +374,12 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
policies = %Policies{presence: %PresencePolicies{read: true, write: true}}
socket = socket_fixture(tenant, topic, key, policies: policies, private?: false, enabled?: false)
- assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "track"}, socket)
+ assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "untrack"}, nil, socket)
topic = socket.assigns.tenant_topic
refute_receive %Broadcast{topic: ^topic, event: "presence_diff"}
end
- test "socket with presence enabled false will ignore presence events in private channel", %{
+ test "socket with presence enabled false will ignore non-track presence events in private channel", %{
tenant: tenant,
topic: topic,
db_conn: db_conn
@@ -270,11 +388,80 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
policies = %Policies{presence: %PresencePolicies{read: true, write: true}}
socket = socket_fixture(tenant, topic, key, policies: policies, private?: false, enabled?: false)
- assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket)
+ assert {:ok, _socket} = PresenceHandler.handle(%{"event" => "untrack"}, db_conn, socket)
+ topic = socket.assigns.tenant_topic
+ refute_receive %Broadcast{topic: ^topic, event: "presence_diff"}
+ end
+
+ test "socket with presence disabled will enable presence on track message for public channel", %{
+ tenant: tenant,
+ topic: topic
+ } do
+ key = random_string()
+ policies = %Policies{presence: %PresencePolicies{read: true, write: true}}
+ socket = socket_fixture(tenant, topic, key, policies: policies, private?: false, enabled?: false)
+
+ refute socket.assigns.presence_enabled?
+
+ assert {:ok, updated_socket} = PresenceHandler.handle(%{"event" => "track"}, nil, socket)
+
+ assert updated_socket.assigns.presence_enabled?
+ topic = socket.assigns.tenant_topic
+ assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}}
+ assert Map.has_key?(joins, key)
+ end
+
+ test "socket with presence disabled will enable presence on track message for private channel", %{
+ tenant: tenant,
+ topic: topic,
+ db_conn: db_conn
+ } do
+ key = random_string()
+ policies = %Policies{presence: %PresencePolicies{read: true, write: true}}
+ socket = socket_fixture(tenant, topic, key, policies: policies, private?: true, enabled?: false)
+
+ refute socket.assigns.presence_enabled?
+
+ assert {:ok, updated_socket} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket)
+
+ assert updated_socket.assigns.presence_enabled?
+ topic = socket.assigns.tenant_topic
+ assert_receive %Broadcast{topic: ^topic, event: "presence_diff", payload: %{joins: joins, leaves: %{}}}
+ assert Map.has_key?(joins, key)
+ end
+
+ test "socket with presence disabled will not enable presence on untrack message", %{
+ tenant: tenant,
+ topic: topic,
+ db_conn: db_conn
+ } do
+ key = random_string()
+ policies = %Policies{presence: %PresencePolicies{read: true, write: true}}
+ socket = socket_fixture(tenant, topic, key, policies: policies, enabled?: false)
+
+ refute socket.assigns.presence_enabled?
+
+ assert {:ok, updated_socket} = PresenceHandler.handle(%{"event" => "untrack"}, db_conn, socket)
+
+ refute updated_socket.assigns.presence_enabled?
topic = socket.assigns.tenant_topic
refute_receive %Broadcast{topic: ^topic, event: "presence_diff"}
end
+ test "socket with presence disabled will not enable presence on unknown event", %{
+ tenant: tenant,
+ topic: topic,
+ db_conn: db_conn
+ } do
+ key = random_string()
+ policies = %Policies{presence: %PresencePolicies{read: true, write: true}}
+ socket = socket_fixture(tenant, topic, key, policies: policies, enabled?: false)
+
+ refute socket.assigns.presence_enabled?
+
+ assert {:error, :unknown_presence_event} = PresenceHandler.handle(%{"event" => "unknown"}, db_conn, socket)
+ end
+
@tag policies: [:authenticated_read_broadcast_and_presence, :authenticated_write_broadcast_and_presence]
test "rate limit is checked on private channel", %{tenant: tenant, topic: topic, db_conn: db_conn} do
key = random_string()
@@ -283,8 +470,9 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
log =
capture_log(fn ->
- for _ <- 1..300, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket)
- Process.sleep(1100)
+ for _ <- 1..1500, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket)
+
+ {:ok, _} = RateCounterHelper.tick!(Tenants.presence_events_per_second_rate(tenant))
assert {:error, :rate_limit_exceeded} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket)
end)
@@ -298,14 +486,38 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
log =
capture_log(fn ->
- for _ <- 1..300, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket)
- Process.sleep(1100)
+ for _ <- 1..1500, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket)
+
+ {:ok, _} = RateCounterHelper.tick!(Tenants.presence_events_per_second_rate(tenant))
assert {:error, :rate_limit_exceeded} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket)
end)
assert log =~ "PresenceRateLimitReached"
end
+
+ test "returns error when track payload is not a map", %{tenant: tenant, topic: topic, db_conn: db_conn} do
+ key = random_string()
+ policies = %Policies{presence: %PresencePolicies{read: true, write: true}}
+ socket = socket_fixture(tenant, topic, key, policies: policies, private?: false)
+
+ assert {:error, :invalid_payload} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => "1111"}, db_conn, socket)
+
+ topic = socket.assigns.tenant_topic
+ refute_receive %Broadcast{topic: ^topic, event: "presence_diff"}
+ end
+
+ test "fails on high payload size", %{tenant: tenant, topic: topic, db_conn: db_conn} do
+ key = random_string()
+ socket = socket_fixture(tenant, topic, key, private?: false)
+ payload_size = tenant.max_payload_size_in_kb * 1000
+
+ payload = %{content: random_string(payload_size)}
+
+ assert {:error, :payload_size_exceeded} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => payload}, db_conn, socket)
+ end
end
describe "sync/1" do
@@ -355,8 +567,9 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
log =
capture_log(fn ->
- for _ <- 1..300, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket)
- Process.sleep(1100)
+ for _ <- 1..1500, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket)
+
+ {:ok, _} = RateCounterHelper.tick!(Tenants.presence_events_per_second_rate(tenant))
assert {:error, :rate_limit_exceeded} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket)
end)
@@ -371,8 +584,9 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
log =
capture_log(fn ->
- for _ <- 1..300, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket)
- Process.sleep(1100)
+ for _ <- 1..1500, do: PresenceHandler.handle(%{"event" => "track"}, db_conn, socket)
+
+ {:ok, _} = RateCounterHelper.tick!(Tenants.presence_events_per_second_rate(tenant))
assert {:error, :rate_limit_exceeded} = PresenceHandler.handle(%{"event" => "track"}, db_conn, socket)
end)
@@ -381,10 +595,186 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
end
end
+ describe "per-client rate limiting" do
+ test "allows calls under the limit", %{tenant: tenant, topic: topic} do
+ client_rate_limit = %{max_calls: 10, window_ms: 60_000, counter: 0, reset_at: nil}
+ socket = socket_fixture(tenant, topic, random_string(), private?: false, client_rate_limit: client_rate_limit)
+
+ # Make 9 calls (under limit of 10)
+ socket =
+ Enum.reduce(1..9, socket, fn _, acc_socket ->
+ {:ok, updated_socket} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, acc_socket)
+
+ updated_socket
+ end)
+
+ assert %{counter: 9, max_calls: 10, window_ms: 60000, reset_at: _} = socket.assigns.presence_client_rate_limit
+
+ # 10th call should still work
+ assert {:ok, socket} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket)
+
+ assert %{counter: 10, max_calls: 10, window_ms: 60000, reset_at: _} = socket.assigns.presence_client_rate_limit
+ end
+
+ test "blocks calls over the limit", %{tenant: tenant, topic: topic} do
+ client_rate_limit = %{max_calls: 10, window_ms: 60_000, counter: 0, reset_at: nil}
+ socket = socket_fixture(tenant, topic, random_string(), private?: false, client_rate_limit: client_rate_limit)
+
+ # Make 10 calls (at limit)
+ socket =
+ Enum.reduce(1..10, socket, fn _, acc_socket ->
+ {:ok, updated_socket} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, acc_socket)
+
+ updated_socket
+ end)
+
+ # 11th call should fail
+ assert {:error, :client_rate_limit_exceeded} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket)
+
+ assert %{counter: 10, max_calls: 10, window_ms: 60000, reset_at: _} = socket.assigns.presence_client_rate_limit
+ end
+
+ test "rate limits work independently per socket", %{tenant: tenant, topic: topic} do
+ client_rate_limit = %{max_calls: 10, window_ms: 60_000, counter: 0, reset_at: nil}
+ socket1 = socket_fixture(tenant, topic, random_string(), private?: false, client_rate_limit: client_rate_limit)
+ socket2 = socket_fixture(tenant, topic, random_string(), private?: false, client_rate_limit: client_rate_limit)
+
+ socket1 =
+ Enum.reduce(1..10, socket1, fn _, acc_socket ->
+ {:ok, updated_socket} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, acc_socket)
+
+ updated_socket
+ end)
+
+ assert {:error, :client_rate_limit_exceeded} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket1)
+
+ # socket2 should still work (independent limit)
+ assert {:ok, _socket} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket2)
+ end
+
+ test "tenant override for max_client_presence_events_per_window is applied", %{tenant: tenant, topic: topic} do
+ {:ok, updated_tenant} =
+ Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{max_client_presence_events_per_window: 3})
+
+ Realtime.Tenants.Cache.update_cache(updated_tenant)
+
+ socket = socket_fixture(updated_tenant, topic, random_string(), private?: false)
+
+ assert %{max_calls: 3} = socket.assigns.presence_client_rate_limit
+
+ socket =
+ Enum.reduce(1..3, socket, fn _, acc_socket ->
+ {:ok, updated_socket} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, acc_socket)
+
+ updated_socket
+ end)
+
+ assert {:error, :client_rate_limit_exceeded} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket)
+ end
+
+ test "falls back to env config when tenant override is nil", %{tenant: tenant, topic: topic} do
+ assert is_nil(tenant.max_client_presence_events_per_window)
+ assert is_nil(tenant.client_presence_window_ms)
+
+ config = Application.get_env(:realtime, :client_presence_rate_limit)
+ expected_max_calls = config[:max_calls]
+ expected_window_ms = config[:window_ms]
+ socket = socket_fixture(tenant, topic, random_string(), private?: false)
+
+ assert %{max_calls: ^expected_max_calls, window_ms: ^expected_window_ms} =
+ socket.assigns.presence_client_rate_limit
+ end
+
+ test "tenant override for client_presence_window_ms is applied", %{tenant: tenant, topic: topic} do
+ {:ok, updated_tenant} =
+ Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{client_presence_window_ms: 5_000})
+
+ Realtime.Tenants.Cache.update_cache(updated_tenant)
+
+ socket = socket_fixture(updated_tenant, topic, random_string(), private?: false)
+
+ assert %{window_ms: 5_000} = socket.assigns.presence_client_rate_limit
+ end
+
+ test "tenant override for client_presence_window_ms respects the window", %{tenant: tenant, topic: topic} do
+ {:ok, updated_tenant} =
+ Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{
+ max_client_presence_events_per_window: 3,
+ client_presence_window_ms: 100
+ })
+
+ Realtime.Tenants.Cache.update_cache(updated_tenant)
+
+ socket = socket_fixture(updated_tenant, topic, random_string(), private?: false)
+
+ assert %{max_calls: 3, window_ms: 100} = socket.assigns.presence_client_rate_limit
+
+ socket =
+ Enum.reduce(1..3, socket, fn _, acc_socket ->
+ {:ok, updated_socket} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, acc_socket)
+
+ updated_socket
+ end)
+
+ assert {:error, :client_rate_limit_exceeded} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket)
+
+ Process.sleep(101)
+
+ assert {:ok, _socket} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket)
+ end
+
+ test "rate limit resets after window expires", %{tenant: tenant, topic: topic} do
+ # Create socket with a very short window (100ms)
+ socket = socket_fixture(tenant, topic, random_string(), private?: false)
+
+ # Override the window to be very short for testing
+ short_window_config = %{
+ max_calls: 3,
+ window_ms: 100,
+ counter: 0,
+ reset_at: nil
+ }
+
+ socket = %{socket | assigns: Map.put(socket.assigns, :presence_client_rate_limit, short_window_config)}
+
+ # Make 3 calls (at limit)
+ socket =
+ Enum.reduce(1..3, socket, fn _, acc_socket ->
+ {:ok, updated_socket} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, acc_socket)
+
+ updated_socket
+ end)
+
+ # 4th call should fail
+ assert {:error, :client_rate_limit_exceeded} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket)
+
+ # Wait for window to expire
+ Process.sleep(101)
+
+ # Should be able to call again after window reset
+ assert {:ok, _socket} =
+ PresenceHandler.handle(%{"event" => "track", "payload" => %{"call" => random_string()}}, nil, socket)
+ end
+ end
+
defp initiate_tenant(context) do
tenant = Containers.checkout_tenant(run_migrations: true)
# Warm cache to avoid Cachex and Ecto.Sandbox ownership issues
- Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant})
+ Realtime.Tenants.Cache.update_cache(tenant)
{:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
assert Connect.ready?(tenant.external_id)
@@ -427,6 +817,34 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
RateCounter.new(rate)
+ client_rate_limit_override = Keyword.get(opts, :client_rate_limit)
+
+ client_rate_limit =
+ if client_rate_limit_override do
+ client_rate_limit_override
+ else
+ config = Application.get_env(:realtime, :client_presence_rate_limit, max_calls: 10, window_ms: 60_000)
+
+ max_calls =
+ case tenant.max_client_presence_events_per_window do
+ value when is_integer(value) and value > 0 -> value
+ _ -> config[:max_calls]
+ end
+
+ window_ms =
+ case tenant.client_presence_window_ms do
+ value when is_integer(value) and value > 0 -> value
+ _ -> config[:window_ms]
+ end
+
+ %{
+ max_calls: max_calls,
+ window_ms: window_ms,
+ counter: 0,
+ reset_at: nil
+ }
+ end
+
%Phoenix.Socket{
joined: true,
topic: "realtime:#{topic}",
@@ -438,6 +856,7 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
policies: policies,
authorization_context: authorization_context,
presence_rate_counter: rate,
+ presence_client_rate_limit: client_rate_limit,
private?: private?,
presence_key: presence_key,
presence_enabled?: enabled?,
@@ -447,4 +866,10 @@ defmodule RealtimeWeb.RealtimeChannel.PresenceHandlerTest do
}
}
end
+
+ def handle_telemetry(event, measures, metadata, %{pid: pid, tenant: tenant}) do
+ if metadata[:tenant] == tenant.external_id do
+ send(pid, {:telemetry, event, measures, metadata})
+ end
+ end
end
diff --git a/test/realtime_web/channels/realtime_channel/tracker_test.exs b/test/realtime_web/channels/realtime_channel/tracker_test.exs
index 2590b9597..7137256c1 100644
--- a/test/realtime_web/channels/realtime_channel/tracker_test.exs
+++ b/test/realtime_web/channels/realtime_channel/tracker_test.exs
@@ -1,5 +1,7 @@
defmodule RealtimeWeb.RealtimeChannel.TrackerTest do
- use Realtime.DataCase
+ # It kills websockets when no channels are open
+ # It can affect other tests
+ use Realtime.DataCase, async: false
alias RealtimeWeb.RealtimeChannel.Tracker
setup do
diff --git a/test/realtime_web/channels/realtime_channel_test.exs b/test/realtime_web/channels/realtime_channel_test.exs
index 2dff83da3..2f52a57d8 100644
--- a/test/realtime_web/channels/realtime_channel_test.exs
+++ b/test/realtime_web/channels/realtime_channel_test.exs
@@ -1,61 +1,731 @@
defmodule RealtimeWeb.RealtimeChannelTest do
- # Can't run async true because under the hood Cachex is used and it doesn't see Ecto Sandbox
- use RealtimeWeb.ChannelCase, async: false
+ use RealtimeWeb.ChannelCase, async: true
use Mimic
import ExUnit.CaptureLog
- alias Phoenix.Socket
alias Phoenix.Channel.Server
+ alias Phoenix.Socket
alias Realtime.Tenants.Authorization
alias Realtime.Tenants.Connect
alias Realtime.RateCounter
alias RealtimeWeb.UserSocket
- @default_limits %{
- max_concurrent_users: 200,
- max_events_per_second: 100,
- max_joins_per_second: 100,
- max_channels_per_client: 100,
- max_bytes_per_second: 100_000
- }
-
setup do
tenant = Containers.checkout_tenant(run_migrations: true)
+ {:ok, db_conn} = Realtime.Database.connect(tenant, "realtime_test", :stop)
+ Integrations.setup_postgres_changes(db_conn)
+ GenServer.stop(db_conn)
+ Realtime.Tenants.Cache.update_cache(tenant)
{:ok, tenant: tenant}
end
setup :rls_context
- describe "presence" do
- test "events are counted", %{tenant: tenant} do
+ describe "join - tenant not found" do
+ test "sends disconnect to transport_pid and logs TenantNotFound", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
+
+ stub(Realtime.Tenants.Cache, :fetch_tenant_by_external_id, fn _id ->
+ {:error, :tenant_not_found}
+ end)
+
+ log =
+ capture_log(fn ->
+ assert {:error, _} = subscribe_and_join(socket, "realtime:test", %{})
+ end)
+
+ assert log =~ "TenantNotFound"
+ assert_received %Phoenix.Socket.Broadcast{event: "disconnect"}
+ end
+ end
+
+ describe "process flags" do
+ test "max heap size is set for both transport and channel processes", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
+
+ assert Process.info(socket.transport_pid, :max_heap_size) ==
+ {:max_heap_size, %{error_logger: true, include_shared_binaries: false, kill: true, size: 6_250_000}}
+
+ assert {:ok, _, socket} = subscribe_and_join(socket, "realtime:test", %{})
+
+ assert Process.info(socket.channel_pid, :max_heap_size) ==
+ {:max_heap_size, %{error_logger: true, include_shared_binaries: false, kill: true, size: 6_250_000}}
+ end
+
+ # We don't test the socket because on unit tests Phoenix is not setting the fullsweep_after config
+ test "fullsweep_after is set on channel process", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
+
+ assert {:ok, _, socket} = subscribe_and_join(socket, "realtime:test", %{})
+
+ assert Process.info(socket.channel_pid, :fullsweep_after) == {:fullsweep_after, 20}
+ end
+ end
+
+ describe "postgres changes" do
+ test "subscribes to inserts", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
+
+ config = %{
+ "postgres_changes" => [%{"event" => "INSERT", "schema" => "public", "table" => "test"}]
+ }
+
+ assert {:ok, reply, _socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config})
+
+ assert %{postgres_changes: [%{:id => sub_id, "event" => "INSERT", "schema" => "public", "table" => "test"}]} =
+ reply
+
+ assert_push "system",
+ %{message: "Subscribed to PostgreSQL", status: "ok", extension: "postgres_changes", channel: "test"},
+ 5000
+
+ {:ok, conn} = Connect.lookup_or_start_connection(tenant.external_id)
+ %{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", [])
+
+ assert_push "postgres_changes", %{data: data, ids: [^sub_id]}, 500
+
+ # we encode and decode because the data is a Jason.Fragment
+ assert %{
+ "table" => "test",
+ "type" => "INSERT",
+ "record" => %{"details" => "test", "id" => ^id, "binary_data" => nil},
+ "columns" => [
+ %{"name" => "id", "type" => "int4"},
+ %{"name" => "details", "type" => "text"},
+ %{"name" => "binary_data", "type" => "bytea"}
+ ],
+ "errors" => nil,
+ "schema" => "public",
+ "commit_timestamp" => _
+ } = Jason.encode!(data) |> Jason.decode!()
+
+ refute_receive %Socket.Message{}
+ refute_receive %Socket.Reply{}
+ end
+
+ test "multiple subscriptions", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
+
+ config = %{
+ "postgres_changes" => [
+ %{"event" => "INSERT", "schema" => "public", "table" => "test"},
+ %{"event" => "DELETE", "schema" => "public", "table" => "test"}
+ ]
+ }
+
+ assert {:ok, reply, _socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config})
+
+ assert %{
+ postgres_changes: [
+ %{:id => insert_sub_id, "event" => "INSERT", "schema" => "public", "table" => "test"},
+ %{
+ :id => delete_sub_id,
+ "event" => "DELETE",
+ "schema" => "public",
+ "table" => "test"
+ }
+ ]
+ } =
+ reply
+
+ assert_push "system",
+ %{message: "Subscribed to PostgreSQL", status: "ok", extension: "postgres_changes", channel: "test"},
+ 5000
+
+ {:ok, conn} = Connect.lookup_or_start_connection(tenant.external_id)
+ # Insert, update and delete but update should not be received
+ %{rows: [[id]]} = Postgrex.query!(conn, "insert into test (details) values ('test') returning id", [])
+ Postgrex.query!(conn, "update test set details = 'test' where id = $1", [id])
+ Postgrex.query!(conn, "delete from test where id = $1", [id])
+
+ assert_push "postgres_changes", %{data: data, ids: [^insert_sub_id]}, 500
+
+ # we encode and decode because the data is a Jason.Fragment
+ assert %{
+ "table" => "test",
+ "type" => "INSERT",
+ "record" => %{"details" => "test", "id" => ^id},
+ "columns" => [
+ %{"name" => "id", "type" => "int4"},
+ %{"name" => "details", "type" => "text"},
+ %{"name" => "binary_data", "type" => "bytea"}
+ ],
+ "errors" => nil,
+ "schema" => "public",
+ "commit_timestamp" => _
+ } = Jason.encode!(data) |> Jason.decode!()
+
+ assert_push "postgres_changes", %{data: data, ids: [^delete_sub_id]}, 500
+
+ # we encode and decode because the data is a Jason.Fragment
+ assert %{
+ "table" => "test",
+ "type" => "DELETE",
+ "old_record" => %{"id" => ^id},
+ "columns" => [
+ %{"name" => "id", "type" => "int4"},
+ %{"name" => "details", "type" => "text"},
+ %{"name" => "binary_data", "type" => "bytea"}
+ ],
+ "errors" => nil,
+ "schema" => "public",
+ "commit_timestamp" => _
+ } = Jason.encode!(data) |> Jason.decode!()
+
+ refute_receive _any
+ end
+
+ test "malformed subscription params", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
+
+ config = %{
+ "postgres_changes" => [%{"event" => "*", "schema" => "public", "table" => "test", "filter" => "wrong"}]
+ }
+
+ assert {:ok, reply, socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config})
+
+ assert %{postgres_changes: [%{"event" => "*", "schema" => "public", "table" => "test"}]} = reply
+
+ assert_push "system",
+ %{
+ message: "Error parsing `filter` params: [\"wrong\"]",
+ status: "error",
+ extension: "postgres_changes",
+ channel: "test"
+ },
+ 3000
+
+ socket = Server.socket(socket.channel_pid)
+
+ # It won't re-subscribe
+ assert socket.assigns.pg_sub_ref == nil
+ end
+
+ test "invalid subscription table does not exist", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
+
+ config = %{
+ "postgres_changes" => [%{"event" => "*", "schema" => "public", "table" => "doesnotexist"}]
+ }
+
+ assert {:ok, reply, socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config})
+
+ assert %{postgres_changes: [%{"event" => "*", "schema" => "public", "table" => "doesnotexist"}]} = reply
+
+ assert_push "system",
+ %{
+ message:
+ "Unable to subscribe to changes with given parameters. Please check Realtime is enabled for the given connect parameters: [event: *, schema: public, table: doesnotexist, filters: [], select: nil]",
+ status: "error",
+ extension: "postgres_changes",
+ channel: "test"
+ },
+ 5000
+
+ socket = Server.socket(socket.channel_pid)
+
+ # It won't re-subscribe
+ assert socket.assigns.pg_sub_ref == nil
+ end
+
+ test "invalid subscription column does not exist", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
+
+ config = %{
+ "postgres_changes" => [
+ %{"event" => "*", "schema" => "public", "table" => "test", "filter" => "notacolumn=eq.123"}
+ ]
+ }
+
+ assert {:ok, reply, socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config})
+
+ assert %{postgres_changes: [%{"event" => "*", "schema" => "public", "table" => "test"}]} = reply
+
+ assert_push "system",
+ %{
+ message:
+ "Unable to subscribe to changes with given parameters. An exception happened so please check your connect parameters: [event: *, schema: public, table: test, filters: [{\"notacolumn\", \"eq\", \"123\"}], select: nil]. Exception: ERROR P0001 (raise_exception) invalid column for filter notacolumn",
+ status: "error",
+ extension: "postgres_changes",
+ channel: "test"
+ },
+ 5000
+
+ socket = Server.socket(socket.channel_pid)
+
+ # It won't re-subscribe
+ assert socket.assigns.pg_sub_ref == nil
+ end
+
+ test "connection error", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
+
+ config = %{
+ "postgres_changes" => [%{"event" => "*", "schema" => "public", "table" => "test"}]
+ }
+
+ conn = spawn(fn -> :ok end)
+ # Let's set the subscription manager conn to be a pid that is no more
+
+ assert {:ok, reply, socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config})
+
+ assert %{postgres_changes: [%{"event" => "*", "schema" => "public", "table" => "test"}]} = reply
+
+ assert_push "system",
+ %{
+ message: "Subscribed to PostgreSQL",
+ status: "ok",
+ extension: "postgres_changes",
+ channel: "test"
+ },
+ 5000
+
+ {:ok, manager_pid, _conn} = Extensions.PostgresCdcRls.get_manager_conn(tenant.external_id)
+ Extensions.PostgresCdcRls.update_meta(tenant.external_id, manager_pid, conn)
+
+ assert {:ok, _reply, socket} = subscribe_and_join(socket, "realtime:test_fail", %{"config" => config})
+
+ assert_push "system",
+ %{message: message, status: "error", extension: "postgres_changes", channel: "test_fail"},
+ 5000
+
+ assert message =~ "{:error, \"Too many database timeouts\"}"
+ socket = Server.socket(socket.channel_pid)
+
+ # It will try again in the future
+ assert socket.assigns.pg_sub_ref != nil
+ end
+ end
+
+ describe "broadcast" do
+ @describetag policies: [:authenticated_all_topic_read]
+
+ test "broadcast map payload", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
+
+ config = %{
+ "broadcast" => %{"self" => true}
+ }
+
+ assert {:ok, _, socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config})
+
+ push(socket, "broadcast", %{"event" => "my_event", "payload" => %{"hello" => "world"}})
+
+ assert_receive %Phoenix.Socket.Message{
+ topic: "realtime:test",
+ event: "broadcast",
+ payload: %{"event" => "my_event", "payload" => %{"hello" => "world"}}
+ }
+ end
+
+ test "broadcast non-map payload", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
+
+ config = %{
+ "broadcast" => %{"self" => true}
+ }
+
+ assert {:ok, _, socket} = subscribe_and_join(socket, "realtime:test", %{"config" => config})
+
+ push(socket, "broadcast", "not a map")
+
+ assert_receive %Phoenix.Socket.Message{
+ topic: "realtime:test",
+ event: "broadcast",
+ payload: "not a map"
+ }
+ end
+
+ test "wrong replay params", %{tenant: tenant} do
jwt = Generators.generate_jwt_token(tenant)
{:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt))
- assert {:ok, _, %Socket{} = socket} = subscribe_and_join(socket, "realtime:test", %{})
+ config = %{
+ "private" => true,
+ "broadcast" => %{
+ "replay" => %{"limit" => "not a number", "since" => :erlang.system_time(:millisecond) - 5 * 60000}
+ }
+ }
- presence_diff = %Socket.Broadcast{event: "presence_diff", payload: %{joins: %{}, leaves: %{}}}
- send(socket.channel_pid, presence_diff)
+ assert {:error, %{reason: "UnableToReplayMessages: Replay params are not valid"}} =
+ subscribe_and_join(socket, "realtime:test", %{"config" => config})
- assert_receive %Socket.Message{topic: "realtime:test", event: "presence_state", payload: %{}}
+ config = %{
+ "private" => true,
+ "broadcast" => %{
+ "replay" => %{"limit" => 1, "since" => "not a number"}
+ }
+ }
+
+ assert {:error, %{reason: "UnableToReplayMessages: Replay params are not valid"}} =
+ subscribe_and_join(socket, "realtime:test", %{"config" => config})
+
+ config = %{
+ "private" => true,
+ "broadcast" => %{
+ "replay" => %{}
+ }
+ }
+
+ assert {:error, %{reason: "UnableToReplayMessages: Replay params are not valid"}} =
+ subscribe_and_join(socket, "realtime:test", %{"config" => config})
+ end
+
+ test "failure to replay", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt))
+
+ config = %{
+ "private" => true,
+ "broadcast" => %{
+ "replay" => %{"limit" => 12, "since" => :erlang.system_time(:millisecond) - 5 * 60000}
+ }
+ }
+
+ Authorization
+ |> expect(:get_read_authorizations, fn _, _, _, _ ->
+ {:ok,
+ %Authorization.Policies{
+ broadcast: %Authorization.Policies.BroadcastPolicies{read: true, write: nil}
+ }}
+ end)
+
+ # Broken database connection
+ conn = spawn(fn -> :ok end)
+ Connect.lookup_or_start_connection(tenant.external_id)
+ {:ok, _} = :syn.update_registry(Connect, tenant.external_id, fn _pid, meta -> %{meta | conn: conn} end)
+
+ assert {:error, %{reason: "UnableToReplayMessages: Realtime was unable to replay messages"}} =
+ subscribe_and_join(socket, "realtime:test", %{"config" => config})
+ end
+
+ test "replay messages on public topic not allowed", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt))
+
+ config = %{
+ "broadcast" => %{"replay" => %{"limit" => 2, "since" => :erlang.system_time(:millisecond) - 5 * 60000}}
+ }
+
+ assert {
+ :error,
+ %{reason: "UnableToReplayMessages: Replay is not allowed for public channels"}
+ } = subscribe_and_join(socket, "realtime:test", %{"config" => config})
+
+ refute_receive %Socket.Message{}
+ refute_receive %Socket.Reply{}
+ end
+
+ @tag policies: [:authenticated_all_topic_read]
+ test "replay messages on private topic", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt))
+
+ # Old message
+ message_fixture(tenant, %{
+ "private" => true,
+ "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :day),
+ "event" => "old",
+ "extension" => "broadcast",
+ "topic" => "test",
+ "payload" => %{"value" => "old"}
+ })
+
+ %{id: message1_id} =
+ message_fixture(tenant, %{
+ "private" => true,
+ "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-1, :minute),
+ "event" => "first",
+ "extension" => "broadcast",
+ "topic" => "test",
+ "payload" => %{"value" => "first"}
+ })
+
+ %{id: message2_id} =
+ message_fixture(tenant, %{
+ "private" => true,
+ "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-2, :minute),
+ "event" => "second",
+ "extension" => "broadcast",
+ "topic" => "test",
+ "payload" => %{"value" => "second"}
+ })
+
+ # This one should not be received because of the limit
+ message_fixture(tenant, %{
+ "private" => true,
+ "inserted_at" => NaiveDateTime.utc_now() |> NaiveDateTime.add(-3, :minute),
+ "event" => "third",
+ "extension" => "broadcast",
+ "topic" => "test",
+ "payload" => %{"value" => "third"}
+ })
+
+ config = %{
+ "private" => true,
+ "broadcast" => %{"replay" => %{"limit" => 2, "since" => :erlang.system_time(:millisecond) - 5 * 60000}}
+ }
+
+ assert {:ok, _, %Socket{}} = subscribe_and_join(socket, "realtime:test", %{"config" => config})
assert_receive %Socket.Message{
topic: "realtime:test",
- event: "presence_diff",
- payload: %{joins: %{}, leaves: %{}}
+ event: "broadcast",
+ payload: %{
+ "event" => "first",
+ "meta" => %{"id" => ^message1_id, "replayed" => true},
+ "payload" => %{"value" => "first"},
+ "type" => "broadcast"
+ }
}
- tenant_id = tenant.external_id
+ assert_receive %Socket.Message{
+ topic: "realtime:test",
+ event: "broadcast",
+ payload: %{
+ "event" => "second",
+ "meta" => %{"id" => ^message2_id, "replayed" => true},
+ "payload" => %{"value" => "second"},
+ "type" => "broadcast"
+ }
+ }
+
+ refute_receive %Socket.Message{}
+ end
+ end
- # Wait for RateCounter to tick
- Process.sleep(1100)
+ describe "presence" do
+ test "presence state event is counted", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt))
+
+ assert {:ok, _, %Socket{} = socket} =
+ subscribe_and_join(socket, "realtime:test", %{"config" => %{"presence" => %{"enabled" => true}}})
+
+ assert_receive %Socket.Message{topic: "realtime:test", event: "presence_state", payload: %{}}
+
+ tenant_id = tenant.external_id
assert {:ok, %RateCounter{id: {:channel, :presence_events, ^tenant_id}, bucket: bucket}} =
- RateCounter.get(socket.assigns.presence_rate_counter)
+ RateCounterHelper.tick!(socket.assigns.presence_rate_counter)
+
+ # presence_state
+ assert Enum.sum(bucket) == 1
+ end
+
+ test "client rate limit blocks calls over the limit and shuts down channel", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt))
+
+ config = %{"config" => %{"presence" => %{"enabled" => true, "key" => "user_id"}}}
+ assert {:ok, _, %Socket{channel_pid: channel_pid} = socket} = subscribe_and_join(socket, "realtime:test", config)
+
+ assert_receive %Socket.Message{topic: "realtime:test", event: "presence_state", payload: %{}}
+
+ # Make 5 presence calls (at the default limit)
+ for i <- 1..5 do
+ ref = push(socket, "presence", %{"type" => "presence", "event" => "TRACK", "payload" => %{"call" => i}})
+ assert_receive %Socket.Reply{ref: ^ref, status: :ok}, 500
+ end
+
+ assert capture_log(fn ->
+ # 6th call should cause channel shutdown
+ push(socket, "presence", %{"type" => "presence", "event" => "TRACK", "payload" => %{"call" => 6}})
+
+ assert_receive %Socket.Message{
+ topic: "realtime:test",
+ event: "system",
+ payload: %{
+ message: "Client presence rate limit exceeded",
+ status: "error",
+ extension: "system",
+ channel: "test"
+ }
+ },
+ 500
+ end) =~ "ClientPresenceRateLimitReached"
+
+ assert_process_down(channel_pid)
+ end
+
+ test "client rate limits are independent per connection", %{tenant: tenant} do
+ jwt1 = Generators.generate_jwt_token(tenant)
+ jwt2 = Generators.generate_jwt_token(tenant)
+
+ {:ok, %Socket{} = socket1} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt1))
+ {:ok, %Socket{} = socket2} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt2))
+
+ config = %{"config" => %{"presence" => %{"key" => "user_id"}}}
+
+ assert {:ok, _, %Socket{channel_pid: channel_pid1} = socket1} =
+ subscribe_and_join(socket1, "realtime:test1", config)
+
+ assert {:ok, _, %Socket{} = socket2} = subscribe_and_join(socket2, "realtime:test2", config)
+
+ # Exhaust rate limit for socket1
+ for i <- 1..5 do
+ ref = push(socket1, "presence", %{"type" => "presence", "event" => "TRACK", "payload" => %{"call" => i}})
+ assert_receive %Socket.Reply{ref: ^ref, status: :ok}, 500
+ end
+
+ # socket1's 6th call should cause shutdown
+ push(socket1, "presence", %{"type" => "presence", "event" => "TRACK", "payload" => %{"call" => 6}})
+
+ assert_receive %Socket.Message{
+ topic: "realtime:test1",
+ event: "system",
+ payload: %{
+ message: "Client presence rate limit exceeded",
+ status: "error",
+ extension: "system",
+ channel: "test1"
+ }
+ },
+ 500
+
+ assert_process_down(channel_pid1)
+
+ # socket2 should still work (independent rate limit)
+ ref = push(socket2, "presence", %{"type" => "presence", "event" => "TRACK", "payload" => %{"call" => 1}})
+ assert_receive %Socket.Reply{ref: ^ref, status: :ok}, 500
+ end
+
+ test "presence track closes on high payload size", %{tenant: tenant} do
+ topic = "realtime:test"
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt))
- # presence_state + presence_diff
- assert 2 in bucket
+ assert {:ok, _, %Socket{} = socket} =
+ subscribe_and_join(socket, topic, %{"config" => %{"presence" => %{"enabled" => true}}})
+
+ assert_receive %Phoenix.Socket.Message{topic: "realtime:test", event: "presence_state"}, 500
+
+ payload = %{
+ type: "presence",
+ event: "TRACK",
+ payload: %{name: "realtime_presence_96", t: 1814.7000000029802, content: String.duplicate("a", 3_500_000)}
+ }
+
+ push(socket, "presence", payload)
+
+ assert_receive %Phoenix.Socket.Message{
+ event: "system",
+ payload: %{
+ extension: "system",
+ message: "Track message size exceeded",
+ status: "error"
+ },
+ topic: ^topic
+ },
+ 500
+ end
+
+ test "presence track with non-map payload replies with error and keeps socket alive", %{tenant: tenant} do
+ topic = "realtime:test"
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt))
+
+ assert {:ok, _, %Socket{} = socket} =
+ subscribe_and_join(socket, topic, %{"config" => %{"presence" => %{"enabled" => true}}})
+
+ assert_receive %Phoenix.Socket.Message{topic: "realtime:test", event: "presence_state"}, 500
+
+ ref = push(socket, "presence", %{"type" => "presence", "event" => "TRACK", "payload" => "not a map"})
+
+ assert_receive %Socket.Reply{
+ ref: ^ref,
+ status: :error,
+ payload: %{reason: "Presence track payload must be a map"}
+ },
+ 500
+
+ ref = push(socket, "presence", %{"type" => "presence", "event" => "TRACK", "payload" => %{"user" => "a"}})
+ assert_receive %Socket.Reply{ref: ^ref, status: :ok}, 500
+ end
+
+ test "presence track with same payload does nothing", %{tenant: tenant} do
+ topic = "realtime:test"
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt))
+
+ assert {:ok, _, %Socket{} = socket} =
+ subscribe_and_join(socket, topic, %{config: %{presence: %{enabled: true, key: "my_key"}}})
+
+ assert_receive %Phoenix.Socket.Message{topic: "realtime:test", event: "presence_state"}, 500
+
+ payload = %{type: "presence", event: "TRACK", payload: %{"hello" => "world"}}
+
+ push(socket, "presence", payload)
+
+ assert_receive %Socket.Reply{payload: %{}, topic: "realtime:test", status: :ok}, 500
+
+ assert_receive %Socket.Message{
+ payload: %{
+ joins: %{"my_key" => %{metas: [%{:phx_ref => _, "hello" => "world"}]}},
+ leaves: %{}
+ },
+ topic: "realtime:test",
+ event: "presence_diff"
+ },
+ 500
+
+ push(socket, "presence", payload)
+
+ assert_receive %Socket.Reply{payload: %{}, topic: "realtime:test", status: :ok}, 500
+ # no presence_diff this time
+
+ refute_receive %Socket.Message{}
+ refute_receive %Socket.Reply{}
+ end
+
+ test "presence is disabled when tenant has presence_enabled false and client does not override", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
+
+ assert {:ok, _, %Socket{} = socket} = subscribe_and_join(socket, "realtime:test", %{})
+
+ refute_receive %Socket.Message{event: "presence_state"}, 200
+ assert socket.assigns.presence_enabled? == false
+ end
+
+ test "presence is enabled when client explicitly enables it even if tenant flag is false", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
+
+ config = %{"config" => %{"presence" => %{"enabled" => true}}}
+
+ assert {:ok, _, %Socket{} = socket} = subscribe_and_join(socket, "realtime:test", config)
+
+ assert_receive %Socket.Message{event: "presence_state"}, 500
+ assert socket.assigns.presence_enabled? == true
+ end
+
+ test "presence defaults to tenant flag when client does not specify", %{tenant: tenant} do
+ {:ok, tenant} =
+ Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{"presence_enabled" => true})
+
+ Realtime.Tenants.Cache.update_cache(tenant)
+
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
+
+ assert {:ok, _, %Socket{} = socket} = subscribe_and_join(socket, "realtime:test", %{})
+
+ assert_receive %Socket.Message{event: "presence_state"}, 500
+ assert socket.assigns.presence_enabled? == true
end
end
@@ -74,12 +744,26 @@ defmodule RealtimeWeb.RealtimeChannelTest do
end) =~ "UnknownErrorOnChannel: Realtime was unable to connect to the project database"
end
- test "unexpected error while setting policies", %{tenant: tenant} do
+ test "unexpected error while setting policies logs UnknownErrorOnChannel", %{tenant: tenant} do
jwt = Generators.generate_jwt_token(tenant)
{:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
- expect(Authorization, :get_read_authorizations, fn _, _, _ ->
- {:error, "Realtime was unable to connect to the project database"}
+ expect(Authorization, :get_read_authorizations, fn _, _, _, _ ->
+ {:error, "unexpected error"}
+ end)
+
+ assert capture_log(fn ->
+ assert {:error, %{reason: "Unknown Error on Channel"}} =
+ subscribe_and_join(socket, "realtime:test", %{"config" => %{"private" => true}})
+ end) =~ "UnknownErrorOnChannel"
+ end
+
+ test "struct error while setting policies logs UnableToSetPolicies", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
+
+ expect(Authorization, :get_read_authorizations, fn _, _, _, _ ->
+ {:error, %DBConnection.ConnectionError{message: "unexpected error", reason: :error, severity: :error}}
end)
assert capture_log(fn ->
@@ -87,6 +771,66 @@ defmodule RealtimeWeb.RealtimeChannelTest do
subscribe_and_join(socket, "realtime:test", %{"config" => %{"private" => true}})
end) =~ "UnableToSetPolicies"
end
+
+ test "query canceled during join logs QueryCanceled", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
+
+ expect(Authorization, :get_read_authorizations, fn _, _, _, _ ->
+ {:error, :query_canceled,
+ %Postgrex.Error{postgres: %{code: :query_canceled, message: "canceling statement due to user request"}}}
+ end)
+
+ assert capture_log(fn ->
+ assert {:error, _} =
+ subscribe_and_join(socket, "realtime:test", %{"config" => %{"private" => true}})
+ end) =~ "QueryCanceled"
+ end
+
+ test "missing partition during join logs MissingPartition", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{}, conn_opts(tenant, jwt))
+
+ expect(Authorization, :get_read_authorizations, fn _, _, _, _ -> {:error, :missing_partition} end)
+
+ assert capture_log(fn ->
+ assert {:error, _} =
+ subscribe_and_join(socket, "realtime:test", %{"config" => %{"private" => true}})
+ end) =~ "MissingPartition"
+ end
+ end
+
+ describe "maximum number of channels per client" do
+ test "logs error once when last channel slot is taken", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "error"}, conn_opts(tenant, jwt))
+
+ Realtime.Tenants.Cache.update_cache(%{tenant | max_channels_per_client: 1})
+
+ log =
+ capture_log(fn ->
+ assert {:ok, _, _} = subscribe_and_join(socket, "realtime:test", %{})
+ end)
+
+ assert log =~ "ChannelRateLimitReached"
+ end
+
+ test "does not log when channel limit is already exceeded", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+ {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt))
+
+ Realtime.Tenants.Cache.update_cache(%{tenant | max_channels_per_client: 1})
+
+ capture_log(fn -> subscribe_and_join(socket, "realtime:test", %{}) end)
+
+ log =
+ capture_log(fn ->
+ assert {:error, %{reason: "ChannelRateLimitReached: Too many channels"}} =
+ subscribe_and_join(socket, "realtime:test2", %{})
+ end)
+
+ refute log =~ "ChannelRateLimitReached"
+ end
end
describe "maximum number of connected clients per tenant" do
@@ -94,25 +838,38 @@ defmodule RealtimeWeb.RealtimeChannelTest do
jwt = Generators.generate_jwt_token(tenant)
{:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt))
- socket = Socket.assign(socket, %{limits: %{@default_limits | max_concurrent_users: 1}})
+ Realtime.Tenants.Cache.update_cache(%{tenant | max_concurrent_users: 1})
+
assert {:ok, _, %Socket{}} = subscribe_and_join(socket, "realtime:test", %{})
end
- test "reached", %{tenant: tenant} do
+ test "reached after connecting", %{tenant: tenant} do
jwt = Generators.generate_jwt_token(tenant)
{:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt))
- socket_at_capacity =
- Socket.assign(socket, %{limits: %{@default_limits | max_concurrent_users: 0}})
+ Realtime.Tenants.Cache.update_cache(%{tenant | max_concurrent_users: 1})
- socket_over_capacity =
- Socket.assign(socket, %{limits: %{@default_limits | max_concurrent_users: -1}})
+ pid = spawn_link(fn -> Process.sleep(:infinity) end)
+ Realtime.UsersCounter.add(pid, tenant.external_id)
assert {:error, %{reason: "ConnectionRateLimitReached: Too many connected users"}} =
- subscribe_and_join(socket_at_capacity, "realtime:test", %{})
+ subscribe_and_join(socket, "realtime:test", %{})
+
+ pid = spawn_link(fn -> Process.sleep(:infinity) end)
+ Realtime.UsersCounter.add(pid, tenant.external_id)
assert {:error, %{reason: "ConnectionRateLimitReached: Too many connected users"}} =
- subscribe_and_join(socket_over_capacity, "realtime:test", %{})
+ subscribe_and_join(socket, "realtime:test", %{})
+ end
+
+ test "reached before connecting", %{tenant: tenant} do
+ jwt = Generators.generate_jwt_token(tenant)
+
+ Realtime.Tenants.Cache.update_cache(%{tenant | max_concurrent_users: 1})
+
+ Realtime.UsersCounter.add(self(), tenant.external_id)
+
+ {:error, :too_many_connections} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt))
end
end
@@ -122,7 +879,11 @@ defmodule RealtimeWeb.RealtimeChannelTest do
jwt = Generators.generate_jwt_token(tenant)
{:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt))
- assert socket = subscribe_and_join!(socket, "realtime:test", %{"config" => %{"private" => true}})
+ assert socket =
+ subscribe_and_join!(socket, "realtime:test", %{
+ "config" => %{"private" => true, "presence" => %{"enabled" => true}}
+ })
+
old_confirm_ref = socket.assigns.confirm_token_ref
assert socket.assigns.policies == %Realtime.Tenants.Authorization.Policies{
@@ -152,7 +913,10 @@ defmodule RealtimeWeb.RealtimeChannelTest do
jwt = Generators.generate_jwt_token(tenant)
{:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt))
- assert socket = subscribe_and_join!(socket, "realtime:test", %{"config" => %{"private" => true}})
+ assert socket =
+ subscribe_and_join!(socket, "realtime:test", %{
+ "config" => %{"private" => true, "presence" => %{"enabled" => true}}
+ })
assert socket.assigns.policies == %Realtime.Tenants.Authorization.Policies{
broadcast: %Realtime.Tenants.Authorization.Policies.BroadcastPolicies{read: true, write: nil},
@@ -379,6 +1143,21 @@ defmodule RealtimeWeb.RealtimeChannelTest do
end
describe "access_token validations" do
+ test "access_token has exp and iat in decimal format", %{tenant: tenant} do
+ api_key = Generators.generate_jwt_token(tenant)
+
+ jwt =
+ Generators.generate_jwt_token(tenant, %{
+ role: "authenticated",
+ exp: System.system_time(:second) + 100.99,
+ iat: System.system_time(:second) - 100.99
+ })
+
+ assert {:ok, socket} = connect(UserSocket, %{}, conn_opts(tenant, api_key))
+
+ assert {:ok, _, _} = subscribe_and_join(socket, "realtime:test", %{"access_token" => jwt})
+ end
+
test "access_token has expired", %{tenant: tenant} do
api_key = Generators.generate_jwt_token(tenant)
jwt = Generators.generate_jwt_token(tenant, %{role: "authenticated", exp: System.system_time(:second) - 1})
@@ -709,7 +1488,7 @@ defmodule RealtimeWeb.RealtimeChannelTest do
"settings" => %{
"db_host" => "127.0.0.1",
"db_name" => "postgres",
- "db_user" => "supabase_admin",
+ "db_user" => "supabase_realtime_admin",
"db_password" => "postgres",
"poll_interval" => 100,
"poll_max_changes" => 100,
@@ -733,19 +1512,6 @@ defmodule RealtimeWeb.RealtimeChannelTest do
end
end
- test "registers transport pid and channel pid per tenant", %{tenant: tenant} do
- jwt = Generators.generate_jwt_token(tenant)
- {:ok, %Socket{} = socket} = connect(UserSocket, %{"log_level" => "warning"}, conn_opts(tenant, jwt))
-
- assert {:ok, _, %Socket{transport_pid: transport_pid_1} = socket} =
- subscribe_and_join(socket, "realtime:#{random_string()}", %{})
-
- assert {:ok, _, %Socket{transport_pid: ^transport_pid_1}} =
- subscribe_and_join(socket, "realtime:#{random_string()}", %{})
-
- assert [{_, ^transport_pid_1}] = Registry.lookup(RealtimeWeb.SocketDisconnect.Registry, tenant.external_id)
- end
-
defp conn_opts(tenant, token) do
[
connect_info: %{
@@ -762,7 +1528,10 @@ defmodule RealtimeWeb.RealtimeChannelTest do
put_in(extension, ["settings", "db_port"], db_port)
]
- Realtime.Api.update_tenant(tenant, %{extensions: extensions})
+ with {:ok, tenant} <- Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{extensions: extensions}) do
+ Realtime.Tenants.Cache.update_cache(tenant)
+ {:ok, tenant}
+ end
end
defp assert_process_down(pid) do
diff --git a/test/realtime_web/channels/socket_disconnect_test.exs b/test/realtime_web/channels/socket_disconnect_test.exs
deleted file mode 100644
index 585958457..000000000
--- a/test/realtime_web/channels/socket_disconnect_test.exs
+++ /dev/null
@@ -1,165 +0,0 @@
-defmodule RealtimeWeb.SocketDisconnectTest do
- use ExUnit.Case
- import ExUnit.CaptureLog
- import Generators
-
- alias Phoenix.PubSub
- alias RealtimeWeb.SocketDisconnect
-
- @aux_mod (quote do
- defmodule DisconnectTestAux do
- alias RealtimeWeb.SocketDisconnect
-
- def generate_tenant_processes(tenant_external_id) do
- tenant_pids =
- for _ <- 1..10 do
- pid = spawn(fn -> Process.sleep(:infinity) end)
- SocketDisconnect.add(tenant_external_id, %Phoenix.Socket{transport_pid: pid})
- pid
- end
-
- other_tenant_pids =
- for _ <- 1..10 do
- pid = spawn(fn -> Process.sleep(:infinity) end)
- SocketDisconnect.add(Generators.random_string(), %Phoenix.Socket{transport_pid: pid})
- pid
- end
-
- %{tenant: tenant_pids, other: other_tenant_pids}
- end
- end
- end)
-
- Code.eval_quoted(@aux_mod)
-
- describe "add/2" do
- test "successfully registers a socket with the tenant's external_id" do
- tenant_external_id = random_string()
- pid = spawn(fn -> Process.sleep(:infinity) end)
- socket = %Phoenix.Socket{transport_pid: pid}
-
- assert :ok = SocketDisconnect.add(tenant_external_id, socket)
- # Verify that the socket is registered in the registry
-
- assert [{_, ^pid}] = Registry.lookup(RealtimeWeb.SocketDisconnect.Registry, tenant_external_id)
- end
-
- test "successfully registers multiple entries repeatedly without collision" do
- tenant_external_id = random_string()
-
- transport_pid = spawn(fn -> Process.sleep(:infinity) end)
- socket = %Phoenix.Socket{transport_pid: transport_pid}
-
- assert :ok = SocketDisconnect.add(tenant_external_id, socket)
- assert :ok = SocketDisconnect.add(tenant_external_id, socket)
- assert :ok = SocketDisconnect.add(tenant_external_id, socket)
-
- assert result = Registry.lookup(RealtimeWeb.SocketDisconnect.Registry, tenant_external_id)
- assert length(result) == 1
- assert [{_, ^transport_pid}] = result
- assert Process.alive?(transport_pid)
- end
-
- test "successfully registers multiple entries from different pids without collision" do
- tenant_external_id = random_string()
-
- for _ <- 1..10 do
- pid = spawn(fn -> Process.sleep(:infinity) end)
- socket = %Phoenix.Socket{transport_pid: pid}
- assert :ok = SocketDisconnect.add(tenant_external_id, socket)
- assert :ok = SocketDisconnect.add(tenant_external_id, socket)
- assert :ok = SocketDisconnect.add(tenant_external_id, socket)
-
- pid
- end
-
- # Verify that only one entry is registered
- result = Registry.lookup(RealtimeWeb.SocketDisconnect.Registry, tenant_external_id)
- assert length(result) == 10
- for {_, pid} <- result, do: assert(Process.alive?(pid))
- end
- end
-
- describe "disconnect/1" do
- test "successfully disconnects all sockets associated with a given tenant on the current node" do
- tenant_external_id = random_string()
- %{tenant: tenant_pids, other: other_pids} = DisconnectTestAux.generate_tenant_processes(tenant_external_id)
-
- # Ensure all processes are alive before disconnecting
- for pid <- tenant_pids, do: assert(Process.alive?(pid))
- for pid <- other_pids, do: assert(Process.alive?(pid))
-
- # Perform the disconnect
- assert :ok = SocketDisconnect.disconnect(tenant_external_id)
-
- # Verify that tenant processes are killed and other processes remain alive
- for pid <- tenant_pids, do: refute(Process.alive?(pid))
- for pid <- other_pids, do: assert(Process.alive?(pid))
- end
-
- test "after disconnect, pid is unregistered" do
- tenant_external_id = random_string()
- PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant_external_id)
- %{tenant: tenant_pids, other: other_pids} = DisconnectTestAux.generate_tenant_processes(tenant_external_id)
-
- # Ensure all processes are alive before disconnecting
- for pid <- tenant_pids, do: assert(Process.alive?(pid))
- for pid <- other_pids, do: assert(Process.alive?(pid))
-
- # Perform the disconnect
- log =
- capture_log(fn ->
- assert :ok = SocketDisconnect.disconnect(tenant_external_id)
- end)
-
- assert_received :disconnect
- Process.sleep(200)
- assert [] = Registry.lookup(RealtimeWeb.SocketDisconnect.Registry, tenant_external_id)
- assert log =~ "Disconnecting all sockets for tenant #{tenant_external_id}"
- end
- end
-
- describe "distributed_disconnect/1" do
- setup do
- {:ok, node} = Clustered.start(@aux_mod)
- %{node: node}
- end
-
- test "successfully kills all processes associated with a given tenant and non others" do
- tenant_external_id = random_string()
- # Generate fake processes for the tenant and other tenants
- %{tenant: tenant_pids, other: other_pids} = DisconnectTestAux.generate_tenant_processes(tenant_external_id)
-
- %{tenant: remote_tenant_pids, other: remote_other_pids} =
- :erpc.call(Node.self(), DisconnectTestAux, :generate_tenant_processes, [tenant_external_id])
-
- # Ensure all processes are alive before disconnecting
- for pid <- tenant_pids ++ remote_tenant_pids, do: assert(Process.alive?(pid))
- for pid <- other_pids ++ remote_other_pids, do: assert(Process.alive?(pid))
-
- # Perform the distributed disconnect
- assert [:ok, :ok] = SocketDisconnect.distributed_disconnect(tenant_external_id)
-
- # Verify that tenant processes are killed and other processes remain alive
- for pid <- tenant_pids ++ remote_tenant_pids, do: refute(Process.alive?(pid))
- for pid <- other_pids ++ remote_other_pids, do: assert(Process.alive?(pid))
- end
- end
-
- test "on registered pid dead, Registry cleans up" do
- tenant_external_id = random_string()
-
- pid =
- spawn(fn ->
- pid = spawn(fn -> Process.sleep(:infinity) end)
- socket = %Phoenix.Socket{transport_pid: pid}
- assert :ok = SocketDisconnect.add(tenant_external_id, socket)
-
- assert [^pid] = Registry.lookup(RealtimeWeb.SocketDisconnect.Registry, tenant_external_id)
- end)
-
- Process.sleep(100)
- refute Process.alive?(pid)
- assert [] = Registry.lookup(RealtimeWeb.SocketDisconnect.Registry, tenant_external_id)
- end
-end
diff --git a/test/realtime_web/channels/tenant_rate_limiters_test.exs b/test/realtime_web/channels/tenant_rate_limiters_test.exs
new file mode 100644
index 000000000..05d56ec82
--- /dev/null
+++ b/test/realtime_web/channels/tenant_rate_limiters_test.exs
@@ -0,0 +1,31 @@
+defmodule RealtimeWeb.TenantRateLimitersTest do
+ use Realtime.DataCase, async: true
+
+ use Mimic
+ alias RealtimeWeb.TenantRateLimiters
+ alias Realtime.Api.Tenant
+
+ setup do
+ tenant = %Tenant{external_id: random_string(), max_concurrent_users: 1, max_joins_per_second: 1}
+
+ %{tenant: tenant}
+ end
+
+ describe "check_tenant/1" do
+ test "rate is not exceeded", %{tenant: tenant} do
+ assert TenantRateLimiters.check_tenant(tenant) == :ok
+ end
+
+ test "max concurrent users is exceeded", %{tenant: tenant} do
+ Realtime.UsersCounter.add(self(), tenant.external_id)
+
+ assert TenantRateLimiters.check_tenant(tenant) == {:error, :too_many_connections}
+ end
+
+ test "max joins is exceeded", %{tenant: tenant} do
+ expect(Realtime.RateCounter, :get, fn _ -> {:ok, %{limit: %{triggered: true}}} end)
+
+ assert TenantRateLimiters.check_tenant(tenant) == {:error, :too_many_joins}
+ end
+ end
+end
diff --git a/test/realtime_web/channels/user_socket_test.exs b/test/realtime_web/channels/user_socket_test.exs
new file mode 100644
index 000000000..f66eef421
--- /dev/null
+++ b/test/realtime_web/channels/user_socket_test.exs
@@ -0,0 +1,102 @@
+defmodule RealtimeWeb.UserSocketTest do
+ use ExUnit.Case, async: true
+ import ExUnit.CaptureLog
+
+ alias RealtimeWeb.Socket.V2Serializer
+ alias RealtimeWeb.UserSocket
+
+ @socket %Phoenix.Socket{
+ serializer: V2Serializer,
+ assigns: %{tenant: "test-tenant", access_token: "test-token", log_level: :error}
+ }
+ @state {%{channels: %{}, channels_inverse: %{}}, @socket}
+
+ describe "disconnect/1" do
+ test "returns :ok" do
+ assert :ok = UserSocket.disconnect("tenant-disconnect-ok")
+ end
+
+ test "broadcasts socket drain to subscribers topic" do
+ tenant_id = "tenant-disconnect-drain"
+ Phoenix.PubSub.subscribe(Realtime.PubSub, UserSocket.subscribers_id(tenant_id))
+
+ UserSocket.disconnect(tenant_id)
+
+ assert_receive :socket_drain
+ end
+
+ test "broadcasts system disconnect message to operations topic" do
+ tenant_id = "tenant-disconnect-ops"
+ Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> tenant_id)
+
+ UserSocket.disconnect(tenant_id)
+
+ assert_receive %Phoenix.Socket.Broadcast{
+ event: "system",
+ payload: %{extension: "system", status: "ok", message: "Server requested disconnect"}
+ }
+ end
+
+ test "logs a warning with tenant id" do
+ tenant_id = "tenant-disconnect-log"
+
+ log =
+ capture_log(fn ->
+ UserSocket.disconnect(tenant_id)
+ end)
+
+ assert log =~ "Disconnecting all sockets for tenant #{tenant_id}"
+ end
+ end
+
+ describe "handle_in/2 with invalid messages" do
+ test "does not crash and logs when message is an array with not enough items" do
+ raw = Jason.encode!(["join_ref", "ref", "topic"])
+
+ log =
+ capture_log(fn ->
+ assert {:ok, @state} = UserSocket.handle_in({raw, [opcode: :text]}, @state)
+ end)
+
+ assert log =~ "MalformedWebSocketMessage"
+ end
+
+ test "does not crash and logs when message is a map" do
+ raw = Jason.encode!(%{"topic" => "t", "event" => "e", "payload" => %{}})
+
+ log =
+ capture_log(fn ->
+ assert {:ok, @state} = UserSocket.handle_in({raw, [opcode: :text]}, @state)
+ end)
+
+ assert log =~ "MalformedWebSocketMessage"
+ end
+
+ test "does not crash and logs when message is empty string" do
+ log =
+ capture_log(fn ->
+ assert {:ok, @state} = UserSocket.handle_in({"", [opcode: :text]}, @state)
+ end)
+
+ assert log =~ "MalformedWebSocketMessage"
+ end
+
+ test "does not crash and logs when message is invalid JSON" do
+ log =
+ capture_log(fn ->
+ assert {:ok, @state} = UserSocket.handle_in({"not json", [opcode: :text]}, @state)
+ end)
+
+ assert log =~ "MalformedWebSocketMessage"
+ end
+
+ test "does not crash and logs on unexpected errors" do
+ log =
+ capture_log(fn ->
+ assert {:ok, @state} = UserSocket.handle_in({:not_a_binary, [opcode: :text]}, @state)
+ end)
+
+ assert log =~ "UnknownErrorOnWebSocketMessage"
+ end
+ end
+end
diff --git a/test/realtime_web/controllers/broadcast_controller_test.exs b/test/realtime_web/controllers/broadcast_controller_test.exs
index 9c38d58bd..418fe24b4 100644
--- a/test/realtime_web/controllers/broadcast_controller_test.exs
+++ b/test/realtime_web/controllers/broadcast_controller_test.exs
@@ -12,13 +12,10 @@ defmodule RealtimeWeb.BroadcastControllerTest do
alias RealtimeWeb.Endpoint
alias RealtimeWeb.TenantBroadcaster
- @token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1MTYyMzkwMjIsInJvbGUiOiJmb28iLCJleHAiOiJiYXIifQ.Ret2CevUozCsPhpgW2FMeFL7RooLgoOvfQzNpLBj5ak"
- @expired_token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MjEwNzMyOTAsImlhdCI6MTYyNzg4NjQ0MCwicm9sZSI6ImFub24ifQ.AHmuaydSU3XAxwoIFhd3gwGwjnBIKsjFil0JQEOLtRw"
-
setup %{conn: conn} do
tenant = Containers.checkout_tenant(run_migrations: true)
# Warm cache to avoid Cachex and Ecto.Sandbox ownership issues
- Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant})
+ Realtime.Tenants.Cache.update_cache(tenant)
conn = generate_conn(conn, tenant)
@@ -141,16 +138,57 @@ defmodule RealtimeWeb.BroadcastControllerTest do
assert conn.status == 422
- # Wait for counters to increment. RateCounter tick is 1 second
- Process.sleep(2000)
- {:ok, rate_counter} = RateCounter.get(Tenants.requests_per_second_rate(tenant))
+ {:ok, rate_counter} = RateCounterHelper.tick!(Tenants.requests_per_second_rate(tenant))
assert rate_counter.avg != 0.0
- {:ok, rate_counter} = RateCounter.get(Tenants.events_per_second_rate(tenant))
+ {:ok, rate_counter} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant))
assert rate_counter.avg == 0.0
refute_receive {:socket_push, _, _}
end
+
+ test "returns 422 when batch of messages includes a message that exceeds the tenant payload size", %{
+ conn: conn,
+ tenant: tenant
+ } do
+ sub_topic_1 = "sub_topic_1"
+ sub_topic_2 = "sub_topic_2"
+
+ payload_1 = %{"data" => "data"}
+ payload_2 = %{"data" => random_string(tenant.max_payload_size_in_kb * 1000 + 100)}
+ event_1 = "event_1"
+ event_2 = "event_2"
+
+ conn =
+ post(conn, Routes.broadcast_path(conn, :broadcast), %{
+ "messages" => [
+ %{"topic" => sub_topic_1, "payload" => payload_1, "event" => event_1},
+ %{"topic" => sub_topic_1, "payload" => payload_1, "event" => event_1},
+ %{"topic" => sub_topic_2, "payload" => payload_2, "event" => event_2}
+ ]
+ })
+
+ assert conn.status == 422
+ end
+ end
+
+ describe "suspended tenant" do
+ test "returns 403 and does not broadcast when tenant is suspended", %{conn: conn, tenant: tenant} do
+ Realtime.Tenants.Cache.update_cache(%{tenant | suspend: true})
+
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
+
+ conn =
+ post(conn, Routes.broadcast_path(conn, :broadcast), %{
+ "messages" => [
+ %{"topic" => "sub_topic", "payload" => %{"data" => "data"}, "event" => "event"}
+ ]
+ })
+
+ assert conn.status == 403
+ assert conn.resp_body == Jason.encode!(%{message: "Tenant is suspended"})
+ assert calls(&TenantBroadcaster.pubsub_broadcast/5) == []
+ end
end
describe "too many requests" do
@@ -209,23 +247,28 @@ defmodule RealtimeWeb.BroadcastControllerTest do
end
describe "unauthorized" do
- test "invalid token returns 401", %{conn: conn} do
+ test "invalid token returns 401", %{conn: conn, tenant: tenant} do
conn =
conn
+ |> delete_req_header("authorization")
|> put_req_header("accept", "application/json")
|> put_req_header("x-api-key", "potato")
- |> then(&%{&1 | host: "dev_tenant.supabase.com"})
+ |> then(&%{&1 | host: "#{tenant.external_id}.supabase.com"})
conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{})
assert conn.status == 401
end
- test "expired token returns 401", %{conn: conn} do
+ test "expired token returns 401", %{conn: conn, tenant: tenant} do
+ now = System.system_time(:second)
+ expired_token = generate_jwt_token(tenant, %{role: "anon", iat: now - 200, exp: now - 100})
+
conn =
conn
+ |> delete_req_header("authorization")
|> put_req_header("accept", "application/json")
- |> put_req_header("x-api-key", @expired_token)
- |> then(&%{&1 | host: "dev_tenant.supabase.com"})
+ |> put_req_header("x-api-key", expired_token)
+ |> then(&%{&1 | host: "#{tenant.external_id}.supabase.com"})
conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{})
assert conn.status == 401
@@ -272,7 +315,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do
} do
request_events_key = Tenants.requests_per_second_key(tenant)
broadcast_events_key = Tenants.events_per_second_key(tenant)
- expect(TenantBroadcaster, :pubsub_broadcast, 5, fn _, _, _, _ -> :ok end)
+ expect(TenantBroadcaster, :pubsub_broadcast, 5, fn _, _, _, _, _ -> :ok end)
messages_to_send =
Stream.repeatedly(fn -> generate_message_with_policies(db_conn, tenant) end)
@@ -294,7 +337,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do
conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages})
- broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/4)
+ broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/5)
Enum.each(messages_to_send, fn %{topic: topic} ->
broadcast_topic = Tenants.tenant_topic(tenant, topic, false)
@@ -310,7 +353,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do
}
assert Enum.any?(broadcast_calls, fn
- [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher] -> true
+ [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher, :broadcast] -> true
_ -> false
end)
end)
@@ -326,7 +369,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do
} do
request_events_key = Tenants.requests_per_second_key(tenant)
broadcast_events_key = Tenants.events_per_second_key(tenant)
- expect(TenantBroadcaster, :pubsub_broadcast, 6, fn _, _, _, _ -> :ok end)
+ expect(TenantBroadcaster, :pubsub_broadcast, 6, fn _, _, _, _, _ -> :ok end)
channels =
Stream.repeatedly(fn -> generate_message_with_policies(db_conn, tenant) end)
@@ -358,7 +401,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do
conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages})
- broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/4)
+ broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/5)
Enum.each(channels, fn %{topic: topic} ->
broadcast_topic = Tenants.tenant_topic(tenant, topic, false)
@@ -374,7 +417,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do
}
assert Enum.count(broadcast_calls, fn
- [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher] -> true
+ [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher, :broadcast] -> true
_ -> false
end) == 1
end)
@@ -393,7 +436,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do
open_channel_topic = Tenants.tenant_topic(tenant, "open_channel", true)
assert Enum.count(broadcast_calls, fn
- [_, ^open_channel_topic, ^message, RealtimeChannel.MessageDispatcher] -> true
+ [_, ^open_channel_topic, ^message, RealtimeChannel.MessageDispatcher, :broadcast] -> true
_ -> false
end) == 1
@@ -408,7 +451,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do
} do
request_events_key = Tenants.requests_per_second_key(tenant)
broadcast_events_key = Tenants.events_per_second_key(tenant)
- expect(TenantBroadcaster, :pubsub_broadcast, 5, fn _, _, _, _ -> :ok end)
+ expect(TenantBroadcaster, :pubsub_broadcast, 5, fn _, _, _, _, _ -> :ok end)
messages_to_send =
Stream.repeatedly(fn -> generate_message_with_policies(db_conn, tenant) end)
@@ -428,11 +471,12 @@ defmodule RealtimeWeb.BroadcastControllerTest do
GenCounter
|> expect(:add, fn ^request_events_key -> :ok end)
- |> expect(:add, length(messages_to_send), fn ^broadcast_events_key -> :ok end)
+ # remove the one message that won't be broadcasted for this user
+ |> expect(:add, length(messages) - 1, fn ^broadcast_events_key -> :ok end)
conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages})
- broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/4)
+ broadcast_calls = calls(&TenantBroadcaster.pubsub_broadcast/5)
Enum.each(messages_to_send, fn %{topic: topic} ->
broadcast_topic = Tenants.tenant_topic(tenant, topic, false)
@@ -448,7 +492,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do
}
assert Enum.count(broadcast_calls, fn
- [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher] -> true
+ [_, ^broadcast_topic, ^message, RealtimeChannel.MessageDispatcher, :broadcast] -> true
_ -> false
end) == 1
end)
@@ -461,7 +505,7 @@ defmodule RealtimeWeb.BroadcastControllerTest do
@tag role: "anon"
test "user without permission won't broadcast", %{conn: conn, db_conn: db_conn, tenant: tenant} do
request_events_key = Tenants.requests_per_second_key(tenant)
- reject(&TenantBroadcaster.pubsub_broadcast/4)
+ reject(&TenantBroadcaster.pubsub_broadcast/5)
messages =
Stream.repeatedly(fn -> generate_message_with_policies(db_conn, tenant) end)
@@ -482,7 +526,6 @@ defmodule RealtimeWeb.BroadcastControllerTest do
GenCounter
|> expect(:add, fn ^request_events_key -> 1 end)
- |> reject(:add, 1)
conn = post(conn, Routes.broadcast_path(conn, :broadcast), %{"messages" => messages})
@@ -497,9 +540,12 @@ defmodule RealtimeWeb.BroadcastControllerTest do
end
defp generate_conn(conn, tenant) do
+ now = System.system_time(:second)
+ claims = %{role: "test", iat: now, exp: now + 100_000}
+
conn
|> put_req_header("accept", "application/json")
- |> put_req_header("authorization", "Bearer #{@token}")
+ |> put_req_header("authorization", "Bearer #{generate_jwt_token(tenant, claims)}")
|> then(&%{&1 | host: "#{tenant.external_id}.supabase.com"})
end
end
diff --git a/test/realtime_web/controllers/broadcast_single_controller_test.exs b/test/realtime_web/controllers/broadcast_single_controller_test.exs
new file mode 100644
index 000000000..e358f78b7
--- /dev/null
+++ b/test/realtime_web/controllers/broadcast_single_controller_test.exs
@@ -0,0 +1,665 @@
+defmodule RealtimeWeb.BroadcastSingleControllerTest do
+ use RealtimeWeb.ConnCase, async: true
+ use Mimic
+
+ alias Realtime.Crypto
+ alias Realtime.GenCounter
+ alias Realtime.RateCounter
+ alias Realtime.Tenants
+ alias Realtime.Tenants.Authorization
+ alias Realtime.Tenants.Connect
+
+ alias RealtimeWeb.RealtimeChannel
+ alias RealtimeWeb.Endpoint
+
+ setup %{conn: conn} do
+ tenant = Containers.checkout_tenant(run_migrations: true)
+ # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues
+ Realtime.Tenants.Cache.update_cache(tenant)
+
+ conn = generate_conn(conn, tenant)
+
+ {:ok, conn: conn, tenant: tenant}
+ end
+
+ defp subscribe(tenant_topic, topic, serializer \\ Phoenix.Socket.V1.JSONSerializer) do
+ fastlane = RealtimeChannel.MessageDispatcher.fastlane_metadata(self(), serializer, topic, :error, "tenant_id")
+
+ Endpoint.subscribe(tenant_topic, metadata: fastlane)
+ end
+
+ defp assert_receive_message do
+ assert_receive {:socket_push, :text, data}
+
+ data
+ |> IO.iodata_to_binary()
+ |> Jason.decode!()
+ end
+
+ describe "JSON broadcast" do
+ test "returns 202 when JSON message is broadcasted", %{conn: conn, tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ request_events_key = Tenants.requests_per_second_key(tenant)
+
+ GenCounter
+ |> expect(:add, fn ^request_events_key -> :ok end)
+ |> expect(:add, fn ^broadcast_events_key -> :ok end)
+
+ sub_topic = "room:123"
+ event = "message"
+ topic = Tenants.tenant_topic(tenant, sub_topic)
+ payload = %{"text" => "hello", "user" => "alice"}
+ json_payload = Jason.encode!(payload)
+
+ subscribe(topic, sub_topic)
+ subscribe(topic, sub_topic, RealtimeWeb.Socket.V2Serializer)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), payload)
+
+ assert conn.status == 202
+
+ message = assert_receive_message()
+
+ assert message == %{
+ "event" => "broadcast",
+ "payload" => %{
+ "payload" => payload,
+ "event" => event,
+ "type" => "broadcast"
+ },
+ "ref" => nil,
+ "topic" => sub_topic
+ }
+
+ # Assert WebSocket binary message received with V2Serializer format
+ assert_receive {:socket_push, :binary, data}
+
+ # Verify V2 binary format:
+ # Header: [type(1), topic_size(1), event_size(1), metadata_size(1), encoding(1)]
+ # Body: [topic, event, metadata?, payload]
+ topic_size = byte_size(sub_topic)
+ event_size = byte_size(event)
+
+ assert IO.iodata_to_binary(data) == <<
+ # user broadcast type = 4
+ 4::size(8),
+ # sizes
+ topic_size::size(8),
+ event_size::size(8),
+ # metadata_size = 0 (no metadata)
+ 0::size(8),
+ # json encoding = 1
+ 1::size(8),
+ # topic and event strings
+ sub_topic::binary,
+ event::binary,
+ # binary payload
+ json_payload::binary
+ >>
+
+ refute_receive {:socket_push, _, _}
+ end
+
+ test "handles empty JSON payload", %{conn: conn, tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ request_events_key = Tenants.requests_per_second_key(tenant)
+
+ GenCounter
+ |> expect(:add, fn ^request_events_key -> :ok end)
+ |> expect(:add, fn ^broadcast_events_key -> :ok end)
+
+ sub_topic = "room:456"
+ event = "empty"
+ topic = Tenants.tenant_topic(tenant, sub_topic)
+
+ subscribe(topic, sub_topic)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), %{})
+
+ assert conn.status == 202
+
+ message = assert_receive_message()
+
+ assert message == %{
+ "event" => "broadcast",
+ "payload" => %{
+ "payload" => %{},
+ "event" => event,
+ "type" => "broadcast"
+ },
+ "ref" => nil,
+ "topic" => sub_topic
+ }
+
+ refute_receive {:socket_push, _, _}
+ end
+
+ test "handles topics with colons", %{conn: conn, tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ request_events_key = Tenants.requests_per_second_key(tenant)
+
+ GenCounter
+ |> expect(:add, fn ^request_events_key -> :ok end)
+ |> expect(:add, fn ^broadcast_events_key -> :ok end)
+
+ sub_topic = "room:lobby:main"
+ event = "message"
+ topic = Tenants.tenant_topic(tenant, sub_topic)
+
+ subscribe(topic, sub_topic)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), %{"data" => "test"})
+
+ assert conn.status == 202
+
+ message = assert_receive_message()
+
+ assert message == %{
+ "event" => "broadcast",
+ "payload" => %{
+ "payload" => %{"data" => "test"},
+ "event" => event,
+ "type" => "broadcast"
+ },
+ "ref" => nil,
+ "topic" => sub_topic
+ }
+
+ refute_receive {:socket_push, _, _}
+ end
+
+ test "returns 422 when private=true and the JWT role cannot be set in Postgres", %{conn: conn, tenant: tenant} do
+ request_events_key = Tenants.requests_per_second_key(tenant)
+
+ # Only request counter is bumped; broadcast counter must NOT be incremented because no message is published.
+ expect(GenCounter, :add, fn ^request_events_key -> :ok end)
+
+ sub_topic = "private:room"
+ event = "secret"
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event) <> "?private=true", %{
+ "secret" => "data"
+ })
+
+ assert conn.status == 422
+ assert Jason.decode!(conn.resp_body)["message"] == "RLS policy error"
+ end
+
+ test "handles private=false query param (default)", %{conn: conn, tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ request_events_key = Tenants.requests_per_second_key(tenant)
+
+ GenCounter
+ |> expect(:add, fn ^request_events_key -> :ok end)
+ |> expect(:add, fn ^broadcast_events_key -> :ok end)
+
+ sub_topic = "public:room"
+ event = "message"
+ topic = Tenants.tenant_topic(tenant, sub_topic)
+
+ subscribe(topic, sub_topic)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event) <> "?private=false", %{
+ "data" => "public"
+ })
+
+ assert conn.status == 202
+ end
+
+ test "returns 422 when JSON payload exceeds size limit", %{conn: conn, tenant: tenant} do
+ request_events_key = Tenants.requests_per_second_key(tenant)
+
+ expect(GenCounter, :add, fn ^request_events_key -> :ok end)
+
+ sub_topic = "room:large"
+ event = "message"
+ large_payload = %{"data" => String.duplicate("a", tenant.max_payload_size_in_kb * 1024)}
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), large_payload)
+
+ assert conn.status == 422
+ assert Jason.decode!(conn.resp_body)["errors"]["payload"] == ["Payload size exceeds tenant limit"]
+
+ {:ok, rate_counter} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant))
+ assert rate_counter.avg == 0.0
+ end
+
+ test "returns 401 when JWT is expired", %{conn: conn, tenant: tenant} do
+ sub_topic = "room:123"
+ event = "message"
+ now = System.system_time(:second)
+ expired_token = generate_jwt_token(tenant, %{role: "anon", iat: now - 200, exp: now - 100})
+
+ conn =
+ conn
+ |> put_req_header("authorization", "Bearer #{expired_token}")
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), %{"data" => "test"})
+
+ assert conn.status == 401
+ end
+
+ test "returns 401 when JWT is missing", %{conn: conn, tenant: _tenant} do
+ sub_topic = "room:123"
+ event = "message"
+
+ conn =
+ conn
+ |> delete_req_header("authorization")
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), %{"data" => "test"})
+
+ assert conn.status == 401
+ end
+ end
+
+ describe "Binary broadcast" do
+ test "returns 202 when binary message is broadcasted", %{conn: conn, tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ request_events_key = Tenants.requests_per_second_key(tenant)
+
+ GenCounter
+ |> expect(:add, fn ^request_events_key -> :ok end)
+ |> expect(:add, fn ^broadcast_events_key -> :ok end)
+
+ sub_topic = "binary:room"
+ event = "data"
+ topic = Tenants.tenant_topic(tenant, sub_topic)
+ binary_payload = <<1, 2, 3, 4, 5>>
+
+ # Subscribe with V2Serializer to receive binary messages
+ subscribe(topic, sub_topic, RealtimeWeb.Socket.V2Serializer)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/octet-stream")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), binary_payload)
+
+ assert conn.status == 202
+
+ # Assert binary message received with V2Serializer format
+ assert_receive {:socket_push, :binary, data}
+
+ # Verify V2 binary format:
+ # Header: [type(1), topic_size(1), event_size(1), metadata_size(1), encoding(1)]
+ # Body: [topic, event, metadata?, payload]
+ topic_size = byte_size(sub_topic)
+ event_size = byte_size(event)
+
+ assert IO.iodata_to_binary(data) == <<
+ # user broadcast type = 4
+ 4::size(8),
+ # sizes
+ topic_size::size(8),
+ event_size::size(8),
+ # metadata_size = 0 (no metadata)
+ 0::size(8),
+ # binary encoding = 0
+ 0::size(8),
+ # topic and event strings
+ sub_topic::binary,
+ event::binary,
+ # binary payload
+ binary_payload::binary
+ >>
+ end
+
+ test "handles empty binary payload", %{conn: conn, tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ request_events_key = Tenants.requests_per_second_key(tenant)
+
+ GenCounter
+ |> expect(:add, fn ^request_events_key -> :ok end)
+ |> expect(:add, fn ^broadcast_events_key -> :ok end)
+
+ sub_topic = "binary:empty"
+ event = "empty"
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/octet-stream")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), <<>>)
+
+ assert conn.status == 202
+ end
+
+ test "returns 422 when binary payload exceeds size limit", %{conn: conn, tenant: tenant} do
+ request_events_key = Tenants.requests_per_second_key(tenant)
+
+ expect(GenCounter, :add, fn ^request_events_key -> :ok end)
+
+ sub_topic = "binary:large"
+ event = "data"
+ large_binary = :crypto.strong_rand_bytes(tenant.max_payload_size_in_kb * 1024 + 1)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/octet-stream")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), large_binary)
+
+ assert conn.status == 422
+ assert Jason.decode!(conn.resp_body)["errors"]["payload"] == ["Payload size exceeds tenant limit"]
+
+ {:ok, rate_counter} = RateCounterHelper.tick!(Tenants.events_per_second_rate(tenant))
+ assert rate_counter.avg == 0.0
+ end
+
+ test "returns 401 when JWT is expired for binary", %{conn: conn, tenant: tenant} do
+ sub_topic = "binary:room"
+ event = "data"
+ now = System.system_time(:second)
+ expired_token = generate_jwt_token(tenant, %{role: "anon", iat: now - 200, exp: now - 100})
+
+ conn =
+ conn
+ |> put_req_header("authorization", "Bearer #{expired_token}")
+ |> put_req_header("content-type", "application/octet-stream")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), <<1, 2, 3>>)
+
+ assert conn.status == 401
+ end
+ end
+
+ describe "Content-Type handling" do
+ test "returns 415 for unsupported content type", %{conn: conn, tenant: _tenant} do
+ sub_topic = "room:123"
+ event = "message"
+
+ conn =
+ conn
+ |> put_req_header("content-type", "text/plain")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), "plain text")
+
+ assert conn.status == 415
+ assert Jason.decode!(conn.resp_body)["error"] =~ "Unsupported Media Type"
+ end
+
+ test "handles application/json with charset", %{conn: conn, tenant: tenant} do
+ broadcast_events_key = Tenants.events_per_second_key(tenant)
+ request_events_key = Tenants.requests_per_second_key(tenant)
+
+ GenCounter
+ |> expect(:add, fn ^request_events_key -> :ok end)
+ |> expect(:add, fn ^broadcast_events_key -> :ok end)
+
+ sub_topic = "room:charset"
+ event = "message"
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json; charset=utf-8")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), %{"data" => "test"})
+
+ assert conn.status == 202
+ end
+ end
+
+ describe "suspended tenant" do
+ test "returns 403 and does not broadcast when tenant is suspended", %{conn: conn, tenant: tenant} do
+ Realtime.Tenants.Cache.update_cache(%{tenant | suspend: true})
+
+ sub_topic = "room:123"
+ event = "message"
+ topic = Tenants.tenant_topic(tenant, sub_topic)
+
+ subscribe(topic, sub_topic)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), %{"data" => "test"})
+
+ assert conn.status == 403
+ assert Jason.decode!(conn.resp_body)["message"] == "Tenant is suspended"
+
+ refute_receive {:socket_push, _, _}
+ end
+ end
+
+ describe "Rate limiting" do
+ test "returns 429 when rate limit is exceeded", %{conn: conn, tenant: tenant} do
+ request_events_key = Tenants.requests_per_second_key(tenant)
+ events_per_second_rate = Tenants.events_per_second_rate(tenant)
+
+ expect(GenCounter, :add, fn ^request_events_key -> :ok end)
+
+ RateCounter
+ |> stub(:new, fn _ -> {:ok, nil} end)
+ |> stub(:get, fn rate ->
+ case rate do
+ ^events_per_second_rate ->
+ {:ok, %RateCounter{avg: tenant.max_events_per_second + 1}}
+
+ _ ->
+ {:ok, %RateCounter{avg: 0}}
+ end
+ end)
+
+ sub_topic = "room:rate-limited"
+ event = "message"
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event), %{"data" => "test"})
+
+ assert conn.status == 429
+ end
+ end
+
+ describe "Private broadcast authorization" do
+ setup %{conn: conn, tenant: tenant} do
+ jwt_secret = Crypto.decrypt!(tenant.jwt_secret)
+ claims = %{sub: "test-user", role: "anon", exp: Joken.current_time() + 1_000}
+ signer = Joken.Signer.create("HS256", jwt_secret)
+ jwt = Joken.generate_and_sign!(%{}, claims, signer)
+
+ conn =
+ conn
+ |> delete_req_header("authorization")
+ |> put_req_header("authorization", "Bearer #{jwt}")
+
+ {:ok, conn: conn}
+ end
+
+ test "returns 403 when anon caller has no RLS write policy", %{conn: conn, tenant: tenant} do
+ request_events_key = Tenants.requests_per_second_key(tenant)
+
+ expect(GenCounter, :add, fn ^request_events_key -> :ok end)
+
+ sub_topic = "private:room"
+ event = "secret"
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, sub_topic, event) <> "?private=true", %{
+ "secret" => "data"
+ })
+
+ assert conn.status == 403
+ assert Jason.decode!(conn.resp_body)["message"] == "Unauthorized"
+ end
+
+ test "returns 422 when authorization query is canceled", %{conn: conn, tenant: tenant} do
+ request_events_key = Tenants.requests_per_second_key(tenant)
+ expect(GenCounter, :add, fn ^request_events_key -> :ok end)
+
+ expect(Authorization, :get_write_authorizations, fn _, _ ->
+ {:error, :query_canceled,
+ %Postgrex.Error{postgres: %{code: :query_canceled, message: "canceling statement due to user request"}}}
+ end)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1})
+
+ assert conn.status == 422
+ assert Jason.decode!(conn.resp_body)["message"] == "Query canceled"
+ end
+
+ test "returns 422 when messages partition is missing", %{conn: conn, tenant: tenant} do
+ request_events_key = Tenants.requests_per_second_key(tenant)
+ expect(GenCounter, :add, fn ^request_events_key -> :ok end)
+
+ expect(Authorization, :get_write_authorizations, fn _, _ -> {:error, :missing_partition} end)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1})
+
+ assert conn.status == 422
+ assert Jason.decode!(conn.resp_body)["message"] == "Missing messages partition"
+ end
+
+ test "returns 429 when authorization signals connection pool exhaustion", %{conn: conn, tenant: tenant} do
+ request_events_key = Tenants.requests_per_second_key(tenant)
+ expect(GenCounter, :add, fn ^request_events_key -> :ok end)
+
+ expect(Authorization, :get_write_authorizations, fn _, _ -> {:error, :increase_connection_pool} end)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1})
+
+ assert conn.status == 429
+ assert Jason.decode!(conn.resp_body)["message"] == "Connection pool exhausted"
+ end
+
+ test "returns 422 when tenant database is unavailable", %{conn: conn, tenant: tenant} do
+ request_events_key = Tenants.requests_per_second_key(tenant)
+ expect(GenCounter, :add, fn ^request_events_key -> :ok end)
+
+ expect(Authorization, :get_write_authorizations, fn _, _ -> {:error, :tenant_database_unavailable} end)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1})
+
+ assert conn.status == 422
+ assert Jason.decode!(conn.resp_body)["message"] == "Tenant database unavailable"
+ end
+
+ test "returns 500 for unexpected authorization errors", %{conn: conn, tenant: tenant} do
+ request_events_key = Tenants.requests_per_second_key(tenant)
+ expect(GenCounter, :add, fn ^request_events_key -> :ok end)
+
+ expect(Authorization, :get_write_authorizations, fn _, _ -> {:error, "boom"} end)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1})
+
+ assert conn.status == 500
+ assert Jason.decode!(conn.resp_body)["message"] == "Unable to authorize broadcast"
+ end
+
+ test "returns 422 when tenant database is initializing", %{conn: conn, tenant: tenant} do
+ request_events_key = Tenants.requests_per_second_key(tenant)
+ expect(GenCounter, :add, fn ^request_events_key -> :ok end)
+
+ expect(Connect, :lookup_or_start_connection, fn _ -> {:error, :initializing} end)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1})
+
+ assert conn.status == 422
+ assert Jason.decode!(conn.resp_body)["message"] == "Tenant database initializing"
+ end
+
+ test "returns 422 when tenant database connection is initializing", %{conn: conn, tenant: tenant} do
+ request_events_key = Tenants.requests_per_second_key(tenant)
+ expect(GenCounter, :add, fn ^request_events_key -> :ok end)
+
+ expect(Connect, :lookup_or_start_connection, fn _ -> {:error, :tenant_database_connection_initializing} end)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1})
+
+ assert conn.status == 422
+ assert Jason.decode!(conn.resp_body)["message"] == "Tenant database connection initializing"
+ end
+
+ test "returns 422 when tenant database has too many connections", %{conn: conn, tenant: tenant} do
+ request_events_key = Tenants.requests_per_second_key(tenant)
+ expect(GenCounter, :add, fn ^request_events_key -> :ok end)
+
+ expect(Connect, :lookup_or_start_connection, fn _ -> {:error, :tenant_db_too_many_connections} end)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1})
+
+ assert conn.status == 422
+ assert Jason.decode!(conn.resp_body)["message"] == "Tenant database has too many connections"
+ end
+
+ test "returns 422 when connect rate limit is reached", %{conn: conn, tenant: tenant} do
+ request_events_key = Tenants.requests_per_second_key(tenant)
+ expect(GenCounter, :add, fn ^request_events_key -> :ok end)
+
+ expect(Connect, :lookup_or_start_connection, fn _ -> {:error, :connect_rate_limit_reached} end)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1})
+
+ assert conn.status == 422
+ assert Jason.decode!(conn.resp_body)["message"] == "Connect rate limit reached"
+ end
+
+ test "returns 500 when an RPC error occurs while looking up the connection", %{conn: conn, tenant: tenant} do
+ request_events_key = Tenants.requests_per_second_key(tenant)
+ expect(GenCounter, :add, fn ^request_events_key -> :ok end)
+
+ expect(Connect, :lookup_or_start_connection, fn _ -> {:error, :rpc_error, :timeout} end)
+
+ conn =
+ conn
+ |> put_req_header("content-type", "application/json")
+ |> post(Routes.broadcast_single_path(conn, :broadcast, "private:room", "evt") <> "?private=true", %{"a" => 1})
+
+ assert conn.status == 500
+ assert Jason.decode!(conn.resp_body)["message"] == "RPC error"
+ end
+ end
+
+ defp generate_conn(conn, tenant) do
+ now = System.system_time(:second)
+ claims = %{role: "test", iat: now, exp: now + 100_000}
+
+ conn
+ |> put_req_header("accept", "application/json")
+ |> put_req_header("authorization", "Bearer #{generate_jwt_token(tenant, claims)}")
+ |> then(&%{&1 | host: "#{tenant.external_id}.supabase.com"})
+ end
+end
diff --git a/test/realtime_web/controllers/fallback_controller_test.exs b/test/realtime_web/controllers/fallback_controller_test.exs
new file mode 100644
index 000000000..ce1684fce
--- /dev/null
+++ b/test/realtime_web/controllers/fallback_controller_test.exs
@@ -0,0 +1,76 @@
+defmodule RealtimeWeb.FallbackControllerTest do
+ use RealtimeWeb.ConnCase, async: true
+
+ import ExUnit.CaptureLog
+
+ alias RealtimeWeb.FallbackController
+
+ describe "call/2" do
+ test "returns 404 with not found message", %{conn: conn} do
+ conn = FallbackController.call(conn, {:error, :not_found})
+
+ assert json_response(conn, 404) == %{"message" => "not found"}
+ end
+
+ test "returns 422 with changeset errors", %{conn: conn} do
+ changeset =
+ {%{}, %{name: :string}}
+ |> Ecto.Changeset.cast(%{name: 123}, [:name])
+
+ conn = FallbackController.call(conn, {:error, changeset})
+
+ assert %{"errors" => _} = json_response(conn, 422)
+ end
+
+ test "returns custom status with message", %{conn: conn} do
+ conn = FallbackController.call(conn, {:error, :bad_request, "invalid input"})
+
+ assert json_response(conn, 400) == %{"message" => "invalid input"}
+ end
+
+ test "does not log UnprocessableEntity for non-422 statuses", %{conn: conn} do
+ log =
+ capture_log(fn ->
+ conn = FallbackController.call(conn, {:error, :forbidden, "Tenant is suspended"})
+
+ assert json_response(conn, 403) == %{"message" => "Tenant is suspended"}
+ end)
+
+ refute log =~ "UnprocessableEntity"
+ end
+
+ test "logs UnprocessableEntity for 422 status with message", %{conn: conn} do
+ log =
+ capture_log(fn ->
+ conn = FallbackController.call(conn, {:error, :unprocessable_entity, "invalid input"})
+
+ assert json_response(conn, 422) == %{"message" => "invalid input"}
+ end)
+
+ assert log =~ "UnprocessableEntity: invalid input"
+ end
+
+ test "returns 401 for generic error tuple", %{conn: conn} do
+ conn = FallbackController.call(conn, {:error, "something went wrong"})
+
+ assert json_response(conn, 401) == %{"message" => "Unauthorized"}
+ end
+
+ test "returns 422 for bare invalid changeset", %{conn: conn} do
+ changeset =
+ {%{}, %{name: :string}}
+ |> Ecto.Changeset.cast(%{name: 123}, [:name])
+ |> Map.put(:valid?, false)
+
+ conn = FallbackController.call(conn, changeset)
+
+ assert %{"errors" => _} = json_response(conn, 422)
+ end
+
+ test "returns 422 for unknown error format", %{conn: conn} do
+ conn = FallbackController.call(conn, :unexpected_value)
+
+ assert json_response(conn, 422) == %{"message" => "Unknown error"}
+ end
+ end
+end
diff --git a/test/realtime_web/controllers/live_dasboard_test.exs b/test/realtime_web/controllers/live_dasboard_test.exs
index 9e5c06c43..0a62d962d 100644
--- a/test/realtime_web/controllers/live_dasboard_test.exs
+++ b/test/realtime_web/controllers/live_dasboard_test.exs
@@ -1,28 +1,25 @@
defmodule RealtimeWeb.LiveDashboardTest do
use RealtimeWeb.ConnCase
import Generators
+ import Mimic
- describe "live_dashboard" do
+ describe "live_dashboard with basic_auth" do
setup do
user = random_string()
password = random_string()
- System.put_env("DASHBOARD_USER", user)
- System.put_env("DASHBOARD_PASSWORD", password)
+ Application.put_env(:realtime, :dashboard_auth, :basic_auth)
+ Application.put_env(:realtime, :dashboard_credentials, {user, password})
on_exit(fn ->
- System.delete_env("DASHBOARD_USER")
- System.delete_env("DASHBOARD_PASSWORD")
+ Application.delete_env(:realtime, :dashboard_auth)
+ Application.delete_env(:realtime, :dashboard_credentials)
end)
%{user: user, password: password}
end
- test "with credetentials renders view", %{
- conn: conn,
- user: user,
- password: password
- } do
+ test "with credentials renders view", %{conn: conn, user: user, password: password} do
path =
conn
|> using_basic_auth(user, password)
@@ -34,9 +31,42 @@ defmodule RealtimeWeb.LiveDashboardTest do
assert html_response(conn, 200) =~ "Dashboard"
end
- test "without credetentials returns 401", %{conn: conn} do
+ test "without credentials returns 401", %{conn: conn} do
assert conn |> get("/admin/dashboard") |> response(401)
end
+
+ test "with wrong credentials returns 401", %{conn: conn} do
+ assert conn |> using_basic_auth("wrong", "wrong") |> get("/admin/dashboard") |> response(401)
+ end
+ end
+
+ describe "live_dashboard with zta" do
+ setup do
+ Application.put_env(:realtime, :dashboard_auth, :zta)
+
+ on_exit(fn -> Application.delete_env(:realtime, :dashboard_auth) end)
+ end
+
+ test "with valid cf token renders view", %{conn: conn} do
+ stub(NimbleZTA.Cloudflare, :authenticate, fn _name, conn -> {conn, %{email: "user@example.com"}} end)
+
+ path = conn |> get("/admin/dashboard") |> redirected_to(302)
+ conn = conn |> recycle() |> get(path)
+
+ assert html_response(conn, 200) =~ "Dashboard"
+ end
+
+ test "without cf token returns 403", %{conn: conn} do
+ stub(NimbleZTA.Cloudflare, :authenticate, fn _name, conn -> {conn, nil} end)
+
+ assert conn |> get("/admin/dashboard") |> response(403)
+ end
+
+ test "when zta service is unavailable returns 503", %{conn: conn} do
+ stub(NimbleZTA.Cloudflare, :authenticate, fn _name, _conn -> exit(:noproc) end)
+
+ assert conn |> get("/admin/dashboard") |> response(503)
+ end
end
defp using_basic_auth(conn, username, password) do
diff --git a/test/realtime_web/controllers/metrics_controller_test.exs b/test/realtime_web/controllers/metrics_controller_test.exs
index f16edc83f..ee37b28c4 100644
--- a/test/realtime_web/controllers/metrics_controller_test.exs
+++ b/test/realtime_web/controllers/metrics_controller_test.exs
@@ -1,77 +1,284 @@
defmodule RealtimeWeb.MetricsControllerTest do
# Usage of Clustered
- # Also changing Application env
use RealtimeWeb.ConnCase, async: false
+ alias Realtime.GenRpc
import ExUnit.CaptureLog
+ use Mimic
+
+ # {help_metric, value_metric, tags}
+ # help_metric: base name checked against "# HELP " in the response
+ # value_metric: metric name passed to MetricsHelper.search (distributions use _count suffix); nil = skip value check
+ # tags: label filters for the value assertion; nil = any labels
+ @global_metrics [
+ # BEAM / OS — polling metrics, no fired events, skip value check
+ {"beam_system_schedulers_online_info", nil, nil},
+ {"osmon_ram_usage", nil, nil},
+ # Phoenix counters — populated by fire_all_tenant_events/0
+ {"phoenix_channel_joined_total", "phoenix_channel_joined_total",
+ [result: "ok", transport: "websocket", endpoint: "RealtimeWeb.Endpoint"]},
+ # Phoenix distributions — value lives under _count suffix
+ {"phoenix_channel_handled_in_duration_milliseconds", "phoenix_channel_handled_in_duration_milliseconds_count",
+ [endpoint: "RealtimeWeb.Endpoint"]},
+ {"phoenix_socket_connected_duration_milliseconds", "phoenix_socket_connected_duration_milliseconds_count",
+ [
+ result: "ok",
+ transport: "websocket",
+ endpoint: "RealtimeWeb.Endpoint",
+ serializer: "Phoenix.Socket.V2.JSONSerializer"
+ ]},
+ # Phoenix connections — polling metrics, skip value check
+ {"phoenix_connections_active", nil, nil},
+ {"phoenix_connections_max", nil, nil},
+ # GenRPC call latency — distribution, value lives under _count suffix
+ {"realtime_global_rpc", "realtime_global_rpc_count", [success: "true", mechanism: "erpc"]},
+ # Global aggregates — sums with no explicit tags (framework adds global labels)
+ {"realtime_channel_global_events", "realtime_channel_global_events", nil},
+ {"realtime_channel_global_presence_events", "realtime_channel_global_presence_events", nil},
+ {"realtime_channel_global_db_events", "realtime_channel_global_db_events", nil},
+ {"realtime_channel_global_joins", "realtime_channel_global_joins", nil},
+ {"realtime_channel_global_input_bytes", "realtime_channel_global_input_bytes", nil},
+ {"realtime_channel_global_output_bytes", "realtime_channel_global_output_bytes", nil},
+ {"realtime_channel_global_error", "realtime_channel_global_error", [code: "TestError"]},
+ # Global payload size — distribution, value lives under _count suffix
+ {"realtime_payload_size", "realtime_payload_size_count", [message_type: "broadcast"]}
+ ]
+
+ @tenant_metrics [
+ # Per-tenant channel events — sums with tenant tag
+ {"realtime_channel_events", "realtime_channel_events", [tenant: "test_tenant"]},
+ {"realtime_channel_presence_events", "realtime_channel_presence_events", [tenant: "test_tenant"]},
+ {"realtime_channel_db_events", "realtime_channel_db_events", [tenant: "test_tenant"]},
+ {"realtime_channel_joins", "realtime_channel_joins", [tenant: "test_tenant"]},
+ {"realtime_channel_input_bytes", "realtime_channel_input_bytes", [tenant: "test_tenant"]},
+ {"realtime_channel_output_bytes", "realtime_channel_output_bytes", [tenant: "test_tenant"]},
+ # Per-tenant distributions — value lives under _count suffix
+ {"realtime_tenants_payload_size", "realtime_tenants_payload_size_count",
+ [tenant: "test_tenant", message_type: "broadcast"]},
+ {"realtime_replication_poller_query_duration", "realtime_replication_poller_query_duration_count",
+ [tenant: "test_tenant"]},
+ {"realtime_tenants_read_authorization_check", "realtime_tenants_read_authorization_check_count",
+ [tenant: "test_tenant"]},
+ {"realtime_tenants_write_authorization_check", "realtime_tenants_write_authorization_check_count",
+ [tenant: "test_tenant"]},
+ {"realtime_tenants_broadcast_from_database_latency_committed_at",
+ "realtime_tenants_broadcast_from_database_latency_committed_at_count", [tenant: "test_tenant"]},
+ {"realtime_tenants_broadcast_from_database_latency_inserted_at",
+ "realtime_tenants_broadcast_from_database_latency_inserted_at_count", [tenant: "test_tenant"]},
+ {"realtime_tenants_replay", "realtime_tenants_replay_count", [tenant: "test_tenant"]},
+ # Per-tenant errors
+ {"realtime_channel_error", "realtime_channel_error", [code: "TestError", tenant: "test_tenant"]}
+ ]
+
+ # Fires every telemetry event needed to populate all event-based metrics
+ defp fire_all_tenant_events do
+ tenant_meta = %{tenant: "test_tenant"}
+
+ :telemetry.execute([:realtime, :channel, :error], %{count: 1}, %{code: "TestError", tenant: "test_tenant"})
+ :telemetry.execute([:realtime, :rate_counter, :channel, :events], %{sum: 5}, tenant_meta)
+ :telemetry.execute([:realtime, :rate_counter, :channel, :presence_events], %{sum: 3}, tenant_meta)
+ :telemetry.execute([:realtime, :rate_counter, :channel, :db_events], %{sum: 2}, tenant_meta)
+ :telemetry.execute([:realtime, :rate_counter, :channel, :joins], %{sum: 1}, tenant_meta)
+ :telemetry.execute([:realtime, :channel, :input_bytes], %{size: 1024}, tenant_meta)
+ :telemetry.execute([:realtime, :channel, :output_bytes], %{size: 2048}, tenant_meta)
+
+ :telemetry.execute(
+ [:realtime, :tenants, :payload, :size],
+ %{size: 512},
+ Map.put(tenant_meta, :message_type, "broadcast")
+ )
+
+ :telemetry.execute([:realtime, :replication, :poller, :query, :stop], %{duration: 100}, tenant_meta)
+ :telemetry.execute([:realtime, :tenants, :read_authorization_check], %{latency: 10}, tenant_meta)
+ :telemetry.execute([:realtime, :tenants, :write_authorization_check], %{latency: 15}, tenant_meta)
+
+ :telemetry.execute(
+ [:realtime, :tenants, :broadcast_from_database],
+ %{latency_committed_at: 50, latency_inserted_at: 40},
+ tenant_meta
+ )
+
+ :telemetry.execute([:realtime, :tenants, :replay], %{latency: 20}, tenant_meta)
+ :telemetry.execute([:realtime, :rpc], %{latency: 5}, %{success: true, mechanism: :erpc})
+
+ :telemetry.execute([:phoenix, :channel_joined], %{}, %{
+ result: :ok,
+ socket: %Phoenix.Socket{transport: :websocket, endpoint: RealtimeWeb.Endpoint}
+ })
+
+ :telemetry.execute([:phoenix, :channel_handled_in], %{duration: 500_000}, %{
+ socket: %Phoenix.Socket{endpoint: RealtimeWeb.Endpoint}
+ })
+
+ :telemetry.execute([:phoenix, :socket_connected], %{duration: 200_000}, %{
+ result: :ok,
+ endpoint: RealtimeWeb.Endpoint,
+ transport: :websocket,
+ serializer: Phoenix.Socket.V2.JSONSerializer
+ })
+ end
setup_all do
- {:ok, _} = Clustered.start(nil, extra_config: [{:realtime, :region, "ap-southeast-2"}])
+ metrics_tags = %{
+ region: "ap-southeast-2",
+ host: "anothernode@something.com",
+ id: "someid"
+ }
+
+ {:ok, _} =
+ Clustered.start(nil,
+ extra_config: [{:realtime, :region, "ap-southeast-2"}, {:realtime, :metrics_tags, metrics_tags}]
+ )
+
:ok
end
+ setup %{conn: conn} do
+ jwt_secret = Application.fetch_env!(:realtime, :metrics_jwt_secret)
+ token = generate_jwt_token(jwt_secret, %{})
+
+ {:ok, conn: put_req_header(conn, "authorization", "Bearer #{token}")}
+ end
+
describe "GET /metrics" do
- setup %{conn: conn} do
- # The metrics pipeline requires authentication
- jwt_secret = Application.fetch_env!(:realtime, :metrics_jwt_secret)
- token = generate_jwt_token(jwt_secret, %{})
- authenticated_conn = put_req_header(conn, "authorization", "Bearer #{token}")
+ test "contains both global and tenant metrics with values", %{conn: conn} do
+ fire_all_tenant_events()
- {:ok, conn: authenticated_conn}
- end
+ response =
+ conn
+ |> get(~p"/metrics")
+ |> text_response(200)
- test "returns 200", %{conn: conn} do
- assert response =
- conn
- |> get(~p"/metrics")
- |> text_response(200)
+ for {help_metric, value_metric, tags} <- @global_metrics do
+ assert response =~ "# HELP #{help_metric}", "expected global metric #{help_metric} to be present"
- # Check prometheus like metrics
- assert response =~
- "# HELP beam_system_schedulers_online_info The number of scheduler threads that are online."
+ if value_metric do
+ assert MetricsHelper.search(response, value_metric, tags) > 0,
+ "expected global metric #{value_metric} to have a value with tags #{inspect(tags)}"
+ end
+ end
- assert response =~ "region=\"ap-southeast-2"
- assert response =~ "region=\"us-east-1"
+ for {help_metric, value_metric, tags} <- @tenant_metrics do
+ assert response =~ "# HELP #{help_metric}", "expected tenant metric #{help_metric} to be present"
+
+ if value_metric do
+ assert MetricsHelper.search(response, value_metric, tags) > 0,
+ "expected tenant metric #{value_metric} to have a value with tags #{inspect(tags)}"
+ end
+ end
+ end
+
+ test "includes region tags from all nodes", %{conn: conn} do
+ response =
+ conn
+ |> get(~p"/metrics")
+ |> text_response(200)
+
+ assert response =~ "region=\"ap-southeast-2\""
+ assert response =~ "region=\"us-east-1\""
end
- test "returns 200 and log on timeout", %{conn: conn} do
- current_value = Application.get_env(:realtime, :metrics_rpc_timeout)
- on_exit(fn -> Application.put_env(:realtime, :metrics_rpc_timeout, current_value) end)
- Application.put_env(:realtime, :metrics_rpc_timeout, 0)
+ test "returns 200 and logs error on node timeout", %{conn: conn} do
+ Mimic.stub(GenRpc, :call, fn node, mod, func, args, opts ->
+ if node != node() do
+ {:error, :rpc_error, :timeout}
+ else
+ call_original(GenRpc, :call, [node, mod, func, args, opts])
+ end
+ end)
log =
capture_log(fn ->
- assert response =
- conn
- |> get(~p"/metrics")
- |> text_response(200)
-
- # Check prometheus like metrics
- assert response =~
- "# HELP beam_system_schedulers_online_info The number of scheduler threads that are online."
+ response =
+ conn
+ |> get(~p"/metrics")
+ |> text_response(200)
- refute response =~ "region=\"ap-southeast-2"
- assert response =~ "region=\"us-east-1"
+ refute response =~ "region=\"ap-southeast-2\""
+ assert response =~ "region=\"us-east-1\""
end)
assert log =~ "Cannot fetch metrics from the node"
end
test "returns 403 when authorization header is missing", %{conn: conn} do
- assert conn
- |> delete_req_header("authorization")
- |> get(~p"/metrics")
- |> response(403)
+ conn
+ |> delete_req_header("authorization")
+ |> get(~p"/metrics")
+ |> response(403)
end
test "returns 403 when authorization header is wrong", %{conn: conn} do
- token = generate_jwt_token("bad_secret", %{})
+ conn
+ |> put_req_header("authorization", "Bearer #{generate_jwt_token("bad_secret", %{})}")
+ |> get(~p"/metrics")
+ |> response(403)
+ end
+ end
+
+ describe "GET /metrics/:region" do
+ test "returns both global and tenant metrics with values scoped to the given region", %{conn: conn} do
+ fire_all_tenant_events()
+
+ response =
+ conn
+ |> get(~p"/metrics/us-east-1")
+ |> text_response(200)
+
+ for {help_metric, value_metric, tags} <- @global_metrics do
+ assert response =~ "# HELP #{help_metric}", "expected global metric #{help_metric} to be present"
+
+ if value_metric do
+ assert MetricsHelper.search(response, value_metric, tags) > 0,
+ "expected global metric #{value_metric} to have a value with tags #{inspect(tags)}"
+ end
+ end
+
+ for {help_metric, value_metric, tags} <- @tenant_metrics do
+ assert response =~ "# HELP #{help_metric}", "expected tenant metric #{help_metric} to be present"
+
+ if value_metric do
+ assert MetricsHelper.search(response, value_metric, tags) > 0,
+ "expected tenant metric #{value_metric} to have a value with tags #{inspect(tags)}"
+ end
+ end
+ end
+
+ test "filters metrics to the given region", %{conn: conn} do
+ response =
+ conn
+ |> get(~p"/metrics/ap-southeast-2")
+ |> text_response(200)
+
+ assert response =~ "region=\"ap-southeast-2\""
+ refute response =~ "region=\"us-east-1\""
+ end
+
+ test "returns 200 and logs error on node timeout", %{conn: conn} do
+ Mimic.stub(GenRpc, :call, fn _node, _mod, _func, _args, _opts ->
+ {:error, :rpc_error, :timeout}
+ end)
- assert _ =
- conn
- |> put_req_header("authorization", "Bearer #{token}")
- |> get(~p"/metrics")
- |> response(403)
+ log =
+ capture_log(fn ->
+ assert conn |> get(~p"/metrics/ap-southeast-2") |> text_response(200) == ""
+ end)
+
+ assert log =~ "Cannot fetch metrics from the node"
+ end
+
+ test "returns 403 when authorization header is missing", %{conn: conn} do
+ conn
+ |> delete_req_header("authorization")
+ |> get(~p"/metrics/ap-southeast-2")
+ |> response(403)
+ end
+
+ test "returns 403 when authorization header is wrong", %{conn: conn} do
+ conn
+ |> put_req_header("authorization", "Bearer #{generate_jwt_token("bad_secret", %{})}")
+ |> get(~p"/metrics/ap-southeast-2")
+ |> response(403)
end
end
end
diff --git a/test/realtime_web/controllers/page_controller_test.exs b/test/realtime_web/controllers/page_controller_test.exs
index 91fe825b5..581c46970 100644
--- a/test/realtime_web/controllers/page_controller_test.exs
+++ b/test/realtime_web/controllers/page_controller_test.exs
@@ -1,5 +1,7 @@
defmodule RealtimeWeb.PageControllerTest do
- use RealtimeWeb.ConnCase
+ use RealtimeWeb.ConnCase, async: false
+
+ import ExUnit.CaptureLog
test "GET / renders index page", %{conn: conn} do
conn = get(conn, "/")
@@ -10,4 +12,48 @@ defmodule RealtimeWeb.PageControllerTest do
conn = get(conn, "/healthcheck")
assert text_response(conn, 200) == "ok"
end
+
+ describe "GET /healthcheck logging behavior" do
+ setup do
+ original_value = Application.get_env(:realtime, :disable_healthcheck_logging, false)
+ on_exit(fn -> Application.put_env(:realtime, :disable_healthcheck_logging, original_value) end)
+ :ok
+ end
+
+ test "logs request when DISABLE_HEALTHCHECK_LOGGING is false", %{conn: conn} do
+ Application.put_env(:realtime, :disable_healthcheck_logging, false)
+
+ log =
+ capture_log(fn ->
+ conn = get(conn, "/healthcheck")
+ assert text_response(conn, 200) == "ok"
+ end)
+
+ assert log =~ "GET /healthcheck"
+ end
+
+ test "does not log request when DISABLE_HEALTHCHECK_LOGGING is true", %{conn: conn} do
+ Application.put_env(:realtime, :disable_healthcheck_logging, true)
+
+ log =
+ capture_log(fn ->
+ conn = get(conn, "/healthcheck")
+ assert text_response(conn, 200) == "ok"
+ end)
+
+ refute log =~ "GET /healthcheck"
+ end
+
+ test "logs request when DISABLE_HEALTHCHECK_LOGGING is not set (default)", %{conn: conn} do
+ Application.delete_env(:realtime, :disable_healthcheck_logging)
+
+ log =
+ capture_log(fn ->
+ conn = get(conn, "/healthcheck")
+ assert text_response(conn, 200) == "ok"
+ end)
+
+ assert log =~ "GET /healthcheck"
+ end
+ end
end
diff --git a/test/realtime_web/controllers/tenant_controller_test.exs b/test/realtime_web/controllers/tenant_controller_test.exs
index 3974e7e7b..2e6e775a5 100644
--- a/test/realtime_web/controllers/tenant_controller_test.exs
+++ b/test/realtime_web/controllers/tenant_controller_test.exs
@@ -3,6 +3,7 @@ defmodule RealtimeWeb.TenantControllerTest do
# Also using global otel_simple_processor
use RealtimeWeb.ConnCase, async: false
+ import ExUnit.CaptureLog
require OpenTelemetry.Tracer, as: Tracer
alias Realtime.Api.Tenant
@@ -48,7 +49,7 @@ defmodule RealtimeWeb.TenantControllerTest do
test "returns not found on non existing tenant", %{conn: conn} do
conn = get(conn, ~p"/api/tenants/no")
response = json_response(conn, 404)
- assert response == %{"error" => "not found"}
+ assert response == %{"message" => "not found"}
end
test "sets appropriate observability metadata", %{conn: conn, tenant: tenant} do
@@ -89,7 +90,7 @@ defmodule RealtimeWeb.TenantControllerTest do
assert Crypto.encrypt!("127.0.0.1") == settings["db_host"]
assert Crypto.encrypt!("postgres") == settings["db_name"]
- assert Crypto.encrypt!("supabase_admin") == settings["db_user"]
+ assert Crypto.encrypt!("supabase_realtime_admin") == settings["db_user"]
refute settings["db_password"]
Process.sleep(100)
@@ -118,7 +119,7 @@ defmodule RealtimeWeb.TenantControllerTest do
assert Crypto.encrypt!("127.0.0.1") == settings["db_host"]
assert Crypto.encrypt!("postgres") == settings["db_name"]
- assert Crypto.encrypt!("supabase_admin") == settings["db_user"]
+ assert Crypto.encrypt!("supabase_realtime_admin") == settings["db_user"]
refute settings["db_password"]
Process.sleep(100)
%{extensions: [%{settings: settings}]} = tenant = Tenants.get_tenant_by_external_id(external_id)
@@ -133,7 +134,7 @@ defmodule RealtimeWeb.TenantControllerTest do
test "renders tenant when data is valid", %{conn: conn, tenant: tenant} do
external_id = tenant.external_id
- port = Database.from_tenant(tenant, "realtime_test", :stop).port
+ port = tenant_db_port(tenant)
attrs = default_tenant_attrs(port)
attrs = Map.put(attrs, "external_id", external_id)
conn = post(conn, ~p"/api/tenants", tenant: attrs)
@@ -147,6 +148,46 @@ defmodule RealtimeWeb.TenantControllerTest do
assert 100 = json_response(conn, 200)["data"]["max_joins_per_second"]
end
+ test "can set max_client_presence_events_per_window", %{conn: conn, tenant: tenant} do
+ external_id = tenant.external_id
+ port = tenant_db_port(tenant)
+ attrs = default_tenant_attrs(port) |> Map.put("max_client_presence_events_per_window", 42)
+ attrs = Map.put(attrs, "external_id", external_id)
+
+ conn = post(conn, ~p"/api/tenants", tenant: attrs)
+ assert %{"max_client_presence_events_per_window" => 42} = json_response(conn, 200)["data"]
+
+ conn = get(conn, Routes.tenant_path(conn, :show, external_id))
+ assert 42 = json_response(conn, 200)["data"]["max_client_presence_events_per_window"]
+ end
+
+ test "max_client_presence_events_per_window defaults to nil", %{conn: conn, tenant: tenant} do
+ external_id = tenant.external_id
+
+ conn = get(conn, Routes.tenant_path(conn, :show, external_id))
+ assert is_nil(json_response(conn, 200)["data"]["max_client_presence_events_per_window"])
+ end
+
+ test "can set client_presence_window_ms", %{conn: conn, tenant: tenant} do
+ external_id = tenant.external_id
+ port = tenant_db_port(tenant)
+ attrs = default_tenant_attrs(port) |> Map.put("client_presence_window_ms", 5_000)
+ attrs = Map.put(attrs, "external_id", external_id)
+
+ conn = post(conn, ~p"/api/tenants", tenant: attrs)
+ assert %{"client_presence_window_ms" => 5_000} = json_response(conn, 200)["data"]
+
+ conn = get(conn, Routes.tenant_path(conn, :show, external_id))
+ assert 5_000 = json_response(conn, 200)["data"]["client_presence_window_ms"]
+ end
+
+ test "client_presence_window_ms defaults to nil", %{conn: conn, tenant: tenant} do
+ external_id = tenant.external_id
+
+ conn = get(conn, Routes.tenant_path(conn, :show, external_id))
+ assert is_nil(json_response(conn, 200)["data"]["client_presence_window_ms"])
+ end
+
test "renders errors when data is invalid", %{conn: conn} do
conn = post(conn, ~p"/api/tenants", tenant: @invalid_attrs)
assert json_response(conn, 422)["errors"] != %{}
@@ -164,7 +205,7 @@ defmodule RealtimeWeb.TenantControllerTest do
test "renders tenant when data is valid", %{tenant: tenant, conn: conn} do
external_id = tenant.external_id
- port = Database.from_tenant(tenant, "realtime_test", :stop).port
+ port = tenant_db_port(tenant)
attrs = default_tenant_attrs(port)
conn = put(conn, ~p"/api/tenants/#{external_id}", tenant: attrs)
@@ -178,6 +219,38 @@ defmodule RealtimeWeb.TenantControllerTest do
assert 100 = json_response(conn, 200)["data"]["max_joins_per_second"]
end
+ test "can update max_client_presence_events_per_window", %{tenant: tenant, conn: conn} do
+ external_id = tenant.external_id
+ port = tenant_db_port(tenant)
+ attrs = default_tenant_attrs(port) |> Map.put("max_client_presence_events_per_window", 99)
+
+ conn = put(conn, ~p"/api/tenants/#{external_id}", tenant: attrs)
+ assert %{"max_client_presence_events_per_window" => 99} = json_response(conn, 200)["data"]
+ end
+
+ test "can update client_presence_window_ms", %{tenant: tenant, conn: conn} do
+ external_id = tenant.external_id
+ port = tenant_db_port(tenant)
+ attrs = default_tenant_attrs(port) |> Map.put("client_presence_window_ms", 10_000)
+
+ conn = put(conn, ~p"/api/tenants/#{external_id}", tenant: attrs)
+ assert %{"client_presence_window_ms" => 10_000} = json_response(conn, 200)["data"]
+ end
+
+ test "can update presence_enabled", %{tenant: tenant, conn: conn} do
+ external_id = tenant.external_id
+ port = tenant_db_port(tenant)
+
+ assert tenant.presence_enabled == false
+
+ attrs = default_tenant_attrs(port) |> Map.put("presence_enabled", true)
+ conn = put(conn, ~p"/api/tenants/#{external_id}", tenant: attrs)
+ assert %{"presence_enabled" => true} = json_response(conn, 200)["data"]
+
+ updated_tenant = Realtime.Api.get_tenant_by_external_id(external_id, use_replica?: false)
+ assert updated_tenant.presence_enabled == true
+ end
+
test "renders errors when data is invalid", %{conn: conn} do
conn = put(conn, ~p"/api/tenants/#{random_string()}", tenant: @invalid_attrs)
assert json_response(conn, 422)["errors"] != %{}
@@ -191,7 +264,7 @@ defmodule RealtimeWeb.TenantControllerTest do
test "sets appropriate observability metadata", %{conn: conn, tenant: tenant} do
external_id = tenant.external_id
- port = Database.from_tenant(tenant, "realtime_test", :stop).port
+ port = tenant_db_port(tenant)
attrs = default_tenant_attrs(port)
# opentelemetry_phoenix expects to be a child of the originating cowboy process hence the Task here :shrug:
@@ -218,6 +291,7 @@ defmodule RealtimeWeb.TenantControllerTest do
{:ok, _pid} = Connect.lookup_or_start_connection(tenant.external_id)
assert Connect.ready?(tenant.external_id)
+ assert Realtime.Tenants.ReplicationConnection.ready?(tenant.external_id)
assert Cache.get_tenant_by_external_id(tenant.external_id)
{:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
@@ -291,7 +365,11 @@ defmodule RealtimeWeb.TenantControllerTest do
assert status == 204
- assert_receive :disconnect
+ assert_receive %Phoenix.Socket.Broadcast{
+ payload: %{message: "Server requested disconnect", status: "ok", extension: "system"},
+ event: "system"
+ }
+
assert_receive {:DOWN, _, :process, ^manager_pid, _}
assert_receive {:DOWN, _, :process, ^connect_pid, _}
@@ -330,12 +408,66 @@ defmodule RealtimeWeb.TenantControllerTest do
end
end
+ describe "shutdown Connect module for tenant" do
+ setup [:with_tenant]
+
+ test "shuts down Connect process when tenant exists", %{conn: conn, tenant: %{external_id: external_id}} do
+ Phoenix.PubSub.subscribe(Realtime.PubSub, "realtime:operations:" <> external_id)
+
+ {:ok, connect_pid} = Connect.lookup_or_start_connection(external_id)
+ Process.monitor(connect_pid)
+
+ assert Process.alive?(connect_pid)
+
+ %{status: status} = post(conn, ~p"/api/tenants/#{external_id}/shutdown")
+
+ assert status == 204
+ assert_receive {:DOWN, _, :process, ^connect_pid, _}
+ refute Process.alive?(connect_pid)
+ end
+
+ test "returns 204 when tenant exists but Connect is not running", %{conn: conn, tenant: %{external_id: external_id}} do
+ %{status: status} = post(conn, ~p"/api/tenants/#{external_id}/shutdown")
+ assert status == 204
+ end
+
+ test "returns 404 when tenant does not exist", %{conn: conn} do
+ %{status: status} = post(conn, ~p"/api/tenants/nope/shutdown")
+ assert status == 404
+ end
+
+ test "returns 403 when jwt is invalid", %{conn: conn, tenant: tenant} do
+ conn = put_req_header(conn, "authorization", "Bearer potato")
+ conn = post(conn, ~p"/api/tenants/#{tenant.external_id}/shutdown")
+ assert response(conn, 403) == ""
+ end
+
+ test "sets appropriate observability metadata", %{conn: conn, tenant: tenant} do
+ external_id = tenant.external_id
+
+ Tracer.with_span "test" do
+ Task.async(fn ->
+ post(conn, ~p"/api/tenants/#{tenant.external_id}/shutdown")
+
+ assert Logger.metadata()[:external_id] == external_id
+ assert Logger.metadata()[:project] == external_id
+ end)
+ |> Task.await()
+ end
+
+ assert_receive {:span, span(name: "POST /api/tenants/:tenant_id/shutdown", attributes: attributes)}
+
+ assert attributes(map: %{external_id: ^external_id}) = attributes
+ end
+ end
+
describe "health check tenant" do
setup [:with_tenant]
setup do
+ previous_region = Application.get_env(:realtime, :region)
Application.put_env(:realtime, :region, "us-east-1")
- on_exit(fn -> Application.put_env(:realtime, :region, nil) end)
+ on_exit(fn -> Application.put_env(:realtime, :region, previous_region) end)
end
test "health check when tenant does not exist", %{conn: conn} do
@@ -354,13 +486,14 @@ defmodule RealtimeWeb.TenantControllerTest do
assert %{
"healthy" => true,
"db_connected" => false,
+ "replication_connected" => false,
"connected_cluster" => 0,
"region" => "us-east-1",
"node" => "#{node()}"
} == data
end
- test "unhealthy tenant with 1 client connections", %{
+ test "unhealthy tenant with 1 client connections and no db connection", %{
conn: conn,
tenant: %Tenant{external_id: ext_id}
} do
@@ -374,19 +507,23 @@ defmodule RealtimeWeb.TenantControllerTest do
assert %{
"healthy" => false,
"db_connected" => false,
+ "replication_connected" => false,
"connected_cluster" => 1,
"region" => "us-east-1",
"node" => "#{node()}"
} == data
end
- test "healthy tenant with 1 client connection", %{conn: conn, tenant: %Tenant{external_id: ext_id}} do
+ test "healthy tenant with db connection but no replication connection", %{
+ conn: conn,
+ tenant: %Tenant{external_id: ext_id}
+ } do
{:ok, db_conn} = Connect.lookup_or_start_connection(ext_id)
# Fake adding a connected client here
UsersCounter.add(self(), ext_id)
- # Fake a db connection
- :syn.register(Realtime.Tenants.Connect, ext_id, self(), %{conn: nil})
+ # Fake a db connection without replication (replication_conn: nil)
+ :syn.register(Realtime.Tenants.Connect, ext_id, self(), %{conn: nil, region: "us-east-1", replication_conn: nil})
:syn.update_registry(Realtime.Tenants.Connect, ext_id, fn _pid, meta ->
%{meta | conn: db_conn}
@@ -398,6 +535,32 @@ defmodule RealtimeWeb.TenantControllerTest do
assert %{
"healthy" => true,
"db_connected" => true,
+ "replication_connected" => false,
+ "connected_cluster" => 1,
+ "region" => "us-east-1",
+ "node" => "#{node()}"
+ } == data
+ end
+
+ test "healthy tenant with db and replication connection", %{conn: conn, tenant: %Tenant{external_id: ext_id}} do
+ {:ok, db_conn} = Connect.lookup_or_start_connection(ext_id)
+ # Fake adding a connected client here
+ UsersCounter.add(self(), ext_id)
+
+ # Fake a db connection with replication_conn in syn metadata
+ :syn.register(Realtime.Tenants.Connect, ext_id, self(), %{conn: nil, region: "us-east-1", replication_conn: nil})
+
+ :syn.update_registry(Realtime.Tenants.Connect, ext_id, fn _pid, meta ->
+ %{meta | conn: db_conn, replication_conn: self()}
+ end)
+
+ conn = get(conn, ~p"/api/tenants/#{ext_id}/health")
+ data = json_response(conn, 200)["data"]
+
+ assert %{
+ "healthy" => true,
+ "db_connected" => true,
+ "replication_connected" => true,
"connected_cluster" => 1,
"region" => "us-east-1",
"node" => "#{node()}"
@@ -410,19 +573,27 @@ defmodule RealtimeWeb.TenantControllerTest do
assert response(conn, 403) == ""
end
- test "runs migrations", %{conn: conn} do
+ test "triggers migrations without blocking and self heals eventually", %{conn: conn} do
tenant = Containers.checkout_tenant(run_migrations: false)
{:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
assert {:error, _} = Postgrex.query(db_conn, "SELECT * FROM realtime.messages", [])
conn = get(conn, ~p"/api/tenants/#{tenant.external_id}/health")
- data = json_response(conn, 200)["data"]
- Process.sleep(2000)
+
+ assert %{"healthy" => false, "db_connected" => false, "replication_connected" => false, "connected_cluster" => 0} =
+ json_response(conn, 200)["data"]
+
+ assert eventually(fn ->
+ match?({:ok, %{healthy: true}}, Realtime.Tenants.health_check(tenant.external_id))
+ end)
assert {:ok, %{rows: []}} = Postgrex.query(db_conn, "SELECT * FROM realtime.messages", [])
- assert %{"healthy" => true, "db_connected" => false, "connected_cluster" => 0} = data
+ conn = get(conn, ~p"/api/tenants/#{tenant.external_id}/health")
+
+ assert %{"healthy" => true, "db_connected" => false, "replication_connected" => false, "connected_cluster" => 0} =
+ json_response(conn, 200)["data"]
end
test "sets appropriate observability metadata", %{conn: conn, tenant: tenant} do
@@ -442,6 +613,51 @@ defmodule RealtimeWeb.TenantControllerTest do
assert attributes(map: %{external_id: ^external_id}) = attributes
end
+
+ test "logs request when DISABLE_HEALTHCHECK_LOGGING is false", %{conn: conn, tenant: tenant} do
+ original_value = Application.get_env(:realtime, :disable_healthcheck_logging, false)
+ Application.put_env(:realtime, :disable_healthcheck_logging, false)
+ on_exit(fn -> Application.put_env(:realtime, :disable_healthcheck_logging, original_value) end)
+
+ log =
+ capture_log(fn ->
+ conn = get(conn, ~p"/api/tenants/#{tenant.external_id}/health")
+ assert json_response(conn, 200)
+ end)
+
+ assert log =~ "GET /api/tenants"
+ assert log =~ "/health"
+ end
+
+ test "does not log request when DISABLE_HEALTHCHECK_LOGGING is true", %{conn: conn, tenant: tenant} do
+ original_value = Application.get_env(:realtime, :disable_healthcheck_logging, false)
+ Application.put_env(:realtime, :disable_healthcheck_logging, true)
+ on_exit(fn -> Application.put_env(:realtime, :disable_healthcheck_logging, original_value) end)
+
+ log =
+ capture_log(fn ->
+ conn = get(conn, ~p"/api/tenants/#{tenant.external_id}/health")
+ assert json_response(conn, 200)
+ end)
+
+ refute log =~ "GET /api/tenants"
+ refute log =~ "/health"
+ end
+
+ test "logs request when DISABLE_HEALTHCHECK_LOGGING is not set (default)", %{conn: conn, tenant: tenant} do
+ original_value = Application.get_env(:realtime, :disable_healthcheck_logging, false)
+ Application.delete_env(:realtime, :disable_healthcheck_logging)
+ on_exit(fn -> Application.put_env(:realtime, :disable_healthcheck_logging, original_value) end)
+
+ log =
+ capture_log(fn ->
+ conn = get(conn, ~p"/api/tenants/#{tenant.external_id}/health")
+ assert json_response(conn, 200)
+ end)
+
+ assert log =~ "GET /api/tenants"
+ assert log =~ "/health"
+ end
end
defp default_tenant_attrs(port) do
@@ -452,7 +668,7 @@ defmodule RealtimeWeb.TenantControllerTest do
"settings" => %{
"db_host" => "127.0.0.1",
"db_name" => "postgres",
- "db_user" => "supabase_admin",
+ "db_user" => "supabase_realtime_admin",
"db_password" => "postgres",
"db_port" => "#{port}",
"poll_interval" => 100,
@@ -484,4 +700,9 @@ defmodule RealtimeWeb.TenantControllerTest do
wait_on_postgres_cdc_rls(external_id, attempt - 1)
end
end
+
+ defp tenant_db_port(tenant) do
+ {:ok, settings} = Database.from_tenant(tenant, "realtime_test", :stop)
+ settings.port
+ end
end
diff --git a/test/realtime_web/dashboard/tenant_info_test.exs b/test/realtime_web/dashboard/tenant_info_test.exs
new file mode 100644
index 000000000..26ff89efd
--- /dev/null
+++ b/test/realtime_web/dashboard/tenant_info_test.exs
@@ -0,0 +1,87 @@
+defmodule RealtimeWeb.Dashboard.TenantInfoTest do
+ use RealtimeWeb.ConnCase
+ import Phoenix.LiveViewTest
+
+ setup do
+ Application.put_env(:realtime, :dashboard_auth, :basic_auth)
+ Application.put_env(:realtime, :dashboard_credentials, {"user", "pass"})
+
+ on_exit(fn ->
+ Application.delete_env(:realtime, :dashboard_auth)
+ Application.delete_env(:realtime, :dashboard_credentials)
+ end)
+
+ tenant = Containers.checkout_tenant(run_migrations: true)
+ conn = using_basic_auth(build_conn(), "user", "pass")
+
+ %{tenant: tenant, conn: conn}
+ end
+
+ test "renders lookup form", %{conn: conn} do
+ {:ok, _view, html} = live(conn, "/admin/dashboard/tenant_info")
+
+ assert html =~ "Tenant Info"
+ assert html =~ "external_id"
+ end
+
+ test "shows tenant info for valid external_id via URL param", %{conn: conn, tenant: tenant} do
+ {:ok, _view, html} = live(conn, "/admin/dashboard/tenant_info?external_id=#{tenant.external_id}")
+
+ assert html =~ tenant.external_id
+ assert html =~ tenant.name
+ assert html =~ "postgres_cdc_rls"
+ end
+
+ test "shows tenant info for valid external_id via form submit", %{conn: conn, tenant: tenant} do
+ {:ok, view, _html} = live(conn, "/admin/dashboard/tenant_info")
+
+ html = view |> element("form[phx-submit='lookup']") |> render_submit(%{external_id: tenant.external_id})
+
+ assert html =~ tenant.external_id
+ assert html =~ tenant.name
+ assert html =~ "postgres_cdc_rls"
+ end
+
+ test "shows error for unknown external_id via URL param", %{conn: conn} do
+ {:ok, _view, html} = live(conn, "/admin/dashboard/tenant_info?external_id=nonexistent")
+
+ assert html =~ "Tenant not found"
+ end
+
+ test "shows error for unknown external_id via form submit", %{conn: conn} do
+ {:ok, view, _html} = live(conn, "/admin/dashboard/tenant_info")
+
+ html = view |> element("form[phx-submit='lookup']") |> render_submit(%{external_id: "nonexistent"})
+
+ assert html =~ "Tenant not found"
+ end
+
+ test "does not show db_password", %{conn: conn, tenant: tenant} do
+ {:ok, _view, html} = live(conn, "/admin/dashboard/tenant_info?external_id=#{tenant.external_id}")
+
+ refute html =~ "db_password"
+ end
+
+ test "does not show db_pass_realtime", %{conn: conn, tenant: tenant} do
+ {:ok, _view, html} = live(conn, "/admin/dashboard/tenant_info?external_id=#{tenant.external_id}")
+
+ refute html =~ "db_pass_realtime"
+ end
+
+ test "shows decrypted db_host", %{conn: conn, tenant: tenant} do
+ {:ok, _view, html} = live(conn, "/admin/dashboard/tenant_info?external_id=#{tenant.external_id}")
+
+ assert html =~ "127.0.0.1"
+ end
+
+ test "shows resolved db_host", %{conn: conn, tenant: tenant} do
+ {:ok, _view, html} = live(conn, "/admin/dashboard/tenant_info?external_id=#{tenant.external_id}")
+
+ assert html =~ "db_host_resolved"
+ end
+
+ defp using_basic_auth(conn, username, password) do
+ header_content = "Basic " <> Base.encode64("#{username}:#{password}")
+ put_req_header(conn, "authorization", header_content)
+ end
+end
diff --git a/test/realtime_web/dashboard/tenant_migrations_test.exs b/test/realtime_web/dashboard/tenant_migrations_test.exs
new file mode 100644
index 000000000..4fc61b6c5
--- /dev/null
+++ b/test/realtime_web/dashboard/tenant_migrations_test.exs
@@ -0,0 +1,183 @@
+defmodule RealtimeWeb.Dashboard.TenantMigrationsTest do
+ use RealtimeWeb.ConnCase, async: false
+ import Phoenix.LiveViewTest
+
+ alias Realtime.Api
+ alias Realtime.Database
+ alias Realtime.Tenants.Migrations
+ alias RealtimeWeb.Dashboard.TenantMigrations
+
+ setup do
+ Application.put_env(:realtime, :dashboard_auth, :basic_auth)
+ Application.put_env(:realtime, :dashboard_credentials, {"user", "pass"})
+
+ on_exit(fn ->
+ Application.delete_env(:realtime, :dashboard_auth)
+ Application.delete_env(:realtime, :dashboard_credentials)
+ end)
+
+ tenant = Containers.checkout_tenant(run_migrations: true)
+ conn = using_basic_auth(build_conn(), "user", "pass")
+
+ %{tenant: tenant, conn: conn}
+ end
+
+ test "renders lookup form", %{conn: conn} do
+ {:ok, view, _html} = live(conn, "/admin/dashboard/tenant_migrations")
+
+ assert has_element?(view, "h5.card-title", "Tenant Migrations")
+ assert has_element?(view, "input[name=external_id]")
+ assert has_element?(view, "button[type=submit]", "Lookup")
+ end
+
+ test "shows schema_migrations for valid external_id via URL param", %{conn: conn, tenant: tenant} do
+ {:ok, view, _html} = live(conn, "/admin/dashboard/tenant_migrations?external_id=#{tenant.external_id}")
+
+ assert has_element?(view, "h6", "realtime.schema_migrations")
+ assert has_element?(view, "th", "version")
+ assert has_element?(view, "th", "inserted_at")
+ end
+
+ test "shows schema_migrations for valid external_id via form submit", %{conn: conn, tenant: tenant} do
+ {:ok, view, _html} = live(conn, "/admin/dashboard/tenant_migrations")
+
+ view
+ |> element("form[phx-submit=lookup]")
+ |> render_submit(%{external_id: tenant.external_id})
+
+ assert has_element?(view, "h6", "realtime.schema_migrations")
+ assert has_element?(view, "th", "version")
+ end
+
+ test "shows error for unknown external_id via URL param", %{conn: conn} do
+ {:ok, view, _html} = live(conn, "/admin/dashboard/tenant_migrations?external_id=nonexistent")
+
+ assert has_element?(view, "p.text-danger", "Tenant not found")
+ end
+
+ test "shows error for unknown external_id via form submit", %{conn: conn} do
+ {:ok, view, _html} = live(conn, "/admin/dashboard/tenant_migrations")
+
+ view
+ |> element("form[phx-submit=lookup]")
+ |> render_submit(%{external_id: "nonexistent"})
+
+ assert has_element?(view, "p.text-danger", "Tenant not found")
+ end
+
+ test "renders pg-delta section header when tenant is found", %{conn: conn, tenant: tenant} do
+ {:ok, view, _html} = live(conn, "/admin/dashboard/tenant_migrations?external_id=#{tenant.external_id}")
+
+ assert has_element?(view, "h6", "pg-delta plan vs catalog")
+ end
+
+ describe "backfill_schema_migrations/1" do
+ test "inserts missing versions and updates tenants.migrations_ran", %{tenant: tenant} do
+ {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
+
+ Postgrex.query!(
+ db_conn,
+ "DELETE FROM realtime.schema_migrations WHERE version > 20211116213934",
+ []
+ )
+
+ {:ok, _} = Api.update_migrations_ran(tenant.external_id, 7)
+
+ assert :ok = TenantMigrations.backfill_schema_migrations(tenant)
+
+ %{rows: [[count]]} =
+ Postgrex.query!(db_conn, "SELECT count(*)::int FROM realtime.schema_migrations", [])
+
+ total = length(Migrations.migrations())
+ assert count == total
+
+ updated = Api.get_tenant_by_external_id(tenant.external_id, use_replica?: false)
+ assert updated.migrations_ran == total
+ end
+
+ test "running twice keeps the row count and migrations_ran stable", %{tenant: tenant} do
+ {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
+ total = length(Migrations.migrations())
+
+ assert :ok = TenantMigrations.backfill_schema_migrations(tenant)
+ assert :ok = TenantMigrations.backfill_schema_migrations(tenant)
+
+ %{rows: [[count]]} =
+ Postgrex.query!(db_conn, "SELECT count(*)::int FROM realtime.schema_migrations", [])
+
+ assert count == total
+
+ updated = Api.get_tenant_by_external_id(tenant.external_id, use_replica?: false)
+ assert updated.migrations_ran == total
+ end
+ end
+
+ describe "apply_pg_delta/2" do
+ test "runs the sql plan and backfills schema_migrations", %{tenant: tenant} do
+ {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
+
+ Postgrex.query!(
+ db_conn,
+ "DELETE FROM realtime.schema_migrations WHERE version > 20211116213934",
+ []
+ )
+
+ {:ok, _} = Api.update_migrations_ran(tenant.external_id, 7)
+
+ assert :ok = TenantMigrations.apply_pg_delta(tenant, "SELECT 1")
+
+ %{rows: [[count]]} =
+ Postgrex.query!(db_conn, "SELECT count(*)::int FROM realtime.schema_migrations", [])
+
+ total = length(Migrations.migrations())
+ assert count == total
+
+ updated = Api.get_tenant_by_external_id(tenant.external_id, use_replica?: false)
+ assert updated.migrations_ran == total
+ end
+ end
+
+ describe "postgres_url/1" do
+ test "builds a valid URL for IPv4 hosts" do
+ assert TenantMigrations.postgres_url(%Database{
+ hostname: "db.example.com",
+ port: 5432,
+ database: "postgres",
+ username: "supabase_admin",
+ password: "s3cr3t",
+ socket_options: [:inet],
+ ssl: true
+ }) == "postgresql://supabase_admin:s3cr3t@db.example.com:5432/postgres?sslmode=require"
+ end
+
+ test "builds a valid URL for IPv6 hosts" do
+ assert TenantMigrations.postgres_url(%Database{
+ hostname: "2600:1f14:359d:9302:205d:38ca:a017:c7e3",
+ port: 5432,
+ database: "postgres",
+ username: "supabase_admin",
+ password: "s3cr3t",
+ socket_options: [:inet6],
+ ssl: true
+ }) ==
+ "postgresql://supabase_admin:s3cr3t@[2600:1f14:359d:9302:205d:38ca:a017:c7e3]:5432/postgres?sslmode=require"
+ end
+
+ test "builds a valid URL for DNS hostnames resolved over IPv6" do
+ assert TenantMigrations.postgres_url(%Database{
+ hostname: "db.example.com",
+ port: 5432,
+ database: "postgres",
+ username: "supabase_admin",
+ password: "s3cr3t",
+ socket_options: [:inet6],
+ ssl: true
+ }) == "postgresql://supabase_admin:s3cr3t@db.example.com:5432/postgres?sslmode=require"
+ end
+ end
+
+ defp using_basic_auth(conn, username, password) do
+ header_content = "Basic " <> Base.encode64("#{username}:#{password}")
+ put_req_header(conn, "authorization", header_content)
+ end
+end
diff --git a/test/realtime_web/live/feature_flags_live/index_test.exs b/test/realtime_web/live/feature_flags_live/index_test.exs
new file mode 100644
index 000000000..41c89ec61
--- /dev/null
+++ b/test/realtime_web/live/feature_flags_live/index_test.exs
@@ -0,0 +1,160 @@
+defmodule RealtimeWeb.FeatureFlagsLive.IndexTest do
+ use RealtimeWeb.ConnCase, async: false
+ import Phoenix.LiveViewTest
+
+ alias Realtime.Api
+ alias Realtime.Api.FeatureFlag
+ alias Realtime.FeatureFlags.Cache
+ alias RealtimeWeb.Endpoint
+
+ setup do
+ user = random_string()
+ password = random_string()
+
+ Application.put_env(:realtime, :dashboard_auth, :basic_auth)
+ Application.put_env(:realtime, :dashboard_credentials, {user, password})
+
+ on_exit(fn ->
+ Application.delete_env(:realtime, :dashboard_auth)
+ Application.delete_env(:realtime, :dashboard_credentials)
+ Cachex.clear(Cache)
+ end)
+
+ Cachex.clear(Cache)
+
+ %{user: user, password: password}
+ end
+
+ describe "auth" do
+ test "renders feature flags page with valid credentials", %{conn: conn, user: user, password: password} do
+ {:ok, _view, html} =
+ conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags")
+
+ assert html =~ "Feature Flags"
+ end
+
+ test "returns 401 without credentials", %{conn: conn} do
+ assert conn |> get(~p"/admin/feature-flags") |> response(401)
+ end
+
+ test "returns 401 with wrong credentials", %{conn: conn} do
+ assert conn |> using_basic_auth("wrong", "wrong") |> get(~p"/admin/feature-flags") |> response(401)
+ end
+ end
+
+ describe "mount" do
+ test "lists existing flags ordered by name", %{conn: conn, user: user, password: password} do
+ {:ok, _alpha} = Api.upsert_feature_flag(%{name: "alpha_flag", enabled: true})
+ {:ok, _zeta} = Api.upsert_feature_flag(%{name: "zeta_flag", enabled: false})
+
+ {:ok, _view, html} =
+ conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags")
+
+ assert html =~ "alpha_flag"
+ assert html =~ "zeta_flag"
+ end
+ end
+
+ describe "create event" do
+ test "adds a new flag to the list and persists it", %{conn: conn, user: user, password: password} do
+ flag_name = "created_#{random_string()}"
+
+ {:ok, view, _} = conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags")
+
+ html = view |> form("form[phx-submit=create]", name: flag_name) |> render_submit()
+
+ assert html =~ flag_name
+ assert %FeatureFlag{enabled: false} = Api.get_feature_flag(flag_name)
+ end
+
+ test "trims whitespace from the new flag name", %{conn: conn, user: user, password: password} do
+ flag_name = "trim_#{random_string()}"
+
+ {:ok, view, _} = conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags")
+
+ _ = view |> form("form[phx-submit=create]", name: " #{flag_name} ") |> render_submit()
+
+ assert %FeatureFlag{name: ^flag_name} = Api.get_feature_flag(flag_name)
+ end
+
+ test "does not create a flag when name is empty", %{conn: conn, user: user, password: password} do
+ {:ok, view, _} = conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags")
+
+ flags_before = Api.list_feature_flags() |> length()
+
+ _ = view |> form("form[phx-submit=create]", name: "") |> render_submit()
+
+ assert Api.list_feature_flags() |> length() == flags_before
+ end
+ end
+
+ describe "toggle event" do
+ test "flips the enabled state and persists", %{conn: conn, user: user, password: password} do
+ flag_name = "toggle_#{random_string()}"
+ {:ok, flag} = Api.upsert_feature_flag(%{name: flag_name, enabled: false})
+
+ {:ok, view, _} = conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags")
+
+ view |> element("button[phx-click=toggle][phx-value-id='#{flag.id}']") |> render_click()
+
+ assert %FeatureFlag{enabled: true} = Api.get_feature_flag(flag_name)
+ end
+ end
+
+ describe "delete event" do
+ test "removes the flag from the list and DB", %{conn: conn, user: user, password: password} do
+ flag_name = "delete_#{random_string()}"
+ {:ok, flag} = Api.upsert_feature_flag(%{name: flag_name, enabled: false})
+
+ {:ok, view, _} = conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags")
+
+ html = view |> element("button[phx-click=delete][phx-value-id='#{flag.id}']") |> render_click()
+
+ refute html =~ flag_name
+ refute Api.get_feature_flag(flag_name)
+ end
+ end
+
+ describe "broadcasts" do
+ test "adds a new flag when an 'updated' broadcast arrives for an unseen flag",
+ %{conn: conn, user: user, password: password} do
+ {:ok, view, _} = conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags")
+
+ remote = %FeatureFlag{id: Ecto.UUID.generate(), name: "remote_#{random_string()}", enabled: true}
+ Endpoint.broadcast("feature_flags", "updated", remote)
+
+ assert render(view) =~ remote.name
+ end
+
+ test "updates an existing flag when an 'updated' broadcast arrives",
+ %{conn: conn, user: user, password: password} do
+ flag_name = "broadcast_#{random_string()}"
+ {:ok, flag} = Api.upsert_feature_flag(%{name: flag_name, enabled: false})
+
+ {:ok, view, html} = conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags")
+ assert html =~ "Disabled"
+
+ Endpoint.broadcast("feature_flags", "updated", %{flag | enabled: true})
+
+ assert render(view) =~ "Enabled"
+ end
+
+ test "removes a flag when a 'deleted' broadcast arrives",
+ %{conn: conn, user: user, password: password} do
+ flag_name = "broadcast_delete_#{random_string()}"
+ {:ok, _flag} = Api.upsert_feature_flag(%{name: flag_name, enabled: false})
+
+ {:ok, view, html} = conn |> using_basic_auth(user, password) |> live(~p"/admin/feature-flags")
+ assert html =~ flag_name
+
+ Endpoint.broadcast("feature_flags", "deleted", %{name: flag_name})
+
+ refute render(view) =~ flag_name
+ end
+ end
+
+ defp using_basic_auth(conn, username, password) do
+ header_content = "Basic " <> Base.encode64("#{username}:#{password}")
+ put_req_header(conn, "authorization", header_content)
+ end
+end
diff --git a/test/realtime_web/live/status_live/index_test.exs b/test/realtime_web/live/status_live/index_test.exs
new file mode 100644
index 000000000..ae3af0ad0
--- /dev/null
+++ b/test/realtime_web/live/status_live/index_test.exs
@@ -0,0 +1,33 @@
+defmodule RealtimeWeb.StatusLive.IndexTest do
+ use RealtimeWeb.ConnCase
+ import Phoenix.LiveViewTest
+
+ alias Realtime.Latency.Payload
+ alias Realtime.Nodes
+ alias RealtimeWeb.Endpoint
+
+ describe "Status LiveView" do
+ test "renders status page", %{conn: conn} do
+ {:ok, _view, html} = live(conn, ~p"/status")
+
+ assert html =~ "Realtime Status"
+ end
+
+ test "receives broadcast from PubSub", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/status")
+
+ payload = %Payload{
+ from_node: Nodes.short_node_id_from_name(:"pink@127.0.0.1"),
+ node: Nodes.short_node_id_from_name(:"orange@127.0.0.1"),
+ latency: "42ms",
+ timestamp: DateTime.utc_now()
+ }
+
+ Endpoint.broadcast("admin:cluster", "ping", payload)
+
+ html = render(view)
+ assert html =~ "42ms"
+ assert html =~ "pink@127.0.0.1_orange@127.0.0.1"
+ end
+ end
+end
diff --git a/test/realtime_web/live/tenants_live/index_test.exs b/test/realtime_web/live/tenants_live/index_test.exs
index 71faa9cee..844c66be9 100644
--- a/test/realtime_web/live/tenants_live/index_test.exs
+++ b/test/realtime_web/live/tenants_live/index_test.exs
@@ -1,18 +1,20 @@
defmodule RealtimeWeb.TenantsLive.IndexTest do
use RealtimeWeb.ConnCase
import Phoenix.LiveViewTest
+ import Generators
+ import Mimic
- describe "TenantsLive Index" do
+ describe "TenantsLive Index with basic_auth" do
setup do
user = random_string()
password = random_string()
- System.put_env("DASHBOARD_USER", user)
- System.put_env("DASHBOARD_PASSWORD", password)
+ Application.put_env(:realtime, :dashboard_auth, :basic_auth)
+ Application.put_env(:realtime, :dashboard_credentials, {user, password})
on_exit(fn ->
- System.delete_env("DASHBOARD_USER")
- System.delete_env("DASHBOARD_PASSWORD")
+ Application.delete_env(:realtime, :dashboard_auth)
+ Application.delete_env(:realtime, :dashboard_credentials)
end)
%{user: user, password: password}
@@ -28,6 +30,38 @@ defmodule RealtimeWeb.TenantsLive.IndexTest do
test "returns 401 if no credentials", %{conn: conn} do
assert conn |> get(~p"/admin/tenants") |> response(401)
end
+
+ test "returns 401 with wrong credentials", %{conn: conn} do
+ assert conn |> using_basic_auth("wrong", "wrong") |> get(~p"/admin/tenants") |> response(401)
+ end
+ end
+
+ describe "TenantsLive Index with zta" do
+ setup do
+ Application.put_env(:realtime, :dashboard_auth, :zta)
+
+ on_exit(fn -> Application.delete_env(:realtime, :dashboard_auth) end)
+ end
+
+ test "renders tenant view with valid cf token", %{conn: conn} do
+ stub(NimbleZTA.Cloudflare, :authenticate, fn _name, conn -> {conn, %{email: "user@example.com"}} end)
+
+ {:ok, _view, html} = live(conn, ~p"/admin/tenants")
+
+ assert html =~ "Listing all Supabase Realtime tenants."
+ end
+
+ test "returns 403 without cf token", %{conn: conn} do
+ stub(NimbleZTA.Cloudflare, :authenticate, fn _name, conn -> {conn, nil} end)
+
+ assert conn |> get(~p"/admin/tenants") |> response(403)
+ end
+
+ test "returns 503 when zta service is unavailable", %{conn: conn} do
+ stub(NimbleZTA.Cloudflare, :authenticate, fn _name, _conn -> exit(:noproc) end)
+
+ assert conn |> get(~p"/admin/tenants") |> response(503)
+ end
end
defp using_basic_auth(conn, username, password) do
diff --git a/test/realtime_web/plugs/assign_tenant_test.exs b/test/realtime_web/plugs/assign_tenant_test.exs
index 536d7a548..102c8c6a1 100644
--- a/test/realtime_web/plugs/assign_tenant_test.exs
+++ b/test/realtime_web/plugs/assign_tenant_test.exs
@@ -15,7 +15,7 @@ defmodule RealtimeWeb.Plugs.AssignTenantTest do
"settings" => %{
"db_host" => "127.0.0.1",
"db_name" => "postgres",
- "db_user" => "supabase_admin",
+ "db_user" => "supabase_realtime_admin",
"db_password" => "postgres",
"db_port" => "6432",
"poll_interval" => 100,
diff --git a/test/realtime_web/plugs/auth_tenant_test.exs b/test/realtime_web/plugs/auth_tenant_test.exs
index ea75d5eb1..e993a99f9 100644
--- a/test/realtime_web/plugs/auth_tenant_test.exs
+++ b/test/realtime_web/plugs/auth_tenant_test.exs
@@ -5,7 +5,6 @@ defmodule RealtimeWeb.AuthTenantTest do
alias RealtimeWeb.AuthTenant
- @token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1MTYyMzkwMjIsInJvbGUiOiJmb28iLCJleHAiOiJiYXIifQ.Ret2CevUozCsPhpgW2FMeFL7RooLgoOvfQzNpLBj5ak"
describe "without tenant" do
test "returns 401", %{conn: conn} do
conn = AuthTenant.call(conn, %{})
@@ -16,13 +15,23 @@ defmodule RealtimeWeb.AuthTenantTest do
describe "with tenant" do
setup %{conn: conn} = context do
- api_key = Map.get(context, :api_key)
+ tenant = tenant_fixture()
+ now = System.system_time(:second)
+ token = generate_jwt_token(tenant, %{role: "test", iat: now, exp: now + 100_000})
+
header = Map.get(context, :header)
- conn = if api_key, do: put_req_header(conn, header, api_key), else: conn
+ api_key =
+ cond do
+ literal = Map.get(context, :api_key) -> literal
+ header -> Map.get(context, :prefix, "Bearer ") <> token
+ true -> nil
+ end
+
+ conn = if header && api_key, do: put_req_header(conn, header, api_key), else: conn
- conn = assign(conn, :tenant, tenant_fixture())
- %{conn: conn}
+ conn = assign(conn, :tenant, tenant)
+ %{conn: conn, token: token}
end
test "returns 401 if token isn't present in header", %{conn: conn} do
@@ -38,7 +47,7 @@ defmodule RealtimeWeb.AuthTenantTest do
assert conn.halted
end
- @tag api_key: "Bearer #{@token}", header: "authorization"
+ @tag header: "authorization"
test "returns non halted and null status if token in authorization header is valid", %{
conn: conn
} do
@@ -47,7 +56,7 @@ defmodule RealtimeWeb.AuthTenantTest do
refute conn.halted
end
- @tag api_key: "bearer #{@token}", header: "authorization"
+ @tag header: "authorization", prefix: "bearer "
test "returns non halted and null status if token in authorization header is valid and case insensitive",
%{
conn: conn
@@ -57,7 +66,7 @@ defmodule RealtimeWeb.AuthTenantTest do
refute conn.halted
end
- @tag api_key: "earer #{@token}", header: "authorization"
+ @tag api_key: "earer invalid", header: "authorization"
test "returns halted and unauthorized if token is badly formatted", %{
conn: conn
} do
@@ -73,7 +82,7 @@ defmodule RealtimeWeb.AuthTenantTest do
assert conn.halted
end
- @tag api_key: @token, header: "apikey"
+ @tag header: "apikey", prefix: ""
test "returns non halted and null status if token in apikey header is valid", %{
conn: conn
} do
@@ -82,14 +91,13 @@ defmodule RealtimeWeb.AuthTenantTest do
refute conn.halted
end
- @tag api_key: "Bearer #{@token}", header: "authorization"
- test "assigns jwt information on success", %{
- conn: conn
- } do
+ @tag header: "authorization"
+ test "assigns jwt information on success", %{conn: conn, token: token} do
conn = AuthTenant.call(conn, %{})
- assert conn.assigns.jwt == @token
- assert conn.assigns.claims == %{"exp" => "bar", "iat" => 1_516_239_022, "role" => "foo"}
- assert conn.assigns.role == "foo"
+ assert conn.assigns.jwt == token
+ assert conn.assigns.role == "test"
+ assert %{"exp" => exp, "iat" => iat, "role" => "test"} = conn.assigns.claims
+ assert is_integer(exp) and is_integer(iat)
end
end
end
diff --git a/test/realtime_web/plugs/parsers/octet_stream_test.exs b/test/realtime_web/plugs/parsers/octet_stream_test.exs
new file mode 100644
index 000000000..da8957366
--- /dev/null
+++ b/test/realtime_web/plugs/parsers/octet_stream_test.exs
@@ -0,0 +1,125 @@
+defmodule RealtimeWeb.Plugs.Parsers.OctetStreamTest do
+ use ExUnit.Case, async: true
+ import Plug.Test
+ import Plug.Conn
+
+ alias RealtimeWeb.Plugs.Parsers.OctetStream
+
+ defmodule TimeoutReader do
+ def read_body(_conn, _opts), do: {:error, :timeout}
+ end
+
+ defmodule ErrorReader do
+ def read_body(_conn, _opts), do: {:error, :closed}
+ end
+
+ describe "init/1" do
+ test "defaults the body reader to Plug.Conn.read_body/2" do
+ assert {{Plug.Conn, :read_body, []}, opts} = OctetStream.init([])
+ assert opts == []
+ end
+
+ test "passes other opts through untouched" do
+ assert {{Plug.Conn, :read_body, []}, opts} = OctetStream.init(length: 42)
+ assert Keyword.get(opts, :length) == 42
+ end
+
+ test "pops :body_reader out of opts" do
+ reader = {TimeoutReader, :read_body, []}
+ assert {^reader, opts} = OctetStream.init(body_reader: reader, length: 10)
+ refute Keyword.has_key?(opts, :body_reader)
+ assert Keyword.get(opts, :length) == 10
+ end
+ end
+
+ describe "parse/5" do
+ test "returns {:next, conn} for non-octet-stream content types" do
+ conn = conn(:post, "/", "anything")
+ opts = OctetStream.init([])
+
+ assert {:next, ^conn} = OctetStream.parse(conn, "application", "json", %{}, opts)
+ assert {:next, ^conn} = OctetStream.parse(conn, "text", "plain", %{}, opts)
+ assert {:next, ^conn} = OctetStream.parse(conn, "application", "x-www-form-urlencoded", %{}, opts)
+ end
+
+ test "parses application/octet-stream body into %{\"_binary\" => body}" do
+ body = <<1, 2, 3, 4, 5>>
+
+ conn =
+ conn(:post, "/", body)
+ |> put_req_header("content-type", "application/octet-stream")
+
+ opts = OctetStream.init([])
+
+ assert {:ok, %{"_binary" => ^body}, %Plug.Conn{}} =
+ OctetStream.parse(conn, "application", "octet-stream", %{}, opts)
+ end
+
+ test "handles empty binary body" do
+ conn =
+ conn(:post, "/", <<>>)
+ |> put_req_header("content-type", "application/octet-stream")
+
+ opts = OctetStream.init([])
+
+ assert {:ok, %{"_binary" => <<>>}, %Plug.Conn{}} =
+ OctetStream.parse(conn, "application", "octet-stream", %{}, opts)
+ end
+
+ test "returns {:error, :too_large, conn} when body exceeds :length" do
+ body = :crypto.strong_rand_bytes(2_000)
+
+ conn =
+ conn(:post, "/", body)
+ |> put_req_header("content-type", "application/octet-stream")
+
+ opts = OctetStream.init(length: 100, read_length: 100)
+
+ assert {:error, :too_large, %Plug.Conn{}} =
+ OctetStream.parse(conn, "application", "octet-stream", %{}, opts)
+ end
+
+ test "raises Plug.TimeoutError when body reader returns {:error, :timeout}" do
+ conn =
+ conn(:post, "/", <<1, 2, 3>>)
+ |> put_req_header("content-type", "application/octet-stream")
+
+ opts = OctetStream.init(body_reader: {TimeoutReader, :read_body, []})
+
+ assert_raise Plug.TimeoutError, fn ->
+ OctetStream.parse(conn, "application", "octet-stream", %{}, opts)
+ end
+ end
+
+ test "raises Plug.BadRequestError for other read_body errors" do
+ conn =
+ conn(:post, "/", <<1, 2, 3>>)
+ |> put_req_header("content-type", "application/octet-stream")
+
+ opts = OctetStream.init(body_reader: {ErrorReader, :read_body, []})
+
+ assert_raise Plug.BadRequestError, fn ->
+ OctetStream.parse(conn, "application", "octet-stream", %{}, opts)
+ end
+ end
+
+ test "ignores content-type parameters" do
+ body = <<10, 20, 30>>
+
+ conn =
+ conn(:post, "/", body)
+ |> put_req_header("content-type", "application/octet-stream; charset=binary")
+
+ opts = OctetStream.init([])
+
+ assert {:ok, %{"_binary" => ^body}, %Plug.Conn{}} =
+ OctetStream.parse(
+ conn,
+ "application",
+ "octet-stream",
+ %{"charset" => "binary"},
+ opts
+ )
+ end
+ end
+end
diff --git a/test/realtime_web/plugs/rate_limiter_test.exs b/test/realtime_web/plugs/rate_limiter_test.exs
index 78b22fc8f..7fd270a66 100644
--- a/test/realtime_web/plugs/rate_limiter_test.exs
+++ b/test/realtime_web/plugs/rate_limiter_test.exs
@@ -13,7 +13,7 @@ defmodule RealtimeWeb.Plugs.RateLimiterTest do
"settings" => %{
"db_host" => "127.0.0.1",
"db_name" => "postgres",
- "db_user" => "supabase_admin",
+ "db_user" => "supabase_realtime_admin",
"db_password" => "postgres",
"db_port" => "6432",
"poll_interval" => 100,
@@ -47,9 +47,7 @@ defmodule RealtimeWeb.Plugs.RateLimiterTest do
end
test "serve a 200 when rate limit is set to 100", %{conn: conn} do
- {:ok, _tenant} =
- Api.get_tenant_by_external_id(@tenant["external_id"])
- |> Api.update_tenant(%{"max_events_per_second" => 100})
+ {:ok, _tenant} = Api.update_tenant_by_external_id(@tenant["external_id"], %{"max_events_per_second" => 100})
conn =
conn
@@ -58,4 +56,23 @@ defmodule RealtimeWeb.Plugs.RateLimiterTest do
assert conn.status == 200
end
+
+ test "passes through when tenant is not in assigns", %{conn: conn} do
+ alias RealtimeWeb.Plugs.RateLimiter
+
+ result = RateLimiter.call(conn, [])
+
+ refute result.halted
+ end
+
+ test "sets rate limit headers on 429 response", %{conn: conn} do
+ conn =
+ conn
+ |> Map.put(:host, "localhost.localhost.com")
+ |> get(Routes.ping_path(conn, :ping))
+
+ assert conn.status == 429
+ assert get_resp_header(conn, "x-rate-limit") == ["0"]
+ assert get_resp_header(conn, "x-rate-rolling") != []
+ end
end
diff --git a/test/realtime_web/plugs/validate_broadcast_content_type_test.exs b/test/realtime_web/plugs/validate_broadcast_content_type_test.exs
new file mode 100644
index 000000000..ee4e8ceca
--- /dev/null
+++ b/test/realtime_web/plugs/validate_broadcast_content_type_test.exs
@@ -0,0 +1,97 @@
+defmodule RealtimeWeb.Plugs.ValidateBroadcastContentTypeTest do
+ use ExUnit.Case, async: true
+ import Plug.Test
+ import Plug.Conn
+
+ alias RealtimeWeb.Plugs.ValidateBroadcastContentType
+
+ defp call(conn) do
+ ValidateBroadcastContentType.call(conn, ValidateBroadcastContentType.init([]))
+ end
+
+ describe "allowed content types" do
+ test "passes application/json through unchanged" do
+ conn =
+ conn(:post, "/", "{}")
+ |> put_req_header("content-type", "application/json")
+ |> call()
+
+ refute conn.halted
+ assert is_nil(conn.status)
+ end
+
+ test "passes application/json with charset through" do
+ conn =
+ conn(:post, "/", "{}")
+ |> put_req_header("content-type", "application/json; charset=utf-8")
+ |> call()
+
+ refute conn.halted
+ assert is_nil(conn.status)
+ end
+
+ test "passes application/octet-stream through" do
+ conn =
+ conn(:post, "/", <<1, 2, 3>>)
+ |> put_req_header("content-type", "application/octet-stream")
+ |> call()
+
+ refute conn.halted
+ assert is_nil(conn.status)
+ end
+
+ test "passes through when content-type header is missing" do
+ conn =
+ conn(:post, "/", "")
+ |> call()
+
+ refute conn.halted
+ assert is_nil(conn.status)
+ end
+ end
+
+ describe "rejected content types" do
+ test "returns 415 for text/plain" do
+ conn =
+ conn(:post, "/", "plain text")
+ |> put_req_header("content-type", "text/plain")
+ |> call()
+
+ assert conn.halted
+ assert conn.status == 415
+ assert get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"]
+ assert Jason.decode!(conn.resp_body)["error"] =~ "Unsupported Media Type"
+
+ assert Jason.decode!(conn.resp_body)["error"] ==
+ "Unsupported Media Type. Use application/json or application/octet-stream"
+ end
+
+ test "returns 415 for application/xml" do
+ conn =
+ conn(:post, "/", "")
+ |> put_req_header("content-type", "application/xml")
+ |> call()
+
+ assert conn.halted
+ assert conn.status == 415
+ assert Jason.decode!(conn.resp_body)["error"] =~ "Unsupported Media Type"
+ end
+
+ test "returns 415 for multipart/form-data" do
+ conn =
+ conn(:post, "/", "")
+ |> put_req_header("content-type", "multipart/form-data; boundary=abc")
+ |> call()
+
+ assert conn.halted
+ assert conn.status == 415
+ end
+ end
+
+ describe "init/1" do
+ test "returns its input unchanged" do
+ assert ValidateBroadcastContentType.init([]) == []
+ assert ValidateBroadcastContentType.init(foo: :bar) == [foo: :bar]
+ end
+ end
+end
diff --git a/test/realtime_web/socket/v2_serializer_test.exs b/test/realtime_web/socket/v2_serializer_test.exs
new file mode 100644
index 000000000..bd9de3c80
--- /dev/null
+++ b/test/realtime_web/socket/v2_serializer_test.exs
@@ -0,0 +1,571 @@
+defmodule RealtimeWeb.Socket.V2SerializerTest do
+ use ExUnit.Case, async: true
+
+ alias Phoenix.Socket.{Broadcast, Message, Reply}
+ alias RealtimeWeb.Socket.UserBroadcast
+ alias RealtimeWeb.Socket.V2Serializer
+
+ @serializer V2Serializer
+ @v2_fastlane_json "[null,null,\"t\",\"e\",{\"m\":1}]"
+ @v2_msg_json "[null,null,\"t\",\"e\",{\"m\":1}]"
+
+ @client_push <<
+ # push
+ 0::size(8),
+ # join_ref_size
+ 2,
+ # ref_size
+ 3,
+ # topic_size
+ 5,
+ # event_size
+ 5,
+ "12",
+ "123",
+ "topic",
+ "event",
+ 101,
+ 102,
+ 103
+ >>
+
+ @client_binary_user_broadcast_push <<
+ # user broadcast push
+ 3::size(8),
+ # join_ref_size
+ 2,
+ # ref_size
+ 3,
+ # topic_size
+ 5,
+ # user_event_size
+ 10,
+ # metadata_size
+ 0,
+ # binary encoding
+ 0::size(8),
+ "12",
+ "123",
+ "topic",
+ "user_event",
+ 101,
+ 102,
+ 103
+ >>
+
+ @client_json_user_broadcast_push <<
+ # user broadcast push
+ 3::size(8),
+ # join_ref_size
+ 2,
+ # ref_size
+ 3,
+ # topic_size
+ 5,
+ # user_event_size
+ 10,
+ # metadata_size
+ 0,
+ # json encoding
+ 1::size(8),
+ "12",
+ "123",
+ "topic",
+ "user_event",
+ 123,
+ 34,
+ 97,
+ 34,
+ 58,
+ 34,
+ 98,
+ 34,
+ 125
+ >>
+
+ @client_binary_user_broadcast_push_with_metadata <<
+ # user broadcast push
+ 3::size(8),
+ # join_ref_size
+ 2,
+ # ref_size
+ 3,
+ # topic_size
+ 5,
+ # user_event_size
+ 10,
+ # metadata_size
+ 14,
+ # binary encoding
+ 0::size(8),
+ "12",
+ "123",
+ "topic",
+ "user_event",
+ ~s<{"store":true}>,
+ 101,
+ 102,
+ 103
+ >>
+
+ @reply <<
+ # reply
+ 1::size(8),
+ # join_ref_size
+ 2,
+ # ref_size
+ 3,
+ # topic_size
+ 5,
+ # status_size
+ 2,
+ "12",
+ "123",
+ "topic",
+ "ok",
+ 101,
+ 102,
+ 103
+ >>
+
+ @broadcast <<
+ # broadcast
+ 2::size(8),
+ # topic_size
+ 5,
+ # event_size
+ 5,
+ "topic",
+ "event",
+ 101,
+ 102,
+ 103
+ >>
+
+ @binary_user_broadcast <<
+ # user broadcast
+ 4::size(8),
+ # topic_size
+ 5,
+ # user_event_size
+ 10,
+ # metadata_size
+ 17,
+ # binary encoding
+ 0::size(8),
+ "topic",
+ "user_event",
+ # metadata
+ 123,
+ 34,
+ 114,
+ 101,
+ 112,
+ 108,
+ 97,
+ 121,
+ 101,
+ 100,
+ 34,
+ 58,
+ 116,
+ 114,
+ 117,
+ 101,
+ 125,
+ # payload
+ 101,
+ 102,
+ 103
+ >>
+
+ @binary_user_broadcast_no_metadata <<
+ # user broadcast
+ 4::size(8),
+ # topic_size
+ 5,
+ # user_event_size
+ 10,
+ # metadata_size
+ 0,
+ # binary encoding
+ 0::size(8),
+ "topic",
+ "user_event",
+ # metadata
+ # payload
+ 101,
+ 102,
+ 103
+ >>
+
+ @json_user_broadcast <<
+ # user broadcast
+ 4::size(8),
+ # topic_size
+ 5,
+ # user_event_size
+ 10,
+ # metadata_size
+ 17,
+ # json encoding
+ 1::size(8),
+ "topic",
+ "user_event",
+ # metadata
+ 123,
+ 34,
+ 114,
+ 101,
+ 112,
+ 108,
+ 97,
+ 121,
+ 101,
+ 100,
+ 34,
+ 58,
+ 116,
+ 114,
+ 117,
+ 101,
+ 125,
+ # payload
+ 123,
+ 34,
+ 97,
+ 34,
+ 58,
+ 34,
+ 98,
+ 34,
+ 125
+ >>
+
+ @json_user_broadcast_no_metadata <<
+ # broadcast
+ 4::size(8),
+ # topic_size
+ 5,
+ # user_event_size
+ 10,
+ # metadata_size
+ 0,
+ # json encoding
+ 1::size(8),
+ "topic",
+ "user_event",
+ # metadata
+ # payload
+ 123,
+ 34,
+ 97,
+ 34,
+ 58,
+ 34,
+ 98,
+ 34,
+ 125
+ >>
+
+ defp encode!(serializer, msg) do
+ case serializer.encode!(msg) do
+ {:socket_push, :text, encoded} ->
+ assert is_list(encoded)
+ IO.iodata_to_binary(encoded)
+
+ {:socket_push, :binary, encoded} ->
+ assert is_binary(encoded)
+ encoded
+ end
+ end
+
+ defp decode!(serializer, msg, opts), do: serializer.decode!(msg, opts)
+
+ defp fastlane!(serializer, msg) do
+ case serializer.fastlane!(msg) do
+ {:socket_push, :text, encoded} ->
+ assert is_list(encoded)
+ IO.iodata_to_binary(encoded)
+
+ {:socket_push, :binary, encoded} ->
+ assert is_binary(encoded)
+ encoded
+ end
+ end
+
+ test "encode!/1 encodes `Phoenix.Socket.Message` as JSON" do
+ msg = %Message{topic: "t", event: "e", payload: %{m: 1}}
+ assert encode!(@serializer, msg) == @v2_msg_json
+ end
+
+ test "encode!/1 raises when payload is not a map" do
+ msg = %Message{topic: "t", event: "e", payload: "invalid"}
+ assert_raise ArgumentError, fn -> encode!(@serializer, msg) end
+ end
+
+ test "encode!/1 encodes `Phoenix.Socket.Reply` as JSON" do
+ msg = %Reply{topic: "t", payload: %{m: 1}}
+ encoded = encode!(@serializer, msg)
+
+ assert Jason.decode!(encoded) == [
+ nil,
+ nil,
+ "t",
+ "phx_reply",
+ %{"response" => %{"m" => 1}, "status" => nil}
+ ]
+ end
+
+ test "decode!/2 decodes `Phoenix.Socket.Message` from JSON" do
+ assert %Message{topic: "t", event: "e", payload: %{"m" => 1}} ==
+ decode!(@serializer, @v2_msg_json, opcode: :text)
+ end
+
+ test "fastlane!/1 encodes a broadcast into a message as JSON" do
+ msg = %Broadcast{topic: "t", event: "e", payload: %{m: 1}}
+ assert fastlane!(@serializer, msg) == @v2_fastlane_json
+ end
+
+ test "fastlane!/1 raises when payload is not a map" do
+ msg = %Broadcast{topic: "t", event: "e", payload: "invalid"}
+ assert_raise ArgumentError, fn -> fastlane!(@serializer, msg) end
+ end
+
+ describe "binary encode" do
+ test "general pushed message" do
+ push = <<
+ # push
+ 0::size(8),
+ # join_ref_size
+ 2,
+ # topic_size
+ 5,
+ # event_size
+ 5,
+ "12",
+ "topic",
+ "event",
+ 101,
+ 102,
+ 103
+ >>
+
+ assert encode!(@serializer, %Phoenix.Socket.Message{
+ join_ref: "12",
+ ref: nil,
+ topic: "topic",
+ event: "event",
+ payload: {:binary, <<101, 102, 103>>}
+ }) == push
+ end
+
+ test "encode with oversized headers" do
+ assert_raise ArgumentError, ~r/unable to convert topic to binary/, fn ->
+ encode!(@serializer, %Phoenix.Socket.Message{
+ join_ref: "12",
+ ref: nil,
+ topic: String.duplicate("t", 256),
+ event: "event",
+ payload: {:binary, <<101, 102, 103>>}
+ })
+ end
+
+ assert_raise ArgumentError, ~r/unable to convert event to binary/, fn ->
+ encode!(@serializer, %Phoenix.Socket.Message{
+ join_ref: "12",
+ ref: nil,
+ topic: "topic",
+ event: String.duplicate("e", 256),
+ payload: {:binary, <<101, 102, 103>>}
+ })
+ end
+
+ assert_raise ArgumentError, ~r/unable to convert join_ref to binary/, fn ->
+ encode!(@serializer, %Phoenix.Socket.Message{
+ join_ref: String.duplicate("j", 256),
+ ref: nil,
+ topic: "topic",
+ event: "event",
+ payload: {:binary, <<101, 102, 103>>}
+ })
+ end
+ end
+
+ test "reply" do
+ assert encode!(@serializer, %Phoenix.Socket.Reply{
+ join_ref: "12",
+ ref: "123",
+ topic: "topic",
+ status: :ok,
+ payload: {:binary, <<101, 102, 103>>}
+ }) == @reply
+ end
+
+ test "reply with oversized headers" do
+ assert_raise ArgumentError, ~r/unable to convert ref to binary/, fn ->
+ encode!(@serializer, %Phoenix.Socket.Reply{
+ join_ref: "12",
+ ref: String.duplicate("r", 256),
+ topic: "topic",
+ status: :ok,
+ payload: {:binary, <<101, 102, 103>>}
+ })
+ end
+ end
+
+ test "fastlane binary Broadcast" do
+ assert fastlane!(@serializer, %Broadcast{
+ topic: "topic",
+ event: "event",
+ payload: {:binary, <<101, 102, 103>>}
+ }) == @broadcast
+ end
+
+ test "fastlane binary UserBroadcast" do
+ assert fastlane!(@serializer, %UserBroadcast{
+ topic: "topic",
+ user_event: "user_event",
+ metadata: %{"replayed" => true},
+ user_payload_encoding: :binary,
+ user_payload: <<101, 102, 103>>
+ }) == @binary_user_broadcast
+ end
+
+ test "fastlane binary UserBroadcast no metadata" do
+ assert fastlane!(@serializer, %UserBroadcast{
+ topic: "topic",
+ user_event: "user_event",
+ metadata: nil,
+ user_payload_encoding: :binary,
+ user_payload: <<101, 102, 103>>
+ }) == @binary_user_broadcast_no_metadata
+ end
+
+ test "fastlane json UserBroadcast" do
+ assert fastlane!(@serializer, %UserBroadcast{
+ topic: "topic",
+ user_event: "user_event",
+ metadata: %{"replayed" => true},
+ user_payload_encoding: :json,
+ user_payload: "{\"a\":\"b\"}"
+ }) == @json_user_broadcast
+ end
+
+ test "fastlane json UserBroadcast no metadata" do
+ assert fastlane!(@serializer, %UserBroadcast{
+ topic: "topic",
+ user_event: "user_event",
+ user_payload_encoding: :json,
+ user_payload: "{\"a\":\"b\"}"
+ }) == @json_user_broadcast_no_metadata
+ end
+
+ test "fastlane with oversized headers" do
+ assert_raise ArgumentError, ~r/unable to convert topic to binary/, fn ->
+ fastlane!(@serializer, %Broadcast{
+ topic: String.duplicate("t", 256),
+ event: "event",
+ payload: {:binary, <<101, 102, 103>>}
+ })
+ end
+
+ assert_raise ArgumentError, ~r/unable to convert event to binary/, fn ->
+ fastlane!(@serializer, %Broadcast{
+ topic: "topic",
+ event: String.duplicate("e", 256),
+ payload: {:binary, <<101, 102, 103>>}
+ })
+ end
+
+ assert_raise ArgumentError, ~r/unable to convert topic to binary/, fn ->
+ fastlane!(@serializer, %UserBroadcast{
+ topic: String.duplicate("t", 256),
+ user_event: "user_event",
+ user_payload_encoding: :json,
+ user_payload: "{\"a\":\"b\"}"
+ })
+ end
+
+ assert_raise ArgumentError, ~r/unable to convert user_event to binary/, fn ->
+ fastlane!(@serializer, %UserBroadcast{
+ topic: "topic",
+ user_event: String.duplicate("e", 256),
+ user_payload_encoding: :json,
+ user_payload: "{\"a\":\"b\"}"
+ })
+ end
+
+ assert_raise ArgumentError, ~r/unable to convert metadata to binary/, fn ->
+ fastlane!(@serializer, %UserBroadcast{
+ topic: "topic",
+ user_event: "user_event",
+ metadata: %{k: String.duplicate("e", 256)},
+ user_payload_encoding: :json,
+ user_payload: "{\"a\":\"b\"}"
+ })
+ end
+ end
+ end
+
+ describe "decode!/2 invalid text formats" do
+ test "raises on a bare JSON string" do
+ raw = Jason.encode!("just a string")
+
+ assert_raise Phoenix.Socket.InvalidMessageError, fn ->
+ decode!(@serializer, raw, opcode: :text)
+ end
+ end
+
+ test "raises on a V1 map" do
+ raw = Jason.encode!(%{"topic" => "t", "event" => "e", "payload" => %{"m" => 1}, "ref" => "1", "join_ref" => "11"})
+
+ assert_raise Phoenix.Socket.InvalidMessageError, fn ->
+ decode!(@serializer, raw, opcode: :text)
+ end
+ end
+ end
+
+ describe "binary decode" do
+ test "pushed message" do
+ assert decode!(@serializer, @client_push, opcode: :binary) == %Phoenix.Socket.Message{
+ join_ref: "12",
+ ref: "123",
+ topic: "topic",
+ event: "event",
+ payload: {:binary, <<101, 102, 103>>}
+ }
+ end
+
+ test "binary user pushed message with metadata" do
+ assert decode!(@serializer, @client_binary_user_broadcast_push_with_metadata, opcode: :binary) ==
+ %Phoenix.Socket.Message{
+ join_ref: "12",
+ ref: "123",
+ topic: "topic",
+ event: "broadcast",
+ payload: {"user_event", :binary, <<101, 102, 103>>, %{"store" => true}}
+ }
+ end
+
+ test "binary user pushed message" do
+ assert decode!(@serializer, @client_binary_user_broadcast_push, opcode: :binary) == %Phoenix.Socket.Message{
+ join_ref: "12",
+ ref: "123",
+ topic: "topic",
+ event: "broadcast",
+ payload: {"user_event", :binary, <<101, 102, 103>>, %{}}
+ }
+ end
+
+ test "json binary user pushed message" do
+ assert decode!(@serializer, @client_json_user_broadcast_push, opcode: :binary) == %Phoenix.Socket.Message{
+ join_ref: "12",
+ ref: "123",
+ topic: "topic",
+ event: "broadcast",
+ payload: {"user_event", :json, "{\"a\":\"b\"}", %{}}
+ }
+ end
+ end
+end
diff --git a/test/realtime_web/socket_test.exs b/test/realtime_web/socket_test.exs
new file mode 100644
index 000000000..69e885b3e
--- /dev/null
+++ b/test/realtime_web/socket_test.exs
@@ -0,0 +1,64 @@
+defmodule RealtimeWeb.SocketTest do
+ use ExUnit.Case, async: true
+
+ alias RealtimeWeb.Socket
+
+ describe "collect_traffic_telemetry/4" do
+ test "returns previous values unchanged when transport_pid is nil" do
+ assert Socket.collect_traffic_telemetry(nil, "tenant", 42, 99) ==
+ %{latest_recv: 42, latest_send: 99}
+ end
+
+ test "fires no telemetry when transport_pid is nil" do
+ ref = :telemetry_test.attach_event_handlers(self(), [[:realtime, :channel, :output_bytes]])
+
+ Socket.collect_traffic_telemetry(nil, "tenant", 0, 0)
+
+ refute_received {[:realtime, :channel, :output_bytes], ^ref, _, _}
+ end
+
+ test "returns zero stats when transport process has no port links" do
+ pid = spawn(fn -> Process.sleep(:infinity) end)
+
+ assert Socket.collect_traffic_telemetry(pid, "tenant", 0, 0) ==
+ %{latest_recv: 0, latest_send: 0}
+
+ Process.exit(pid, :kill)
+ end
+
+ test "fires output_bytes and input_bytes telemetry with correct tenant metadata" do
+ pid = spawn(fn -> Process.sleep(:infinity) end)
+
+ ref =
+ :telemetry_test.attach_event_handlers(self(), [
+ [:realtime, :channel, :output_bytes],
+ [:realtime, :channel, :input_bytes]
+ ])
+
+ Socket.collect_traffic_telemetry(pid, "my-tenant", 0, 0)
+
+ assert_received {[:realtime, :channel, :output_bytes], ^ref, %{size: 0}, %{tenant: "my-tenant"}}
+ assert_received {[:realtime, :channel, :input_bytes], ^ref, %{size: 0}, %{tenant: "my-tenant"}}
+
+ Process.exit(pid, :kill)
+ end
+
+ test "delta is clamped to zero when previous stats exceed current (no negative deltas)" do
+ pid = spawn(fn -> Process.sleep(:infinity) end)
+
+ ref =
+ :telemetry_test.attach_event_handlers(self(), [
+ [:realtime, :channel, :output_bytes],
+ [:realtime, :channel, :input_bytes]
+ ])
+
+ # No port links → latest = 0, but previous > 0 → delta would be negative without max(0, ...)
+ Socket.collect_traffic_telemetry(pid, "tenant", 1000, 500)
+
+ assert_received {[:realtime, :channel, :output_bytes], ^ref, %{size: 0}, _}
+ assert_received {[:realtime, :channel, :input_bytes], ^ref, %{size: 0}, _}
+
+ Process.exit(pid, :kill)
+ end
+ end
+end
diff --git a/test/realtime_web/tenant_broadcaster_test.exs b/test/realtime_web/tenant_broadcaster_test.exs
index d9afbf641..54872283f 100644
--- a/test/realtime_web/tenant_broadcaster_test.exs
+++ b/test/realtime_web/tenant_broadcaster_test.exs
@@ -1,5 +1,5 @@
defmodule RealtimeWeb.TenantBroadcasterTest do
- # Usage of Clustered
+ # Usage of Clustered and changing Application env
use Realtime.DataCase, async: false
alias Phoenix.Socket.Broadcast
@@ -33,6 +33,7 @@ defmodule RealtimeWeb.TenantBroadcasterTest do
end
setup context do
+ tenant_id = random_string()
Endpoint.subscribe(@topic)
:erpc.call(context.node, Subscriber, :subscribe, [self(), @topic])
@@ -44,16 +45,16 @@ defmodule RealtimeWeb.TenantBroadcasterTest do
__MODULE__,
[:realtime, :tenants, :payload, :size],
&__MODULE__.handle_telemetry/4,
- pid: self()
+ %{pid: self(), tenant: tenant_id}
)
- :ok
+ {:ok, tenant_id: tenant_id}
end
- describe "pubsub_broadcast/4" do
- test "pubsub_broadcast", %{node: node} do
+ describe "pubsub_broadcast/5" do
+ test "pubsub_broadcast", %{node: node, tenant_id: tenant_id} do
message = %Broadcast{topic: @topic, event: "an event", payload: %{"a" => "b"}}
- TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub)
+ TenantBroadcaster.pubsub_broadcast(tenant_id, @topic, message, Phoenix.PubSub, :broadcast)
assert_receive ^message
@@ -64,13 +65,13 @@ defmodule RealtimeWeb.TenantBroadcasterTest do
:telemetry,
[:realtime, :tenants, :payload, :size],
%{size: 114},
- %{tenant: "realtime-dev"}
+ %{tenant: ^tenant_id, message_type: :broadcast}
}
end
- test "pubsub_broadcast list payload", %{node: node} do
+ test "pubsub_broadcast list payload", %{node: node, tenant_id: tenant_id} do
message = %Broadcast{topic: @topic, event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]}
- TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub)
+ TenantBroadcaster.pubsub_broadcast(tenant_id, @topic, message, Phoenix.PubSub, :broadcast)
assert_receive ^message
@@ -81,13 +82,13 @@ defmodule RealtimeWeb.TenantBroadcasterTest do
:telemetry,
[:realtime, :tenants, :payload, :size],
%{size: 130},
- %{tenant: "realtime-dev"}
+ %{tenant: ^tenant_id, message_type: :broadcast}
}
end
- test "pubsub_broadcast string payload", %{node: node} do
+ test "pubsub_broadcast string payload", %{node: node, tenant_id: tenant_id} do
message = %Broadcast{topic: @topic, event: "an event", payload: "some text payload"}
- TenantBroadcaster.pubsub_broadcast("realtime-dev", @topic, message, Phoenix.PubSub)
+ TenantBroadcaster.pubsub_broadcast(tenant_id, @topic, message, Phoenix.PubSub, :broadcast)
assert_receive ^message
@@ -98,13 +99,13 @@ defmodule RealtimeWeb.TenantBroadcasterTest do
:telemetry,
[:realtime, :tenants, :payload, :size],
%{size: 119},
- %{tenant: "realtime-dev"}
+ %{tenant: ^tenant_id, message_type: :broadcast}
}
end
end
- describe "pubsub_broadcast_from/5" do
- test "pubsub_broadcast_from", %{node: node} do
+ describe "pubsub_broadcast_from/6" do
+ test "pubsub_broadcast_from", %{node: node, tenant_id: tenant_id} do
parent = self()
spawn_link(fn ->
@@ -120,7 +121,7 @@ defmodule RealtimeWeb.TenantBroadcasterTest do
message = %Broadcast{topic: @topic, event: "an event", payload: %{"a" => "b"}}
- TenantBroadcaster.pubsub_broadcast_from("realtime-dev", self(), @topic, message, Phoenix.PubSub)
+ TenantBroadcaster.pubsub_broadcast_from(tenant_id, self(), @topic, message, Phoenix.PubSub, :broadcast)
assert_receive {:other_process, ^message}
@@ -131,7 +132,7 @@ defmodule RealtimeWeb.TenantBroadcasterTest do
:telemetry,
[:realtime, :tenants, :payload, :size],
%{size: 114},
- %{tenant: "realtime-dev"}
+ %{tenant: ^tenant_id, message_type: :broadcast}
}
# This process does not receive the message
@@ -139,5 +140,99 @@ defmodule RealtimeWeb.TenantBroadcasterTest do
end
end
- def handle_telemetry(event, measures, metadata, pid: pid), do: send(pid, {:telemetry, event, measures, metadata})
+ describe "pubsub_direct_broadcast/6" do
+ test "pubsub_direct_broadcast", %{node: node, tenant_id: tenant_id} do
+ message = %Broadcast{topic: @topic, event: "an event", payload: %{"a" => "b"}}
+
+ TenantBroadcaster.pubsub_direct_broadcast(node(), tenant_id, @topic, message, Phoenix.PubSub, :broadcast)
+ TenantBroadcaster.pubsub_direct_broadcast(node, tenant_id, @topic, message, Phoenix.PubSub, :broadcast)
+
+ assert_receive ^message
+
+ # Remote node received the broadcast
+ assert_receive {:relay, ^node, ^message}
+
+ assert_receive {
+ :telemetry,
+ [:realtime, :tenants, :payload, :size],
+ %{size: 114},
+ %{tenant: ^tenant_id, message_type: :broadcast}
+ }
+ end
+
+ test "pubsub_direct_broadcast list payload", %{node: node, tenant_id: tenant_id} do
+ message = %Broadcast{topic: @topic, event: "an event", payload: ["a", %{"b" => "c"}, 1, 23]}
+
+ TenantBroadcaster.pubsub_direct_broadcast(node(), tenant_id, @topic, message, Phoenix.PubSub, :broadcast)
+ TenantBroadcaster.pubsub_direct_broadcast(node, tenant_id, @topic, message, Phoenix.PubSub, :broadcast)
+
+ assert_receive ^message
+
+ # Remote node received the broadcast
+ assert_receive {:relay, ^node, ^message}
+
+ assert_receive {
+ :telemetry,
+ [:realtime, :tenants, :payload, :size],
+ %{size: 130},
+ %{tenant: ^tenant_id, message_type: :broadcast}
+ }
+ end
+
+ test "pubsub_direct_broadcast string payload", %{node: node, tenant_id: tenant_id} do
+ message = %Broadcast{topic: @topic, event: "an event", payload: "some text payload"}
+
+ TenantBroadcaster.pubsub_direct_broadcast(node(), tenant_id, @topic, message, Phoenix.PubSub, :broadcast)
+ TenantBroadcaster.pubsub_direct_broadcast(node, tenant_id, @topic, message, Phoenix.PubSub, :broadcast)
+
+ assert_receive ^message
+
+ # Remote node received the broadcast
+ assert_receive {:relay, ^node, ^message}
+
+ assert_receive {
+ :telemetry,
+ [:realtime, :tenants, :payload, :size],
+ %{size: 119},
+ %{tenant: ^tenant_id, message_type: :broadcast}
+ }
+ end
+ end
+
+ describe "collect_payload_size/3" do
+ test "emit telemetry for struct", %{tenant_id: tenant_id} do
+ TenantBroadcaster.collect_payload_size(
+ tenant_id,
+ %Phoenix.Socket.Broadcast{event: "broadcast", payload: %{"a" => "b"}},
+ :broadcast
+ )
+
+ assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 65},
+ %{tenant: ^tenant_id, message_type: :broadcast}}
+ end
+
+ test "emit telemetry for map", %{tenant_id: tenant_id} do
+ TenantBroadcaster.collect_payload_size(
+ tenant_id,
+ %{event: "broadcast", payload: %{"a" => "b"}},
+ :postgres_changes
+ )
+
+ assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 53},
+ %{tenant: ^tenant_id, message_type: :postgres_changes}}
+ end
+
+ test "emit telemetry for non-map", %{tenant_id: tenant_id} do
+ TenantBroadcaster.collect_payload_size(tenant_id, "some blob", :presence)
+
+ assert_receive {:telemetry, [:realtime, :tenants, :payload, :size], %{size: 15},
+ %{tenant: ^tenant_id, message_type: :presence}}
+ end
+ end
+
+ def handle_telemetry(event, measures, metadata, %{pid: pid, tenant: tenant}) do
+ if metadata[:tenant] == tenant do
+ send(pid, {:telemetry, event, measures, metadata})
+ end
+ end
end
diff --git a/test/support/channel_case.ex b/test/support/channel_case.ex
index 8bdab2185..ebc823967 100644
--- a/test/support/channel_case.ex
+++ b/test/support/channel_case.ex
@@ -16,7 +16,6 @@ defmodule RealtimeWeb.ChannelCase do
"""
use ExUnit.CaseTemplate
- alias Ecto.Adapters.SQL.Sandbox
using do
quote do
@@ -24,14 +23,13 @@ defmodule RealtimeWeb.ChannelCase do
import Phoenix.ChannelTest
import Generators
import TenantConnection
+ import TestHelpers
# The default endpoint for testing
@endpoint RealtimeWeb.Endpoint
end
end
setup tags do
- pid = Sandbox.start_owner!(Realtime.Repo, shared: not tags[:async])
- on_exit(fn -> Sandbox.stop_owner(pid) end)
- :ok
+ Realtime.DataCase.setup_sandbox(tags)
end
end
diff --git a/test/support/cleanup.ex b/test/support/cleanup.ex
index 12954698c..161eb58de 100644
--- a/test/support/cleanup.ex
+++ b/test/support/cleanup.ex
@@ -10,7 +10,7 @@ defmodule Cleanup do
hostname: "localhost",
port: 5433,
database: "postgres",
- username: "supabase_admin",
+ username: "supabase_realtime_admin",
password: "postgres"
)
diff --git a/test/support/clustered.ex b/test/support/clustered.ex
index c7028b79b..47911a064 100644
--- a/test/support/clustered.ex
+++ b/test/support/clustered.ex
@@ -23,25 +23,42 @@ defmodule Clustered do
end
```
"""
- @spec start(any()) :: {:ok, node}
+ @spec start(any(), keyword()) :: {:ok, node}
def start(aux_mod \\ nil, opts \\ []) do
- {:ok, _pid, node} = start_disconnected(aux_mod, opts)
+ {:ok, pid, node} = start_disconnected(aux_mod, opts)
+
+ :ok = wait_for_gen_rpc(pid)
true = Node.connect(node)
+ max_cast_clients = Application.get_env(:realtime, :max_gen_rpc_clients, 5)
+ max_call_clients = Application.get_env(:realtime, :max_gen_rpc_call_clients, 1)
+
+ for key <- 1..max_cast_clients do
+ _ = :gen_rpc.call({node, {:cast, key}}, :erlang, :node, [], 5_000)
+ end
+
+ for key <- 1..max_call_clients do
+ _ = :gen_rpc.call({node, {:call, key}}, :erlang, :node, [], 5_000)
+ end
+
{:ok, node}
end
@doc """
Similar to `start/2` but the node is not connected automatically
"""
- @spec start_disconnected(any()) :: {:ok, :peer.server_ref(), node}
+ @spec start_disconnected(any(), keyword()) :: {:ok, :peer.server_ref(), node}
def start_disconnected(aux_mod \\ nil, opts \\ []) do
extra_config = Keyword.get(opts, :extra_config, [])
phoenix_port = Keyword.get(opts, :phoenix_port, 4012)
+ name = Keyword.get(opts, :name, :peer.random_name())
+
+ partition = System.get_env("MIX_TEST_PARTITION")
+ node_name = if partition, do: :"main#{partition}@127.0.0.1", else: :"main@127.0.0.1"
:ok =
- case :net_kernel.start([:"main@127.0.0.1"]) do
+ case :net_kernel.start([node_name]) do
{:ok, _} ->
:ok
@@ -53,7 +70,6 @@ defmodule Clustered do
end
true = :erlang.set_cookie(:cookie)
- name = :peer.random_name()
{:ok, pid, node} =
ExUnit.Callbacks.start_supervised(%{
@@ -106,10 +122,12 @@ defmodule Clustered do
:ok = :peer.call(pid, Application, :put_env, [app_name, key, value])
end
+ wait_for_port_free(gen_rpc_tcp_client_port)
+ {:ok, _} = :peer.call(pid, Application, :ensure_all_started, [:gen_rpc])
{:ok, _} = :peer.call(pid, Application, :ensure_all_started, [:mix])
:ok = :peer.call(pid, Mix, :env, [Mix.env()])
- Enum.map(
+ Enum.each(
[:logger, :runtime_tools, :prom_ex, :mix, :os_mon, :realtime],
fn app -> {:ok, _} = :peer.call(pid, Application, :ensure_all_started, [app]) end
)
@@ -121,7 +139,41 @@ defmodule Clustered do
{:ok, pid, node}
end
- def stop() do
- Node.stop()
+ defp wait_for_gen_rpc(pid) do
+ port = :peer.call(pid, Application, :get_env, [:gen_rpc, :tcp_server_port])
+
+ case port do
+ port when is_integer(port) and port > 0 -> wait_for_port({127, 0, 0, 1}, port, 50, 100)
+ _ -> raise "gen_rpc tcp_server_port is not configured: #{inspect(port)}"
+ end
+ end
+
+ defp wait_for_port_free(port, attempts \\ 50, delay_ms \\ 100)
+ defp wait_for_port_free(_port, 0, _delay_ms), do: :ok
+
+ defp wait_for_port_free(port, attempts, delay_ms) do
+ case :gen_tcp.connect({127, 0, 0, 1}, port, [:binary, active: false], 100) do
+ {:ok, socket} ->
+ :gen_tcp.close(socket)
+ Process.sleep(delay_ms)
+ wait_for_port_free(port, attempts - 1, delay_ms)
+
+ {:error, _} ->
+ :ok
+ end
+ end
+
+ defp wait_for_port(_host, _port, 0, _delay_ms), do: raise("gen_rpc tcp server did not start in time")
+
+ defp wait_for_port(host, port, attempts, delay_ms) do
+ case :gen_tcp.connect(host, port, [:binary, active: false], 200) do
+ {:ok, socket} ->
+ :ok = :gen_tcp.close(socket)
+ :ok
+
+ {:error, _reason} ->
+ Process.sleep(delay_ms)
+ wait_for_port(host, port, attempts - 1, delay_ms)
+ end
end
end
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
index 9289af1b5..55f932cbf 100644
--- a/test/support/conn_case.ex
+++ b/test/support/conn_case.ex
@@ -16,16 +16,17 @@ defmodule RealtimeWeb.ConnCase do
"""
use ExUnit.CaseTemplate
- alias Ecto.Adapters.SQL.Sandbox
using do
quote do
# Import conveniences for testing with connections
import Generators
+ import Integrations
import TenantConnection
import Phoenix.ConnTest
import Plug.Conn
import Realtime.DataCase
+ import TestHelpers
alias RealtimeWeb.Router.Helpers, as: Routes
@@ -38,9 +39,7 @@ defmodule RealtimeWeb.ConnCase do
end
setup tags do
- pid = Sandbox.start_owner!(Realtime.Repo, shared: not tags[:async])
- on_exit(fn -> Sandbox.stop_owner(pid) end)
-
+ Realtime.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
end
diff --git a/test/support/containers.ex b/test/support/containers.ex
index cd66f2699..cb4b847bf 100644
--- a/test/support/containers.ex
+++ b/test/support/containers.ex
@@ -3,21 +3,22 @@ defmodule Containers do
alias Realtime.Tenants.Connect
alias Containers.Container
alias Realtime.Database
- alias Realtime.RateCounter
+ alias Realtime.Tenants
alias Realtime.Tenants.Migrations
use GenServer
- @image "supabase/postgres:15.8.1.040"
+ defp image, do: System.get_env("POSTGRES_IMAGE", "supabase/postgres:17.6.1.127")
+
# Pull image if not available
def pull do
- case System.cmd("docker", ["image", "inspect", @image]) do
+ case System.cmd("docker", ["image", "inspect", image()]) do
{_, 0} ->
:ok
_ ->
- IO.puts("Pulling image #{@image}. This might take a while...")
- {_, 0} = System.cmd("docker", ["pull", @image])
+ IO.puts("Pulling image #{image()}. This might take a while...")
+ {_, 0} = System.cmd("docker", ["pull", image()])
end
end
@@ -29,7 +30,14 @@ defmodule Containers do
def init(max_cases) do
existing_containers = existing_containers("realtime-test-*")
ports = for {_, port} <- existing_containers, do: port
- available_ports = Enum.shuffle(5501..9000) -- ports
+
+ partition = System.get_env("MIX_TEST_PARTITION", "1") |> String.to_integer()
+ total_partitions = System.get_env("MIX_TEST_TOTAL_PARTITIONS", "4") |> String.to_integer()
+ all_ports = 5501..9000
+ range_size = div(Enum.count(all_ports), total_partitions)
+
+ available_ports =
+ all_ports |> Enum.slice((partition - 1) * range_size, range_size) |> Enum.shuffle() |> Kernel.--(ports)
{:ok, %{existing_containers: existing_containers, ports: available_ports}, {:continue, {:pool, max_cases}}}
end
@@ -37,7 +45,13 @@ defmodule Containers do
def handle_continue({:pool, max_cases}, state) do
{:ok, _pid} =
:poolboy.start_link(
- [name: {:local, Containers.Pool}, size: max_cases + 2, max_overflow: 0, worker_module: Containers.Container],
+ [
+ strategy: :fifo,
+ name: {:local, Containers.Pool},
+ size: max_cases + 2,
+ max_overflow: 0,
+ worker_module: Containers.Container
+ ],
[]
)
@@ -55,12 +69,22 @@ defmodule Containers do
{:reply, {:ok, name, port}, %{state | existing_containers: rest}}
[] ->
- [port | ports] = state.ports
- name = "realtime-test-#{random_string(12)}"
+ {name, port, ports} = start_available_container(state.ports)
+ {:reply, {:ok, name, port}, %{state | ports: ports}}
+ end
+ end
- docker_run!(name, port)
+ defp start_available_container(ports, attempts \\ 5)
- {:reply, {:ok, name, port}, %{state | ports: ports}}
+ defp start_available_container([], _attempts), do: raise("Containers: no ports left to start a container")
+ defp start_available_container(_ports, 0), do: raise("Containers: exhausted retries starting a container")
+
+ defp start_available_container([port | ports], attempts) do
+ name = "realtime-test-#{random_string(12)}"
+
+ case docker_run(name, port) do
+ {_, 0} -> {name, port, ports}
+ {_output, _code} -> start_available_container(ports, attempts - 1)
end
end
@@ -91,7 +115,7 @@ defmodule Containers do
Migrations.run_migrations(tenant)
{:ok, pid} = Database.connect(tenant, "realtime_test", :stop)
- :ok = Migrations.create_partitions(pid)
+ :ok = Tenants.create_messages_partitions(pid)
Process.exit(pid, :normal)
tenant
@@ -110,73 +134,119 @@ defmodule Containers do
end
end
- # Might be worth changing this to {:ok, tenant}
- def checkout_tenant(opts \\ []) do
+ defp storage_up!(tenant) do
+ {:ok, db_settings} = Database.from_tenant(tenant, "realtime_test", :stop)
+
+ settings =
+ db_settings
+ |> Map.from_struct()
+ |> Keyword.new()
+
+ case Ecto.Adapters.Postgres.storage_up(settings) do
+ :ok -> :ok
+ {:error, :already_up} -> :ok
+ _ -> raise "Failed to create database"
+ end
+ end
+
+ def checkout_tenant(opts \\ []), do: do_checkout_tenant(opts, :sandbox)
+ def checkout_tenant_unboxed(opts \\ []), do: do_checkout_tenant(opts, :unboxed)
+
+ defp do_checkout_tenant(opts, mode) do
with container when is_pid(container) <- :poolboy.checkout(Containers.Pool, true, 5_000),
port <- Container.port(container) do
- tenant = Generators.tenant_fixture(%{port: port, migrations_ran: 0})
+ tenant = repo_run(mode, fn -> Generators.tenant_fixture(%{port: port, migrations_ran: 0}) end)
+
+ # TODO: REAL-818 - remove when Project Migrations v2 is done
+ Realtime.FeatureFlags.Cache.update_cache(%Realtime.Api.FeatureFlag{
+ name: "use_supabase_realtime_admin",
+ enabled: true
+ })
+
run_migrations? = Keyword.get(opts, :run_migrations, false)
- settings = Database.from_tenant(tenant, "realtime_test", :stop)
+ {:ok, settings} = Database.from_tenant(tenant, "realtime_test", :stop)
settings = %{settings | max_restarts: 0, ssl: false}
{:ok, conn} = Database.connect_db(settings)
- Postgrex.transaction(conn, fn db_conn ->
- Postgrex.query!(db_conn, "DROP SCHEMA IF EXISTS realtime CASCADE", [])
- Postgrex.query!(db_conn, "CREATE SCHEMA IF NOT EXISTS realtime", [])
- end)
-
- Process.exit(conn, :normal)
+ try do
+ reset_realtime_schema!(settings)
+ storage_up!(tenant)
- RateCounter.stop(tenant.external_id)
+ RateCounterHelper.stop(tenant.external_id)
- # Automatically checkin the container at the end of the test
- ExUnit.Callbacks.on_exit(fn ->
- # Clean up database connections if they are set-up
+ ExUnit.Callbacks.on_exit(fn ->
+ if connect_pid = Connect.whereis(tenant.external_id) do
+ supervisor = {:via, PartitionSupervisor, {Realtime.Tenants.Connect.DynamicSupervisor, tenant.external_id}}
- if connect_pid = Connect.whereis(tenant.external_id) do
- supervisor = {:via, PartitionSupervisor, {Realtime.Tenants.Connect.DynamicSupervisor, tenant.external_id}}
+ DynamicSupervisor.terminate_child(supervisor, connect_pid)
+ end
- DynamicSupervisor.terminate_child(supervisor, connect_pid)
- end
+ try do
+ PostgresCdcRls.handle_stop(tenant.external_id, 5_000)
+ catch
+ _, _ -> :ok
+ end
- try do
- PostgresCdcRls.handle_stop(tenant.external_id, 5_000)
- catch
- _, _ -> :ok
- end
+ if mode == :unboxed do
+ repo_run(:unboxed, fn -> Realtime.Api.delete_tenant_by_external_id(tenant.external_id) end)
+ end
- :poolboy.checkin(Containers.Pool, container)
- end)
+ :poolboy.checkin(Containers.Pool, container)
+ end)
- tenant =
if run_migrations? do
case run_migrations(tenant) do
{:ok, count} ->
- # Avoiding to use Tenants.update_migrations_ran/2 because it touches Cachex and it doesn't play well with
- # Ecto Sandbox
- :ok = Migrations.create_partitions(conn)
- {:ok, tenant} = Realtime.Api.update_tenant(tenant, %{migrations_ran: count})
+ :ok = Tenants.create_messages_partitions(conn)
+
+ {:ok, tenant} =
+ repo_run(mode, fn ->
+ Realtime.Api.update_tenant_by_external_id(tenant.external_id, %{migrations_ran: count})
+ end)
+
+ if mode == :sandbox, do: Realtime.Tenants.Cache.invalidate_tenant_cache(tenant.external_id)
+
tenant
- _ ->
- raise "Faled to run migrations"
+ error ->
+ raise "Failed to run migrations: #{inspect(error)}"
end
else
tenant
end
-
- tenant
+ after
+ GenServer.stop(conn)
+ end
else
_ -> {:error, "failed to checkout a container"}
end
end
+ defp repo_run(:unboxed, fun), do: Ecto.Adapters.SQL.Sandbox.unboxed_run(Realtime.Repo, fun)
+ defp repo_run(:sandbox, fun), do: fun.()
+
+ defp reset_realtime_schema!(settings) do
+ {:ok, admin_conn} =
+ Postgrex.start_link(
+ hostname: settings.hostname,
+ port: settings.port,
+ database: settings.database,
+ username: "supabase_admin",
+ password: settings.password
+ )
+
+ Postgrex.query!(admin_conn, "DROP PUBLICATION IF EXISTS supabase_realtime_test", [])
+ Postgrex.query!(admin_conn, "DROP SCHEMA IF EXISTS realtime CASCADE", [])
+ Postgrex.query!(admin_conn, "CREATE SCHEMA realtime", [])
+ Postgrex.query!(admin_conn, "GRANT USAGE ON SCHEMA realtime TO postgres, anon, authenticated, service_role", [])
+ Postgrex.query!(admin_conn, "GRANT ALL ON SCHEMA realtime TO supabase_realtime_admin WITH GRANT OPTION", [])
+ end
+
def stop_containers() do
{list, 0} = System.cmd("docker", ["ps", "-a", "--format", "{{.Names}}", "--filter", "name=realtime-test-*"])
- names = list |> String.trim() |> String.split("\n")
- for name <- names do
+ for name <- String.split(list, "\n", trim: true) do
System.cmd("docker", ["rm", "-f", name])
end
end
@@ -222,7 +292,7 @@ defmodule Containers do
# This exists so we avoid using an external process on Realtime.Tenants.Migrations
defp run_migrations(tenant) do
%{extensions: [%{settings: settings} | _]} = tenant
- settings = Database.from_settings(settings, "realtime_migrations", :stop)
+ {:ok, settings} = Database.from_settings(settings, "realtime_migrations", :stop)
[
hostname: settings.hostname,
@@ -251,8 +321,16 @@ defmodule Containers do
end
defp docker_run!(name, port) do
- {_, 0} =
- System.cmd("docker", [
+ {_, 0} = docker_run(name, port)
+ end
+
+ defp docker_run(name, port) do
+ initdb_sh = Path.expand("../../dev/postgres/za-permit-supabase-admin.sh", __DIR__)
+ initdb_sql = Path.expand("../../dev/postgres/zb-supabase-schema.sql", __DIR__)
+
+ System.cmd(
+ "docker",
+ [
"run",
"-d",
"--rm",
@@ -262,12 +340,24 @@ defmodule Containers do
"POSTGRES_HOST=/var/run/postgresql",
"-e",
"POSTGRES_PASSWORD=postgres",
+ "-v",
+ "#{initdb_sh}:/docker-entrypoint-initdb.d/za-permit-supabase-admin.sh",
+ "-v",
+ "#{initdb_sql}:/docker-entrypoint-initdb.d/zb-supabase-schema.sql",
"-p",
"#{port}:5432",
- @image,
+ image(),
"postgres",
"-c",
- "config_file=/etc/postgresql/postgresql.conf"
- ])
+ "config_file=/etc/postgresql/postgresql.conf",
+ "-c",
+ "wal_keep_size=32MB",
+ "-c",
+ "max_wal_size=32MB",
+ "-c",
+ "max_slot_wal_keep_size=32MB"
+ ],
+ stderr_to_stdout: true
+ )
end
end
diff --git a/test/support/data_case.ex b/test/support/data_case.ex
index 3c9cd02b8..65cd08f84 100644
--- a/test/support/data_case.ex
+++ b/test/support/data_case.ex
@@ -25,13 +25,18 @@ defmodule Realtime.DataCase do
import Realtime.DataCase
import Generators
import TenantConnection
+ import TestHelpers
end
end
- setup tags do
+ def setup_sandbox(tags) do
pid = Sandbox.start_owner!(Realtime.Repo, shared: not tags[:async])
on_exit(fn -> Sandbox.stop_owner(pid) end)
+ :ok
+ end
+ setup tags do
+ setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
diff --git a/test/support/generators.ex b/test/support/generators.ex
index 768e3823b..5669e15c5 100644
--- a/test/support/generators.ex
+++ b/test/support/generators.ex
@@ -20,10 +20,12 @@ defmodule Generators do
"settings" => %{
"db_host" => "127.0.0.1",
"db_name" => "postgres",
- "db_user" => "supabase_admin",
+ "db_user" => System.get_env("DB_USER", "supabase_admin"),
"db_password" => "postgres",
+ "db_user_realtime" => System.get_env("DB_USER_REALTIME", "supabase_realtime_admin"),
+ "db_pass_realtime" => "postgres",
"db_port" => "#{override[:port] || port()}",
- "poll_interval" => 100,
+ "poll_interval_ms" => 10,
"poll_max_changes" => 100,
"poll_max_record_bytes" => 1_048_576,
"region" => "us-east-1",
@@ -48,7 +50,7 @@ defmodule Generators do
@spec message_fixture(Realtime.Api.Tenant.t()) :: any()
def message_fixture(tenant, override \\ %{}) do
{:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
- Realtime.Tenants.Migrations.create_partitions(db_conn)
+ Realtime.Tenants.create_messages_partitions(db_conn)
create_attrs = %{
"topic" => random_string(),
@@ -283,25 +285,31 @@ defmodule Generators do
jwt
end
- @port 4003
- @serializer Phoenix.Socket.V1.JSONSerializer
+ defp test_port do
+ :realtime
+ |> Application.get_env(RealtimeWeb.Endpoint, %{})
+ |> get_in([:http, :port]) || 4002
+ end
+
+ def get_connection(tenant, serializer \\ Phoenix.Socket.V1.JSONSerializer, opts \\ []) do
+ params = Keyword.get(opts, :params, %{log_level: :warning})
+ claims = Keyword.get(opts, :claims, %{})
+ role = Keyword.get(opts, :role, "anon")
- def get_connection(
- tenant,
- role \\ "anon",
- claims \\ %{},
- params \\ %{vsn: "1.0.0", log_level: :warning}
- ) do
params = Enum.reduce(params, "", fn {k, v}, acc -> "#{acc}{k}=#{v}" end)
- uri = "#{uri(tenant)}?#{params}"
+ uri = "#{uri(tenant, serializer)}{params}"
with {:ok, token} <- token_valid(tenant, role, claims),
- {:ok, socket} <- WebsocketClient.connect(self(), uri, @serializer, [{"x-api-key", token}]) do
+ {:ok, socket} <- WebsocketClient.connect(self(), uri, serializer, [{"x-api-key", token}]) do
{socket, token}
end
end
- def uri(tenant, port \\ @port), do: "ws://#{tenant.external_id}.localhost:#{port}/socket/websocket"
+ def uri(tenant, serializer, port \\ nil),
+ do: "ws://#{tenant.external_id}.localhost:#{port || test_port()}/socket/websocket?vsn=#{vsn(serializer)}"
+
+ defp vsn(Phoenix.Socket.V1.JSONSerializer), do: "1.0.0"
+ defp vsn(RealtimeWeb.Socket.V2Serializer), do: "2.0.0"
@spec token_valid(Tenant.t(), binary(), map()) :: {:ok, binary()}
def token_valid(tenant, role, claims \\ %{}), do: generate_token(tenant, Map.put(claims, :role, role))
diff --git a/test/support/integrations.ex b/test/support/integrations.ex
new file mode 100644
index 000000000..8ed2bc06d
--- /dev/null
+++ b/test/support/integrations.ex
@@ -0,0 +1,125 @@
+defmodule Integrations do
+ import ExUnit.Assertions
+ import Generators
+
+ alias Realtime.Api.Tenant
+ alias Realtime.Database
+ alias Realtime.Tenants.Authorization
+ alias Realtime.Tenants.Connect
+
+ def checkout_tenant_and_connect(_context \\ %{}) do
+ tenant = Containers.checkout_tenant(run_migrations: true)
+ {:ok, db_conn} = Connect.lookup_or_start_connection(tenant.external_id)
+ assert Connect.ready?(tenant.external_id)
+ %{db_conn: db_conn, tenant: tenant}
+ end
+
+ def rls_context(%{tenant: tenant} = context) do
+ {:ok, db_conn} = Database.connect(tenant, "realtime_test", :stop)
+ clean_table(db_conn, "realtime", "messages")
+ topic = Map.get(context, :topic, random_string())
+ policies = Map.get(context, :policies, nil)
+ role = Map.get(context, :role, nil)
+ sub = Map.get(context, :sub, nil)
+
+ if policies, do: create_rls_policies(db_conn, policies, %{topic: topic, role: role, sub: sub})
+
+ authorization_context =
+ Authorization.build_authorization_params(%{
+ tenant_id: tenant.external_id,
+ topic: topic,
+ headers: [{"header-1", "value-1"}],
+ claims: %{sub: sub, role: role},
+ role: role,
+ sub: sub
+ })
+
+ ExUnit.Callbacks.on_exit(fn ->
+ if Process.alive?(db_conn) do
+ try do
+ GenServer.stop(db_conn, :normal, 1_000)
+ catch
+ :exit, _ -> :ok
+ end
+ end
+ end)
+
+ %{topic: topic, role: role, sub: sub, db_conn: db_conn, authorization_context: authorization_context}
+ end
+
+ def change_tenant_configuration(%Tenant{external_id: external_id}, limit, value) do
+ tenant =
+ external_id
+ |> Realtime.Tenants.get_tenant_by_external_id()
+ |> Tenant.changeset(%{limit => value})
+ |> Realtime.Repo.update!()
+
+ Realtime.Tenants.Cache.update_cache(tenant)
+ end
+
+ def checkout_tenant_connect_and_setup_postgres_changes(_context \\ %{}) do
+ %{db_conn: db_conn} = result = checkout_tenant_and_connect()
+ setup_postgres_changes(db_conn)
+ result
+ end
+
+ def setup_postgres_changes(conn) do
+ publication = "supabase_realtime_test"
+
+ Postgrex.transaction(conn, fn db_conn ->
+ queries = [
+ "DROP TABLE IF EXISTS public.test",
+ "DROP PUBLICATION IF EXISTS #{publication}",
+ "create sequence if not exists test_id_seq;",
+ """
+ create table "public"."test" (
+ "id" int4 not null default nextval('test_id_seq'::regclass),
+ "details" text,
+ "binary_data" bytea,
+ primary key ("id"));
+ """,
+ "grant all on table public.test to anon;",
+ "grant all on table public.test to supabase_realtime_admin;",
+ "grant all on table public.test to authenticated;",
+ # `for all tables` requires superuser
+ "create publication #{publication} for table public.test",
+ """
+ DO $$
+ DECLARE
+ r RECORD;
+ BEGIN
+ FOR r IN
+ SELECT slot_name, active_pid
+ FROM pg_replication_slots
+ WHERE slot_name LIKE 'supabase_realtime%'
+ LOOP
+ IF r.active_pid IS NOT NULL THEN
+ BEGIN
+ SELECT pg_terminate_backend(r.active_pid);
+ PERFORM pg_sleep(0.5);
+ EXCEPTION WHEN OTHERS THEN
+ NULL;
+ END;
+ END IF;
+
+ BEGIN
+ IF EXISTS (SELECT 1 FROM pg_replication_slots WHERE slot_name = r.slot_name) THEN
+ PERFORM pg_drop_replication_slot(r.slot_name);
+ END IF;
+ EXCEPTION WHEN OTHERS THEN
+ NULL;
+ END;
+ END LOOP;
+ END$$;
+ """
+ ]
+
+ Enum.each(queries, &Postgrex.query!(db_conn, &1, []))
+ end)
+ end
+
+ def assert_process_down(pid, timeout \\ 5000) do
+ ref = Process.monitor(pid)
+ assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, timeout
+ end
+end
diff --git a/test/support/metrics_helper.ex b/test/support/metrics_helper.ex
new file mode 100644
index 000000000..ca31ad91b
--- /dev/null
+++ b/test/support/metrics_helper.ex
@@ -0,0 +1,53 @@
+defmodule MetricsHelper do
+ @spec search(String.t(), String.t(), map() | keyword() | nil) ::
+ {:ok, String.t(), map(), String.t()} | {:error, String.t()}
+ def search(prometheus_metrics, metric_name, expected_tags \\ nil) do
+ # Escape the metric_name to handle any special regex characters
+ escaped_name = Regex.escape(metric_name)
+ regex = ~r/^(?#{escaped_name})\{(?[^}]+)\}\s+(?\d+(?:\.\d+)?)$/
+
+ prometheus_metrics
+ |> IO.iodata_to_binary()
+ |> String.split("\n", trim: true)
+ |> Enum.find_value(
+ nil,
+ fn item ->
+ case parse(item, regex, expected_tags) do
+ {:ok, value} -> value
+ {:error, _reason} -> false
+ end
+ end
+ )
+ |> case do
+ nil -> nil
+ number -> String.to_integer(number)
+ end
+ end
+
+ defp parse(metric_string, regex, expected_tags) do
+ case Regex.named_captures(regex, metric_string) do
+ %{"name" => _name, "tags" => tags_string, "value" => value} ->
+ tags = parse_tags(tags_string)
+
+ if expected_tags && !matching_tags(tags, expected_tags) do
+ {:error, "Tags do not match expected tags"}
+ else
+ {:ok, value}
+ end
+
+ nil ->
+ {:error, "Invalid metric format or metric name mismatch"}
+ end
+ end
+
+ defp parse_tags(tags_string) do
+ ~r/(?[a-zA-Z_][a-zA-Z0-9_]*)="(?[^"]*)"/
+ |> Regex.scan(tags_string, capture: :all_names)
+ |> Enum.map(fn [key, value] -> {key, value} end)
+ |> Map.new()
+ end
+
+ defp matching_tags(tags, expected_tags) do
+ Enum.all?(expected_tags, fn {k, v} -> Map.get(tags, to_string(k)) == to_string(v) end)
+ end
+end
diff --git a/test/support/prometheus_fixtures.ex b/test/support/prometheus_fixtures.ex
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/support/rate_counter_helper.ex b/test/support/rate_counter_helper.ex
new file mode 100644
index 000000000..7a290d5a3
--- /dev/null
+++ b/test/support/rate_counter_helper.ex
@@ -0,0 +1,56 @@
+defmodule RateCounterHelper do
+ alias Realtime.RateCounter
+
+ @spec new!(RateCounter.Args.t()) :: pid()
+ def new!(args) do
+ {:ok, _} = RateCounter.new(args)
+ [{pid, _}] = Registry.lookup(Realtime.Registry.Unique, {RateCounter, :rate_counter, args.id})
+ await_initial_tick(pid)
+ pid
+ end
+
+ defp await_initial_tick(pid) do
+ case :sys.get_state(pid) do
+ %RateCounter{bucket: []} -> await_initial_tick(pid)
+ state -> state
+ end
+ end
+
+ @spec stop(term()) :: :ok
+ def stop(tenant_id) do
+ keys =
+ Registry.select(Realtime.Registry.Unique, [
+ {{{:"$1", :_, {:_, :_, :"$2"}}, :"$3", :_}, [{:==, :"$1", RateCounter}, {:==, :"$2", tenant_id}], [:"$_"]}
+ ])
+
+ Enum.each(keys, fn {{_, _, key}, {pid, _}} ->
+ if Process.alive?(pid), do: GenServer.stop(pid)
+ Realtime.GenCounter.delete(key)
+ Cachex.del!(RateCounter, key)
+ end)
+
+ :ok
+ end
+
+ @spec tick!(RateCounter.Args.t()) :: RateCounter.t()
+ def tick!(args) do
+ [{pid, _}] = Registry.lookup(Realtime.Registry.Unique, {RateCounter, :rate_counter, args.id})
+ send(pid, :tick)
+ {:ok, :sys.get_state(pid)}
+ end
+
+ def tick_tenant_rate_counters!(tenant_id) do
+ keys =
+ Registry.select(Realtime.Registry.Unique, [
+ {{{:"$1", :_, {:_, :_, :"$2"}}, :"$3", :_}, [{:==, :"$1", RateCounter}, {:==, :"$2", tenant_id}], [:"$_"]}
+ ])
+
+ Enum.each(keys, fn {{_, _, _key}, {pid, _}} ->
+ send(pid, :tick)
+ # do a get_state to wait for the tick to be processed
+ :sys.get_state(pid)
+ end)
+
+ :ok
+ end
+end
diff --git a/test/support/tenant_connection.ex b/test/support/tenant_connection.ex
index ce5956b49..77328bdfc 100644
--- a/test/support/tenant_connection.ex
+++ b/test/support/tenant_connection.ex
@@ -4,17 +4,17 @@ defmodule TenantConnection do
"""
alias Realtime.Api.Message
alias Realtime.Database
- alias Realtime.Repo
+ alias Realtime.Tenants.Repo
alias Realtime.Tenants.Connect
alias RealtimeWeb.Endpoint
def create_message(attrs, conn, opts \\ [mode: :savepoint]) do
- channel = Message.changeset(%Message{}, attrs)
+ message = Message.changeset(%Message{}, attrs)
{:ok, result} =
Database.transaction(conn, fn transaction_conn ->
- with {:ok, %Message{} = channel} <- Repo.insert(transaction_conn, channel, Message, opts) do
- channel
+ with {:ok, %Message{} = message} <- Repo.insert(transaction_conn, message, Message, opts) do
+ message
end
end)
diff --git a/test/support/test_endpoint.ex b/test/support/test_endpoint.ex
deleted file mode 100644
index 67c477153..000000000
--- a/test/support/test_endpoint.ex
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule TestEndpoint do
- use Phoenix.Endpoint, otp_app: :phoenix
-
- @session_config store: :cookie,
- key: "_hello_key",
- signing_salt: "change_me"
-
- socket("/socket", RealtimeWeb.UserSocket,
- websocket: [
- connect_info: [:peer_data, :uri, :x_headers],
- fullsweep_after: 20,
- max_frame_size: 8_000_000
- ]
- )
-
- plug(Plug.Session, @session_config)
- plug(:fetch_session)
- plug(Plug.CSRFProtection)
- plug(:put_session)
-
- defp put_session(conn, _) do
- conn
- |> put_session(:from_session, "123")
- |> send_resp(200, Plug.CSRFProtection.get_csrf_token())
- end
-end
diff --git a/test/support/test_helpers.ex b/test/support/test_helpers.ex
new file mode 100644
index 000000000..74239537c
--- /dev/null
+++ b/test/support/test_helpers.ex
@@ -0,0 +1,33 @@
+defmodule TestHelpers do
+ @moduledoc """
+ Generic helpers for tests.
+ """
+
+ @doc """
+ Runs `fun` until it returns a truthy value, retrying until it runs out of retries.
+ Returns `true` if `fun` succeeded within the retries, `false` otherwise.
+
+ ## Options
+
+ * `:retries` - how many times to retry before giving up (default: `50`)
+ * `:sleep` - how long to wait between retries, in milliseconds (default: `100`)
+ """
+ @spec eventually((-> as_boolean(term())), keyword()) :: boolean()
+ def eventually(fun, opts \\ []) do
+ retries = Keyword.get(opts, :retries, 50)
+ sleep = Keyword.get(opts, :sleep, 100)
+
+ cond do
+ fun.() ->
+ true
+
+ retries == 0 ->
+ false
+
+ true ->
+ opts = Keyword.put(opts, :retries, retries - 1)
+ Process.sleep(sleep)
+ eventually(fun, opts)
+ end
+ end
+end
diff --git a/test/support/websocket_client.ex b/test/support/websocket_client.ex
index 1e7204f50..fd4d5a7bb 100644
--- a/test/support/websocket_client.ex
+++ b/test/support/websocket_client.ex
@@ -61,6 +61,18 @@ defmodule Realtime.Integration.WebsocketClient do
"""
def send_heartbeat(socket), do: send_event(socket, "phoenix", "heartbeat", %{})
+ @doc """
+ Sends a user broadcast push (V2 binary wire format, type 3). `payload` is the
+ raw user payload (binary). `opts` may include `:encoding` (`:binary` (default)
+ or `:json`) and `:metadata` (a map JSON-encoded into the frame).
+ """
+ def send_user_broadcast(socket, topic, user_event, payload, opts \\ []) do
+ encoding = Keyword.get(opts, :encoding, :binary)
+ metadata = Keyword.get(opts, :metadata)
+ payload_tuple = {user_event, encoding, payload, metadata}
+ GenServer.call(socket, {:send, %Message{topic: topic, event: "broadcast", payload: payload_tuple}})
+ end
+
@doc """
Sends join event to the WebSocket server per the Message protocol
"""
@@ -223,7 +235,8 @@ defmodule Realtime.Integration.WebsocketClient do
{[binary_decode(data)], state}
# prepare to close the connection when a close frame is received
- {:close, _code, _data}, state ->
+ {:close, code, _data}, state ->
+ Kernel.send(state.sender, {:close_code, code})
{[], put_in(state.closing?, true)}
frame, state ->
@@ -265,6 +278,15 @@ defmodule Realtime.Integration.WebsocketClient do
{{:binary, binary_encode_push!(msg)}, put_in(state.ref, ref + 1)}
end
+ defp serialize_msg(%Message{payload: {user_event, encoding, user_payload, metadata}} = msg, %{ref: ref} = state)
+ when is_binary(user_event) and encoding in [:json, :binary] and is_binary(user_payload) do
+ {join_ref, state} = join_ref_for(msg, state)
+ msg = Map.merge(msg, %{ref: to_string(ref), join_ref: to_string(join_ref)})
+
+ {{:binary, binary_encode_user_broadcast_push!(msg, user_event, encoding, user_payload, metadata)},
+ put_in(state.ref, ref + 1)}
+ end
+
defp serialize_msg(%Message{} = msg, %{ref: ref} = state) do
{join_ref, state} = join_ref_for(msg, state)
msg = Map.merge(msg, %{ref: to_string(ref), join_ref: to_string(join_ref)})
@@ -290,6 +312,29 @@ defmodule Realtime.Integration.WebsocketClient do
IO.chardata_to_string(chardata)
end
+ defp binary_encode_user_broadcast_push!(%Message{} = msg, user_event, encoding, user_payload, metadata) do
+ ref = to_string(msg.ref)
+ join_ref = to_string(msg.join_ref)
+ metadata_bin = if metadata, do: Jason.encode!(metadata), else: <<>>
+ encoding_byte = if encoding == :json, do: 1, else: 0
+
+ <<
+ 3::size(8),
+ byte_size(join_ref)::size(8),
+ byte_size(ref)::size(8),
+ byte_size(msg.topic)::size(8),
+ byte_size(user_event)::size(8),
+ byte_size(metadata_bin)::size(8),
+ encoding_byte::size(8),
+ join_ref::binary,
+ ref::binary,
+ msg.topic::binary,
+ user_event::binary,
+ metadata_bin::binary,
+ user_payload::binary
+ >>
+ end
+
defp binary_encode_push!(%Message{payload: {:binary, data}} = msg) do
ref = to_string(msg.ref)
join_ref = to_string(msg.join_ref)
@@ -342,4 +387,34 @@ defmodule Realtime.Integration.WebsocketClient do
payload = %{"status" => status, "response" => {:binary, data}}
%Message{join_ref: join_ref, ref: ref, topic: topic, event: "phx_reply", payload: payload}
end
+
+ # user broadcast
+ defp binary_decode(<<
+ 4::size(8),
+ topic_size::size(8),
+ user_event_size::size(8),
+ metadata_size::size(8),
+ user_payload_encoding::size(8),
+ topic::binary-size(topic_size),
+ user_event::binary-size(user_event_size),
+ metadata::binary-size(metadata_size),
+ user_payload::binary
+ >>) do
+ decoded_metadata = if metadata_size > 0, do: Jason.decode!(metadata), else: %{}
+
+ decoded_payload =
+ case user_payload_encoding do
+ 1 -> Jason.decode!(user_payload)
+ 0 -> {:binary, user_payload}
+ end
+
+ payload = %{
+ "event" => user_event,
+ "payload" => decoded_payload,
+ "type" => "broadcast",
+ "meta" => decoded_metadata
+ }
+
+ %Message{topic: topic, event: "broadcast", payload: payload}
+ end
end
diff --git a/test/test_helper.exs b/test/test_helper.exs
index 435f00ef8..030148c89 100644
--- a/test/test_helper.exs
+++ b/test/test_helper.exs
@@ -1,8 +1,33 @@
start_time = :os.system_time(:millisecond)
alias Realtime.Api
-alias Realtime.Database
-ExUnit.start(exclude: [:failing], max_cases: 3, capture_log: true)
+max_cases = String.to_integer(System.get_env("MAX_CASES", "4"))
+
+repo_config = Application.fetch_env!(:realtime, Realtime.Repo)
+
+{:ok, pg_conn} =
+ Postgrex.start_link(
+ hostname: repo_config[:hostname],
+ port: repo_config[:port] || 5432,
+ username: repo_config[:username],
+ password: repo_config[:password],
+ database: "postgres"
+ )
+
+%{rows: [[pg_version_num]]} = Postgrex.query!(pg_conn, "SELECT current_setting('server_version_num')::int")
+
+%{rows: [[has_supautils_subscription_grants]]} =
+ Postgrex.query!(pg_conn, "SELECT current_setting('supautils.policy_grants', true) LIKE '%realtime.subscription%'")
+
+# `realtime.broadcast_changes(..., NEW record, OLD record, ...)` (introduced in commit 2922658c) called from a trigger via `PERFORM` fails on PG <= 14.5
+requires_pg_140006 = if pg_version_num < 140_006, do: :requires_pg_140006
+
+# Restriction assertions on the postgres role only hold on builds where supautils.policy_grants includes realtime.subscription (supabase/postgres 15.14.1.018 or higher)
+requires_supautils_policy_grants = if !has_supautils_subscription_grants, do: :requires_supautils_policy_grants
+
+exclude = [:failing, requires_pg_140006, requires_supautils_policy_grants]
+
+ExUnit.start(exclude: exclude, max_cases: max_cases, capture_log: true)
max_cases = ExUnit.configuration()[:max_cases]
@@ -10,53 +35,30 @@ Containers.pull()
if System.get_env("REUSE_CONTAINERS") != "true" do
Containers.stop_containers()
- Containers.stop_container("dev_tenant")
end
{:ok, _pid} = Containers.start_link(max_cases)
-for tenant <- Api.list_tenants(), do: Api.delete_tenant(tenant)
-
-tenant_name = "dev_tenant"
-tenant = Containers.initialize(tenant_name)
-publication = "supabase_realtime_test"
-
-# Start dev_realtime container to be used in integration tests
-{:ok, conn} = Database.connect(tenant, "realtime_seed", :stop)
-
-Database.transaction(conn, fn db_conn ->
- queries = [
- "DROP TABLE IF EXISTS public.test",
- "DROP PUBLICATION IF EXISTS #{publication}",
- "create sequence if not exists test_id_seq;",
- """
- create table "public"."test" (
- "id" int4 not null default nextval('test_id_seq'::regclass),
- "details" text,
- primary key ("id"));
- """,
- "grant all on table public.test to anon;",
- "grant all on table public.test to postgres;",
- "grant all on table public.test to authenticated;",
- "create publication #{publication} for all tables"
- ]
-
- Enum.each(queries, &Postgrex.query!(db_conn, &1, []))
-end)
+for tenant <- Api.list_tenants(), do: Api.delete_tenant_by_external_id(tenant.external_id)
Ecto.Adapters.SQL.Sandbox.mode(Realtime.Repo, :manual)
-end_time = :os.system_time(:millisecond)
-IO.puts("[test_helper.exs] Time to start tests: #{end_time - start_time} ms")
-
Mimic.copy(:syn)
+Mimic.copy(Ecto.Migrator)
+Mimic.copy(Extensions.PostgresCdcRls)
+Mimic.copy(Extensions.PostgresCdcRls.Replications)
+Mimic.copy(Extensions.PostgresCdcRls.Subscriptions)
+Mimic.copy(Realtime.Database)
+Mimic.copy(Realtime.FeatureFlags)
Mimic.copy(Realtime.GenCounter)
+Mimic.copy(Realtime.GenRpc)
Mimic.copy(Realtime.Nodes)
+Mimic.copy(Realtime.Repo.Replica)
Mimic.copy(Realtime.RateCounter)
Mimic.copy(Realtime.Tenants.Authorization)
Mimic.copy(Realtime.Tenants.Cache)
+Mimic.copy(Realtime.Tenants.Repo)
Mimic.copy(Realtime.Tenants.Connect)
-Mimic.copy(Realtime.Database)
Mimic.copy(Realtime.Tenants.Migrations)
Mimic.copy(Realtime.Tenants.Rebalancer)
Mimic.copy(Realtime.Tenants.ReplicationConnection)
@@ -64,3 +66,14 @@ Mimic.copy(RealtimeWeb.ChannelsAuthorization)
Mimic.copy(RealtimeWeb.Endpoint)
Mimic.copy(RealtimeWeb.JwtVerification)
Mimic.copy(RealtimeWeb.TenantBroadcaster)
+Mimic.copy(NimbleZTA.Cloudflare)
+
+partition = System.get_env("MIX_TEST_PARTITION")
+node_name = if partition, do: :"main#{partition}@127.0.0.1", else: :"main@127.0.0.1"
+:net_kernel.start([node_name])
+region = Application.get_env(:realtime, :region)
+[{pid, _}] = :syn.members(RegionNodes, region)
+:syn.update_member(RegionNodes, region, pid, fn _ -> [node: node()] end)
+
+end_time = :os.system_time(:millisecond)
+IO.puts("[test_helper.exs] Time to start tests: #{end_time - start_time} ms")
]