Current section
Files
Jump to
Current section
Files
lib/snake_manager.ex
defmodule Venomous.SnakeManager do
@moduledoc """
Manager for brave 🐍 workers
This module manages the snake workers, ensuring that inactive workers are cleaned up periodically.
Main call `:get_ready_snake` retrieves/spawns a `Venomous.SnakeWorker` with :retrieved status.
Workers with status :ready and :retrieved are considered inactive and will be cleared up by main process loop running `:clean_inactive_workers` if they exceed their given TTL
Workers with :retrieved retrieved again until they are used.
## Configuration
The following configurations are retrieved from :venomous :snake_manager Application env:
- `erlport_encoder: %{module: atom(), func: atom(), args: list(any())}`: Optional :erlport encoder/decoder python function for converting types. The function can also provide any callbacks from :erlport documentation like .cast() message handler
- `snake_ttl_minutes: non_neg_integer()`: Time-to-live for a Snake in minutes. Default is 15 min.
- `perpetual_workers: non_neg_integer()`: Number of Snakes to keep alive perpetually. Default is 10.
- `cleaner_interval: non_neg_integer()`: Interval in milliseconds for cleaning up inactive Snakes. Default is 60_000 ms.
"""
use GenServer
require Logger
alias Venomous.SnakeSupervisor
alias Venomous.SnakeWorker
@default_ttl 15
@default_perpetual 10
@default_interval 60_000
def start_link(args) do
GenServer.start_link(__MODULE__, args, name: __MODULE__)
end
def init(state) do
Logger.info("Started Snake Manager")
{:ok, state, {:continue, :clean_inactive}}
end
def handle_continue(:clean_inactive, state) do
GenServer.cast(self(), :clean_inactive_workers)
{:noreply, state}
end
def handle_info(:clean_inactive_info, state) do
GenServer.cast(self(), :clean_inactive_workers)
{:noreply, state}
end
def handle_info({:reload, module}, state) do
reload_module = state.reload_module
serpent_opts = %Venomous.SnakeArgs{
module: reload_module,
func: :reload,
args: [module |> to_string()]
}
:ets.tab2list(state.table)
|> Enum.each(fn {pid, _pypid, _os_pid, _status, _date} ->
send(pid, {:reload, serpent_opts})
end)
{:noreply, state}
end
def handle_info({_ref, :ok}, state) do
{:noreply, state}
end
def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do
Logger.error("Snake Crashed ")
{:noreply, state}
end
def handle_info({:sacrifice_snake, pid}, state) do
try do
DynamicSupervisor.terminate_child(SnakeSupervisor, pid)
:ets.delete(state.table, pid)
catch
:exit, reason ->
Logger.error("Crashed at terminating #{inspect(reason)}")
:ets.delete(state.table, pid)
end
{:noreply, state}
end
def handle_cast(:clean_inactive_workers, state) do
cleaner_interval = state |> Map.get(:cleaner_interval_ms, @default_interval)
cleared = clean_inactive_workers(state)
unless cleared == 0, do: Logger.info("Cleared #{cleared} unused snakes.")
Process.send_after(self(), :clean_inactive_info, cleaner_interval)
{:noreply, state}
end
def handle_call(:clean_inactive_workers, _from, state) do
{:reply, clean_inactive_workers(state), state}
end
def handle_call(
{:molt_snake, status, %SnakeWorker{pid: pid, pypid: pypid, os_pid: os_pid}},
from,
state
) do
# Does Task even make a difference here? lol
Task.start(fn ->
now = DateTime.utc_now()
:ets.insert(state.table, {pid, pypid, os_pid, status, now})
GenServer.reply(from, :ok)
end)
{:noreply, state}
end
def handle_call(:list_snakes, _from, state) do
{:reply, :ets.tab2list(state.table), state}
end
def handle_call(:preload_snake, from, state) do
Task.start(fn ->
snake = deploy_new_snake(state.table, state.python_opts, :ready)
GenServer.reply(from, snake)
end)
{:noreply, state}
end
def handle_call(:get_ready_snake, from, state) do
available? = state.table |> :ets.match({:"$1", :"$2", :"$3", :ready, :_}) |> Enum.at(0)
case available? do
nil ->
Task.start(fn ->
snake = deploy_new_snake(state.table, state.python_opts)
GenServer.reply(from, snake)
end)
{:noreply, state}
[pid, pypid, os_pid] ->
:ets.insert(state.table, {pid, pypid, os_pid, :retrieved, DateTime.utc_now()})
{:reply, %SnakeWorker{pid: pid, pypid: pypid, os_pid: os_pid}, state}
end
end
def handle_call({:remove_snake, pid}, _from, state) do
:ets.delete(state.table, pid)
{:reply, :ok, state}
end
defp deploy_new_snake(table, python_opts, status \\ :retrieved)
defp deploy_new_snake({:ok, pid}, table, status) do
try do
worker = GenServer.call(pid, :get_pypid)
:ets.insert(
table,
{worker.pid, worker.pypid, worker.os_pid, status, DateTime.utc_now()}
)
worker
catch
:exit, reason ->
Logger.error("Crashed at deploying new snake: #{inspect(reason)}")
{:retrieve_error, reason}
end
end
defp deploy_new_snake({:error, message}, _table, _status) do
# Logger.error("Error while creating new snake: #{message}")
{:retrieve_error, message}
end
defp deploy_new_snake(table, python_opts, status) do
SnakeSupervisor.deploy_snake_worker(python_opts) |> deploy_new_snake(table, status)
end
defp clean_inactive_workers(state) do
ttl = state |> Map.get(:snake_ttl_minutes, @default_ttl)
perpetual_workers = state |> Map.get(:perpetual_workers, @default_perpetual)
clean_inactive_workers(state.table, perpetual_workers, ttl)
end
defp clean_inactive_workers({pid, _pypid, _os_pid, status, update_utc}, table, ttl) do
now = DateTime.utc_now()
max_ttl =
DateTime.add(
update_utc,
ttl,
:minute
)
active =
DateTime.compare(
now,
max_ttl
) != :gt
unless status not in [:ready, :retrieved] or active do
:ets.delete(table, pid)
kill_inactive_worker(pid)
end
active
end
defp clean_inactive_workers(table, perpetual_workers, ttl) do
{_perpetual_workers, rest} = table |> :ets.tab2list() |> Enum.split(perpetual_workers)
rest |> Enum.filter(&(clean_inactive_workers(&1, table, ttl) == false)) |> length()
end
defp kill_inactive_worker(pid) do
DynamicSupervisor.terminate_child(SnakeSupervisor, pid)
end
def get_snake_worker_status(table, pid) when is_pid(pid) do
with [{_pid, pypid, os_pid, status, update_utc} | _] <- :ets.lookup(table, pid) do
{pypid, os_pid, status, update_utc}
end
end
end