Packages
An in-memory caching library using tuples as the core data type, with support for structs, key/value pairs, queues, and stacks. Includes experimental distributed caching.
Current section
Files
Jump to
Current section
Files
lib/interface/queue.ex
defmodule SuperCache.Queue do
@moduledoc """
Named FIFO queues backed by SuperCache ETS partitions.
Any number of independent queues can coexist by using different
`queue_name` values. Any process in the VM can enqueue and dequeue from
the same queue by name.
Requires `SuperCache.start!/1` to be called first.
## Concurrency model
Queue mutations (enqueue, dequeue, drain) use a soft write-lock stored in
ETS. The lock is a single `{:queue, :updating, queue_name}` record that
is inserted before a structural mutation and deleted immediately after.
Concurrent callers that observe the lock entry spin via `:erlang.yield/0`
and retry. This keeps the implementation lock-free at the OTP level while
still serialising mutations to each queue.
## Storage layout
Each queue is represented by three kinds of records in the same partition:
| ETS key | Value | Purpose |
|-----------------------------------|----------|----------------------|
| `{:queue, :head, queue_name}` | integer | Index of front item |
| `{:queue, :tail, queue_name}` | integer | Index of back item |
| `{:queue, queue_name, index}` | any | The item itself |
Head and tail are both `0` when the queue is empty.
## Example
alias SuperCache.Queue
SuperCache.start!()
Queue.add("jobs", :compress)
Queue.add("jobs", :upload)
Queue.add("jobs", :notify)
Queue.count("jobs") # => 3
Queue.peak("jobs") # => :compress (non-destructive)
Queue.out("jobs") # => :compress
Queue.out("jobs") # => :upload
Queue.get_all("jobs") # => [:notify] (drains the queue)
Queue.count("jobs") # => 0
Queue.out("jobs") # => nil
Queue.out("jobs", :empty) # => :empty
## Cluster mode
For distributed deployments use `SuperCache.Distributed.Queue`. The API
is identical; mutations are routed to the primary node for the queue's
partition.
"""
alias SuperCache.{Storage, Partition}
require Logger
## API ────────────────────────────────────────────────────────────────────────
@doc """
Enqueue `value` into `queue_name`.
Creates the queue if it does not yet exist. Returns `true`.
## Example
Queue.add("tasks", %{id: 1, type: :email})
"""
@spec add(any, any) :: true
def add(queue_name, value) do
part = Partition.get_partition(queue_name)
queue_in(part, queue_name, value)
end
@doc """
Dequeue and return the front value.
Returns `default` (default `nil`) when the queue is empty or does not
exist.
## Example
Queue.add("q", :first)
Queue.add("q", :second)
Queue.out("q") # => :first
Queue.out("q") # => :second
Queue.out("q") # => nil
Queue.out("q", :empty) # => :empty
"""
@spec out(any, any) :: any
def out(queue_name, default \\ nil) do
part = Partition.get_partition(queue_name)
queue_out(part, queue_name, default)
end
@doc """
Return the front value **without removing it**.
Returns `default` (default `nil`) when the queue is empty or does not
exist.
## Example
Queue.add("q", :hello)
Queue.peak("q") # => :hello
Queue.count("q") # => 1 (unchanged)
"""
@spec peak(any, any) :: any
def peak(queue_name, default \\ nil) do
part = Partition.get_partition(queue_name)
queue_peak(part, queue_name, default)
end
@doc """
Return the number of items in `queue_name`.
Returns `0` for an empty or non-existent queue.
## Example
Queue.add("q", :a)
Queue.add("q", :b)
Queue.count("q") # => 2
Queue.out("q")
Queue.count("q") # => 1
"""
@spec count(any) :: non_neg_integer
def count(queue_name) do
part = Partition.get_partition(queue_name)
count_safe(part, queue_name, 50)
end
@doc """
Remove and return **all** items as a list (oldest first).
The queue is empty after this call. Returns `[]` for an empty or
non-existent queue.
## Example
Enum.each(1..5, &Queue.add("q", &1))
Queue.get_all("q") # => [1, 2, 3, 4, 5]
Queue.count("q") # => 0
"""
@spec get_all(any) :: list
def get_all(queue_name) do
part = Partition.get_partition(queue_name)
queue_drain(part, queue_name)
end
## Private — queue_in ────────────────────────────────────────────────────────
defp queue_in(partition, queue_name, value) do
case Storage.take({:queue, :tail, queue_name}, partition) do
[] ->
case Storage.get({:queue, :updating, queue_name}, partition) do
[] ->
queue_init(queue_name)
queue_in(partition, queue_name, value)
_ ->
:erlang.yield()
queue_in(partition, queue_name, value)
end
[{_, 0}] ->
lock(partition, queue_name)
Storage.delete({:queue, :head, queue_name}, partition)
Storage.put({{:queue, queue_name, 1}, value}, partition)
Storage.put({{:queue, :head, queue_name}, 1}, partition)
Storage.put({{:queue, :tail, queue_name}, 1}, partition)
unlock(partition, queue_name)
Logger.debug(fn -> "super_cache, queue #{inspect(queue_name)}, in: #{inspect(value)}" end)
true
[{_, counter}] ->
next = counter + 1
lock(partition, queue_name)
Storage.put({{:queue, queue_name, next}, value}, partition)
Storage.put({{:queue, :tail, queue_name}, next}, partition)
unlock(partition, queue_name)
Logger.debug(fn -> "super_cache, queue #{inspect(queue_name)}, in: #{inspect(value)}" end)
true
end
end
## Private — queue_out ───────────────────────────────────────────────────────
defp queue_out(partition, queue_name, default) do
# If no lock record exists yet the queue may not be initialised.
case Storage.get({:queue, :updating, queue_name}, partition) do
[_] ->
# Another process is mutating — yield and retry.
:erlang.yield()
queue_out(partition, queue_name, default)
[] ->
case Storage.get({:queue, :head, queue_name}, partition) do
[] ->
# Queue does not exist.
default
[{_, 0}] ->
# Queue is empty.
default
[{_, counter}] ->
# Acquire lock, then perform the dequeue entirely inside it.
lock(partition, queue_name)
value =
case Storage.take({:queue, queue_name, counter}, partition) do
[] ->
# Item missing (shouldn't happen under correct usage).
reset_queue(partition, queue_name)
default
[{_, v}] ->
next = counter + 1
case Storage.get({:queue, :tail, queue_name}, partition) do
[{_, tail}] when next > tail ->
reset_queue(partition, queue_name)
_ ->
# Delete the old head key, write the new one.
Storage.delete({:queue, :head, queue_name}, partition)
Storage.put({{:queue, :head, queue_name}, next}, partition)
end
v
end
unlock(partition, queue_name)
Logger.debug(fn -> "super_cache, queue #{inspect(queue_name)}, out: #{inspect(value)}" end)
value
end
end
end
## Private — queue_peak ──────────────────────────────────────────────────────
defp queue_peak(partition, queue_name, default) do
case Storage.get({:queue, :head, queue_name}, partition) do
[] ->
case Storage.get({:queue, :updating, queue_name}, partition) do
[] -> default
_ -> :erlang.yield(); queue_peak(partition, queue_name, default)
end
[{_, 0}] -> default
[{_, counter}] ->
case Storage.get({:queue, queue_name, counter}, partition) do
[] -> default
[{_, v}] -> v
end
end
end
## Private — queue_drain ─────────────────────────────────────────────────────
defp queue_drain(partition, queue_name) do
case Storage.get({:queue, :updating, queue_name}, partition) do
[_] ->
:erlang.yield()
queue_drain(partition, queue_name)
[] ->
case Storage.get({:queue, :head, queue_name}, partition) do
[] -> []
[{_, 0}] -> []
[{_, first}] ->
lock(partition, queue_name)
[{_, last}] = Storage.get({:queue, :tail, queue_name}, partition)
values =
Enum.reduce(first..last, [], fn i, acc ->
case Storage.take({:queue, queue_name, i}, partition) do
[] -> acc
[{_, v}] -> [v | acc]
end
end)
|> Enum.reverse()
reset_queue(partition, queue_name)
unlock(partition, queue_name)
Logger.debug(fn -> "super_cache, queue #{inspect(queue_name)}, drained #{length(values)} item(s)" end)
values
end
end
end
## Private — init / lock helpers ─────────────────────────────────────────────
defp queue_init(queue_name) do
Logger.debug("super_cache, queue, init: #{inspect(queue_name)}")
partition = Partition.get_partition(queue_name)
# insert_new acts as a CAS — only the first caller proceeds.
if Storage.insert_new({{:queue, :updating, queue_name}, true}, partition) do
Storage.put({{:queue, :head, queue_name}, 0}, partition)
Storage.put({{:queue, :tail, queue_name}, 0}, partition)
Storage.delete({:queue, :updating, queue_name}, partition)
end
end
defp lock(partition, queue_name),
do: Storage.put({{:queue, :updating, queue_name}, true}, partition)
defp unlock(partition, queue_name),
do: Storage.delete({:queue, :updating, queue_name}, partition)
defp reset_queue(partition, queue_name) do
Storage.put({{:queue, :head, queue_name}, 0}, partition)
Storage.put({{:queue, :tail, queue_name}, 0}, partition)
end
defp count_safe(_part, _queue_name, 0),
do: 0 # give up after max retries rather than looping forever
defp count_safe(part, queue_name, retries) do
# Refuse to read while a structural mutation is in progress.
case Storage.get({:queue, :updating, queue_name}, part) do
[_] ->
:erlang.yield()
count_safe(part, queue_name, retries - 1)
[] ->
tail = Storage.get({:queue, :tail, queue_name}, part)
head = Storage.get({:queue, :head, queue_name}, part)
case {head, tail} do
# Queue does not exist yet.
{[], []} -> 0
# Empty queue sentinel.
{[{_, 0}], _} -> 0
{_, [{_, 0}]} -> 0
# Torn read — a mutation landed between our two gets; retry.
{[], _} ->
:erlang.yield()
count_safe(part, queue_name, retries - 1)
# Clean read.
{[{_, h}], [{_, t}]} -> max(0, t - h + 1)
end
end
end
end