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.
@@ -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