mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-23 12:08:00 +01:00
wip
This commit is contained in:
parent
f91d9f2143
commit
7dc57cee6f
11 changed files with 1139 additions and 0 deletions
276
lib/philomena_job/jobs.ex
Normal file
276
lib/philomena_job/jobs.ex
Normal file
|
@ -0,0 +1,276 @@
|
||||||
|
defmodule PhilomenaJob.Jobs do
|
||||||
|
@moduledoc """
|
||||||
|
The Jobs context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query, warn: false
|
||||||
|
alias Ecto.Multi
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Return a `m:Ecto.Multi` to create a job from the `:id` key of a previous operation.
|
||||||
|
|
||||||
|
This function automatically raises an error when a deadlock condition could be created.
|
||||||
|
When inserting into multiple tables in the same transaction, you must run job creation in
|
||||||
|
order of the creating module name.
|
||||||
|
|
||||||
|
On successful completion, the table is notified for new work.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Multi.new()
|
||||||
|
|> Multi.insert(:image, image_changeset)
|
||||||
|
|> Jobs.create_job_from_op(ImageIndexRequest, :image, %{index_type: "update"})
|
||||||
|
|> Repo.transaction()
|
||||||
|
|> case do
|
||||||
|
{:ok, %{image: image}} ->
|
||||||
|
{:ok, image}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:error, image_changeset}
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
def create_job_from_op(multi, job_module, key, map \\ %{}) do
|
||||||
|
[primary_key] = job_module.__schema__(:primary_key)
|
||||||
|
job_update = update(job_module, set: [request_time: fragment("EXCLUDED.request_time")])
|
||||||
|
|
||||||
|
multi
|
||||||
|
|> Multi.run({:lock_table, job_module}, lock_table(job_module))
|
||||||
|
|> Multi.run({:insert, job_module}, fn repo, %{^key => %{id: id}} ->
|
||||||
|
entry =
|
||||||
|
Map.merge(map, %{
|
||||||
|
primary_key => id,
|
||||||
|
request_time: DateTime.utc_now()
|
||||||
|
})
|
||||||
|
|
||||||
|
result =
|
||||||
|
repo.insert_all(
|
||||||
|
job_module,
|
||||||
|
[entry],
|
||||||
|
on_conflict: job_update,
|
||||||
|
conflict_target: primary_key
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, result}
|
||||||
|
end)
|
||||||
|
|> Multi.run({:notify, job_module}, notify_table(job_module))
|
||||||
|
|> avoid_deadlock()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Return a `m:Ecto.Multi` to bulk create and notify jobs from an input query.
|
||||||
|
|
||||||
|
Due to [this Ecto bug](https://github.com/elixir-ecto/ecto/issues/4430), the caller must
|
||||||
|
select the fields needed for the table insert to succeed. The input query should look like:
|
||||||
|
|
||||||
|
job_query =
|
||||||
|
from i in Image,
|
||||||
|
where: ...,
|
||||||
|
select: %{
|
||||||
|
image_id: i.id,
|
||||||
|
request_time: ^DateTime.utc_now(),
|
||||||
|
index_type: "update"
|
||||||
|
}
|
||||||
|
|
||||||
|
This function automatically raises an error when a deadlock condition could be created.
|
||||||
|
When inserting into multiple tables in the same transaction, you must run job creation in
|
||||||
|
order of the creating module name.
|
||||||
|
|
||||||
|
On successful completion, the table is notified for new work.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Multi.new()
|
||||||
|
|> Multi.update_all(:images, images, [])
|
||||||
|
|> Jobs.create_jobs_from_query(ImageIndexRequest, job_query)
|
||||||
|
|> Repo.transaction()
|
||||||
|
|> case do
|
||||||
|
{:ok, %{images: images}} ->
|
||||||
|
{:ok, images}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:error, ...}
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
def create_jobs_from_query(multi \\ Multi.new(), job_module, query) do
|
||||||
|
primary_key = job_module.__schema__(:primary_key)
|
||||||
|
job_update = update(job_module, set: [request_time: fragment("EXCLUDED.request_time")])
|
||||||
|
|
||||||
|
multi
|
||||||
|
|> Multi.run({:lock_table, job_module}, lock_table(job_module))
|
||||||
|
|> Multi.insert_all({:insert, job_module}, job_module, query,
|
||||||
|
on_conflict: job_update,
|
||||||
|
conflict_target: primary_key
|
||||||
|
)
|
||||||
|
|> Multi.run({:notify, job_module}, notify_table(job_module))
|
||||||
|
|> avoid_deadlock()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Return a `m:Ecto.Multi` to fetch and assign `limit` number of free jobs.
|
||||||
|
|
||||||
|
Jobs can be ordered by request_time `:asc` (default) or `:desc`. This can
|
||||||
|
be used e.g. to have workers sample from both ends of the queue.
|
||||||
|
|
||||||
|
Results are returned in the `jobs` key of the multi. The table is not notified
|
||||||
|
for new work.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
ImageIndexRequest
|
||||||
|
|> Jobs.fetch_and_assign_jobs("images_0", 500, :desc)
|
||||||
|
|> Repo.transaction()
|
||||||
|
|> case do
|
||||||
|
{:ok, %{jobs: {_, jobs}}} ->
|
||||||
|
{:ok, jobs}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:error, :job_assignment_failed}
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
def fetch_and_assign_jobs(job_module, worker_name, limit, order \\ :asc) do
|
||||||
|
update_query =
|
||||||
|
from job in job_module,
|
||||||
|
where: is_nil(job.worker_name),
|
||||||
|
limit: ^limit,
|
||||||
|
order_by: [{^order, :request_time}],
|
||||||
|
update: [set: [worker_name: ^worker_name]],
|
||||||
|
select: job
|
||||||
|
|
||||||
|
Multi.new()
|
||||||
|
|> Multi.run(:lock_table, lock_table(job_module))
|
||||||
|
|> Multi.update_all(:jobs, update_query, [])
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Return a `m:Ecto.Multi` to release all jobs with the given worker name.
|
||||||
|
|
||||||
|
On successful completion, the table is notified for new work.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
ImageIndexRequest
|
||||||
|
|> Jobs.release_jobs("images_0")
|
||||||
|
|> Repo.transaction()
|
||||||
|
|
||||||
|
"""
|
||||||
|
def release_jobs(job_module, worker_name) do
|
||||||
|
update_query =
|
||||||
|
from job in job_module,
|
||||||
|
where: job.worker_name == ^worker_name,
|
||||||
|
update: [set: [worker_name: nil]]
|
||||||
|
|
||||||
|
Multi.new()
|
||||||
|
|> Multi.run(:lock_table, lock_table(job_module))
|
||||||
|
|> Multi.update_all(:update, update_query, [])
|
||||||
|
|> Multi.run(:notify, notify_table(job_module))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Return a `m:Ecto.Multi` to complete all jobs in the list of jobs.
|
||||||
|
|
||||||
|
Jobs where the request time is identical to the fetched job are deleted
|
||||||
|
entirely. Jobs where the request time is newer than the fetched job are
|
||||||
|
updated to reset their attempt count.
|
||||||
|
|
||||||
|
On successful completion, the table is notified for new work.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
ImageIndexRequest
|
||||||
|
|> Jobs.complete_jobs(jobs)
|
||||||
|
|> Repo.transaction()
|
||||||
|
|
||||||
|
"""
|
||||||
|
def complete_jobs(job_module, jobs) do
|
||||||
|
[primary_key] = job_module.__schema__(:primary_key)
|
||||||
|
|
||||||
|
delete_query = where(job_module, fragment("'t' = 'f'"))
|
||||||
|
|
||||||
|
delete_query =
|
||||||
|
Enum.reduce(jobs, delete_query, fn job, query ->
|
||||||
|
or_where(
|
||||||
|
query,
|
||||||
|
[q],
|
||||||
|
field(q, ^primary_key) == ^Map.fetch!(job, primary_key) and
|
||||||
|
q.request_time == ^job.request_time
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
job_keys = Enum.map(jobs, &Map.fetch!(&1, primary_key))
|
||||||
|
|
||||||
|
update_query =
|
||||||
|
from job in job_module,
|
||||||
|
where: field(job, ^primary_key) in ^job_keys,
|
||||||
|
update: [set: [attempt_count: 0, worker_name: nil]]
|
||||||
|
|
||||||
|
Multi.new()
|
||||||
|
|> Multi.run(:lock_table, lock_table(job_module))
|
||||||
|
|> Multi.delete_all(:delete, delete_query)
|
||||||
|
|> Multi.update_all(:update, update_query, [])
|
||||||
|
|> Multi.run(:notify, notify_table(job_module))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Return a `m:Ecto.Multi` to fail the given job, incrementing its attempt
|
||||||
|
counter.
|
||||||
|
|
||||||
|
On successful completion, the table is notified for new work.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
ImageIndexRequest
|
||||||
|
|> Jobs.fail_job(job)
|
||||||
|
|> Repo.transaction()
|
||||||
|
|
||||||
|
"""
|
||||||
|
def fail_job(job_module, job) do
|
||||||
|
[primary_key] = job_module.__schema__(:primary_key)
|
||||||
|
|
||||||
|
update_query =
|
||||||
|
from q in job_module,
|
||||||
|
where: field(q, ^primary_key) == ^Map.fetch!(job, primary_key),
|
||||||
|
update: [inc: [attempt_count: 1], set: [worker_name: nil]]
|
||||||
|
|
||||||
|
Multi.new()
|
||||||
|
|> Multi.run(:lock_table, lock_table(job_module))
|
||||||
|
|> Multi.update_all(:update, update_query, [])
|
||||||
|
|> Multi.run(:notify, notify_table(job_module))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp avoid_deadlock(multi) do
|
||||||
|
table_lock_operations =
|
||||||
|
multi
|
||||||
|
|> Multi.to_list()
|
||||||
|
|> Enum.flat_map(fn
|
||||||
|
{{:lock_table, name}, _} -> [name]
|
||||||
|
_ -> []
|
||||||
|
end)
|
||||||
|
|
||||||
|
if table_lock_operations != Enum.sort(table_lock_operations) do
|
||||||
|
raise "Table lock operations do not occur in sorted order.\n" <>
|
||||||
|
"Got: #{inspect(table_lock_operations)}\n" <>
|
||||||
|
"Sort the lock operations to prevent deadlock."
|
||||||
|
else
|
||||||
|
multi
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp lock_table(job_module) do
|
||||||
|
fn repo, _changes ->
|
||||||
|
repo.query("LOCK TABLE $1 IN EXCLUSIVE MODE", [table_name(job_module)])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp notify_table(job_module) do
|
||||||
|
fn repo, _changes ->
|
||||||
|
repo.query("NOTIFY $1", [table_name(job_module)])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp table_name(job_module) do
|
||||||
|
job_module.__schema__(:source)
|
||||||
|
end
|
||||||
|
end
|
58
lib/philomena_job/jobs/job.ex
Normal file
58
lib/philomena_job/jobs/job.ex
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
defmodule PhilomenaJob.Jobs.Job do
|
||||||
|
@moduledoc """
|
||||||
|
Base schema module for processing jobs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
defmacro __using__ do
|
||||||
|
quote do
|
||||||
|
use Ecto.Schema
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Defines custom schema fields for processing jobs.
|
||||||
|
|
||||||
|
Processing jobs have three default fields, which are created automatically
|
||||||
|
by the `job_schema/2` macro and should not be redefined:
|
||||||
|
- `:request_time`
|
||||||
|
- `:attempt_count`
|
||||||
|
- `:worker_name`
|
||||||
|
|
||||||
|
The client should define the primary key and any additional fields.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
defmodule Philomena.Images.IndexRequest do
|
||||||
|
use Philomena.Jobs.Job
|
||||||
|
|
||||||
|
job_schema "image_index_requests" do
|
||||||
|
belongs_to Philomena.Images.Image, primary_key: true
|
||||||
|
field :index_type, :string, default: "update"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defmodule Philomena.Images.StorageRequest do
|
||||||
|
use Philomena.Jobs.Job
|
||||||
|
|
||||||
|
job_schema "image_storage_requests" do
|
||||||
|
belongs_to Philomena.Images.Image, primary_key: true
|
||||||
|
field :operation, :string, default: "put"
|
||||||
|
field :key, :string
|
||||||
|
field :data, :blob
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
defmacro job_schema(name, do: block) do
|
||||||
|
quote do
|
||||||
|
schema unquote(name) do
|
||||||
|
field :request_time, :utc_datetime_usec
|
||||||
|
field :attempt_count, :integer
|
||||||
|
field :worker_name, :string
|
||||||
|
|
||||||
|
unquote(block)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
71
lib/philomena_job/listener/server.ex
Normal file
71
lib/philomena_job/listener/server.ex
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
defmodule PhilomenaJob.Listener.Server do
|
||||||
|
@moduledoc """
|
||||||
|
A listener server which holds references to worker processes, and converts database
|
||||||
|
event notifications into messages for those worker processes.
|
||||||
|
|
||||||
|
A `PhilomenaJob.NotifierServer` reference must be provided. This is a server pid or name.
|
||||||
|
|
||||||
|
A supervision tree example:
|
||||||
|
|
||||||
|
children = [
|
||||||
|
{PhilomenaJob.Listener.Server, name: WorkerListener, notifier: WorkerNotifier}
|
||||||
|
]
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias PhilomenaJob.Listener.State
|
||||||
|
alias PhilomenaJob.NotifierServer
|
||||||
|
alias PhilomenaJob.Worker
|
||||||
|
|
||||||
|
use GenServer, restart: :temporary, significant: true
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Registers the current process to activate when `channel_name` receives a notification.
|
||||||
|
|
||||||
|
Process listeners are automatically unregistered when the process exits.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
iex> link_worker(listener_ref, "image_index_requests")
|
||||||
|
:ok
|
||||||
|
|
||||||
|
"""
|
||||||
|
def link_worker(listener, channel_name) do
|
||||||
|
:ok = GenServer.call(listener, {:link_worker, channel_name})
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def start_link(opts) do
|
||||||
|
GenServer.start_link(__MODULE__, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
def init(opts) do
|
||||||
|
notifier = Keyword.fetch!(opts, :notifier)
|
||||||
|
|
||||||
|
unlisten = &NotifierServer.unlisten!(notifier, &1)
|
||||||
|
listen = &NotifierServer.listen!(notifier, &1)
|
||||||
|
notify = &Worker.notify/1
|
||||||
|
|
||||||
|
{:ok, State.new(unlisten: unlisten, listen: listen, notify: notify)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
def handle_call({:link_worker, channel_name}, {pid, _}, state) do
|
||||||
|
{:reply, :ok, State.add_worker(state, channel_name, pid)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
def handle_info(message, state)
|
||||||
|
|
||||||
|
def handle_info({:DOWN, _ref, :process, worker_pid, _reason}, state) do
|
||||||
|
{:noreply, State.remove_worker(state, worker_pid)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:notification, _pid, listen_ref, _channel_name, _message}, state) do
|
||||||
|
{:noreply, State.notify_worker(state, listen_ref)}
|
||||||
|
end
|
||||||
|
end
|
137
lib/philomena_job/listener/state.ex
Normal file
137
lib/philomena_job/listener/state.ex
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
defmodule PhilomenaJob.Listener.State do
|
||||||
|
@moduledoc """
|
||||||
|
Internal listener state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
defmodule Entry do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
defstruct channel_name: nil,
|
||||||
|
pid: nil,
|
||||||
|
process_ref: nil,
|
||||||
|
listen_ref: nil
|
||||||
|
end
|
||||||
|
|
||||||
|
defstruct entries: [],
|
||||||
|
notify: nil,
|
||||||
|
unlisten: nil,
|
||||||
|
listen: nil,
|
||||||
|
demonitor: nil,
|
||||||
|
monitor: nil
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Create a new instance of the internal listener state.
|
||||||
|
|
||||||
|
Supported options:
|
||||||
|
- `:notify`: callback which receives a worker pid. Required.
|
||||||
|
- `:unlisten`: callback to stop listening to a channel. Required.
|
||||||
|
- `:listen`: callback to begin listening to a channel. Required.
|
||||||
|
- `:demonitor`: callback to de-monitor a process. Optional, defaults to `Process.demonitor/1`.
|
||||||
|
- `:monitor`: callback to monitor a process. Optional, defaults to `Process.monitor/1`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> State.new(listen: &Notifier.listen!/2, unlisten: &Notifier.unlisten!/2)
|
||||||
|
%State{}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def new(opts) do
|
||||||
|
notify = Keyword.fetch!(opts, :notify)
|
||||||
|
unlisten = Keyword.fetch!(opts, :unlisten)
|
||||||
|
listen = Keyword.fetch!(opts, :listen)
|
||||||
|
demonitor = Keyword.get(opts, :demonitor, &Process.demonitor/1)
|
||||||
|
monitor = Keyword.get(opts, :monitor, &Process.monitor/1)
|
||||||
|
|
||||||
|
%__MODULE__{
|
||||||
|
notify: notify,
|
||||||
|
unlisten: unlisten,
|
||||||
|
listen: listen,
|
||||||
|
demonitor: demonitor,
|
||||||
|
monitor: monitor
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Registers the given `worker_pid` to activate when `channel_name` receives a notification.
|
||||||
|
|
||||||
|
Processes which are added are monitored, and exits with any added process may trigger a
|
||||||
|
mailbox `DOWN` message by the caller which must be handled. See the documentation
|
||||||
|
for `Process.monitor/1` for more information about the `DOWN` message.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
iex> add_worker(state, "image_index_requests", self())
|
||||||
|
%State{}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def add_worker(%__MODULE__{} = state, channel_name, worker_pid) do
|
||||||
|
# Ensure that there is no worker already registered with this pid.
|
||||||
|
[] = filter_by(state, pid: worker_pid)
|
||||||
|
|
||||||
|
# Monitor and begin listening.
|
||||||
|
process_ref = state.monitor.(worker_pid)
|
||||||
|
listen_ref = state.listen.(channel_name)
|
||||||
|
|
||||||
|
# Add to state.
|
||||||
|
e = %Entry{
|
||||||
|
channel_name: channel_name,
|
||||||
|
pid: worker_pid,
|
||||||
|
process_ref: process_ref,
|
||||||
|
listen_ref: listen_ref
|
||||||
|
}
|
||||||
|
|
||||||
|
update_in(state.workers, &([e] ++ &1))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Unregisters the given `worker_pid` for notifications.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
iex> remove_worker(state, self())
|
||||||
|
%State{}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def remove_worker(%__MODULE__{} = state, worker_pid) do
|
||||||
|
case filter_by(state, pid: worker_pid) do
|
||||||
|
[%Entry{} = entry] ->
|
||||||
|
# Stop listening and unmonitor.
|
||||||
|
state.unlisten.(entry.listen_ref)
|
||||||
|
state.demonitor.(entry.process_ref)
|
||||||
|
|
||||||
|
# Remove from state.
|
||||||
|
erase_by(state, pid: worker_pid)
|
||||||
|
|
||||||
|
[] ->
|
||||||
|
state
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sends a worker listening on the given `listen_ref` a notification using the
|
||||||
|
`notify` callback.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
iex> notify_workers(state, listen_ref)
|
||||||
|
%State{}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def notify_worker(%__MODULE__{} = state, listen_ref) do
|
||||||
|
for entry <- filter_by(state, listen_ref: listen_ref) do
|
||||||
|
state.notify.(entry.pid)
|
||||||
|
end
|
||||||
|
|
||||||
|
state
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filter_by(%__MODULE__{} = state, [{key, value}]) do
|
||||||
|
Enum.filter(state.entries, &match?(%{^key => ^value}, &1))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp erase_by(%__MODULE__{} = state, [{key, value}]) do
|
||||||
|
workers = Enum.filter(state.entries, &(not match?(%{^key => ^value}, &1)))
|
||||||
|
|
||||||
|
put_in(state.workers, workers)
|
||||||
|
end
|
||||||
|
end
|
41
lib/philomena_job/notifier_server.ex
Normal file
41
lib/philomena_job/notifier_server.ex
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
defmodule PhilomenaJob.NotifierServer do
|
||||||
|
@moduledoc """
|
||||||
|
Process wrapper to receive notifications from the Postgres LISTEN command.
|
||||||
|
|
||||||
|
A supervision tree example:
|
||||||
|
|
||||||
|
children = [
|
||||||
|
{PhilomenaJob.NotifierServer, repo_url: "ecto://postgres@postgres/philomena_dev"}
|
||||||
|
]
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Postgrex.Notifications
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def child_spec(opts) do
|
||||||
|
url = Keyword.fetch!(opts, :repo_url)
|
||||||
|
opts = Ecto.Repo.Supervisor.parse_url(url)
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: __MODULE__,
|
||||||
|
start: {Notifications, :start_link, [opts]},
|
||||||
|
restart: :temporary,
|
||||||
|
significant: true
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Begin listening to the given channel. Returns a reference.
|
||||||
|
|
||||||
|
See `Postgrex.Notifications.listen!/3` for more information.
|
||||||
|
"""
|
||||||
|
defdelegate listen!(pid, channel, opts \\ []), to: Notifications
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Stop listening to the channel identified by the given reference.
|
||||||
|
|
||||||
|
See `Postgrex.Notifications.unlisten!/3` for more information.
|
||||||
|
"""
|
||||||
|
defdelegate unlisten!(pid, ref, opts \\ []), to: Notifications
|
||||||
|
end
|
13
lib/philomena_job/processor.ex
Normal file
13
lib/philomena_job/processor.ex
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
defmodule PhilomenaJob.Processor do
|
||||||
|
@moduledoc """
|
||||||
|
Interface implemented by processors passed to `PhilomenaJob.Supervisor`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Check to see if work is available, and if so, run it.
|
||||||
|
|
||||||
|
Return false to temporarily yield control and get called again.
|
||||||
|
Return true only when no more work is available.
|
||||||
|
"""
|
||||||
|
@callback check_work(keyword()) :: boolean()
|
||||||
|
end
|
114
lib/philomena_job/semaphore/server.ex
Normal file
114
lib/philomena_job/semaphore/server.ex
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
defmodule PhilomenaJob.Semaphore.Server do
|
||||||
|
@moduledoc """
|
||||||
|
A counting semaphore.
|
||||||
|
|
||||||
|
This is used to limit the concurrency of a potentially large group of processes by calling
|
||||||
|
`acquire/1` or `run/1` - only up to the number of processes indicated by the startup value
|
||||||
|
are allowed to run concurrently.
|
||||||
|
|
||||||
|
A supervision tree example:
|
||||||
|
|
||||||
|
children = [
|
||||||
|
{PhilomenaJob.Semaphore.Server, name: WorkerSemaphore, max_concurrency: 16}
|
||||||
|
]
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias PhilomenaJob.Semaphore.State
|
||||||
|
use GenServer, restart: :temporary, significant: true
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Wraps the given callback with an acquire before the callback and a release after running
|
||||||
|
the callback.
|
||||||
|
|
||||||
|
Returns the return value of the callback.
|
||||||
|
|
||||||
|
See `acquire/1` and `release/1` for additional details.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
iex> Semaphore.run(WorkerSemaphore, fn -> check_work(state) end)
|
||||||
|
true
|
||||||
|
|
||||||
|
"""
|
||||||
|
def run(name, callback) do
|
||||||
|
acquire(name)
|
||||||
|
ret = callback.()
|
||||||
|
release(name)
|
||||||
|
ret
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Decrement the semaphore with the given name.
|
||||||
|
|
||||||
|
This either returns immediately with the semaphore value acquired, or blocks indefinitely until
|
||||||
|
sufficient other processes have released their holds on the semaphore to allow a new one to
|
||||||
|
acquire.
|
||||||
|
|
||||||
|
Processes which acquire the semaphore are monitored, and exits with an acquired value trigger
|
||||||
|
automatic release, so exceptions will not break the semaphore.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
iex> Semaphore.acquire(semaphore)
|
||||||
|
:ok
|
||||||
|
|
||||||
|
"""
|
||||||
|
def acquire(name) do
|
||||||
|
:ok = GenServer.call(name, :acquire, :infinity)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Increment the semaphore with the given name.
|
||||||
|
|
||||||
|
This releases the hold given by the current process and allows another process to begin running.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
iex> Semaphore.release(semaphore)
|
||||||
|
:ok
|
||||||
|
|
||||||
|
"""
|
||||||
|
def release(name) do
|
||||||
|
:ok = GenServer.call(name, :release, :infinity)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def start_link(opts) do
|
||||||
|
GenServer.start_link(__MODULE__, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
def init(opts) do
|
||||||
|
{:ok, State.new(opts)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
def handle_call(message, from, state)
|
||||||
|
|
||||||
|
def handle_call(:acquire, from, state) do
|
||||||
|
case State.add_pending_process(state, from) do
|
||||||
|
{:ok, new_state} ->
|
||||||
|
{:noreply, new_state}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
{:reply, error, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call(:release, {pid, _}, state) do
|
||||||
|
{:ok, new_state} = State.release_active_process(state, pid)
|
||||||
|
|
||||||
|
{:reply, :ok, new_state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
|
||||||
|
{:ok, state} = State.release_active_process(state, pid)
|
||||||
|
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
end
|
131
lib/philomena_job/semaphore/state.ex
Normal file
131
lib/philomena_job/semaphore/state.ex
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
defmodule PhilomenaJob.Semaphore.State do
|
||||||
|
@doc """
|
||||||
|
Internal semaphore state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
defstruct active_processes: %{},
|
||||||
|
pending_processes: %{},
|
||||||
|
max_concurrency: nil,
|
||||||
|
demonitor: nil,
|
||||||
|
monitor: nil,
|
||||||
|
reply: nil
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Create a new instance of the internal semaphore state.
|
||||||
|
|
||||||
|
Supported options:
|
||||||
|
- `:max_concurrency`: the maximum number of processes which can be active. Required.
|
||||||
|
- `:demonitor`: callback to de-monitor a process. Optional, defaults to `Process.demonitor/1`.
|
||||||
|
- `:monitor`: callback to monitor a process. Optional, defaults to `Process.monitor/1`.
|
||||||
|
- `:reply`: callback to reply to a process. Optional, defaults to `GenServer.reply/2`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> State.new(max_concurrency: System.schedulers_online())
|
||||||
|
%State{}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def new(opts) do
|
||||||
|
max_concurrency = Keyword.fetch!(opts, :max_concurrency)
|
||||||
|
demonitor = Keyword.get(opts, :demonitor, &Process.demonitor/1)
|
||||||
|
monitor = Keyword.get(opts, :monitor, &Process.monitor/1)
|
||||||
|
reply = Keyword.get(opts, :reply, &GenServer.reply/2)
|
||||||
|
|
||||||
|
%__MODULE__{
|
||||||
|
max_concurrency: max_concurrency,
|
||||||
|
demonitor: demonitor,
|
||||||
|
monitor: monitor,
|
||||||
|
reply: reply
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Decrement the semaphore with the given name.
|
||||||
|
|
||||||
|
This returns immediately with the state updated. The referenced process will be called with
|
||||||
|
the reply function once the semaphore is available.
|
||||||
|
|
||||||
|
Processes which acquire the semaphore are monitored, and exits with an acquired process may
|
||||||
|
trigger a mailbox `DOWN` message by the caller which must be handled. See the documentation
|
||||||
|
for `Process.monitor/1` for more information about the `DOWN` message.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
iex> State.add_pending_process(state, {self(), 0})
|
||||||
|
{:ok, %State{}}
|
||||||
|
|
||||||
|
iex> State.add_pending_process(state, {self(), 0})
|
||||||
|
{:error, :already_pending_or_active}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def add_pending_process(%__MODULE__{} = state, {pid, _} = from) do
|
||||||
|
if active?(state, pid) or pending?(state, pid) do
|
||||||
|
{:error, :already_pending_or_active, state}
|
||||||
|
else
|
||||||
|
state = update_in(state.pending_processes, &Map.put(&1, pid, from))
|
||||||
|
{:ok, try_acquire_process(state)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Increment the semaphore with the given name.
|
||||||
|
|
||||||
|
This returns immediately with the state updated, releases the hold given by the specified
|
||||||
|
process, and potentially allows another process to begin running.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
iex> State.release_active_process(state, self())
|
||||||
|
{:ok, %State{}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
def release_active_process(%__MODULE__{} = state, pid) do
|
||||||
|
if active?(state, pid) do
|
||||||
|
{:ok, release_process(state, pid)}
|
||||||
|
else
|
||||||
|
{:ok, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp try_acquire_process(%__MODULE__{} = state)
|
||||||
|
when state.pending_processes != %{} and
|
||||||
|
map_size(state.active_processes) < state.max_concurrency do
|
||||||
|
# Start monitoring the process. We will automatically clean up when it exits.
|
||||||
|
{pid, from} = Enum.at(state.pending_processes, 0)
|
||||||
|
ref = state.monitor.(pid)
|
||||||
|
|
||||||
|
# Drop from pending and add to active.
|
||||||
|
state = update_in(state.pending_processes, &Map.delete(&1, pid))
|
||||||
|
state = update_in(state.active_processes, &Map.put(&1, pid, ref))
|
||||||
|
|
||||||
|
# Reply to the client which has now acquired the semaphore.
|
||||||
|
state.reply.(from, :ok)
|
||||||
|
|
||||||
|
state
|
||||||
|
end
|
||||||
|
|
||||||
|
defp try_acquire_process(state) do
|
||||||
|
# No pending processes or too many active processes, so nothing to do.
|
||||||
|
state
|
||||||
|
end
|
||||||
|
|
||||||
|
defp release_process(%__MODULE__{} = state, pid) do
|
||||||
|
# Stop watching the process.
|
||||||
|
ref = Map.fetch!(state.active_processes, pid)
|
||||||
|
state.demonitor.(ref)
|
||||||
|
|
||||||
|
# Drop from active set.
|
||||||
|
state = update_in(state.active_processes, &Map.delete(&1, pid))
|
||||||
|
|
||||||
|
# Try to acquire something new.
|
||||||
|
try_acquire_process(state)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp active?(%__MODULE__{} = state, pid) do
|
||||||
|
Map.has_key?(state.active_processes, pid)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pending?(%__MODULE__{} = state, pid) do
|
||||||
|
Map.has_key?(state.pending_processes, pid)
|
||||||
|
end
|
||||||
|
end
|
92
lib/philomena_job/supervisor.ex
Normal file
92
lib/philomena_job/supervisor.ex
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
defmodule PhilomenaJob.Supervisor do
|
||||||
|
@moduledoc """
|
||||||
|
Main supervisor for jobs processing.
|
||||||
|
|
||||||
|
Supported options:
|
||||||
|
- `:max_concurrency`: the maximum number of processors which can run in parallel. Required.
|
||||||
|
This is global across all processors specified by this supervisor instance.
|
||||||
|
Lowering the maximum concurrency delays processors until the concurrency drops.
|
||||||
|
- `:repo_url`: the Ecto URL to the database. Can be fetched from application env. Required.
|
||||||
|
- `:processors`: A list of processor modules to create worker processes for. Required.
|
||||||
|
- `:name`: the global name for this supervisor instance. Required.
|
||||||
|
|
||||||
|
Processor modules should implement the processor behaviour. See the `PhilomenaJob.Processor`
|
||||||
|
documentation for more information on required callbacks.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
children = [
|
||||||
|
{PhilomenaJob.Supervisor,
|
||||||
|
max_concurrency: System.schedulers_online(),
|
||||||
|
repo_url: Application.get_env(:philomena, Philomena.Repo)[:url],
|
||||||
|
processors: [
|
||||||
|
CommentIndexUpdater,
|
||||||
|
FilterIndexUpdater,
|
||||||
|
GalleryIndexUpdater,
|
||||||
|
ImageIndexUpdater,
|
||||||
|
PostIndexUpdater,
|
||||||
|
ReportIndexUpdater,
|
||||||
|
TagIndexUpdater
|
||||||
|
],
|
||||||
|
name: IndexWorkSupervisor
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
Supervisor.start_link(children, opts)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias PhilomenaJob.Semaphore.Server, as: SemaphoreServer
|
||||||
|
alias PhilomenaJob.Listener.Server, as: ListenerServer
|
||||||
|
alias PhilomenaJob.NotifierServer
|
||||||
|
alias PhilomenaJob.Worker
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def child_spec(opts) do
|
||||||
|
name = Keyword.fetch!(opts, :name)
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: Module.concat(__MODULE__, name),
|
||||||
|
start: {__MODULE__, :start_link, [opts]}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def start_link(opts) do
|
||||||
|
processors = Keyword.fetch!(opts, :processors)
|
||||||
|
name = Keyword.fetch!(opts, :name)
|
||||||
|
|
||||||
|
# Start the main supervisor.
|
||||||
|
{:ok, main_sup} =
|
||||||
|
Supervisor.start_link(
|
||||||
|
[],
|
||||||
|
strategy: :one_for_one,
|
||||||
|
auto_shutdown: :any_significant,
|
||||||
|
name: name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start all three significant processes.
|
||||||
|
# If any of these exit, the supervisor exits.
|
||||||
|
opts =
|
||||||
|
opts
|
||||||
|
|> start_named_child(name, :notifier, NotifierServer)
|
||||||
|
|> start_named_child(name, :semaphore, SemaphoreServer)
|
||||||
|
|> start_named_child(name, :listener, ListenerServer)
|
||||||
|
|
||||||
|
# Start workers. These can restart automatically.
|
||||||
|
for processor <- processors do
|
||||||
|
opts = Keyword.merge(opts, processor: processor)
|
||||||
|
|
||||||
|
Supervisor.start_child(name, {Worker, opts})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Return the main supervisor.
|
||||||
|
{:ok, main_sup}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_named_child(opts, sup, name, child_module) do
|
||||||
|
{:ok, child} = Supervisor.start_child(sup, {child_module, opts})
|
||||||
|
|
||||||
|
Keyword.merge(opts, [{name, child}])
|
||||||
|
end
|
||||||
|
end
|
63
lib/philomena_job/worker.ex
Normal file
63
lib/philomena_job/worker.ex
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
defmodule PhilomenaJob.Worker do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias PhilomenaJob.Listener.Server, as: ListenerServer
|
||||||
|
alias PhilomenaJob.Semaphore.Server, as: SemaphoreServer
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Notify the given worker that work may be available.
|
||||||
|
"""
|
||||||
|
def notify(name) do
|
||||||
|
GenServer.cast(name, :check_work)
|
||||||
|
end
|
||||||
|
|
||||||
|
defstruct semaphore: nil,
|
||||||
|
listener: nil,
|
||||||
|
processor: nil,
|
||||||
|
opts: nil
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def init(opts) do
|
||||||
|
state = %__MODULE__{
|
||||||
|
semaphore: Keyword.fetch!(opts, :semaphore),
|
||||||
|
listener: Keyword.fetch!(opts, :listener),
|
||||||
|
processor: Keyword.fetch!(opts, :processor),
|
||||||
|
opts: Keyword.drop(opts, [:semaphore, :listener, :processor])
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start listening for events.
|
||||||
|
ListenerServer.link_worker(state.listener, state.processor.channel())
|
||||||
|
|
||||||
|
# Check for new work.
|
||||||
|
{:ok, check_work(state)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def handle_cast(:check_work, %__MODULE__{} = state) do
|
||||||
|
{:noreply, check_work(state)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_work(%__MODULE__{} = state) do
|
||||||
|
# We have just started or received notification that work may be available.
|
||||||
|
processor = state.processor
|
||||||
|
opts = state.opts
|
||||||
|
|
||||||
|
# Keep calling check_work until we run out of work.
|
||||||
|
cycle(fn ->
|
||||||
|
SemaphoreServer.run(state.semaphore, fn ->
|
||||||
|
processor.check_work(opts)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
state
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cycle(callback) do
|
||||||
|
if callback.() do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
cycle(callback)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
143
test/philomena_job/semaphore_test.exs
Normal file
143
test/philomena_job/semaphore_test.exs
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
defmodule PhilomenaJob.SemaphoreTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias PhilomenaJob.Semaphore.Server, as: SemaphoreServer
|
||||||
|
|
||||||
|
@max_concurrency 8
|
||||||
|
|
||||||
|
describe "Server functionality" do
|
||||||
|
setup do
|
||||||
|
{:ok, pid} = SemaphoreServer.start_link(max_concurrency: @max_concurrency)
|
||||||
|
on_exit(fn -> Process.exit(pid, :kill) end)
|
||||||
|
|
||||||
|
%{pid: pid}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows max_concurrency processes to acquire", %{pid: pid} do
|
||||||
|
# Check acquire
|
||||||
|
results =
|
||||||
|
(1..@max_concurrency)
|
||||||
|
|> Task.async_stream(fn _ -> SemaphoreServer.acquire(pid) end)
|
||||||
|
|> Enum.map(fn {:ok, res} -> res end)
|
||||||
|
|
||||||
|
assert true == Enum.all?(results)
|
||||||
|
|
||||||
|
# Check linking to process exit
|
||||||
|
# If this hangs, linking to process exit does not release the semaphore
|
||||||
|
results =
|
||||||
|
(1..@max_concurrency)
|
||||||
|
|> Task.async_stream(fn _ -> SemaphoreServer.acquire(pid) end)
|
||||||
|
|> Enum.map(fn {:ok, res} -> res end)
|
||||||
|
|
||||||
|
assert true == Enum.all?(results)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not allow max_concurrency + 1 processes to acquire (exit)", %{pid: pid} do
|
||||||
|
processes =
|
||||||
|
(1..@max_concurrency)
|
||||||
|
|> Enum.map(fn _ -> acquire_and_wait_for_release(pid) end)
|
||||||
|
|
||||||
|
# This task should not be able to acquire
|
||||||
|
task = Task.async(fn -> SemaphoreServer.acquire(pid) end)
|
||||||
|
assert nil == Task.yield(task, 10)
|
||||||
|
|
||||||
|
# Terminate processes holding the semaphore
|
||||||
|
Enum.each(processes, &Process.exit(&1, :kill))
|
||||||
|
|
||||||
|
# Now the task should be able to acquire
|
||||||
|
assert {:ok, :ok} == Task.yield(task, 10)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not allow max_concurrency + 1 processes to acquire (release)", %{pid: pid} do
|
||||||
|
processes =
|
||||||
|
(1..@max_concurrency)
|
||||||
|
|> Enum.map(fn _ -> acquire_and_wait_for_release(pid) end)
|
||||||
|
|
||||||
|
# This task should not be able to acquire
|
||||||
|
task = Task.async(fn -> SemaphoreServer.acquire(pid) end)
|
||||||
|
assert nil == Task.yield(task, 10)
|
||||||
|
|
||||||
|
# Release processes holding the semaphore
|
||||||
|
Enum.each(processes, &send(&1, :release))
|
||||||
|
|
||||||
|
# Now the task should be able to acquire
|
||||||
|
assert {:ok, :ok} == Task.yield(task, 10)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not allow max_concurrency + 1 processes to acquire (run)", %{pid: pid} do
|
||||||
|
processes =
|
||||||
|
(1..@max_concurrency)
|
||||||
|
|> Enum.map(fn _ -> run_and_wait_for_release(pid) end)
|
||||||
|
|
||||||
|
# This task should not be able to acquire
|
||||||
|
task = Task.async(fn -> SemaphoreServer.acquire(pid) end)
|
||||||
|
assert nil == Task.yield(task, 10)
|
||||||
|
|
||||||
|
# Release processes holding the semaphore
|
||||||
|
Enum.each(processes, &send(&1, :release))
|
||||||
|
|
||||||
|
# Now the task should be able to acquire
|
||||||
|
assert {:ok, :ok} == Task.yield(task, 10)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not allow re-acquire from the same process", %{pid: pid} do
|
||||||
|
acquire = fn ->
|
||||||
|
try do
|
||||||
|
{:ok, SemaphoreServer.acquire(pid)}
|
||||||
|
rescue
|
||||||
|
err -> {:error, err}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
task = Task.async(fn ->
|
||||||
|
acquire.()
|
||||||
|
acquire.()
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:ok, {:error, %MatchError{}}} = Task.yield(task)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows re-release from the same process", %{pid: pid} do
|
||||||
|
release = fn ->
|
||||||
|
try do
|
||||||
|
{:ok, SemaphoreServer.release(pid)}
|
||||||
|
rescue
|
||||||
|
err -> {:error, err}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
task = Task.async(fn ->
|
||||||
|
release.()
|
||||||
|
release.()
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:ok, {:ok, :ok}} = Task.yield(task)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run_and_wait_for_release(pid) do
|
||||||
|
spawn(fn ->
|
||||||
|
SemaphoreServer.run(pid, fn ->
|
||||||
|
wait_for_release()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp acquire_and_wait_for_release(pid) do
|
||||||
|
spawn(fn ->
|
||||||
|
SemaphoreServer.acquire(pid)
|
||||||
|
wait_for_release()
|
||||||
|
SemaphoreServer.release(pid)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp wait_for_release do
|
||||||
|
receive do
|
||||||
|
:release ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
wait_for_release()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in a new issue