Raxol.Gateway.SessionRouter (RaxolGateway v0.2.0)

Copy Markdown View Source

Routes inbound events to per-chat sessions, one process per chat.

Generalizes the Telegram per-chat router: sessions are keyed by Raxol.Gateway.Route.key/1 (agent:main:{platform}:{chat_type}:{chat_id}), started lazily under a DynamicSupervisor, monitored for cleanup, and bounded by the same idle-timeout, per-key start cooldown, and max-session limits.

Options

  • :handler (required) -- {module, opts} the sessions run. The router refuses to start, with {:handler_not_available, module}, when the module cannot be loaded.
  • :sessions_sup (required) -- a DynamicSupervisor name for the sessions
  • :adapter -- {module, conn}; outbound is delivered via its send_message/3. Or pass :deliver, a (Route.t(), rendered -> any()).
  • :max_sessions (default 1000)
  • :idle_timeout ms (default 10 minutes)
  • :handler_init_timeout -- passed to each session; ms its handler's init/2 may take before the session is killed (default 30 seconds)
  • :cooldown_ms -- minimum ms between starts for one key (default 5000)
  • :authorize -- (Route.t() -> :allow | :deny), consulted before a session is started or an event delivered. Unset allows everything.

  • :name -- the router's registered name

Authorization

The router is the narrow point every session passes through, so the gate goes here rather than in each caller. :authorize is a function and not a Pairing server name because the router does not depend on how the decision is made -- Raxol.Gateway.Pairing is one implementation, and a deployment with its own identity system should not have to route around the gate to use it.

Wiring it means no caller can skip it: route/3 and start_session/2 are public, and a feed loop that calls them directly gets the same decision the documented path gets. Leaving it unset is the open posture, which is why Raxol.Console.Boot always sets it -- an open Console is open because its Pairing server allows those platforms, not because nothing asked.

The function runs inside the router's own call, so it should be cheap: every event pays for it, including events for sessions that already exist. A function that raises or exits is logged and DENIES rather than crashing the router -- a gate that cannot answer is not a reason to serve the chat.

Telemetry

Every event is [:raxol_gateway, :session, event], measured %{system_time: _}, with the session's :key in metadata.

  • :started -- a session PROCESS was spawned. Its handler has NOT initialized yet, and route/3 has already replied :ok.
  • :ready -- the handler initialized and the chat can be served. Emitted by Raxol.Gateway.Session; metadata adds :handler.
  • :init_timeout -- the handler never returned from init/2 and the session was killed. Emitted by Raxol.Gateway.Session; metadata adds :handler and :timeout.
  • :down -- a session died for any reason other than :normal or :shutdown. Metadata adds :reason, which is :noproc when the router found the session dead before its :DOWN arrived and so never saw why.
  • :stopped -- stop_session/2 was called. Metadata adds reason: :explicit.
  • :rejected -- no session was started. Metadata adds :reason, one of :max_sessions, :rate_limited or :unauthorized.

A :started with no :ready behind it is a handler that failed or hung at startup. Since handler init is deferred (see Raxol.Gateway.Session), that pair is the only signal of it -- route/3 cannot report it.

Summary

Functions

Returns a specification to start this module under a supervisor.

The session pid for a key, or nil.

Hand a conversation off to another platform: start a session on to_route reusing the source session's conversation_id (and :log), so a configured log resumes the same history. Returns the destination session pid.

Route an inbound event to the session for route, starting one if needed.

The number of active sessions.

Start (or fetch) the session for route.

Router stats: active sessions and cooldown-map size.

Stop the session for a key.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

get_session(server, key)

@spec get_session(GenServer.server(), String.t()) :: pid() | nil

The session pid for a key, or nil.

handle_manager_cast(msg, state)

Callback implementation for Raxol.Core.Behaviours.BaseManager.handle_manager_cast/2.

handoff(server, from_key, to_route)

@spec handoff(GenServer.server(), String.t(), Raxol.Gateway.Route.t()) ::
  {:ok, pid()} | {:error, term()}

Hand a conversation off to another platform: start a session on to_route reusing the source session's conversation_id (and :log), so a configured log resumes the same history. Returns the destination session pid.

The destination is started like any other session, so it passes the same authorization, :max_sessions and per-key cooldown gates; a refusal is {:error, :unauthorized | :max_sessions | :rate_limited} and leaves the source session as it was. A live destination session keeps its own conversation; a tracked one that is already dead is replaced, as in route/3.

route(server, route, event)

@spec route(GenServer.server(), Raxol.Gateway.Route.t(), term()) ::
  :ok | {:error, term()}

Route an inbound event to the session for route, starting one if needed.

:ok means the event was ACCEPTED for delivery, not that it was served. A session's handler initializes asynchronously (see Raxol.Gateway.Session), so a handler that fails or hangs at startup does so after this has replied; [:raxol_gateway, :session, :ready], :down and :init_timeout are what report that. An {:error, _} here is only ever a refusal to route -- :unauthorized, :max_sessions or :rate_limited.

A tracked session that is already dead when the event arrives (its :DOWN still queued behind this call) is dropped and replaced by a fresh session, under the same authorization, :max_sessions and per-key cooldown as any other start. Within the cooldown that is {:error, :rate_limited}, never an :ok for an event cast into a dead process. The event is cast, not called, so a session that dies after the liveness check and before reading its mailbox still loses it after :ok; :down telemetry reports that death.

session_count(server)

@spec session_count(GenServer.server()) :: non_neg_integer()

The number of active sessions.

start_link(init_opts \\ [])

@spec start_link(keyword()) :: GenServer.on_start()

start_session(server, route)

@spec start_session(GenServer.server(), Raxol.Gateway.Route.t()) ::
  {:ok, pid()} | {:error, term()}

Start (or fetch) the session for route.

stats(server)

@spec stats(GenServer.server()) :: %{
  sessions: non_neg_integer(),
  cooldown_entries: non_neg_integer()
}

Router stats: active sessions and cooldown-map size.

stop_session(server, key)

@spec stop_session(GenServer.server(), String.t()) :: :ok

Stop the session for a key.