Packages
honeydew
1.2.0
1.5.0
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.0
1.2.8
1.2.7
1.2.6
1.2.5
1.2.4
1.2.3
1.2.2
1.2.1
1.2.0
1.1.6
1.1.5
1.1.4
1.1.3
1.1.2
1.1.1
1.1.0
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
1.0.0-rc7
1.0.0-rc6
1.0.0-rc5
1.0.0-rc4
1.0.0-rc3
1.0.0-rc2
1.0.0-rc1
0.0.11
0.0.10
0.0.9
0.0.8
0.0.7
0.0.6
0.0.5
0.0.4
0.0.3
0.0.2
0.0.1
Pluggable local/clusterable job queue focused on safety.
Current section
Files
Jump to
Current section
Files
lib/honeydew/sources/ecto_source.ex
#
# The goal of this module is to lamprey a queue onto an existing ecto schema with as few requirements and as little
# disruption as possible. It adds two fields to the schema, a "lock" field and a "private" field. As we don't know
# which database we're running on, I've tried to keep it vanilla, no special db features are used.
#
# The lock field is an integer overloaded with three jobs:
# 1. Acts as a lock, to ensure that only one worker is processing the job at a time, no matter how many nodes are running
# copies of the queue. It expires after a configurable period of time (the queue process or entire node crashed).
# 2. Indicates the status of the job, it can be either:
# - "ready", between zero and the beginning of the stale window
# - "in progress", inside of the stale window
# - "abandoned", -1
# 3. Indicate the order in which jobs should be processed.
#
#
#
# unix epoch zero
# |<--------------------- ready --------------------------->|
# v |
# time <------0---------------|----------------------------|------------|-------|--->
# ^ ^ ^ ^ ^
# abandoned(-1) new jobs far_in_the_past stale now
# (now - far_in_the_past)
#
#
#
# The private field is a simple binary field that contains an erlang term, it's used for data that needs to be
# persisted between job attempts, specificaly, it's the "failure_private" contents of the job.
#
#
# As the main objective is to minimize disruption, I wanted the default values for the additional fields to be set
# statically in the migration, rather than possibly interfering with the user's schema validations on save etc...
# The only runtimes configuration the user should set is the `stale_timeout`, which should be the maximum expected
# time that a job will take.
#
#
# This module is tested and dialyzed via the included test project in examples/ecto_poll_queue
#
if Code.ensure_loaded?(Ecto) do
defmodule Honeydew.EctoSource do
require Logger
alias Honeydew.Job
alias Honeydew.PollQueue
alias Honeydew.EctoSource.State
@behaviour PollQueue
@abandoned -1
def abandoned, do: @abandoned
@impl true
def init(queue, args) do
schema = Keyword.fetch!(args, :schema)
repo = Keyword.fetch!(args, :repo)
sql = Keyword.fetch!(args, :sql)
stale_timeout = args[:stale_timeout] * 1_000
table = schema.__schema__(:source)
key_field = schema.__schema__(:primary_key) |> List.first()
task_fn =
schema.__info__(:functions)
|> Enum.member?({:honeydew_task, 2})
|> if do
&schema.honeydew_task/2
else
fn(id, _queue) -> {:run, [id]} end
end
{:ok, %State{schema: schema,
repo: repo,
sql: sql,
table: table,
key_field: key_field,
lock_field: field_name(queue, :lock),
private_field: field_name(queue, :private),
task_fn: task_fn,
queue: queue,
stale_timeout: stale_timeout}}
end
# lock a row for processing
@impl true
def reserve(%State{queue: queue, schema: schema, repo: repo, sql: sql, key_field: key_field, private_field: private_field, task_fn: task_fn} = state) do
try do
state
|> sql.reserve
|> repo.query([])
rescue e in DBConnection.ConnectionError ->
{:error, e}
end
|> case do
{:ok, %{num_rows: 1, rows: [[id, private]]}} ->
# convert key and private_field from db representation to schema's type
%^schema{^key_field => id, ^private_field => private} =
repo.load(schema, %{key_field => id, private_field => private})
job =
id
|> task_fn.(queue)
|> Job.new(queue)
|> struct(failure_private: private)
{{:value, {id, job}}, state}
{:ok, %{num_rows: 0}} ->
{:empty, state}
{:error, error} ->
Logger.warn("[Honeydew] Ecto queue '#{inspect queue}' couldn't poll for jobs because #{inspect error}")
{:empty, state}
end
end
@impl true
# acked without completing, either moved or abandoned
def ack(%Job{private: id, completed_at: nil}, state) do
finalize(id, @abandoned, nil, state)
end
@impl true
def ack(%Job{private: id}, state) do
finalize(id, nil, nil, state)
end
@impl true
def nack(%Job{private: id, failure_private: private}, state) do
finalize(id, 1, private, state)
end
@impl true
def cancel(%Job{private: id}, %State{schema: schema, repo: repo, sql: sql, key_field: key_field} = state) do
{:ok, id} = dump_field(schema, repo, key_field, id)
state
|> sql.cancel
|> repo.query([id])
|> case do
{:ok, %{num_rows: 1}} ->
{:ok, state}
{:ok, %{num_rows: 0}} ->
{{:error, :not_found}, state}
end
end
@impl true
def status(%State{repo: repo, sql: sql} = state) do
{:ok, %{num_rows: 1, rows: [[count, in_progress, abandoned]]}} =
state
|> sql.status
|> repo.query([])
%{count: count, in_progress: in_progress, abandoned: abandoned}
end
@impl true
def filter(%State{repo: repo, schema: schema, sql: sql, queue: queue} = state, filter) do
{:ok, %{rows: rows}} =
state
|> sql.filter(filter)
|> repo.query([])
Enum.map(rows, fn [id] ->
# convert key from db representation to schema's type
%^schema{id: id} = repo.load(schema, %{id: id})
%Job{queue: queue, private: id}
end)
end
@impl true
def handle_info(msg, queue_state) do
Logger.warn("[Honeydew] Queue #{inspect(self())} received unexpected message #{inspect(msg)}")
{:noreply, queue_state}
end
def field_name(queue, name) do
:"honeydew_#{Honeydew.table_name(queue)}_#{name}"
end
defp finalize(id, lock, private, state) do
import Ecto.Query
from(s in state.schema,
where: field(s, ^state.key_field) == ^id,
update: [set: ^[{state.lock_field, lock}, {state.private_field, private}]])
|> state.repo.update_all([]) # avoids touching auto-generated fields
state
end
defp dump_field(schema, repo, field, value) do
type = schema.__schema__(:type, field)
Ecto.Type.adapter_dump(repo.__adapter__(), type, value)
end
end
end