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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +71 -7
- data/README.md +70 -42
- data/docs/client/quick_start.md +279 -0
- data/docs/client.md +825 -0
- data/docs/configuration.md +550 -0
- data/docs/grpc.md +50 -25
- data/docs/testing.md +118 -28
- data/docs/workers.md +982 -0
- data/exe/busybee +6 -0
- data/lib/busybee/cli.rb +173 -0
- data/lib/busybee/client/error_handling.rb +37 -0
- data/lib/busybee/client/job_operations.rb +236 -0
- data/lib/busybee/client/message_operations.rb +84 -0
- data/lib/busybee/client/process_operations.rb +108 -0
- data/lib/busybee/client/variable_operations.rb +64 -0
- data/lib/busybee/client.rb +87 -0
- data/lib/busybee/configure.rb +290 -0
- data/lib/busybee/credentials/camunda_cloud.rb +58 -0
- data/lib/busybee/credentials/insecure.rb +24 -0
- data/lib/busybee/credentials/oauth.rb +157 -0
- data/lib/busybee/credentials/tls.rb +43 -0
- data/lib/busybee/credentials.rb +200 -0
- data/lib/busybee/defaults.rb +20 -0
- data/lib/busybee/error.rb +50 -0
- data/lib/busybee/grpc/error.rb +60 -0
- data/lib/busybee/grpc.rb +2 -2
- data/lib/busybee/job.rb +219 -0
- data/lib/busybee/job_stream.rb +85 -0
- data/lib/busybee/logging.rb +61 -0
- data/lib/busybee/railtie.rb +113 -0
- data/lib/busybee/runner/hybrid.rb +64 -0
- data/lib/busybee/runner/multi.rb +101 -0
- data/lib/busybee/runner/polling.rb +54 -0
- data/lib/busybee/runner/streaming.rb +159 -0
- data/lib/busybee/runner.rb +97 -0
- data/lib/busybee/runtime_config.rb +184 -0
- data/lib/busybee/serialization.rb +100 -0
- data/lib/busybee/testing/activated_job.rb +33 -8
- data/lib/busybee/testing/helpers/execution.rb +139 -0
- data/lib/busybee/testing/helpers/support.rb +78 -0
- data/lib/busybee/testing/helpers.rb +56 -66
- data/lib/busybee/testing/matchers/complete_job.rb +55 -0
- data/lib/busybee/testing/matchers/fail_job.rb +75 -0
- data/lib/busybee/testing/matchers/have_activated.rb +1 -1
- data/lib/busybee/testing/matchers/have_available_jobs.rb +44 -0
- data/lib/busybee/testing/matchers/throw_bpmn_error_on.rb +72 -0
- data/lib/busybee/testing.rb +5 -33
- data/lib/busybee/version.rb +1 -1
- data/lib/busybee/worker/configuration.rb +287 -0
- data/lib/busybee/worker/dsl.rb +187 -0
- data/lib/busybee/worker/shutdown.rb +27 -0
- data/lib/busybee/worker.rb +130 -0
- data/lib/busybee.rb +134 -2
- 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
|