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,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stampeded
|
|
4
|
+
module ContinuableExt
|
|
5
|
+
PROGRESS_EVERY = 1.0
|
|
6
|
+
|
|
7
|
+
def checkpoint!
|
|
8
|
+
stampeded_save_progress(force: queue_adapter.stopping?) if Current.context
|
|
9
|
+
super
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def resume_job(exception)
|
|
15
|
+
return super unless Current.context
|
|
16
|
+
|
|
17
|
+
stampeded_save_progress(force: true)
|
|
18
|
+
raise exception unless exception.is_a?(ActiveJob::Continuation::Interrupt)
|
|
19
|
+
|
|
20
|
+
if max_resumptions && resumptions >= max_resumptions
|
|
21
|
+
raise ActiveJob::Continuation::ResumeLimitError, "Job was resumed a maximum of #{max_resumptions} times"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
raise Requeue, exception.message
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def stampeded_save_progress(force:)
|
|
28
|
+
progress = continuation.to_h
|
|
29
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
30
|
+
fresh = progress["completed"] == @stampeded_saved_steps && now - (@stampeded_saved_at || -PROGRESS_EVERY) < PROGRESS_EVERY
|
|
31
|
+
return if fresh && !force
|
|
32
|
+
|
|
33
|
+
Current.context.checkpoint!("activejob" => { "continuation" => progress, "resumptions" => resumptions })
|
|
34
|
+
@stampeded_saved_steps = progress["completed"]
|
|
35
|
+
@stampeded_saved_at = now
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
ActiveSupport.on_load(:active_job_continuable) { prepend Stampeded::ContinuableExt }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stampeded
|
|
4
|
+
module Current
|
|
5
|
+
KEY = :__stampeded_job_context
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def context = Thread.current[KEY]
|
|
10
|
+
|
|
11
|
+
def with(context)
|
|
12
|
+
previous = Thread.current[KEY]
|
|
13
|
+
Thread.current[KEY] = context
|
|
14
|
+
yield
|
|
15
|
+
ensure
|
|
16
|
+
Thread.current[KEY] = previous
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stampeded
|
|
4
|
+
module Daemon
|
|
5
|
+
BINARY = "stampeded"
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def platform
|
|
10
|
+
%i[cpu os].map { |m| Gem::Platform.local.public_send(m) }.join("-")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def executable
|
|
14
|
+
if (dir = ENV.fetch("STAMPEDED_INSTALL_DIR", nil))
|
|
15
|
+
path = File.expand_path(File.join(dir, BINARY))
|
|
16
|
+
raise DaemonNotFound, "STAMPEDED_INSTALL_DIR is set to #{dir}, but #{path} is not executable" \
|
|
17
|
+
unless File.executable?(path)
|
|
18
|
+
|
|
19
|
+
return path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
path = bundled_path
|
|
23
|
+
return path if path && File.executable?(path)
|
|
24
|
+
|
|
25
|
+
raise DaemonNotFound, <<~MSG
|
|
26
|
+
Could not find the stampeded daemon: there is no build for #{platform}.
|
|
27
|
+
Build it and point at it:
|
|
28
|
+
|
|
29
|
+
cargo build --release --bin stampeded
|
|
30
|
+
export STAMPEDED_INSTALL_DIR=/path/to/stampeded/target/release
|
|
31
|
+
|
|
32
|
+
The daemon is OPTIONAL: without it jobs still run, with no scheduler,
|
|
33
|
+
reaper, pruner or dashboard.
|
|
34
|
+
MSG
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def bundled_path
|
|
38
|
+
File.expand_path(File.join("../..", "libexec", BINARY), __dir__)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def available?
|
|
42
|
+
!executable.nil?
|
|
43
|
+
rescue DaemonNotFound
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def exec!(*args)
|
|
48
|
+
Kernel.exec(executable, *args)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stampeded
|
|
4
|
+
module Envelope
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def tasks
|
|
8
|
+
@tasks ||= {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def register(task, job_class)
|
|
12
|
+
name = task.to_s
|
|
13
|
+
raise ArgumentError, "a task name cannot be blank" if name.strip.empty?
|
|
14
|
+
|
|
15
|
+
owner = tasks[name]
|
|
16
|
+
if owner && owner != job_class.name
|
|
17
|
+
raise ArgumentError, "task #{name.inspect} is already #{owner}'s; #{job_class.name} cannot claim it too"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
tasks[name] = job_class.name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def from_active_job(job)
|
|
24
|
+
task = job.class.try(:stampeded_task)
|
|
25
|
+
context = job.serialize.except("arguments", "provider_job_id")
|
|
26
|
+
kwargs = standard_arguments(job.arguments)
|
|
27
|
+
|
|
28
|
+
if kwargs
|
|
29
|
+
[task || job.class.name, kwargs, { "activejob" => context }]
|
|
30
|
+
elsif task
|
|
31
|
+
raise PortableArgumentsError.new(job.class.name, task)
|
|
32
|
+
else
|
|
33
|
+
context["arguments"] = ActiveJob::Arguments.serialize(job.arguments)
|
|
34
|
+
[job.class.name, {}, { "activejob" => context }]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_active_job_payload(id:, queue:, task:, args:, meta:, checkpoint: nil)
|
|
39
|
+
context = meta.fetch("activejob", {})
|
|
40
|
+
job_class = context["job_class"] || tasks[task] || task
|
|
41
|
+
|
|
42
|
+
arguments =
|
|
43
|
+
context.fetch("arguments") do
|
|
44
|
+
kwargs = args.transform_keys(&:to_sym)
|
|
45
|
+
kwargs.empty? ? [] : ActiveJob::Arguments.serialize([Hash.ruby2_keywords_hash(kwargs)])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
context.merge(
|
|
49
|
+
"job_class" => job_class,
|
|
50
|
+
"job_id" => context["job_id"] || id,
|
|
51
|
+
"queue_name" => queue,
|
|
52
|
+
"arguments" => arguments,
|
|
53
|
+
"executions" => context["executions"] || 0,
|
|
54
|
+
"exception_executions" => context["exception_executions"] || {},
|
|
55
|
+
"enqueued_at" => context["enqueued_at"] || Time.now.utc.iso8601
|
|
56
|
+
).merge(continuation(checkpoint))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def continuation(checkpoint)
|
|
60
|
+
return {} unless checkpoint.is_a?(Hash) && checkpoint["activejob"].is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
checkpoint["activejob"].slice("continuation", "resumptions")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def standard_arguments(arguments)
|
|
66
|
+
return {} if arguments.empty?
|
|
67
|
+
return nil unless arguments.size == 1
|
|
68
|
+
|
|
69
|
+
kwargs = arguments.first
|
|
70
|
+
return nil unless kwargs.is_a?(Hash) && Hash.ruby2_keywords_hash?(kwargs)
|
|
71
|
+
return nil unless plain?(kwargs)
|
|
72
|
+
|
|
73
|
+
JSON.parse(JSON.generate(kwargs))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def plain?(value)
|
|
77
|
+
case value
|
|
78
|
+
when String, Integer, true, false, nil then true
|
|
79
|
+
when Float then value.finite?
|
|
80
|
+
when Array then value.all? { plain?(it) }
|
|
81
|
+
when Hash then value.all? { |k, v| (k.is_a?(String) || k.is_a?(Symbol)) && plain?(v) }
|
|
82
|
+
else false
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stampeded
|
|
4
|
+
Error = Class.new(StandardError)
|
|
5
|
+
|
|
6
|
+
class RetryConflict < Error
|
|
7
|
+
def initialize(job_class)
|
|
8
|
+
super(<<~MSG)
|
|
9
|
+
#{job_class} declares `retry_on`; Stampeded owns retries:
|
|
10
|
+
|
|
11
|
+
class #{job_class} < ApplicationJob
|
|
12
|
+
stampeded max_attempts: 5, backoff_base_ms: 1_000, backoff_max_ms: 300_000
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
For a specific delay: raise Stampeded::RetryIn.new(seconds). `discard_on` is fine.
|
|
16
|
+
MSG
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class PortableArgumentsError < Error
|
|
21
|
+
def initialize(job_class, task)
|
|
22
|
+
super(<<~MSG)
|
|
23
|
+
#{job_class} is task #{task.inspect}: pass keyword arguments with
|
|
24
|
+
plain JSON values (strings, numbers, booleans, nil, arrays, hashes),
|
|
25
|
+
ids rather than records, ISO 8601 strings rather than times:
|
|
26
|
+
|
|
27
|
+
#{job_class}.perform_later(document_id: document.id)
|
|
28
|
+
MSG
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class DaemonNotFound < Error; end
|
|
33
|
+
class LibraryNotFound < Error; end
|
|
34
|
+
class SchemaMissing < Error
|
|
35
|
+
def initialize(cause = nil)
|
|
36
|
+
super(<<~MSG)
|
|
37
|
+
The Stampeded schema is not installed in this database.
|
|
38
|
+
|
|
39
|
+
In Rails:
|
|
40
|
+
|
|
41
|
+
bin/rails stampeded:install
|
|
42
|
+
|
|
43
|
+
Elsewhere:
|
|
44
|
+
|
|
45
|
+
Stampeded::Schema.install!
|
|
46
|
+
|
|
47
|
+
Postgres 18 or newer is required.
|
|
48
|
+
#{"\nUnderlying error: #{cause.message.lines.first&.strip}" if cause}
|
|
49
|
+
MSG
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class UnknownTask < Error; end
|
|
54
|
+
|
|
55
|
+
class Requeue < Exception; end
|
|
56
|
+
class RetryIn < Error
|
|
57
|
+
attr_reader :seconds
|
|
58
|
+
|
|
59
|
+
def initialize(seconds, message = nil)
|
|
60
|
+
@seconds = Float(seconds)
|
|
61
|
+
raise ArgumentError, "RetryIn needs a non-negative delay, got #{seconds.inspect}" if @seconds.negative?
|
|
62
|
+
|
|
63
|
+
super(message || "retry in #{@seconds}s")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def delay_ms = (@seconds * 1000).round
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Stampeded
|
|
7
|
+
class Job
|
|
8
|
+
ATTRIBUTES = %w[
|
|
9
|
+
id queue task args meta state priority attempt max_attempts
|
|
10
|
+
interruptions max_interruptions deferrals max_deferrals
|
|
11
|
+
tenant_id destination dead_letter_queue expire_seconds
|
|
12
|
+
scheduled_at last_error failure_kind
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :row
|
|
16
|
+
|
|
17
|
+
def initialize(row, decoded: false)
|
|
18
|
+
@row = row
|
|
19
|
+
@decoded = decoded
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def id = row["id"]
|
|
23
|
+
def queue = row["queue"]
|
|
24
|
+
def task = row["task"]
|
|
25
|
+
def state = row["state"]
|
|
26
|
+
def terminal? = Lifecycle.terminal?(state)
|
|
27
|
+
def claimable? = Lifecycle.claimable?(state)
|
|
28
|
+
|
|
29
|
+
def available_events = Lifecycle.events_from(state)
|
|
30
|
+
|
|
31
|
+
def respond_to_missing?(name, include_private = false) = state_predicate?(name) || super
|
|
32
|
+
|
|
33
|
+
def method_missing(name, *args)
|
|
34
|
+
return super unless args.empty? && state_predicate?(name)
|
|
35
|
+
|
|
36
|
+
state.to_s == name.to_s.delete_suffix("?")
|
|
37
|
+
end
|
|
38
|
+
def attempt = row["attempt"].to_i
|
|
39
|
+
def max_attempts = row["max_attempts"].to_i
|
|
40
|
+
def interruptions = row["interruptions"].to_i
|
|
41
|
+
def deferrals = row["deferrals"].to_i
|
|
42
|
+
def tenant_id = row["tenant_id"]
|
|
43
|
+
def destination = row["destination"]
|
|
44
|
+
|
|
45
|
+
def args
|
|
46
|
+
@args ||= decode(row["args"])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def meta
|
|
50
|
+
@meta ||= decode(row["meta"])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def checkpoint
|
|
54
|
+
return @checkpoint if defined?(@checkpoint)
|
|
55
|
+
|
|
56
|
+
raw = row["checkpoint"]
|
|
57
|
+
@checkpoint = raw.nil? ? nil : decode(raw)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def resumed? = !checkpoint.nil?
|
|
61
|
+
|
|
62
|
+
def checkpoint_seq = row["checkpoint_seq"].to_i
|
|
63
|
+
def attempts_remaining = [max_attempts - attempt, 0].max
|
|
64
|
+
|
|
65
|
+
def last_attempt? = attempts_remaining.zero?
|
|
66
|
+
|
|
67
|
+
def to_h = ATTRIBUTES.to_h { |a| [a, row[a]] }
|
|
68
|
+
|
|
69
|
+
def inspect
|
|
70
|
+
"#<Stampeded::Job #{id} #{queue}/#{task} attempt=#{attempt}/#{max_attempts}>"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def state_predicate?(name) = name.end_with?("?") && Lifecycle.states.include?(name.to_s.delete_suffix("?").to_sym)
|
|
76
|
+
|
|
77
|
+
def decode(value)
|
|
78
|
+
case value
|
|
79
|
+
when nil then {}
|
|
80
|
+
when String then @decoded ? value : JSON.parse(value)
|
|
81
|
+
else value
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Stampeded
|
|
6
|
+
class JobContext
|
|
7
|
+
extend Forwardable
|
|
8
|
+
def_delegators :@job, :id, :queue, :task, :args, :meta, :attempt, :max_attempts,
|
|
9
|
+
:attempts_remaining, :last_attempt?, :tenant_id, :destination,
|
|
10
|
+
:checkpoint, :resumed?, :checkpoint_seq, :row
|
|
11
|
+
|
|
12
|
+
attr_reader :job, :worker_id
|
|
13
|
+
|
|
14
|
+
def initialize(job, worker_id)
|
|
15
|
+
@job = job
|
|
16
|
+
@worker_id = worker_id
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def checkpoint!(state = nil, conn: nil, **rest)
|
|
20
|
+
state ||= rest
|
|
21
|
+
raise ArgumentError, "checkpoint! needs some state" if state.nil? || state.empty?
|
|
22
|
+
|
|
23
|
+
sql = "SELECT stampeded_checkpoint($1, $2, $3, $4, $5)"
|
|
24
|
+
params = [job.queue, job.id, worker_id, job.attempt, JSON.generate(state)]
|
|
25
|
+
|
|
26
|
+
seq =
|
|
27
|
+
if conn
|
|
28
|
+
conn.exec_params(sql, params)[0]["stampeded_checkpoint"]
|
|
29
|
+
else
|
|
30
|
+
Connection.with { |c| c.exec_params(sql, params)[0]["stampeded_checkpoint"] }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
!seq.nil?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def steps
|
|
37
|
+
@steps ||= Steps.new(self)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def step(name, cursor: nil, &) = steps.step(name, cursor: cursor, &)
|
|
41
|
+
def touch!
|
|
42
|
+
ok = Connection.own do |c|
|
|
43
|
+
c.exec_params("SELECT stampeded_touch($1, $2, $3, $4)",
|
|
44
|
+
[job.queue, job.id, worker_id, job.attempt])[0]["stampeded_touch"]
|
|
45
|
+
end
|
|
46
|
+
Stampeded.truthy?(ok)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stampeded
|
|
4
|
+
module Lifecycle
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def states = @states ||= native.lifecycle_states
|
|
8
|
+
|
|
9
|
+
def events = @events ||= native.lifecycle_events
|
|
10
|
+
|
|
11
|
+
def apply(from, event) = native.lifecycle_apply(from, event)
|
|
12
|
+
|
|
13
|
+
def legal?(from, event)
|
|
14
|
+
!apply(from, event).nil?
|
|
15
|
+
rescue Error
|
|
16
|
+
false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def events_from(state) = events.select { legal?(state, it) }
|
|
20
|
+
|
|
21
|
+
def terminal?(state) = events_from(state).empty?
|
|
22
|
+
|
|
23
|
+
def claimable?(state) = legal?(state, :claim)
|
|
24
|
+
|
|
25
|
+
def native
|
|
26
|
+
require "stampeded/native"
|
|
27
|
+
Native
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ffi"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Stampeded
|
|
7
|
+
module Native
|
|
8
|
+
extend FFI::Library
|
|
9
|
+
|
|
10
|
+
OK = 0
|
|
11
|
+
NONE = 1
|
|
12
|
+
ERROR = -1
|
|
13
|
+
|
|
14
|
+
LIBRARY = FFI.map_library_name("stampeded_ffi")
|
|
15
|
+
|
|
16
|
+
def self.library_path
|
|
17
|
+
if (dir = ENV.fetch("STAMPEDED_INSTALL_DIR", nil))
|
|
18
|
+
path = File.expand_path(File.join(dir, LIBRARY))
|
|
19
|
+
raise LibraryNotFound, "STAMPEDED_INSTALL_DIR is set to #{dir}, but #{path} does not exist" \
|
|
20
|
+
unless File.file?(path)
|
|
21
|
+
|
|
22
|
+
return path
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
path = File.expand_path(File.join("../..", "libexec", LIBRARY), __dir__)
|
|
26
|
+
return path if File.file?(path)
|
|
27
|
+
|
|
28
|
+
raise LibraryNotFound, <<~MSG
|
|
29
|
+
Could not find #{LIBRARY} for #{Daemon.platform}. Build it and point at it:
|
|
30
|
+
|
|
31
|
+
cargo build --release -p stampeded-ffi
|
|
32
|
+
export STAMPEDED_INSTALL_DIR=/path/to/stampeded/target/release
|
|
33
|
+
MSG
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
ffi_lib library_path
|
|
37
|
+
|
|
38
|
+
class PullerOptions < FFI::Struct
|
|
39
|
+
layout :queues, :pointer,
|
|
40
|
+
:except_queues, :pointer,
|
|
41
|
+
:max_leases, :uint32,
|
|
42
|
+
:batch_size, :uint32,
|
|
43
|
+
:poll_interval_ms, :uint32,
|
|
44
|
+
:heartbeat_interval_ms, :uint32
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
attach_function :stampeded_version, [], :string
|
|
48
|
+
attach_function :stampeded_last_error, [], :string
|
|
49
|
+
attach_function :stampeded_log_init, [:string], :int32
|
|
50
|
+
|
|
51
|
+
attach_function :stampeded_connect, [:string], :pointer, blocking: true
|
|
52
|
+
attach_function :stampeded_client_free, [:pointer], :void, blocking: true
|
|
53
|
+
|
|
54
|
+
attach_function :stampeded_puller_start, [:pointer, PullerOptions.by_ref], :pointer, blocking: true
|
|
55
|
+
attach_function :stampeded_puller_worker_id, [:pointer], :string
|
|
56
|
+
attach_function :stampeded_puller_next, %i[pointer uint32 pointer], :int32, blocking: true
|
|
57
|
+
attach_function :stampeded_puller_stop, %i[pointer uint32], :int32, blocking: true
|
|
58
|
+
attach_function :stampeded_puller_free, [:pointer], :void, blocking: true
|
|
59
|
+
|
|
60
|
+
attach_function :stampeded_lease_job, [:pointer], :string
|
|
61
|
+
attach_function :stampeded_lease_complete, [:pointer], :int32, blocking: true
|
|
62
|
+
attach_function :stampeded_lease_fail, %i[pointer string int64], :int32, blocking: true
|
|
63
|
+
attach_function :stampeded_lease_timed_out, %i[pointer string], :int32, blocking: true
|
|
64
|
+
attach_function :stampeded_lease_requeue, [:pointer], :int32, blocking: true
|
|
65
|
+
attach_function :stampeded_lease_discard, %i[pointer string], :int32, blocking: true
|
|
66
|
+
attach_function :stampeded_lease_free, [:pointer], :void, blocking: true
|
|
67
|
+
|
|
68
|
+
attach_function :stampeded_lifecycle_states, [], :string
|
|
69
|
+
attach_function :stampeded_lifecycle_events, [], :string
|
|
70
|
+
attach_function :stampeded_lifecycle_apply, %i[string string pointer], :int32
|
|
71
|
+
attach_function :stampeded_cron_check, %i[string string pointer], :int32
|
|
72
|
+
|
|
73
|
+
attach_function :stampeded_steps_load, [:string], :pointer
|
|
74
|
+
attach_function :stampeded_steps_free, [:pointer], :void
|
|
75
|
+
attach_function :stampeded_steps_completed, %i[pointer string], :int32
|
|
76
|
+
attach_function :stampeded_steps_value, %i[pointer string], :string
|
|
77
|
+
attach_function :stampeded_steps_cursor, %i[pointer string], :string
|
|
78
|
+
attach_function :stampeded_steps_complete, %i[pointer string string], :int32
|
|
79
|
+
attach_function :stampeded_steps_set_cursor, %i[pointer string string], :int32
|
|
80
|
+
attach_function :stampeded_steps_json, [:pointer], :string
|
|
81
|
+
|
|
82
|
+
module_function
|
|
83
|
+
|
|
84
|
+
def version = stampeded_version
|
|
85
|
+
def log_init(filter)
|
|
86
|
+
status = stampeded_log_init(filter)
|
|
87
|
+
raise Error, last_error if status == ERROR
|
|
88
|
+
|
|
89
|
+
status == OK
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def last_error = stampeded_last_error || "unknown error"
|
|
93
|
+
|
|
94
|
+
def ms(seconds) = (seconds.to_f * 1000).round.clamp(0, 0xFFFF_FFFF)
|
|
95
|
+
def text(value) = value.to_s.encode("UTF-8", invalid: :replace, undef: :replace).delete("\0")
|
|
96
|
+
|
|
97
|
+
def lifecycle_states = stampeded_lifecycle_states.split(",").map(&:to_sym)
|
|
98
|
+
|
|
99
|
+
def lifecycle_events = stampeded_lifecycle_events.split(",").map(&:to_sym)
|
|
100
|
+
|
|
101
|
+
def lifecycle_apply(state, event)
|
|
102
|
+
out = FFI::MemoryPointer.new(:pointer)
|
|
103
|
+
case stampeded_lifecycle_apply(state.to_s, event.to_s, out)
|
|
104
|
+
when OK then out.read_pointer.read_string.to_sym
|
|
105
|
+
when NONE then nil
|
|
106
|
+
else raise Error, last_error
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def cron_check(expr, timezone)
|
|
111
|
+
out = FFI::MemoryPointer.new(:pointer)
|
|
112
|
+
raise Error, last_error if stampeded_cron_check(text(expr), text(timezone), out) == ERROR
|
|
113
|
+
|
|
114
|
+
out.read_pointer.read_string
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
class Steps
|
|
118
|
+
def initialize(checkpoint)
|
|
119
|
+
pointer = Native.stampeded_steps_load(checkpoint.nil? ? nil : JSON.generate(checkpoint))
|
|
120
|
+
raise Error, Native.last_error if pointer.null?
|
|
121
|
+
|
|
122
|
+
@pointer = FFI::AutoPointer.new(pointer, Native.method(:stampeded_steps_free))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def completed?(name) = check(Native.stampeded_steps_completed(@pointer, name.to_s)) == OK
|
|
126
|
+
|
|
127
|
+
def value(name) = decode(Native.stampeded_steps_value(@pointer, name.to_s))
|
|
128
|
+
|
|
129
|
+
def cursor(name) = decode(Native.stampeded_steps_cursor(@pointer, name.to_s))
|
|
130
|
+
|
|
131
|
+
def complete(name, json) = check(Native.stampeded_steps_complete(@pointer, name.to_s, json))
|
|
132
|
+
|
|
133
|
+
def set_cursor(name, json) = check(Native.stampeded_steps_set_cursor(@pointer, name.to_s, json))
|
|
134
|
+
|
|
135
|
+
def to_h = JSON.parse(Native.stampeded_steps_json(@pointer))
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def check(status)
|
|
140
|
+
raise Error, Native.last_error if status == ERROR
|
|
141
|
+
|
|
142
|
+
status
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def decode(json) = json && JSON.parse(json)
|
|
146
|
+
end
|
|
147
|
+
class Puller
|
|
148
|
+
attr_reader :worker_id
|
|
149
|
+
|
|
150
|
+
def initialize(url:, queues:, except:, max_leases:, batch_size:, poll_interval:, heartbeat_interval:)
|
|
151
|
+
@client = Native.stampeded_connect(url)
|
|
152
|
+
raise Error, Native.last_error if @client.null?
|
|
153
|
+
|
|
154
|
+
options = PullerOptions.new
|
|
155
|
+
queue_spec = FFI::MemoryPointer.from_string(Native.text(queues))
|
|
156
|
+
except_spec = except && FFI::MemoryPointer.from_string(Native.text(except))
|
|
157
|
+
options[:queues] = queue_spec
|
|
158
|
+
options[:except_queues] = except_spec || FFI::Pointer::NULL
|
|
159
|
+
options[:max_leases] = max_leases
|
|
160
|
+
options[:batch_size] = batch_size
|
|
161
|
+
options[:poll_interval_ms] = Native.ms(poll_interval)
|
|
162
|
+
options[:heartbeat_interval_ms] = Native.ms(heartbeat_interval)
|
|
163
|
+
|
|
164
|
+
@puller = Native.stampeded_puller_start(@client, options)
|
|
165
|
+
if @puller.null?
|
|
166
|
+
message = Native.last_error
|
|
167
|
+
close
|
|
168
|
+
raise Error, "could not start the worker: #{message}"
|
|
169
|
+
end
|
|
170
|
+
@worker_id = Native.stampeded_puller_worker_id(@puller)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def next(timeout)
|
|
174
|
+
out = FFI::MemoryPointer.new(:pointer)
|
|
175
|
+
case Native.stampeded_puller_next(@puller, Native.ms(timeout), out)
|
|
176
|
+
when OK then Lease.new(out.read_pointer)
|
|
177
|
+
when NONE then nil
|
|
178
|
+
else raise Error, Native.last_error
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def stop(drain)
|
|
183
|
+
raise Error, Native.last_error if Native.stampeded_puller_stop(@puller, Native.ms(drain)) == ERROR
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def close
|
|
187
|
+
Native.stampeded_puller_free(@puller) if @puller && !@puller.null?
|
|
188
|
+
Native.stampeded_client_free(@client) if @client && !@client.null?
|
|
189
|
+
@puller = @client = nil
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
class Lease
|
|
194
|
+
attr_reader :row
|
|
195
|
+
|
|
196
|
+
def initialize(pointer)
|
|
197
|
+
@pointer = pointer
|
|
198
|
+
@row = JSON.parse(Native.stampeded_lease_job(pointer))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def settled? = @pointer.nil?
|
|
202
|
+
|
|
203
|
+
def complete! = settle { Native.stampeded_lease_complete(it) }
|
|
204
|
+
def fail!(message, retry_in_ms: nil)
|
|
205
|
+
settle { Native.stampeded_lease_fail(it, Native.text(message), retry_in_ms || -1) }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def requeue! = settle { Native.stampeded_lease_requeue(it) }
|
|
209
|
+
|
|
210
|
+
def timed_out!(message) = settle { Native.stampeded_lease_timed_out(it, Native.text(message)) }
|
|
211
|
+
|
|
212
|
+
def discard!(message) = settle { Native.stampeded_lease_discard(it, Native.text(message)) }
|
|
213
|
+
|
|
214
|
+
def release!
|
|
215
|
+
return if settled?
|
|
216
|
+
|
|
217
|
+
pointer = @pointer
|
|
218
|
+
@pointer = nil
|
|
219
|
+
Native.stampeded_lease_free(pointer)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
def settle
|
|
225
|
+
raise Error, "lease already settled" if settled?
|
|
226
|
+
|
|
227
|
+
pointer = @pointer
|
|
228
|
+
@pointer = nil
|
|
229
|
+
raise Error, Native.last_error if yield(pointer) == ERROR
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|