├── test
├── e2e
│ ├── .gitignore
│ ├── .tool-versions
│ └── .template.env
├── realtime_web
│ ├── views
│ │ ├── page_view_test.exs
│ │ ├── layout_view_test.exs
│ │ └── error_view_test.exs
│ ├── live
│ │ ├── inspector_live
│ │ │ └── index_test.exs
│ │ ├── page_live
│ │ │ └── index_test.exs
│ │ ├── status_live
│ │ │ └── index_test.exs
│ │ └── tenants_live
│ │ │ └── index_test.exs
│ ├── controllers
│ │ ├── page_controller_test.exs
│ │ ├── openapi_controller_test.exs
│ │ └── live_dasboard_test.exs
│ ├── channels
│ │ ├── tenant_rate_limiters_test.exs
│ │ └── auth
│ │ │ └── channels_authorization_test.exs
│ ├── integration
│ │ └── tracing_test.exs
│ └── plugs
│ │ └── rate_limiter_test.exs
├── realtime
│ ├── helpers_test.exs
│ ├── oid_test.exs
│ ├── adapters
│ │ └── postgres
│ │ │ └── protocol_test.exs
│ ├── monitoring
│ │ ├── prom_ex_test.exs
│ │ ├── erl_sys_mon_test.exs
│ │ ├── distributed_metrics_test.exs
│ │ └── latency_test.exs
│ ├── logs_test.exs
│ ├── telemetry
│ │ └── logger_test.exs
│ ├── signal_handler_test.exs
│ ├── tenants
│ │ ├── migrations_test.exs
│ │ ├── rebalancer_test.exs
│ │ └── connect
│ │ │ └── register_process_test.exs
│ └── metrics_cleaner_test.exs
├── support
│ ├── replication_test_handler.ex
│ ├── tracing.ex
│ ├── channel_case.ex
│ ├── joken_current_time_mock.ex
│ ├── rate_counter_helper.ex
│ ├── cleanup.ex
│ ├── containers
│ │ └── container.ex
│ ├── conn_case.ex
│ ├── tenant_connection.ex
│ ├── data_case.ex
│ └── metrics_helper.ex
└── api_jwt_secret_test.exs
├── .tool-versions
├── lib
├── realtime.ex
├── realtime_web
│ ├── views
│ │ ├── layout_view.ex
│ │ ├── error_view.ex
│ │ ├── changeset_view.ex
│ │ ├── tenant_view.ex
│ │ └── error_helpers.ex
│ ├── controllers
│ │ ├── ping_controller.ex
│ │ ├── page_controller.ex
│ │ └── broadcast_controller.ex
│ ├── channels
│ │ ├── presence.ex
│ │ ├── payloads
│ │ │ ├── presence.ex
│ │ │ ├── broadcast
│ │ │ │ └── replay.ex
│ │ │ ├── postgres_change.ex
│ │ │ ├── broadcast.ex
│ │ │ ├── config.ex
│ │ │ └── join.ex
│ │ ├── realtime_channel
│ │ │ └── assign.ex
│ │ ├── tenant_rate_limiters.ex
│ │ └── auth
│ │ │ └── channels_authorization.ex
│ ├── live
│ │ ├── page_live
│ │ │ ├── index.ex
│ │ │ └── index.html.heex
│ │ ├── time_live.ex
│ │ ├── status_live
│ │ │ ├── index.html.heex
│ │ │ └── index.ex
│ │ ├── ping_live.ex
│ │ └── tenants_live
│ │ │ └── index.html.heex
│ ├── templates
│ │ └── layout
│ │ │ └── root.html.heex
│ ├── gettext.ex
│ ├── plugs
│ │ ├── rate_limiter.ex
│ │ ├── baggage_request_id.ex
│ │ ├── assign_tenant.ex
│ │ └── auth_tenant.ex
│ ├── api_spec.ex
│ ├── dashboard
│ │ └── process_dump.ex
│ ├── socket
│ │ └── user_broadcast.ex
│ └── telemetry.ex
├── realtime
│ ├── tenants
│ │ ├── repo
│ │ │ └── migrations
│ │ │ │ ├── 20231204144025_enable_channels_rls.ex
│ │ │ │ ├── 20240919163305_change_messages_id_type.ex
│ │ │ │ ├── 20211122062447_grant_realtime_usage_to_authenticated_role.ex
│ │ │ │ ├── 20250506224012_subscription_index_bridging_disabled.ex
│ │ │ │ ├── 20241019105805_uuid_auto_generation.ex
│ │ │ │ ├── 20250523164012_run_subscription_index_bridging_disabled.ex
│ │ │ │ ├── 20240109165339_add_update_grant_to_channels.ex
│ │ │ │ ├── 20240805133720_logged_messages_table.ex
│ │ │ │ ├── 20240108234812_add_channels_column_for_write_check.ex
│ │ │ │ ├── 20240801235015_unlogged_messages_table.ex
│ │ │ │ ├── 20250107150512_realtime_subscription_unlogged.ex
│ │ │ │ ├── 20231018144023_create_channels.ex
│ │ │ │ ├── 20250905041441_create_messages_replay_index.ex
│ │ │ │ ├── 20240418121054_remove_check_columns.ex
│ │ │ │ ├── 20231204144024_create_rls_helper_functions.ex
│ │ │ │ ├── 20250110162412_realtime_subscription_logged.ex
│ │ │ │ ├── 20241108114728_messages_using_uuid.ex
│ │ │ │ ├── 20211116213355_create_realtime_cast_function.ex
│ │ │ │ ├── 20250123174212_remove_unused_publications.ex
│ │ │ │ ├── 20240311171622_add_insert_and_delete_grant_to_channels.ex
│ │ │ │ ├── 20241130184212_recreate_entity_index_using_btree.ex
│ │ │ │ ├── 20231204144023_set_required_grants.ex
│ │ │ │ ├── 20240227174441_add_broadcast_permissions_table.ex
│ │ │ │ ├── 20250714121412_broadcast_send_error_logging.ex
│ │ │ │ ├── 20240321100241_add_presences_permissions_table.ex
│ │ │ │ ├── 20211116213934_create_realtime_is_visible_through_filters_function.ex
│ │ │ │ ├── 20211116051442_create_realtime_check_equality_op_function.ex
│ │ │ │ ├── 20241220123912_realtime_send_handle_exceptions_remove_partition_creation.ex
│ │ │ │ ├── 20241121104152_fix_send_function_.ex
│ │ │ │ ├── 20241224161212_realtime_send_sets_config.ex
│ │ │ │ ├── 20250128220012_realtime_send_sets_topic_config.ex
│ │ │ │ ├── 20211202204605_update_realtime_build_prepared_statement_sql_function_for_compatibility_with_all_types.ex
│ │ │ │ ├── 20241220035512_fix_send_function_partition_creation.ex
│ │ │ │ ├── 20211116050929_create_realtime_quote_wal2json_function.ex
│ │ │ │ ├── 20220712093339_recreate_realtime_build_prepared_statement_sql_function.ex
│ │ │ │ ├── 20240401105812_create_realtime_admin_and_move_ownership.ex
│ │ │ │ ├── 20251103001201_broadcast_send_include_payload_id.ex
│ │ │ │ ├── 20240523004032_redefine_authorization_tables.ex
│ │ │ │ ├── 20211116024918_create_realtime_subscription_table.ex
│ │ │ │ ├── 20220908172859_null_passes_filters_recreate_is_visible_through_filters.ex
│ │ │ │ └── 20211116212300_create_realtime_build_prepared_statement_sql_function.ex
│ │ ├── authorization
│ │ │ ├── policies
│ │ │ │ ├── presence_policies.ex
│ │ │ │ └── broadcast_policies.ex
│ │ │ └── policies.ex
│ │ ├── connect
│ │ │ ├── get_tenant.ex
│ │ │ ├── check_connection.ex
│ │ │ ├── register_process.ex
│ │ │ └── piper.ex
│ │ ├── janitor
│ │ │ └── maintenance_task.ex
│ │ ├── rebalancer.ex
│ │ └── cache.ex
│ ├── telemetry
│ │ ├── telemetry.ex
│ │ └── logger.ex
│ ├── rate_counter
│ │ └── dynamic_supervisor.ex
│ ├── repo.ex
│ ├── monitoring
│ │ ├── prom_ex
│ │ │ └── plugins
│ │ │ │ ├── channels.ex
│ │ │ │ └── tenants.ex
│ │ ├── os_metrics.ex
│ │ └── erl_sys_mon.ex
│ ├── context_cache.ex
│ ├── syn
│ │ └── postgres_cdc.ex
│ ├── adapters
│ │ ├── postgres
│ │ │ └── protocol
│ │ │ │ ├── keep_alive.ex
│ │ │ │ └── write.ex
│ │ └── changes.ex
│ ├── signal_handler.ex
│ ├── helpers.ex
│ ├── release.ex
│ ├── crypto.ex
│ ├── api
│ │ └── message.ex
│ ├── gen_counter
│ │ └── gen_counter.ex
│ ├── metrics_cleaner.ex
│ └── logs.ex
└── extensions
│ ├── extensions.ex
│ └── postgres_cdc_rls
│ ├── db_settings.ex
│ ├── supervisor.ex
│ ├── worker_supervisor.ex
│ └── message_dispatcher.ex
├── rel
├── overlays
│ ├── bin
│ │ ├── server.bat
│ │ ├── migrate.bat
│ │ ├── server
│ │ └── migrate
│ └── config.example.yml
├── env.bat.eex
├── vm.args.eex
└── env.sh.eex
├── assets
├── css
│ └── app.css
├── package.json
└── tailwind.config.js
├── priv
├── repo
│ ├── migrations
│ │ ├── .formatter.exs
│ │ ├── 20240902173232_add_extension_external_id_index.exs
│ │ ├── 20220527210857_add_external_id_uniq_index.exs
│ │ ├── 20240306114423_add_tenant_jwt_jwks.exs
│ │ ├── 20251218000543_ensure_jwt_secret_is_text.exs
│ │ ├── 20221223010058_drop_tenants_uniq_external_id_index.exs
│ │ ├── 20240625211759_remove_enable_authorization_flag.exs
│ │ ├── 20231024094642_add_tenant_suspend_flag.exs
│ │ ├── 20240418082835_add_authorization_flag.exs
│ │ ├── 20250424203323_add_migrations_ran_to_tenant.exs
│ │ ├── 20250613072131_add_tenant_broadcast_adapter.exs
│ │ ├── 20240704172020_add_notify_private_alpha.exs
│ │ ├── 20220815211129_new_max_events_per_second_default.exs
│ │ ├── 20221102172703_rename_pg_type.exs
│ │ ├── 20241106103258_add_private_only_flag_column_to_tenant.exs
│ │ ├── 20230810220924_alter_extensions_table_columns_to_text.exs
│ │ ├── 20220815215024_set_current_max_events_per_second.exs
│ │ ├── 20250711044927_change_default_broadcast_adapter_to_gen_rpc.exs
│ │ ├── 20250926223044_set_default_presence_value.exs
│ │ ├── 20250811121559_add_max_presence_events_per_second.exs
│ │ ├── 20220410212326_add_tenant_max_eps.exs
│ │ ├── 20221018173709_add_cdc_default.exs
│ │ ├── 20230810220907_alter_tenants_table_columns_to_text.exs
│ │ ├── 20220506102948_rename_poll_interval_to_poll_interval_ms.exs
│ │ ├── 20251204170944_nullable_jwt_secrets.exs
│ │ ├── 20230110180046_add_limits_fields_to_tenants.exs
│ │ ├── 20210706140551_create_tenant.exs
│ │ ├── 20220818141501_change_limits_defaults.exs
│ │ └── 20220329161857_add_extensions_table.exs
│ ├── seeds_before_migration.exs
│ └── seeds.exs
└── static
│ ├── worker.js
│ ├── robots.txt
│ └── favicon.svg
├── .releaserc
├── .sobelow-conf
├── .formatter.exs
├── bench
├── gen_counter.exs
└── secrets.exs
├── .github
└── workflows
│ ├── docker-build.yml
│ ├── integration_tests.yml
│ ├── version_updated.yml
│ ├── mirror.yml
│ ├── prod_linter.yml
│ └── tests.yml
├── docker-compose.dbs.yml
├── dev
└── postgres
│ └── 00-supabase-schema.sql
├── .credo.exs
├── .gitignore
├── coveralls.json
├── deploy
└── fly
│ ├── qa.toml
│ ├── prod.toml
│ └── staging.toml
├── .dockerignore
├── docker-compose.yml
└── config
└── test.exs
/test/e2e/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 |
--------------------------------------------------------------------------------
/test/e2e/.tool-versions:
--------------------------------------------------------------------------------
1 | deno latest
2 |
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | elixir 1.18.4-otp-27
2 | nodejs 24
3 | erlang 27
4 |
--------------------------------------------------------------------------------
/lib/realtime.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime do
2 | @moduledoc false
3 | end
4 |
--------------------------------------------------------------------------------
/rel/overlays/bin/server.bat:
--------------------------------------------------------------------------------
1 | set PHX_SERVER=true
2 | call "%~dp0\realtime" start
3 |
--------------------------------------------------------------------------------
/test/e2e/.template.env:
--------------------------------------------------------------------------------
1 | PROJECT_URL=
2 | PROJECT_ANON_TOKEN=
3 | PROJECT_JWT_SECRET=
--------------------------------------------------------------------------------
/assets/css/app.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
--------------------------------------------------------------------------------
/rel/overlays/bin/migrate.bat:
--------------------------------------------------------------------------------
1 | call "%~dp0\realtime" eval Realtime.Release.migrate
2 |
--------------------------------------------------------------------------------
/assets/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@supabase/supabase-js": "^2.85.0"
4 | }
5 | }
--------------------------------------------------------------------------------
/priv/repo/migrations/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto_sql],
3 | inputs: ["*.exs"]
4 | ]
5 |
--------------------------------------------------------------------------------
/rel/overlays/bin/server:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | cd -P -- "$(dirname -- "$0")"
3 | PHX_SERVER=true exec ./realtime start
4 |
--------------------------------------------------------------------------------
/lib/realtime_web/views/layout_view.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.LayoutView do
2 | use RealtimeWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/rel/overlays/bin/migrate:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | cd -P -- "$(dirname -- "$0")"
3 | exec ./realtime eval Realtime.Release.migrate
4 |
--------------------------------------------------------------------------------
/test/realtime_web/views/page_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.PageViewTest do
2 | use RealtimeWeb.ConnCase
3 | end
4 |
--------------------------------------------------------------------------------
/test/realtime/helpers_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.HelpersTest do
2 | use Realtime.DataCase
3 | doctest Realtime.Helpers
4 | end
5 |
--------------------------------------------------------------------------------
/priv/repo/seeds_before_migration.exs:
--------------------------------------------------------------------------------
1 | import Ecto.Adapters.SQL, only: [query: 3]
2 |
3 | [
4 | "create schema if not exists realtime"
5 | ]
6 | |> Enum.each(&query(Realtime.Repo, &1, []))
7 |
--------------------------------------------------------------------------------
/priv/static/worker.js:
--------------------------------------------------------------------------------
1 | addEventListener("message", (e) => {
2 | if (e.data.event === "start") {
3 | setInterval(() => postMessage({ event: "keepAlive" }), e.data.interval);
4 | }
5 | });
6 |
--------------------------------------------------------------------------------
/test/realtime/oid_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.OidTest do
2 | use ExUnit.Case, async: true
3 | import Realtime.Adapters.Postgres.OidDatabase
4 | doctest Realtime.Adapters.Postgres.OidDatabase
5 | end
6 |
--------------------------------------------------------------------------------
/lib/realtime_web/controllers/ping_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.PingController do
2 | use RealtimeWeb, :controller
3 |
4 | def ping(conn, _params) do
5 | json(conn, %{message: "Success"})
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/.releaserc:
--------------------------------------------------------------------------------
1 | {
2 | "branches": [
3 | "main"
4 | ],
5 | "plugins": [
6 | "@semantic-release/commit-analyzer",
7 | "@semantic-release/release-notes-generator",
8 | "@semantic-release/github"
9 | ]
10 | }
--------------------------------------------------------------------------------
/priv/static/robots.txt:
--------------------------------------------------------------------------------
1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | # User-agent: *
5 | # Disallow: /
6 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240902173232_add_extension_external_id_index.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.AddExtensionExternalIdIndex do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create_if_not_exists index("extensions", [:tenant_external_id])
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/.sobelow-conf:
--------------------------------------------------------------------------------
1 | [
2 | verbose: true,
3 | private: false,
4 | skip: false,
5 | router: nil,
6 | exit: :low,
7 | format: "txt",
8 | out: nil,
9 | threshold: :medium,
10 | ignore: ["Config.CSP", "Config.HTTPS"],
11 | ignore_files: [],
12 | version: false
13 | ]
14 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20220527210857_add_external_id_uniq_index.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.AddExternalIdUniqIndex do
2 | use Ecto.Migration
3 |
4 | def change do
5 | execute("alter table tenants add constraint uniq_external_id unique (external_id)")
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240306114423_add_tenant_jwt_jwks.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.AdddTenantJwtJwksColumn do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:tenants) do
6 | add :jwt_jwks, :map, default: nil
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20251218000543_ensure_jwt_secret_is_text.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.EnsureJwtSecretIsText do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:tenants) do
6 | modify :jwt_secret, :text, null: true
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto, :ecto_sql, :phoenix, :open_api_spex],
3 | subdirectories: ["priv/*/migrations"],
4 | plugins: [Phoenix.LiveView.HTMLFormatter],
5 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/*seeds*.exs"],
6 | line_length: 120
7 | ]
8 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20221223010058_drop_tenants_uniq_external_id_index.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.DropTenantsUniqExternalIdIndex do
2 | use Ecto.Migration
3 |
4 | def change do
5 | execute("ALTER TABLE IF EXISTS tenants DROP CONSTRAINT IF EXISTS uniq_external_id")
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240625211759_remove_enable_authorization_flag.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.RemoveEnableAuthorizationFlag do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:tenants) do
6 | remove :enable_authorization
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20231024094642_add_tenant_suspend_flag.exs:
--------------------------------------------------------------------------------
1 | defmodule :"Elixir.Realtime.Repo.Migrations.Add-tenant-suspend-flag" do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:tenants) do
6 | add :suspend, :boolean, default: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240418082835_add_authorization_flag.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.AddAuthorizationFlag do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:tenants) do
6 | add :enable_authorization, :boolean, default: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250424203323_add_migrations_ran_to_tenant.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.AddMigrationsRanToTenant do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:tenants) do
6 | add(:migrations_ran, :integer, default: 0)
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20231204144025_enable_channels_rls.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.EnableChannelsRls do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | execute("ALTER TABLE realtime.channels ENABLE row level security")
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250613072131_add_tenant_broadcast_adapter.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.AddTenantBroadcastAdapter do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:tenants) do
6 | add :broadcast_adapter, :string, default: "phoenix"
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240704172020_add_notify_private_alpha.exs:
--------------------------------------------------------------------------------
1 | defmodule :"Elixir.Realtime.Repo.Migrations.Add-notify-private-alpha" do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:tenants) do
6 | add :notify_private_alpha, :boolean, default: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20240919163305_change_messages_id_type.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.ChangeMessagesIdType do
2 | @moduledoc false
3 | use Ecto.Migration
4 |
5 | def change do
6 | alter table(:messages) do
7 | add_if_not_exists :uuid, :uuid
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/lib/realtime_web/views/error_view.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.ErrorView do
2 | use RealtimeWeb, :view
3 |
4 | def render("error.json", %{conn: %{assigns: %{message: message}}}), do: %{message: message}
5 |
6 | def template_not_found(template, _assigns), do: Phoenix.Controller.status_message_from_template(template)
7 | end
8 |
--------------------------------------------------------------------------------
/test/realtime_web/views/layout_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.LayoutViewTest do
2 | use RealtimeWeb.ConnCase
3 |
4 | # When testing helpers, you may want to import Phoenix.HTML and
5 | # use functions such as safe_to_string() to convert the helper
6 | # result into an HTML string.
7 | # import Phoenix.HTML
8 | end
9 |
--------------------------------------------------------------------------------
/bench/gen_counter.exs:
--------------------------------------------------------------------------------
1 | alias Realtime.GenCounter
2 |
3 | counter = :counters.new(1, [:write_concurrency])
4 | _gen_counter = GenCounter.new(:any_term)
5 |
6 | Benchee.run(
7 | %{
8 | ":counters.add" => fn -> :counters.add(counter, 1, 1) end,
9 | "GenCounter.add" => fn -> GenCounter.add(:any_term) end
10 | }
11 | )
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20220815211129_new_max_events_per_second_default.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.NewMaxEventsPerSecondDefault do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table("tenants") do
6 | modify(:max_events_per_second, :integer, null: false, default: 1_000)
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20221102172703_rename_pg_type.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.RenamePgType do
2 | use Ecto.Migration
3 |
4 | def up do
5 | execute("update extensions set type = 'postgres_cdc_rls'")
6 | end
7 |
8 | def down do
9 | execute("update extensions set type = 'postgres'")
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20241106103258_add_private_only_flag_column_to_tenant.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.AddPrivateOnlyFlagColumnToTenant do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:tenants) do
6 | add(:private_only, :boolean, default: false, null: false)
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/realtime_web/controllers/page_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.PageController do
2 | use RealtimeWeb, :controller
3 |
4 | def index(conn, _params) do
5 | render(conn, "index.html")
6 | end
7 |
8 | def healthcheck(conn, _params) do
9 | conn
10 | |> put_status(:ok)
11 | |> text("ok")
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20211122062447_grant_realtime_usage_to_authenticated_role.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.GrantRealtimeUsageToAuthenticatedRole do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | execute("grant usage on schema realtime to authenticated;")
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230810220924_alter_extensions_table_columns_to_text.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.AlterExtensionsTableColumnsToText do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:extensions) do
6 | modify :type, :text
7 | modify :tenant_external_id, :text
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/rel/env.bat.eex:
--------------------------------------------------------------------------------
1 | @echo off
2 | rem Set the release to work across nodes. If using the long name format like
3 | rem the one below (my_app@127.0.0.1), you need to also uncomment the
4 | rem RELEASE_DISTRIBUTION variable below. Must be "sname", "name" or "none".
5 | rem set RELEASE_DISTRIBUTION=name
6 | rem set RELEASE_NODE=<%= @release.name %>@127.0.0.1
7 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20250506224012_subscription_index_bridging_disabled.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.SubscriptionIndexBridgingDisabled do
2 | @moduledoc false
3 | use Ecto.Migration
4 |
5 | def change do
6 | """
7 | alter table realtime.subscription reset (index_bridging);
8 | """
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20241019105805_uuid_auto_generation.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.UuidAutoGeneration do
2 | @moduledoc false
3 | use Ecto.Migration
4 |
5 | def change do
6 | alter table(:messages) do
7 | modify :uuid, :uuid, null: false, default: fragment("gen_random_uuid()")
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20220815215024_set_current_max_events_per_second.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.SetCurrentMaxEventsPerSecond do
2 | use Ecto.Migration
3 |
4 | def change do
5 | execute(
6 | "update tenants set max_events_per_second = 1000",
7 | "update tenants set max_events_per_second = 10000"
8 | )
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20250523164012_run_subscription_index_bridging_disabled.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.RunSubscriptionIndexBridgingDisabled do
2 | @moduledoc false
3 | use Ecto.Migration
4 |
5 | def change do
6 | execute("""
7 | alter table realtime.subscription reset (index_bridging);
8 | """)
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20240109165339_add_update_grant_to_channels.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.AddUpdateGrantToChannels do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | execute("""
8 | GRANT UPDATE ON realtime.channels TO postgres, anon, authenticated, service_role
9 | """)
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20240805133720_logged_messages_table.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.LoggedMessagesTable do
2 | @moduledoc false
3 | use Ecto.Migration
4 |
5 | def change do
6 | execute """
7 | -- Commented to have oriole compatability
8 | -- ALTER TABLE realtime.messages SET LOGGED;
9 | """
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20240108234812_add_channels_column_for_write_check.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.AddChannelsColumnForWriteCheck do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | alter table(:channels, prefix: "realtime") do
8 | add :check, :boolean, default: false
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20240801235015_unlogged_messages_table.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.UnloggedMessagesTable do
2 | @moduledoc false
3 | use Ecto.Migration
4 |
5 | def change do
6 | execute """
7 | -- Commented to have oriole compatability
8 | -- ALTER TABLE realtime.messages SET UNLOGGED;
9 | """
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250711044927_change_default_broadcast_adapter_to_gen_rpc.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.ChangeDefaultBroadcastAdapterToGenRpc do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table("tenants") do
6 | modify :broadcast_adapter, :string, default: "gen_rpc", from: {:string, default: "phoenix"}
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/realtime/telemetry/telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Telemetry do
2 | @moduledoc """
3 | Telemetry wrapper
4 | """
5 |
6 | @doc """
7 | Dispatches Telemetry events.
8 | """
9 |
10 | @spec execute([atom, ...], map, map) :: :ok
11 | def execute(event, measurements, metadata \\ %{}) do
12 | :telemetry.execute(event, measurements, metadata)
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250926223044_set_default_presence_value.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.SetDefaultPresenceValue do
2 | use Ecto.Migration
3 | @disable_ddl_transaction true
4 | @disable_migration_lock true
5 | def change do
6 | alter table(:tenants) do
7 | modify :max_presence_events_per_second, :integer, default: 1000
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20250107150512_realtime_subscription_unlogged.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.RealtimeSubscriptionUnlogged do
2 | @moduledoc false
3 | use Ecto.Migration
4 |
5 | def change do
6 | execute("""
7 | -- Commented to have oriole compatability
8 | -- ALTER TABLE realtime.subscription SET UNLOGGED;
9 | """)
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250811121559_add_max_presence_events_per_second.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.AddMaxPresenceEventsPerSecond do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:tenants) do
6 | add :max_presence_events_per_second, :integer, default: 10000
7 | add :max_payload_size_in_kb, :integer, default: 3000
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/rel/overlays/config.example.yml:
--------------------------------------------------------------------------------
1 | endpoint_port: 4000
2 | db_repo:
3 | - hostname: "127.0.0.1"
4 | username: "postgres"
5 | password: "postgres"
6 | database: "postgres"
7 | pool_size: 3
8 | port: 5432
9 | cluster:
10 | - cookie: "cookie_config"
11 | service: "realtime-dns"
12 | application_name: "realtime"
13 | polling_interval: 5000
14 | # debug: false
15 |
--------------------------------------------------------------------------------
/test/realtime_web/live/inspector_live/index_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.InspectorLive.IndexTest do
2 | use RealtimeWeb.ConnCase
3 | import Phoenix.LiveViewTest
4 |
5 | describe "Inspector LiveView" do
6 | test "renders inspector page", %{conn: conn} do
7 | {:ok, _view, html} = live(conn, ~p"/inspector")
8 |
9 | assert html =~ "Realtime Inspector"
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/test/realtime_web/live/page_live/index_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.PageLive.IndexTest do
2 | use RealtimeWeb.ConnCase
3 | import Phoenix.LiveViewTest
4 |
5 | describe "Index LiveView" do
6 | test "renders page successfully", %{conn: conn} do
7 | {:ok, _view, html} = live(conn, "/")
8 |
9 | assert html =~ "Supabase Realtime: Multiplayer Edition"
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20220410212326_add_tenant_max_eps.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.AddTenantMaxEps do
2 | use Ecto.Migration
3 |
4 | def up do
5 | alter table("tenants") do
6 | add(:max_events_per_second, :integer, default: 10_000)
7 | end
8 | end
9 |
10 | def down do
11 | alter table("tenants") do
12 | remove(:max_events_per_second)
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20221018173709_add_cdc_default.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.AddCdcDefault do
2 | use Ecto.Migration
3 |
4 | def up do
5 | alter table("tenants") do
6 | add(:postgres_cdc_default, :string, default: "postgres_cdc_rls")
7 | end
8 | end
9 |
10 | def down do
11 | alter table("tenants") do
12 | remove(:postgres_cdc_default)
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20231018144023_create_channels.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.CreateChannels do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | create table(:channels, prefix: "realtime") do
8 | add(:name, :string, null: false)
9 | timestamps()
10 | end
11 |
12 | create unique_index(:channels, [:name], prefix: "realtime")
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230810220907_alter_tenants_table_columns_to_text.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.AlterTenantsTableColumnsToText do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:tenants) do
6 | modify :name, :text
7 | modify :external_id, :text
8 | modify :jwt_secret, :text
9 | modify :postgres_cdc_default, :text, default: "postgres_cdc_rls"
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20250905041441_create_messages_replay_index.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.CreateMessagesReplayIndex do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | create_if_not_exists index(:messages, [{:desc, :inserted_at}, :topic],
8 | where: "extension = 'broadcast' and private IS TRUE"
9 | )
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/.github/workflows/docker-build.yml:
--------------------------------------------------------------------------------
1 | name: Docker Build
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Checkout code
14 | uses: actions/checkout@v4
15 |
16 | - name: Set up Docker Buildx
17 | uses: docker/setup-buildx-action@v3
18 |
19 | - name: Build Docker image
20 | run: docker build .
21 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20220506102948_rename_poll_interval_to_poll_interval_ms.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.RenamePollIntervalToPollIntervalMs do
2 | use Ecto.Migration
3 | import Realtime.Api, only: [rename_settings_field: 2]
4 |
5 | def up do
6 | rename_settings_field("poll_interval", "poll_interval_ms")
7 | end
8 |
9 | def down do
10 | rename_settings_field("poll_interval_ms", "poll_interval")
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20251204170944_nullable_jwt_secrets.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.NullableJwtSecrets do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:tenants) do
6 | modify :jwt_secret, :text, null: true
7 | end
8 |
9 | create constraint(:tenants, :jwt_secret_or_jwt_jwks_required,
10 | check: "jwt_secret IS NOT NULL OR jwt_jwks IS NOT NULL"
11 | )
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20240418121054_remove_check_columns.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.RemoveCheckColumns do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | alter table(:channels) do
8 | remove :check
9 | end
10 |
11 | alter table(:broadcasts) do
12 | remove :check
13 | end
14 |
15 | alter table(:presences) do
16 | remove :check
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20231204144024_create_rls_helper_functions.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.CreateRlsHelperFunctions do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | execute("""
8 | create or replace function realtime.channel_name() returns text as $$
9 | select nullif(current_setting('realtime.channel_name', true), '')::text;
10 | $$ language sql stable;
11 | """)
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/realtime_web/channels/presence.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.Presence do
2 | @moduledoc """
3 | Provides presence tracking to channels and processes.
4 |
5 | See the [`Phoenix.Presence`](http://hexdocs.pm/phoenix/Phoenix.Presence.html)
6 | docs for more details.
7 | """
8 | use Phoenix.Presence,
9 | otp_app: :realtime,
10 | pubsub_server: Realtime.PubSub,
11 | dispatcher: RealtimeWeb.RealtimeChannel.MessageDispatcher,
12 | pool_size: 10
13 | end
14 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230110180046_add_limits_fields_to_tenants.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.AddLimitsFieldsToTenants do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table("tenants") do
6 | add(:max_bytes_per_second, :integer, default: 100_000, null: false)
7 | add(:max_channels_per_client, :integer, default: 100, null: false)
8 | add(:max_joins_per_second, :integer, default: 500, null: false)
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/test/realtime_web/controllers/page_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.PageControllerTest do
2 | use RealtimeWeb.ConnCase
3 |
4 | test "GET / renders index page", %{conn: conn} do
5 | conn = get(conn, "/")
6 | assert html_response(conn, 200) =~ " Supabase Realtime: Multiplayer Edition"
7 | end
8 |
9 | test "GET /healthcheck returns ok status", %{conn: conn} do
10 | conn = get(conn, "/healthcheck")
11 | assert text_response(conn, 200) == "ok"
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20250110162412_realtime_subscription_logged.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.RealtimeSubscriptionLogged do
2 | @moduledoc false
3 | use Ecto.Migration
4 |
5 | # PG Updates doesn't allow us to use UNLOGGED tables due to the fact that Sequences on PG14 still need to be logged
6 | def change do
7 | execute("""
8 | -- Commented to have oriole compatability
9 | -- ALTER TABLE realtime.subscription SET LOGGED;
10 | """)
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/test/realtime/adapters/postgres/protocol_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Adapters.Postgres.ProtocolTest do
2 | use ExUnit.Case, async: true
3 | alias Realtime.Adapters.Postgres.Protocol
4 |
5 | test "defguard is_write/1" do
6 | require Protocol
7 | assert Protocol.is_write("w")
8 | refute Protocol.is_write("k")
9 | end
10 |
11 | test "defguard is_keep_alive/1" do
12 | require Protocol
13 | assert Protocol.is_keep_alive("k")
14 | refute Protocol.is_keep_alive("w")
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/test/realtime_web/views/error_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.ErrorViewTest do
2 | use RealtimeWeb.ConnCase, async: true
3 |
4 | # Bring render/3 and render_to_string/3 for testing custom views
5 | import Phoenix.View
6 |
7 | test "renders 404.html" do
8 | assert render_to_string(RealtimeWeb.ErrorView, "404.html", []) == "Not Found"
9 | end
10 |
11 | test "renders 500.html" do
12 | assert render_to_string(RealtimeWeb.ErrorView, "500.html", []) == "Internal Server Error"
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/realtime/rate_counter/dynamic_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.RateCounter.DynamicSupervisor do
2 | @moduledoc """
3 | Dynamic Supervisor to spin up `RateCounter`s as needed.
4 | """
5 |
6 | use DynamicSupervisor
7 |
8 | @spec start_link(list()) :: {:error, any} | {:ok, pid}
9 | def start_link(args) do
10 | DynamicSupervisor.start_link(__MODULE__, args, name: __MODULE__)
11 | end
12 |
13 | @impl true
14 | def init(_args) do
15 | DynamicSupervisor.init(strategy: :one_for_one)
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/authorization/policies/presence_policies.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Authorization.Policies.PresencePolicies do
2 | @moduledoc """
3 | PresencePolicies structure that holds the required authorization information for a given connection within the scope of a tracking / receiving presence messages
4 | """
5 | require Logger
6 |
7 | defstruct read: nil, write: nil
8 |
9 | @type t :: %__MODULE__{
10 | read: boolean() | nil,
11 | write: boolean() | nil
12 | }
13 | end
14 |
--------------------------------------------------------------------------------
/lib/realtime_web/live/page_live/index.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.PageLive.Index do
2 | use RealtimeWeb, :live_view
3 |
4 | @impl true
5 | def mount(_params, _session, socket) do
6 | {:ok, socket}
7 | end
8 |
9 | @impl true
10 | def handle_params(params, _url, socket) do
11 | {:noreply, apply_action(socket, socket.assigns.live_action, params)}
12 | end
13 |
14 | defp apply_action(socket, :index, _params) do
15 | socket
16 | |> assign(:page_title, "Home - Supabase Realtime")
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/authorization/policies/broadcast_policies.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Authorization.Policies.BroadcastPolicies do
2 | @moduledoc """
3 | BroadcastPolicies structure that holds the required authorization information for a given connection within the scope of a sending / receiving broadcasts messages
4 | """
5 | require Logger
6 |
7 | defstruct read: nil, write: nil
8 |
9 | @type t :: %__MODULE__{
10 | read: boolean() | nil,
11 | write: boolean() | nil
12 | }
13 | end
14 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20241108114728_messages_using_uuid.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.MessagesUsingUuid do
2 | @moduledoc false
3 | use Ecto.Migration
4 |
5 | def change do
6 | alter table(:messages) do
7 | remove(:id)
8 | remove(:uuid)
9 | add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"))
10 | end
11 |
12 | execute("ALTER TABLE realtime.messages ADD PRIMARY KEY (id, inserted_at)")
13 | execute("DROP SEQUENCE realtime.messages_id_seq")
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/realtime/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo do
2 | use Ecto.Repo,
3 | otp_app: :realtime,
4 | adapter: Ecto.Adapters.Postgres
5 |
6 | def with_dynamic_repo(config, callback) do
7 | default_dynamic_repo = get_dynamic_repo()
8 | {:ok, repo} = [name: nil, pool_size: 2] |> Keyword.merge(config) |> Realtime.Repo.start_link()
9 |
10 | try do
11 | put_dynamic_repo(repo)
12 | callback.(repo)
13 | after
14 | put_dynamic_repo(default_dynamic_repo)
15 | Supervisor.stop(repo)
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20210706140551_create_tenant.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.CreateTenants do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:tenants, primary_key: false) do
6 | add(:id, :binary_id, primary_key: true)
7 | add(:name, :string)
8 | add(:external_id, :string)
9 | add(:jwt_secret, :string, size: 500)
10 | add(:max_concurrent_users, :integer, default: 10_000)
11 | timestamps()
12 | end
13 |
14 | create(index(:tenants, [:external_id], unique: true))
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/realtime_web/views/changeset_view.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.ChangesetView do
2 | use RealtimeWeb, :view
3 |
4 | @doc """
5 | Traverses and translates changeset errors.
6 |
7 | See `Ecto.Changeset.traverse_errors/2` and
8 | `RealtimeWeb.ErrorHelpers.translate_error/1` for more details.
9 | """
10 | def translate_errors(changeset) do
11 | Ecto.Changeset.traverse_errors(changeset, &translate_error/1)
12 | end
13 |
14 | def render("error.json", %{changeset: changeset}) do
15 | %{errors: translate_errors(changeset)}
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/extensions/extensions.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Extensions do
2 | @moduledoc """
3 | This module provides functions to get extension settings.
4 | """
5 | def db_settings(type) do
6 | db_settings =
7 | Application.get_env(:realtime, :extensions)
8 | |> Enum.reduce(nil, fn
9 | {_, %{key: ^type, db_settings: db_settings}}, _ -> db_settings
10 | _, acc -> acc
11 | end)
12 |
13 | %{
14 | default: apply(db_settings, :default, []),
15 | required: apply(db_settings, :required, [])
16 | }
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/realtime_web/channels/payloads/presence.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.Channels.Payloads.Presence do
2 | @moduledoc """
3 | Validate presence field of the join payload.
4 | """
5 | use Ecto.Schema
6 | import Ecto.Changeset
7 | alias RealtimeWeb.Channels.Payloads.Join
8 |
9 | embedded_schema do
10 | field :enabled, :boolean, default: true
11 | field :key, :any, default: UUID.uuid1(), virtual: true
12 | end
13 |
14 | def changeset(presence, attrs) do
15 | cast(presence, attrs, [:enabled, :key], message: &Join.error_message/2)
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/test/realtime_web/controllers/openapi_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.Controllers.OpenapiControllerTest do
2 | use RealtimeWeb.ConnCase
3 |
4 | describe "openapi" do
5 | test "returns the openapi spec", %{conn: conn} do
6 | conn = get(conn, ~p"/api/openapi")
7 | assert json_response(conn, 200)
8 | end
9 | end
10 |
11 | describe "swaggerui" do
12 | test "returns the swaggerui", %{conn: conn} do
13 | conn = get(conn, ~p"/swaggerui")
14 | assert html_response(conn, 200) =~ "Swagger UI"
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/realtime_web/channels/payloads/broadcast/replay.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.Channels.Payloads.Broadcast.Replay do
2 | @moduledoc """
3 | Validate broadcast replay field of the join payload.
4 | """
5 | use Ecto.Schema
6 | import Ecto.Changeset
7 | alias RealtimeWeb.Channels.Payloads.Join
8 |
9 | embedded_schema do
10 | field :limit, :integer, default: 10
11 | field :since, :integer, default: 0
12 | end
13 |
14 | def changeset(broadcast, attrs) do
15 | cast(broadcast, attrs, [:limit, :since], message: &Join.error_message/2)
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20220818141501_change_limits_defaults.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.ChangeLimitsDefaults do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table("tenants") do
6 | modify(:max_events_per_second, :integer,
7 | null: false,
8 | default: 100,
9 | from: {:integer, null: false, default: 100}
10 | )
11 |
12 | modify(:max_concurrent_users, :integer,
13 | null: false,
14 | default: 200,
15 | from: {:integer, null: false, default: 200}
16 | )
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20211116213355_create_realtime_cast_function.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.CreateRealtimeCastFunction do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | execute("create function realtime.cast(val text, type_ regtype)
8 | returns jsonb
9 | immutable
10 | language plpgsql
11 | as $$
12 | declare
13 | res jsonb;
14 | begin
15 | execute format('select to_jsonb(%L::'|| type_::text || ')', val) into res;
16 | return res;
17 | end
18 | $$;")
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/connect/get_tenant.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Connect.GetTenant do
2 | @moduledoc """
3 | Get tenant database connection.
4 | """
5 |
6 | alias Realtime.Api.Tenant
7 | alias Realtime.Tenants
8 | @behaviour Realtime.Tenants.Connect.Piper
9 |
10 | @impl Realtime.Tenants.Connect.Piper
11 | def run(acc) do
12 | %{tenant_id: tenant_id} = acc
13 |
14 | case Tenants.Cache.get_tenant_by_external_id(tenant_id) do
15 | %Tenant{} = tenant -> {:ok, Map.put(acc, :tenant, tenant)}
16 | _ -> {:error, :tenant_not_found}
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/realtime/monitoring/prom_ex/plugins/channels.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.PromEx.Plugins.Channels do
2 | @moduledoc """
3 | Realtime channels monitoring plugin for PromEx
4 | """
5 | use PromEx.Plugin
6 | require Logger
7 |
8 | @impl true
9 | def event_metrics(_opts) do
10 | Event.build(:realtime, [
11 | counter(
12 | [:realtime, :channel, :error],
13 | event_name: [:realtime, :channel, :error],
14 | measurement: :code,
15 | tags: [:code],
16 | description: "Count of errors in the Realtime channels initialization"
17 | )
18 | ])
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20250123174212_remove_unused_publications.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.RemoveUnusedPublications do
2 | @moduledoc false
3 | use Ecto.Migration
4 |
5 | def change do
6 | execute("""
7 | DO $$
8 | DECLARE
9 | r RECORD;
10 | BEGIN
11 | FOR r IN
12 | SELECT pubname FROM pg_publication WHERE pubname LIKE 'realtime_messages%' or pubname LIKE 'supabase_realtime_messages%'
13 | LOOP
14 | EXECUTE 'DROP PUBLICATION IF EXISTS ' || quote_ident(r.pubname) || ';' ;
15 | END LOOP;
16 | END $$;
17 | """)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/connect/check_connection.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Connect.CheckConnection do
2 | @moduledoc """
3 | Check tenant database connection.
4 | """
5 |
6 | @behaviour Realtime.Tenants.Connect.Piper
7 | @impl true
8 | def run(acc) do
9 | %{tenant: tenant} = acc
10 |
11 | case Realtime.Database.check_tenant_connection(tenant) do
12 | {:ok, conn} ->
13 | db_conn_reference = Process.monitor(conn)
14 | {:ok, %{acc | db_conn_pid: conn, db_conn_reference: db_conn_reference}}
15 |
16 | {:error, error} ->
17 | {:error, error}
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/realtime_web/channels/payloads/postgres_change.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.Channels.Payloads.PostgresChange do
2 | @moduledoc """
3 | Validate postgres_changes field of the join payload.
4 | """
5 | use Ecto.Schema
6 | import Ecto.Changeset
7 | alias RealtimeWeb.Channels.Payloads.Join
8 |
9 | embedded_schema do
10 | field :event, :string
11 | field :schema, :string
12 | field :table, :string
13 | field :filter, :string
14 | end
15 |
16 | def changeset(postgres_change, attrs) do
17 | cast(postgres_change, attrs, [:event, :schema, :table, :filter], message: &Join.error_message/2)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20220329161857_add_extensions_table.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Repo.Migrations.AddExtensionsTable do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:extensions, primary_key: false) do
6 | add(:id, :binary_id, primary_key: true)
7 | add(:type, :string)
8 | add(:settings, :map)
9 |
10 | add(
11 | :tenant_external_id,
12 | references(:tenants, on_delete: :delete_all, type: :string, column: :external_id)
13 | )
14 |
15 | timestamps()
16 | end
17 |
18 | create(index(:extensions, [:tenant_external_id, :type], unique: true))
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/realtime/context_cache.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.ContextCache do
2 | @moduledoc """
3 | Read through cache for hot database paths.
4 | """
5 |
6 | require Logger
7 |
8 | def apply_fun(context, {fun, arity}, args) do
9 | cache = cache_name(context)
10 | cache_key = {{fun, arity}, args}
11 |
12 | case Cachex.fetch(cache, cache_key, fn {{_fun, _arity}, args} -> {:commit, {:cached, apply(context, fun, args)}} end) do
13 | {:commit, {:cached, value}} -> value
14 | {:ok, {:cached, value}} -> value
15 | end
16 | end
17 |
18 | defp cache_name(context) do
19 | Module.concat(context, Cache)
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20240311171622_add_insert_and_delete_grant_to_channels.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.AddInsertAndDeleteGrantToChannels do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | execute("""
8 | GRANT INSERT, DELETE ON realtime.channels TO postgres, anon, authenticated, service_role
9 | """)
10 |
11 | execute("""
12 | GRANT INSERT ON realtime.broadcasts TO postgres, anon, authenticated, service_role
13 | """)
14 |
15 | execute("""
16 | GRANT USAGE ON SEQUENCE realtime.broadcasts_id_seq TO postgres, anon, authenticated, service_role
17 | """)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/realtime_web/live/time_live.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.TimeLive do
2 | use RealtimeWeb, :live_view
3 |
4 | def mount(_params, _session, socket) do
5 | {:ok, assign_time(socket)}
6 | end
7 |
8 | def render(assigns) do
9 | ~H"""
10 | <%= @server_time %>
11 | """
12 | end
13 |
14 | def handle_info(:time, socket) do
15 | {:noreply, assign_time(socket)}
16 | end
17 |
18 | defp assign_time(socket) do
19 | timer = if Mix.env() == :dev, do: 60_000, else: 100
20 | Process.send_after(self(), :time, timer)
21 | now = DateTime.utc_now() |> DateTime.to_string()
22 |
23 | socket
24 | |> assign(:server_time, now)
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20241130184212_recreate_entity_index_using_btree.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.RecreateEntityIndexUsingBtree do
2 | @moduledoc false
3 | use Ecto.Migration
4 |
5 | def change do
6 | execute("drop index if exists \"realtime\".\"ix_realtime_subscription_entity\"")
7 |
8 | execute("""
9 | do $$
10 | begin
11 | create index concurrently if not exists ix_realtime_subscription_entity on realtime.subscription using btree (entity);
12 | exception
13 | when others then
14 | create index if not exists ix_realtime_subscription_entity on realtime.subscription using btree (entity);
15 | end$$;
16 | """)
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/realtime_web/channels/payloads/broadcast.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.Channels.Payloads.Broadcast do
2 | @moduledoc """
3 | Validate broadcast field of the join payload.
4 | """
5 | use Ecto.Schema
6 | import Ecto.Changeset
7 | alias RealtimeWeb.Channels.Payloads.Join
8 |
9 | embedded_schema do
10 | field :ack, :boolean, default: false
11 | field :self, :boolean, default: false
12 | embeds_one :replay, RealtimeWeb.Channels.Payloads.Broadcast.Replay
13 | end
14 |
15 | def changeset(broadcast, attrs) do
16 | cast(broadcast, attrs, [:ack, :self], message: &Join.error_message/2)
17 | |> cast_embed(:replay, invalid_message: "unable to parse, expected a map")
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/realtime_web/live/status_live/index.html.heex:
--------------------------------------------------------------------------------
1 | <.h1>Supabase Realtime: Multiplayer Edition
2 |
3 | <.h2>Cluster Status
4 |
5 |
Understand the latency between nodes across the Realtime cluster.
6 |
7 |
8 |
9 |
10 |
From: <%= p.payload.from_region %> - <%= p.payload.from_node %>
11 |
To: <%= p.payload.region %> - <%= p.payload.node %>
12 |
<%= p.payload.latency %> ms
13 |
<%= p.payload.timestamp %>
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/rel/vm.args.eex:
--------------------------------------------------------------------------------
1 | ## Customize flags given to the VM: http://erlang.org/doc/man/erl.html
2 | ## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here
3 |
4 | ## Number of dirty schedulers doing IO work (file, sockets, and others)
5 | ##+SDio 5
6 |
7 | ## Increase number of concurrent ports/sockets
8 | ##+Q 65536
9 |
10 | ## Tweak GC to run more often
11 | ##-env ERL_FULLSWEEP_AFTER 10
12 |
13 | ## Limit process heap for all procs to 2500 MB. The number here is the number of words
14 | +hmax <%= div(2_500_000_000, :erlang.system_info(:wordsize)) %>
15 |
16 | ## Set distribution buffer busy limit (default is 1024)
17 | +zdbbl 100000
18 |
19 | ## Disable Busy Wait
20 | +sbwt none
21 | +sbwtdio none
22 | +sbwtdcpu none
23 |
--------------------------------------------------------------------------------
/test/support/replication_test_handler.ex:
--------------------------------------------------------------------------------
1 | defmodule Replication.TestHandler do
2 | @behaviour PostgresReplication.Handler
3 | import PostgresReplication.Protocol
4 | alias PostgresReplication.Protocol.KeepAlive
5 |
6 | @impl true
7 | def call(message, _metadata) when is_write(message) do
8 | :noreply
9 | end
10 |
11 | def call(message, _metadata) when is_keep_alive(message) do
12 | reply =
13 | case parse(message) do
14 | %KeepAlive{reply: :now, wal_end: wal_end} ->
15 | wal_end = wal_end + 1
16 | standby(wal_end, wal_end, wal_end, :now)
17 |
18 | _ ->
19 | hold()
20 | end
21 |
22 | {:reply, reply}
23 | end
24 |
25 | def call(_, _), do: :noreply
26 | end
27 |
--------------------------------------------------------------------------------
/docker-compose.dbs.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 |
3 | services:
4 | db:
5 | image: supabase/postgres:14.1.0.105
6 | container_name: realtime-db
7 | ports:
8 | - "5432:5432"
9 | volumes:
10 | - ./dev/postgres:/docker-entrypoint-initdb.d/
11 | command: postgres -c config_file=/etc/postgresql/postgresql.conf
12 | environment:
13 | POSTGRES_HOST: /var/run/postgresql
14 | POSTGRES_PASSWORD: postgres
15 | tenant_db:
16 | image: supabase/postgres:14.1.0.105
17 | container_name: tenant-db
18 | ports:
19 | - "5433:5432"
20 | command: postgres -c config_file=/etc/postgresql/postgresql.conf
21 | environment:
22 | POSTGRES_HOST: /var/run/postgresql
23 | POSTGRES_PASSWORD: postgres
24 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/janitor/maintenance_task.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Janitor.MaintenanceTask do
2 | @moduledoc """
3 | Perform maintenance on the messages table.
4 | * Delete old messages
5 | * Create new partitions
6 | """
7 |
8 | @spec run(String.t()) :: :ok | {:error, any}
9 | def run(tenant_external_id) do
10 | with %Realtime.Api.Tenant{} = tenant <- Realtime.Tenants.Cache.get_tenant_by_external_id(tenant_external_id),
11 | {:ok, conn} <- Realtime.Database.connect(tenant, "realtime_janitor"),
12 | :ok <- Realtime.Messages.delete_old_messages(conn),
13 | :ok <- Realtime.Tenants.Migrations.create_partitions(conn) do
14 | GenServer.stop(conn)
15 | :ok
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/dev/postgres/00-supabase-schema.sql:
--------------------------------------------------------------------------------
1 | create role anon nologin noinherit;
2 | create role authenticated nologin noinherit;
3 | create role service_role nologin noinherit bypassrls;
4 |
5 | grant usage on schema public to anon, authenticated, service_role;
6 |
7 | alter default privileges in schema public grant all on tables to anon, authenticated, service_role;
8 | alter default privileges in schema public grant all on functions to anon, authenticated, service_role;
9 | alter default privileges in schema public grant all on sequences to anon, authenticated, service_role;
10 |
11 | create schema if not exists _realtime;
12 | create schema if not exists realtime;
13 |
14 | create publication supabase_realtime with (publish = 'insert, update, delete');
15 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20231204144023_set_required_grants.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.SetRequiredGrants do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | execute("""
8 | GRANT USAGE ON SCHEMA realtime TO postgres, anon, authenticated, service_role
9 | """)
10 |
11 | execute("""
12 | GRANT SELECT ON ALL TABLES IN SCHEMA realtime TO postgres, anon, authenticated, service_role
13 | """)
14 |
15 | execute("""
16 | GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA realtime TO postgres, anon, authenticated, service_role
17 | """)
18 |
19 | execute("""
20 | GRANT USAGE ON ALL SEQUENCES IN SCHEMA realtime TO postgres, anon, authenticated, service_role
21 | """)
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/test/support/tracing.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tracing do
2 | defmacro __using__(_opts) do
3 | quote do
4 | # Use Record module to extract fields of the Span record from the opentelemetry dependency.
5 | require Record
6 | @span_fields Record.extract(:span, from: "deps/opentelemetry/include/otel_span.hrl")
7 | @status_fields Record.extract(:status, from: "deps/opentelemetry_api/include/opentelemetry.hrl")
8 | @attributes_fields Record.extract(:attributes, from: "deps/opentelemetry_api/src/otel_attributes.erl")
9 | # Define macros for otel stuff
10 | Record.defrecordp(:span, @span_fields)
11 | Record.defrecordp(:status, @status_fields)
12 | Record.defrecordp(:attributes, @attributes_fields)
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/extensions/postgres_cdc_rls/db_settings.ex:
--------------------------------------------------------------------------------
1 | defmodule Extensions.PostgresCdcRls.DbSettings do
2 | @moduledoc """
3 | Schema callbacks for CDC RLS implementation.
4 | """
5 |
6 | def default do
7 | %{
8 | "poll_interval_ms" => 100,
9 | "poll_max_changes" => 100,
10 | "poll_max_record_bytes" => 1_048_576,
11 | "publication" => "supabase_realtime",
12 | "slot_name" => "supabase_realtime_replication_slot"
13 | }
14 | end
15 |
16 | def required do
17 | [
18 | {"region", &is_binary/1, false},
19 | {"db_host", &is_binary/1, true},
20 | {"db_name", &is_binary/1, true},
21 | {"db_user", &is_binary/1, true},
22 | {"db_port", &is_binary/1, true},
23 | {"db_password", &is_binary/1, true}
24 | ]
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/realtime_web/templates/layout/root.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | <%= csrf_meta_tag() %>
9 | <.live_title>
10 | <%= assigns[:page_title] || "Supabase Realtime" %>
11 |
12 |
13 |
15 |
16 |
17 | <%= @inner_content %>
18 |
19 |
20 |
--------------------------------------------------------------------------------
/lib/realtime/monitoring/os_metrics.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.OsMetrics do
2 | @moduledoc """
3 | This module provides functions to get CPU and RAM usage.
4 | """
5 |
6 | @spec ram_usage() :: float()
7 | def ram_usage do
8 | mem = :memsup.get_system_memory_data()
9 | free_mem = if Mix.env() in [:dev, :test], do: mem[:free_memory], else: mem[:available_memory]
10 | 100 - free_mem / mem[:total_memory] * 100
11 | end
12 |
13 | @spec cpu_la() :: %{avg1: float(), avg5: float(), avg15: float()}
14 | def cpu_la do
15 | %{
16 | avg1: :cpu_sup.avg1() / 256,
17 | avg5: :cpu_sup.avg5() / 256,
18 | avg15: :cpu_sup.avg15() / 256
19 | }
20 | end
21 |
22 | @spec cpu_util() :: float() | {:error, term()}
23 | def cpu_util do
24 | :cpu_sup.util()
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/connect/register_process.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Connect.RegisterProcess do
2 | @moduledoc """
3 | Registers the database process in :syn
4 | """
5 | alias Realtime.Tenants.Connect
6 | @behaviour Realtime.Tenants.Connect.Piper
7 |
8 | @impl true
9 | def run(acc) do
10 | %{tenant_id: tenant_id, db_conn_pid: conn} = acc
11 |
12 | with {:ok, _} <- :syn.update_registry(Connect, tenant_id, fn _pid, meta -> %{meta | conn: conn} end),
13 | {:ok, _} <- Registry.register(Connect.Registry, tenant_id, %{}) do
14 | {:ok, acc}
15 | else
16 | {:error, :undefined} -> {:error, :process_not_found}
17 | {:error, {:already_registered, _}} -> {:error, :already_registered}
18 | {:error, reason} -> {:error, reason}
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20240227174441_add_broadcast_permissions_table.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.AddBroadcastsPoliciesTable do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | create table(:broadcasts) do
8 | add :channel_id, references(:channels, on_delete: :delete_all), null: false
9 | add :check, :boolean, default: false, null: false
10 | timestamps()
11 | end
12 |
13 | create unique_index(:broadcasts, :channel_id)
14 |
15 | execute("ALTER TABLE realtime.broadcasts ENABLE row level security")
16 | execute("GRANT SELECT ON realtime.broadcasts TO postgres, anon, authenticated, service_role")
17 | execute("GRANT UPDATE ON realtime.broadcasts TO postgres, anon, authenticated, service_role")
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/realtime/syn/postgres_cdc.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Syn.PostgresCdc do
2 | @moduledoc """
3 | Scope for the PostgresCdc module.
4 | """
5 |
6 | @doc """
7 | Returns the scope for a given tenant id.
8 | """
9 | @spec scope(String.t()) :: atom()
10 | def scope(tenant_id) do
11 | shards = Application.fetch_env!(:realtime, :postgres_cdc_scope_shards)
12 | shard = :erlang.phash2(tenant_id, shards)
13 | :"realtime_postgres_cdc_#{shard}"
14 | end
15 |
16 | def scopes() do
17 | shards = Application.fetch_env!(:realtime, :postgres_cdc_scope_shards)
18 | Enum.map(0..(shards - 1), fn shard -> :"realtime_postgres_cdc_#{shard}" end)
19 | end
20 |
21 | def syn_topic_prefix(), do: "realtime_postgres_cdc_"
22 | def syn_topic(tenant_id), do: "#{syn_topic_prefix()}#{tenant_id}"
23 | end
24 |
--------------------------------------------------------------------------------
/lib/realtime_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.Gettext do
2 | @moduledoc """
3 | A module providing Internationalization with a gettext-based API.
4 |
5 | By using [Gettext](https://hexdocs.pm/gettext),
6 | your module gains a set of macros for translations, for example:
7 |
8 | import RealtimeWeb.Gettext
9 |
10 | # Simple translation
11 | gettext("Here is the string to translate")
12 |
13 | # Plural translation
14 | ngettext("Here is the string to translate",
15 | "Here are the strings to translate",
16 | 3)
17 |
18 | # Domain-based translation
19 | dgettext("errors", "Here is the error message to translate")
20 |
21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
22 | """
23 | use Gettext.Backend, otp_app: :realtime
24 | end
25 |
--------------------------------------------------------------------------------
/test/api_jwt_secret_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.ApiJwtSecretTest do
2 | use RealtimeWeb.ConnCase, async: false
3 |
4 | test "no api key", %{conn: conn} do
5 | previous = Application.get_env(:realtime, :api_jwt_secret)
6 | Application.put_env(:realtime, :api_jwt_secret, nil)
7 | on_exit(fn -> Application.put_env(:realtime, :api_jwt_secret, previous) end)
8 |
9 | conn = get(conn, Routes.tenant_path(conn, :index))
10 | assert conn.status == 403
11 | end
12 |
13 | test "api key is right", %{conn: conn} do
14 | api_jwt_secret = Application.get_env(:realtime, :api_jwt_secret)
15 | jwt = generate_jwt_token(api_jwt_secret)
16 | conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer " <> jwt)
17 | conn = get(conn, Routes.tenant_path(conn, :index))
18 | assert conn.status == 200
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/.credo.exs:
--------------------------------------------------------------------------------
1 | %{
2 | configs: [
3 | %{
4 | name: "default",
5 | files: %{
6 | included: ["lib/", "src/", "web/", "apps/"],
7 | excluded: []
8 | },
9 | plugins: [],
10 | requires: [],
11 | strict: false,
12 | parse_timeout: 5000,
13 | color: true,
14 | checks: %{
15 | disabled: [
16 | {Credo.Check.Design.TagTODO, []},
17 | {Credo.Check.Consistency.ExceptionNames, []},
18 | {Credo.Check.Refactor.Nesting, []},
19 | {Credo.Check.Refactor.CyclomaticComplexity, []},
20 | {Credo.Check.Readability.WithSingleClause, []},
21 | {Credo.Check.Readability.AliasOrder, []},
22 | {Credo.Check.Readability.StringSigils, []},
23 | {Credo.Check.Refactor.Apply, []}
24 | ]
25 | }
26 | }
27 | ]
28 | }
29 |
--------------------------------------------------------------------------------
/.github/workflows/integration_tests.yml:
--------------------------------------------------------------------------------
1 | name: Integration Tests
2 | on:
3 | pull_request:
4 | paths:
5 | - "lib/**"
6 | - "test/**"
7 | - "config/**"
8 | - "priv/**"
9 | - "assets/**"
10 | - "rel/**"
11 | - "mix.exs"
12 | - "Dockerfile"
13 | - "run.sh"
14 | - "docker-compose.test.yml"
15 |
16 | push:
17 | branches:
18 | - main
19 |
20 | concurrency:
21 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
22 | cancel-in-progress: true
23 |
24 | jobs:
25 | tests:
26 | name: Tests
27 | runs-on: blacksmith-8vcpu-ubuntu-2404
28 |
29 | steps:
30 | - uses: actions/checkout@v2
31 | - name: Run integration test
32 | run: docker compose -f docker-compose.tests.yml up --abort-on-container-exit --exit-code-from test-runner
33 |
34 |
--------------------------------------------------------------------------------
/test/realtime/monitoring/prom_ex_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.PromExTest do
2 | use ExUnit.Case, async: true
3 | doctest Realtime.PromEx
4 | alias Realtime.PromEx
5 |
6 | describe "get_metrics/0" do
7 | test "builds metrics in prometheus format which includes host region and id" do
8 | metrics = PromEx.get_metrics() |> IO.iodata_to_binary()
9 |
10 | assert String.contains?(
11 | metrics,
12 | "# HELP beam_system_schedulers_online_info The number of scheduler threads that are online."
13 | )
14 |
15 | assert String.contains?(metrics, "# TYPE beam_system_schedulers_online_info gauge")
16 |
17 | assert String.contains?(
18 | metrics,
19 | "beam_system_schedulers_online_info{host=\"nohost\",id=\"nohost\",region=\"us-east-1\"}"
20 | )
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/bench/secrets.exs:
--------------------------------------------------------------------------------
1 | alias RealtimeWeb.ChannelsAuthorization
2 | alias Realtime.Helpers, as: H
3 |
4 | jwt =
5 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxvY2FsaG9zdCIsInJvbGUiOiJhbm9uIiwiaWF0IjoxNjU4NjAwNzkxLCJleHAiOjE5NzQxNzY3OTF9.Iki--9QilZ7vySEUJHj0a1T8BDHkR7rmdWStXImCZfk"
6 |
7 | jwt_secret = "d3v_HtNXEpT+zfsyy1LE1WPGmNKLWRfw/rpjnVtCEEM2cSFV2s+kUh5OKX7TPYmG"
8 |
9 | secret_key = "1234567890123456"
10 | string_to_encrypt = "supabase_realtime"
11 | string_to_decrypt = "A5mS7ggkPXm0FaKKoZtrsYNlZA3qZxFe9XA9w2YYqgU="
12 |
13 | Benchee.run(%{
14 | "authorize_jwt" => fn ->
15 | {:ok, _} = ChannelsAuthorization.authorize_conn(jwt, jwt_secret, nil)
16 | end,
17 | "encrypt_string" => fn ->
18 | H.encrypt!(string_to_encrypt, secret_key)
19 | end,
20 | "decrypt_string" => fn ->
21 | H.decrypt!(string_to_decrypt, secret_key)
22 | end
23 | })
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | realtime-*.tar
24 |
25 | # Ignore assets that are produced by build tools.
26 | /priv/static/assets/
27 |
28 | # Ignore Dialyzer .plt
29 | /priv/plts/*
30 | node_modules
31 | .supabase
32 | config/prod.secret.exs
33 | demo/.env
34 | .lexical
35 | .vscode
--------------------------------------------------------------------------------
/lib/realtime/tenants/connect/piper.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Connect.Piper do
2 | @moduledoc """
3 | Pipes different commands to execute specific actions during the connection process.
4 | """
5 | require Logger
6 | @callback run(any()) :: {:ok, any()} | {:error, any()}
7 |
8 | def run(pipers, init) do
9 | Enum.reduce_while(pipers, {:ok, init}, fn piper, {:ok, acc} ->
10 | case :timer.tc(fn -> piper.run(acc) end, :millisecond) do
11 | {exec_time, {:ok, result}} ->
12 | Logger.info("#{inspect(piper)} executed in #{exec_time} ms")
13 | {:cont, {:ok, result}}
14 |
15 | {exec_time, {:error, error}} ->
16 | Logger.error("#{inspect(piper)} failed in #{exec_time} ms")
17 | {:halt, {:error, error}}
18 |
19 | _ ->
20 | raise ArgumentError, "must return {:ok, _} or {:error, _}"
21 | end
22 | end)
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/rel/env.sh.eex:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Set the release to work across nodes. If using the long name format like
4 | # the one below (my_app@127.0.0.1), you need to also uncomment the
5 | # RELEASE_DISTRIBUTION variable below. Must be "sname", "name" or "none".
6 |
7 | # for Fly.io
8 | ip=$(grep fly-local-6pn /etc/hosts | cut -f 1)
9 |
10 | if [ "$AWS_EXECUTION_ENV" = "AWS_ECS_FARGATE" ]; then
11 | # for AWS ECS Fargate
12 | ip=$(hostname -I | awk '{print $3}')
13 | elif [ -n "${POD_IP}" ]; then
14 | # for kubernetes
15 | ip=${POD_IP}
16 | fi
17 |
18 | # default to localhost
19 | if [ -z $ip ]; then
20 | ip=127.0.0.1
21 | fi
22 |
23 | # assign the value of NODE_NAME if it exists, else assign the value of FLY_APP_NAME,
24 | # and if that doesn't exist either, assign "realtime" to node_name
25 | node_name="${NODE_NAME:=${APP_NAME:=realtime}}"
26 |
27 | export RELEASE_DISTRIBUTION=name
28 | export RELEASE_NODE=$node_name@$ip
29 |
--------------------------------------------------------------------------------
/lib/realtime/adapters/postgres/protocol/keep_alive.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Adapters.Postgres.Protocol.KeepAlive do
2 | @moduledoc """
3 | Primary keepalive message (B)
4 | Byte1('k')
5 | Identifies the message as a sender keepalive.
6 |
7 | Int64
8 | The current end of WAL on the server.
9 |
10 | Int64
11 | The server's system clock at the time of transmission, as microseconds since midnight on 2000-01-01.
12 |
13 | Byte1
14 | 1 means that the client should reply to this message as soon as possible, to avoid a timeout disconnect. 0 otherwise.
15 |
16 | The receiving process can send replies back to the sender at any time, using one of the following message formats (also in the payload of a CopyData message):
17 | """
18 | @type t :: %__MODULE__{
19 | wal_end: integer(),
20 | clock: integer(),
21 | reply: :now | :await
22 | }
23 | defstruct [:wal_end, :clock, :reply]
24 | end
25 |
--------------------------------------------------------------------------------
/lib/realtime/adapters/postgres/protocol/write.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Adapters.Postgres.Protocol.Write do
2 | @moduledoc """
3 | XLogData (B)
4 | Byte1('w')
5 | Identifies the message as WAL data.
6 |
7 | Int64
8 | The starting point of the WAL data in this message.
9 |
10 | Int64
11 | The current end of WAL on the server.
12 |
13 | Int64
14 | The server's system clock at the time of transmission, as microseconds since midnight on 2000-01-01.
15 |
16 | Byten
17 | A section of the WAL data stream.
18 |
19 | A single WAL record is never split across two XLogData messages. When a WAL record crosses a WAL page boundary, and is therefore already split using continuation records, it can be split at the page boundary. In other words, the first main WAL record and its continuation records can be sent in different XLogData messages.
20 | """
21 | defstruct [:server_wal_start, :server_wal_end, :server_system_clock, :message]
22 | end
23 |
--------------------------------------------------------------------------------
/lib/realtime_web/live/page_live/index.html.heex:
--------------------------------------------------------------------------------
1 | <.h1>Supabase Realtime: Multiplayer Edition
2 | Listen to PostgreSQL changes in real-time over WebSockets
3 | Presence and Broadcast for multiplayer features
4 |
5 |
6 |
Tools
7 |
8 | - <.link href={Routes.inspector_index_path(@socket, :new)}>🔎 Inspector
9 | - <.link href={Routes.status_index_path(@socket, :index)}>🟢 Status
10 |
11 |
12 |
13 |
14 |
Links
15 |
16 | - <.link href="https://supabase.com/docs/guides/realtime">📗 Guides
17 | - <.link href="https://github.com/supabase/realtime">💾 Github
18 | - <.link href="https://github.com/supabase/realtime-js">💾 realtime-js
19 | - <.link href="https://multiplayer.dev">👾 multiplayer.dev
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.github/workflows/version_updated.yml:
--------------------------------------------------------------------------------
1 | on:
2 | pull_request:
3 | branches:
4 | - "main"
5 | paths:
6 | - "lib/**"
7 | - "config/**"
8 | - "priv/**"
9 | - "assets/**"
10 | - "rel/**"
11 | - "mix.exs"
12 | - "Dockerfile"
13 | - "run.sh"
14 |
15 | permissions:
16 | contents: read
17 |
18 | name: Default Checks
19 |
20 | jobs:
21 | versions_updated:
22 | name: Versions Updated
23 | runs-on: blacksmith-4vcpu-ubuntu-2404
24 | steps:
25 | - name: Checkout code
26 | uses: actions/checkout@v3
27 |
28 | - name: Verify Versions Updated
29 | uses: step-security/changed-files@v45
30 | id: verify_changed_files
31 | with:
32 | files: |
33 | mix.exs
34 |
35 | - name: Fail Unless Versions Updated
36 | id: fail_unless_changed
37 | if: steps.verify_changed_files.outputs.any_changed == 'false'
38 | run: |
39 | echo "::error ::Please update the mix.exs version"
40 | exit 1
41 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20250714121412_broadcast_send_error_logging.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.BroadcastSendErrorLogging do
2 | @moduledoc false
3 | use Ecto.Migration
4 | # Removes pg_notification to use postgres logging instead
5 | def change do
6 | execute("""
7 | CREATE OR REPLACE FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean DEFAULT true ) RETURNS void
8 | AS $$
9 | BEGIN
10 | BEGIN
11 | -- Set the topic configuration
12 | EXECUTE format('SET LOCAL realtime.topic TO %L', topic);
13 |
14 | -- Attempt to insert the message
15 | INSERT INTO realtime.messages (payload, event, topic, private, extension)
16 | VALUES (payload, event, topic, private, 'broadcast');
17 | EXCEPTION
18 | WHEN OTHERS THEN
19 | -- Capture and notify the error
20 | RAISE WARNING 'ErrorSendingBroadcastMessage: %', SQLERRM;
21 | END;
22 | END;
23 | $$
24 | LANGUAGE plpgsql;
25 | """)
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/lib/realtime/signal_handler.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.SignalHandler do
2 | @moduledoc false
3 | @behaviour :gen_event
4 | require Logger
5 |
6 | @spec shutdown_in_progress? :: :ok | {:error, :shutdown_in_progress}
7 | def shutdown_in_progress? do
8 | case !!Application.get_env(:realtime, :shutdown_in_progress) do
9 | true -> {:error, :shutdown_in_progress}
10 | false -> :ok
11 | end
12 | end
13 |
14 | @impl true
15 | def init({%{handler_mod: _} = args, :ok}) do
16 | {:ok, args}
17 | end
18 |
19 | @impl true
20 | def handle_event(signal, %{handler_mod: handler_mod} = state) do
21 | Logger.error("#{__MODULE__}: #{inspect(signal)} received")
22 |
23 | if signal == :sigterm do
24 | Application.put_env(:realtime, :shutdown_in_progress, true)
25 | end
26 |
27 | handler_mod.handle_event(signal, state)
28 | end
29 |
30 | @impl true
31 | defdelegate handle_info(info, state), to: :erl_signal_handler
32 |
33 | @impl true
34 | defdelegate handle_call(request, state), to: :erl_signal_handler
35 | end
36 |
--------------------------------------------------------------------------------
/test/realtime/monitoring/erl_sys_mon_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Monitoring.ErlSysMonTest do
2 | use ExUnit.Case, async: true
3 | import ExUnit.CaptureLog
4 | alias Realtime.ErlSysMon
5 |
6 | describe "system monitoring" do
7 | test "logs system monitor events" do
8 | start_supervised!({ErlSysMon, config: [{:long_message_queue, {1, 100}}]})
9 |
10 | log =
11 | capture_log(fn ->
12 | Task.async(fn ->
13 | Process.register(self(), TestProcess)
14 | Enum.map(1..1000, &send(self(), &1))
15 | # Wait for ErlSysMon to notice
16 | Process.sleep(4000)
17 | end)
18 | |> Task.await()
19 | end)
20 |
21 | assert log =~ "Realtime.ErlSysMon message:"
22 | assert log =~ "$initial_call\", {Realtime.Monitoring.ErlSysMonTest"
23 | assert log =~ "ancestors\", [#{inspect(self())}]"
24 | assert log =~ "registered_name: TestProcess"
25 | assert log =~ "message_queue_len: "
26 | assert log =~ "total_heap_size: "
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20240321100241_add_presences_permissions_table.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.AddPresencesPoliciesTable do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | create table(:presences) do
8 | add :channel_id, references(:channels, on_delete: :delete_all), null: false
9 | add :check, :boolean, default: false, null: false
10 | timestamps()
11 | end
12 |
13 | create unique_index(:presences, :channel_id)
14 |
15 | execute("ALTER TABLE realtime.presences ENABLE row level security")
16 | execute("GRANT SELECT ON realtime.presences TO postgres, anon, authenticated, service_role")
17 | execute("GRANT UPDATE ON realtime.presences TO postgres, anon, authenticated, service_role")
18 |
19 | execute("""
20 | GRANT INSERT ON realtime.presences TO postgres, anon, authenticated, service_role
21 | """)
22 |
23 | execute("""
24 | GRANT USAGE ON SEQUENCE realtime.presences_id_seq TO postgres, anon, authenticated, service_role
25 | """)
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/test/realtime_web/live/status_live/index_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.StatusLive.IndexTest do
2 | use RealtimeWeb.ConnCase
3 | import Phoenix.LiveViewTest
4 |
5 | alias Realtime.Latency.Payload
6 | alias Realtime.Nodes
7 | alias RealtimeWeb.Endpoint
8 |
9 | describe "Status LiveView" do
10 | test "renders status page", %{conn: conn} do
11 | {:ok, _view, html} = live(conn, ~p"/status")
12 |
13 | assert html =~ "Realtime Status"
14 | end
15 |
16 | test "receives broadcast from PubSub", %{conn: conn} do
17 | {:ok, view, _html} = live(conn, ~p"/status")
18 |
19 | payload = %Payload{
20 | from_node: Nodes.short_node_id_from_name(:"pink@127.0.0.1"),
21 | node: Nodes.short_node_id_from_name(:"orange@127.0.0.1"),
22 | latency: "42ms",
23 | timestamp: DateTime.utc_now()
24 | }
25 |
26 | Endpoint.broadcast("admin:cluster", "ping", payload)
27 |
28 | html = render(view)
29 | assert html =~ "42ms"
30 | assert html =~ "pink@127.0.0.1_orange@127.0.0.1"
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/lib/realtime_web/live/ping_live.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.PingLive do
2 | use RealtimeWeb, :live_view
3 |
4 | def mount(_params, _session, socket) do
5 | ping()
6 | {:ok, assign(socket, :ping, "0.0 ms")}
7 | end
8 |
9 | def render(assigns) do
10 | ~H"""
11 | <%= @ping %>
12 | """
13 | end
14 |
15 | def handle_info(:ping, socket) do
16 | socket = socket |> push_event("ping", %{ping: DateTime.utc_now() |> DateTime.to_iso8601()})
17 |
18 | {:noreply, socket}
19 | end
20 |
21 | def handle_event("pong", %{"ping" => ping}, socket) do
22 | {:ok, datetime, 0} = DateTime.from_iso8601(ping)
23 |
24 | pong =
25 | (DateTime.diff(DateTime.utc_now(), datetime, :microsecond) / 1000)
26 | |> Float.round(1)
27 | |> Float.to_string()
28 |
29 | ping()
30 | {:noreply, assign(socket, :ping, pong <> " ms")}
31 | end
32 |
33 | defp ping do
34 | timer = if Mix.env() == :dev, do: 60_000, else: 1_000
35 | Process.send_after(self(), :ping, timer)
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/test/realtime/logs_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.LogsTest do
2 | use ExUnit.Case
3 |
4 | describe "Jason.Encoder implementation" do
5 | test "encodes DBConnection.ConnectionError" do
6 | error = %DBConnection.ConnectionError{
7 | message: "connection lost",
8 | reason: :timeout,
9 | severity: :error
10 | }
11 |
12 | encoded = Jason.encode!(error)
13 | assert encoded =~ "message: \"connection lost\""
14 | assert encoded =~ "reason: :timeout"
15 | assert encoded =~ "severity: :error"
16 | end
17 |
18 | test "encodes Postgrex.Error" do
19 | error = %Postgrex.Error{
20 | message: "relation not found",
21 | postgres: %{
22 | code: "42P01",
23 | schema: "public",
24 | table: "users"
25 | }
26 | }
27 |
28 | encoded = Jason.encode!(error)
29 | assert encoded =~ "message: \"relation not found\""
30 | assert encoded =~ "schema: \"public\""
31 | assert encoded =~ "table: \"users\""
32 | assert encoded =~ "code: \"42P01\""
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/test/realtime_web/channels/tenant_rate_limiters_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.TenantRateLimitersTest do
2 | use Realtime.DataCase, async: true
3 |
4 | use Mimic
5 | alias RealtimeWeb.TenantRateLimiters
6 | alias Realtime.Api.Tenant
7 |
8 | setup do
9 | tenant = %Tenant{external_id: random_string(), max_concurrent_users: 1, max_joins_per_second: 1}
10 |
11 | %{tenant: tenant}
12 | end
13 |
14 | describe "check_tenant/1" do
15 | test "rate is not exceeded", %{tenant: tenant} do
16 | assert TenantRateLimiters.check_tenant(tenant) == :ok
17 | end
18 |
19 | test "max concurrent users is exceeded", %{tenant: tenant} do
20 | Realtime.UsersCounter.add(self(), tenant.external_id)
21 |
22 | assert TenantRateLimiters.check_tenant(tenant) == {:error, :too_many_connections}
23 | end
24 |
25 | test "max joins is exceeded", %{tenant: tenant} do
26 | expect(Realtime.RateCounter, :get, fn _ -> {:ok, %{limit: %{triggered: true}}} end)
27 |
28 | assert TenantRateLimiters.check_tenant(tenant) == {:error, :too_many_joins}
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/test/realtime/telemetry/logger_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Telemetry.LoggerTest do
2 | use ExUnit.Case
3 | import ExUnit.CaptureLog
4 | alias Realtime.Telemetry.Logger, as: TelemetryLogger
5 |
6 | setup do
7 | level = Logger.level()
8 | Logger.configure(level: :info)
9 | on_exit(fn -> Logger.configure(level: level) end)
10 | end
11 |
12 | describe "logger backend initialization" do
13 | test "logs on telemetry event" do
14 | start_link_supervised!({TelemetryLogger, handler_id: "telemetry-logger-test"})
15 |
16 | assert capture_log(fn ->
17 | :telemetry.execute([:realtime, :connections], %{count: 1}, %{tenant: "tenant"})
18 | end) =~ "Billing metrics: [:realtime, :connections]"
19 | end
20 |
21 | test "ignores events without tenant" do
22 | start_link_supervised!({TelemetryLogger, handler_id: "telemetry-logger-test"})
23 |
24 | refute capture_log(fn ->
25 | :telemetry.execute([:realtime, :connections], %{count: 1}, %{})
26 | end) =~ "Billing metrics: [:realtime, :connections]"
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/test/realtime/monitoring/distributed_metrics_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.DistributedMetricsTest do
2 | # Async false due to Clustered usage
3 | use ExUnit.Case, async: false
4 |
5 | alias Realtime.DistributedMetrics
6 |
7 | setup_all do
8 | {:ok, node} = Clustered.start()
9 | %{node: node}
10 | end
11 |
12 | describe "info/0 while connected" do
13 | test "per node metric", %{node: node} do
14 | assert %{
15 | ^node => %{
16 | pid: _pid,
17 | port: _port,
18 | queue_size: {:ok, _},
19 | state: :up,
20 | inet_stats: [
21 | recv_oct: _,
22 | recv_cnt: _,
23 | recv_max: _,
24 | recv_avg: _,
25 | recv_dvi: _,
26 | send_oct: _,
27 | send_cnt: _,
28 | send_max: _,
29 | send_avg: _,
30 | send_pend: _
31 | ]
32 | }
33 | } = DistributedMetrics.info()
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/lib/extensions/postgres_cdc_rls/supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Extensions.PostgresCdcRls.Supervisor do
2 | @moduledoc """
3 | Supervisor to spin up the Postgres CDC RLS tree.
4 | """
5 | use Supervisor
6 |
7 | alias Extensions.PostgresCdcRls
8 |
9 | @spec start_link :: :ignore | {:error, any} | {:ok, pid}
10 | def start_link do
11 | Supervisor.start_link(__MODULE__, [], name: __MODULE__)
12 | end
13 |
14 | @impl true
15 | def init(_args) do
16 | load_migrations_modules()
17 |
18 | :syn.add_node_to_scopes(Realtime.Syn.PostgresCdc.scopes())
19 |
20 | children = [
21 | {
22 | PartitionSupervisor,
23 | partitions: 20, child_spec: DynamicSupervisor, strategy: :one_for_one, name: PostgresCdcRls.DynamicSupervisor
24 | }
25 | ]
26 |
27 | Supervisor.init(children, strategy: :one_for_one)
28 | end
29 |
30 | defp load_migrations_modules do
31 | {:ok, modules} = :application.get_key(:realtime, :modules)
32 |
33 | modules
34 | |> Enum.filter(&String.starts_with?(to_string(&1), "Elixir.Realtime.Tenants.Migrations"))
35 | |> Enum.each(&Code.ensure_loaded!/1)
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/priv/static/favicon.svg:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20211116213934_create_realtime_is_visible_through_filters_function.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.CreateRealtimeIsVisibleThroughFiltersFunction do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | execute(
8 | "create function realtime.is_visible_through_filters(columns realtime.wal_column[], filters realtime.user_defined_filter[])
9 | returns bool
10 | language sql
11 | immutable
12 | as $$
13 | /*
14 | Should the record be visible (true) or filtered out (false) after *filters* are applied
15 | */
16 | select
17 | -- Default to allowed when no filters present
18 | coalesce(
19 | sum(
20 | realtime.check_equality_op(
21 | op:=f.op,
22 | type_:=col.type::regtype,
23 | -- cast jsonb to text
24 | val_1:=col.value #>> '{}',
25 | val_2:=f.value
26 | )::int
27 | ) = count(1),
28 | true
29 | )
30 | from
31 | unnest(filters) f
32 | join unnest(columns) col
33 | on f.column_name = col.name;
34 | $$;"
35 | )
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/.github/workflows/mirror.yml:
--------------------------------------------------------------------------------
1 | name: Mirror Image
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | version:
7 | description: "Image tag"
8 | required: true
9 | type: string
10 |
11 | jobs:
12 | mirror:
13 | runs-on: blacksmith-4vcpu-ubuntu-2404
14 | permissions:
15 | contents: read
16 | packages: write
17 | id-token: write
18 | steps:
19 | - name: configure aws credentials
20 | uses: aws-actions/configure-aws-credentials@v1
21 | with:
22 | role-to-assume: ${{ secrets.PROD_AWS_ROLE }}
23 | aws-region: us-east-1
24 | - uses: docker/login-action@v2
25 | with:
26 | registry: public.ecr.aws
27 | - uses: docker/login-action@v2
28 | with:
29 | registry: ghcr.io
30 | username: ${{ github.actor }}
31 | password: ${{ secrets.GITHUB_TOKEN }}
32 | - uses: akhilerm/tag-push-action@v2.1.0
33 | with:
34 | src: docker.io/supabase/realtime:${{ inputs.version }}
35 | dst: |
36 | public.ecr.aws/supabase/realtime:${{ inputs.version }}
37 | ghcr.io/supabase/realtime:${{ inputs.version }}
38 |
--------------------------------------------------------------------------------
/lib/realtime/helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Helpers do
2 | @moduledoc """
3 | This module includes helper functions for different contexts that can't be union in one module.
4 | """
5 | require Logger
6 |
7 | @spec cancel_timer(reference() | nil) :: non_neg_integer() | false | :ok | nil
8 | def cancel_timer(nil), do: nil
9 | def cancel_timer(ref), do: Process.cancel_timer(ref)
10 |
11 | @doc """
12 | Takes the first N items from the queue and returns the list of items and the new queue.
13 |
14 | ## Examples
15 |
16 | iex> q = :queue.new()
17 | iex> q = :queue.in(1, q)
18 | iex> q = :queue.in(2, q)
19 | iex> q = :queue.in(3, q)
20 | iex> Realtime.Helpers.queue_take(q, 2)
21 | {[2, 1], {[], [3]}}
22 | """
23 |
24 | @spec queue_take(:queue.queue(), non_neg_integer()) :: {list(), :queue.queue()}
25 | def queue_take(q, count) do
26 | Enum.reduce_while(1..count, {[], q}, fn _, {items, queue} ->
27 | case :queue.out(queue) do
28 | {{:value, item}, new_q} ->
29 | {:cont, {[item | items], new_q}}
30 |
31 | {:empty, new_q} ->
32 | {:halt, {items, new_q}}
33 | end
34 | end)
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/lib/realtime/release.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Release do
2 | @moduledoc """
3 | Used for executing DB release tasks when run in production without Mix
4 | installed.
5 | """
6 | @app :realtime
7 |
8 | def migrate do
9 | load_app()
10 |
11 | for repo <- repos() do
12 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
13 | end
14 | end
15 |
16 | def rollback(repo, version) do
17 | load_app()
18 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
19 | end
20 |
21 | def seeds(repo) do
22 | load_app()
23 | {:ok, _} = Application.ensure_all_started(:realtime)
24 |
25 | {:ok, {:ok, _}, _} =
26 | Ecto.Migrator.with_repo(repo, fn _repo ->
27 | seeds_file = "#{:code.priv_dir(@app)}/repo/seeds.exs"
28 |
29 | if File.regular?(seeds_file) do
30 | {:ok, Code.eval_file(seeds_file)}
31 | else
32 | {:error, "Seeds file not found."}
33 | end
34 | end)
35 | end
36 |
37 | defp repos do
38 | Application.fetch_env!(@app, :ecto_repos)
39 | end
40 |
41 | defp load_app do
42 | Application.load(@app)
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/lib/realtime/crypto.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Crypto do
2 | @moduledoc """
3 | Encrypt and decrypt operations required by Realtime. It uses the secret set on Application.get_env(:realtime, :db_enc_key)
4 | """
5 |
6 | @doc """
7 | Encrypts the given text
8 | """
9 | @spec encrypt!(binary()) :: binary()
10 | def encrypt!(text) do
11 | secret_key = Application.get_env(:realtime, :db_enc_key)
12 |
13 | :aes_128_ecb
14 | |> :crypto.crypto_one_time(secret_key, pad(text), true)
15 | |> Base.encode64()
16 | end
17 |
18 | @doc """
19 | Decrypts the given base64 encoded text
20 | """
21 | @spec decrypt!(binary()) :: binary()
22 | def decrypt!(base64_text) do
23 | secret_key = Application.get_env(:realtime, :db_enc_key)
24 | crypto_text = Base.decode64!(base64_text)
25 |
26 | :aes_128_ecb
27 | |> :crypto.crypto_one_time(secret_key, crypto_text, false)
28 | |> unpad()
29 | end
30 |
31 | defp pad(data) do
32 | to_add = 16 - rem(byte_size(data), 16)
33 | data <> :binary.copy(<>, to_add)
34 | end
35 |
36 | defp unpad(data) do
37 | to_remove = :binary.last(data)
38 | :binary.part(data, 0, byte_size(data) - to_remove)
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/lib/realtime_web/plugs/rate_limiter.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.Plugs.RateLimiter do
2 | @moduledoc """
3 | Rate limits tenants.
4 | """
5 | import Plug.Conn
6 | import Phoenix.Controller, only: [json: 2]
7 | require Logger
8 |
9 | alias Realtime.Api.Tenant
10 |
11 | def init(opts) do
12 | opts
13 | end
14 |
15 | def call(
16 | %{
17 | assigns: %{
18 | tenant: %Tenant{
19 | events_per_second_rolling: avg,
20 | events_per_second_now: _current,
21 | max_events_per_second: max
22 | }
23 | }
24 | } = conn,
25 | _opts
26 | ) do
27 | avg = trunc(avg)
28 |
29 | conn =
30 | conn
31 | |> put_resp_header("x-rate-rolling", Integer.to_string(avg))
32 | |> put_resp_header("x-rate-limit", Integer.to_string(max))
33 | |> put_resp_header("x-rate-limit-remaining", Integer.to_string(max - avg))
34 |
35 | if avg >= max do
36 | conn
37 | |> put_status(429)
38 | |> json(%{message: "Too many requests"})
39 | |> halt()
40 | else
41 | conn
42 | end
43 | end
44 |
45 | def call(conn, _opts) do
46 | conn
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20211116051442_create_realtime_check_equality_op_function.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.CreateRealtimeCheckEqualityOpFunction do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | execute("create function realtime.check_equality_op(
8 | op realtime.equality_op,
9 | type_ regtype,
10 | val_1 text,
11 | val_2 text
12 | )
13 | returns bool
14 | immutable
15 | language plpgsql
16 | as $$
17 | /*
18 | Casts *val_1* and *val_2* as type *type_* and check the *op* condition for truthiness
19 | */
20 | declare
21 | op_symbol text = (
22 | case
23 | when op = 'eq' then '='
24 | when op = 'neq' then '!='
25 | when op = 'lt' then '<'
26 | when op = 'lte' then '<='
27 | when op = 'gt' then '>'
28 | when op = 'gte' then '>='
29 | else 'UNKNOWN OP'
30 | end
31 | );
32 | res boolean;
33 | begin
34 | execute format('select %L::'|| type_::text || ' ' || op_symbol || ' %L::'|| type_::text, val_1, val_2) into res;
35 | return res;
36 | end;
37 | $$;")
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/authorization/policies.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Authorization.Policies do
2 | @moduledoc """
3 | Policies structure that holds the required authorization information for a given connection.
4 |
5 | Currently there are two types of policies:
6 | * Realtime.Tenants.Authorization.Policies.BroadcastPolicies - Used to store the access to Broadcast feature on a given Topic
7 | * Realtime.Tenants.Authorization.Policies.PresencePolicies - Used to store the access to Presence feature on a given Topic
8 | """
9 |
10 | alias Realtime.Tenants.Authorization.Policies.BroadcastPolicies
11 | alias Realtime.Tenants.Authorization.Policies.PresencePolicies
12 |
13 | defstruct broadcast: %BroadcastPolicies{},
14 | presence: %PresencePolicies{}
15 |
16 | @type t :: %__MODULE__{
17 | broadcast: BroadcastPolicies.t(),
18 | presence: PresencePolicies.t()
19 | }
20 |
21 | @doc """
22 | Updates the Policies struct sub key with the given value.
23 | """
24 | @spec update_policies(t(), atom, atom, boolean) :: t()
25 | def update_policies(policies, key, sub_key, value) do
26 | Map.update!(policies, key, fn map -> Map.put(map, sub_key, value) end)
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/coveralls.json:
--------------------------------------------------------------------------------
1 | {
2 | "skip_files": [
3 | "lib/realtime_web/api_spec.ex",
4 | "lib/realtime_web/channels/presence.ex",
5 | "lib/realtime_web/controllers/page_controller.ex",
6 | "lib/realtime_web/dashboard/",
7 | "lib/realtime_web/endpoint.ex",
8 | "lib/realtime_web/gettext.ex",
9 | "lib/realtime_web/live/",
10 | "lib/realtime_web/open_api_schemas.ex",
11 | "lib/realtime_web/telemetry.ex",
12 | "lib/realtime_web/views/",
13 | "lib/realtime.ex",
14 | "lib/realtime/adapters/changes.ex",
15 | "lib/realtime/adapters/postgres/decoder.ex",
16 | "lib/realtime/adapters/postgres/oid_database.ex",
17 | "lib/realtime/adapters/postgres/protocol/",
18 | "lib/realtime/application.ex",
19 | "lib/realtime/monitoring/prom_ex/plugins/phoenix.ex",
20 | "lib/realtime/operations.ex",
21 | "lib/realtime/release.ex",
22 | "lib/realtime/tenants/authorization/policies/broadcast_policies.ex",
23 | "lib/realtime/tenants/authorization/policies/presence_policies.ex",
24 | "lib/realtime/tenants/repo/migrations/",
25 | "/lib/realtime/tenants/cache_supervisor.ex",
26 | "test/"
27 | ]
28 | }
--------------------------------------------------------------------------------
/lib/realtime_web/api_spec.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.ApiSpec do
2 | @moduledoc false
3 |
4 | alias OpenApiSpex.Components
5 | alias OpenApiSpex.Info
6 | alias OpenApiSpex.OpenApi
7 | alias OpenApiSpex.Paths
8 | alias OpenApiSpex.SecurityScheme
9 | alias OpenApiSpex.Server
10 | alias OpenApiSpex.ServerVariable
11 |
12 | alias RealtimeWeb.Router
13 |
14 | @behaviour OpenApi
15 |
16 | @impl OpenApi
17 | def spec do
18 | url =
19 | case Mix.env() do
20 | :prod -> "https://{tenant}.supabase.co/realtime/v1"
21 | _ -> "http://{tenant}.localhost:4000/"
22 | end
23 |
24 | %OpenApi{
25 | servers: [
26 | %Server{
27 | url: url,
28 | variables: %{"tenant" => %ServerVariable{default: "tenant"}}
29 | }
30 | ],
31 | info: %Info{
32 | title: to_string(Application.spec(:realtime, :description)),
33 | version: to_string(Application.spec(:realtime, :vsn))
34 | },
35 | paths: Paths.from_router(Router),
36 | components: %Components{
37 | securitySchemes: %{"authorization" => %SecurityScheme{type: "http", scheme: "bearer"}}
38 | }
39 | }
40 | |> OpenApiSpex.resolve_schema_modules()
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/lib/realtime_web/plugs/baggage_request_id.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.Plugs.BaggageRequestId do
2 | @moduledoc """
3 | Populates request ID based on trace baggage.
4 | It looks for the specified `baggage_key` (default to 'request-id').
5 |
6 | Otherwise generates a request ID using `Plug.RequestId`
7 | """
8 |
9 | def baggage_key, do: Application.get_env(:realtime, :request_id_baggage_key, "request-id")
10 |
11 | require Logger
12 | alias Plug.Conn
13 | @behaviour Plug
14 |
15 | @impl true
16 | @doc false
17 | def init(opts) do
18 | Keyword.get(opts, :baggage_key, "request-id")
19 | end
20 |
21 | @impl true
22 | @doc false
23 | @spec call(Conn.t(), String.t()) :: Conn.t()
24 | def call(conn, baggage_key) do
25 | :otel_propagator_text_map.extract(conn.req_headers)
26 |
27 | with %{^baggage_key => {request_id, _}} <- :otel_baggage.get_all(),
28 | true <- valid_request_id?(request_id) do
29 | Logger.metadata(request_id: request_id)
30 | Conn.put_resp_header(conn, "x-request-id", request_id)
31 | else
32 | _ ->
33 | opts = Plug.RequestId.init([])
34 | Plug.RequestId.call(conn, opts)
35 | end
36 | end
37 |
38 | defp valid_request_id?(s), do: byte_size(s) in 10..200
39 | end
40 |
--------------------------------------------------------------------------------
/deploy/fly/qa.toml:
--------------------------------------------------------------------------------
1 | app = "realtime-qa"
2 | kill_signal = "SIGTERM"
3 | kill_timeout = 5
4 | processes = []
5 |
6 | [deploy]
7 | release_command = "/app/bin/migrate"
8 | strategy = "rolling"
9 |
10 | [env]
11 | DNS_NODES = "realtime-qa.internal"
12 | ERL_CRASH_DUMP = "/data/erl_crash.dump"
13 | ERL_CRASH_DUMP_SECONDS = 30
14 |
15 | [experimental]
16 | allowed_public_ports = []
17 | auto_rollback = true
18 |
19 | [[services]]
20 | internal_port = 4000
21 | processes = ["app"]
22 | protocol = "tcp"
23 | script_checks = []
24 | [services.concurrency]
25 | hard_limit = 100000
26 | soft_limit = 100000
27 | type = "connections"
28 |
29 | [[services.ports]]
30 | force_https = true
31 | handlers = ["http"]
32 | port = 80
33 |
34 | [[services.ports]]
35 | handlers = ["tls", "http"]
36 | port = 443
37 |
38 | [[services.tcp_checks]]
39 | grace_period = "30s"
40 | interval = "15s"
41 | restart_limit = 6
42 | timeout = "2s"
43 |
44 | [[services.http_checks]]
45 | interval = 10000
46 | grace_period = "5s"
47 | method = "get"
48 | path = "/"
49 | protocol = "http"
50 | restart_limit = 0
51 | timeout = 2000
52 | tls_skip_verify = false
53 | [services.http_checks.headers]
54 |
--------------------------------------------------------------------------------
/lib/realtime_web/views/tenant_view.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.TenantView do
2 | use RealtimeWeb, :view
3 | alias RealtimeWeb.TenantView
4 |
5 | def render("index.json", %{tenants: tenants}) do
6 | %{data: render_many(tenants, TenantView, "tenant.json")}
7 | end
8 |
9 | def render("show.json", %{tenant: tenant}) do
10 | %{data: render_one(tenant, TenantView, "tenant.json")}
11 | end
12 |
13 | def render("not_found.json", %{tenant: nil}) do
14 | %{error: "not found"}
15 | end
16 |
17 | def render("tenant.json", %{tenant: tenant}) do
18 | %{
19 | id: tenant.id,
20 | external_id: tenant.external_id,
21 | name: tenant.name,
22 | max_concurrent_users: tenant.max_concurrent_users,
23 | max_channels_per_client: tenant.max_channels_per_client,
24 | max_events_per_second: tenant.max_events_per_second,
25 | max_joins_per_second: tenant.max_joins_per_second,
26 | inserted_at: tenant.inserted_at,
27 | extensions:
28 | Enum.map(tenant.extensions, fn extension ->
29 | Map.update(extension, :settings, %{}, fn settings ->
30 | Map.drop(settings, ["db_password"])
31 | end)
32 | end),
33 | private_only: tenant.private_only
34 | }
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/test/realtime_web/live/tenants_live/index_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.TenantsLive.IndexTest do
2 | use RealtimeWeb.ConnCase
3 | import Phoenix.LiveViewTest
4 |
5 | describe "TenantsLive Index" do
6 | setup do
7 | user = random_string()
8 | password = random_string()
9 |
10 | System.put_env("DASHBOARD_USER", user)
11 | System.put_env("DASHBOARD_PASSWORD", password)
12 |
13 | on_exit(fn ->
14 | System.delete_env("DASHBOARD_USER")
15 | System.delete_env("DASHBOARD_PASSWORD")
16 | end)
17 |
18 | %{user: user, password: password}
19 | end
20 |
21 | test "renders tenant view", %{conn: conn, user: user, password: password} do
22 | {:ok, _view, html} =
23 | conn |> using_basic_auth(user, password) |> live(~p"/admin/tenants")
24 |
25 | assert html =~ "Listing all Supabase Realtime tenants."
26 | end
27 |
28 | test "returns 401 if no credentials", %{conn: conn} do
29 | assert conn |> get(~p"/admin/tenants") |> response(401)
30 | end
31 | end
32 |
33 | defp using_basic_auth(conn, username, password) do
34 | header_content = "Basic " <> Base.encode64("#{username}:#{password}")
35 | put_req_header(conn, "authorization", header_content)
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/realtime_web/channels/payloads/config.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.Channels.Payloads.Config do
2 | @moduledoc """
3 | Validate config field of the join payload.
4 | """
5 | use Ecto.Schema
6 | import Ecto.Changeset
7 | alias RealtimeWeb.Channels.Payloads.Join
8 | alias RealtimeWeb.Channels.Payloads.Broadcast
9 | alias RealtimeWeb.Channels.Payloads.Presence
10 | alias RealtimeWeb.Channels.Payloads.PostgresChange
11 |
12 | embedded_schema do
13 | embeds_one :broadcast, Broadcast
14 | embeds_one :presence, Presence
15 | embeds_many :postgres_changes, PostgresChange
16 | field :private, :boolean, default: false
17 | end
18 |
19 | def changeset(config, attrs) do
20 | attrs =
21 | attrs
22 | |> Enum.map(fn
23 | {k, v} when is_list(v) -> {k, Enum.filter(v, fn v -> v != nil end)}
24 | {k, v} -> {k, v}
25 | end)
26 | |> Map.new()
27 |
28 | config
29 | |> cast(attrs, [:private], message: &Join.error_message/2)
30 | |> cast_embed(:broadcast, invalid_message: "unable to parse, expected a map")
31 | |> cast_embed(:presence, invalid_message: "unable to parse, expected a map")
32 | |> cast_embed(:postgres_changes, invalid_message: "unable to parse, expected an array of maps")
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/test/support/channel_case.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.ChannelCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | channel tests.
5 |
6 | Such tests rely on `Phoenix.ChannelTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use RealtimeWeb.ChannelCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 | alias Ecto.Adapters.SQL.Sandbox
20 |
21 | using do
22 | quote do
23 | # Import conveniences for testing with channels
24 | import Phoenix.ChannelTest
25 | import Generators
26 | import TenantConnection
27 | # The default endpoint for testing
28 | @endpoint RealtimeWeb.Endpoint
29 | end
30 | end
31 |
32 | setup tags do
33 | pid = Sandbox.start_owner!(Realtime.Repo, shared: not tags[:async])
34 | on_exit(fn -> Sandbox.stop_owner(pid) end)
35 | :ok
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/test/support/joken_current_time_mock.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.Joken.CurrentTime.Mock do
2 | @moduledoc """
3 |
4 | Mock implementation of Joken current time with time freezing.
5 |
6 | This is a copy of Joken.CurrentTime.Mock.
7 |
8 | """
9 |
10 | use Agent
11 |
12 | def start_link do
13 | Agent.start_link(
14 | fn ->
15 | %{is_frozen: false, frozen_value: nil}
16 | end,
17 | name: Joken
18 | )
19 | end
20 |
21 | def child_spec(_args) do
22 | %{
23 | id: __MODULE__,
24 | start: {__MODULE__, :start_link, []}
25 | }
26 | end
27 |
28 | def current_time do
29 | state = Agent.get(Joken, fn state -> state end)
30 |
31 | if state[:is_frozen] do
32 | state[:frozen_value]
33 | else
34 | :os.system_time(:second)
35 | end
36 | end
37 |
38 | def freeze do
39 | freeze(:os.system_time(:second))
40 | end
41 |
42 | def freeze(timestamp) do
43 | Agent.update(Joken, fn _state ->
44 | %{is_frozen: true, frozen_value: timestamp}
45 | end)
46 | end
47 |
48 | def unique_name_per_process do
49 | binary_pid =
50 | self()
51 | |> :erlang.pid_to_list()
52 | |> :erlang.iolist_to_binary()
53 |
54 | "{__MODULE__}_#{binary_pid}" |> String.to_atom()
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20241220123912_realtime_send_handle_exceptions_remove_partition_creation.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.RealtimeSendHandleExceptionsRemovePartitionCreation do
2 | @moduledoc false
3 | use Ecto.Migration
4 |
5 | # We missed the schema prefix of `realtime.` in the create table partition statement
6 | def change do
7 | execute("""
8 | CREATE OR REPLACE FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean DEFAULT true ) RETURNS void
9 | AS $$
10 | BEGIN
11 | BEGIN
12 | -- Attempt to insert the message
13 | INSERT INTO realtime.messages (payload, event, topic, private, extension)
14 | VALUES (payload, event, topic, private, 'broadcast');
15 | EXCEPTION
16 | WHEN OTHERS THEN
17 | -- Capture and notify the error
18 | PERFORM pg_notify(
19 | 'realtime:system',
20 | jsonb_build_object(
21 | 'error', SQLERRM,
22 | 'function', 'realtime.send',
23 | 'event', event,
24 | 'topic', topic,
25 | 'private', private
26 | )::text
27 | );
28 | END;
29 | END;
30 | $$
31 | LANGUAGE plpgsql;
32 | """)
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/lib/realtime/telemetry/logger.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Telemetry.Logger do
2 | @moduledoc """
3 | We can log less frequent Telemetry events to get data into BigQuery.
4 | """
5 |
6 | require Logger
7 |
8 | use GenServer
9 |
10 | @events [
11 | [:realtime, :connections],
12 | [:realtime, :rate_counter, :channel, :events],
13 | [:realtime, :rate_counter, :channel, :joins],
14 | [:realtime, :rate_counter, :channel, :db_events],
15 | [:realtime, :rate_counter, :channel, :presence_events]
16 | ]
17 |
18 | def start_link(args) do
19 | GenServer.start_link(__MODULE__, args)
20 | end
21 |
22 | def init(handler_id: handler_id) do
23 | :telemetry.attach_many(handler_id, @events, &__MODULE__.handle_event/4, [])
24 |
25 | {:ok, []}
26 | end
27 |
28 | @doc """
29 | Logs billing metrics for a tenant aggregated and emitted by a PromEx metric poller.
30 | """
31 | def handle_event(event, measurements, %{tenant: tenant}, _config) do
32 | meta = %{project: tenant, measurements: measurements}
33 | Logger.info(["Billing metrics: ", inspect(event)], meta)
34 | :ok
35 | end
36 |
37 | def handle_event(_event, _measurements, _metadata, _config) do
38 | :ok
39 | end
40 |
41 | def handle_info(_msg, state) do
42 | {:noreply, state}
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/lib/realtime_web/dashboard/process_dump.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Dashboard.ProcessDump do
2 | @moduledoc """
3 | Live Dashboard page to dump the current processes tree
4 | """
5 | use Phoenix.LiveDashboard.PageBuilder
6 |
7 | @impl true
8 | def menu_link(_, _) do
9 | {:ok, "Process Dump"}
10 | end
11 |
12 | @impl true
13 | def mount(_, _, socket) do
14 | ts = :os.system_time(:millisecond)
15 | name = "process_dump_#{ts}"
16 | content = dump_processes(name)
17 | {:ok, socket |> assign(content: content) |> assign(name: name)}
18 | end
19 |
20 | @impl true
21 | def render(assigns) do
22 | ~H"""
23 |
24 |
Process Dump
25 |
26 | Download
27 |
28 |
After you untar the file, you can use `File.read!("filename") |> :erlang.binary_to_term` to check the contents
29 |
30 | """
31 | end
32 |
33 | defp dump_processes(name) do
34 | term = Process.list() |> Enum.map(&Process.info/1) |> :erlang.term_to_binary()
35 | path = "/tmp/#{name}"
36 | File.write!(path, term)
37 | System.cmd("tar", ["-czf", "#{path}.tar.gz", path])
38 | "#{path}.tar.gz" |> File.read!() |> Base.encode64()
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/test/realtime/signal_handler_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.SignalHandlerTest do
2 | use ExUnit.Case
3 | import ExUnit.CaptureLog
4 | alias Realtime.SignalHandler
5 |
6 | defmodule FakeHandler do
7 | def handle_event(:sigterm, _state), do: send(self(), :ok)
8 | end
9 |
10 | setup do
11 | on_exit(fn ->
12 | Application.put_env(:realtime, :shutdown_in_progress, false)
13 | end)
14 | end
15 |
16 | describe "signal handling" do
17 | test "sends signal to handler_mod" do
18 | {:ok, state} = SignalHandler.init({%{handler_mod: FakeHandler}, :ok})
19 |
20 | assert capture_log(fn -> SignalHandler.handle_event(:sigterm, state) end) =~
21 | "SignalHandler: :sigterm received"
22 |
23 | assert_receive :ok
24 | end
25 | end
26 |
27 | describe "shutdown_in_progress?/1" do
28 | test "shutdown_in_progress? returns error when shutdown is in progress" do
29 | Application.put_env(:realtime, :shutdown_in_progress, true)
30 | assert SignalHandler.shutdown_in_progress?() == {:error, :shutdown_in_progress}
31 | end
32 |
33 | test "shutdown_in_progress? returns ok when no shutdown in progress" do
34 | Application.put_env(:realtime, :shutdown_in_progress, false)
35 | assert SignalHandler.shutdown_in_progress?() == :ok
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20241121104152_fix_send_function_.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.FixSendFunction do
2 | @moduledoc false
3 | use Ecto.Migration
4 |
5 | # We missed the schema prefix of `realtime.` in the create table partition statement
6 | def change do
7 | execute("""
8 | CREATE OR REPLACE FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean DEFAULT true)
9 | RETURNS void
10 | AS $$
11 | DECLARE
12 | partition_name text;
13 | BEGIN
14 | partition_name := 'messages_' || to_char(NOW(), 'YYYY_MM_DD');
15 |
16 | IF NOT EXISTS (
17 | SELECT 1
18 | FROM pg_class c
19 | JOIN pg_namespace n ON n.oid = c.relnamespace
20 | WHERE n.nspname = 'realtime'
21 | AND c.relname = partition_name
22 | ) THEN
23 | EXECUTE format(
24 | 'CREATE TABLE realtime.%I PARTITION OF realtime.messages FOR VALUES FROM (%L) TO (%L)',
25 | partition_name,
26 | NOW(),
27 | (NOW() + interval '1 day')::timestamp
28 | );
29 | END IF;
30 |
31 | INSERT INTO realtime.messages (payload, event, topic, private, extension)
32 | VALUES (payload, event, topic, private, 'broadcast');
33 | END;
34 | $$
35 | LANGUAGE plpgsql;
36 | """)
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20241224161212_realtime_send_sets_config.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.RealtimeSendSetsConfig do
2 | @moduledoc false
3 | use Ecto.Migration
4 |
5 | # We missed the schema prefix of `realtime.` in the create table partition statement
6 | def change do
7 | execute("""
8 | CREATE OR REPLACE FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean DEFAULT true ) RETURNS void
9 | AS $$
10 | BEGIN
11 | BEGIN
12 | -- Set the topic configuration
13 | SET LOCAL realtime.topic TO topic;
14 |
15 | -- Attempt to insert the message
16 | INSERT INTO realtime.messages (payload, event, topic, private, extension)
17 | VALUES (payload, event, topic, private, 'broadcast');
18 | EXCEPTION
19 | WHEN OTHERS THEN
20 | -- Capture and notify the error
21 | PERFORM pg_notify(
22 | 'realtime:system',
23 | jsonb_build_object(
24 | 'error', SQLERRM,
25 | 'function', 'realtime.send',
26 | 'event', event,
27 | 'topic', topic,
28 | 'private', private
29 | )::text
30 | );
31 | END;
32 | END;
33 | $$
34 | LANGUAGE plpgsql;
35 | """)
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/deploy/fly/prod.toml:
--------------------------------------------------------------------------------
1 | # fly.toml app configuration file generated for realtime-prod on 2023-08-08T09:07:09-07:00
2 | #
3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file.
4 | #
5 |
6 | app = "realtime-prod"
7 | primary_region = "sea"
8 | kill_signal = "SIGTERM"
9 | kill_timeout = "5s"
10 |
11 | [experimental]
12 | auto_rollback = true
13 |
14 | [deploy]
15 | release_command = "/app/bin/migrate"
16 | strategy = "rolling"
17 |
18 | [env]
19 | DNS_NODES = "realtime-prod.internal"
20 | ERL_CRASH_DUMP = "/data/erl_crash.dump"
21 | ERL_CRASH_DUMP_SECONDS = "30"
22 |
23 |
24 | [[services]]
25 | protocol = "tcp"
26 | internal_port = 4000
27 | processes = ["app"]
28 |
29 | [[services.ports]]
30 | port = 80
31 | handlers = ["http"]
32 | force_https = true
33 |
34 | [[services.ports]]
35 | port = 443
36 | handlers = ["tls", "http"]
37 | [services.concurrency]
38 | type = "connections"
39 | hard_limit = 100000
40 | soft_limit = 100000
41 |
42 | [[services.tcp_checks]]
43 | interval = "15s"
44 | timeout = "2s"
45 | grace_period = "30s"
46 |
47 | [[services.http_checks]]
48 | interval = "10s"
49 | timeout = "2s"
50 | grace_period = "5s"
51 | method = "get"
52 | path = "/"
53 | protocol = "http"
54 | tls_skip_verify = false
55 |
--------------------------------------------------------------------------------
/test/support/rate_counter_helper.ex:
--------------------------------------------------------------------------------
1 | defmodule RateCounterHelper do
2 | alias Realtime.RateCounter
3 |
4 | @spec stop(term()) :: :ok
5 | def stop(tenant_id) do
6 | keys =
7 | Registry.select(Realtime.Registry.Unique, [
8 | {{{:"$1", :_, {:_, :_, :"$2"}}, :"$3", :_}, [{:==, :"$1", RateCounter}, {:==, :"$2", tenant_id}], [:"$_"]}
9 | ])
10 |
11 | Enum.each(keys, fn {{_, _, key}, {pid, _}} ->
12 | if Process.alive?(pid), do: GenServer.stop(pid)
13 | Realtime.GenCounter.delete(key)
14 | Cachex.del!(RateCounter, key)
15 | end)
16 |
17 | :ok
18 | end
19 |
20 | @spec tick!(RateCounter.Args.t()) :: RateCounter.t()
21 | def tick!(args) do
22 | [{pid, _}] = Registry.lookup(Realtime.Registry.Unique, {RateCounter, :rate_counter, args.id})
23 | send(pid, :tick)
24 | {:ok, :sys.get_state(pid)}
25 | end
26 |
27 | def tick_tenant_rate_counters!(tenant_id) do
28 | keys =
29 | Registry.select(Realtime.Registry.Unique, [
30 | {{{:"$1", :_, {:_, :_, :"$2"}}, :"$3", :_}, [{:==, :"$1", RateCounter}, {:==, :"$2", tenant_id}], [:"$_"]}
31 | ])
32 |
33 | Enum.each(keys, fn {{_, _, _key}, {pid, _}} ->
34 | send(pid, :tick)
35 | # do a get_state to wait for the tick to be processed
36 | :sys.get_state(pid)
37 | end)
38 |
39 | :ok
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/assets/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const plugin = require("tailwindcss/plugin")
2 | const colors = require('tailwindcss/colors')
3 |
4 | module.exports = {
5 | content: [
6 | './js/**/*.js',
7 | '../lib/*_web.ex',
8 | '../lib/*_web/**/*.*ex',
9 | ],
10 | theme: {
11 | colors: {
12 | transparent: 'transparent',
13 | current: 'currentColor',
14 | black: colors.black,
15 | white: colors.white,
16 | gray: colors.gray,
17 | emerald: colors.emerald,
18 | indigo: colors.indigo,
19 | yellow: colors.yellow,
20 | green: colors.green
21 | },
22 | fontFamily: {
23 | sans: ['custom-font', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
24 | mono: ['Source Code Pro', 'Menlo', 'monospace'],
25 | },
26 | },
27 | plugins: [
28 | require("@tailwindcss/forms"),
29 | require('@tailwindcss/typography'),
30 | plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])),
31 | plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
32 | plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
33 | plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"]))
34 | ]
35 | };
36 |
--------------------------------------------------------------------------------
/test/realtime/monitoring/latency_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.LatencyTest do
2 | # async: false due to the usage of Clustered mode that interacts with this tests and breaks their expectations
3 | use Realtime.DataCase, async: false
4 | alias Realtime.Latency
5 |
6 | describe "ping/3" do
7 | setup do
8 | Node.stop()
9 | :ok
10 | end
11 |
12 | @tag skip: "Clustered tests creating flakiness, requires time to analyse"
13 | test "emulate a healthy remote node" do
14 | assert [{%Task{}, {:ok, %{response: {:ok, {:pong, "not_set"}}}}}] = Latency.ping()
15 | end
16 |
17 | @tag skip: "Clustered tests creating flakiness, requires time to analyse"
18 | test "emulate a slow but healthy remote node" do
19 | assert [{%Task{}, {:ok, %{response: {:ok, {:pong, "not_set"}}}}}] = Latency.ping(5_000, 10_000, 30_000)
20 | end
21 |
22 | @tag skip: "Clustered tests creating flakiness, requires time to analyse"
23 | test "emulate an unhealthy remote node" do
24 | assert [{%Task{}, {:ok, %{response: {:badrpc, :timeout}}}}] = Latency.ping(5_000, 1_000)
25 | end
26 |
27 | @tag skip: "Clustered tests creating flakiness, requires time to analyse"
28 | test "no response from our Task for a remote node at all" do
29 | assert [{%Task{}, nil}] = Latency.ping(10_000, 5_000, 2_000)
30 | end
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/test/support/cleanup.ex:
--------------------------------------------------------------------------------
1 | defmodule Cleanup do
2 | alias Realtime.Tenants.Connect
3 | def ensure_no_replication_slot(attempts \\ 5)
4 | def ensure_no_replication_slot(0), do: raise("Replication slot teardown failed")
5 | @table_name :"syn_registry_by_name_Elixir.Realtime.Tenants.Connect"
6 |
7 | def ensure_no_replication_slot(attempts) do
8 | {:ok, conn} =
9 | Postgrex.start_link(
10 | hostname: "localhost",
11 | port: 5433,
12 | database: "postgres",
13 | username: "supabase_admin",
14 | password: "postgres"
15 | )
16 |
17 | # Stop lingering connections
18 | Enum.each(:ets.tab2list(@table_name), fn {tenant_id, _, _, _, _, _} ->
19 | Connect.shutdown(tenant_id)
20 | end)
21 |
22 | # Ensure no replication slots are active
23 | case Postgrex.query(conn, "SELECT active_pid, slot_name FROM pg_replication_slots", []) do
24 | {:ok, %{rows: []}} ->
25 | :ok
26 |
27 | {:ok, %{rows: rows}} ->
28 | Enum.each(rows, fn [pid, slot_name] ->
29 | Postgrex.query(conn, "select pg_terminate_backend($1) ", [pid])
30 | Postgrex.query(conn, "select pg_drop_replication_slot($1)", [slot_name])
31 | end)
32 |
33 | {:error, _} ->
34 | Process.sleep(1000)
35 | ensure_no_replication_slot(attempts - 1)
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/test/realtime/tenants/migrations_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.MigrationsTest do
2 | alias Realtime.Tenants.Cache
3 | # Can't use async: true because Cachex does not work well with Ecto Sandbox
4 | use Realtime.DataCase, async: false
5 |
6 | alias Realtime.Tenants.Migrations
7 |
8 | describe "run_migrations/1" do
9 | test "migrations for a given tenant only run once" do
10 | tenant = Containers.checkout_tenant()
11 |
12 | res =
13 | for _ <- 0..10 do
14 | Task.async(fn -> Migrations.run_migrations(tenant) end)
15 | end
16 | |> Task.await_many()
17 | |> Enum.uniq()
18 |
19 | assert [:ok] = res
20 | end
21 |
22 | test "migrations run if tenant has migrations_ran set to 0" do
23 | tenant = Containers.checkout_tenant()
24 |
25 | assert Migrations.run_migrations(tenant) == :ok
26 | # Sleeping waiting for Cache to be invalided
27 | Process.sleep(100)
28 | assert Cache.get_tenant_by_external_id(tenant.external_id).migrations_ran == Enum.count(Migrations.migrations())
29 | end
30 |
31 | test "migrations do not run if tenant has migrations_ran at the count of all migrations" do
32 | tenant = tenant_fixture(%{migrations_ran: Enum.count(Migrations.migrations())})
33 | assert Migrations.run_migrations(tenant) == :noop
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20250128220012_realtime_send_sets_topic_config.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.RealtimeSendSetsTopicConfig do
2 | @moduledoc false
3 | use Ecto.Migration
4 |
5 | # We missed the schema prefix of `realtime.` in the create table partition statement
6 | def change do
7 | execute("""
8 | CREATE OR REPLACE FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean DEFAULT true ) RETURNS void
9 | AS $$
10 | BEGIN
11 | BEGIN
12 | -- Set the topic configuration
13 | EXECUTE format('SET LOCAL realtime.topic TO %L', topic);
14 |
15 | -- Attempt to insert the message
16 | INSERT INTO realtime.messages (payload, event, topic, private, extension)
17 | VALUES (payload, event, topic, private, 'broadcast');
18 | EXCEPTION
19 | WHEN OTHERS THEN
20 | -- Capture and notify the error
21 | PERFORM pg_notify(
22 | 'realtime:system',
23 | jsonb_build_object(
24 | 'error', SQLERRM,
25 | 'function', 'realtime.send',
26 | 'event', event,
27 | 'topic', topic,
28 | 'private', private
29 | )::text
30 | );
31 | END;
32 | END;
33 | $$
34 | LANGUAGE plpgsql;
35 | """)
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/realtime/api/message.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Api.Message do
2 | @moduledoc """
3 | Defines the Message schema to be used to check RLS authorization policies
4 | """
5 | use Ecto.Schema
6 | import Ecto.Changeset
7 |
8 | @primary_key {:id, Ecto.UUID, autogenerate: true}
9 | @schema_prefix "realtime"
10 |
11 | @type t :: %__MODULE__{}
12 | @timestamps_opts [type: :naive_datetime_usec]
13 | schema "messages" do
14 | field(:topic, :string)
15 | field(:extension, Ecto.Enum, values: [:broadcast, :presence])
16 | field(:payload, :map)
17 | field(:event, :string)
18 | field(:private, :boolean)
19 |
20 | timestamps()
21 | end
22 |
23 | def changeset(message, attrs) do
24 | message
25 | |> cast(attrs, [
26 | :topic,
27 | :extension,
28 | :payload,
29 | :event,
30 | :private,
31 | :inserted_at,
32 | :updated_at
33 | ])
34 | |> validate_required([:topic, :extension])
35 | |> put_timestamp(:updated_at)
36 | |> maybe_put_timestamp(:inserted_at)
37 | end
38 |
39 | defp put_timestamp(changeset, field) do
40 | put_change(changeset, field, NaiveDateTime.utc_now(:microsecond))
41 | end
42 |
43 | defp maybe_put_timestamp(changeset, field) do
44 | case get_field(changeset, field) do
45 | nil -> put_timestamp(changeset, field)
46 | _ -> changeset
47 | end
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20211202204605_update_realtime_build_prepared_statement_sql_function_for_compatibility_with_all_types.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.UpdateRealtimeBuildPreparedStatementSqlFunctionForCompatibilityWithAllTypes do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | execute("create or replace function realtime.build_prepared_statement_sql(
8 | prepared_statement_name text,
9 | entity regclass,
10 | columns realtime.wal_column[]
11 | )
12 | returns text
13 | language sql
14 | as $$
15 | /*
16 | Builds a sql string that, if executed, creates a prepared statement to
17 | tests retrive a row from *entity* by its primary key columns.
18 |
19 | Example
20 | select realtime.build_prepared_statment_sql('public.notes', '{\"id\"}'::text[], '{\"bigint\"}'::text[])
21 | */
22 | select
23 | 'prepare ' || prepared_statement_name || ' as
24 | select
25 | exists(
26 | select
27 | 1
28 | from
29 | ' || entity || '
30 | where
31 | ' || string_agg(quote_ident(pkc.name) || '=' || quote_nullable(pkc.value #>> '{}') , ' and ') || '
32 | )'
33 | from
34 | unnest(columns) pkc
35 | where
36 | pkc.is_pkey
37 | group by
38 | entity
39 | $$;")
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/realtime_web/channels/realtime_channel/assign.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.RealtimeChannel.Assigns do
2 | @moduledoc """
3 | Assigns for RealtimeChannel
4 | """
5 |
6 | defstruct [
7 | :tenant,
8 | :log_level,
9 | :rate_counter,
10 | :limits,
11 | :tenant_topic,
12 | :pg_sub_ref,
13 | :pg_change_params,
14 | :postgres_extension,
15 | :claims,
16 | :jwt_secret,
17 | :jwt_jwks,
18 | :tenant_token,
19 | :access_token,
20 | :postgres_cdc_module,
21 | :channel_name,
22 | :headers
23 | ]
24 |
25 | @type t :: %__MODULE__{
26 | tenant: String.t(),
27 | log_level: Logger.level(),
28 | rate_counter: Realtime.RateCounter.t(),
29 | limits: %{
30 | max_events_per_second: integer(),
31 | max_concurrent_users: integer(),
32 | max_bytes_per_second: integer(),
33 | max_channels_per_client: integer(),
34 | max_joins_per_second: integer()
35 | },
36 | tenant_topic: String.t(),
37 | pg_sub_ref: reference() | nil,
38 | pg_change_params: map(),
39 | postgres_extension: map(),
40 | claims: map(),
41 | jwt_secret: String.t(),
42 | jwt_jwks: map(),
43 | tenant_token: String.t(),
44 | access_token: String.t(),
45 | channel_name: String.t()
46 | }
47 | end
48 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/rebalancer.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Rebalancer do
2 | @moduledoc """
3 | Responsible to tell if the executing node is in the correct region for this tenant
4 | """
5 |
6 | alias Realtime.Api.Tenant
7 |
8 | @spec check(MapSet.t(node), MapSet.t(node), binary) :: :ok | {:error, :wrong_region}
9 | def check(previous_nodes_set, current_nodes_set, tenant_id)
10 | when is_struct(previous_nodes_set, MapSet) and is_struct(current_nodes_set, MapSet) and is_binary(tenant_id) do
11 | # Check if the current nodes set is equal to the previous nodes set
12 | # If they are equal it means that the cluster is relatively stable
13 | # We can check now if this Connect process is in the correct region
14 | if MapSet.equal?(current_nodes_set, previous_nodes_set) do
15 | with %Tenant{} = tenant <- Realtime.Tenants.Cache.get_tenant_by_external_id(tenant_id),
16 | {:ok, _node, expected_region} <- Realtime.Nodes.get_node_for_tenant(tenant),
17 | region when is_binary(region) <- Application.get_env(:realtime, :region) do
18 | if region == expected_region do
19 | :ok
20 | else
21 | {:error, :wrong_region}
22 | end
23 | else
24 | _ -> :ok
25 | end
26 | else
27 | # Nodes have changed, we can assume that the cluster is not stable enough to rebalance
28 | :ok
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20241220035512_fix_send_function_partition_creation.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.FixSendFunctionPartitionCreation do
2 | @moduledoc false
3 | use Ecto.Migration
4 |
5 | # We missed the schema prefix of `realtime.` in the create table partition statement
6 | def change do
7 | execute("""
8 | CREATE OR REPLACE FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean DEFAULT true)
9 | RETURNS void
10 | AS $$
11 | DECLARE
12 | partition_name text;
13 | partition_start timestamp;
14 | partition_end timestamp;
15 | BEGIN
16 | partition_start := date_trunc('day', NOW());
17 | partition_end := partition_start + interval '1 day';
18 | partition_name := 'messages_' || to_char(partition_start, 'YYYY_MM_DD');
19 |
20 | BEGIN
21 | EXECUTE format(
22 | 'CREATE TABLE IF NOT EXISTS realtime.%I PARTITION OF realtime.messages FOR VALUES FROM (%L) TO (%L)',
23 | partition_name,
24 | partition_start,
25 | partition_end
26 | );
27 | EXCEPTION WHEN duplicate_table THEN
28 | -- Ignore; table already exists
29 | END;
30 |
31 | INSERT INTO realtime.messages (payload, event, topic, private, extension)
32 | VALUES (payload, event, topic, private, 'broadcast');
33 | END;
34 | $$
35 | LANGUAGE plpgsql;
36 | """)
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/test/realtime_web/controllers/live_dasboard_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.LiveDashboardTest do
2 | use RealtimeWeb.ConnCase
3 | import Generators
4 |
5 | describe "live_dashboard" do
6 | setup do
7 | user = random_string()
8 | password = random_string()
9 |
10 | System.put_env("DASHBOARD_USER", user)
11 | System.put_env("DASHBOARD_PASSWORD", password)
12 |
13 | on_exit(fn ->
14 | System.delete_env("DASHBOARD_USER")
15 | System.delete_env("DASHBOARD_PASSWORD")
16 | end)
17 |
18 | %{user: user, password: password}
19 | end
20 |
21 | test "with credetentials renders view", %{
22 | conn: conn,
23 | user: user,
24 | password: password
25 | } do
26 | path =
27 | conn
28 | |> using_basic_auth(user, password)
29 | |> get("/admin/dashboard")
30 | |> redirected_to(302)
31 |
32 | conn = conn |> recycle() |> using_basic_auth(user, password) |> get(path)
33 |
34 | assert html_response(conn, 200) =~ "Dashboard"
35 | end
36 |
37 | test "without credetentials returns 401", %{conn: conn} do
38 | assert conn |> get("/admin/dashboard") |> response(401)
39 | end
40 | end
41 |
42 | defp using_basic_auth(conn, username, password) do
43 | header_content = "Basic " <> Base.encode64("#{username}:#{password}")
44 | put_req_header(conn, "authorization", header_content)
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20211116050929_create_realtime_quote_wal2json_function.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.CreateRealtimeQuoteWal2jsonFunction do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | execute("create function realtime.quote_wal2json(entity regclass)
8 | returns text
9 | language sql
10 | immutable
11 | strict
12 | as $$
13 | select
14 | (
15 | select string_agg('\' || ch,'')
16 | from unnest(string_to_array(nsp.nspname::text, null)) with ordinality x(ch, idx)
17 | where
18 | not (x.idx = 1 and x.ch = '\"')
19 | and not (
20 | x.idx = array_length(string_to_array(nsp.nspname::text, null), 1)
21 | and x.ch = '\"'
22 | )
23 | )
24 | || '.'
25 | || (
26 | select string_agg('\' || ch,'')
27 | from unnest(string_to_array(pc.relname::text, null)) with ordinality x(ch, idx)
28 | where
29 | not (x.idx = 1 and x.ch = '\"')
30 | and not (
31 | x.idx = array_length(string_to_array(nsp.nspname::text, null), 1)
32 | and x.ch = '\"'
33 | )
34 | )
35 | from
36 | pg_class pc
37 | join pg_namespace nsp
38 | on pc.relnamespace = nsp.oid
39 | where
40 | pc.oid = entity
41 | $$;")
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # This file excludes paths from the Docker build context.
2 | #
3 | # By default, Docker's build context includes all files (and folders) in the
4 | # current directory. Even if a file isn't copied into the container it is still sent to
5 | # the Docker daemon.
6 | #
7 | # There are multiple reasons to exclude files from the build context:
8 | #
9 | # 1. Prevent nested folders from being copied into the container (ex: exclude
10 | # /assets/node_modules when copying /assets)
11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
12 | # 3. Avoid sending files containing sensitive information
13 | #
14 | # More information on using .dockerignore is available here:
15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file
16 |
17 | .dockerignore
18 |
19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed:
20 | #
21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc
23 | .git
24 | !.git/HEAD
25 | !.git/refs
26 |
27 | # Common development/test artifacts
28 | /cover/
29 | /doc/
30 | /test/
31 | /tmp/
32 | .elixir_ls
33 |
34 | # Mix artifacts
35 | /_build/
36 | /deps/
37 | *.ez
38 |
39 | # Generated on crash by the VM
40 | erl_crash.dump
41 |
42 | # Static artifacts - These should be fetched and built inside the Docker image
43 | /assets/node_modules/
44 | /priv/static/assets/
45 | /priv/static/cache_manifest.json
46 |
--------------------------------------------------------------------------------
/lib/realtime_web/controllers/broadcast_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.BroadcastController do
2 | use RealtimeWeb, :controller
3 | use OpenApiSpex.ControllerSpecs
4 | require Logger
5 |
6 | alias Realtime.Tenants.BatchBroadcast
7 | alias RealtimeWeb.OpenApiSchemas.EmptyResponse
8 | alias RealtimeWeb.OpenApiSchemas.TenantBatchParams
9 | alias RealtimeWeb.OpenApiSchemas.TooManyRequestsResponse
10 | alias RealtimeWeb.OpenApiSchemas.UnprocessableEntityResponse
11 |
12 | action_fallback(RealtimeWeb.FallbackController)
13 |
14 | operation(:broadcast,
15 | summary: "Broadcasts a batch of messages",
16 | parameters: [
17 | token: [
18 | in: :header,
19 | name: "Authorization",
20 | schema: %OpenApiSpex.Schema{type: :string},
21 | required: true,
22 | example:
23 | "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2ODAxNjIxNTR9.U9orU6YYqXAtpF8uAiw6MS553tm4XxRzxOhz2IwDhpY"
24 | ]
25 | ],
26 | request_body: TenantBatchParams.params(),
27 | responses: %{
28 | 202 => EmptyResponse.response(),
29 | 403 => EmptyResponse.response(),
30 | 422 => UnprocessableEntityResponse.response(),
31 | 429 => TooManyRequestsResponse.response()
32 | }
33 | )
34 |
35 | def broadcast(%{assigns: %{tenant: tenant}} = conn, attrs) do
36 | with :ok <- BatchBroadcast.broadcast(conn, tenant, attrs) do
37 | send_resp(conn, :accepted, "")
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/deploy/fly/staging.toml:
--------------------------------------------------------------------------------
1 | # fly.toml app configuration file generated for realtime-staging on 2023-06-27T07:39:20-07:00
2 | #
3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file.
4 | #
5 |
6 | app = "realtime-staging"
7 | primary_region = "lhr"
8 | kill_signal = "SIGTERM"
9 | kill_timeout = "5s"
10 |
11 | [experimental]
12 | auto_rollback = true
13 |
14 | [deploy]
15 | release_command = "/app/bin/migrate"
16 | strategy = "rolling"
17 |
18 | [env]
19 | DNS_NODES = "realtime-staging.internal"
20 | ERL_CRASH_DUMP = "/data/erl_crash.dump"
21 | ERL_CRASH_DUMP_SECONDS = "30"
22 |
23 | [[mounts]]
24 | source = "data_vol_machines"
25 | destination = "/data"
26 | processes = ["app"]
27 |
28 | [[services]]
29 | protocol = "tcp"
30 | internal_port = 4000
31 | processes = ["app"]
32 |
33 | [[services.ports]]
34 | port = 80
35 | handlers = ["http"]
36 | force_https = true
37 |
38 | [[services.ports]]
39 | port = 443
40 | handlers = ["tls", "http"]
41 | [services.concurrency]
42 | type = "connections"
43 | hard_limit = 16384
44 | soft_limit = 16384
45 |
46 | [[services.tcp_checks]]
47 | interval = "15s"
48 | timeout = "2s"
49 | grace_period = "30s"
50 | restart_limit = 6
51 |
52 | [[services.http_checks]]
53 | interval = "10s"
54 | timeout = "2s"
55 | grace_period = "5s"
56 | restart_limit = 0
57 | method = "get"
58 | path = "/"
59 | protocol = "http"
60 |
--------------------------------------------------------------------------------
/lib/realtime_web/channels/tenant_rate_limiters.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.TenantRateLimiters do
2 | @moduledoc """
3 | Rate limiters for tenants.
4 | """
5 | require Logger
6 | alias Realtime.UsersCounter
7 | alias Realtime.Tenants
8 | alias Realtime.RateCounter
9 | alias Realtime.Api.Tenant
10 |
11 | @spec check_tenant(Realtime.Api.Tenant.t()) :: :ok | {:error, :too_many_connections | :too_many_joins}
12 | def check_tenant(tenant) do
13 | with :ok <- max_concurrent_users_check(tenant) do
14 | max_joins_per_second_check(tenant)
15 | end
16 | end
17 |
18 | defp max_concurrent_users_check(%Tenant{max_concurrent_users: max_conn_users, external_id: external_id}) do
19 | total_conn_users = UsersCounter.tenant_users(external_id)
20 |
21 | if total_conn_users < max_conn_users,
22 | do: :ok,
23 | else: {:error, :too_many_connections}
24 | end
25 |
26 | defp max_joins_per_second_check(%Tenant{max_joins_per_second: max_joins_per_second} = tenant) do
27 | rate_args = Tenants.joins_per_second_rate(tenant.external_id, max_joins_per_second)
28 |
29 | RateCounter.new(rate_args)
30 |
31 | case RateCounter.get(rate_args) do
32 | {:ok, %{limit: %{triggered: false}}} ->
33 | :ok
34 |
35 | {:ok, %{limit: %{triggered: true}}} ->
36 | {:error, :too_many_joins}
37 |
38 | error ->
39 | Logger.error("UnknownErrorOnCounter: #{inspect(error)}")
40 | {:error, error}
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/lib/realtime_web/channels/auth/channels_authorization.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.ChannelsAuthorization do
2 | @moduledoc """
3 | Check connection is authorized to access channel
4 | """
5 | require Logger
6 |
7 | @doc """
8 | Authorize connection to access channel
9 | """
10 | @spec authorize(binary(), binary(), binary() | nil) ::
11 | {:ok, map()} | {:error, any()} | {:error, :expired_token, String.t()}
12 | def authorize(token, jwt_secret, jwt_jwks) when is_binary(token) do
13 | token
14 | |> clean_token()
15 | |> RealtimeWeb.JwtVerification.verify(jwt_secret, jwt_jwks)
16 | end
17 |
18 | def authorize(_token, _jwt_secret, _jwt_jwks), do: {:error, :invalid_token}
19 |
20 | def authorize_conn(token, jwt_secret, jwt_jwks) do
21 | case authorize(token, jwt_secret, jwt_jwks) do
22 | {:ok, claims} ->
23 | required = ["role", "exp"]
24 | claims_keys = Map.keys(claims)
25 |
26 | if Enum.all?(required, &(&1 in claims_keys)),
27 | do: {:ok, claims},
28 | else: {:error, :missing_claims}
29 |
30 | {:error, [message: validation_timer, claim: "exp", claim_val: claim_val]} when is_integer(validation_timer) ->
31 | msg = "Token has expired #{validation_timer - claim_val} seconds ago"
32 | {:error, :expired_token, msg}
33 |
34 | {:error, reason} ->
35 | {:error, reason}
36 | end
37 | end
38 |
39 | defp clean_token(token), do: Regex.replace(~r/\s|\n/, URI.decode(token), "")
40 | end
41 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20220712093339_recreate_realtime_build_prepared_statement_sql_function.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.RecreateRealtimeBuildPreparedStatementSqlFunction do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | execute("
8 | create or replace function realtime.build_prepared_statement_sql(
9 | prepared_statement_name text,
10 | entity regclass,
11 | columns realtime.wal_column[]
12 | )
13 | returns text
14 | language sql
15 | as $$
16 | /*
17 | Builds a sql string that, if executed, creates a prepared statement to
18 | tests retrive a row from *entity* by its primary key columns.
19 | Example
20 | select realtime.build_prepared_statement_sql('public.notes', '{\"id\"}'::text[], '{\"bigint\"}'::text[])
21 | */
22 | select
23 | 'prepare ' || prepared_statement_name || ' as
24 | select
25 | exists(
26 | select
27 | 1
28 | from
29 | ' || entity || '
30 | where
31 | ' || string_agg(quote_ident(pkc.name) || '=' || quote_nullable(pkc.value #>> '{}') , ' and ') || '
32 | )'
33 | from
34 | unnest(columns) pkc
35 | where
36 | pkc.is_pkey
37 | group by
38 | entity
39 | $$;
40 | ")
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20240401105812_create_realtime_admin_and_move_ownership.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.CreateRealtimeAdminAndMoveOwnership do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | execute("""
8 | DO
9 | $do$
10 | BEGIN
11 | IF EXISTS (
12 | SELECT FROM pg_catalog.pg_roles
13 | WHERE rolname = 'supabase_realtime_admin') THEN
14 |
15 | RAISE NOTICE 'Role "supabase_realtime_admin" already exists. Skipping.';
16 | ELSE
17 | CREATE ROLE supabase_realtime_admin WITH NOINHERIT NOLOGIN NOREPLICATION;
18 | END IF;
19 | END
20 | $do$;
21 | """)
22 |
23 | execute("GRANT ALL PRIVILEGES ON SCHEMA realtime TO supabase_realtime_admin")
24 | execute("GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA realtime TO supabase_realtime_admin")
25 | execute("GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA realtime TO supabase_realtime_admin")
26 | execute("GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA realtime TO supabase_realtime_admin")
27 |
28 | execute("ALTER table realtime.channels OWNER to supabase_realtime_admin")
29 | execute("ALTER table realtime.broadcasts OWNER to supabase_realtime_admin")
30 | execute("ALTER table realtime.presences OWNER TO supabase_realtime_admin")
31 | execute("ALTER function realtime.channel_name() owner to supabase_realtime_admin")
32 |
33 | execute("GRANT supabase_realtime_admin TO postgres")
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20251103001201_broadcast_send_include_payload_id.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.BroadcastSendIncludePayloadId do
2 | @moduledoc false
3 | use Ecto.Migration
4 |
5 | # Include ID in the payload if not defined
6 | def change do
7 | execute("""
8 | CREATE OR REPLACE FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean DEFAULT true ) RETURNS void
9 | AS $$
10 | DECLARE
11 | generated_id uuid;
12 | final_payload jsonb;
13 | BEGIN
14 | BEGIN
15 | -- Generate a new UUID for the id
16 | generated_id := gen_random_uuid();
17 |
18 | -- Check if payload has an 'id' key, if not, add the generated UUID
19 | IF payload ? 'id' THEN
20 | final_payload := payload;
21 | ELSE
22 | final_payload := jsonb_set(payload, '{id}', to_jsonb(generated_id));
23 | END IF;
24 |
25 | -- Set the topic configuration
26 | EXECUTE format('SET LOCAL realtime.topic TO %L', topic);
27 |
28 | -- Attempt to insert the message
29 | INSERT INTO realtime.messages (id, payload, event, topic, private, extension)
30 | VALUES (generated_id, final_payload, event, topic, private, 'broadcast');
31 | EXCEPTION
32 | WHEN OTHERS THEN
33 | -- Capture and notify the error
34 | RAISE WARNING 'ErrorSendingBroadcastMessage: %', SQLERRM;
35 | END;
36 | END;
37 | $$
38 | LANGUAGE plpgsql;
39 | """)
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/realtime_web/live/status_live/index.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.StatusLive.Index do
2 | use RealtimeWeb, :live_view
3 |
4 | alias Realtime.Latency.Payload
5 | alias Realtime.Nodes
6 | alias RealtimeWeb.Endpoint
7 |
8 | @impl true
9 | def mount(_params, _session, socket) do
10 | if connected?(socket), do: Endpoint.subscribe("admin:cluster")
11 |
12 | socket =
13 | socket
14 | |> assign(nodes: Enum.count(all_nodes()))
15 | |> stream(:pings, default_pings())
16 |
17 | {:ok, socket}
18 | end
19 |
20 | @impl true
21 | def handle_params(params, _url, socket) do
22 | {:noreply, apply_action(socket, socket.assigns.live_action, params)}
23 | end
24 |
25 | @impl true
26 | def handle_info(%Phoenix.Socket.Broadcast{payload: %Payload{} = payload}, socket) do
27 | pair = pair_id(payload.from_node, payload.node)
28 |
29 | {:noreply, stream(socket, :pings, [%{id: pair, payload: payload}])}
30 | end
31 |
32 | defp apply_action(socket, :index, _params) do
33 | socket
34 | |> assign(:page_title, "Realtime Status")
35 | end
36 |
37 | defp all_nodes do
38 | [Node.self() | Node.list()] |> Enum.map(&Nodes.short_node_id_from_name/1)
39 | end
40 |
41 | defp default_pings do
42 | for n <- all_nodes(), f <- all_nodes() do
43 | pair = pair_id(f, n)
44 |
45 | %{id: pair, payload: %Payload{from_node: f, latency: "Loading...", node: n, timestamp: "Loading..."}}
46 | end
47 | end
48 |
49 | defp pair_id(from, to) do
50 | from <> "_" <> to
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/lib/realtime_web/live/tenants_live/index.html.heex:
--------------------------------------------------------------------------------
1 | <.h1>Supabase Realtime: Multiplayer Edition
2 | <.h2>Tenants
3 | Listing all Supabase Realtime tenants.
4 |
5 |
6 |
7 | <.form :let={f} for={@filter_changeset} phx-change="validate" phx-submit="filter_submit">
8 |
9 | <.select form={f} field={:order_by} list={@sort_fields} selected={:inserted_at} />
10 |
11 |
12 | <.select form={f} field={:order} list={[:desc, :asc]} selected={:desc} />
13 |
14 |
15 | <.text_input form={f} field={:search} opts={[phx_change: "validate", placeholder: "tenant"]} />
16 |
17 |
18 | <.text_input form={f} field={:limit} opts={[phx_change: "validate", placeholder: "limit"]} />
19 |
20 |
21 |
22 |
23 |
24 |
25 | <%= for t <- @tenants do %>
26 | -
27 | <%= t.external_id %>
28 |
29 | <%= for {k, v} <- Map.take(t, [:max_events_per_second, :max_concurrent_users, :max_bytes_per_second]) do %>
30 |
<%= k %>: <%= v %>
31 | <% end %>
32 |
33 |
34 | <% end %>
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/test/support/containers/container.ex:
--------------------------------------------------------------------------------
1 | defmodule Containers.Container do
2 | use GenServer
3 |
4 | def start_link(args \\ [], opts \\ []) do
5 | GenServer.start_link(__MODULE__, args, opts)
6 | end
7 |
8 | def port(pid), do: GenServer.call(pid, :port, 15_000)
9 | def name(pid), do: GenServer.call(pid, :name, 15_000)
10 |
11 | @impl true
12 | def handle_call(:port, _from, state) do
13 | {:reply, state[:port], state}
14 | end
15 |
16 | @impl true
17 | def handle_call(:name, _from, state) do
18 | {:reply, state[:name], state}
19 | end
20 |
21 | @impl true
22 | def init(_args) do
23 | {:ok, %{}, {:continue, :start_container}}
24 | end
25 |
26 | @impl true
27 | def handle_continue(:start_container, _state) do
28 | {:ok, name, port} = Containers.start_container()
29 |
30 | {:noreply, %{name: name, port: port}, {:continue, :check_container_ready}}
31 | end
32 |
33 | @impl true
34 | def handle_continue(:check_container_ready, state) do
35 | check_container_ready(state[:name])
36 | {:noreply, state}
37 | end
38 |
39 | defp check_container_ready(name, attempts \\ 100)
40 | defp check_container_ready(name, 0), do: raise("Container #{name} is not ready")
41 |
42 | defp check_container_ready(name, attempts) do
43 | case System.cmd("docker", ["exec", name, "pg_isready", "-p", "5432", "-h", "localhost"]) do
44 | {_, 0} ->
45 | :ok
46 |
47 | {_, _} ->
48 | Process.sleep(250)
49 | check_container_ready(name, attempts - 1)
50 | end
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/lib/realtime_web/socket/user_broadcast.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.Socket.UserBroadcast do
2 | @moduledoc """
3 | Defines a message sent from pubsub to channels and vice-versa.
4 |
5 | The message format requires the following keys:
6 |
7 | * `:topic` - The string topic or topic:subtopic pair namespace, for example "messages", "messages:123"
8 | * `:user_event`- The string user event name, for example "my-event"
9 | * `:user_payload_encoding`- :json or :binary
10 | * `:user_payload` - The actual message payload
11 |
12 | Optionally metadata which is a map to be JSON encoded
13 | """
14 |
15 | alias Phoenix.Socket.Broadcast
16 |
17 | @type t :: %__MODULE__{}
18 | defstruct topic: nil, user_event: nil, user_payload: nil, user_payload_encoding: nil, metadata: nil
19 |
20 | @spec convert_to_json_broadcast(t) :: {:ok, Broadcast.t()} | {:error, String.t()}
21 | def convert_to_json_broadcast(%__MODULE__{user_payload_encoding: :json} = user_broadcast) do
22 | payload = %{
23 | "event" => user_broadcast.user_event,
24 | "payload" => Jason.Fragment.new(user_broadcast.user_payload),
25 | "type" => "broadcast"
26 | }
27 |
28 | payload =
29 | if user_broadcast.metadata do
30 | Map.put(payload, "meta", user_broadcast.metadata)
31 | else
32 | payload
33 | end
34 |
35 | {:ok, %Broadcast{event: "broadcast", payload: payload, topic: user_broadcast.topic}}
36 | end
37 |
38 | def convert_to_json_broadcast(%__MODULE__{}), do: {:error, "User payload encoding is not JSON"}
39 | end
40 |
--------------------------------------------------------------------------------
/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use RealtimeWeb.ConnCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 | alias Ecto.Adapters.SQL.Sandbox
20 |
21 | using do
22 | quote do
23 | # Import conveniences for testing with connections
24 | import Generators
25 | import TenantConnection
26 | import Phoenix.ConnTest
27 | import Plug.Conn
28 | import Realtime.DataCase
29 |
30 | alias RealtimeWeb.Router.Helpers, as: Routes
31 |
32 | use RealtimeWeb, :verified_routes
33 | use Realtime.Tracing
34 |
35 | # The default endpoint for testing
36 | @endpoint RealtimeWeb.Endpoint
37 | end
38 | end
39 |
40 | setup tags do
41 | pid = Sandbox.start_owner!(Realtime.Repo, shared: not tags[:async])
42 | on_exit(fn -> Sandbox.stop_owner(pid) end)
43 |
44 | {:ok, conn: Phoenix.ConnTest.build_conn()}
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/test/realtime_web/channels/auth/channels_authorization_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.ChannelsAuthorizationTest do
2 | use ExUnit.Case, async: true
3 |
4 | use Mimic
5 |
6 | import Generators
7 |
8 | alias RealtimeWeb.ChannelsAuthorization
9 | alias RealtimeWeb.JwtVerification
10 |
11 | @secret ""
12 | describe "authorize_conn/3" do
13 | test "when token is authorized" do
14 | input_token = "\n token %20 1 %20 2 %20 3 "
15 | expected_token = "token123"
16 |
17 | expect(JwtVerification, :verify, 1, fn token, @secret, _jwks ->
18 | assert token == expected_token
19 | {:ok, %{}}
20 | end)
21 |
22 | assert {:ok, %{}} = ChannelsAuthorization.authorize(input_token, @secret, nil)
23 | end
24 |
25 | test "when token is unauthorized" do
26 | expect(JwtVerification, :verify, 1, fn _token, @secret, _jwks -> :error end)
27 | assert :error = ChannelsAuthorization.authorize("bad_token", @secret, nil)
28 | end
29 |
30 | test "when token is not a jwt token" do
31 | assert {:error, :token_malformed} = ChannelsAuthorization.authorize("bad_token", @secret, nil)
32 | end
33 |
34 | test "when token is not a string" do
35 | assert {:error, :invalid_token} = ChannelsAuthorization.authorize([], @secret, nil)
36 | end
37 |
38 | test "authorize_conn/3 fails when has missing headers" do
39 | jwt = generate_jwt_token(@secret, %{})
40 |
41 | assert {:error, :missing_claims} =
42 | ChannelsAuthorization.authorize_conn(jwt, @secret, nil)
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/lib/realtime_web/plugs/assign_tenant.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.Plugs.AssignTenant do
2 | @moduledoc """
3 | Picks out the tenant from the request and assigns it in the conn.
4 | """
5 | import Plug.Conn
6 | import Phoenix.Controller, only: [json: 2]
7 |
8 | require Logger
9 |
10 | alias Realtime.Api
11 | alias Realtime.Api.Tenant
12 | alias Realtime.Database
13 | alias Realtime.GenCounter
14 | alias Realtime.RateCounter
15 | alias Realtime.Tenants
16 |
17 | def init(opts) do
18 | opts
19 | end
20 |
21 | def call(%Plug.Conn{host: host} = conn, _opts) do
22 | with {:ok, external_id} <- Database.get_external_id(host),
23 | %Tenant{} = tenant <- Api.get_tenant_by_external_id(external_id, use_replica?: true) do
24 | Logger.metadata(external_id: external_id, project: external_id)
25 | OpenTelemetry.Tracer.set_attributes(external_id: external_id)
26 |
27 | tenant =
28 | tenant
29 | |> tap(&initialize_counters/1)
30 | |> tap(&GenCounter.add(Tenants.requests_per_second_key(&1)))
31 | |> Api.preload_counters()
32 |
33 | assign(conn, :tenant, tenant)
34 | else
35 | nil -> error_response(conn, "Tenant not found in database")
36 | end
37 | end
38 |
39 | defp error_response(conn, message) do
40 | conn
41 | |> put_status(401)
42 | |> json(%{message: message})
43 | |> halt()
44 | end
45 |
46 | defp initialize_counters(tenant) do
47 | RateCounter.new(Tenants.requests_per_second_rate(tenant))
48 | RateCounter.new(Tenants.events_per_second_rate(tenant))
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/cache.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Cache do
2 | @moduledoc """
3 | Cache for Tenants.
4 | """
5 | require Cachex.Spec
6 | require Logger
7 |
8 | alias Realtime.Tenants
9 |
10 | def child_spec(_) do
11 | tenant_cache_expiration = Application.get_env(:realtime, :tenant_cache_expiration)
12 |
13 | %{
14 | id: __MODULE__,
15 | start: {Cachex, :start_link, [__MODULE__, [expiration: Cachex.Spec.expiration(default: tenant_cache_expiration)]]}
16 | }
17 | end
18 |
19 | def get_tenant_by_external_id(keyword), do: apply_repo_fun(__ENV__.function, [keyword])
20 |
21 | @doc """
22 | Invalidates the cache for a tenant in the local node
23 | """
24 | def invalidate_tenant_cache(tenant_id), do: Cachex.del(__MODULE__, {{:get_tenant_by_external_id, 1}, [tenant_id]})
25 |
26 | @doc """
27 | Broadcasts a message to invalidate the tenant cache to all connected nodes
28 | """
29 | @spec distributed_invalidate_tenant_cache(String.t()) :: boolean()
30 | def distributed_invalidate_tenant_cache(tenant_id) when is_binary(tenant_id) do
31 | nodes = [Node.self() | Node.list()]
32 | results = :erpc.multicall(nodes, __MODULE__, :invalidate_tenant_cache, [tenant_id], 1000)
33 |
34 | results
35 | |> Enum.map(fn
36 | {res, _} ->
37 | res
38 |
39 | exception ->
40 | Logger.error("Failed to invalidate tenant cache: #{inspect(exception)}")
41 | :error
42 | end)
43 | |> Enum.all?(&(&1 == :ok))
44 | end
45 |
46 | defp apply_repo_fun(arg1, arg2), do: Realtime.ContextCache.apply_fun(Tenants, arg1, arg2)
47 | end
48 |
--------------------------------------------------------------------------------
/lib/realtime/gen_counter/gen_counter.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.GenCounter do
2 | @moduledoc """
3 | Process holds an ETS table where each row is a key and a counter
4 | """
5 |
6 | use GenServer
7 |
8 | @name __MODULE__
9 | @table :gen_counter
10 |
11 | @spec start_link(any) :: GenServer.on_start()
12 | def start_link(_), do: GenServer.start_link(__MODULE__, :ok, name: @name)
13 |
14 | @spec add(term, integer) :: integer
15 | def add(term), do: add(term, 1)
16 |
17 | def add(term, count), do: :ets.update_counter(@table, term, count, {term, 0})
18 |
19 | @spec get(term) :: integer
20 | def get(term) do
21 | case :ets.lookup(@table, term) do
22 | [{^term, value}] -> value
23 | [] -> 0
24 | end
25 | end
26 |
27 | @doc "Reset counter to 0 and return previous value"
28 | @spec reset(term) :: integer
29 | def reset(term) do
30 | # We might lose some updates between lookup and the update
31 | case :ets.lookup(@table, term) do
32 | [{^term, 0}] ->
33 | 0
34 |
35 | [{^term, previous}] ->
36 | :ets.update_element(@table, term, {2, 0}, {term, 0})
37 | previous
38 |
39 | [] ->
40 | 0
41 | end
42 | end
43 |
44 | @spec delete(term) :: :ok
45 | def delete(term) do
46 | :ets.delete(@table, term)
47 | :ok
48 | end
49 |
50 | @impl true
51 | def init(_) do
52 | table =
53 | :ets.new(@table, [
54 | :set,
55 | :public,
56 | :named_table,
57 | {:decentralized_counters, true},
58 | {:write_concurrency, :auto}
59 | ])
60 |
61 | {:ok, table}
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/test/support/tenant_connection.ex:
--------------------------------------------------------------------------------
1 | defmodule TenantConnection do
2 | @moduledoc """
3 | Boilerplate code to handle Realtime.Tenants.Connect during tests
4 | """
5 | alias Realtime.Api.Message
6 | alias Realtime.Database
7 | alias Realtime.Tenants.Repo
8 | alias Realtime.Tenants.Connect
9 | alias RealtimeWeb.Endpoint
10 |
11 | def create_message(attrs, conn, opts \\ [mode: :savepoint]) do
12 | message = Message.changeset(%Message{}, attrs)
13 |
14 | {:ok, result} =
15 | Database.transaction(conn, fn transaction_conn ->
16 | with {:ok, %Message{} = message} <- Repo.insert(transaction_conn, message, Message, opts) do
17 | message
18 | end
19 | end)
20 |
21 | case result do
22 | %Ecto.Changeset{valid?: false} = error -> {:error, error}
23 | {:error, error} -> {:error, error}
24 | result -> {:ok, result}
25 | end
26 | end
27 |
28 | def ensure_connect_down(tenant_id) do
29 | # Using syn and not a normal Process.monitor because we want to ensure
30 | # that the process is down AND that the registry has been updated accordingly
31 | Endpoint.subscribe("connect:#{tenant_id}")
32 |
33 | if Connect.whereis(tenant_id) do
34 | Connect.shutdown(tenant_id)
35 |
36 | receive do
37 | %{event: "connect_down"} -> :ok
38 | after
39 | 5000 ->
40 | if Connect.whereis(tenant_id) do
41 | raise "Connect process for tenant #{tenant_id} did not shut down in time"
42 | end
43 | end
44 | end
45 | after
46 | Endpoint.unsubscribe("connect:#{tenant_id}")
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20240523004032_redefine_authorization_tables.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.RedefineAuthorizationTables do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | drop table(:broadcasts), mode: :cascade
8 | drop table(:presences), mode: :cascade
9 | drop table(:channels), mode: :cascade
10 |
11 | create_if_not_exists table(:messages) do
12 | add :topic, :text, null: false
13 | add :extension, :text, null: false
14 | timestamps()
15 | end
16 |
17 | create_if_not_exists index(:messages, [:topic])
18 |
19 | execute("ALTER TABLE realtime.messages ENABLE row level security")
20 | execute("GRANT SELECT ON realtime.messages TO postgres, anon, authenticated, service_role")
21 | execute("GRANT UPDATE ON realtime.messages TO postgres, anon, authenticated, service_role")
22 |
23 | execute("""
24 | GRANT INSERT ON realtime.messages TO postgres, anon, authenticated, service_role
25 | """)
26 |
27 | execute("""
28 | GRANT USAGE ON SEQUENCE realtime.messages_id_seq TO postgres, anon, authenticated, service_role
29 | """)
30 |
31 | execute("ALTER table realtime.messages OWNER to supabase_realtime_admin")
32 |
33 | execute("""
34 | DROP function realtime.channel_name
35 | """)
36 |
37 | execute("""
38 | create or replace function realtime.topic() returns text as $$
39 | select nullif(current_setting('realtime.topic', true), '')::text;
40 | $$ language sql stable;
41 | """)
42 |
43 | execute("ALTER function realtime.topic() owner to supabase_realtime_admin")
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/test/realtime_web/integration/tracing_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Integration.TracingTest do
2 | # Async due to usage of global otel_simple_processor
3 | use RealtimeWeb.ConnCase, async: false
4 |
5 | @parent_id "b7ad6b7169203331"
6 | @traceparent "00-0af7651916cd43dd8448eb211c80319c-#{@parent_id}-01"
7 | @span_parent_id Integer.parse(@parent_id, 16) |> elem(0)
8 |
9 | # This is doing a blackbox approach because tracing is not captured with normal Phoenix controller tests
10 | # We need cowboy, endpoint and router to trigger their telemetry events
11 |
12 | test "traces basic HTTP request with phoenix and cowboy information" do
13 | :otel_simple_processor.set_exporter(:otel_exporter_pid, self())
14 | url = RealtimeWeb.Endpoint.url() <> "/healthcheck"
15 |
16 | baggage_request_id = UUID.uuid4()
17 |
18 | response =
19 | Req.get!(url, headers: [{"traceparent", @traceparent}, {"baggage", "sb-request-id=#{baggage_request_id}"}])
20 |
21 | assert_receive {:span, span(name: "GET /healthcheck", attributes: attributes, parent_span_id: @span_parent_id)}
22 |
23 | assert attributes(
24 | map: %{
25 | "http.request.method": :GET,
26 | "http.response.status_code": 200,
27 | "http.route": "/healthcheck",
28 | "phoenix.action": :healthcheck,
29 | "phoenix.plug": RealtimeWeb.PageController,
30 | "url.path": "/healthcheck",
31 | "url.scheme": :http
32 | }
33 | ) = attributes
34 |
35 | assert %{"x-request-id" => [^baggage_request_id]} = response.headers
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | db:
3 | image: supabase/postgres:14.1.0.105
4 | container_name: realtime-db
5 | ports:
6 | - "5432:5432"
7 | volumes:
8 | - ./dev/postgres:/docker-entrypoint-initdb.d/
9 | command: postgres -c config_file=/etc/postgresql/postgresql.conf
10 | environment:
11 | POSTGRES_HOST: /var/run/postgresql
12 | POSTGRES_PASSWORD: postgres
13 | tenant_db:
14 | image: supabase/postgres:14.1.0.105
15 | container_name: tenant-db
16 | ports:
17 | - "5433:5432"
18 | command: postgres -c config_file=/etc/postgresql/postgresql.conf
19 | environment:
20 | POSTGRES_HOST: /var/run/postgresql
21 | POSTGRES_PASSWORD: postgres
22 | realtime:
23 | depends_on:
24 | - db
25 | build: .
26 | container_name: realtime-server
27 | ports:
28 | - "4000:4000"
29 | extra_hosts:
30 | - "host.docker.internal:host-gateway"
31 | environment:
32 | PORT: 4000
33 | DB_HOST: host.docker.internal
34 | DB_PORT: 5432
35 | DB_USER: postgres
36 | DB_PASSWORD: postgres
37 | DB_NAME: postgres
38 | DB_ENC_KEY: supabaserealtime
39 | DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
40 | API_JWT_SECRET: dc447559-996d-4761-a306-f47a5eab1623
41 | SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq
42 | ERL_AFLAGS: -proto_dist inet_tcp
43 | RLIMIT_NOFILE: 1000000
44 | DNS_NODES: "''"
45 | APP_NAME: realtime
46 | RUN_JANITOR: true
47 | JANITOR_INTERVAL: 60000
48 | LOG_LEVEL: "info"
49 | SEED_SELF_HOST: true
50 |
51 |
--------------------------------------------------------------------------------
/lib/realtime/monitoring/erl_sys_mon.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.ErlSysMon do
2 | @moduledoc """
3 | Logs Erlang System Monitor events.
4 | """
5 |
6 | use GenServer
7 |
8 | require Logger
9 |
10 | @defaults [
11 | :busy_dist_port,
12 | :busy_port,
13 | {:long_gc, 500},
14 | {:long_schedule, 500},
15 | {:long_message_queue, {0, 1_000}}
16 | ]
17 |
18 | def start_link(args), do: GenServer.start_link(__MODULE__, args)
19 |
20 | def init(args) do
21 | config = Keyword.get(args, :config, @defaults)
22 | :erlang.system_monitor(self(), config)
23 |
24 | {:ok, []}
25 | end
26 |
27 | def handle_info({:monitor, pid, _type, _meta} = msg, state) when is_pid(pid) do
28 | log_process_info(msg, pid)
29 | {:noreply, state}
30 | end
31 |
32 | def handle_info(msg, state) do
33 | Logger.warning("#{__MODULE__} message: " <> inspect(msg))
34 | {:noreply, state}
35 | end
36 |
37 | defp log_process_info(msg, pid) do
38 | pid_info =
39 | pid
40 | |> Process.info(:dictionary)
41 | |> case do
42 | {:dictionary, dict} when is_list(dict) ->
43 | {List.keyfind(dict, :"$initial_call", 0), List.keyfind(dict, :"$ancestors", 0)}
44 |
45 | other ->
46 | other
47 | end
48 |
49 | extra_info = Process.info(pid, [:registered_name, :message_queue_len, :total_heap_size])
50 |
51 | Logger.warning(
52 | "#{__MODULE__} message: " <>
53 | inspect(msg) <> "|\n process info: #{inspect(pid_info)} #{inspect(extra_info)}"
54 | )
55 | rescue
56 | _ ->
57 | Logger.warning("#{__MODULE__} message: " <> inspect(msg))
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20211116024918_create_realtime_subscription_table.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.CreateRealtimeSubscriptionTable do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | execute("""
8 | DO $$
9 | BEGIN
10 | IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'equality_op') THEN
11 | CREATE TYPE realtime.equality_op AS ENUM(
12 | 'eq', 'neq', 'lt', 'lte', 'gt', 'gte'
13 | );
14 | END IF;
15 | END$$;
16 | """)
17 |
18 | execute("""
19 | DO $$
20 | BEGIN
21 | IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_defined_filter') THEN
22 | CREATE TYPE realtime.user_defined_filter as (
23 | column_name text,
24 | op realtime.equality_op,
25 | value text
26 | );
27 | END IF;
28 | END$$;
29 | """)
30 |
31 | execute("create table if not exists realtime.subscription (
32 | -- Tracks which users are subscribed to each table
33 | id bigint not null generated always as identity,
34 | user_id uuid not null,
35 | -- Populated automatically by trigger. Required to enable auth.email()
36 | email varchar(255),
37 | entity regclass not null,
38 | filters realtime.user_defined_filter[] not null default '{}',
39 | created_at timestamp not null default timezone('utc', now()),
40 |
41 | constraint pk_subscription primary key (id),
42 | unique (entity, user_id, filters)
43 | )")
44 |
45 | execute("create index if not exists ix_realtime_subscription_entity on realtime.subscription using hash (entity)")
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/lib/realtime/metrics_cleaner.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.MetricsCleaner do
2 | @moduledoc false
3 |
4 | use GenServer
5 | require Logger
6 |
7 | defstruct [:check_ref, :interval]
8 |
9 | def start_link(args), do: GenServer.start_link(__MODULE__, args)
10 |
11 | def init(_args) do
12 | interval = Application.get_env(:realtime, :metrics_cleaner_schedule_timer_in_ms)
13 |
14 | Logger.info("Starting MetricsCleaner")
15 | {:ok, %{check_ref: check(interval), interval: interval}}
16 | end
17 |
18 | def handle_info(:check, %{interval: interval} = state) do
19 | Process.cancel_timer(state.check_ref)
20 |
21 | {exec_time, _} = :timer.tc(fn -> loop_and_cleanup_metrics_table() end, :millisecond)
22 |
23 | if exec_time > :timer.seconds(5),
24 | do: Logger.warning("Metrics check took: #{exec_time} ms")
25 |
26 | {:noreply, %{state | check_ref: check(interval)}}
27 | end
28 |
29 | def handle_info(msg, state) do
30 | Logger.error("Unexpected message: #{inspect(msg)}")
31 | {:noreply, state}
32 | end
33 |
34 | defp check(interval), do: Process.send_after(self(), :check, interval)
35 |
36 | @peep_filter_spec [{{{:_, %{tenant: :"$1"}}, :_}, [{:is_binary, :"$1"}], [:"$1"]}]
37 |
38 | defp loop_and_cleanup_metrics_table do
39 | tenant_ids = Realtime.Tenants.Connect.list_tenants() |> MapSet.new()
40 |
41 | {_, {tid, _}} = Peep.Persistent.storage(Realtime.PromEx.Metrics)
42 |
43 | tid
44 | |> :ets.select(@peep_filter_spec)
45 | |> Enum.uniq()
46 | |> Stream.reject(fn tenant_id -> MapSet.member?(tenant_ids, tenant_id) end)
47 | |> Enum.map(fn tenant_id -> %{tenant: tenant_id} end)
48 | |> then(&Peep.prune_tags(Realtime.PromEx.Metrics, &1))
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20220908172859_null_passes_filters_recreate_is_visible_through_filters.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.NullPassesFiltersRecreateIsVisibleThroughFilters do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | execute("
8 | create or replace function realtime.is_visible_through_filters(columns realtime.wal_column[], filters realtime.user_defined_filter[])
9 | returns bool
10 | language sql
11 | immutable
12 | as $$
13 | /*
14 | Should the record be visible (true) or filtered out (false) after *filters* are applied
15 | */
16 | select
17 | -- Default to allowed when no filters present
18 | $2 is null -- no filters. this should not happen because subscriptions has a default
19 | or array_length($2, 1) is null -- array length of an empty array is null
20 | or bool_and(
21 | coalesce(
22 | realtime.check_equality_op(
23 | op:=f.op,
24 | type_:=coalesce(
25 | col.type_oid::regtype, -- null when wal2json version <= 2.4
26 | col.type_name::regtype
27 | ),
28 | -- cast jsonb to text
29 | val_1:=col.value #>> '{}',
30 | val_2:=f.value
31 | ),
32 | false -- if null, filter does not match
33 | )
34 | )
35 | from
36 | unnest(filters) f
37 | join unnest(columns) col
38 | on f.column_name = col.name;
39 | $$;
40 | ")
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/lib/realtime/tenants/repo/migrations/20211116212300_create_realtime_build_prepared_statement_sql_function.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Migrations.CreateRealtimeBuildPreparedStatementSqlFunction do
2 | @moduledoc false
3 |
4 | use Ecto.Migration
5 |
6 | def change do
7 | execute("""
8 | DO $$
9 | BEGIN
10 | IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'wal_column') THEN
11 | CREATE TYPE realtime.wal_column AS (
12 | name text,
13 | type text,
14 | value jsonb,
15 | is_pkey boolean,
16 | is_selectable boolean
17 | );
18 | END IF;
19 | END$$;
20 | """)
21 |
22 | execute("create function realtime.build_prepared_statement_sql(
23 | prepared_statement_name text,
24 | entity regclass,
25 | columns realtime.wal_column[]
26 | )
27 | returns text
28 | language sql
29 | as $$
30 | /*
31 | Builds a sql string that, if executed, creates a prepared statement to
32 | tests retrive a row from *entity* by its primary key columns.
33 |
34 | Example
35 | select realtime.build_prepared_statment_sql('public.notes', '{\"id\"}'::text[], '{\"bigint\"}'::text[])
36 | */
37 | select
38 | 'prepare ' || prepared_statement_name || ' as
39 | select
40 | exists(
41 | select
42 | 1
43 | from
44 | ' || entity || '
45 | where
46 | ' || string_agg(quote_ident(pkc.name) || '=' || quote_nullable(pkc.value) , ' and ') || '
47 | )'
48 | from
49 | unnest(columns) pkc
50 | where
51 | pkc.is_pkey
52 | group by
53 | entity
54 | $$;")
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/realtime_web/views/error_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.ErrorHelpers do
2 | @moduledoc """
3 | Conveniences for translating and building error messages.
4 | """
5 |
6 | use Phoenix.HTML
7 |
8 | @doc """
9 | Generates tag for inlined form input errors.
10 | """
11 | def error_tag(form, field) do
12 | Enum.map(Keyword.get_values(form.errors, field), fn error ->
13 | content_tag(:span, translate_error(error),
14 | class: "invalid-feedback",
15 | phx_feedback_for: input_id(form, field)
16 | )
17 | end)
18 | end
19 |
20 | @doc """
21 | Translates an error message using gettext.
22 | """
23 | def translate_error({msg, opts}) do
24 | # When using gettext, we typically pass the strings we want
25 | # to translate as a static argument:
26 | #
27 | # # Translate "is invalid" in the "errors" domain
28 | # dgettext("errors", "is invalid")
29 | #
30 | # # Translate the number of files with plural rules
31 | # dngettext("errors", "1 file", "%{count} files", count)
32 | #
33 | # Because the error messages we show in our forms and APIs
34 | # are defined inside Ecto, we need to translate them dynamically.
35 | # This requires us to call the Gettext module passing our gettext
36 | # backend as first argument.
37 | #
38 | # Note we use the "errors" domain, which means translations
39 | # should be written to the errors.po file. The :count option is
40 | # set by Ecto and indicates we should also apply plural rules.
41 | if count = opts[:count] do
42 | Gettext.dngettext(RealtimeWeb.Gettext, "errors", msg, msg, count, opts)
43 | else
44 | Gettext.dgettext(RealtimeWeb.Gettext, "errors", msg, opts)
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/test/realtime_web/plugs/rate_limiter_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.Plugs.RateLimiterTest do
2 | use RealtimeWeb.ConnCase
3 |
4 | alias Realtime.Api
5 |
6 | @tenant %{
7 | "external_id" => "localhost",
8 | "name" => "localhost",
9 | "max_events_per_second" => 0,
10 | "extensions" => [
11 | %{
12 | "type" => "postgres_cdc_rls",
13 | "settings" => %{
14 | "db_host" => "127.0.0.1",
15 | "db_name" => "postgres",
16 | "db_user" => "supabase_admin",
17 | "db_password" => "postgres",
18 | "db_port" => "6432",
19 | "poll_interval" => 100,
20 | "poll_max_changes" => 100,
21 | "poll_max_record_bytes" => 1_048_576,
22 | "region" => "us-east-1"
23 | }
24 | }
25 | ],
26 | "postgres_cdc_default" => "postgres_cdc_rls",
27 | "jwt_secret" => "new secret"
28 | }
29 |
30 | setup %{conn: conn} do
31 | conn =
32 | conn
33 | |> put_req_header("accept", "application/json")
34 |
35 | {:ok, _tenant} = Api.create_tenant(@tenant)
36 |
37 | {:ok, conn: conn}
38 | end
39 |
40 | test "serve a 429 when rate limit is set to 0", %{conn: conn} do
41 | conn =
42 | conn
43 | |> Map.put(:host, "localhost.localhost.com")
44 | |> get(Routes.ping_path(conn, :ping))
45 |
46 | assert conn.status == 429
47 | end
48 |
49 | test "serve a 200 when rate limit is set to 100", %{conn: conn} do
50 | {:ok, _tenant} = Api.update_tenant_by_external_id(@tenant["external_id"], %{"max_events_per_second" => 100})
51 |
52 | conn =
53 | conn
54 | |> Map.put(:host, "localhost.localhost.com")
55 | |> get(Routes.ping_path(conn, :ping))
56 |
57 | assert conn.status == 200
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/lib/extensions/postgres_cdc_rls/worker_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Extensions.PostgresCdcRls.WorkerSupervisor do
2 | @moduledoc false
3 | use Supervisor
4 |
5 | alias Extensions.PostgresCdcRls
6 | alias PostgresCdcRls.ReplicationPoller
7 | alias PostgresCdcRls.SubscriptionManager
8 | alias PostgresCdcRls.SubscriptionsChecker
9 | alias Realtime.Tenants.Cache
10 | alias Realtime.PostgresCdc.Exception
11 |
12 | def start_link(args) do
13 | name = PostgresCdcRls.supervisor_id(args["id"], args["region"])
14 | Supervisor.start_link(__MODULE__, args, name: {:via, :syn, name})
15 | end
16 |
17 | @impl true
18 | def init(%{"id" => tenant} = args) when is_binary(tenant) do
19 | Logger.metadata(external_id: tenant, project: tenant)
20 | unless Cache.get_tenant_by_external_id(tenant), do: raise(Exception)
21 |
22 | subscribers_pids_table = :ets.new(__MODULE__, [:public, :bag])
23 | subscribers_nodes_table = :ets.new(__MODULE__, [:public, :set])
24 |
25 | tid_args =
26 | Map.merge(args, %{
27 | "subscribers_pids_table" => subscribers_pids_table,
28 | "subscribers_nodes_table" => subscribers_nodes_table
29 | })
30 |
31 | children = [
32 | %{
33 | id: ReplicationPoller,
34 | start: {ReplicationPoller, :start_link, [tid_args]},
35 | restart: :transient
36 | },
37 | %{
38 | id: SubscriptionManager,
39 | start: {SubscriptionManager, :start_link, [tid_args]},
40 | restart: :transient
41 | },
42 | %{
43 | id: SubscriptionsChecker,
44 | start: {SubscriptionsChecker, :start_link, [tid_args]},
45 | restart: :transient
46 | }
47 | ]
48 |
49 | Supervisor.init(children, strategy: :rest_for_one, max_restarts: 10, max_seconds: 60)
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/test/support/data_case.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.DataCase do
2 | @moduledoc """
3 | This module defines the setup for tests requiring
4 | access to the application's data layer.
5 | You may define functions here to be used as helpers in
6 | your tests.
7 | Finally, if the test case interacts with the database,
8 | we enable the SQL sandbox, so changes done to the database
9 | are reverted at the end of every test. If you are using
10 | PostgreSQL, you can even run database tests asynchronously
11 | by setting `use Realtime.DataCase, async: true`, although
12 | this option is not recommended for other databases.
13 | """
14 |
15 | use ExUnit.CaseTemplate
16 | alias Ecto.Adapters.SQL.Sandbox
17 |
18 | using do
19 | quote do
20 | alias Realtime.Repo
21 |
22 | import Ecto
23 | import Ecto.Changeset
24 | import Ecto.Query
25 | import Realtime.DataCase
26 | import Generators
27 | import TenantConnection
28 | end
29 | end
30 |
31 | setup tags do
32 | pid = Sandbox.start_owner!(Realtime.Repo, shared: not tags[:async])
33 | on_exit(fn -> Sandbox.stop_owner(pid) end)
34 |
35 | {:ok, conn: Phoenix.ConnTest.build_conn()}
36 | end
37 |
38 | @doc """
39 | A helper that transforms changeset errors into a map of messages.
40 | assert {:error, changeset} = Accounts.create_user(%{password: "short"})
41 | assert "password is too short" in errors_on(changeset).password
42 | assert %{password: ["password is too short"]} = errors_on(changeset)
43 | """
44 | def errors_on(changeset) do
45 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
46 | Regex.replace(~r"%{(\w+)}", message, fn _, key ->
47 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
48 | end)
49 | end)
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/.github/workflows/prod_linter.yml:
--------------------------------------------------------------------------------
1 | name: Production Formatting Checks
2 | on:
3 | pull_request:
4 | branches:
5 | - release
6 |
7 | jobs:
8 | format:
9 | name: Formatting Checks
10 | runs-on: blacksmith-4vcpu-ubuntu-2404
11 |
12 | steps:
13 | - uses: actions/checkout@v2
14 | - name: Setup elixir
15 | id: beam
16 | uses: erlef/setup-beam@v1
17 | with:
18 | otp-version: 27.x # Define the OTP version [required]
19 | elixir-version: 1.18.x # Define the elixir version [required]
20 | - name: Cache Mix
21 | uses: actions/cache@v4
22 | with:
23 | path: deps
24 | key: ${{ runner.os }}-mix-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }}
25 | restore-keys: |
26 | ${{ runner.os }}-mix-
27 |
28 | - name: Install dependencies
29 | run: mix deps.get
30 | - name: Set up Postgres
31 | run: docker compose -f docker-compose.dbs.yml up -d
32 | - name: Run database migrations
33 | run: mix ecto.migrate
34 | - name: Run format check
35 | run: mix format --check-formatted
36 | - name: Credo checks
37 | run: mix credo --strict --mute-exit-status
38 | - name: Retrieve PLT Cache
39 | uses: actions/cache@v4
40 | id: plt-cache
41 | with:
42 | path: priv/plts
43 | key: ${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-plts-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }}
44 | - name: Create PLTs
45 | if: steps.plt-cache.outputs.cache-hit != 'true'
46 | run: |
47 | mkdir -p priv/plts
48 | mix dialyzer.build
49 | - name: Run dialyzer
50 | run: mix dialyzer
51 | - name: Run tests
52 | run: mix test
53 |
--------------------------------------------------------------------------------
/test/realtime/tenants/rebalancer_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.RebalancerTest do
2 | use Realtime.DataCase, async: true
3 |
4 | alias Realtime.Tenants.Rebalancer
5 | alias Realtime.Nodes
6 |
7 | use Mimic
8 |
9 | setup do
10 | tenant = Containers.checkout_tenant(run_migrations: true)
11 | # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues
12 | Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant})
13 | %{tenant: tenant}
14 | end
15 |
16 | describe "check/3" do
17 | test "different node set returns :ok", %{tenant: tenant} do
18 | external_id = tenant.external_id
19 |
20 | # Don't even try to look for the region
21 | reject(&Nodes.get_node_for_tenant/1)
22 |
23 | assert Rebalancer.check(MapSet.new([node()]), MapSet.new([node(), :other_node]), external_id) == :ok
24 | end
25 |
26 | test "same node set correct region set returns :ok", %{tenant: tenant} do
27 | external_id = tenant.external_id
28 | current_region = Application.fetch_env!(:realtime, :region)
29 |
30 | expect(Nodes, :get_node_for_tenant, fn ^tenant -> {:ok, :some_node, current_region} end)
31 | reject(&Nodes.get_node_for_tenant/1)
32 |
33 | assert Rebalancer.check(MapSet.new([node(), :some_node]), MapSet.new([node(), :some_node]), external_id) == :ok
34 | end
35 |
36 | test "same node set different region set returns :ok", %{tenant: tenant} do
37 | external_id = tenant.external_id
38 |
39 | expect(Nodes, :get_node_for_tenant, fn ^tenant -> {:ok, :some_node, "ap-southeast-1"} end)
40 | reject(&Nodes.get_node_for_tenant/1)
41 |
42 | assert Rebalancer.check(MapSet.new([node(), :some_node]), MapSet.new([node(), :some_node]), external_id) ==
43 | {:error, :wrong_region}
44 | end
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 | on:
3 | pull_request:
4 | paths:
5 | - "lib/**"
6 | - "test/**"
7 | - "config/**"
8 | - "priv/**"
9 | - "assets/**"
10 | - "rel/**"
11 | - "mix.exs"
12 | - "Dockerfile"
13 | - "run.sh"
14 |
15 | push:
16 | branches:
17 | - main
18 |
19 | concurrency:
20 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
21 | cancel-in-progress: true
22 |
23 | env:
24 | MIX_ENV: test
25 |
26 | jobs:
27 | tests:
28 | name: Tests
29 | runs-on: blacksmith-8vcpu-ubuntu-2404
30 |
31 | steps:
32 | - uses: actions/checkout@v2
33 | - name: Setup elixir
34 | id: beam
35 | uses: erlef/setup-beam@v1
36 | with:
37 | otp-version: 27.x # Define the OTP version [required]
38 | elixir-version: 1.18.x # Define the elixir version [required]
39 | - name: Cache Mix
40 | uses: actions/cache@v4
41 | with:
42 | path: |
43 | deps
44 | _build
45 | key: ${{ github.workflow }}-${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-${{ hashFiles('**/mix.lock') }}
46 | restore-keys: |
47 | ${{ github.workflow }}-${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-
48 |
49 | - name: Pull postgres image quietly in background (used by test/support/containers.ex)
50 | run: docker pull supabase/postgres:15.8.1.040 > /dev/null 2>&1 &
51 | - name: Install dependencies
52 | run: mix deps.get
53 | - name: Set up Postgres
54 | run: docker compose -f docker-compose.dbs.yml up -d
55 | - name: Start epmd
56 | run: epmd -daemon
57 | - name: Run tests
58 | run: MIX_ENV=test MAX_CASES=3 mix coveralls.github
59 | env:
60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
61 |
--------------------------------------------------------------------------------
/lib/realtime/adapters/changes.ex:
--------------------------------------------------------------------------------
1 | # This file draws heavily from https://github.com/cainophile/cainophile
2 | # License: https://github.com/cainophile/cainophile/blob/master/LICENSE
3 |
4 | require Protocol
5 |
6 | defmodule Realtime.Adapters.Changes do
7 | @moduledoc """
8 | This module provides structures of CDC changes.
9 | """
10 | defmodule Transaction do
11 | @moduledoc false
12 | defstruct [:changes, :commit_timestamp]
13 | end
14 |
15 | defmodule NewRecord do
16 | @moduledoc false
17 | @derive {Jason.Encoder, except: [:subscription_ids]}
18 | defstruct [
19 | :columns,
20 | :commit_timestamp,
21 | :errors,
22 | :schema,
23 | :table,
24 | :record,
25 | :subscription_ids,
26 | :type
27 | ]
28 | end
29 |
30 | defmodule UpdatedRecord do
31 | @moduledoc false
32 | @derive {Jason.Encoder, except: [:subscription_ids]}
33 | defstruct [
34 | :columns,
35 | :commit_timestamp,
36 | :errors,
37 | :schema,
38 | :table,
39 | :old_record,
40 | :record,
41 | :subscription_ids,
42 | :type
43 | ]
44 | end
45 |
46 | defmodule DeletedRecord do
47 | @moduledoc false
48 | @derive {Jason.Encoder, except: [:subscription_ids]}
49 | defstruct [
50 | :columns,
51 | :commit_timestamp,
52 | :errors,
53 | :schema,
54 | :table,
55 | :old_record,
56 | :subscription_ids,
57 | :type
58 | ]
59 | end
60 |
61 | defmodule TruncatedRelation do
62 | @moduledoc false
63 | defstruct [:type, :schema, :table, :commit_timestamp]
64 | end
65 | end
66 |
67 | Protocol.derive(Jason.Encoder, Realtime.Adapters.Changes.Transaction)
68 | Protocol.derive(Jason.Encoder, Realtime.Adapters.Changes.TruncatedRelation)
69 | Protocol.derive(Jason.Encoder, Realtime.Adapters.Postgres.Decoder.Messages.Relation.Column)
70 |
--------------------------------------------------------------------------------
/test/support/metrics_helper.ex:
--------------------------------------------------------------------------------
1 | defmodule MetricsHelper do
2 | @spec search(String.t(), String.t(), map() | keyword() | nil) ::
3 | {:ok, String.t(), map(), String.t()} | {:error, String.t()}
4 | def search(prometheus_metrics, metric_name, expected_tags \\ nil) do
5 | # Escape the metric_name to handle any special regex characters
6 | escaped_name = Regex.escape(metric_name)
7 | regex = ~r/^(?#{escaped_name})\{(?[^}]+)\}\s+(?\d+(?:\.\d+)?)$/
8 |
9 | prometheus_metrics
10 | |> IO.iodata_to_binary()
11 | |> String.split("\n", trim: true)
12 | |> Enum.find_value(
13 | nil,
14 | fn item ->
15 | case parse(item, regex, expected_tags) do
16 | {:ok, value} -> value
17 | {:error, _reason} -> false
18 | end
19 | end
20 | )
21 | |> case do
22 | nil -> nil
23 | number -> String.to_integer(number)
24 | end
25 | end
26 |
27 | defp parse(metric_string, regex, expected_tags) do
28 | case Regex.named_captures(regex, metric_string) do
29 | %{"name" => _name, "tags" => tags_string, "value" => value} ->
30 | tags = parse_tags(tags_string)
31 |
32 | if expected_tags && !matching_tags(tags, expected_tags) do
33 | {:error, "Tags do not match expected tags"}
34 | else
35 | {:ok, value}
36 | end
37 |
38 | nil ->
39 | {:error, "Invalid metric format or metric name mismatch"}
40 | end
41 | end
42 |
43 | defp parse_tags(tags_string) do
44 | ~r/(?[a-zA-Z_][a-zA-Z0-9_]*)="(?[^"]*)"/
45 | |> Regex.scan(tags_string, capture: :all_names)
46 | |> Enum.map(fn [key, value] -> {key, value} end)
47 | |> Map.new()
48 | end
49 |
50 | defp matching_tags(tags, expected_tags) do
51 | Enum.all?(expected_tags, fn {k, v} -> Map.get(tags, to_string(k)) == to_string(v) end)
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/lib/realtime/monitoring/prom_ex/plugins/tenants.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.PromEx.Plugins.Tenants do
2 | @moduledoc false
3 |
4 | use PromEx.Plugin
5 |
6 | alias PromEx.MetricTypes.Event
7 | alias Realtime.Tenants.Connect
8 |
9 | require Logger
10 |
11 | defmodule Buckets do
12 | @moduledoc false
13 | use Peep.Buckets.Custom, buckets: [10, 250, 5000, 15_000]
14 | end
15 |
16 | @event_connected [:prom_ex, :plugin, :realtime, :tenants, :connected]
17 |
18 | @impl true
19 | def event_metrics(_) do
20 | Event.build(:realtime, [
21 | distribution(
22 | [:realtime, :global, :rpc],
23 | event_name: [:realtime, :rpc],
24 | description: "Global Latency of rpc calls",
25 | measurement: :latency,
26 | unit: {:microsecond, :millisecond},
27 | tags: [:success, :mechanism],
28 | reporter_options: [peep_bucket_calculator: Buckets]
29 | )
30 | ])
31 | end
32 |
33 | @impl true
34 | def polling_metrics(opts) do
35 | poll_rate = Keyword.get(opts, :poll_rate)
36 |
37 | [
38 | Polling.build(
39 | :realtime_tenants_events,
40 | poll_rate,
41 | {__MODULE__, :execute_metrics, []},
42 | [
43 | last_value(
44 | [:realtime, :tenants, :connected],
45 | event_name: @event_connected,
46 | description: "The total count of connected tenants.",
47 | measurement: :connected
48 | )
49 | ],
50 | detach_on_error: false
51 | )
52 | ]
53 | end
54 |
55 | def execute_metrics do
56 | connected =
57 | if Enum.member?(:syn.node_scopes(), Connect),
58 | do: :syn.local_registry_count(Connect),
59 | else: -1
60 |
61 | execute_metrics(@event_connected, %{connected: connected})
62 | end
63 |
64 | defp execute_metrics(event, metrics) do
65 | :telemetry.execute(event, metrics, %{})
66 | end
67 | end
68 |
--------------------------------------------------------------------------------
/test/realtime/metrics_cleaner_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.MetricsCleanerTest do
2 | # async: false due to potentially polluting metrics with other tenant metrics from other tests
3 | use Realtime.DataCase, async: false
4 |
5 | alias Realtime.MetricsCleaner
6 | alias Realtime.Tenants.Connect
7 |
8 | setup do
9 | interval = Application.get_env(:realtime, :metrics_cleaner_schedule_timer_in_ms)
10 | Application.put_env(:realtime, :metrics_cleaner_schedule_timer_in_ms, 100)
11 | on_exit(fn -> Application.put_env(:realtime, :metrics_cleaner_schedule_timer_in_ms, interval) end)
12 |
13 | tenant = Containers.checkout_tenant(run_migrations: true)
14 |
15 | %{tenant: tenant}
16 | end
17 |
18 | describe "metrics cleanup" do
19 | test "cleans up metrics for users that have been disconnected", %{tenant: %{external_id: external_id}} do
20 | start_supervised!(MetricsCleaner)
21 | {:ok, _} = Connect.lookup_or_start_connection(external_id)
22 | # Wait for promex to collect the metrics
23 | Process.sleep(6000)
24 |
25 | :telemetry.execute(
26 | [:realtime, :connections],
27 | %{connected: 10, connected_cluster: 10, limit: 100},
28 | %{tenant: external_id}
29 | )
30 |
31 | :telemetry.execute(
32 | [:realtime, :connections],
33 | %{connected: 20, connected_cluster: 20, limit: 100},
34 | %{tenant: "disconnected-tenant"}
35 | )
36 |
37 | metrics = Realtime.PromEx.get_metrics() |> IO.iodata_to_binary()
38 |
39 | assert String.contains?(metrics, external_id)
40 | assert String.contains?(metrics, "disconnected-tenant")
41 |
42 | # Wait for clenaup to run
43 | Process.sleep(200)
44 |
45 | metrics = Realtime.PromEx.get_metrics() |> IO.iodata_to_binary()
46 |
47 | assert String.contains?(metrics, external_id)
48 | refute String.contains?(metrics, "disconnected-tenant")
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/priv/repo/seeds.exs:
--------------------------------------------------------------------------------
1 | require Logger
2 |
3 | import Ecto.Adapters.SQL, only: [query: 3]
4 |
5 | alias Realtime.Api.Tenant
6 | alias Realtime.Repo
7 | alias Realtime.Tenants
8 |
9 | tenant_name = System.get_env("SELF_HOST_TENANT_NAME", "realtime-dev")
10 | default_db_host = "host.docker.internal"
11 |
12 | {:ok, tenant} =
13 | Repo.transaction(fn ->
14 | case Repo.get_by(Tenant, external_id: tenant_name) do
15 | %Tenant{} = tenant -> Repo.delete!(tenant)
16 | nil -> {:ok, nil}
17 | end
18 |
19 | %Tenant{}
20 | |> Tenant.changeset(%{
21 | "name" => tenant_name,
22 | "external_id" => tenant_name,
23 | "jwt_secret" => System.get_env("API_JWT_SECRET", "super-secret-jwt-token-with-at-least-32-characters-long"),
24 | "jwt_jwks" => System.get_env("API_JWT_JWKS") |> then(fn v -> if v, do: Jason.decode!(v) end),
25 | "extensions" => [
26 | %{
27 | "type" => "postgres_cdc_rls",
28 | "settings" => %{
29 | "db_name" => System.get_env("DB_NAME", "postgres"),
30 | "db_host" => System.get_env("DB_HOST", default_db_host),
31 | "db_user" => System.get_env("DB_USER", "supabase_admin"),
32 | "db_password" => System.get_env("DB_PASSWORD", "postgres"),
33 | "db_port" => System.get_env("DB_PORT", "5433"),
34 | "region" => "us-east-1",
35 | "poll_interval_ms" => 100,
36 | "poll_max_record_bytes" => 1_048_576,
37 | "ssl_enforced" => false
38 | }
39 | }
40 | ]
41 | })
42 | |> Repo.insert!()
43 | end)
44 |
45 | tenant = Tenants.get_tenant_by_external_id(tenant_name)
46 |
47 | with res when res in [:noop, :ok] <- Tenants.Migrations.run_migrations(tenant),
48 | :ok <- Tenants.Janitor.MaintenanceTask.run(tenant.external_id) do
49 | Logger.info("Tenant set-up successfully")
50 | else
51 | error ->
52 | Logger.info("Failed to set-up tenant: #{inspect(error)}")
53 | end
54 |
--------------------------------------------------------------------------------
/lib/realtime/logs.ex:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Logs do
2 | @moduledoc """
3 | Logging operations for Realtime
4 | """
5 | require Logger
6 |
7 | defmacro __using__(_opts) do
8 | quote do
9 | require Logger
10 |
11 | import Realtime.Logs
12 | end
13 | end
14 |
15 | @doc """
16 | Prepares a value to be logged
17 | """
18 | def to_log(value) when is_binary(value), do: value
19 | def to_log(value), do: inspect(value, pretty: true)
20 |
21 | defmacro log_error(code, error, metadata \\ []) do
22 | quote bind_quoted: [code: code, error: error, metadata: metadata], location: :keep do
23 | Logger.error("#{code}: #{Realtime.Logs.to_log(error)}", [error_code: code] ++ metadata)
24 | end
25 | end
26 |
27 | defmacro log_warning(code, warning, metadata \\ []) do
28 | quote bind_quoted: [code: code, warning: warning, metadata: metadata], location: :keep do
29 | Logger.warning("#{code}: #{Realtime.Logs.to_log(warning)}", [{:error_code, code} | metadata])
30 | end
31 | end
32 | end
33 |
34 | defimpl Jason.Encoder, for: DBConnection.ConnectionError do
35 | def encode(
36 | %DBConnection.ConnectionError{message: message, reason: reason, severity: severity},
37 | _opts
38 | ) do
39 | inspect(%{message: message, reason: reason, severity: severity}, pretty: true)
40 | end
41 | end
42 |
43 | defimpl Jason.Encoder, for: Postgrex.Error do
44 | def encode(
45 | %Postgrex.Error{
46 | message: message,
47 | postgres: %{code: code, schema: schema, table: table}
48 | },
49 | _opts
50 | ) do
51 | inspect(%{message: message, schema: schema, table: table, code: code}, pretty: true)
52 | end
53 | end
54 |
55 | defimpl Jason.Encoder, for: Tuple do
56 | require Logger
57 |
58 | def encode(tuple, _opts) do
59 | Logger.error("UnableToEncodeJson: Tuple encoding not supported: #{inspect(tuple)}")
60 | inspect(%{error: "unable to parse response"}, pretty: true)
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/lib/realtime_web/plugs/auth_tenant.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.AuthTenant do
2 | @moduledoc """
3 | Authorization plug to ensure that only authorized clients can connect to the their tenant's endpoints.
4 | """
5 | require Logger
6 |
7 | import Plug.Conn
8 | import Phoenix.Controller, only: [json: 2]
9 |
10 | alias Realtime.Api.Tenant
11 | alias Realtime.Crypto
12 |
13 | alias RealtimeWeb.ChannelsAuthorization
14 |
15 | def init(opts), do: opts
16 |
17 | def call(%{assigns: %{tenant: tenant}} = conn, _opts) do
18 | Logger.metadata(external_id: tenant.external_id, project: tenant.external_id)
19 |
20 | with %Tenant{jwt_secret: jwt_secret, jwt_jwks: jwt_jwks} <- tenant,
21 | token when is_binary(token) <- access_token(conn),
22 | jwt_secret_dec <- Crypto.decrypt!(jwt_secret),
23 | {:ok, claims} <- ChannelsAuthorization.authorize_conn(token, jwt_secret_dec, jwt_jwks) do
24 | conn
25 | |> assign(:claims, claims)
26 | |> assign(:jwt, token)
27 | |> assign(:role, claims["role"])
28 | |> assign(:sub, claims["sub"])
29 | else
30 | _error -> unauthorized(conn)
31 | end
32 | end
33 |
34 | def call(conn, _opts), do: unauthorized(conn)
35 |
36 | defp access_token(conn) do
37 | authorization = get_req_header(conn, "authorization")
38 | apikey = get_req_header(conn, "apikey")
39 |
40 | authorization =
41 | case authorization do
42 | [] ->
43 | nil
44 |
45 | [""] ->
46 | nil
47 |
48 | [value | _] ->
49 | [bearer, token] = value |> String.split(" ")
50 | bearer = String.downcase(bearer)
51 | if bearer == "bearer", do: token
52 | end
53 |
54 | apikey =
55 | case apikey do
56 | [] -> nil
57 | [value | _] -> value
58 | end
59 |
60 | cond do
61 | authorization -> authorization
62 | apikey -> apikey
63 | true -> nil
64 | end
65 | end
66 |
67 | defp unauthorized(conn),
68 | do: conn |> put_status(401) |> json(%{message: "Unauthorized"}) |> halt()
69 | end
70 |
--------------------------------------------------------------------------------
/lib/realtime_web/telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.Telemetry do
2 | @moduledoc false
3 |
4 | use Supervisor
5 | import Telemetry.Metrics
6 |
7 | def start_link(arg) do
8 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
9 | end
10 |
11 | @impl true
12 | def init(_arg) do
13 | children = [
14 | # Telemetry poller will execute the given period measurements
15 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
16 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
17 | # Add reporters as children of your supervision tree.
18 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
19 | ]
20 |
21 | Supervisor.init(children, strategy: :one_for_one)
22 | end
23 |
24 | def metrics do
25 | [
26 | # Phoenix Metrics
27 | summary("phoenix.endpoint.stop.duration",
28 | unit: {:native, :millisecond}
29 | ),
30 | summary("phoenix.router_dispatch.stop.duration",
31 | tags: [:route],
32 | unit: {:native, :millisecond}
33 | ),
34 |
35 | # Database Metrics
36 | summary("realtime.repo.query.total_time", unit: {:native, :millisecond}),
37 | summary("realtime.repo.query.decode_time", unit: {:native, :millisecond}),
38 | summary("realtime.repo.query.query_time", unit: {:native, :millisecond}),
39 | summary("realtime.repo.query.queue_time", unit: {:native, :millisecond}),
40 | summary("realtime.repo.query.idle_time", unit: {:native, :millisecond}),
41 |
42 | # VM Metrics
43 | summary("vm.memory.total", unit: {:byte, :kilobyte}),
44 | summary("vm.total_run_queue_lengths.total"),
45 | summary("vm.total_run_queue_lengths.cpu"),
46 | summary("vm.total_run_queue_lengths.io")
47 | ]
48 | end
49 |
50 | defp periodic_measurements do
51 | [
52 | # A module, function and arguments to be invoked periodically.
53 | # This function must call :telemetry.execute/3 and a metric must be added above.
54 | # {RealtimeWeb, :count_users, []}
55 | ]
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/lib/extensions/postgres_cdc_rls/message_dispatcher.ex:
--------------------------------------------------------------------------------
1 | # This file draws from https://github.com/phoenixframework/phoenix/blob/9941711736c8464b27b40914a4d954ed2b4f5958/lib/phoenix/channel/server.ex
2 | # License: https://github.com/phoenixframework/phoenix/blob/518a4640a70aa4d1370a64c2280d598e5b928168/LICENSE.md
3 |
4 | defmodule Extensions.PostgresCdcRls.MessageDispatcher do
5 | @moduledoc """
6 | Hook invoked by Phoenix.PubSub dispatch.
7 | """
8 |
9 | alias Phoenix.Socket.Broadcast
10 |
11 | def dispatch([_ | _] = topic_subscriptions, _from, {type, payload, sub_ids}) do
12 | _ =
13 | Enum.reduce(topic_subscriptions, %{}, fn
14 | {_pid, {:subscriber_fastlane, fastlane_pid, serializer, ids, join_topic, is_new_api}}, cache ->
15 | for {bin_id, id} <- ids, reduce: [] do
16 | acc ->
17 | if MapSet.member?(sub_ids, bin_id) do
18 | [id | acc]
19 | else
20 | acc
21 | end
22 | end
23 | |> case do
24 | [_ | _] = valid_ids ->
25 | new_payload =
26 | if is_new_api do
27 | %Broadcast{
28 | topic: join_topic,
29 | event: "postgres_changes",
30 | payload: %{ids: valid_ids, data: Jason.Fragment.new(payload)}
31 | }
32 | else
33 | %Broadcast{topic: join_topic, event: type, payload: Jason.Fragment.new(payload)}
34 | end
35 |
36 | broadcast_message(cache, fastlane_pid, new_payload, serializer)
37 |
38 | _ ->
39 | cache
40 | end
41 | end)
42 |
43 | :ok
44 | end
45 |
46 | defp broadcast_message(cache, fastlane_pid, msg, serializer) do
47 | case cache do
48 | %{^msg => encoded_msg} ->
49 | send(fastlane_pid, encoded_msg)
50 | cache
51 |
52 | %{} ->
53 | encoded_msg = serializer.fastlane!(msg)
54 | send(fastlane_pid, encoded_msg)
55 | Map.put(cache, msg, encoded_msg)
56 | end
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Configure your database
4 | #
5 | # The MIX_TEST_PARTITION environment variable can be used
6 | # to provide built-in test partitioning in CI environment.
7 | # Run `mix help test` for more information.
8 | for repo <- [
9 | Realtime.Repo,
10 | Realtime.Repo.Replica.FRA,
11 | Realtime.Repo.Replica.IAD,
12 | Realtime.Repo.Replica.SIN,
13 | Realtime.Repo.Replica.SJC,
14 | Realtime.Repo.Replica.Singapore,
15 | Realtime.Repo.Replica.London,
16 | Realtime.Repo.Replica.NorthVirginia,
17 | Realtime.Repo.Replica.Oregon,
18 | Realtime.Repo.Replica.SanJose
19 | ] do
20 | config :realtime, repo,
21 | username: "postgres",
22 | password: "postgres",
23 | database: "realtime_test",
24 | hostname: "127.0.0.1",
25 | pool: Ecto.Adapters.SQL.Sandbox
26 | end
27 |
28 | # Running server during tests to run integration tests
29 | config :realtime, RealtimeWeb.Endpoint,
30 | http: [port: 4002],
31 | server: true
32 |
33 | # that's what config/runtime.exs expects to see as region
34 | System.put_env("REGION", "us-east-1")
35 |
36 | config :realtime,
37 | regional_broadcasting: true,
38 | region: "us-east-1",
39 | db_enc_key: "1234567890123456",
40 | jwt_claim_validators: System.get_env("JWT_CLAIM_VALIDATORS", "{}"),
41 | api_jwt_secret: System.get_env("API_JWT_SECRET", "secret"),
42 | metrics_jwt_secret: "test",
43 | prom_poll_rate: 5_000,
44 | request_id_baggage_key: "sb-request-id"
45 |
46 | # Print nothing during tests unless captured or a test failure happens
47 | config :logger,
48 | backends: [],
49 | level: :info
50 |
51 | # Configures Elixir's Logger
52 | config :logger, :console,
53 | format: "$time $metadata[$level] $message\n",
54 | metadata: [:error_code, :request_id, :project, :external_id, :application_name, :sub, :iss, :exp]
55 |
56 | config :opentelemetry,
57 | span_processor: :simple,
58 | traces_exporter: :none,
59 | processors: [{:otel_simple_processor, %{}}]
60 |
61 | # Using different ports so that a remote node during test can connect using the same local network
62 | # See Clustered module
63 | config :gen_rpc,
64 | tcp_server_port: 5969,
65 | tcp_client_port: 5970
66 |
--------------------------------------------------------------------------------
/test/realtime/tenants/connect/register_process_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Realtime.Tenants.Connect.RegisterProcessTest do
2 | use Realtime.DataCase, async: true
3 | alias Realtime.Tenants.Connect.RegisterProcess
4 | alias Realtime.Database
5 |
6 | describe "run/1" do
7 | setup do
8 | tenant = Containers.checkout_tenant(run_migrations: true)
9 | # Warm cache to avoid Cachex and Ecto.Sandbox ownership issues
10 | Cachex.put!(Realtime.Tenants.Cache, {{:get_tenant_by_external_id, 1}, [tenant.external_id]}, {:cached, tenant})
11 | {:ok, conn} = Database.connect(tenant, "realtime_test")
12 | %{tenant_id: tenant.external_id, db_conn_pid: conn}
13 | end
14 |
15 | test "registers the process in syn and Registry and updates metadata", %{tenant_id: tenant_id, db_conn_pid: conn} do
16 | # Fake the process registration in :syn
17 | :syn.register(Realtime.Tenants.Connect, tenant_id, self(), %{conn: nil})
18 | assert {:ok, _} = RegisterProcess.run(%{tenant_id: tenant_id, db_conn_pid: conn})
19 | assert {pid, %{conn: ^conn}} = :syn.lookup(Realtime.Tenants.Connect, tenant_id)
20 | assert [{^pid, %{}}] = Registry.lookup(Realtime.Tenants.Connect.Registry, tenant_id)
21 | end
22 |
23 | test "fails to register the process in syn and Registry and updates metadata", %{
24 | tenant_id: tenant_id,
25 | db_conn_pid: conn
26 | } do
27 | # Fake the process registration in :syn
28 | :syn.register(Realtime.Tenants.Connect, tenant_id, self(), %{conn: nil})
29 |
30 | # Register normally
31 | assert {:ok, _} = RegisterProcess.run(%{tenant_id: tenant_id, db_conn_pid: conn})
32 | assert {pid, %{conn: ^conn}} = :syn.lookup(Realtime.Tenants.Connect, tenant_id)
33 | assert [{^pid, %{}}] = Registry.lookup(Realtime.Tenants.Connect.Registry, tenant_id)
34 |
35 | # Check failure
36 | assert {:error, :already_registered} = RegisterProcess.run(%{tenant_id: tenant_id, db_conn_pid: conn})
37 | end
38 |
39 | test "handles undefined process error" do
40 | assert {:error, :process_not_found} =
41 | RegisterProcess.run(%{tenant_id: Generators.random_string(), db_conn_pid: nil})
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/lib/realtime_web/channels/payloads/join.ex:
--------------------------------------------------------------------------------
1 | defmodule RealtimeWeb.Channels.Payloads.Join do
2 | @moduledoc """
3 | Payload validation for the phx_join event.
4 | """
5 | use Ecto.Schema
6 | import Ecto.Changeset
7 | alias RealtimeWeb.Channels.Payloads.Config
8 | alias RealtimeWeb.Channels.Payloads.Broadcast
9 | alias RealtimeWeb.Channels.Payloads.Presence
10 |
11 | embedded_schema do
12 | embeds_one :config, Config
13 | field :access_token, :string
14 | field :user_token, :string
15 | end
16 |
17 | def changeset(join, attrs) do
18 | join
19 | |> cast(attrs, [:access_token, :user_token], message: &error_message/2)
20 | |> cast_embed(:config, invalid_message: "unable to parse, expected a map")
21 | end
22 |
23 | @spec validate(map()) :: {:ok, %__MODULE__{}} | {:error, :invalid_join_payload, map()}
24 | def validate(params) do
25 | case changeset(%__MODULE__{}, params) do
26 | %Ecto.Changeset{valid?: true} = changeset ->
27 | {:ok, Ecto.Changeset.apply_changes(changeset)}
28 |
29 | %Ecto.Changeset{valid?: false} = changeset ->
30 | errors = Ecto.Changeset.traverse_errors(changeset, &elem(&1, 0))
31 | {:error, :invalid_join_payload, errors}
32 | end
33 | end
34 |
35 | def presence_enabled?(%__MODULE__{config: %Config{presence: %Presence{enabled: enabled}}}), do: enabled
36 | def presence_enabled?(_), do: true
37 |
38 | def presence_key(%__MODULE__{config: %Config{presence: %Presence{key: ""}}}), do: UUID.uuid1()
39 | def presence_key(%__MODULE__{config: %Config{presence: %Presence{key: key}}}), do: key
40 | def presence_key(_), do: UUID.uuid1()
41 |
42 | def ack_broadcast?(%__MODULE__{config: %Config{broadcast: %Broadcast{ack: ack}}}), do: ack
43 | def ack_broadcast?(_), do: false
44 |
45 | def self_broadcast?(%__MODULE__{config: %Config{broadcast: %Broadcast{self: self}}}), do: self
46 | def self_broadcast?(_), do: false
47 |
48 | def private?(%__MODULE__{config: %Config{private: private}}), do: private
49 | def private?(_), do: false
50 |
51 | def error_message(_field, meta) do
52 | type = Keyword.get(meta, :type)
53 |
54 | if type,
55 | do: "unable to parse, expected #{type}",
56 | else: "unable to parse"
57 | end
58 | end
59 |
--------------------------------------------------------------------------------