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 atomic select_replace CAS claims a free slot — {n, :free, class, 0, 0, 0, nil} → {n, :reserved, class, pid, epoch, 0, nil}. epoch is 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: ONE select_replace CAS — 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: a reserved row can never hold a payload, so a holder that dies after reserve leaves a sweepable reserved/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 CAS enqueued → processing returning the payload. finish/2: owner CAS processing → 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 before enqueue).
  • sweep/1: frees :reserved rows whose holder pid is dead (died between reserve and enqueue — no payload exists to orphan). :enqueued/:processing rows 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

class()

@type class() :: :general | :internal

payload()

@type payload() :: {reference(), map(), integer()}

token()

@type token() :: {non_neg_integer(), non_neg_integer()}

Functions

bounds()

@spec bounds() :: map()

The declared split for evidence/tests.

enqueue(tab, arg, payload)

@spec enqueue(:ets.tid(), token(), payload()) :: :ok | {:error, :lost | :no_table}

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.

enqueued(tab)

@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.

finish(tab, arg)

@spec finish(:ets.tid(), token()) :: :ok

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.

new()

@spec new() :: :ets.tid()

Create the slot table (all slots :free). Owned by the caller.

release(tab, arg)

@spec release(:ets.tid(), token()) :: :ok

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.

reserve(tab, class)

@spec reserve(:ets.tid(), class()) ::
  {:ok, token()} | {:error, :exhausted | :no_table}

Atomically claim a free slot of class for the calling pid. {:ok, {n, epoch}} | {:error, :exhausted | :no_table}.

snapshot(tab)

@spec snapshot(:ets.tid()) :: map()

Current per-class usage and queue-state counts — for health/evidence.

sweep(tab)

@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.

take(tab, n, epoch)

@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.