Current section
Files
Jump to
Current section
Files
lib/phoenix/socket_client/message_processor.ex
defmodule Phoenix.SocketClient.MessageProcessor do
@moduledoc """
High-performance asynchronous message processor for Phoenix Socket Client.
This module provides efficient, non-blocking JSON encoding/decoding
operations with message batching, backpressure, and memory management
for high-throughput scenarios.
## Features
- Synchronous mode (default) for direct JSON encoding/decoding
- Asynchronous mode with message batching for high-throughput scenarios
- Bounded queues with backpressure (async mode)
- Comprehensive monitoring and telemetry
## Sync Mode (Default)
By default, `:sync_mode` is `true`. In this mode, `encode/4` and `decode/4`
call `JSON.encode!/1` and `JSON.decode!/1` directly without going through
the worker pool, batching, or binary pooling. This is the recommended mode
for most use cases since JSON encoding/decoding takes microseconds.
## Async Mode
Set `:sync_mode` to `false` to enable the async batch processing pipeline
with worker pools, message batching, and binary pooling. This may be useful
for extremely high-throughput scenarios.
## Usage
The processor is started automatically by the Socket supervisor and
handles all message serialization/deserialization operations transparently.
"""
use GenServer
require Logger
alias Phoenix.SocketClient.Message
alias Phoenix.SocketClient.Telemetry
@default_batch_size 20
@default_batch_interval 10
@default_max_queue_size 1000
@default_concurrency 8
defstruct [
:serializer,
:json_library,
:registry_name,
:binary_pool_pid,
sync_mode: true,
encode_queue: :queue.new(),
decode_queue: :queue.new(),
encode_timer: nil,
decode_timer: nil,
batch_size: @default_batch_size,
batch_interval: @default_batch_interval,
max_queue_size: @default_max_queue_size,
worker_sup: nil,
pending_replies: %{}
]
@doc """
Starts the message processor with the given options.
## Options
* `:serializer` - Message serializer module (required)
* `:json_library` - JSON library module (defaults to `JSON`)
* `:registry_name` - Registry name for channel lookup
* `:sync_mode` - When true (default), encode/decode synchronously without worker pool
* `:batch_size` - Maximum messages per batch (default: 20, async mode only)
* `:batch_interval` - Batch timeout in ms (default: 10, async mode only)
* `:max_queue_size` - Maximum queue size before backpressure (default: 1000, async mode only)
* `:concurrency` - Number of worker processes (default: 8, async mode only)
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: message_name(opts[:registry_name]))
end
@doc """
Encodes a message.
In sync mode (default), encodes directly and sends the result to the caller.
In async mode, enqueues for batch processing by the worker pool.
Returns `:ok` on success. The encoded result is sent as
`{:encode_result, ref, {:ok, encoded}}` to the caller process.
"""
@spec encode(pid(), Message.t(), pid(), reference()) :: :ok | {:error, term()}
def encode(processor, message, caller \\ self(), ref \\ make_ref()) do
GenServer.call(processor, {:enqueue, :encode, message, caller, ref}, 1000)
end
@doc """
Decodes a message.
In sync mode (default), decodes directly and sends the result to the caller.
In async mode, enqueues for batch processing by the worker pool.
Returns `:ok` on success. The decoded result is sent as
`{:decode_result, ref, {:ok, decoded}}` to the caller process.
"""
@spec decode(pid(), String.t(), pid(), reference()) :: :ok | {:error, term()}
def decode(processor, raw_message, caller \\ self(), ref \\ make_ref()) do
GenServer.call(processor, {:enqueue, :decode, raw_message, caller, ref}, 1000)
end
@doc """
Gets current processing statistics.
"""
@spec stats(pid()) :: map()
def stats(processor) do
GenServer.call(processor, :stats, 1000)
end
@impl true
def init(opts) do
Process.flag(:trap_exit, true)
opts = if Keyword.keyword?(opts), do: opts, else: Enum.into(opts, [])
serializer = Keyword.fetch!(opts, :serializer)
json_library = Keyword.get(opts, :json_library, JSON)
registry_name = Keyword.get(opts, :registry_name)
sync_mode = Keyword.get(opts, :sync_mode, true)
state =
if sync_mode do
# Sync mode: no worker pool, no binary pool, no route cache
%__MODULE__{
serializer: serializer,
json_library: json_library,
registry_name: registry_name,
sync_mode: true
}
else
# Async mode: start the full batch processing pipeline
concurrency = Keyword.get(opts, :concurrency, @default_concurrency)
binary_pool_opts =
[
registry_name: registry_name,
pool_size: Keyword.get(opts, :binary_pool_size),
cleanup_interval: Keyword.get(opts, :binary_cleanup_interval),
max_age: Keyword.get(opts, :binary_max_age)
]
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
{:ok, binary_pool_pid} = Phoenix.SocketClient.BinaryPool.start_link(binary_pool_opts)
{:ok, worker_sup} =
start_worker_supervisor(concurrency, serializer, json_library, binary_pool_pid)
%__MODULE__{
serializer: serializer,
json_library: json_library,
registry_name: registry_name,
binary_pool_pid: binary_pool_pid,
sync_mode: false,
worker_sup: worker_sup,
batch_size: Keyword.get(opts, :batch_size, @default_batch_size),
batch_interval: Keyword.get(opts, :batch_interval, @default_batch_interval),
max_queue_size: Keyword.get(opts, :max_queue_size, @default_max_queue_size)
}
end
Telemetry.execute([:phoenix_socket_client, :message_processor, :started], %{}, %{
registry_name: registry_name,
sync_mode: sync_mode
})
{:ok, state}
end
@impl true
def handle_call({:enqueue, operation, data, caller, ref}, _from, %{sync_mode: true} = state) do
# Sync mode: encode/decode directly and send result to caller
result =
case operation do
:encode ->
try do
encoded = Message.encode!(state.serializer, data, state.json_library)
{:ok, encoded}
catch
kind, reason -> {:error, {kind, reason}}
end
:decode ->
try do
decoded = Message.decode!(state.serializer, data, state.json_library)
{:ok, decoded}
catch
kind, reason -> {:error, {kind, reason}}
end
end
result_tag = if operation == :encode, do: :encode_result, else: :decode_result
send(caller, {result_tag, ref, result})
{:reply, :ok, state}
end
def handle_call({:enqueue, operation, data, caller, ref}, _from, state) do
# Async mode: enqueue for batch processing
queue_size = :queue.len(state.encode_queue) + :queue.len(state.decode_queue)
if queue_size >= state.max_queue_size do
Telemetry.execute(
[:phoenix_socket_client, :message_processor, :backpressure],
%{queue_size: queue_size},
%{
operation: operation
}
)
{:reply, {:error, :queue_full}, state}
else
item = {operation, data, caller, ref}
new_state =
case operation do
:encode ->
%{state | encode_queue: :queue.in(item, state.encode_queue)}
:decode ->
%{state | decode_queue: :queue.in(item, state.decode_queue)}
end
# Start batch timer if not already running
new_state = maybe_start_batch_timer(new_state, operation)
# Process immediately if batch is full
new_state = maybe_process_batch(new_state, operation)
# Store pending reply tracking
new_state = %{
new_state
| pending_replies: Map.put(new_state.pending_replies, ref, {caller, operation})
}
Telemetry.execute(
[:phoenix_socket_client, :message_processor, :queued],
%{
queue_size: :queue.len(new_state.encode_queue) + :queue.len(new_state.decode_queue),
operation: operation
},
%{}
)
{:reply, :ok, new_state}
end
end
def handle_call(:stats, _from, %{sync_mode: true} = state) do
stats = %{
sync_mode: true,
encode_queue_size: 0,
decode_queue_size: 0,
pending_replies: 0
}
{:reply, stats, state}
end
def handle_call(:stats, _from, state) do
stats = %{
sync_mode: false,
encode_queue_size: :queue.len(state.encode_queue),
decode_queue_size: :queue.len(state.decode_queue),
pending_replies: map_size(state.pending_replies),
batch_size: state.batch_size,
batch_interval: state.batch_interval,
max_queue_size: state.max_queue_size
}
{:reply, stats, state}
end
@impl true
def handle_info(:process_encode_batch, state) do
{batch, new_encode_queue} = take_batch(state.encode_queue, state.batch_size)
new_state = %{state | encode_queue: new_encode_queue, encode_timer: nil}
if length(batch) > 0 do
process_batch_async(:encode, batch, new_state)
end
{:noreply, new_state}
end
def handle_info(:process_decode_batch, state) do
{batch, new_decode_queue} = take_batch(state.decode_queue, state.batch_size)
new_state = %{state | decode_queue: new_decode_queue, decode_timer: nil}
if length(batch) > 0 do
process_batch_async(:decode, batch, new_state)
end
{:noreply, new_state}
end
def handle_info({:worker_result, operation, results}, state) do
# Send results back to callers
Enum.each(results, fn {ref, result} ->
case Map.get(state.pending_replies, ref) do
{caller, op} when is_pid(caller) and op == operation ->
case operation do
:encode -> send(caller, {:encode_result, ref, result})
:decode -> send(caller, {:decode_result, ref, result})
end
_ ->
# Caller may have died or operation mismatch, clean up
:ok
end
end)
# Remove processed replies from tracking
refs = Enum.map(results, fn {ref, _} -> ref end)
new_pending_replies = Map.drop(state.pending_replies, refs)
new_state = %{state | pending_replies: new_pending_replies}
Telemetry.execute(
[:phoenix_socket_client, :message_processor, :batch_completed],
%{
operation: operation,
batch_size: length(results)
},
%{}
)
{:noreply, new_state}
end
def handle_info(_msg, state), do: {:noreply, state}
# Private helper functions
defp message_name(nil), do: __MODULE__
defp message_name(registry_name), do: {:via, Registry, {registry_name, :message_processor}}
# TODO: Refactor Task workers to GenServer workers (see GitHub issue #33).
#
# These Task workers run infinite receive loops, which is not idiomatic usage.
# Tasks are designed for one-shot, finite work. Using them for long-running
# message loops has the following limitations:
#
# - If a worker crashes, any in-flight messages in its mailbox are lost.
# - Task.Supervisor features (async_nolink, yield, etc.) are not leveraged.
# - The restart strategy restarts the Task with a fresh loop, but pending
# messages sent to the old process PID are discarded.
#
# The correct approach would be to use GenServer workers under the supervisor,
# each handling {:encode_batch, ...} and {:decode_batch, ...} via handle_info/2.
# This is a larger refactor and is deferred for now.
defp start_worker_supervisor(concurrency, serializer, json_library, binary_pool_pid) do
children =
for i <- 1..concurrency do
Supervisor.child_spec(
{Task, fn -> worker_loop(i, serializer, json_library, binary_pool_pid) end},
id: i
)
end
Supervisor.start_link(children, strategy: :one_for_one, max_restarts: 10, max_seconds: 60)
end
defp worker_loop(worker_id, serializer, json_library, binary_pool_pid) do
receive do
{:encode_batch, batch, from} ->
results = process_encode_batch_sync(batch, serializer, json_library, binary_pool_pid)
send(from, {:worker_result, :encode, results})
worker_loop(worker_id, serializer, json_library, binary_pool_pid)
{:decode_batch, batch, from} ->
results = process_decode_batch_sync(batch, serializer, json_library, binary_pool_pid)
send(from, {:worker_result, :decode, results})
worker_loop(worker_id, serializer, json_library, binary_pool_pid)
end
end
defp process_encode_batch_sync(batch, serializer, json_library, binary_pool_pid) do
Enum.map(batch, fn {_op, message, _caller, ref} ->
try do
# Encode the message
result = Message.encode!(serializer, message, json_library)
# Try to get from binary pool or store it
pooled_result = Phoenix.SocketClient.BinaryPool.get_or_pool(binary_pool_pid, result)
{ref, {:ok, pooled_result}}
catch
kind, reason ->
{ref, {:error, {kind, reason}}}
end
end)
end
defp process_decode_batch_sync(batch, serializer, json_library, _binary_pool_pid) do
Enum.map(batch, fn {_op, raw_message, _caller, ref} ->
try do
result = Message.decode!(serializer, raw_message, json_library)
{ref, {:ok, result}}
catch
kind, reason ->
{ref, {:error, {kind, reason}}}
end
end)
end
defp process_batch_async(operation, batch, state) do
# Find available worker
children = Supervisor.which_children(state.worker_sup)
case Enum.find(children, fn {_id, pid, _worker, _modules} ->
is_pid(pid) and Process.alive?(pid)
end) do
{_, pid, _worker, _modules} ->
send(pid, {:"#{operation}_batch", batch, self()})
state
nil ->
# No available workers, re-queue
case operation do
:encode ->
%{state | encode_queue: requeue_batch(batch, state.encode_queue)}
:decode ->
%{state | decode_queue: requeue_batch(batch, state.decode_queue)}
end
end
end
defp take_batch(queue, max_size) do
case :queue.out(queue) do
{{:value, item}, remaining_queue} ->
{batch, final_queue} = take_batch_helper([item], remaining_queue, max_size - 1)
{Enum.reverse(batch), final_queue}
{:empty, _queue} ->
{[], queue}
end
end
defp requeue_batch(batch, queue) do
batch
|> :queue.from_list()
|> :queue.join(queue)
end
defp take_batch_helper(batch, queue, 0), do: {batch, queue}
defp take_batch_helper(batch, queue, remaining) do
case :queue.out(queue) do
{{:value, item}, remaining_queue} ->
take_batch_helper([item | batch], remaining_queue, remaining - 1)
{:empty, _queue} ->
{batch, queue}
end
end
defp maybe_start_batch_timer(state, operation) do
case operation do
:encode ->
if state.encode_timer == nil and :queue.len(state.encode_queue) > 0 do
timer = Process.send_after(self(), :process_encode_batch, state.batch_interval)
%{state | encode_timer: timer}
else
state
end
:decode ->
if state.decode_timer == nil and :queue.len(state.decode_queue) > 0 do
timer = Process.send_after(self(), :process_decode_batch, state.batch_interval)
%{state | decode_timer: timer}
else
state
end
end
end
defp maybe_process_batch(state, operation) do
case operation do
:encode ->
if :queue.len(state.encode_queue) >= state.batch_size do
{batch, new_encode_queue} = take_batch(state.encode_queue, state.batch_size)
new_state = %{state | encode_queue: new_encode_queue}
process_batch_async(:encode, batch, new_state)
else
state
end
:decode ->
if :queue.len(state.decode_queue) >= state.batch_size do
{batch, new_decode_queue} = take_batch(state.decode_queue, state.batch_size)
new_state = %{state | decode_queue: new_decode_queue}
process_batch_async(:decode, batch, new_state)
else
state
end
end
end
end