DrinkMeAlice. Credits
(drink_me_alice v0.1.0)
Copy Markdown
The 32-credit RPC admission bound — a fixed slot table whose rows also hold the bounded request payload.
Design (post-review): the request is stored in the slot row, not
carried through the owner mailbox. The owner drains :enqueued rows
on a fixed poll — there is no caller→owner message at all, so the
mailbox bound does not depend on caller timing (a delayed doorbell
sender can inject a stale message at an arbitrary later time; a
counter edge cannot bound that — polling can).
reserve/2: one atomicselect_replaceCAS claims a free slot —{n, :free, class, 0, 0, 0, nil}→{n, :reserved, class, pid, epoch, 0, nil}.epochis a unique reservation identity; every later transition matches on{n, epoch}so no release can be replayed or land on a recycled slot.enqueue/3: ONEselect_replaceCAS —reserved → enqueued— that matches{n, :reserved, _, self(), epoch, _, nil}and installs the payload via{:const, payload}in the same write. Payload installation and state publication are atomic: areservedrow can never hold a payload, so a holder that dies afterreserveleaves a sweepablereserved/nil row and nothing more. A stale token cannot match — the epoch pin fails on any recycled slot, so no write can stomp another slot lifetime.take/3: owner CASenqueued → processingreturning the payload.finish/2: owner CASprocessing → free. The slot's credit lifetime covers the entire queued-and-processing window — including caller death or caller timeout — because freeing is the owner's act of consuming the row, not the caller's.release/2: caller-side free, used only when the reservation was provably never enqueued (owner unresolvable beforeenqueue).sweep/1: frees:reservedrows whose holder pid is dead (died between reserve and enqueue — no payload exists to orphan).:enqueued/:processingrows are never swept — they always carry a real payload owned by the live owner; if the owner dies the whole table dies with it.
Slot keys are flat integers (1..26 general, 27..32 internal) because
ets:select_replace requires a literal non-compound key in the match
head — {n, …} keeps every transition a single-key CAS.
Slot bound: exactly 32 reserved requests, total.
Dead-table handling: an ArgumentError from ETS is typed as
:no_table/empty/zero only when the table is actually gone
(:ets.info → :undefined). On a live table the error is re-raised —
a malformed match spec is a programming defect, not a dead table, and
must crash loudly in tests rather than masquerade as :no_table.
Summary
Functions
The declared split for evidence/tests.
Publish the request payload and flip reserved → enqueued in ONE
select_replace CAS. :ok | {:error, :lost | :no_table}.
List currently :enqueued slots, oldest enqueue first
({n, epoch, marked_ms, payload}). Read-only view for the owner's
eligibility scan; consumption is still the CAS in take/3.
Owner-side free once the request is consumed (replied or refused).
Matched on {n, epoch} — frees :processing (normal) and, defensively,
:enqueued rows so a slot listed twice across a poll boundary still
drains to :free exactly once.
Create the slot table (all slots :free). Owned by the caller.
Caller-side free of a reservation that was never enqueued — used when the owner could not be resolved. Epoch- and holder-pinned; at most once.
Atomically claim a free slot of class for the calling pid.
{:ok, {n, epoch}} | {:error, :exhausted | :no_table}.
Current per-class usage and queue-state counts — for health/evidence.
Reclaim :reserved slots whose holder died between reserve and
enqueue — the only state without an installed payload. :enqueued and
:processing rows are left to the owner, which always consumes them.
Returns the number reclaimed.
Atomically claim one enqueued slot for processing —
enqueued → processing in one CAS, returning the stored payload.
{:ok, payload} | :none.
Types
Functions
@spec bounds() :: map()
The declared split for evidence/tests.
Publish the request payload and flip reserved → enqueued in ONE
select_replace CAS. :ok | {:error, :lost | :no_table}.
The match requires {n, :reserved, _, self(), epoch, _, nil} — the
payload rides in the spec as {:const, payload}, so installation and
publication are a single atomic write. There is no interim
reserved-with-payload state: a holder that dies before the CAS leaves
an ordinary sweepable reserved/nil row; a stale token on a recycled
slot fails the epoch match and cannot overwrite anything.
@spec enqueued(:ets.tid()) :: [ {non_neg_integer(), non_neg_integer(), integer(), payload()} ]
List currently :enqueued slots, oldest enqueue first
({n, epoch, marked_ms, payload}). Read-only view for the owner's
eligibility scan; consumption is still the CAS in take/3.
Owner-side free once the request is consumed (replied or refused).
Matched on {n, epoch} — frees :processing (normal) and, defensively,
:enqueued rows so a slot listed twice across a poll boundary still
drains to :free exactly once.
@spec new() :: :ets.tid()
Create the slot table (all slots :free). Owned by the caller.
Caller-side free of a reservation that was never enqueued — used when the owner could not be resolved. Epoch- and holder-pinned; at most once.
Atomically claim a free slot of class for the calling pid.
{:ok, {n, epoch}} | {:error, :exhausted | :no_table}.
Current per-class usage and queue-state counts — for health/evidence.
@spec sweep(:ets.tid()) :: non_neg_integer()
Reclaim :reserved slots whose holder died between reserve and
enqueue — the only state without an installed payload. :enqueued and
:processing rows are left to the owner, which always consumes them.
Returns the number reclaimed.
@spec take(:ets.tid(), non_neg_integer(), non_neg_integer()) :: {:ok, payload()} | :none
Atomically claim one enqueued slot for processing —
enqueued → processing in one CAS, returning the stored payload.
{:ok, payload} | :none.