stampeded 0.3.1-aarch64-linux-gnu
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +28 -0
- data/README.md +206 -0
- data/exe/stampeded +13 -0
- data/lib/active_job/queue_adapters/stampeded_adapter.rb +58 -0
- data/lib/generators/stampeded/install/install_generator.rb +47 -0
- data/lib/generators/stampeded/install/templates/stampeded.rb +13 -0
- data/lib/stampeded/active_job_ext.rb +89 -0
- data/lib/stampeded/active_job_worker.rb +28 -0
- data/lib/stampeded/client.rb +47 -0
- data/lib/stampeded/configuration.rb +41 -0
- data/lib/stampeded/connection.rb +82 -0
- data/lib/stampeded/continuable_ext.rb +40 -0
- data/lib/stampeded/current.rb +19 -0
- data/lib/stampeded/daemon.rb +51 -0
- data/lib/stampeded/envelope.rb +86 -0
- data/lib/stampeded/errors.rb +68 -0
- data/lib/stampeded/job.rb +85 -0
- data/lib/stampeded/job_context.rb +49 -0
- data/lib/stampeded/lifecycle.rb +30 -0
- data/lib/stampeded/native.rb +233 -0
- data/lib/stampeded/queue_policy.rb +39 -0
- data/lib/stampeded/railtie.rb +23 -0
- data/lib/stampeded/schedule.rb +102 -0
- data/lib/stampeded/schema.rb +68 -0
- data/lib/stampeded/steps.rb +94 -0
- data/lib/stampeded/tasks/stampeded.rake +32 -0
- data/lib/stampeded/version.rb +5 -0
- data/lib/stampeded/worker.rb +224 -0
- data/lib/stampeded.rb +72 -0
- data/libexec/libstampeded_ffi.so +0 -0
- data/libexec/stampeded +0 -0
- data/schema/schema.sql +1049 -0
- metadata +110 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stampeded
|
|
4
|
+
module QueuePolicy
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def set(queue, max_running: nil, max_wait: nil)
|
|
8
|
+
raise Error, "a queue policy needs a queue name" if queue.to_s.strip.empty?
|
|
9
|
+
|
|
10
|
+
Connection.translating_errors do
|
|
11
|
+
Connection.with do |c|
|
|
12
|
+
c.exec_params(<<~SQL, [queue.to_s, max_running&.to_i, max_wait&.to_i])
|
|
13
|
+
INSERT INTO stampeded_queue_policies (queue, max_running, max_wait_secs)
|
|
14
|
+
VALUES ($1, $2, $3)
|
|
15
|
+
ON CONFLICT (queue) DO UPDATE SET
|
|
16
|
+
max_running = EXCLUDED.max_running, max_wait_secs = EXCLUDED.max_wait_secs,
|
|
17
|
+
updated_at = now()
|
|
18
|
+
SQL
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
queue.to_s
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def remove(queue)
|
|
25
|
+
Connection.with { |c| c.exec_params("DELETE FROM stampeded_queue_policies WHERE queue = $1", [queue.to_s]) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def all
|
|
29
|
+
Connection.translating_errors do
|
|
30
|
+
Connection.with do |c|
|
|
31
|
+
c.exec(<<~SQL).to_a
|
|
32
|
+
SELECT queue, max_running, max_wait_secs, verdict, reason, decided_at
|
|
33
|
+
FROM stampeded_queue_policies ORDER BY queue
|
|
34
|
+
SQL
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Stampeded
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
rake_tasks do
|
|
8
|
+
load File.expand_path("tasks/stampeded.rake", __dir__)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "stampeded.active_job", before: "active_job.set_configs" do
|
|
12
|
+
ActiveSupport.on_load(:active_job) do
|
|
13
|
+
require "active_job/queue_adapters/stampeded_adapter"
|
|
14
|
+
require "stampeded/active_job_ext"
|
|
15
|
+
require "stampeded/active_job_worker"
|
|
16
|
+
include Stampeded::ActiveJobExt
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
initializer "stampeded.config" do
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Stampeded
|
|
6
|
+
module Schedule
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def recurring(key:, job:, cron:, args: [], queue: nil, timezone: "UTC",
|
|
10
|
+
dst_policy: nil, priority: nil)
|
|
11
|
+
raise Error, "a schedule needs a key" if key.to_s.strip.empty?
|
|
12
|
+
|
|
13
|
+
suggested = check_cron!(cron, timezone)
|
|
14
|
+
dst_policy ||= suggested
|
|
15
|
+
job_class = job.is_a?(Class) ? job : job.class
|
|
16
|
+
queue ||= resolve_queue_name(job_class) || Stampeded.config.default_queue
|
|
17
|
+
|
|
18
|
+
task, standard_args, meta = envelope(job_class, args, queue, priority)
|
|
19
|
+
|
|
20
|
+
Connection.with do |c|
|
|
21
|
+
c.exec_params(<<~SQL, [key, queue, task, JSON.generate(standard_args), JSON.generate(meta), cron, timezone, dst_policy, priority])
|
|
22
|
+
INSERT INTO stampeded_schedules
|
|
23
|
+
(key, queue, task, args, meta, cron_expr, timezone, dst_policy, priority, next_run_at)
|
|
24
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7,
|
|
25
|
+
$8, coalesce($9, 0), now())
|
|
26
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
27
|
+
queue = EXCLUDED.queue, task = EXCLUDED.task, args = EXCLUDED.args, meta = EXCLUDED.meta,
|
|
28
|
+
cron_expr = EXCLUDED.cron_expr, interval_secs = NULL,
|
|
29
|
+
timezone = EXCLUDED.timezone, dst_policy = EXCLUDED.dst_policy,
|
|
30
|
+
priority = EXCLUDED.priority, updated_at = now()
|
|
31
|
+
SQL
|
|
32
|
+
end
|
|
33
|
+
key
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def remove(key)
|
|
37
|
+
Connection.with { |c| c.exec_params("DELETE FROM stampeded_schedules WHERE key = $1", [key]) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def pause(key, paused: true)
|
|
41
|
+
Connection.with do |c|
|
|
42
|
+
c.exec_params("UPDATE stampeded_schedules SET paused = $2, updated_at = now() WHERE key = $1",
|
|
43
|
+
[key, paused])
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def all
|
|
48
|
+
Connection.with do |c|
|
|
49
|
+
c.exec("SELECT key, queue, task, cron_expr, timezone, dst_policy, paused, next_run_at, last_run_at
|
|
50
|
+
FROM stampeded_schedules ORDER BY next_run_at").to_a
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def check_cron!(expr, timezone)
|
|
55
|
+
require "stampeded/native"
|
|
56
|
+
Native.cron_check(expr, timezone)
|
|
57
|
+
rescue Error => e
|
|
58
|
+
raise Error, e.message unless e.message.include?("cron")
|
|
59
|
+
|
|
60
|
+
raise Error, <<~MSG
|
|
61
|
+
#{expr.inspect}: #{e.message}
|
|
62
|
+
|
|
63
|
+
Five fields are required: minute hour day-of-month month day-of-week
|
|
64
|
+
|
|
65
|
+
"0 3 * * *" every day at 03:00
|
|
66
|
+
"*/15 * * * *" every fifteen minutes
|
|
67
|
+
"0 9-17 * * mon-fri"
|
|
68
|
+
MSG
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def resolve_queue_name(job_class)
|
|
72
|
+
return nil unless job_class.respond_to?(:queue_name)
|
|
73
|
+
|
|
74
|
+
name = job_class.queue_name
|
|
75
|
+
name = job_class.new.queue_name if name.respond_to?(:call)
|
|
76
|
+
name&.to_s
|
|
77
|
+
rescue StandardError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def envelope(job_class, args, queue, priority)
|
|
82
|
+
task = job_class.try(:stampeded_task)
|
|
83
|
+
context = {
|
|
84
|
+
"job_class" => job_class.name,
|
|
85
|
+
"queue_name" => queue,
|
|
86
|
+
"priority" => priority,
|
|
87
|
+
"locale" => defined?(I18n) ? I18n.locale.to_s : "en",
|
|
88
|
+
"timezone" => defined?(Time.zone) && Time.zone ? Time.zone.name : "UTC"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
kwargs = args.is_a?(Hash) ? args : ({} if Array(args).empty?)
|
|
92
|
+
if kwargs && Envelope.plain?(kwargs)
|
|
93
|
+
[task || job_class.name, JSON.parse(JSON.generate(kwargs)), { "activejob" => context }]
|
|
94
|
+
elsif task
|
|
95
|
+
raise PortableArgumentsError.new(job_class.name, task)
|
|
96
|
+
else
|
|
97
|
+
context["arguments"] = ActiveJob::Arguments.serialize(Array(args))
|
|
98
|
+
[job_class.name, {}, { "activejob" => context }]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stampeded
|
|
4
|
+
module Schema
|
|
5
|
+
FILES = %w[schema.sql].freeze
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def root
|
|
10
|
+
candidates = [
|
|
11
|
+
File.expand_path("../../schema", __dir__),
|
|
12
|
+
File.expand_path("../../../schema", __dir__)
|
|
13
|
+
]
|
|
14
|
+
candidates.find { |d| File.directory?(d) } ||
|
|
15
|
+
raise(Error, "cannot find schema/*.sql (looked in #{candidates.join(", ")})")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def sql
|
|
19
|
+
FILES.map { |f| File.read(File.join(root, f)) }.join("\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def install!(conn = nil)
|
|
23
|
+
run = lambda do |c|
|
|
24
|
+
c.exec("BEGIN")
|
|
25
|
+
begin
|
|
26
|
+
c.exec(sql)
|
|
27
|
+
c.exec("COMMIT")
|
|
28
|
+
rescue StandardError
|
|
29
|
+
c.exec("ROLLBACK")
|
|
30
|
+
raise
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
conn ? run.call(conn) : Connection.with(&run)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def drop!(conn = nil)
|
|
38
|
+
run = lambda do |c|
|
|
39
|
+
c.exec(<<~SQL)
|
|
40
|
+
DROP TABLE IF EXISTS stampeded_cron_occurrences, stampeded_schedules,
|
|
41
|
+
stampeded_queue_policies, stampeded_queue_counts,
|
|
42
|
+
stampeded_jobs, stampeded_workers CASCADE;
|
|
43
|
+
DO $$
|
|
44
|
+
DECLARE r record;
|
|
45
|
+
BEGIN
|
|
46
|
+
FOR r IN
|
|
47
|
+
SELECT p.oid::regprocedure AS sig
|
|
48
|
+
FROM pg_proc p
|
|
49
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
50
|
+
WHERE p.proname LIKE 'stampeded\_%'
|
|
51
|
+
AND n.nspname = current_schema()
|
|
52
|
+
LOOP
|
|
53
|
+
EXECUTE 'DROP FUNCTION IF EXISTS ' || r.sig || ' CASCADE';
|
|
54
|
+
END LOOP;
|
|
55
|
+
END $$;
|
|
56
|
+
SQL
|
|
57
|
+
end
|
|
58
|
+
conn ? run.call(conn) : Connection.with(&run)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def installed?(conn = nil)
|
|
62
|
+
check = lambda do |c|
|
|
63
|
+
Stampeded.truthy?(c.exec("SELECT to_regclass('stampeded_jobs') IS NOT NULL AS ok")[0]["ok"])
|
|
64
|
+
end
|
|
65
|
+
conn ? check.call(conn) : Connection.with(&check)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module Stampeded
|
|
7
|
+
class Steps
|
|
8
|
+
class DuplicateStep < Error; end
|
|
9
|
+
class ResultTooLarge < Error; end
|
|
10
|
+
|
|
11
|
+
def initialize(context)
|
|
12
|
+
require "stampeded/native"
|
|
13
|
+
@ctx = context
|
|
14
|
+
@record = Native::Steps.new(context.checkpoint)
|
|
15
|
+
@seen = Set.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def step(name, cursor: nil, &block)
|
|
19
|
+
name = name.to_s
|
|
20
|
+
raise DuplicateStep, "step #{name.inspect} is declared twice in this job" unless @seen.add?(name)
|
|
21
|
+
return @record.value(name) if @record.completed?(name)
|
|
22
|
+
|
|
23
|
+
result = cursor.nil? ? block.call : run_cursor_step(name, cursor, &block)
|
|
24
|
+
complete(name, result)
|
|
25
|
+
persist!
|
|
26
|
+
result
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def completed?(name) = @record.completed?(name)
|
|
30
|
+
|
|
31
|
+
def completed_steps = @record.to_h["completed"]
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def run_cursor_step(name, initial, &block)
|
|
36
|
+
saved = @record.cursor(name)
|
|
37
|
+
block.call(Cursor.new(saved.nil? ? initial : saved, self, name))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def advance(name, value)
|
|
41
|
+
@record.set_cursor(name, JSON.generate(value))
|
|
42
|
+
persist!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def complete(name, result)
|
|
46
|
+
json =
|
|
47
|
+
begin
|
|
48
|
+
result.nil? ? nil : JSON.generate(result)
|
|
49
|
+
rescue JSON::GeneratorError => e
|
|
50
|
+
raise ResultTooLarge, "step #{name.inspect} returned something not JSON-serialisable: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
@record.complete(name, json)
|
|
53
|
+
rescue ResultTooLarge
|
|
54
|
+
raise
|
|
55
|
+
rescue Error => e
|
|
56
|
+
raise ResultTooLarge, e.message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def persist! = @ctx.checkpoint!(@record.to_h)
|
|
60
|
+
|
|
61
|
+
class Cursor
|
|
62
|
+
attr_reader :value
|
|
63
|
+
|
|
64
|
+
alias cursor value
|
|
65
|
+
|
|
66
|
+
def initialize(value, steps, name)
|
|
67
|
+
@value = value
|
|
68
|
+
@steps = steps
|
|
69
|
+
@name = name
|
|
70
|
+
@since_write = 0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def advance!(value, every: 1)
|
|
74
|
+
@value = value
|
|
75
|
+
@since_write += 1
|
|
76
|
+
return value if every > 1 && @since_write < every
|
|
77
|
+
|
|
78
|
+
@since_write = 0
|
|
79
|
+
@steps.send(:advance, @name, value)
|
|
80
|
+
value
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def flush!
|
|
84
|
+
return @value if @since_write.zero?
|
|
85
|
+
|
|
86
|
+
@since_write = 0
|
|
87
|
+
@steps.send(:advance, @name, @value)
|
|
88
|
+
@value
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def to_s = @value.to_s
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :stampeded do
|
|
4
|
+
desc "Install the Stampeded schema (Postgres 18+)"
|
|
5
|
+
task install: :environment do
|
|
6
|
+
require "stampeded"
|
|
7
|
+
Stampeded::Schema.install!
|
|
8
|
+
puts "schema installed"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
desc "Run an ActiveJob worker (QUEUES=default,mail or QUEUES=* QUEUES_EXCEPT=dlq; CONCURRENCY=5)"
|
|
12
|
+
task work: :environment do
|
|
13
|
+
require "stampeded/active_job_worker"
|
|
14
|
+
Rails.application.eager_load! unless Rails.application.config.eager_load
|
|
15
|
+
|
|
16
|
+
worker = Stampeded::ActiveJobWorker.new(
|
|
17
|
+
queues: ENV["QUEUES"] || ENV.fetch("QUEUE", nil),
|
|
18
|
+
except: ENV.fetch("QUEUES_EXCEPT", nil),
|
|
19
|
+
concurrency: ENV.fetch("CONCURRENCY", Stampeded.config.concurrency).to_i
|
|
20
|
+
)
|
|
21
|
+
puts "stampeded worker: queues=#{worker.all_queues? ? "*" : worker.queues.join(",")} pid=#{Process.pid}"
|
|
22
|
+
worker.run
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "Run the daemon (scheduler, reaper, pruner, dashboard). Not the worker."
|
|
26
|
+
task daemon: :environment do
|
|
27
|
+
require "stampeded/daemon"
|
|
28
|
+
args = ["--database-url", Stampeded.database_url_for_daemon, "--no-worker"]
|
|
29
|
+
args += ENV["STAMPEDED_ARGS"].to_s.split
|
|
30
|
+
Stampeded::Daemon.exec!(*args)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module Stampeded
|
|
6
|
+
class Worker
|
|
7
|
+
attr_reader :queues, :handlers
|
|
8
|
+
|
|
9
|
+
def initialize(queue: nil, queues: nil, except: nil, concurrency: nil, batch_size: nil, poll_interval: nil)
|
|
10
|
+
requested = queues || queue || Stampeded.config.queues || Stampeded.config.default_queue
|
|
11
|
+
@all_queues = wildcard?(requested)
|
|
12
|
+
raise ArgumentError, "except: applies only to queues: :all" if except && !@all_queues
|
|
13
|
+
|
|
14
|
+
@except = @all_queues ? Array(except || Stampeded.config.except_queues).flat_map { it.to_s.split(",") }.map(&:strip) : []
|
|
15
|
+
@queues = @all_queues ? [] : queue_list!(requested)
|
|
16
|
+
@concurrency = positive!(concurrency || Stampeded.config.concurrency, "concurrency")
|
|
17
|
+
@batch_size = positive!(batch_size || Stampeded.config.batch_size, "batch_size")
|
|
18
|
+
@poll_interval = poll_interval || Stampeded.config.poll_interval
|
|
19
|
+
@handlers = {}
|
|
20
|
+
@stopping = false
|
|
21
|
+
@wake_reader, @wake_writer = IO.pipe
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def queue = @queues.first
|
|
25
|
+
|
|
26
|
+
def all_queues? = @all_queues
|
|
27
|
+
def register(task, &block)
|
|
28
|
+
@handlers[task.to_s] = block
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def stop
|
|
33
|
+
@stopping = true
|
|
34
|
+
Stampeded.stopping = true
|
|
35
|
+
@wake_writer.write_nonblock(".", exception: false)
|
|
36
|
+
rescue IOError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stopping? = @stopping
|
|
41
|
+
def run
|
|
42
|
+
require "stampeded/native"
|
|
43
|
+
Native.log_init(library_log_level)
|
|
44
|
+
Stampeded.stopping = false
|
|
45
|
+
|
|
46
|
+
puller = Connection.translating_errors { start_puller }
|
|
47
|
+
log "registered #{puller.worker_id} on #{description}"
|
|
48
|
+
|
|
49
|
+
@threads = Array.new(@concurrency) { worker_thread(puller) }
|
|
50
|
+
install_signal_handlers
|
|
51
|
+
@wake_reader.wait_readable until stopping?
|
|
52
|
+
|
|
53
|
+
deadline = now_monotonic + Stampeded.config.shutdown_timeout
|
|
54
|
+
puller.stop(Stampeded.config.shutdown_timeout)
|
|
55
|
+
stopped = true
|
|
56
|
+
@threads.each { |t| t.join([deadline - now_monotonic, 0].max) }
|
|
57
|
+
|
|
58
|
+
stragglers = @threads.count(&:alive?)
|
|
59
|
+
if stragglers.positive?
|
|
60
|
+
log "#{stragglers} job(s) did not finish in #{Stampeded.config.shutdown_timeout}s; " \
|
|
61
|
+
"the reaper will reclaim them"
|
|
62
|
+
end
|
|
63
|
+
log "stopped"
|
|
64
|
+
ensure
|
|
65
|
+
@stopping = true
|
|
66
|
+
begin
|
|
67
|
+
puller.stop(0) if puller && !stopped
|
|
68
|
+
rescue Error => e
|
|
69
|
+
log "could not stop: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
@threads&.each { |t| t.join(@poll_interval * 2) }
|
|
72
|
+
puller&.close if @threads.nil? || @threads.none?(&:alive?)
|
|
73
|
+
@wake_reader.close
|
|
74
|
+
@wake_writer.close
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def start_puller
|
|
80
|
+
Native::Puller.new(
|
|
81
|
+
url: Connection.url,
|
|
82
|
+
queues: @all_queues ? "*" : @queues.join(","),
|
|
83
|
+
except: @except.any? ? @except.join(",") : nil,
|
|
84
|
+
max_leases: @concurrency,
|
|
85
|
+
batch_size: @batch_size,
|
|
86
|
+
poll_interval: @poll_interval,
|
|
87
|
+
heartbeat_interval: Stampeded.config.heartbeat_interval
|
|
88
|
+
)
|
|
89
|
+
rescue Error => e
|
|
90
|
+
raise SchemaMissing, e if e.message.include?("stampeded_") && e.message.include?("does not exist")
|
|
91
|
+
|
|
92
|
+
raise
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def description
|
|
96
|
+
return @queues.join(", ") unless @all_queues
|
|
97
|
+
|
|
98
|
+
"every queue#{" except #{@except.join(", ")}" if @except.any?}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def worker_thread(puller)
|
|
102
|
+
Thread.new do
|
|
103
|
+
until stopping?
|
|
104
|
+
lease =
|
|
105
|
+
begin
|
|
106
|
+
puller.next(@poll_interval)
|
|
107
|
+
rescue Error => e
|
|
108
|
+
log "claim failed: #{e.message}"
|
|
109
|
+
sleep @poll_interval
|
|
110
|
+
next
|
|
111
|
+
end
|
|
112
|
+
execute(lease, puller.worker_id) if lease
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def execute(lease, worker_id)
|
|
118
|
+
job = Job.new(lease.row, decoded: true)
|
|
119
|
+
started = now_monotonic
|
|
120
|
+
error = nil
|
|
121
|
+
|
|
122
|
+
begin
|
|
123
|
+
handler = @handlers[job.task]
|
|
124
|
+
raise UnknownTask, "no handler registered for task #{job.task.inspect}" if handler.nil?
|
|
125
|
+
|
|
126
|
+
with_job_timeout(job) { handler.call(JobContext.new(job, worker_id)) }
|
|
127
|
+
rescue Exception => e
|
|
128
|
+
error = e
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
report(lease, error)
|
|
132
|
+
log format("%s %s in %.3fs", job.task, job.id, now_monotonic - started)
|
|
133
|
+
raise error if error.is_a?(SignalException) || error.is_a?(SystemExit)
|
|
134
|
+
ensure
|
|
135
|
+
lease.release!
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def report(lease, error)
|
|
139
|
+
return lease.requeue! if error.is_a?(Requeue)
|
|
140
|
+
|
|
141
|
+
message = error && error.message.to_s[0, 4000]
|
|
142
|
+
case error && failure_kind_for(error)
|
|
143
|
+
when nil then lease.complete!
|
|
144
|
+
when "unrecoverable" then lease.discard!(message)
|
|
145
|
+
when "timed_out" then lease.timed_out!(message)
|
|
146
|
+
else lease.fail!(message, retry_in_ms: error.is_a?(RetryIn) ? error.delay_ms : nil)
|
|
147
|
+
end
|
|
148
|
+
rescue Error => e
|
|
149
|
+
log "could not report #{lease.row["id"]}: #{e.message}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def with_job_timeout(job, &block)
|
|
153
|
+
limit = Stampeded.config.job_timeout
|
|
154
|
+
return yield if limit.nil? || limit <= 0
|
|
155
|
+
|
|
156
|
+
expire = (job.row["expire_seconds"] || 3600).to_i
|
|
157
|
+
if limit >= expire
|
|
158
|
+
log "job_timeout (#{limit}s) is not shorter than #{job.task}'s expire_seconds (#{expire}s): it may run twice"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
Timeout.timeout(limit, Timeout::Error, "job exceeded job_timeout of #{limit}s", &block)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def failure_kind_for(error)
|
|
165
|
+
case error
|
|
166
|
+
when UnknownTask then "unrecoverable"
|
|
167
|
+
when Timeout::Error then "timed_out"
|
|
168
|
+
else "raised"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def wildcard?(value)
|
|
173
|
+
return true if value == :all || value.to_s.strip == "*"
|
|
174
|
+
|
|
175
|
+
names = value.is_a?(Array) ? value.map(&:to_s) : value.to_s.split(",")
|
|
176
|
+
raise ArgumentError, "\"*\" cannot be mixed with queue names; use except: to leave queues out" if names.map(&:strip).include?("*")
|
|
177
|
+
|
|
178
|
+
false
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def queue_list!(value)
|
|
182
|
+
names = value.is_a?(Array) ? value.map(&:to_s) : value.to_s.split(",", -1)
|
|
183
|
+
names = names.map(&:strip)
|
|
184
|
+
raise ArgumentError, "queues must name at least one queue, got #{value.inspect}" if names.empty?
|
|
185
|
+
raise ArgumentError, "queues has a blank entry: #{value.inspect}" if names.any?(&:empty?)
|
|
186
|
+
|
|
187
|
+
names.uniq
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def positive!(value, name)
|
|
191
|
+
n = value.to_i
|
|
192
|
+
raise ArgumentError, "#{name} must be at least 1, got #{value.inspect}" if n < 1
|
|
193
|
+
|
|
194
|
+
n
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def install_signal_handlers
|
|
198
|
+
%w[INT TERM].each do |sig|
|
|
199
|
+
trap(sig) { stop }
|
|
200
|
+
rescue ArgumentError
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def now_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
206
|
+
|
|
207
|
+
def log? = $stdout.tty? || ENV.key?("STAMPEDED_LOG")
|
|
208
|
+
|
|
209
|
+
LOG_LEVELS = %w[trace debug info warn error].freeze
|
|
210
|
+
|
|
211
|
+
def library_log_level
|
|
212
|
+
level = ENV.fetch("STAMPEDED_LOG", nil)&.downcase
|
|
213
|
+
return level if LOG_LEVELS.include?(level)
|
|
214
|
+
|
|
215
|
+
log? ? "info" : "warn"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def log(message)
|
|
219
|
+
return unless log?
|
|
220
|
+
|
|
221
|
+
warn "[stampeded] #{message}"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
data/lib/stampeded.rb
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "stampeded/version"
|
|
4
|
+
require_relative "stampeded/errors"
|
|
5
|
+
require_relative "stampeded/configuration"
|
|
6
|
+
require_relative "stampeded/connection"
|
|
7
|
+
require "forwardable"
|
|
8
|
+
require_relative "stampeded/lifecycle"
|
|
9
|
+
require_relative "stampeded/job"
|
|
10
|
+
require_relative "stampeded/job_context"
|
|
11
|
+
require_relative "stampeded/current"
|
|
12
|
+
require_relative "stampeded/steps"
|
|
13
|
+
require_relative "stampeded/client"
|
|
14
|
+
require_relative "stampeded/envelope"
|
|
15
|
+
require_relative "stampeded/schema"
|
|
16
|
+
require_relative "stampeded/schedule"
|
|
17
|
+
require_relative "stampeded/queue_policy"
|
|
18
|
+
require_relative "stampeded/worker"
|
|
19
|
+
require_relative "stampeded/daemon"
|
|
20
|
+
|
|
21
|
+
module Stampeded
|
|
22
|
+
autoload :ActiveJobWorker, File.expand_path("stampeded/active_job_worker", __dir__)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
require_relative "stampeded/railtie" if defined?(Rails::Railtie)
|
|
26
|
+
|
|
27
|
+
module Stampeded
|
|
28
|
+
def self.truthy?(value)
|
|
29
|
+
value == true || value == "t" || value == "true"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
def config
|
|
34
|
+
@config ||= Configuration.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def configure
|
|
38
|
+
yield config
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
attr_writer :stopping
|
|
42
|
+
|
|
43
|
+
def stopping? = @stopping == true
|
|
44
|
+
|
|
45
|
+
def enqueue(queue:, task:, **opts)
|
|
46
|
+
Client.enqueue(queue: queue, task: task, **opts)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def database_url_for_daemon
|
|
50
|
+
params = Connection.connection_params
|
|
51
|
+
return params if params.is_a?(String)
|
|
52
|
+
|
|
53
|
+
user = [params[:user], params[:password]].compact.join(":")
|
|
54
|
+
host = [params[:host], params[:port]].compact.join(":")
|
|
55
|
+
"postgres://#{user}#{"@" unless user.empty?}#{host}/#{params[:dbname]}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def create_queue(name)
|
|
59
|
+
Connection.with { |c| c.exec_params("SELECT stampeded_create_queue($1)", [name]) }
|
|
60
|
+
name
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def depth(queue)
|
|
64
|
+
Connection.with do |c|
|
|
65
|
+
c.exec_params(
|
|
66
|
+
"SELECT state, n FROM stampeded_queue_counts WHERE queue = $1 AND n <> 0 ORDER BY state",
|
|
67
|
+
[queue]
|
|
68
|
+
).each_with_object({}) { |r, h| h[r["state"]] = r["n"].to_i }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
Binary file
|
data/libexec/stampeded
ADDED
|
Binary file
|