zeridion-flare 0.2.0
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.txt +21 -0
- data/README.md +175 -0
- data/lib/zeridion_flare/client.rb +298 -0
- data/lib/zeridion_flare/errors.rb +34 -0
- data/lib/zeridion_flare/version.rb +7 -0
- data/lib/zeridion_flare/webhook.rb +74 -0
- data/lib/zeridion_flare/worker/cancellation_token.rb +89 -0
- data/lib/zeridion_flare/worker/configuration.rb +97 -0
- data/lib/zeridion_flare/worker/errors.rb +44 -0
- data/lib/zeridion_flare/worker/executor.rb +98 -0
- data/lib/zeridion_flare/worker/heartbeat.rb +123 -0
- data/lib/zeridion_flare/worker/identity.rb +68 -0
- data/lib/zeridion_flare/worker/job.rb +85 -0
- data/lib/zeridion_flare/worker/job_context.rb +91 -0
- data/lib/zeridion_flare/worker/job_definition.rb +58 -0
- data/lib/zeridion_flare/worker/pool.rb +118 -0
- data/lib/zeridion_flare/worker/progress_slot.rb +43 -0
- data/lib/zeridion_flare/worker/recurring_job.rb +81 -0
- data/lib/zeridion_flare/worker/recurring_schedule.rb +51 -0
- data/lib/zeridion_flare/worker/registry.rb +100 -0
- data/lib/zeridion_flare/worker/runner.rb +313 -0
- data/lib/zeridion_flare/worker/worker.rb +211 -0
- data/lib/zeridion_flare/worker.rb +39 -0
- data/lib/zeridion_flare.rb +14 -0
- metadata +103 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zeridion
|
|
4
|
+
module Flare
|
|
5
|
+
module Worker
|
|
6
|
+
# Cooperative cancellation handle handed to every job via JobContext.
|
|
7
|
+
#
|
|
8
|
+
# Cancellation in Ruby is COOPERATIVE ONLY (decision D6): the worker never
|
|
9
|
+
# uses Thread#raise, Thread#kill, or Timeout.timeout — those are unsafe
|
|
10
|
+
# around Net::HTTP / pg / redis and can corrupt connection state. A
|
|
11
|
+
# CPU-bound or blocking handler that never checks the token is
|
|
12
|
+
# uninterruptible from the SDK and is bounded only by the server reaper.
|
|
13
|
+
#
|
|
14
|
+
# Handlers cooperate by polling #cancelled? / calling #check! at safe
|
|
15
|
+
# points, and by using #wait(seconds) instead of Kernel#sleep so a pending
|
|
16
|
+
# sleep wakes immediately on cancel. Cancellation fires on: a server
|
|
17
|
+
# "cancel" heartbeat response, host shutdown (SIGTERM/SIGINT), or the
|
|
18
|
+
# local per-job timeout. In every case the job ultimately acks "failed"
|
|
19
|
+
# (D1), never "cancelled".
|
|
20
|
+
class CancellationToken
|
|
21
|
+
def initialize
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
@cond = ConditionVariable.new
|
|
24
|
+
@cancelled = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Boolean] true once cancellation has been requested.
|
|
28
|
+
def cancelled?
|
|
29
|
+
@mutex.synchronize { @cancelled }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Raise JobCancelledError if cancellation has been requested. Call at
|
|
33
|
+
# natural checkpoints in a long handler.
|
|
34
|
+
def check!
|
|
35
|
+
raise JobCancelledError if cancelled?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Interruptible, monotonic sleep. Returns early (true) the moment
|
|
39
|
+
# cancellation is requested; otherwise sleeps up to `seconds` and
|
|
40
|
+
# returns false. Uses ConditionVariable#wait, which is monotonic-clock
|
|
41
|
+
# based, so it is immune to wall-clock jumps. seconds <= 0 returns
|
|
42
|
+
# immediately.
|
|
43
|
+
#
|
|
44
|
+
# An infinite `seconds` (Float::INFINITY) means "block until cancelled":
|
|
45
|
+
# ConditionVariable#wait rejects a non-finite timeout ("Inf out of Time
|
|
46
|
+
# range"), so we pass nil (wait forever) instead of an out-of-range
|
|
47
|
+
# deadline. #run relies on this to park the main thread until a signal.
|
|
48
|
+
#
|
|
49
|
+
# @param seconds [Numeric]
|
|
50
|
+
# @return [Boolean] true if it woke due to cancellation, false on timeout.
|
|
51
|
+
def wait(seconds)
|
|
52
|
+
infinite = seconds.respond_to?(:infinite?) && seconds.infinite?
|
|
53
|
+
deadline = infinite ? nil : monotonic_now + seconds.to_f
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
loop do
|
|
56
|
+
return true if @cancelled
|
|
57
|
+
|
|
58
|
+
if deadline.nil?
|
|
59
|
+
# Block until #cancel! broadcasts (no timeout).
|
|
60
|
+
@cond.wait(@mutex)
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
remaining = deadline - monotonic_now
|
|
65
|
+
return false if remaining <= 0
|
|
66
|
+
|
|
67
|
+
@cond.wait(@mutex, remaining)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Request cancellation and wake any thread blocked in #wait. Idempotent.
|
|
73
|
+
# Internal — called by the runtime, not by handlers.
|
|
74
|
+
def cancel!
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
@cancelled = true
|
|
77
|
+
@cond.broadcast
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def monotonic_now
|
|
84
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zeridion
|
|
4
|
+
module Flare
|
|
5
|
+
module Worker
|
|
6
|
+
# Resolved worker configuration. One bootstrap object — there is no
|
|
7
|
+
# separate "Configuration class vs Worker class" split; Worker.new builds
|
|
8
|
+
# this from its keyword args and normalizes defaults here.
|
|
9
|
+
class Configuration
|
|
10
|
+
DEFAULT_QUEUES = ["default"].freeze
|
|
11
|
+
DEFAULT_CONCURRENCY = 10
|
|
12
|
+
DEFAULT_POLL_INTERVAL_S = 2.0
|
|
13
|
+
DEFAULT_TIMEOUT_S = 1800
|
|
14
|
+
DEFAULT_MAX_ATTEMPTS = 3
|
|
15
|
+
DEFAULT_SHUTDOWN_GRACE_S = 30.0
|
|
16
|
+
|
|
17
|
+
# The worker owns its own HTTP client with a poll read-timeout > 30 s so
|
|
18
|
+
# the ~30 s long-poll never trips the client's own read deadline
|
|
19
|
+
# (decision D5). 45 s leaves margin for the server's long-poll plus slack.
|
|
20
|
+
DEFAULT_POLL_READ_TIMEOUT_S = 45.0
|
|
21
|
+
# Heartbeat / ack use a short read-timeout (these are quick round-trips).
|
|
22
|
+
DEFAULT_RPC_TIMEOUT_S = 15.0
|
|
23
|
+
|
|
24
|
+
# Floor on the heartbeat cadence in milliseconds (10 s, per the protocol).
|
|
25
|
+
# Shrinkable for deterministic tests via FLARE_HEARTBEAT_MIN_MS (test
|
|
26
|
+
# seam D8) or the explicit heartbeat_min_ms: keyword.
|
|
27
|
+
HEARTBEAT_FLOOR_MS = 10_000
|
|
28
|
+
|
|
29
|
+
attr_reader :queues, :concurrency, :poll_interval_s, :default_timeout_s,
|
|
30
|
+
:default_max_attempts, :shutdown_grace_s, :poll_read_timeout_s,
|
|
31
|
+
:rpc_timeout_s, :heartbeat_min_ms, :hostname, :logger
|
|
32
|
+
|
|
33
|
+
# @param queues [Array<String>, nil] queues to poll. nil → the union of
|
|
34
|
+
# every registered job's queue (falls back to ["default"]).
|
|
35
|
+
# @param concurrency [Integer] max simultaneous jobs (clamped >= 1).
|
|
36
|
+
# @param poll_interval_s [Numeric] idle delay between empty polls.
|
|
37
|
+
# @param default_timeout_s [Integer] timeout when a job item omits one.
|
|
38
|
+
# @param default_max_attempts [Integer]
|
|
39
|
+
# @param shutdown_grace_s [Numeric] max time to await in-flight jobs on stop.
|
|
40
|
+
# @param poll_read_timeout_s [Numeric] poll HTTP read timeout (> 30 s).
|
|
41
|
+
# @param rpc_timeout_s [Numeric] heartbeat/ack HTTP read timeout.
|
|
42
|
+
# @param hostname [String, nil] register hostname (default: machine name).
|
|
43
|
+
# @param logger [Logger, nil] runtime logger (nil → silent).
|
|
44
|
+
# @param heartbeat_min_ms [Integer, nil] override the 10 s heartbeat floor
|
|
45
|
+
# (test seam). Falls back to FLARE_HEARTBEAT_MIN_MS, then 10_000.
|
|
46
|
+
def initialize(
|
|
47
|
+
queues: nil,
|
|
48
|
+
concurrency: DEFAULT_CONCURRENCY,
|
|
49
|
+
poll_interval_s: DEFAULT_POLL_INTERVAL_S,
|
|
50
|
+
default_timeout_s: DEFAULT_TIMEOUT_S,
|
|
51
|
+
default_max_attempts: DEFAULT_MAX_ATTEMPTS,
|
|
52
|
+
shutdown_grace_s: DEFAULT_SHUTDOWN_GRACE_S,
|
|
53
|
+
poll_read_timeout_s: DEFAULT_POLL_READ_TIMEOUT_S,
|
|
54
|
+
rpc_timeout_s: DEFAULT_RPC_TIMEOUT_S,
|
|
55
|
+
hostname: nil,
|
|
56
|
+
logger: nil,
|
|
57
|
+
heartbeat_min_ms: nil
|
|
58
|
+
)
|
|
59
|
+
@queues = normalize_queues(queues)
|
|
60
|
+
@concurrency = [concurrency.to_i, 1].max
|
|
61
|
+
@poll_interval_s = poll_interval_s.to_f
|
|
62
|
+
@default_timeout_s = [default_timeout_s.to_i, 1].max
|
|
63
|
+
@default_max_attempts = [default_max_attempts.to_i, 1].max
|
|
64
|
+
@shutdown_grace_s = shutdown_grace_s.to_f
|
|
65
|
+
@poll_read_timeout_s = poll_read_timeout_s.to_f
|
|
66
|
+
@rpc_timeout_s = rpc_timeout_s.to_f
|
|
67
|
+
@hostname = hostname
|
|
68
|
+
@logger = logger
|
|
69
|
+
@heartbeat_min_ms = resolve_heartbeat_min_ms(heartbeat_min_ms)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Whether the user pinned an explicit queue list (vs deriving from jobs).
|
|
73
|
+
def explicit_queues?
|
|
74
|
+
!@queues.nil?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def normalize_queues(queues)
|
|
80
|
+
return nil if queues.nil?
|
|
81
|
+
|
|
82
|
+
list = Array(queues).map(&:to_s).reject(&:empty?).uniq
|
|
83
|
+
list.empty? ? nil : list
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def resolve_heartbeat_min_ms(explicit)
|
|
87
|
+
return [explicit.to_i, 1].max unless explicit.nil?
|
|
88
|
+
|
|
89
|
+
env = ENV["FLARE_HEARTBEAT_MIN_MS"]
|
|
90
|
+
return [env.to_i, 1].max if env && env.match?(/\A\d+\z/)
|
|
91
|
+
|
|
92
|
+
HEARTBEAT_FLOOR_MS
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zeridion
|
|
4
|
+
module Flare
|
|
5
|
+
module Worker
|
|
6
|
+
# Raised at wiring time when two job classes claim the same job_type. A
|
|
7
|
+
# duplicate type on a shared queue would make routing ambiguous, so the
|
|
8
|
+
# registry refuses it loudly rather than silently last-writer-wins.
|
|
9
|
+
class DuplicateJobTypeError < ::Zeridion::Flare::FlareError
|
|
10
|
+
def initialize(job_type)
|
|
11
|
+
super(
|
|
12
|
+
"Duplicate job_type #{job_type.inspect}: two job classes cannot share the same job_type.",
|
|
13
|
+
status_code: 0,
|
|
14
|
+
code: "duplicate_job_type",
|
|
15
|
+
request_id: nil,
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Raised when a polled job_type has no registered handler. The worker turns
|
|
21
|
+
# this into an ack "failed" with a recognizable error.type rather than
|
|
22
|
+
# crashing the poll loop.
|
|
23
|
+
class UnknownJobTypeError < ::Zeridion::Flare::FlareError
|
|
24
|
+
def initialize(job_type)
|
|
25
|
+
super(
|
|
26
|
+
"No job type registered for #{job_type.inspect}.",
|
|
27
|
+
status_code: 0,
|
|
28
|
+
code: "unknown_job_type",
|
|
29
|
+
request_id: nil,
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Internal sentinel: the in-flight job was cancelled (server "cancel"
|
|
35
|
+
# response, host shutdown, or local timeout). Per decision D1 this still
|
|
36
|
+
# acks "failed" — never "cancelled".
|
|
37
|
+
class JobCancelledError < StandardError
|
|
38
|
+
def initialize(msg = "Job cancelled.")
|
|
39
|
+
super
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zeridion
|
|
4
|
+
module Flare
|
|
5
|
+
module Worker
|
|
6
|
+
# Executes a single polled job: resolves its definition, decodes the
|
|
7
|
+
# payload, builds the JobContext, invokes #perform, and reports the
|
|
8
|
+
# outcome. Mirrors the reference SDK's JobExecutor.
|
|
9
|
+
#
|
|
10
|
+
# Ack-status mapping (decision D1): a normal return → "succeeded"; ANY
|
|
11
|
+
# raised exception — including cancellation, timeout, or an unknown job
|
|
12
|
+
# type — → "failed". The worker NEVER acks "cancelled". The error.type is
|
|
13
|
+
# the exception's class name (one-level cause-unwrap), matching the
|
|
14
|
+
# reference's fully-qualified error type.
|
|
15
|
+
class Executor
|
|
16
|
+
# Outcome of one execution. error is nil on success, else a wire-ready
|
|
17
|
+
# Hash {"type", "message", "stack_trace"} (already nil-compacted).
|
|
18
|
+
Outcome = Struct.new(:succeeded, :error, keyword_init: true) do
|
|
19
|
+
def succeeded?
|
|
20
|
+
succeeded
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(registry:, logger: nil)
|
|
25
|
+
@registry = registry
|
|
26
|
+
@logger = logger
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param item [Hash] the polled job item (string keys: "id", "job_type",
|
|
30
|
+
# "payload", "attempt", "max_attempts", "timeout_seconds", "enqueued_at")
|
|
31
|
+
# @param context [JobContext]
|
|
32
|
+
# @return [Outcome]
|
|
33
|
+
def execute(item, context)
|
|
34
|
+
definition = @registry.lookup(item["job_type"])
|
|
35
|
+
if definition.nil?
|
|
36
|
+
return Outcome.new(
|
|
37
|
+
succeeded: false,
|
|
38
|
+
error: error_hash(UnknownJobTypeError.new(item["job_type"])),
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
invoke(definition, item, context)
|
|
44
|
+
Outcome.new(succeeded: true, error: nil)
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Outcome.new(succeeded: false, error: error_hash(unwrap(e)))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def invoke(definition, item, context)
|
|
53
|
+
instance = definition.job_class.new
|
|
54
|
+
|
|
55
|
+
if definition.recurring?
|
|
56
|
+
instance.perform(context)
|
|
57
|
+
else
|
|
58
|
+
payload = coerce_payload(item["payload"], definition.payload_struct)
|
|
59
|
+
instance.perform(payload, context)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Decode the wire payload. JSON is already parsed into a Hash/Array/
|
|
64
|
+
# scalar by the transport, so by default we pass the Hash through with
|
|
65
|
+
# string keys. With payload_struct: configured, hydrate a typed value via
|
|
66
|
+
# keyword init (symbolized keys). nil / non-Hash → passed through.
|
|
67
|
+
def coerce_payload(payload, payload_struct)
|
|
68
|
+
return payload if payload_struct.nil?
|
|
69
|
+
return payload unless payload.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
symbolized = payload.transform_keys(&:to_sym)
|
|
72
|
+
payload_struct.new(**symbolized)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# One-level unwrap: prefer the underlying cause when an exception wraps
|
|
76
|
+
# another (mirrors the reference's TargetInvocationException unwrap).
|
|
77
|
+
def unwrap(exception)
|
|
78
|
+
exception.cause || exception
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def error_hash(exception)
|
|
82
|
+
{
|
|
83
|
+
"type" => exception.class.name,
|
|
84
|
+
"message" => exception.message,
|
|
85
|
+
"stack_trace" => format_backtrace(exception),
|
|
86
|
+
}.compact
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def format_backtrace(exception)
|
|
90
|
+
bt = exception.backtrace
|
|
91
|
+
return nil if bt.nil? || bt.empty?
|
|
92
|
+
|
|
93
|
+
bt.join("\n")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zeridion
|
|
4
|
+
module Flare
|
|
5
|
+
module Worker
|
|
6
|
+
# One heartbeat pump per in-flight job, run on its own thread.
|
|
7
|
+
#
|
|
8
|
+
# Cadence (frozen protocol §3): every max(10s, timeout_seconds / 3),
|
|
9
|
+
# integer floor-div on the SECONDS, with the 10 s floor shrinkable for
|
|
10
|
+
# tests via Configuration#heartbeat_min_ms (FLARE_HEARTBEAT_MIN_MS seam).
|
|
11
|
+
#
|
|
12
|
+
# A heartbeat is emitted IMMEDIATELY on claim (the first beat is not
|
|
13
|
+
# delayed) so a long job lands in the server reaper's forgiving "heartbeated
|
|
14
|
+
# + silent for GREATEST(120s, timeout × ⅔)" window rather than the strict
|
|
15
|
+
# "never-heartbeated + StartedAt + timeout" branch.
|
|
16
|
+
#
|
|
17
|
+
# The pump keeps beating until the HANDLER finishes (the `stop` signal is
|
|
18
|
+
# raised only when #perform returns or raises). On a server "cancel"
|
|
19
|
+
# response it trips the cancellation token (cooperative cancel) but KEEPS
|
|
20
|
+
# beating while the handler winds down, so a cancelled-but-not-yet-returned
|
|
21
|
+
# job isn't reaped mid-unwind. Heartbeats are best-effort: a failed beat is
|
|
22
|
+
# swallowed (the job keeps running); only a "cancel" status or host
|
|
23
|
+
# shutdown stops execution.
|
|
24
|
+
class Heartbeat
|
|
25
|
+
# @param client [Object] worker HTTP client exposing #heartbeat(body) (strict)
|
|
26
|
+
# @param worker_id [String]
|
|
27
|
+
# @param job_id [String]
|
|
28
|
+
# @param timeout_seconds [Integer] the job item's timeout (cadence base)
|
|
29
|
+
# @param progress_slot [ProgressSlot]
|
|
30
|
+
# @param cancellation [CancellationToken] tripped on a "cancel" response
|
|
31
|
+
# @param stop [CancellationToken] internal latch; cancel!'d when the
|
|
32
|
+
# handler finishes so the pump exits
|
|
33
|
+
# @param heartbeat_min_ms [Integer] floor on the cadence (ms)
|
|
34
|
+
# @param logger [Logger, nil]
|
|
35
|
+
def initialize(client:, worker_id:, job_id:, timeout_seconds:, progress_slot:,
|
|
36
|
+
cancellation:, stop:, heartbeat_min_ms:, logger: nil)
|
|
37
|
+
@client = client
|
|
38
|
+
@worker_id = worker_id
|
|
39
|
+
@job_id = job_id
|
|
40
|
+
@timeout_seconds = timeout_seconds
|
|
41
|
+
@progress_slot = progress_slot
|
|
42
|
+
@cancellation = cancellation
|
|
43
|
+
@stop = stop
|
|
44
|
+
@heartbeat_min_ms = heartbeat_min_ms
|
|
45
|
+
@logger = logger
|
|
46
|
+
@thread = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The cadence in seconds: max(floor, timeout/3) where floor honors the
|
|
50
|
+
# heartbeat_min_ms seam. Pure / side-effect-free so tests can assert it
|
|
51
|
+
# without spinning a thread.
|
|
52
|
+
def interval_seconds
|
|
53
|
+
floor = @heartbeat_min_ms / 1000.0
|
|
54
|
+
third = @timeout_seconds.to_i / 3 # integer floor-div on seconds, per spec
|
|
55
|
+
[floor, third].max
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Start the pump thread. Emits the immediate beat, then loops on the
|
|
59
|
+
# cadence until `stop` is signalled.
|
|
60
|
+
def start
|
|
61
|
+
@thread = Thread.new do
|
|
62
|
+
Thread.current.name = "flare-heartbeat-#{@job_id}" if Thread.current.respond_to?(:name=)
|
|
63
|
+
run
|
|
64
|
+
end
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Signal the pump to stop (handler finished) and join its thread.
|
|
69
|
+
def stop_and_join(timeout: nil)
|
|
70
|
+
@stop.cancel!
|
|
71
|
+
@thread&.join(timeout)
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def run
|
|
78
|
+
# Immediate beat on claim — lands the job in the forgiving reaper window.
|
|
79
|
+
return if beat_once == :cancel && stopped?
|
|
80
|
+
|
|
81
|
+
interval = interval_seconds
|
|
82
|
+
loop do
|
|
83
|
+
break if @stop.wait(interval) # woke because the handler finished
|
|
84
|
+
|
|
85
|
+
beat_once
|
|
86
|
+
end
|
|
87
|
+
rescue StandardError
|
|
88
|
+
# Pump must never crash the process; a beat error is non-fatal.
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Send one heartbeat. Best-effort: swallow transport/HTTP errors. A
|
|
93
|
+
# "cancel" status trips the cancellation token (but does NOT stop the
|
|
94
|
+
# pump — it keeps beating while the handler unwinds). Returns :cancel,
|
|
95
|
+
# :ok, or :error.
|
|
96
|
+
def beat_once
|
|
97
|
+
body = { "job_id" => @job_id, "worker_id" => @worker_id }
|
|
98
|
+
progress = @progress_slot.get
|
|
99
|
+
body["progress"] = progress unless progress.nil?
|
|
100
|
+
|
|
101
|
+
response = @client.heartbeat(body)
|
|
102
|
+
if response.is_a?(Hash) && response["status"] == "cancel"
|
|
103
|
+
@cancellation.cancel!
|
|
104
|
+
log_cancel
|
|
105
|
+
:cancel
|
|
106
|
+
else
|
|
107
|
+
:ok
|
|
108
|
+
end
|
|
109
|
+
rescue StandardError
|
|
110
|
+
:error
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def stopped?
|
|
114
|
+
@stop.cancelled?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def log_cancel
|
|
118
|
+
@logger&.warn("heartbeat_cancel job_id=#{@job_id}")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module Zeridion
|
|
7
|
+
module Flare
|
|
8
|
+
module Worker
|
|
9
|
+
# Builds the worker id: wrk_{host}_{pid}_{rand}
|
|
10
|
+
#
|
|
11
|
+
# * host is SANITIZED to [A-Za-z0-9_-] (replace "." with "-", strip
|
|
12
|
+
# anything else) so a raw FQDN like "web-01.prod.internal" can't
|
|
13
|
+
# violate the server's `^[A-Za-z0-9_\-]+$` regex — which would 422 and
|
|
14
|
+
# silently kill BOTH register and poll.
|
|
15
|
+
# * rand is >= 12 hex chars (>= 48 bits) from a CSPRNG, so two workers
|
|
16
|
+
# sharing a container hostname (PID 1) don't collide and cross-talk.
|
|
17
|
+
# * the whole id is truncated to <= 200 chars (register's cap). The
|
|
18
|
+
# random suffix is preserved; the HOST portion is trimmed if needed so
|
|
19
|
+
# uniqueness survives truncation.
|
|
20
|
+
module Identity
|
|
21
|
+
MAX_LEN = 200
|
|
22
|
+
RAND_HEX = 12 # 48 bits of entropy
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# @param hostname [String, nil] override host (default: machine hostname)
|
|
27
|
+
# @param pid [Integer] override pid (default: this process)
|
|
28
|
+
# @param rand_hex [Integer] hex chars of CSPRNG entropy (>= 12)
|
|
29
|
+
# @return [String]
|
|
30
|
+
def generate(hostname: nil, pid: Process.pid, rand_hex: RAND_HEX)
|
|
31
|
+
host = sanitize(hostname || default_hostname)
|
|
32
|
+
rand = secure_hex([rand_hex, RAND_HEX].max)
|
|
33
|
+
suffix = "_#{pid}_#{rand}"
|
|
34
|
+
prefix = "wrk_"
|
|
35
|
+
|
|
36
|
+
# Trim the host so prefix + host + suffix <= MAX_LEN, never sacrificing
|
|
37
|
+
# the random suffix (uniqueness lives there).
|
|
38
|
+
budget = MAX_LEN - prefix.length - suffix.length
|
|
39
|
+
host = "w" if host.empty? # never emit "wrk__<pid>_<rand>" with a blank host
|
|
40
|
+
host = host[0, budget] if budget.positive? && host.length > budget
|
|
41
|
+
host = "w" if budget <= 0
|
|
42
|
+
|
|
43
|
+
"#{prefix}#{host}#{suffix}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Sanitize a hostname to the server-allowed alphabet.
|
|
47
|
+
def sanitize(raw)
|
|
48
|
+
s = raw.to_s
|
|
49
|
+
s = s.tr(".", "-") # dotted FQDN segments become dashes
|
|
50
|
+
s = s.gsub(/[^A-Za-z0-9_-]/, "") # strip everything else
|
|
51
|
+
s
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def default_hostname
|
|
55
|
+
Socket.gethostname
|
|
56
|
+
rescue StandardError
|
|
57
|
+
"host"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# n hex characters from a CSPRNG (n rounded up to an even count).
|
|
61
|
+
def secure_hex(n)
|
|
62
|
+
bytes = (n + 1) / 2
|
|
63
|
+
SecureRandom.hex(bytes)[0, n]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zeridion
|
|
4
|
+
module Flare
|
|
5
|
+
# Mixin marking a class as a Flare payload job. Include it, optionally set
|
|
6
|
+
# defaults with the `flare_options` macro, and implement `#perform`:
|
|
7
|
+
#
|
|
8
|
+
# class SendWelcomeEmail
|
|
9
|
+
# include Zeridion::Flare::Job
|
|
10
|
+
# flare_options queue: "email", max_attempts: 5, timeout: 120
|
|
11
|
+
#
|
|
12
|
+
# def perform(payload, ctx)
|
|
13
|
+
# return if ctx.cancelled?
|
|
14
|
+
# return if AlreadySent.exists?(ctx.job_id) # at-least-once → idempotent
|
|
15
|
+
# Mailer.welcome(payload["email"]).deliver_now
|
|
16
|
+
# ctx.report_progress(1.0)
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# The `included` hook registers the class into the module-level worker
|
|
21
|
+
# registry at definition time (no ObjectSpace scan). job_type defaults to the
|
|
22
|
+
# class's fully-qualified name; override it with `flare_options job_type:`.
|
|
23
|
+
# Two classes claiming the same job_type raise DuplicateJobTypeError.
|
|
24
|
+
#
|
|
25
|
+
# payload is the decoded JSON Hash with STRING keys by default. Opt into a
|
|
26
|
+
# typed payload with `flare_options payload_struct: MyStruct` — the worker
|
|
27
|
+
# then calls `MyStruct.new(**symbolized_hash)` (works with Struct / Data /
|
|
28
|
+
# any keyword-initializable class).
|
|
29
|
+
module Job
|
|
30
|
+
def self.included(base)
|
|
31
|
+
base.extend(ClassMacros)
|
|
32
|
+
base.include(InstanceShape)
|
|
33
|
+
Worker::Registry.register_payload(base)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# `flare_options ...` and the resolved config readers, mixed into the
|
|
37
|
+
# including class as class methods.
|
|
38
|
+
module ClassMacros
|
|
39
|
+
# Set per-class defaults. All keys optional.
|
|
40
|
+
#
|
|
41
|
+
# @param job_type [String, nil] explicit routing type (default: class FQN)
|
|
42
|
+
# @param queue [String]
|
|
43
|
+
# @param max_attempts [Integer]
|
|
44
|
+
# @param timeout [Integer] seconds
|
|
45
|
+
# @param payload_struct [Class, nil] keyword-initializable payload type
|
|
46
|
+
def flare_options(job_type: nil, queue: nil, max_attempts: nil, timeout: nil, payload_struct: nil)
|
|
47
|
+
@flare_job_type = job_type unless job_type.nil?
|
|
48
|
+
@flare_queue = queue unless queue.nil?
|
|
49
|
+
@flare_max_attempts = max_attempts unless max_attempts.nil?
|
|
50
|
+
@flare_timeout = timeout unless timeout.nil?
|
|
51
|
+
@flare_payload_struct = payload_struct unless payload_struct.nil?
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def flare_job_type
|
|
56
|
+
@flare_job_type || name ||
|
|
57
|
+
raise(ArgumentError, "anonymous job class needs an explicit flare_options(job_type:)")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def flare_queue
|
|
61
|
+
@flare_queue || "default"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def flare_max_attempts
|
|
65
|
+
@flare_max_attempts || 3
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def flare_timeout
|
|
69
|
+
@flare_timeout || 1800
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def flare_payload_struct
|
|
73
|
+
@flare_payload_struct
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Guards against forgetting `#perform`.
|
|
78
|
+
module InstanceShape
|
|
79
|
+
def perform(_payload, _ctx)
|
|
80
|
+
raise NotImplementedError, "#{self.class.name} must implement #perform(payload, ctx)"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|