jonnifyechomq
EchoMQ / E2 · Core / The three runtimes / The Elixir architecture

E2.04.1 · The three runtimes · dive 1

The Elixir architecture

The reference runtime: library-only, host-supervised, one BEAM process per job, and one timer per worker.

EchoMQ-Elixir (echo/apps/echomq v1.3.0) ships no Application and no supervision tree of its own — the host supervises the Worker and the RedisConnection. Above the wire it is all L3/L4: available_slots = concurrency - map_size(active_jobs), one Task.async process per running job, one Process.send_after lock-renewal timer per worker, a NimblePool connection, and EVALSHA-first dispatch. Move the concurrency on the right to read the worker's slot math.

available_slots · set the worker concurrency

slots = concurrency; busy = active_jobs; free = available_slots

Library-only, host-supervised

EchoMQ-Elixir ships no Application and no supervision tree of its own. The host application supervises the runtime's processes as children. The EchoMQ.Worker moduledoc shows the host wiring:

# in the HOST application's supervision tree
children = [
  {EchoMQ.RedisConnection, name: :redis, url: "redis://localhost:6379"},
  {EchoMQ.Worker,
    name: :my_worker, queue: "my_queue", connection: :redis,
    processor: &MyApp.Jobs.process/1, concurrency: 10}
]

There is no EchoMQ.Application to start; the worker and the connection pool are children the host owns. The in-tree EchoMQ.RedisConnection (use Supervisor) is the connection-pool internal — not a queue supervision tree. Pick a process below to read who owns it.

Who supervises what · pick a process

the HOST application supervises Worker + RedisConnection the EchoMQ library no Application; Worker owns job Tasks

EchoMQ-Elixir is a library, not an app. It owns no supervision tree; the host supervises the worker and the connection pool, and the worker owns each job's Task.

One BEAM process per job

EchoMQ.Worker is a GenServer with @default_concurrency 1. On each fetch cycle it computes its spare capacity directly, then fetches that many jobs:

# worker.ex — the slot math, then a process per fetched job
available_slots = state.concurrency - map_size(state.active_jobs)

if available_slots > 0 do
  fetch_and_process_jobs(state, available_slots)
end

For every available slot it fetches a job and runs the processor in its own process via Task.async — one BEAM process per running job:

task =
  Task.async(fn ->
    run_processor(processor_fn, job, worker_pid, telemetry_mod, telemetry_metadata)
  end)
active_jobs = Map.put(state.active_jobs, job.id, {job, task.ref})

A crash in one job's process does not take down the worker or its siblings — the OTP isolation Elixir buys for free. Node, by contrast, runs concurrency on a single event loop, and Go runs goroutines; each runtime's concurrency model is its own L3.

Concurrency is the slot count; available_slots is the count minus the active jobs; each fetched job runs in its own Task.async process. The wire underneath is the same in every runtime.

One timer per worker

EchoMQ.LockManager keeps every held lock fresh with one Process.send_after(:extend_locks) timer per worker — not one per job. When the worker picks a job up it calls LockManager.track_job(manager, job_id, token); when the timer fires, handle_info(:extend_locks, state) scans the tracked jobs and batch-renews the due ones in a single Scripts.extend_locks/5 call over extendLocks-1.lua (one stalled key, all ids and tokens packed into ARGV):

# lock_manager.ex — one timer, one batched script call
def handle_info(:extend_locks, state) do
  # scan tracked_jobs, select those past the half-window threshold,
  # then renew them all at once:
  Scripts.extend_locks(state.connection, state.keys, job_ids, tokens, state.lock_duration)
  Process.send_after(self(), :extend_locks, interval)   # re-arm the one timer
end

The renewal cost is flat — one timer, one round-trip — no matter how many jobs the worker holds. The full threshold-scan mechanics are in E2.03.1 · one timer, not N; the architectural point here is that lock renewal is one process, one timer, in the Elixir runtime.

concurrency
default 1 — the worker's slot count (@default_concurrency).
per-job process
one Task.async BEAM process per running job.
lock renewal
one Process.send_after timer per worker, batched through extendLocks-1.lua.

A pooled connection and EVALSHA dispatch

EchoMQ.RedisConnection is a NimblePool-backed pool (use Supervisor) exposing command/3 and pipeline/3. Every script runs through EchoMQ.Scripts.execute_raw/4, which dispatches EVALSHA first and falls back to EVAL on a NOSCRIPT error:

# scripts.ex — EVALSHA, then reload + EVAL on NOSCRIPT
def execute_raw(conn, script, keys, args) do
  # num_keys = length(keys); encoded_args = pack(args)
  case RedisConnection.command(conn, ["EVALSHA", sha, num_keys | keys ++ encoded_args]) do
    {:error, %Redix.Error{message: "NOSCRIPT" <> _}} -> # reload, then EVAL
    result -> result
  end
end

EVALSHA sends the script's SHA, not its body, so a cached script costs one short round-trip. The same scripts run in Go (which caches via redis.NewScript) and in Node — the dispatch strategy above them is each runtime's own L3.

A NimblePool connection and EVALSHA-first dispatch are the Elixir runtime's L3 — the scripts they run are L2, the same in every runtime.

The Golden Rule — the architecture is L3/L4

Every choice on this page — library-only, host-supervised, one BEAM process per job, one renewal timer per worker, a NimblePool connection, EVALSHA dispatch — is L3/L4, the Elixir runtime's own. The emq: keyspace it writes (L1) and the scripts it runs (moveToActive-11.lua, extendLocks-1.lua — L2) are frozen and shared. Go and Node drive the same wire their own way.

the protocol (immutable L1/L2)

The emq: keyspace and the field names (L1); moveToActive-11.lua and extendLocks-1.lua (L2) — the same in every runtime.

its Elixir runtime (variable L3/L4)

A host-supervised, library-only set of OTP processes — one BEAM process per job via Task.async, one LockManager timer per worker, a NimblePool connection, EVALSHA dispatch. Go and Node drive the same scripts their own way.

The Elixir runtime is the reference: it owns no supervision tree, runs one BEAM process per job, renews every lock with one timer, and dispatches every script via EVALSHA. All of that is L3/L4 — the wire underneath is the shared protocol.

Next: the Go port speaks the same wire, but it is partial. E2.04.2 · the Go gaps names what differs honestly.

The 2.0 fork — EchoMQ leaves the BullMQ wire

The architecture above drives the v1 line (frozen at 1.3.0) — the emq: keyspace and the BullMQ scripts. emq.1 ships EchoMQ 2.0, and this same OTP shape drives the v2 wire: the worker still fetches via one atomic script and the LockManager still renews with one timer, but the scripts now declare every key in KEYS[] and write the emq:{q}:… keyspace the core hashtags transparently — so the reference runtime is the first to speak v2. The host-supervised, library-only design does not change; only the wire it drives.

References

The worker fetch loop, the lock-renewal protocol, and the cached-script dispatch the reference runtime drives.

Sources

Related in this course