feat(jobs): add Prism and Jobs
This commit is contained in:
1 parent
de19b8fabf
commit
8181d227e1
2 files changed
+201
No files matched your search
@@ -0,0 +1,44 @@
|
|||||||
|
module Prism
|
||||||
|
module Jobs
|
||||||
|
# Base class for background jobs, in the spirit of ActiveJob: define
|
||||||
|
# #perform, optionally declare a queue/retry policy, then either run it
|
||||||
|
# inline with .perform_now or hand it to a Queue with .perform_later.
|
||||||
|
class Job
|
||||||
|
class << self
|
||||||
|
attr_reader :queue_name_value, :max_attempts_value, :backoff_value
|
||||||
|
|
||||||
|
def queue_as(name)
|
||||||
|
@queue_name_value = name.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def retry_on(max_attempts: 5, backoff: :exponential, base_delay: 1)
|
||||||
|
@max_attempts_value = max_attempts
|
||||||
|
@backoff_value = { strategy: backoff, base_delay: base_delay }
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_later(*args, queue: nil, run_at: nil)
|
||||||
|
default_queue.enqueue(
|
||||||
|
job_class: name,
|
||||||
|
args: args,
|
||||||
|
queue_name: (queue || queue_name_value || 'default').to_s,
|
||||||
|
max_attempts: max_attempts_value || 5,
|
||||||
|
backoff: backoff_value || { strategy: :exponential, base_delay: 1 },
|
||||||
|
run_at: run_at
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_now(*args)
|
||||||
|
new.perform(*args)
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_queue
|
||||||
|
Jobs.default_queue
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform(*args)
|
||||||
|
raise NotImplementedError, "#{self.class} must implement #perform"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
require 'pstore'
|
||||||
|
require 'fileutils'
|
||||||
|
|
||||||
|
module Prism
|
||||||
|
module Jobs
|
||||||
|
# Persisted, PStore-backed job queue: enqueue/dequeue with per-job
|
||||||
|
# scheduling (run_at), retry bookkeeping (attempts/backoff) and a
|
||||||
|
# dead-letter table for jobs that exhausted their retries. Survives
|
||||||
|
# process restarts since everything is written to disk on every change.
|
||||||
|
class Queue
|
||||||
|
DEFAULT_BACKOFF = { strategy: :exponential, base_delay: 1 }.freeze
|
||||||
|
|
||||||
|
def initialize(path)
|
||||||
|
@path = path
|
||||||
|
directory = File.dirname(path)
|
||||||
|
FileUtils.mkdir_p(directory) unless directory == '.' || Dir.exist?(directory)
|
||||||
|
@pstore = PStore.new(path)
|
||||||
|
@pstore.transaction do
|
||||||
|
@pstore[:jobs] ||= {}
|
||||||
|
@pstore[:dead_letter] ||= {}
|
||||||
|
@pstore[:next_id] ||= 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def enqueue(job_class:, args: [], queue_name: 'default', max_attempts: 5, backoff: DEFAULT_BACKOFF, run_at: nil)
|
||||||
|
@pstore.transaction do
|
||||||
|
id = @pstore[:next_id]
|
||||||
|
@pstore[:next_id] += 1
|
||||||
|
@pstore[:jobs][id] = {
|
||||||
|
id: id,
|
||||||
|
job_class: job_class.to_s,
|
||||||
|
args: args,
|
||||||
|
queue_name: queue_name.to_s,
|
||||||
|
status: 'pending',
|
||||||
|
attempts: 0,
|
||||||
|
max_attempts: max_attempts,
|
||||||
|
backoff: backoff,
|
||||||
|
run_at: run_at || Time.now,
|
||||||
|
last_error: nil,
|
||||||
|
enqueued_at: Time.now
|
||||||
|
}
|
||||||
|
id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns the earliest pending job whose run_at has arrived, or nil.
|
||||||
|
# Does not mark it processing - callers should call #mark_processing
|
||||||
|
# before performing it, so a crash mid-perform leaves a trace.
|
||||||
|
def next_ready(queue_name: nil)
|
||||||
|
@pstore.transaction(true) do
|
||||||
|
candidates = @pstore[:jobs].values.select do |job|
|
||||||
|
job[:status] == 'pending' && job[:run_at] <= Time.now &&
|
||||||
|
(queue_name.nil? || job[:queue_name] == queue_name.to_s)
|
||||||
|
end
|
||||||
|
candidates.min_by { |job| job[:run_at] }&.dup
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_processing(id)
|
||||||
|
@pstore.transaction { @pstore[:jobs][id][:status] = 'processing' }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Atomically finds the earliest ready job and flips it to 'processing'
|
||||||
|
# in a single write transaction (PStore takes an exclusive flock for
|
||||||
|
# the whole block), so two Worker processes/threads sharing the same
|
||||||
|
# queue file can never both pick up the same job - unlike calling
|
||||||
|
# #next_ready followed by #mark_processing as two separate
|
||||||
|
# transactions, which leaves a window where both readers still see
|
||||||
|
# 'pending' before either writes 'processing'.
|
||||||
|
def claim_next!(queue_name: nil)
|
||||||
|
@pstore.transaction do
|
||||||
|
candidates = @pstore[:jobs].values.select do |job|
|
||||||
|
job[:status] == 'pending' && job[:run_at] <= Time.now &&
|
||||||
|
(queue_name.nil? || job[:queue_name] == queue_name.to_s)
|
||||||
|
end
|
||||||
|
job = candidates.min_by { |job| job[:run_at] }
|
||||||
|
next nil unless job
|
||||||
|
job[:status] = 'processing'
|
||||||
|
job.dup
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_completed(id)
|
||||||
|
@pstore.transaction { @pstore[:jobs].delete(id) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Records a failed attempt. Reschedules with backoff if attempts
|
||||||
|
# remain, otherwise moves the job to the dead-letter table.
|
||||||
|
def mark_failed(id, error)
|
||||||
|
@pstore.transaction do
|
||||||
|
job = @pstore[:jobs][id]
|
||||||
|
next unless job
|
||||||
|
job[:attempts] += 1
|
||||||
|
job[:last_error] = error.to_s
|
||||||
|
if job[:attempts] >= job[:max_attempts]
|
||||||
|
job[:status] = 'dead'
|
||||||
|
@pstore[:dead_letter][id] = job
|
||||||
|
@pstore[:jobs].delete(id)
|
||||||
|
else
|
||||||
|
job[:status] = 'pending'
|
||||||
|
job[:run_at] = Time.now + backoff_delay(job[:backoff], job[:attempts])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def size(queue_name: nil)
|
||||||
|
@pstore.transaction(true) do
|
||||||
|
@pstore[:jobs].values.count { |job| queue_name.nil? || job[:queue_name] == queue_name.to_s }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def dead_letter_size
|
||||||
|
@pstore.transaction(true) { @pstore[:dead_letter].size }
|
||||||
|
end
|
||||||
|
|
||||||
|
def pending_jobs
|
||||||
|
@pstore.transaction(true) { @pstore[:jobs].values.map(&:dup) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def dead_jobs
|
||||||
|
@pstore.transaction(true) { @pstore[:dead_letter].values.map(&:dup) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Re-enqueues a dead-lettered job as a fresh pending job (attempts reset).
|
||||||
|
def retry_dead(id)
|
||||||
|
@pstore.transaction do
|
||||||
|
job = @pstore[:dead_letter].delete(id)
|
||||||
|
next nil unless job
|
||||||
|
job = job.dup
|
||||||
|
job[:status] = 'pending'
|
||||||
|
job[:attempts] = 0
|
||||||
|
job[:last_error] = nil
|
||||||
|
job[:run_at] = Time.now
|
||||||
|
@pstore[:jobs][id] = job
|
||||||
|
id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear!
|
||||||
|
@pstore.transaction do
|
||||||
|
@pstore[:jobs] = {}
|
||||||
|
@pstore[:dead_letter] = {}
|
||||||
|
@pstore[:next_id] = 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def backoff_delay(backoff, attempt)
|
||||||
|
base = backoff[:base_delay] || 1
|
||||||
|
case backoff[:strategy]
|
||||||
|
when :exponential then base * (2**(attempt - 1))
|
||||||
|
when :linear then base * attempt
|
||||||
|
else base
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in new issue
Block a user