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.
Movement I · Core (as-built) ← redis-patterns R3
available_slots · set the worker concurrency
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
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.asyncBEAM process per running job. - lock renewal
- one
Process.send_aftertimer per worker, batched throughextendLocks-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 emq: keyspace and the field names (L1);
moveToActive-11.lua and extendLocks-1.lua
(L2) — the same in every runtime.
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
- BullMQ — Documentation — the worker fetch loop, the lock-renewal protocol, and the scripts this runtime drives.
- Redis — EVALSHA — the cached-script dispatch
execute_raw/4uses, EVALSHA-first with an EVAL fallback. - Redis — Documentation — the command reference for the scripts the worker runs.
Related in this course
- E2.04 · The three runtimes & interop — the module hub.
- E2.04.2 · The Go gaps — the Go port's honest status, contrasted with this reference.
- E2.03.1 · One timer, not N — the one-timer LockManager in full.
- E2.02 · Jobs, queues & workers — the worker fetch loop and the job model.
- redis-patterns R3 · Reliable queues — the pattern this runtime applies.