busybee 0.1.0 → 0.3.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -7
  3. data/README.md +70 -42
  4. data/docs/client/quick_start.md +279 -0
  5. data/docs/client.md +825 -0
  6. data/docs/configuration.md +550 -0
  7. data/docs/grpc.md +50 -25
  8. data/docs/testing.md +118 -28
  9. data/docs/workers.md +982 -0
  10. data/exe/busybee +6 -0
  11. data/lib/busybee/cli.rb +173 -0
  12. data/lib/busybee/client/error_handling.rb +37 -0
  13. data/lib/busybee/client/job_operations.rb +236 -0
  14. data/lib/busybee/client/message_operations.rb +84 -0
  15. data/lib/busybee/client/process_operations.rb +108 -0
  16. data/lib/busybee/client/variable_operations.rb +64 -0
  17. data/lib/busybee/client.rb +87 -0
  18. data/lib/busybee/configure.rb +290 -0
  19. data/lib/busybee/credentials/camunda_cloud.rb +58 -0
  20. data/lib/busybee/credentials/insecure.rb +24 -0
  21. data/lib/busybee/credentials/oauth.rb +157 -0
  22. data/lib/busybee/credentials/tls.rb +43 -0
  23. data/lib/busybee/credentials.rb +200 -0
  24. data/lib/busybee/defaults.rb +20 -0
  25. data/lib/busybee/error.rb +50 -0
  26. data/lib/busybee/grpc/error.rb +60 -0
  27. data/lib/busybee/grpc.rb +2 -2
  28. data/lib/busybee/job.rb +219 -0
  29. data/lib/busybee/job_stream.rb +85 -0
  30. data/lib/busybee/logging.rb +61 -0
  31. data/lib/busybee/railtie.rb +113 -0
  32. data/lib/busybee/runner/hybrid.rb +64 -0
  33. data/lib/busybee/runner/multi.rb +101 -0
  34. data/lib/busybee/runner/polling.rb +54 -0
  35. data/lib/busybee/runner/streaming.rb +159 -0
  36. data/lib/busybee/runner.rb +97 -0
  37. data/lib/busybee/runtime_config.rb +184 -0
  38. data/lib/busybee/serialization.rb +100 -0
  39. data/lib/busybee/testing/activated_job.rb +33 -8
  40. data/lib/busybee/testing/helpers/execution.rb +139 -0
  41. data/lib/busybee/testing/helpers/support.rb +78 -0
  42. data/lib/busybee/testing/helpers.rb +56 -66
  43. data/lib/busybee/testing/matchers/complete_job.rb +55 -0
  44. data/lib/busybee/testing/matchers/fail_job.rb +75 -0
  45. data/lib/busybee/testing/matchers/have_activated.rb +1 -1
  46. data/lib/busybee/testing/matchers/have_available_jobs.rb +44 -0
  47. data/lib/busybee/testing/matchers/throw_bpmn_error_on.rb +72 -0
  48. data/lib/busybee/testing.rb +5 -33
  49. data/lib/busybee/version.rb +1 -1
  50. data/lib/busybee/worker/configuration.rb +287 -0
  51. data/lib/busybee/worker/dsl.rb +187 -0
  52. data/lib/busybee/worker/shutdown.rb +27 -0
  53. data/lib/busybee/worker.rb +130 -0
  54. data/lib/busybee.rb +134 -2
  55. metadata +80 -3
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+
5
+ module Busybee
6
+ class Worker
7
+ # Stores all DSL-declared metadata for a Worker subclass.
8
+ # Lazily instantiated per worker class via Worker.configuration.
9
+ class Configuration # rubocop:disable Metrics/ClassLength
10
+ VALID_TYPES = %w[string integer decimal boolean datetime duration uuid null].freeze
11
+ VALID_SOURCES = %i[variable header].freeze
12
+ VALID_WORKER_MODES = %i[polling streaming hybrid].freeze
13
+ VALID_POLLING_KWARGS = %i[max_jobs request_timeout].freeze
14
+ VALID_STREAMING_KWARGS = %i[buffer buffer_throttle].freeze
15
+
16
+ # Represents a declared input (from variable, header, or both).
17
+ Input = Struct.new(:name, :source, :required, :type, :description, :default,
18
+ :accessor_name, :define_accessor, keyword_init: true)
19
+
20
+ # Represents a declared output returned from perform.
21
+ Output = Struct.new(:name, :required, :type, :description, keyword_init: true)
22
+
23
+ attr_accessor :description
24
+ attr_reader :inputs, :outputs, :worker_mode, :polling_config, :streaming_config,
25
+ :job_timeout, :backoff, :backpressure_delay,
26
+ :complete_job_on_success, :fail_job_on_error, :shutdown_on
27
+
28
+ def initialize(worker_class)
29
+ @worker_class = worker_class
30
+ @job_type = nil
31
+ @description = nil
32
+ @inputs = []
33
+ @outputs = []
34
+ @worker_mode = nil
35
+ @polling_config = {}
36
+ @streaming_config = {}
37
+ @job_timeout = nil
38
+ @backoff = nil
39
+ @backpressure_delay = nil
40
+ @complete_job_on_success = true
41
+ @fail_job_on_error = true
42
+ @shutdown_on = []
43
+ end
44
+
45
+ def job_type
46
+ @job_type || derive_default_job_type
47
+ end
48
+
49
+ def job_type=(value)
50
+ @job_type = value.to_s
51
+ end
52
+
53
+ def worker_mode=(value)
54
+ sym = value.to_sym
55
+ unless VALID_WORKER_MODES.include?(sym)
56
+ raise InvalidWorkerDefinition,
57
+ "Invalid worker mode #{value.inspect}. Valid: #{VALID_WORKER_MODES.map(&:inspect).join(', ')}"
58
+ end
59
+
60
+ @worker_mode = sym
61
+ end
62
+
63
+ def polling_config=(kwargs)
64
+ unknown = kwargs.keys - VALID_POLLING_KWARGS
65
+ if unknown.any?
66
+ raise InvalidWorkerDefinition,
67
+ "Unknown polling config: #{unknown.map(&:inspect).join(', ')}. " \
68
+ "Valid: #{VALID_POLLING_KWARGS.map(&:inspect).join(', ')}"
69
+ end
70
+
71
+ @polling_config = kwargs
72
+ end
73
+
74
+ def streaming_config=(kwargs)
75
+ unknown = kwargs.keys - VALID_STREAMING_KWARGS
76
+ if unknown.any?
77
+ raise InvalidWorkerDefinition,
78
+ "Unknown streaming config: #{unknown.map(&:inspect).join(', ')}. " \
79
+ "Valid: #{VALID_STREAMING_KWARGS.map(&:inspect).join(', ')}"
80
+ end
81
+
82
+ validate_buffer_option!(kwargs) if kwargs.key?(:buffer)
83
+ validate_no_throttle_without_buffer!(kwargs)
84
+ validate_buffer_throttle!(kwargs) if kwargs.key?(:buffer_throttle)
85
+
86
+ @streaming_config = kwargs
87
+ end
88
+
89
+ def job_timeout=(value)
90
+ validate_duration!(:job_timeout, value)
91
+ @job_timeout = value
92
+ end
93
+
94
+ def backoff=(value)
95
+ validate_duration!(:backoff, value)
96
+ @backoff = value
97
+ end
98
+
99
+ def backpressure_delay=(value)
100
+ validate_duration!(:backpressure_delay, value)
101
+ @backpressure_delay = value
102
+ end
103
+
104
+ def complete_job_on_success=(value)
105
+ unless [true, false].include?(value)
106
+ raise InvalidWorkerDefinition, "`complete_job_on_success` requires a boolean, got #{value.inspect}"
107
+ end
108
+
109
+ @complete_job_on_success = value
110
+ end
111
+
112
+ def fail_job_on_error=(value)
113
+ unless [true, false].include?(value)
114
+ raise InvalidWorkerDefinition, "`fail_job_on_error` requires a boolean, got #{value.inspect}"
115
+ end
116
+
117
+ @fail_job_on_error = value
118
+ end
119
+
120
+ def add_shutdown_on(*exception_classes)
121
+ exception_classes.each do |klass|
122
+ unless klass.is_a?(Class) && klass <= Exception
123
+ raise InvalidWorkerDefinition,
124
+ "`shutdown_on` expects exception classes, got #{klass.inspect}"
125
+ end
126
+ end
127
+
128
+ @shutdown_on |= exception_classes
129
+ end
130
+
131
+ def add_input(input)
132
+ validate_name!(input.name, "input")
133
+ validate_source!(input)
134
+ validate_type!(input.name, input.type, "input") if input.type
135
+ validate_accessor_options!(input)
136
+ validate_unique_input!(input.name)
137
+
138
+ @inputs << input
139
+ end
140
+
141
+ def add_output(output)
142
+ validate_name!(output.name, "output")
143
+ validate_type!(output.name, output.type, "output") if output.type
144
+ validate_unique_output!(output.name)
145
+
146
+ @outputs << output
147
+ end
148
+
149
+ # Resolved buffer throttle for the streaming pump thread.
150
+ # Returns false (no throttling), 0 (minimal throttle), or a positive Numeric (ms).
151
+ def buffer_throttle
152
+ streaming_config.fetch(:buffer_throttle, Busybee.default_buffer_throttle)
153
+ end
154
+
155
+ # Whether this worker uses a pump thread + buffer for streaming.
156
+ # Default: true. Set to false via `streaming buffer: false` for inline stream processing.
157
+ def buffer?
158
+ streaming_config.fetch(:buffer, Busybee::Defaults::DEFAULT_STREAMING_BUFFER)
159
+ end
160
+
161
+ # Returns resolved polling options for client.with_each_job, merging
162
+ # DSL overrides with gem-level defaults.
163
+ def polling_options
164
+ {
165
+ max_jobs: polling_config[:max_jobs] || Busybee::Defaults::DEFAULT_MAX_JOBS,
166
+ request_timeout: polling_config[:request_timeout] || Busybee.default_job_request_timeout,
167
+ job_timeout: job_timeout || Busybee.default_job_lock_timeout
168
+ }
169
+ end
170
+
171
+ # Returns resolved streaming options for client.open_job_stream.
172
+ def streaming_options
173
+ { job_timeout: job_timeout || Busybee.default_job_lock_timeout }
174
+ end
175
+
176
+ def to_h
177
+ {
178
+ job_type: job_type,
179
+ description: description,
180
+ inputs: inputs.map(&:to_h),
181
+ outputs: outputs.map(&:to_h),
182
+ worker_mode: worker_mode,
183
+ polling_config: polling_config,
184
+ streaming_config: streaming_config,
185
+ job_timeout: job_timeout,
186
+ backoff: backoff,
187
+ backpressure_delay: backpressure_delay,
188
+ complete_job_on_success: complete_job_on_success,
189
+ fail_job_on_error: fail_job_on_error,
190
+ shutdown_on: shutdown_on
191
+ }
192
+ end
193
+
194
+ private
195
+
196
+ def derive_default_job_type
197
+ class_name = @worker_class.name
198
+ return "worker" if class_name.nil?
199
+
200
+ last_segment = class_name.split("::").last
201
+ last_segment = last_segment.delete_suffix("Worker") if last_segment != "Worker"
202
+ last_segment.underscore
203
+ end
204
+
205
+ def validate_name!(name, kind)
206
+ raise InvalidWorkerDefinition, "Name is required for all #{kind}s" if name.nil? || name.to_s.strip.empty?
207
+ end
208
+
209
+ def validate_source!(input)
210
+ sources = Array(input.source)
211
+ raise InvalidWorkerDefinition, "`source:` is required for input :#{input.name}" if sources.empty?
212
+
213
+ invalid = sources - VALID_SOURCES
214
+ unless invalid.empty?
215
+ raise InvalidWorkerDefinition,
216
+ "Invalid source #{invalid.map(&:inspect).join(', ')} for input :#{input.name}. " \
217
+ "Valid: #{VALID_SOURCES.map(&:inspect).join(', ')}"
218
+ end
219
+
220
+ # Deduplicate sources (e.g., [:variable, :variable] → [:variable])
221
+ input.source = sources.uniq
222
+ end
223
+
224
+ def validate_type!(name, type, kind)
225
+ return if VALID_TYPES.include?(type.to_s)
226
+
227
+ raise InvalidWorkerDefinition,
228
+ "Invalid type #{type.inspect} for #{kind} :#{name}. " \
229
+ "Valid: #{VALID_TYPES.join(', ')}"
230
+ end
231
+
232
+ def validate_accessor_options!(input)
233
+ return unless !input.define_accessor && input.accessor_name
234
+
235
+ raise InvalidWorkerDefinition,
236
+ "`define_accessor: false` and `accessor_name:` are mutually exclusive on input :#{input.name} " \
237
+ "— no accessor will be defined, so naming it is meaningless"
238
+ end
239
+
240
+ def validate_unique_input!(name)
241
+ return unless @inputs.any? { |i| i.name == name }
242
+
243
+ raise InvalidWorkerDefinition, "Input :#{name} is already declared"
244
+ end
245
+
246
+ def validate_unique_output!(name)
247
+ return unless @outputs.any? { |o| o.name == name }
248
+
249
+ raise InvalidWorkerDefinition, "Output :#{name} is already declared"
250
+ end
251
+
252
+ def validate_buffer_option!(kwargs)
253
+ return if [true, false].include?(kwargs[:buffer])
254
+
255
+ raise InvalidWorkerDefinition, "`buffer:` requires a boolean, got #{kwargs[:buffer].inspect}"
256
+ end
257
+
258
+ def validate_no_throttle_without_buffer!(kwargs)
259
+ return unless kwargs[:buffer] == false && kwargs.key?(:buffer_throttle)
260
+
261
+ raise InvalidWorkerDefinition,
262
+ "`buffer_throttle:` cannot be set when `buffer: false` — there is no buffer to throttle"
263
+ end
264
+
265
+ def validate_buffer_throttle!(kwargs)
266
+ # Coerce: true → 0 ("enable at minimal setting"), nil → false ("no throttling")
267
+ kwargs[:buffer_throttle] = 0 if kwargs[:buffer_throttle] == true
268
+ kwargs[:buffer_throttle] = false if kwargs[:buffer_throttle].nil?
269
+
270
+ value = kwargs[:buffer_throttle]
271
+ return if value == false
272
+ return if value.is_a?(Numeric) && value >= 0
273
+
274
+ raise InvalidWorkerDefinition,
275
+ "`buffer_throttle:` must be a non-negative Numeric, got #{value.inspect}"
276
+ end
277
+
278
+ def validate_duration!(attr, value)
279
+ return if value.is_a?(Integer)
280
+ return if defined?(ActiveSupport::Duration) && value.is_a?(ActiveSupport::Duration)
281
+
282
+ raise InvalidWorkerDefinition,
283
+ "`#{attr}` accepts Integer (milliseconds) or ActiveSupport::Duration, got #{value.class}"
284
+ end
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "busybee/worker/configuration"
4
+
5
+ module Busybee
6
+ class Worker
7
+ # Class-level DSL for declaring worker metadata and configuration.
8
+ # Extended into Worker subclasses.
9
+ module DSL
10
+ # Sentinel for distinguishing "not passed" from "passed as nil".
11
+ NOT_SET = Object.new.freeze
12
+ private_constant :NOT_SET
13
+
14
+ # Ruby keywords that can't be used as bare method calls (obj.keyword is a syntax error).
15
+ RUBY_KEYWORDS = %w[
16
+ __FILE__ __LINE__ __ENCODING__
17
+ BEGIN END
18
+ alias and begin break case class def defined? do
19
+ else elsif end ensure false for if in module next
20
+ nil not or redo rescue retry return self super
21
+ then true undef unless until when while yield
22
+ ].freeze
23
+ private_constant :RUBY_KEYWORDS
24
+
25
+ def configuration
26
+ @configuration ||= Configuration.new(self)
27
+ end
28
+
29
+ def job_type(value = nil)
30
+ if value.nil?
31
+ configuration.job_type
32
+ else
33
+ configuration.job_type = value
34
+ end
35
+ end
36
+
37
+ def description(value = nil)
38
+ if value.nil?
39
+ configuration.description
40
+ else
41
+ configuration.description = value
42
+ end
43
+ end
44
+
45
+ def input(name, source:, required: NOT_SET, type: nil, description: nil, # rubocop:disable Metrics/ParameterLists
46
+ default: NOT_SET, accessor_name: nil, define_accessor: true)
47
+ validate_required_default_exclusivity!(name, required, default)
48
+
49
+ input_struct = Configuration::Input.new(
50
+ name: name.to_sym,
51
+ source: normalize_source(source),
52
+ required: resolve_required(required, default),
53
+ type: type,
54
+ description: description,
55
+ default: default == NOT_SET ? nil : default,
56
+ accessor_name: accessor_name&.to_sym,
57
+ define_accessor: define_accessor
58
+ )
59
+ configuration.add_input(input_struct)
60
+ define_input_accessor(input_struct) if define_accessor
61
+ end
62
+
63
+ def variable(name, **)
64
+ input(name, source: :variable, **)
65
+ end
66
+
67
+ def header(name, **)
68
+ input(name, source: :header, **)
69
+ end
70
+
71
+ def output(name, required: NOT_SET, type: nil, description: nil)
72
+ output_struct = Configuration::Output.new(
73
+ name: name.to_sym,
74
+ required: required == NOT_SET ? Busybee.default_output_required : !!required,
75
+ type: type,
76
+ description: description
77
+ )
78
+ configuration.add_output(output_struct)
79
+ end
80
+
81
+ def worker_mode(value)
82
+ configuration.worker_mode = value
83
+ end
84
+
85
+ def polling(**kwargs)
86
+ configuration.polling_config = kwargs
87
+ end
88
+
89
+ def streaming(**kwargs)
90
+ configuration.streaming_config = kwargs
91
+ end
92
+
93
+ def job_timeout(value)
94
+ configuration.job_timeout = value
95
+ end
96
+
97
+ def backoff(value)
98
+ configuration.backoff = value
99
+ end
100
+
101
+ def backpressure_delay(value = nil)
102
+ if value.nil?
103
+ configuration.backpressure_delay
104
+ else
105
+ configuration.backpressure_delay = value
106
+ end
107
+ end
108
+
109
+ def complete_job_on_success(value = nil)
110
+ if value.nil?
111
+ configuration.complete_job_on_success
112
+ else
113
+ configuration.complete_job_on_success = value
114
+ end
115
+ end
116
+
117
+ def fail_job_on_error(value = nil)
118
+ if value.nil?
119
+ configuration.fail_job_on_error
120
+ else
121
+ configuration.fail_job_on_error = value
122
+ end
123
+ end
124
+
125
+ def shutdown_on(*exception_classes)
126
+ if exception_classes.empty?
127
+ configuration.shutdown_on
128
+ else
129
+ configuration.add_shutdown_on(*exception_classes)
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def normalize_source(source)
136
+ Array(source).map(&:to_sym)
137
+ end
138
+
139
+ def resolve_required(required, default)
140
+ # If default is provided and required wasn't explicitly set, input is not required
141
+ return false if required == NOT_SET && default != NOT_SET
142
+
143
+ required == NOT_SET ? Busybee.default_input_required : !!required
144
+ end
145
+
146
+ def validate_required_default_exclusivity!(name, required, default)
147
+ return unless required != NOT_SET && default != NOT_SET
148
+
149
+ raise InvalidWorkerDefinition,
150
+ "`required:` and `default:` are mutually exclusive on input :#{name} " \
151
+ "— a default makes the input always available, so `required:` is meaningless"
152
+ end
153
+
154
+ def define_input_accessor(input)
155
+ method_name = input.accessor_name || input.name
156
+ validate_accessor_name!(method_name)
157
+
158
+ sources = Array(input.source)
159
+ default_value = input.default
160
+
161
+ define_method(method_name) do
162
+ sources.each do |src|
163
+ v = public_send(:"#{src}s")[input.name.to_s]
164
+ return v unless v.nil?
165
+ end
166
+ default_value&.clone
167
+ end
168
+ end
169
+
170
+ def validate_accessor_name!(name)
171
+ str_name = name.to_s
172
+
173
+ if RUBY_KEYWORDS.include?(str_name)
174
+ raise InvalidWorkerDefinition,
175
+ "Cannot define accessor :#{name} — it's a Ruby keyword. " \
176
+ "Use `accessor_name:` or `define_accessor: false`"
177
+ end
178
+
179
+ return unless method_defined?(name) || private_method_defined?(name)
180
+
181
+ raise InvalidWorkerDefinition,
182
+ "A method :#{name} already exists on #{self.name || 'this worker'}. " \
183
+ "Use `accessor_name:` or `define_accessor: false`"
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "busybee/error"
4
+
5
+ module Busybee
6
+ class Worker
7
+ # Raised when a `shutdown_on` exception is caught during perform_job.
8
+ # Signals to the Runner that the worker process should shut down.
9
+ # The original exception is available via `cause` (set by Ruby at raise time).
10
+ class Shutdown < Busybee::Error
11
+ attr_reader :worker_class
12
+
13
+ def initialize(message = "Shutting down worker #{Busybee.worker_name}", worker:)
14
+ @worker_class = worker
15
+ super(message)
16
+ end
17
+
18
+ def message
19
+ super.dup.tap do |msg|
20
+ msg << " due to #{cause&.class&.name || 'error'}"
21
+ msg << " in #{worker_class.name}" if worker_class&.name
22
+ msg << ": \"#{cause.message}\"" if cause
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+ require "busybee/worker/configuration"
5
+ require "busybee/worker/dsl"
6
+ require "busybee/worker/shutdown"
7
+
8
+ module Busybee
9
+ # Base class for defining job workers.
10
+ #
11
+ # Subclass and implement `perform` to handle jobs from the workflow engine.
12
+ # Runners call `perform_job(job)` as the entry point (internal use only).
13
+ #
14
+ # @example Minimal worker
15
+ # class ProcessOrderWorker < Busybee::Worker
16
+ # job_type "process-order"
17
+ #
18
+ # def perform
19
+ # order = Order.find(variables[:order_id])
20
+ # order.process!
21
+ # complete!(status: order.status)
22
+ # end
23
+ # end
24
+ #
25
+ class Worker
26
+ extend DSL
27
+
28
+ attr_reader :job
29
+
30
+ delegate :variables, :headers, :complete!, :fail!, :throw_bpmn_error!,
31
+ :update_retries, :update_timeout, to: :job
32
+
33
+ def initialize(job)
34
+ @job = job
35
+ end
36
+
37
+ def perform
38
+ raise NotImplementedError,
39
+ "#{self.class.name} must implement `perform`"
40
+ end
41
+
42
+ class << self
43
+ # Entry point called by Runners. Instantiates the worker, validates inputs,
44
+ # calls perform, and applies lifecycle behaviors (auto-complete, auto-fail, shutdown).
45
+ #
46
+ # Contract with Runners:
47
+ # - Returns normally: job was handled, runner continues.
48
+ # - Raises Shutdown: worker is unhealthy, runner should shut down.
49
+ #
50
+ # @param job [Busybee::Job] An activated job from the workflow engine
51
+ # @return [Object] The return value of perform (useful for testing)
52
+ # @raise [Busybee::Worker::Shutdown] if a shutdown_on exception is caught
53
+ def perform_job(job)
54
+ config = configuration
55
+ instance = new(job)
56
+ # [hook: job.started]
57
+ begin
58
+ validate_inputs!(instance, config)
59
+ result = instance.perform
60
+ handle_success(job, result, config)
61
+ result
62
+ rescue StandardError => e
63
+ handle_failure(job, e, config)
64
+ raise if e.is_a?(Shutdown)
65
+ raise Shutdown.new(worker: self) if shutdown_error?(e, config)
66
+
67
+ log_unhandled_error(job, e) unless config.fail_job_on_error
68
+ ensure # rubocop:disable Lint/EmptyEnsure
69
+ # [hook: job.finished]
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def validate_inputs!(instance, config)
76
+ missing = config.inputs.select(&:required).reject { |input| input_present?(instance, input) }
77
+ return if missing.empty?
78
+
79
+ names = missing.map { |i| ":#{i.name}" }.join(", ")
80
+ raise Busybee::MissingInput, "Missing required inputs for #{configuration.job_type} worker: #{names}"
81
+ end
82
+
83
+ def input_present?(instance, input)
84
+ Array(input.source).any? { |src| !instance.public_send(:"#{src}s")[input.name.to_s].nil? }
85
+ end
86
+
87
+ def handle_success(job, result, config)
88
+ return unless config.complete_job_on_success && job.ready?
89
+
90
+ new_vars = result.is_a?(Hash) ? result : {}
91
+ validate_outputs!(new_vars, config)
92
+ begin
93
+ job.complete!(new_vars)
94
+ rescue StandardError => e
95
+ Busybee.logger&.warn("Failed to complete job #{job.key}: #{e.message}. Job will timeout and retry.")
96
+ end
97
+ end
98
+
99
+ def validate_outputs!(result, config)
100
+ missing = config.outputs.select(&:required).reject { |o| result.key?(o.name) || result.key?(o.name.to_s) }
101
+ return if missing.empty?
102
+
103
+ names = missing.map { |o| ":#{o.name}" }.join(", ")
104
+ raise Busybee::MissingOutput, "Missing required outputs for #{configuration.job_type} worker: #{names}"
105
+ end
106
+
107
+ def handle_failure(job, error, config)
108
+ return unless config.fail_job_on_error && job.ready?
109
+
110
+ fail_with = error.is_a?(Shutdown) ? (error.cause || error) : error
111
+ begin
112
+ job.fail!(fail_with, backoff: config.backoff)
113
+ rescue StandardError => e
114
+ Busybee.logger&.warn("Failed to fail job #{job.key}: #{e.message}. Job will timeout and retry.")
115
+ end
116
+ end
117
+
118
+ def shutdown_error?(error, config)
119
+ (config.shutdown_on + Busybee.shutdown_on_errors).any? { |klass| error.is_a?(klass) }
120
+ end
121
+
122
+ def log_unhandled_error(job, error)
123
+ Busybee.logger&.warn(
124
+ "Unhandled error in #{configuration.job_type} worker for job #{job.key} " \
125
+ "(fail_job_on_error is off): [#{error.class}] #{error.message}. Job will timeout and retry."
126
+ )
127
+ end
128
+ end
129
+ end
130
+ end