Current section

Files

Jump to
phoenix_socket_client lib phoenix socket_client hibernation_manager.ex
Raw

lib/phoenix/socket_client/hibernation_manager.ex

defmodule Phoenix.SocketClient.HibernationManager do
@moduledoc """
Process hibernation manager for idle connections.
This module provides intelligent hibernation for long-lived processes
to reduce memory footprint when connections are idle. It monitors process
activity and automatically hibernates processes that have been idle
for a configurable period.
## Features
- Automatic detection of idle processes
- Configurable hibernation thresholds
- Memory-efficient hibernation strategy
- Activity monitoring and wake-up handling
- Process lifecycle management
## Usage
The hibernation manager is automatically started by the socket supervisor
and monitors all socket-related processes for inactivity.
## Performance Benefits
- Reduces memory usage for idle connections
- Lowers GC pressure from idle processes
- Maintains responsiveness while optimizing memory
- Provides configurable hibernation policies
"""
use GenServer
require Logger
# 5 minutes
@default_idle_timeout 300_000
# 1 minute
@default_check_interval 60_000
# 10KB minimum memory before hibernation
@default_memory_threshold 10_000
@typedoc """
Hibernation manager configuration options
"""
@type opts :: [
idle_timeout: non_neg_integer(),
check_interval: non_neg_integer(),
memory_threshold: non_neg_integer(),
registry_name: atom()
]
@typedoc """
Tracked process information
"""
@type process_info :: %{
pid: pid(),
last_activity: integer(),
memory: non_neg_integer(),
hibernated_count: non_neg_integer(),
name: atom() | {atom(), atom()}
}
@typedoc """
Hibernation manager state
"""
@type t :: %__MODULE__{
idle_timeout: non_neg_integer(),
check_interval: non_neg_integer(),
memory_threshold: non_neg_integer(),
registry_name: atom() | nil,
tracked_processes: %{pid() => process_info()},
check_timer: reference() | nil,
stats: %{
total_hibernations: non_neg_integer(),
total_memory_saved: non_neg_integer(),
current_tracked: non_neg_integer(),
current_hibernated: non_neg_integer()
}
}
defstruct [
:idle_timeout,
:check_interval,
:memory_threshold,
:registry_name,
tracked_processes: %{},
check_timer: nil,
stats: %{
total_hibernations: 0,
total_memory_saved: 0,
current_tracked: 0,
current_hibernated: 0
}
]
@doc """
Starts the hibernation manager.
## Options
* `:idle_timeout` - Time in milliseconds before considering a process idle (default: 300000)
* `:check_interval` - Interval for checking idle processes (default: 60000)
* `:memory_threshold` - Minimum memory usage before hibernation (default: 10000)
* `:registry_name` - Registry name for process discovery
"""
@spec start_link(opts()) :: GenServer.on_start()
def start_link(opts \\ []) do
opts = if Keyword.keyword?(opts), do: opts, else: Enum.into(opts, [])
registry_name = Keyword.get(opts, :registry_name)
name =
if registry_name, do: {:via, Registry, {registry_name, :hibernation_manager}}, else: nil
GenServer.start_link(__MODULE__, opts, name: name)
end
@doc """
Registers a process for hibernation monitoring.
The process will be tracked for activity and hibernated when idle.
"""
@spec register_process(pid(), pid(), atom() | {atom(), atom()}) :: :ok
def register_process(manager_pid, pid, name \\ nil) when is_pid(manager_pid) and is_pid(pid) do
GenServer.cast(manager_pid, {:register_process, pid, name})
end
@doc """
Unregisters a process from hibernation monitoring.
The process will no longer be tracked for hibernation.
"""
@spec unregister_process(pid(), pid()) :: :ok
def unregister_process(manager_pid, pid) when is_pid(manager_pid) and is_pid(pid) do
GenServer.cast(manager_pid, {:unregister_process, pid})
end
@doc """
Reports activity for a process.
This should be called when a process receives a message or performs work
to update its activity timestamp and prevent premature hibernation.
"""
@spec report_activity(pid(), pid()) :: :ok
def report_activity(manager_pid, pid) when is_pid(manager_pid) and is_pid(pid) do
GenServer.cast(manager_pid, {:report_activity, pid})
end
@doc """
Gets hibernation statistics for monitoring.
Returns statistics about hibernation effectiveness and current state.
"""
@spec stats(pid()) :: map()
def stats(manager_pid) when is_pid(manager_pid) do
GenServer.call(manager_pid, :stats, 5000)
end
@doc """
Forces hibernation of a specific process.
Useful for manual hibernation during maintenance or testing.
"""
@spec hibernate_process(pid(), pid()) :: :ok | {:error, term()}
def hibernate_process(manager_pid, pid) when is_pid(manager_pid) and is_pid(pid) do
GenServer.call(manager_pid, {:hibernate_process, pid}, 5000)
end
@doc """
Manually wakes up a hibernated process.
This is typically handled automatically when messages are received.
"""
@spec wake_up_process(pid(), pid()) :: :ok | {:error, term()}
def wake_up_process(manager_pid, pid) when is_pid(manager_pid) and is_pid(pid) do
GenServer.call(manager_pid, {:wake_up_process, pid}, 5000)
end
# GenServer callbacks
@impl true
def init(opts) do
idle_timeout = Keyword.get(opts, :idle_timeout, @default_idle_timeout)
check_interval = Keyword.get(opts, :check_interval, @default_check_interval)
memory_threshold = Keyword.get(opts, :memory_threshold, @default_memory_threshold)
registry_name = Keyword.get(opts, :registry_name)
# Start periodic check timer
{:ok, timer} = :timer.send_interval(check_interval, :check_idle_processes)
state = %__MODULE__{
idle_timeout: idle_timeout,
check_interval: check_interval,
memory_threshold: memory_threshold,
registry_name: registry_name,
check_timer: timer
}
Phoenix.SocketClient.Telemetry.optimization(:hibernation_manager_started, %{
idle_timeout: idle_timeout,
check_interval: check_interval
})
{:ok, state}
end
@impl true
def handle_cast({:register_process, pid, name}, state) do
if Process.alive?(pid) do
{:memory, memory} = :erlang.process_info(pid, :memory)
current_time = System.monotonic_time(:millisecond)
process_info = %{
pid: pid,
last_activity: current_time,
memory: memory,
hibernated_count: 0,
name: name || pid
}
new_tracked = Map.put(state.tracked_processes, pid, process_info)
current_count = map_size(new_tracked)
new_state = %{
state
| tracked_processes: new_tracked,
stats: %{state.stats | current_tracked: current_count}
}
# Monitor the process for termination
Process.monitor(pid)
Phoenix.SocketClient.Telemetry.optimization(:hibernation_process_registered, %{
process_name: name || pid,
process_pid: pid,
memory_words: memory
})
{:noreply, new_state}
else
Phoenix.SocketClient.Telemetry.error(%{
component: :hibernation_manager,
event: :dead_process_registration_attempted,
process_pid: pid
})
{:noreply, state}
end
end
@impl true
def handle_cast({:unregister_process, pid}, state) do
case Map.get(state.tracked_processes, pid) do
nil ->
{:noreply, state}
_process_info ->
new_tracked = Map.delete(state.tracked_processes, pid)
current_count = map_size(new_tracked)
new_state = %{
state
| tracked_processes: new_tracked,
stats: %{state.stats | current_tracked: current_count}
}
Phoenix.SocketClient.Telemetry.optimization(:hibernation_process_unregistered, %{
process_pid: pid
})
{:noreply, new_state}
end
end
@impl true
def handle_cast({:report_activity, pid}, state) do
case Map.get(state.tracked_processes, pid) do
nil ->
{:noreply, state}
process_info ->
current_time = System.monotonic_time(:millisecond)
updated_info = %{process_info | last_activity: current_time}
new_tracked = Map.put(state.tracked_processes, pid, updated_info)
new_state = %{state | tracked_processes: new_tracked}
{:noreply, new_state}
end
end
@impl true
def handle_call(:stats, _from, state) do
stats =
Map.merge(state.stats, %{
idle_timeout: state.idle_timeout,
check_interval: state.check_interval,
memory_threshold: state.memory_threshold,
oldest_idle_time: find_oldest_idle_time(state.tracked_processes, state.idle_timeout)
})
{:reply, stats, state}
end
@impl true
def handle_call({:hibernate_process, pid}, _from, state) do
case Map.get(state.tracked_processes, pid) do
nil ->
{:reply, {:error, :not_tracked}, state}
process_info ->
case attempt_hibernation(pid, process_info, state.memory_threshold) do
:ok ->
updated_info = %{process_info | hibernated_count: process_info.hibernated_count + 1}
new_tracked = Map.put(state.tracked_processes, pid, updated_info)
hibernated_count = count_hibernated(new_tracked)
new_state = %{
state
| tracked_processes: new_tracked,
stats: %{state.stats | current_hibernated: hibernated_count}
}
{:reply, :ok, new_state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
end
@impl true
def handle_call({:wake_up_process, pid}, _from, state) do
case Map.get(state.tracked_processes, pid) do
nil ->
{:reply, {:error, :not_tracked}, state}
process_info ->
current_time = System.monotonic_time(:millisecond)
updated_info = %{process_info | last_activity: current_time}
new_tracked = Map.put(state.tracked_processes, pid, updated_info)
hibernated_count = count_hibernated(new_tracked)
new_state = %{
state
| tracked_processes: new_tracked,
stats: %{state.stats | current_hibernated: hibernated_count}
}
{:reply, :ok, new_state}
end
end
@impl true
def handle_info(:check_idle_processes, state) do
current_time = System.monotonic_time(:millisecond)
idle_timeout = state.idle_timeout
# Find processes that should be hibernated
{to_hibernate, _remaining} =
Enum.split_with(state.tracked_processes, fn {_pid, process_info} ->
current_time - process_info.last_activity > idle_timeout and
process_info.memory >= state.memory_threshold
end)
# Hibernate idle processes
{hibernated_count, memory_saved} =
Enum.reduce(to_hibernate, {0, 0}, fn {pid, process_info}, {count, memory} ->
case attempt_hibernation(pid, process_info, state.memory_threshold) do
:ok ->
Phoenix.SocketClient.Telemetry.optimization(:process_hibernated, %{
process_name: process_info.name,
process_pid: process_info.pid,
memory_words: process_info.memory,
hibernated_count: process_info.hibernated_count + 1
})
{count + 1, memory + process_info.memory}
{:error, _reason} ->
{count, memory}
end
end)
# Update statistics
if hibernated_count > 0 do
new_stats = %{
state.stats
| total_hibernations: state.stats.total_hibernations + hibernated_count,
total_memory_saved: state.stats.total_memory_saved + memory_saved,
current_hibernated: state.stats.current_hibernated + hibernated_count
}
Phoenix.SocketClient.Telemetry.optimization(:hibernation_batch_completed, %{
hibernated_count: hibernated_count,
memory_saved_words: memory_saved,
tracked_processes: map_size(state.tracked_processes)
})
{:noreply, %{state | stats: new_stats}}
else
{:noreply, state}
end
end
@impl true
def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
# Process died, remove from tracking
case Map.get(state.tracked_processes, pid) do
nil ->
{:noreply, state}
_process_info ->
new_tracked = Map.delete(state.tracked_processes, pid)
current_count = map_size(new_tracked)
new_state = %{
state
| tracked_processes: new_tracked,
stats: %{state.stats | current_tracked: current_count}
}
Phoenix.SocketClient.Telemetry.optimization(:hibernation_dead_process_removed, %{
process_pid: pid
})
{:noreply, new_state}
end
end
@impl true
def terminate(_reason, state) do
if state.check_timer do
:timer.cancel(state.check_timer)
end
Phoenix.SocketClient.Telemetry.optimization(:hibernation_manager_terminating, %{
tracked_processes: map_size(state.tracked_processes),
total_hibernations: state.stats.total_hibernations,
total_memory_saved: state.stats.total_memory_saved
})
:ok
end
# Private helper functions
defp attempt_hibernation(pid, process_info, memory_threshold) do
if process_info.memory >= memory_threshold do
try do
# Send hibernation request
send(pid, :hibernate_request)
# Check if process is still alive and responsive
case :erlang.process_info(pid, :message_queue_len) do
{:message_queue_len, _} -> :ok
_undefined -> {:error, :process_not_responsive}
end
catch
:exit, reason -> {:error, reason}
end
else
{:error, :memory_too_low}
end
end
defp count_hibernated(tracked_processes) do
Enum.count(tracked_processes, fn {_pid, process_info} ->
try do
# Check if process is currently hibernated by examining its state
case :erlang.process_info(process_info.pid, :status) do
{:status, :hibernating} -> true
_ -> false
end
rescue
# Process might have died
ArgumentError -> false
end
end)
end
defp find_oldest_idle_time(tracked_processes, idle_timeout) do
current_time = System.monotonic_time(:millisecond)
case Enum.min_by(
tracked_processes,
fn {_pid, process_info} ->
current_time - process_info.last_activity
end,
fn -> nil end
) do
{_pid, process_info} ->
idle_time = current_time - process_info.last_activity
if idle_time > idle_timeout, do: idle_time, else: 0
nil ->
0
end
end
end