A Linda tuple space for the BEAM. Processes coordinate by writing tuples into a shared store and reading them by pattern rather than by address — so a producer never learns who consumed its work, a consumer never learns who produced it, and neither has to be alive at the same moment as the other.
# somewhere
Tuplex.out({:job, 17, "resize thumbnails"})
# somewhere else, possibly later, possibly on a process that did not exist yet
{:ok, {:job, id, task}} = Tuplex.in({:job, :_, :_})This is not a job queue. If you already know who does the work and when, reach for Oban, Broadway, Phoenix.PubSub or a plain GenServer instead — there's a table below. Tuplex is for coordination that is genuinely anonymous, and used as a general-purpose queue it is a worse queue.
The idea in one paragraph
receive is already a guarded command over your mailbox: you write patterns, and the
runtime hands you the first message that matches one. It is the most pleasant coordination
primitive on the BEAM, and it is locked to a single process — to use it, someone has to know
your pid.
A tuple space is that same guarded command over a store that everybody can reach. The pattern is still the whole interface, but the mailbox is shared, so the sender no longer needs to name the receiver. That one change is what lets consumers select work by what they are able to do rather than by being told what to do.
Consumers that select their own work
This is the case nothing else on the BEAM does well, so it goes first.
Suppose you are dispatching container moves to cranes. Cranes differ: reach, maximum weight, whether they handle refrigerated units. With a queue you need a dispatcher that knows the whole fleet, and every new crane capability is a change to the dispatcher.
With a tuple space, the dispatcher does not exist. Each crane asks for work it can actually perform:
# a small crane, reefer-capable
{:ok, move} = Tuplex.in({:move, :_, :light, :reefer})
# a heavy crane that does not do refrigerated units
{:ok, move} = Tuplex.in({:move, :_, :heavy, :dry})and work is posted without any idea of who will take it:
Tuplex.out({:move, "MSCU4823701", :heavy, :dry})The match is the routing. Adding a crane with a new capability is a new template, not a change to shared dispatch logic — nobody has to be told the fleet changed, because nobody knew the fleet in the first place.
The same shape covers plugin dispatch, heterogeneous worker pools, capability negotiation, and any case where "who can do this?" is a better question than "who should do this?".
The classics
A semaphore. Permits are just tuples; taking one is in, returning one is out.
for _ <- 1..3, do: Tuplex.out({:db_permit})
# a worker
{:ok, _} = Tuplex.in({:db_permit}) # blocks until a permit is free
query()
Tuplex.out({:db_permit}) # hand it backIdentical tuples do not collapse — three outs mean three permits, and in removes exactly
one of them.
A barrier. Each worker announces arrival; the coordinator waits for all of them.
# each worker
Tuplex.out({:arrived, worker_id})
# the coordinator
for _ <- 1..n, do: {:ok, _} = Tuplex.in({:arrived, :_})
IO.puts("everyone is here")A shared counter, safely, without a GenServer: take the value, put back the successor.
Because in is destructive and serialised, no two processes can hold it at once.
{:ok, {:count, n}} = Tuplex.in({:count, :_})
Tuplex.out({:count, n + 1})A latch that everyone reads. rd is non-destructive, so a single tuple can unblock any
number of waiters at once.
# many processes
{:ok, {:config, settings}} = Tuplex.rd({:config, :_})
# published once
Tuplex.out({:config, %{mode: :fast}})Exactly once, even when consumers crash
Every prior Linda has the same hole: a consumer that takes a tuple and then dies has destroyed work with no record that it ever existed. Tuplex closes it with leases.
{:ok, job, handle} = Tuplex.in({:job, :_}, lease: {:monitor, :ack})
process(job)
Tuplex.ack(handle)The tuple is not removed when it is taken — it stays in the space, marked as held. It is
discarded only on ack/1. If the holder dies first, for any reason at all, the tuple goes
back and the next consumer gets it.
There is a second form, lease: :monitor, that binds the lease to the calling process's
lifetime rather than to an acknowledgement: a normal exit discards, anything else requeues.
It fits a Task per unit of work and nothing longer-lived, since a worker taking many tuples
that way accumulates a live lease for each. Reach for {:monitor, :ack} unless you know you
want the other.
A requeued tuple goes to the back of the queue, deliberately: at the front, a tuple that crashes whoever takes it would be handed straight to the next taker in a tight loop.
:shutdown requeues along with every other abnormal exit. A supervisor stopping a worker
part-way through is orderly, but the work still did not happen.
When not to use this
Tuplex is for coordination that is genuinely anonymous. If you already know who does the work and when, something else is a better fit:
| If you need | Use | Why not Tuplex |
|---|---|---|
| Durable, retried background jobs | Oban | Tuplex is in memory. A node restart is an empty space. |
| Backpressured data pipelines | Broadway | Consumers pull when free; there is no demand signalling upstream. |
| Topic fan-out to known subscribers | Phoenix.PubSub | If subscribers are known and every one gets a copy, that is a broadcast. |
| To call a specific process and get an answer | GenServer | A request addressed to one server is not anonymous coordination. |
Tuplex earns its place where the coordination is shape-driven and the participants do not know each other. Used as a general-purpose queue it is a worse queue.
Installation
def deps do
[{:tuplex, "~> 0.1.0"}]
endRequires Elixir ~> 1.19 and OTP 28.
Calling in
Call it qualified: Tuplex.in({:job, :_}). take/2 is an alias if the qualified form
bothers your tooling.
import Tuplex does not work, and the reason is worth knowing: the clash is not with another
Tuplex function but with Kernel.in/2 — the x in list operator — which is a macro
auto-imported into every module.import
- ↩
You can import it, by un-importing the Kernel macro first (
import Kernel, except: [in: 2]and thenimport Tuplex). It is not recommended. Four characters saved is a poor trade againstx in listsilently breaking everywhere else in that module.Observability
Every operation emits
:telemetry.inandrdare spans, so their duration is how long a consumer waited — the number that answers whether consumers are starved or producers are behind. Each shard also reports its depth, waiter count and oldest waiter age on a timer.See
Tuplex.Telemetryfor the full vocabulary, and read its note ontagcardinality before mapping any of it onto metric labels.Status
v0.1. The API is complete and covered by 266 tests including six properties — among them a stateful model of the serial API and a property asserting that every tuple written is consumed exactly once however many consumers are killed mid-work.
Out of scope for v0.1, deliberately: distribution across nodes, persistence, and the higher-level blackboard abstractions the
tuplex_blackboardname is reserved for.License
Apache-2.0.