Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 89 additions & 6 deletions lib/phoenix_kit/cache/cache.ex
Original file line number Diff line number Diff line change
Expand Up @@ -323,12 +323,24 @@ defmodule PhoenixKit.Cache do
def init(opts) do
name = Keyword.fetch!(opts, :name)
warmer = Keyword.get(opts, :warmer)
critical_warmer = Keyword.get(opts, :critical_warmer)
sync_init = Keyword.get(opts, :sync_init, false)
ttl = Keyword.get(opts, :ttl)
max_size = Keyword.get(opts, :max_size)

table =
:ets.new(:"cache_#{name}", [:set, :protected, :named_table, {:read_concurrency, true}])

# Support legacy cache_settings table for backwards compatibility
if name == :settings do
try do
:ets.new(:cache_settings, [:set, :protected, :named_table, {:read_concurrency, true}])
rescue
# Table already exists
ArgumentError -> :ok
end
end

state = %__MODULE__{
name: name,
table: table,
Expand All @@ -337,13 +349,19 @@ defmodule PhoenixKit.Cache do
max_size: max_size
}

# Warm cache if warmer function is provided
if warmer do
send(self(), :warm_cache)
Logger.info(
"Started cache #{name} with table #{table}#{if sync_init, do: " (sync_init enabled)", else: ""}"
)

# Use handle_continue to warm cache after init returns
# This prevents blocking supervisor initialization
if sync_init and critical_warmer do
{:ok, state, {:continue, {:warm_critical, critical_warmer, warmer}}}
else
# Standard async warming
if warmer, do: send(self(), :warm_cache)
{:ok, state}
end

Logger.info("Started cache #{name} with table #{table}")
{:ok, state}
end

@impl GenServer
Expand Down Expand Up @@ -481,6 +499,41 @@ defmodule PhoenixKit.Cache do
{:noreply, state}
end

@impl GenServer
def handle_continue({:warm_critical, _critical_warmer, warmer}, %{name: name} = state) do
# Load ALL data synchronously in handle_continue when sync_init is enabled
# This runs after init returns, so supervisor can continue starting other processes
# We load all data instead of just critical to avoid race conditions

# Retry warming if data is empty (repo might not be ready yet)
case warm_with_retry(warmer, name, 3, 100) do
{:ok, data} when is_map(data) and map_size(data) > 0 ->
warm_critical_data(state, data)

Logger.info(
"Synchronously warmed cache #{name} with #{map_size(data)} entries (sync_init mode)"
)

{:ok, empty_data} when is_map(empty_data) ->
Logger.warning(
"Cache #{name} warmed but no data loaded - repository may not be ready yet. Will retry asynchronously."
)

# Schedule async retry
Process.send_after(self(), :warm_cache, 1000)

{:error, error} ->
Logger.error("Failed to synchronously warm cache #{name}: #{inspect(error)}")
# Schedule async retry
Process.send_after(self(), :warm_cache, 1000)

_ ->
Logger.warning("Warmer for cache #{name} returned invalid data")
end

{:noreply, state}
end

@impl GenServer
def handle_info(:warm_cache, state) do
handle_cast(:warm, state)
Expand All @@ -492,12 +545,42 @@ defmodule PhoenixKit.Cache do
PhoenixKit.Cache.Registry.via_tuple(name)
end

defp warm_critical_data(%{name: name, table: table, ttl: ttl}, data) do
Enum.each(data, fn {key, value} ->
expires_at = if ttl, do: System.monotonic_time(:millisecond) + ttl, else: nil
:ets.insert(table, {key, value, expires_at})

# Also write to legacy cache_settings table if this is settings cache
if name == :settings do
:ets.insert(:cache_settings, {key, value, expires_at})
end
end)
end

defp safe_warm(warmer) when is_function(warmer, 0) do
{:ok, warmer.()}
rescue
error -> {:error, error}
end

defp warm_with_retry(warmer, name, retries, delay) do
case safe_warm(warmer) do
{:ok, data} when is_map(data) and map_size(data) > 0 ->
{:ok, data}

{:ok, empty_data} when is_map(empty_data) and retries > 0 ->
Logger.debug(
"Cache #{name} warming returned empty data, retrying in #{delay}ms (#{retries} retries left)"
)

Process.sleep(delay)
warm_with_retry(warmer, name, retries - 1, delay * 2)

result ->
result
end
end

defp maybe_evict(%{max_size: nil} = state), do: state

defp maybe_evict(%{table: table, max_size: max_size} = state) do
Expand Down
4 changes: 2 additions & 2 deletions lib/phoenix_kit/config/ueber_auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ defmodule PhoenixKit.Config.UeberAuth do
"""
@spec get_all() :: Keyword.t()
def get_all do
Config.get_list(:ueberauth, [])
Application.get_env(:ueberauth, Ueberauth, [])
end

@doc """
Expand Down Expand Up @@ -156,7 +156,7 @@ defmodule PhoenixKit.Config.UeberAuth do
def set_all(options) when is_list(options) do
current_config = get_all()
new_config = Keyword.merge(current_config, options)
Config.set(:ueberauth, new_config)
Application.put_env(:ueberauth, Ueberauth, new_config)
:ok
end

Expand Down
70 changes: 68 additions & 2 deletions lib/phoenix_kit/settings/settings.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1328,6 +1328,67 @@ defmodule PhoenixKit.Settings do
%{}
end

@doc """
Warm cache with critical OAuth settings only.

Returns map of critical OAuth settings for synchronous cache warming.
This is used during startup to ensure OAuth configuration is available
immediately, preventing race conditions with OAuthConfigLoader.

Only loads OAuth-related settings that are required for provider configuration.
"""
def warm_critical_cache do
# Critical OAuth keys that must be loaded synchronously at startup
critical_keys = [
# Google OAuth
"oauth_google_client_id",
"oauth_google_client_secret",
# GitHub OAuth
"oauth_github_client_id",
"oauth_github_client_secret",
# Apple OAuth
"oauth_apple_client_id",
"oauth_apple_team_id",
"oauth_apple_key_id",
"oauth_apple_private_key_path",
# Facebook OAuth
"oauth_facebook_app_id",
"oauth_facebook_app_secret",
# OAuth general settings
"oauth_enabled"
]

# Check if repository is available
if repo_available?() do
settings =
Setting
|> where([s], s.key in ^critical_keys)
|> repo().all()

settings
|> Enum.map(fn setting ->
# Prioritize JSON value over string value for cache storage
value =
if setting.value_json do
setting.value_json
else
setting.value
end

{setting.key, value}
end)
|> Map.new()
else
# Repo not available - return empty map
# This should rarely happen as critical cache is loaded at startup
%{}
end
rescue
error ->
Logger.error("Failed to warm critical cache: #{inspect(error)}")
%{}
end

## Private Batch Query Functions

# Batch query multiple string settings from database in a single operation
Expand Down Expand Up @@ -1440,8 +1501,13 @@ defmodule PhoenixKit.Settings do
_ -> true
end

# Check if the repository is available and ready to accept queries
defp repo_available? do
@doc """
Check if the repository is available and ready to accept queries.

Returns true if the repo is configured and running, false otherwise.
Used to prevent errors during Mix tasks when repo might not be started.
"""
def repo_available? do
# First check if repo is configured
case PhoenixKit.Config.get(:repo, nil) do
nil ->
Expand Down
11 changes: 8 additions & 3 deletions lib/phoenix_kit/supervisor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,16 @@ defmodule PhoenixKit.Supervisor do
PhoenixKit.PubSub.Manager,
PhoenixKit.Admin.SimplePresence,
{PhoenixKit.Cache.Registry, []},
{PhoenixKit.Cache, name: :settings, warmer: &PhoenixKit.Settings.warm_cache_data/0},
# Settings cache with synchronous initialization
# Loads all settings in handle_continue (after init returns)
# This ensures OAuth configuration is available before OAuthConfigLoader starts
# while not blocking supervisor initialization
{PhoenixKit.Cache,
name: :settings, sync_init: true, warmer: &PhoenixKit.Settings.warm_cache_data/0},
# Rate limiter backend MUST be started before any authentication requests
PhoenixKit.Users.RateLimiter.Backend,
# OAuth config loader MUST be first to ensure configuration
# is available before any OAuth requests are processed
# OAuth config loader - now guaranteed to have critical settings in cache
# No longer needs retry logic as cache is pre-warmed with OAuth settings
PhoenixKit.Workers.OAuthConfigLoader,
PhoenixKit.Entities.Presence,
# Email tracking supervisor - handles SQS Worker for automatic bounce event processing
Expand Down
Loading
Loading