Current section

Files

Jump to
spqueue lib sp_queue.ex
Raw

lib/sp_queue.ex

defmodule SPQueue do
use GenServer
@moduledoc """
SPQueue is a persistent FIFO queue.
You add or `enqueue/2` items at the end of the queue.
You remove or `dequeue/2` items from the head of the queue.
This is a persistent queue: it stores its contents on files
in a directory `base_dir`/`name` so that it can recover after a
restart or failure, and can grow without using lots of memory.
Items should be maps than can be converted to `JSON`.
## Examples
iex> SPQueue.start(name: :my_queue)
iex> ~w(a b c) |> Enum.each(fn c -> SPQueue.enqueue(:my_queue, %{"what" => c}) end)
iex> SPQueue.count(:my_queue)
3
iex> SPQueue.dequeue(:my_queue)
%{"what" => "a"}
iex> SPQueue.head(:my_queue)
%{"what" => "b"}
iex> SPQueue.reset(:my_queue)
iex> SPQueue.stop(:my_queue)
## Implementation
The implementation uses a up to `number_of_segments` each of `segment_size`.
There is a limit, `number_of_segments` * `segment_size`, of items in the queue.
Only one or two segments are kept in memory, the `first_segment`
where dequeue is happening and possible enqueue when there is only
one segment. If there is more than one segment, `last_segment`
is where enqueue is happening. In the case of more than two segments,
the intervening ones are not kept in memory and are loaded as needed.
For each segment there are two files: an enqueue and a dequeue file,
with a segment_id in their name. Both are appended to only, like a log.
These files have .ndjson as extension, newline delimited JSON.
When segments are both fully enqueued and dequeued, their files are
cleaned up to reclaim disk space.
For each session, an enqueue and dequeue count are kept.
This count is also used as an internal id, along with a timestamp.
## Using internal IDs
The client API has a number of functions with a _r suffix that
return this meta information. One way this can be useful is to do
a head_r, try to process an item, and then dequeue it on the condition
that the id is still the same.
## Delegate receives :enqueued notifications
Optionally, a `delegate` can be specified, a process that will be sent
the `:enqueued` message after each enqueue operation. The delegate can
be a pid or an atom name of a registered process which is looked up each time.
"""
# state structure
@default_segment_size 10
@default_number_of_segments 5
defstruct name: "pq",
base_dir: File.cwd!(),
segment_size: @default_segment_size,
number_of_segments: @default_number_of_segments,
enqueue_count: 0,
dequeue_count: 0,
first_segment: :queue.new(),
last_segment: :queue.new(),
first_segment_id: 0,
last_segment_id: 0,
delegate: nil
# initialization
@doc """
Initialize an `SPQueue` process's `GenServer` state
The following options are provided:
- `name`: the name of the queue (an atom), also used as name for the genserver and the sub directory
- `base_dir`: the base path under which queue sub directories will be created
- `segment_size`: the maximum number of items in a segment
- `number_of_segments`: the maximum number of segments
- `delegate`: the process to send :enqueued when items are added
"""
@impl true
def init(opts) do
name = Keyword.get(opts, :name)
base_dir = Keyword.get(opts, :base_dir)
segment_size = Keyword.get(opts, :segment_size)
number_of_segments = Keyword.get(opts, :number_of_segments)
delegate = Keyword.get(opts, :delegate)
initial_state =
%__MODULE__{}
|> Map.update!(:name, fn default -> name || default end)
|> Map.update!(:base_dir, fn default -> base_dir || default end)
|> Map.update!(:segment_size, fn default -> segment_size || default end)
|> Map.update!(:number_of_segments, fn default -> number_of_segments || default end)
|> Map.update!(:delegate, fn default -> delegate || default end)
|> load_state_from_disk()
{:ok, initial_state}
end
@doc """
Start an `SPQueue` as a linked process
See `init/1` for the list of options
"""
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, opts)
end
@doc """
Start an `SPQueue` process without linking
See `init/1` for the list of options
"""
def start(opts) do
GenServer.start(__MODULE__, opts, opts)
end
@doc """
Stop an `SPQueue` process
"""
def stop(pq) do
GenServer.stop(pq)
end
# client API
@doc """
Enqueue `msg` on queue `pq`, i.e. add it at the end.
The queue is identified by a pid or a genserver name.
`Msg` should be a map that can be `JSON` encoded.
Returns `msg` on success and `nil` when the queue is full.
## Examples
iex> {:ok, q} = SPQueue.start([])
iex> SPQueue.enqueue(q, %{"test" => 42})
%{"test" => 42}
iex> SPQueue.head(q)
%{"test" => 42}
iex> SPQueue.reset(q)
iex> SPQueue.stop(q)
"""
def enqueue(pq, msg) when is_map(msg) do
case enqueue_r(pq, msg) do
{:ok, record} -> record["msg"]
{:error, :full} -> nil
end
end
@doc """
Enqueue msg on queue pq, i.e. add it at the end.
The queue is identified by a pid or a genserver name.
Msg should be a map that can be `JSON` encoded.
Returns `{:ok, %{"id" => id, "ts" => ts, "msg" => msg}}` on success.
Return `{:error, :full}` when the queue is full.
## Examples
iex> {:ok, q} = SPQueue.start([])
iex> {:ok, %{"id" => id, "ts" => _ts, "msg" => msg}} = SPQueue.enqueue_r(q, %{"test" => 42})
iex> id
0
iex> msg
%{"test" => 42}
iex> {:ok, %{"id" => ^id, "ts" => _ts, "msg" => ^msg}} = SPQueue.dequeue_r(q, id: id)
iex> SPQueue.reset(q)
iex> SPQueue.stop(q)
"""
def enqueue_r(pq, msg) when is_map(msg) do
GenServer.call(pq, {:enqueue, msg})
end
@doc """
Dequeue a msg from queue `pq`, i.e. remove it from the head.
The queue is identified by a pid or a genserver name.
Returns a msg `map` on success, `nil` when the queue is empty.
The boolean `ack:` option allows to make a difference between
successful message consumption or message rejection.
Optionally an `id:` can be specified for the expected internal id,
which can be obtained from `enqueue_r/2` or `head_r/1`.
If the `id:` does not match, `nil` is returned and no dequeue happens.
## Examples
iex> {:ok, q} = SPQueue.start([])
iex> SPQueue.enqueue(q, %{"test" => 42})
iex> SPQueue.dequeue(q)
%{"test" => 42}
iex> SPQueue.dequeue(q)
nil
iex> SPQueue.reset(q)
iex> SPQueue.stop(q)
"""
def dequeue(pq, opts \\ [ack: true]) do
case dequeue_r(pq, opts) do
{:ok, record} -> record["msg"]
{:error, :empty} -> nil
{:error, :mismatch} -> nil
end
end
@doc """
Dequeue a msg from queue pq, i.e. remove it from the head.
The queue is identified by a pid or a genserver name.
Returns `{:ok, %{"id" => id, "ts" => ts, "msg" => msg}}` on success,
Returns `{:error, :empty}` when the queue is empty.
The boolean `ack:` option allows to make a difference between
successful message consumption or message rejection.
Optionally an `id:` can be specified for the expected internal id,
which can be obtained from `enqueue_r/2` or `head_r/1`.
If the `id:` does not match, `{:error, :mismatch}` is returned and no dequeue happens.
## Examples
iex> {:ok, q} = SPQueue.start([])
iex> {:ok, %{"id" => id, "ts" => _ts, "msg" => msg}} = SPQueue.enqueue_r(q, %{"test" => 42})
iex> id
0
iex> msg
%{"test" => 42}
iex> {:ok, %{"id" => ^id, "ts" => _ts, "msg" => ^msg}} = SPQueue.dequeue_r(q, id: id)
iex> SPQueue.reset(q)
iex> SPQueue.stop(q)
"""
def dequeue_r(pq, opts \\ [ack: true]) do
GenServer.call(pq, {:dequeue, Keyword.merge([ack: true], opts)})
end
@doc """
Return the head of queue `pq`, the message that would be the result of dequeue,
without actually removing it.
Return `nil` if the queue is empty.
The queue is identified by a pid or a genserver name.
## Examples
iex> {:ok, q} = SPQueue.start([])
iex> SPQueue.enqueue(q, %{"test" => 42})
iex> SPQueue.head(q)
%{"test" => 42}
iex> SPQueue.dequeue(q)
iex> SPQueue.head(q)
nil
iex> SPQueue.reset(q)
iex> SPQueue.stop(q)
"""
def head(pq) do
case head_r(pq) do
{:ok, record} -> record["msg"]
{:error, :empty} -> nil
end
end
@doc """
Return the head of queue `pq`, the message that would be the result of `dequeue_r/2`,
without actually removing it.
The queue is identified by a pid or a genserver name.
Returns `{:ok, %{"id" => id, "ts" => ts, "msg" => msg}}` on success.
`id` is the internal identification that can be used in `dequeue_r/2`
to make sure the same message is removed that was read with `head_r/1`.
Return `{:error, :empty}` if the queue is empty.
## Examples
iex> {:ok, q} = SPQueue.start([])
iex> SPQueue.enqueue(q, %{"test" => 42})
iex> {:ok, %{"id" => id, "ts" => _ts, "msg" => msg}} = SPQueue.head_r(q)
iex> id
0
iex> msg
%{"test" => 42}
iex> {:ok, %{"id" => ^id, "ts" => _ts, "msg" => ^msg}} = SPQueue.dequeue_r(q, id: id)
iex> SPQueue.reset(q)
iex> SPQueue.stop(q)
"""
def head_r(pq) do
GenServer.call(pq, :head)
end
@doc """
Return whether queue `pq` is empty or not.
The queue is identified by a pid or a genserver name.
"""
def empty?(pq) do
count(pq) == 0
end
@doc """
Return the number of items or messages in queue `pq`.
The queue is identified by a pid or a genserver name.
"""
def count(pq) do
GenServer.call(pq, :count)
end
@doc """
Reset queue `pq` to an empty state, both in memory and on disk.
The queue is identified by a pid or a genserver name.
"""
def reset(pq) do
GenServer.call(pq, :reset)
end
@doc """
Return a map with information about queue `pq`.
This includes the value of all options described in `init/1`
and the following properties:
- `queue_base_dir`: the full path to the directory where the queue's files are stored
- `maximum_size`: the total maxium size of the queue
- `count`: the number of items/messages in the queue
The queue is identified by a pid or a genserver name.
"""
def info(pq) do
GenServer.call(pq, :info)
end
# server API
@impl true
def handle_call({:enqueue, msg}, {_sender, _call}, state) do
if full?(state) do
{:reply, {:error, :full}, state}
else
# this is the record that we write to the enqueue file
record = %{
"id" => state.enqueue_count,
"ts" => to_string(DateTime.utc_now()),
"msg" => msg
}
# make sure JSON encoding succeeds
json = JSON.encode_to_iodata!(record)
# now append to the enqueue log file
log_enqueue(state, json)
# update the in memory state
new_state =
case segments_count(state) do
1 ->
# there is only 1 segment, i.e. first and last are the same, only first is used
if state.enqueue_count + 1 == (state.first_segment_id + 1) * state.segment_size do
# first_segment will be full after this, make sure last_segment will be used next time
%{
state
| enqueue_count: state.enqueue_count + 1,
first_segment: :queue.in(record, state.first_segment),
last_segment: :queue.new(),
last_segment_id: state.last_segment_id + 1
}
else
# normal addition to first_segment
%{
state
| enqueue_count: state.enqueue_count + 1,
first_segment: :queue.in(record, state.first_segment)
}
end
_ ->
if state.enqueue_count + 1 == (state.last_segment_id + 1) * state.segment_size do
# last_segment will be full after this, arrange for a new last_segment to be used next time
# there is no need to actually add to the in-memory segment as we start a new one anyway
%{
state
| enqueue_count: state.enqueue_count + 1,
last_segment: :queue.new(),
last_segment_id: state.last_segment_id + 1
}
else
# normal addition to last_segment
%{
state
| enqueue_count: state.enqueue_count + 1,
last_segment: :queue.in(record, state.last_segment)
}
end
end
{:reply, {:ok, record}, notify_delegate(new_state)}
end
end
@impl true
def handle_call({:dequeue, opts}, {_sender, _call}, state) do
cond do
queue_empty?(state) ->
{:reply, {:error, :empty}, state}
Keyword.has_key?(opts, :id) &&
Keyword.get(opts, :id) != :queue.head(state.first_segment)["id"] ->
{:reply, {:error, :mismatch}, state}
true ->
# get a hold of the head without making modifications
head = :queue.head(state.first_segment)
# this is the record that we write to the dequeue file
record = %{
"id" => head["id"],
"ts" => to_string(DateTime.utc_now()),
"ack" => Keyword.get(opts, :ack)
}
# make sure JSON encoding succeeds
json = JSON.encode_to_iodata!(record)
# now append to the dequeue log file
log_dequeue(state, json)
# update the in memory state
{{:value, head}, rest} = :queue.out(state.first_segment)
new_state =
if :queue.is_empty(rest) && segments_count(state) > 1 do
# more than 1 segment is in use and it will be empty afterwards
case segments_count(state) do
2 ->
# exactly 2 segments in use, shift the last one to the first,
# empty the last, and clean up files
%{
state
| first_segment: state.last_segment,
first_segment_id: state.last_segment_id,
last_segment: :queue.new(),
dequeue_count: state.dequeue_count + 1
}
|> gc_unused_segments()
_ ->
# more the 2 segments in use, load a new segment as first one
# and clean up files
new_segment_id = state.first_segment_id + 1
%{
state
| first_segment: :queue.from_list(load_segment(state, new_segment_id)),
first_segment_id: new_segment_id,
dequeue_count: state.dequeue_count + 1
}
|> gc_unused_segments()
end
else
# only first segment is in used, normal removal
%{
state
| first_segment: rest,
dequeue_count: state.dequeue_count + 1
}
end
{:reply, {:ok, head}, new_state}
end
end
@impl true
def handle_call(:head, {_sender, _call}, %__MODULE__{first_segment: first_segment} = state) do
if queue_empty?(state) do
{:reply, {:error, :empty}, state}
else
head = :queue.head(first_segment)
{:reply, {:ok, head}, state}
end
end
@impl true
def handle_call(:count, {_sender, _call}, state) do
{:reply, queued_count(state), state}
end
@impl true
def handle_call(:reset, {_sender, _call}, state) do
base_dir_path = queue_base_dir(state)
File.ls!(base_dir_path)
|> Enum.filter(fn file -> Path.extname(file) == ".ndjson" end)
|> Enum.map(fn file -> File.rm!(Path.join(base_dir_path, file)) end)
{:reply, true,
%{
state
| enqueue_count: 0,
dequeue_count: 0,
first_segment: :queue.new(),
last_segment: :queue.new(),
first_segment_id: 0,
last_segment_id: 0
}}
end
@impl true
def handle_call(:info, {_sender, _call}, state) do
info =
Map.take(state, [:base_dir, :name, :segment_size, :number_of_segments, :delegate])
|> Map.put(:count, queued_count(state))
|> Map.put(:queue_base_dir, queue_base_dir(state))
|> Map.put(:maximum_size, maximum_size(state))
{:reply, info, state}
end
# internals
defp queued_count(
%__MODULE__{enqueue_count: enqueue_count, dequeue_count: dequeue_count} = _state
) do
enqueue_count - dequeue_count
end
defp segments_count(
%__MODULE__{
first_segment_id: first_segment_id,
last_segment_id: last_segment_id
} = _state
) do
last_segment_id - first_segment_id + 1
end
defp queue_empty?(state) do
queued_count(state) == 0
end
defp full?(state) do
queued_count(state) >= maximum_size(state)
end
defp maximum_size(
%__MODULE__{segment_size: segment_size, number_of_segments: number_of_segments} = _state
) do
segment_size * number_of_segments
end
defp load_state_from_disk(state) do
base_dir_path = queue_base_dir(state)
files =
base_dir_path
|> File.ls!()
|> Enum.filter(fn name -> String.match?(name, ~r/^.*.ndjson$/) end)
# find the last (highest id) dequeue segment file
last_dequeue_file =
files
|> Enum.filter(fn name -> String.match?(name, ~r/^dequeue-.*.ndjson$/) end)
|> Enum.max_by(fn name -> segment_id_from_file(name) end, fn -> nil end)
last_dequeue_segment_id =
if last_dequeue_file, do: segment_id_from_file(last_dequeue_file), else: 0
# get the last dequeued id, which equals the dequeue count
dequeue_count =
if last_dequeue_file,
do: (read_last_ndjson(Path.join(base_dir_path, last_dequeue_file)) |> Map.get("id")) + 1,
else: 0
# find the last (highest id) enqueue segment file
last_enqueue_file =
files
|> Enum.filter(fn name -> String.match?(name, ~r/^enqueue-.*.ndjson$/) end)
|> Enum.max_by(fn name -> segment_id_from_file(name) end, fn -> nil end)
last_enqueue_segment_id =
if last_enqueue_file, do: segment_id_from_file(last_enqueue_file), else: 0
# load the whole segment
last_enqueue_segment =
if last_enqueue_file,
do: :queue.from_list(load_segment(state, last_enqueue_segment_id)),
else: :queue.new()
# the enqueue count equals the last id
enqueue_count =
if :queue.is_empty(last_enqueue_segment),
do: 0,
else: (:queue.last(last_enqueue_segment) |> Map.get("id")) + 1
if last_dequeue_segment_id == last_enqueue_segment_id do
# we're in the situation where there is only 1 segment,
# stored in the first segment while the last segment is empty,
# execute the necessary dequeues
{_, last_enqueue_segment} =
:queue.split(
rem(dequeue_count, state.segment_size),
last_enqueue_segment
)
%{
state
| enqueue_count: enqueue_count,
dequeue_count: dequeue_count,
first_segment: last_enqueue_segment,
last_segment: :queue.new(),
first_segment_id: last_dequeue_segment_id,
last_segment_id: last_enqueue_segment_id
}
else
# we're in the at least 2 segments situation
# load the last segment and execute the necessary dequeues
{_, last_dequeue_segment} =
:queue.split(
rem(dequeue_count, state.segment_size),
:queue.from_list(load_segment(state, last_dequeue_segment_id))
)
%{
state
| enqueue_count: enqueue_count,
dequeue_count: dequeue_count,
first_segment: last_dequeue_segment,
last_segment: last_enqueue_segment,
first_segment_id: last_dequeue_segment_id,
last_segment_id: last_enqueue_segment_id
}
end
end
defp load_segment(state, segment_id) do
path = Path.join(queue_base_dir(state), "enqueue-#{segment_id}.ndjson")
File.stream!(path) |> Enum.map(fn line -> JSON.decode!(line) end)
end
defp log_enqueue(
%__MODULE__{enqueue_count: enqueue_count, segment_size: segment_size} = state,
json
) do
segment_id = div(enqueue_count, segment_size)
path = Path.join(queue_base_dir(state), "enqueue-#{segment_id}.ndjson")
append_ndjson(json, path)
end
defp log_dequeue(
%__MODULE__{dequeue_count: dequeue_count, segment_size: segment_size} = state,
json
) do
segment_id = div(dequeue_count, segment_size)
path = Path.join(queue_base_dir(state), "dequeue-#{segment_id}.ndjson")
append_ndjson(json, path)
end
defp gc_unused_segments(%__MODULE__{first_segment_id: first_segment_id} = state) do
base_dir_path = queue_base_dir(state)
# all segment files (enqueue & dequeue) with id less than
# the id of the current first segment are no longer needed
File.ls!(base_dir_path)
|> Enum.filter(fn file -> Path.extname(file) == ".ndjson" end)
|> Enum.filter(fn file -> segment_id_from_file(file) < first_segment_id end)
|> Enum.map(fn file -> File.rm!(Path.join(base_dir_path, file)) end)
# note that if the current segment is full and then fully read out,
# all log files will be removed. if the queue would then be restarted,
# it will start its internal id counting from 0 again.
# as this is an internal id, this should make no functional difference
state
end
defp notify_delegate(state) do
delegate = state.delegate
if delegate do
send(delegate, :enqueued)
end
state
end
defp queue_base_dir(%__MODULE__{name: name, base_dir: base_dir} = _state) do
path = Path.join(base_dir, to_string(name))
if !File.exists?(path) do
File.mkdir_p!(path)
end
path
end
defp segment_id_from_file(name) do
Path.rootname(name) |> String.split("-") |> List.last() |> String.to_integer()
end
# IO support
defp read_last_ndjson(file) do
File.stream!(file) |> Enum.reduce(nil, fn line, _last -> line end) |> JSON.decode!()
end
defp append_ndjson(io_data, file) do
File.write!(file, [io_data, "\n"], [:append])
end
end