diff --git a/lib/realtime/api/feature_flag.ex b/lib/realtime/api/feature_flag.ex new file mode 100644 index 000000000..b8a6d836d --- /dev/null +++ b/lib/realtime/api/feature_flag.ex @@ -0,0 +1,29 @@ +defmodule Realtime.Api.FeatureFlag do + @moduledoc """ + Ecto schema for a global feature flag. + + Flags have a name (unique) and a boolean enabled state. Per-tenant overrides + are stored separately on the `Realtime.Api.Tenant` schema as a JSONB map, + not as associations on this record. + """ + + use Ecto.Schema + import Ecto.Changeset + + @type t :: %__MODULE__{} + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "feature_flags" do + field :name, :string + field :enabled, :boolean, default: false + timestamps() + end + + def changeset(flag, attrs) do + flag + |> cast(attrs, [:name, :enabled]) + |> validate_required([:name]) + |> unique_constraint(:name) + end +end diff --git a/lib/realtime/api/tenant.ex b/lib/realtime/api/tenant.ex index c0abeb512..03755a1c5 100644 --- a/lib/realtime/api/tenant.ex +++ b/lib/realtime/api/tenant.ex @@ -33,6 +33,7 @@ defmodule Realtime.Api.Tenant do field(:max_client_presence_events_per_window, :integer) field(:client_presence_window_ms, :integer) field(:presence_enabled, :boolean, default: false) + field(:feature_flags, :map, default: %{}) has_many(:extensions, Realtime.Api.Extensions, foreign_key: :tenant_external_id, @@ -82,7 +83,8 @@ defmodule Realtime.Api.Tenant do :broadcast_adapter, :max_client_presence_events_per_window, :client_presence_window_ms, - :presence_enabled + :presence_enabled, + :feature_flags ]) |> validate_required([:external_id]) |> check_constraint(:jwt_secret, diff --git a/lib/realtime/application.ex b/lib/realtime/application.ex index 9ef0c4e9b..cf36f6af7 100644 --- a/lib/realtime/application.ex +++ b/lib/realtime/application.ex @@ -121,6 +121,7 @@ defmodule Realtime.Application do id: Realtime.LogThrottle ), Realtime.Tenants.Cache, + Realtime.FeatureFlags.Cache, Realtime.RateCounter.DynamicSupervisor, Realtime.Latency, {Registry, keys: :duplicate, name: Realtime.Registry}, diff --git a/lib/realtime/feature_flags.ex b/lib/realtime/feature_flags.ex new file mode 100644 index 000000000..7796e2d07 --- /dev/null +++ b/lib/realtime/feature_flags.ex @@ -0,0 +1,73 @@ +defmodule Realtime.FeatureFlags do + @moduledoc """ + Manages feature flags with optional per-tenant overrides. + + Each flag has a global enabled/disabled state. Tenants can override that state + via a JSONB map stored on the tenant record. + + Use `enabled?/1` to check the global flag value only. + Use `enabled?/2` when the flag supports per-tenant overrides. Resolution order: + 1. Tenant-specific override (if present) + 2. Global flag value + 3. `false` when the flag does not exist + """ + + import Ecto.Query + alias Realtime.Api + alias Realtime.FeatureFlags.Cache + alias Realtime.Repo + alias Realtime.Api.FeatureFlag + alias Realtime.Tenants.Cache, as: TenantsCache + + @spec list_flags() :: [FeatureFlag.t()] + def list_flags, do: Repo.all(from f in FeatureFlag, order_by: [asc: f.name]) + + @spec get_flag(String.t()) :: FeatureFlag.t() | nil + def get_flag(name) when is_binary(name), do: Repo.get_by(FeatureFlag, name: name) + + @spec upsert_flag(map()) :: {:ok, FeatureFlag.t()} | {:error, Ecto.Changeset.t()} + def upsert_flag(attrs) do + %FeatureFlag{} + |> FeatureFlag.changeset(attrs) + |> Repo.insert(on_conflict: {:replace, [:enabled, :updated_at]}, conflict_target: :name, returning: true) + end + + @spec delete_flag(FeatureFlag.t()) :: {:ok, FeatureFlag.t()} | {:error, Ecto.Changeset.t()} + def delete_flag(%FeatureFlag{} = flag), do: Repo.delete(flag) + + @spec set_tenant_flag(String.t(), String.t(), boolean()) :: + {:ok, Realtime.Api.Tenant.t()} | {:error, :not_found | Ecto.Changeset.t()} + def set_tenant_flag(flag_name, tenant_id, enabled) + when is_binary(flag_name) and is_binary(tenant_id) and is_boolean(enabled) do + case Api.get_tenant_by_external_id(tenant_id, use_replica?: false) do + nil -> + {:error, :not_found} + + tenant -> + updated_flags = Map.put(tenant.feature_flags, flag_name, enabled) + Api.update_tenant_by_external_id(tenant_id, %{feature_flags: updated_flags}) + end + end + + @spec enabled?(String.t()) :: boolean() + def enabled?(flag_name) when is_binary(flag_name) do + case Cache.get_flag(flag_name) do + nil -> false + %FeatureFlag{enabled: enabled} -> enabled + end + end + + @spec enabled?(String.t(), String.t()) :: boolean() + def enabled?(flag_name, tenant_id) when is_binary(flag_name) and is_binary(tenant_id) do + case Cache.get_flag(flag_name) do + nil -> + false + + %FeatureFlag{enabled: global_enabled} -> + case TenantsCache.get_tenant_by_external_id(tenant_id) do + nil -> global_enabled + %{feature_flags: flags} -> Map.get(flags, flag_name, global_enabled) + end + end + end +end diff --git a/lib/realtime/feature_flags/cache.ex b/lib/realtime/feature_flags/cache.ex new file mode 100644 index 000000000..1e4807fe0 --- /dev/null +++ b/lib/realtime/feature_flags/cache.ex @@ -0,0 +1,60 @@ +defmodule Realtime.FeatureFlags.Cache do + @moduledoc """ + In-process Cachex cache for `Realtime.Api.FeatureFlag` records. + + Cache misses fall through to the database automatically via `Cachex.fetch/3`. + Nil results (flag not found) are intentionally not cached so that newly + created flags become visible without requiring an explicit invalidation. + + Use `global_update_cache/1` after mutations to push the updated struct to all + cluster nodes. Use `global_invalidate_cache/1` after deletes. + """ + + require Cachex.Spec + alias Realtime.Api.FeatureFlag + alias Realtime.GenRpc + alias Realtime.FeatureFlags + + def child_spec(_) do + tenant_cache_expiration = Application.get_env(:realtime, :tenant_cache_expiration) + + %{ + id: __MODULE__, + start: {Cachex, :start_link, [__MODULE__, [expiration: Cachex.Spec.expiration(default: tenant_cache_expiration)]]} + } + end + + @spec get_flag(String.t()) :: FeatureFlag.t() | nil + def get_flag(name) do + with {_, value} <- + Cachex.fetch(__MODULE__, cache_key(name), fn _key -> + with %FeatureFlag{} = flag <- FeatureFlags.get_flag(name), + do: {:commit, flag}, + else: (_ -> {:ignore, nil}) + end) do + value + end + end + + @spec update_cache(FeatureFlag.t()) :: {:ok, boolean()} | {:error, boolean()} + def update_cache(%FeatureFlag{} = flag) do + Cachex.put(__MODULE__, cache_key(flag.name), flag) + end + + @spec invalidate_cache(String.t()) :: {:ok, boolean()} | {:error, boolean()} + def invalidate_cache(name) when is_binary(name) do + Cachex.del(__MODULE__, cache_key(name)) + end + + @spec global_update_cache(FeatureFlag.t()) :: :ok + def global_update_cache(%FeatureFlag{} = flag) do + GenRpc.multicast(__MODULE__, :update_cache, [flag]) + end + + @spec global_invalidate_cache(FeatureFlag.t()) :: :ok + def global_invalidate_cache(%FeatureFlag{} = flag) do + GenRpc.multicast(__MODULE__, :invalidate_cache, [flag.name]) + end + + defp cache_key(name), do: {:get_flag, name} +end diff --git a/lib/realtime_web/dashboard/feature_flags.ex b/lib/realtime_web/dashboard/feature_flags.ex new file mode 100644 index 000000000..1d5e51a70 --- /dev/null +++ b/lib/realtime_web/dashboard/feature_flags.ex @@ -0,0 +1,224 @@ +defmodule RealtimeWeb.Dashboard.FeatureFlags do + @moduledoc """ + Phoenix LiveDashboard page for managing feature flags. + + Provides a UI to create, toggle, and delete global feature flags, and to + search for a tenant and override the flag value for that specific tenant. + """ + + use Phoenix.LiveDashboard.PageBuilder + + alias Realtime.FeatureFlags + alias Realtime.FeatureFlags.Cache + alias Realtime.Tenants.Cache, as: TenantsCache + + @impl true + def menu_link(_, _), do: {:ok, "Feature Flags"} + + @impl true + def mount(_params, _, socket) do + {:ok, reset_tenant_state(assign(socket, flags: FeatureFlags.list_flags()))} + end + + @impl true + def handle_event("toggle", %{"id" => id}, socket) do + flag = Enum.find(socket.assigns.flags, &(&1.id == id)) + + case FeatureFlags.upsert_flag(%{name: flag.name, enabled: !flag.enabled}) do + {:ok, updated} -> + Cache.global_update_cache(updated) + flags = Enum.map(socket.assigns.flags, fn f -> if f.id == id, do: updated, else: f end) + {:noreply, assign(socket, flags: flags)} + + {:error, _} -> + {:noreply, socket} + end + end + + @impl true + def handle_event("create", %{"name" => name}, socket) when name != "" do + case FeatureFlags.upsert_flag(%{name: String.trim(name), enabled: false}) do + {:ok, flag} -> + Cache.global_update_cache(flag) + flags = Enum.sort_by([flag | socket.assigns.flags], & &1.name) + {:noreply, assign(socket, flags: flags)} + + {:error, _} -> + {:noreply, socket} + end + end + + @impl true + def handle_event("create", _params, socket), do: {:noreply, socket} + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + flag = Enum.find(socket.assigns.flags, &(&1.id == id)) + + case FeatureFlags.delete_flag(flag) do + {:ok, _} -> + Cache.global_invalidate_cache(flag) + {:noreply, assign(socket, flags: Enum.reject(socket.assigns.flags, &(&1.id == id)))} + + {:error, _} -> + {:noreply, socket} + end + end + + @impl true + def handle_event("open_tenant_manager", %{"id" => id}, socket) do + {:noreply, reset_tenant_state(socket, managing_id: id)} + end + + @impl true + def handle_event("close_tenant_manager", _params, socket) do + {:noreply, reset_tenant_state(socket)} + end + + @impl true + def handle_event("search_tenant", %{"tenant_id" => tenant_id}, socket) do + case TenantsCache.get_tenant_by_external_id(String.trim(tenant_id)) do + nil -> + {:noreply, assign(socket, found_tenant: nil, tenant_error: "Tenant not found", tenant_search: tenant_id)} + + tenant -> + {:noreply, assign(socket, found_tenant: tenant, tenant_error: nil, tenant_search: tenant_id)} + end + end + + @impl true + def handle_event("set_tenant_flag", %{"flag_name" => flag_name, "enabled" => enabled}, socket) do + tenant = socket.assigns.found_tenant + + case FeatureFlags.set_tenant_flag(flag_name, tenant.external_id, enabled == "true") do + {:ok, updated_tenant} -> + {:noreply, assign(socket, found_tenant: updated_tenant)} + + {:error, _} -> + {:noreply, assign(socket, tenant_error: "Failed to update tenant flag")} + end + end + + defp reset_tenant_state(socket, extra \\ []) do + assign(socket, [managing_id: nil, tenant_search: "", found_tenant: nil, tenant_error: nil] ++ extra) + end + + @impl true + def render(assigns) do + ~H""" +
| Name | +Status | +Actions | +
|---|---|---|
| <%= flag.name %> | +
+
+
+
+ <%= if flag.enabled, do: "Enabled", else: "Disabled" %>
+
+
+ |
+
+
+
+
+
+ |
+
|
+
+ Tenant flag: <%= flag.name %>
+
+
+
+
+
+ <%= if @tenant_error do %>
+ <%= @tenant_error %> + <% end %> + + <%= if @found_tenant do %> + <% flag_enabled = Map.get(@found_tenant.feature_flags, flag.name, flag.enabled) %> +
+
+ <% end %>
+ <%= @found_tenant.external_id %>
+ —
+
+
+
+ <%= if flag_enabled, do: "Enabled", else: "Disabled" %>
+
+
+ |
+ ||
| Name | +Status | +Actions | +
|---|---|---|
| <%= flag.name %> | +
+
+
+
+ <%= if flag.enabled, do: "Enabled", else: "Disabled" %>
+
+
+ |
+ + + | +