busybee 0.2.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 +42 -6
- data/README.md +37 -20
- data/docs/client.md +1 -1
- data/docs/configuration.md +158 -3
- data/docs/grpc.md +1 -1
- data/docs/testing.md +28 -23
- data/docs/workers.md +982 -0
- data/exe/busybee +6 -0
- data/lib/busybee/cli.rb +173 -0
- data/lib/busybee/client/job_operations.rb +14 -6
- data/lib/busybee/configure.rb +290 -0
- data/lib/busybee/defaults.rb +7 -1
- data/lib/busybee/error.rb +23 -0
- data/lib/busybee/job.rb +28 -2
- data/lib/busybee/logging.rb +11 -6
- data/lib/busybee/railtie.rb +28 -4
- 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/testing/activated_job.rb +13 -0
- data/lib/busybee/testing/helpers/execution.rb +139 -0
- data/lib/busybee/testing/helpers/support.rb +1 -1
- data/lib/busybee/testing/helpers.rb +4 -2
- 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/throw_bpmn_error_on.rb +72 -0
- data/lib/busybee/testing.rb +4 -18
- 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 +70 -62
- metadata +42 -3
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Busybee
|
|
6
|
+
# Operator-specified runtime configuration, typically from CLI flags or YAML.
|
|
7
|
+
#
|
|
8
|
+
# Two-phase lifecycle:
|
|
9
|
+
# 1. Constructed sparse (only fields the operator explicitly set)
|
|
10
|
+
# 2. After resolve_for(worker_class), fully resolved through the precedence chain:
|
|
11
|
+
# per-worker RuntimeConfig → global RuntimeConfig → worker DSL → gem defaults
|
|
12
|
+
#
|
|
13
|
+
# Fields are divided into two groups:
|
|
14
|
+
# - Worker-scoped: participate in the full 4-level precedence chain including
|
|
15
|
+
# per-worker overrides. Passed to runner constructors.
|
|
16
|
+
# - Process-wide: resolve at global RC → gem default only (no per-worker step).
|
|
17
|
+
# Applied to gem-level config before runners start.
|
|
18
|
+
#
|
|
19
|
+
# Runners hold the resolved config at runtime.
|
|
20
|
+
class RuntimeConfig
|
|
21
|
+
VALID_WORKER_MODES = %i[polling streaming hybrid].freeze
|
|
22
|
+
VALID_LOG_FORMATS = %i[text json].freeze
|
|
23
|
+
|
|
24
|
+
# Top-level keys allowed in YAML config (worker-scoped fields + workers).
|
|
25
|
+
WORKER_SCOPED_KEYS = %i[worker_mode backpressure_delay max_jobs request_timeout
|
|
26
|
+
buffer buffer_throttle job_timeout backoff].freeze
|
|
27
|
+
VALID_YAML_KEYS = (WORKER_SCOPED_KEYS + %i[workers]).freeze
|
|
28
|
+
PROCESS_WIDE_KEYS = %i[log_format worker_name cluster_address].freeze
|
|
29
|
+
|
|
30
|
+
# Parses a YAML config file and returns a kwargs hash suitable for
|
|
31
|
+
# RuntimeConfig.new(**result). Raw YAML types flow through — the
|
|
32
|
+
# constructor handles coercion (e.g., string → symbol for worker_mode).
|
|
33
|
+
def self.parse_yaml(path)
|
|
34
|
+
raw = YAML.safe_load_file(path) || {}
|
|
35
|
+
result = {}
|
|
36
|
+
|
|
37
|
+
raw.each do |key, value|
|
|
38
|
+
sym_key = key.to_sym
|
|
39
|
+
validate_yaml_key!(sym_key)
|
|
40
|
+
if sym_key == :workers
|
|
41
|
+
result[:workers] = parse_workers(value)
|
|
42
|
+
else
|
|
43
|
+
result[sym_key] = value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
result
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.validate_yaml_key!(key)
|
|
51
|
+
if PROCESS_WIDE_KEYS.include?(key)
|
|
52
|
+
raise ArgumentError, "#{key} is CLI-only and cannot be set in YAML. Use the corresponding CLI flag instead."
|
|
53
|
+
end
|
|
54
|
+
return if VALID_YAML_KEYS.include?(key)
|
|
55
|
+
|
|
56
|
+
raise ArgumentError,
|
|
57
|
+
"Unrecognized YAML key: #{key}. Valid keys: #{VALID_YAML_KEYS.join(', ')}"
|
|
58
|
+
end
|
|
59
|
+
private_class_method :validate_yaml_key!
|
|
60
|
+
|
|
61
|
+
def self.parse_workers(workers_list)
|
|
62
|
+
return {} unless workers_list
|
|
63
|
+
|
|
64
|
+
workers_list.each_with_object({}) do |entry, acc|
|
|
65
|
+
name, overrides = case entry
|
|
66
|
+
when String then [entry, {}]
|
|
67
|
+
when Hash then extract_worker_entry(entry)
|
|
68
|
+
end
|
|
69
|
+
validate_worker_override_keys!(name, overrides)
|
|
70
|
+
acc[name.to_s] = overrides
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
private_class_method :parse_workers
|
|
74
|
+
|
|
75
|
+
# A worker entry hash comes in two forms depending on YAML indentation:
|
|
76
|
+
# Nested: { "Worker" => { "max_jobs" => 32 } }
|
|
77
|
+
# Flat: { "Worker" => nil, "max_jobs" => 32 }
|
|
78
|
+
def self.extract_worker_entry(hash)
|
|
79
|
+
first_key, first_value = hash.first
|
|
80
|
+
if first_value.is_a?(Hash)
|
|
81
|
+
[first_key, first_value.transform_keys(&:to_sym)]
|
|
82
|
+
else
|
|
83
|
+
overrides = hash.compact.transform_keys(&:to_sym)
|
|
84
|
+
[first_key, overrides]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
private_class_method :extract_worker_entry
|
|
88
|
+
|
|
89
|
+
def self.validate_worker_override_keys!(worker_name, overrides)
|
|
90
|
+
unknown = overrides.keys - WORKER_SCOPED_KEYS
|
|
91
|
+
return if unknown.empty?
|
|
92
|
+
|
|
93
|
+
raise ArgumentError,
|
|
94
|
+
"Unrecognized override keys for #{worker_name}: #{unknown.join(', ')}. " \
|
|
95
|
+
"Valid keys: #{WORKER_SCOPED_KEYS.join(', ')}"
|
|
96
|
+
end
|
|
97
|
+
private_class_method :validate_worker_override_keys!
|
|
98
|
+
|
|
99
|
+
# Worker-scoped fields (CLI: worker_mode only; all configurable via YAML)
|
|
100
|
+
attr_reader :worker_mode, :backpressure_delay, :max_jobs, :request_timeout,
|
|
101
|
+
:buffer, :buffer_throttle, :job_timeout, :backoff
|
|
102
|
+
|
|
103
|
+
# Process-wide fields
|
|
104
|
+
attr_reader :log_format, :worker_name, :cluster_address
|
|
105
|
+
|
|
106
|
+
def initialize(worker_mode: nil, backpressure_delay: nil, max_jobs: nil, # rubocop:disable Metrics/AbcSize,Metrics/ParameterLists
|
|
107
|
+
request_timeout: nil, buffer: nil, buffer_throttle: nil,
|
|
108
|
+
job_timeout: nil, backoff: nil,
|
|
109
|
+
log_format: nil, worker_name: nil, cluster_address: nil,
|
|
110
|
+
workers: {})
|
|
111
|
+
@worker_mode = coerce_symbol!(worker_mode, VALID_WORKER_MODES, "worker mode") if worker_mode
|
|
112
|
+
@backpressure_delay = backpressure_delay
|
|
113
|
+
@max_jobs = max_jobs
|
|
114
|
+
@request_timeout = request_timeout
|
|
115
|
+
@buffer = buffer
|
|
116
|
+
@buffer_throttle = buffer_throttle
|
|
117
|
+
@job_timeout = job_timeout
|
|
118
|
+
@backoff = backoff
|
|
119
|
+
@log_format = coerce_symbol!(log_format, VALID_LOG_FORMATS, "log format") if log_format
|
|
120
|
+
@worker_name = worker_name
|
|
121
|
+
@cluster_address = cluster_address
|
|
122
|
+
@workers = workers.each_with_object({}) do |(name, overrides), validated|
|
|
123
|
+
if overrides[:worker_mode]
|
|
124
|
+
mode = coerce_symbol!(overrides[:worker_mode], VALID_WORKER_MODES, "worker mode")
|
|
125
|
+
overrides = overrides.merge(worker_mode: mode)
|
|
126
|
+
end
|
|
127
|
+
validated[name.to_s] = overrides
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Resolves configuration for a specific worker class through the full
|
|
132
|
+
# precedence chain. Returns a new RuntimeConfig with all values populated.
|
|
133
|
+
#
|
|
134
|
+
# Worker-scoped fields resolve through 4 levels:
|
|
135
|
+
# 1. Per-worker RuntimeConfig override (highest priority)
|
|
136
|
+
# 2. Global RuntimeConfig value
|
|
137
|
+
# 3. Worker DSL (worker_class.configuration)
|
|
138
|
+
# 4. Gem default (lowest priority)
|
|
139
|
+
#
|
|
140
|
+
# Process-wide fields resolve through 2 levels:
|
|
141
|
+
# 1. Global RuntimeConfig value
|
|
142
|
+
# 2. Gem default
|
|
143
|
+
def resolve_for(worker_class) # rubocop:disable Metrics/AbcSize
|
|
144
|
+
wo = @workers[worker_class.name] || {}
|
|
145
|
+
dsl = worker_class.configuration
|
|
146
|
+
|
|
147
|
+
# rubocop:disable Layout/HashAlignment, Layout/LineLength
|
|
148
|
+
self.class.new(
|
|
149
|
+
worker_mode: first_non_nil(wo[:worker_mode], @worker_mode, dsl.worker_mode, Busybee.default_worker_mode),
|
|
150
|
+
backpressure_delay: first_non_nil(wo[:backpressure_delay], @backpressure_delay, dsl.backpressure_delay, Busybee.default_backpressure_delay),
|
|
151
|
+
max_jobs: first_non_nil(wo[:max_jobs], @max_jobs, dsl.polling_config[:max_jobs], Busybee.default_max_jobs),
|
|
152
|
+
request_timeout: first_non_nil(wo[:request_timeout], @request_timeout, dsl.polling_config[:request_timeout], Busybee.default_job_request_timeout),
|
|
153
|
+
buffer: first_non_nil(wo[:buffer], @buffer, dsl.streaming_config[:buffer], Busybee.default_buffer),
|
|
154
|
+
buffer_throttle: first_non_nil(wo[:buffer_throttle], @buffer_throttle, dsl.streaming_config[:buffer_throttle], Busybee.default_buffer_throttle),
|
|
155
|
+
job_timeout: first_non_nil(wo[:job_timeout], @job_timeout, dsl.job_timeout, Busybee.default_job_lock_timeout),
|
|
156
|
+
backoff: first_non_nil(wo[:backoff], @backoff, dsl.backoff, Busybee.default_fail_job_backoff),
|
|
157
|
+
log_format: first_non_nil(@log_format, Busybee.log_format),
|
|
158
|
+
worker_name: first_non_nil(@worker_name, Busybee.worker_name),
|
|
159
|
+
cluster_address: first_non_nil(@cluster_address, Busybee.cluster_address)
|
|
160
|
+
)
|
|
161
|
+
# rubocop:enable Layout/HashAlignment, Layout/LineLength
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Convenience method for runner consumption. Returns polling-relevant fields
|
|
165
|
+
# as a hash matching the shape expected by client.with_each_job.
|
|
166
|
+
def polling_options
|
|
167
|
+
{ max_jobs: @max_jobs, request_timeout: @request_timeout, job_timeout: @job_timeout }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def first_non_nil(*values)
|
|
173
|
+
values.find { |v| !v.nil? }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def coerce_symbol!(value, valid_set, label)
|
|
177
|
+
sym = value.to_s.to_sym
|
|
178
|
+
return sym if valid_set.include?(sym)
|
|
179
|
+
|
|
180
|
+
raise ArgumentError,
|
|
181
|
+
"Invalid #{label}: #{value.inspect}. Valid: #{valid_set.map(&:inspect).join(', ')}"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -142,6 +142,19 @@ module Busybee
|
|
|
142
142
|
self
|
|
143
143
|
end
|
|
144
144
|
|
|
145
|
+
# Update the job's timeout.
|
|
146
|
+
#
|
|
147
|
+
# @param timeout [Integer] new timeout in milliseconds
|
|
148
|
+
# @return [self]
|
|
149
|
+
def update_timeout(timeout)
|
|
150
|
+
request = Busybee::GRPC::UpdateJobTimeoutRequest.new(
|
|
151
|
+
jobKey: key,
|
|
152
|
+
timeout: timeout.to_i
|
|
153
|
+
)
|
|
154
|
+
client.update_job_timeout(request)
|
|
155
|
+
self
|
|
156
|
+
end
|
|
157
|
+
|
|
145
158
|
private
|
|
146
159
|
|
|
147
160
|
def stringify_keys(hash)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "busybee/job"
|
|
4
|
+
require "busybee/serialization"
|
|
5
|
+
|
|
6
|
+
module Busybee
|
|
7
|
+
module Testing
|
|
8
|
+
module Helpers
|
|
9
|
+
# Builds test jobs and executes workers without a Zeebe connection.
|
|
10
|
+
#
|
|
11
|
+
# Included into Busybee::Testing::Helpers, which is auto-included in all
|
|
12
|
+
# RSpec examples when busybee/testing is required.
|
|
13
|
+
#
|
|
14
|
+
# @example Happy path
|
|
15
|
+
# result = execute_worker(
|
|
16
|
+
# ProcessOrderWorker,
|
|
17
|
+
# variables: { order_id: order.id }
|
|
18
|
+
# )
|
|
19
|
+
# expect(result).to eq(status: "processed")
|
|
20
|
+
#
|
|
21
|
+
# @example Inspect job status after failure
|
|
22
|
+
# job = build_test_job(variables: { order_id: 999 })
|
|
23
|
+
# expect {
|
|
24
|
+
# execute_worker(ProcessOrderWorker, job: job)
|
|
25
|
+
# }.to raise_error(ActiveRecord::RecordNotFound)
|
|
26
|
+
# expect(job).to be_failed
|
|
27
|
+
#
|
|
28
|
+
module Execution
|
|
29
|
+
# Build a test job backed by a stub client.
|
|
30
|
+
#
|
|
31
|
+
# The returned Job behaves like a real job — variables and headers are
|
|
32
|
+
# parsed, status tracking works, and all client operations (complete!,
|
|
33
|
+
# fail!, throw_bpmn_error!, update_retries, update_timeout) succeed
|
|
34
|
+
# silently through a stub client.
|
|
35
|
+
#
|
|
36
|
+
# @param type [String] job type (defaults to "test")
|
|
37
|
+
# @param variables [Hash] process variables
|
|
38
|
+
# @param headers [Hash] custom headers
|
|
39
|
+
# @param bpmn_process_id [String] BPMN process ID
|
|
40
|
+
# @param retries [Integer] retry count
|
|
41
|
+
# @return [Busybee::Job]
|
|
42
|
+
def build_test_job(type: "test", variables: {}, headers: {},
|
|
43
|
+
bpmn_process_id: "test-process", retries: 3)
|
|
44
|
+
client = stub_client
|
|
45
|
+
raw_job = stub_raw_job(
|
|
46
|
+
type: type,
|
|
47
|
+
variables: variables,
|
|
48
|
+
headers: headers,
|
|
49
|
+
bpmn_process_id: bpmn_process_id,
|
|
50
|
+
retries: retries
|
|
51
|
+
)
|
|
52
|
+
Busybee::Job.new(raw_job, client: client)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Execute a worker's full lifecycle against a test job.
|
|
56
|
+
#
|
|
57
|
+
# Runs the real Worker.perform_job — input validation, perform, output
|
|
58
|
+
# validation, auto-complete/auto-fail all execute as in production. The
|
|
59
|
+
# only difference: errors are re-raised after handle_failure completes,
|
|
60
|
+
# so you can use +expect { }.to raise_error+ alongside +expect(job).to be_failed+.
|
|
61
|
+
#
|
|
62
|
+
# @overload execute_worker(worker_class, variables: {}, headers: {},
|
|
63
|
+
# bpmn_process_id: "test-process", retries: 3)
|
|
64
|
+
# Build a test job from keyword arguments and execute.
|
|
65
|
+
# @param worker_class [Class<Busybee::Worker>] the worker class to test
|
|
66
|
+
# @param variables [Hash] process variables
|
|
67
|
+
# @param headers [Hash] custom headers
|
|
68
|
+
# @param bpmn_process_id [String] BPMN process ID
|
|
69
|
+
# @param retries [Integer] retry count
|
|
70
|
+
# @return [Object] the return value of the worker's +perform+ method
|
|
71
|
+
#
|
|
72
|
+
# @overload execute_worker(worker_class, job:)
|
|
73
|
+
# Execute with a pre-built test job (from build_test_job).
|
|
74
|
+
# @param worker_class [Class<Busybee::Worker>] the worker class to test
|
|
75
|
+
# @param job [Busybee::Job] a pre-built test job
|
|
76
|
+
# @return [Object] the return value of the worker's +perform+ method
|
|
77
|
+
#
|
|
78
|
+
def execute_worker(worker_class, job: nil, # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
|
|
79
|
+
variables: {}, headers: {},
|
|
80
|
+
bpmn_process_id: "test-process", retries: 3)
|
|
81
|
+
if job
|
|
82
|
+
unless variables.empty? && headers.empty? &&
|
|
83
|
+
bpmn_process_id == "test-process" && retries == 3
|
|
84
|
+
raise ArgumentError,
|
|
85
|
+
"Cannot pass job: together with variables:, headers:, " \
|
|
86
|
+
"bpmn_process_id:, or retries:. Use build_test_job to " \
|
|
87
|
+
"pre-configure the job, or pass keyword arguments — not both."
|
|
88
|
+
end
|
|
89
|
+
else
|
|
90
|
+
job = build_test_job(
|
|
91
|
+
type: worker_class.job_type,
|
|
92
|
+
variables: variables, headers: headers,
|
|
93
|
+
bpmn_process_id: bpmn_process_id, retries: retries
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Wrap handle_failure to re-raise after production logic runs.
|
|
98
|
+
# This lets tests assert both error class AND job status.
|
|
99
|
+
allow(worker_class).to(
|
|
100
|
+
receive(:handle_failure).and_wrap_original do |m, *args|
|
|
101
|
+
m.call(*args).tap { raise args[1] }
|
|
102
|
+
end
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
worker_class.perform_job(job)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def stub_client
|
|
111
|
+
instance_double(
|
|
112
|
+
Busybee::Client,
|
|
113
|
+
complete_job: true,
|
|
114
|
+
fail_job: true,
|
|
115
|
+
throw_bpmn_error: true,
|
|
116
|
+
update_job_retries: true,
|
|
117
|
+
update_job_timeout: true
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def stub_raw_job(type:, variables:, headers:, bpmn_process_id:, retries:)
|
|
122
|
+
# Plain double because protobuf generates field accessors dynamically
|
|
123
|
+
# via descriptors, which instance_double can't verify against.
|
|
124
|
+
double(
|
|
125
|
+
"Busybee::GRPC::ActivatedJob",
|
|
126
|
+
key: rand(100_000..999_999),
|
|
127
|
+
type: type,
|
|
128
|
+
processInstanceKey: rand(100_000..999_999),
|
|
129
|
+
bpmnProcessId: bpmn_process_id,
|
|
130
|
+
retries: retries,
|
|
131
|
+
deadline: (Time.now.to_i + 300) * 1000,
|
|
132
|
+
variables: Busybee::Serialization.to_json(variables),
|
|
133
|
+
customHeaders: Busybee::Serialization.to_json(headers)
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -43,7 +43,7 @@ module Busybee
|
|
|
43
43
|
def activate_jobs_raw(type, max_jobs:, timeout: nil)
|
|
44
44
|
worker = "#{type}-#{SecureRandom.hex(4)}"
|
|
45
45
|
|
|
46
|
-
request_timeout = timeout || Busybee
|
|
46
|
+
request_timeout = timeout || Busybee.default_job_request_timeout
|
|
47
47
|
request_timeout_ms = if request_timeout.is_a?(ActiveSupport::Duration)
|
|
48
48
|
request_timeout.in_milliseconds.to_i
|
|
49
49
|
else
|
|
@@ -8,6 +8,7 @@ require "busybee/grpc"
|
|
|
8
8
|
require "busybee/serialization"
|
|
9
9
|
require "busybee/testing/activated_job"
|
|
10
10
|
require "busybee/testing/helpers/support"
|
|
11
|
+
require "busybee/testing/helpers/execution"
|
|
11
12
|
|
|
12
13
|
module Busybee
|
|
13
14
|
module Testing
|
|
@@ -17,6 +18,7 @@ module Busybee
|
|
|
17
18
|
# RSpec helper methods for testing BPMN workflows against Zeebe.
|
|
18
19
|
module Helpers
|
|
19
20
|
extend Support
|
|
21
|
+
include Execution
|
|
20
22
|
|
|
21
23
|
# Deploy a BPMN process file to Zeebe.
|
|
22
24
|
#
|
|
@@ -151,7 +153,7 @@ module Busybee
|
|
|
151
153
|
# Activate a single job of the given type.
|
|
152
154
|
#
|
|
153
155
|
# @param type [String] job type
|
|
154
|
-
# @param timeout [Integer, nil] request timeout in milliseconds (defaults to Busybee
|
|
156
|
+
# @param timeout [Integer, nil] request timeout in milliseconds (defaults to Busybee.default_job_request_timeout)
|
|
155
157
|
# @return [ActivatedJob]
|
|
156
158
|
# @raise [NoJobAvailable] if no job is available
|
|
157
159
|
def activate_job(type, timeout: nil)
|
|
@@ -165,7 +167,7 @@ module Busybee
|
|
|
165
167
|
#
|
|
166
168
|
# @param type [String] job type
|
|
167
169
|
# @param max_jobs [Integer] maximum number of jobs to activate
|
|
168
|
-
# @param timeout [Integer, nil] request timeout in milliseconds (defaults to Busybee
|
|
170
|
+
# @param timeout [Integer, nil] request timeout in milliseconds (defaults to Busybee.default_job_request_timeout)
|
|
169
171
|
# @return [Enumerator<ActivatedJob>]
|
|
170
172
|
def activate_jobs(type, max_jobs:, timeout: nil)
|
|
171
173
|
Enumerator.new do |yielder|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/expectations"
|
|
4
|
+
|
|
5
|
+
# Asserts that a worker completes the job successfully.
|
|
6
|
+
#
|
|
7
|
+
# Checks that the job ends in +:complete+ status. Optionally checks
|
|
8
|
+
# the return value of +perform+ for expected variables via +with_vars+.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# job = build_test_job(variables: { order_id: 1 })
|
|
12
|
+
# expect(MyWorker).to complete_job(job)
|
|
13
|
+
#
|
|
14
|
+
# @example With expected return variables
|
|
15
|
+
# expect(MyWorker).to complete_job(job).with_vars(status: "done")
|
|
16
|
+
#
|
|
17
|
+
RSpec::Matchers.define :complete_job do |job|
|
|
18
|
+
match do |worker_class|
|
|
19
|
+
@result = execute_worker(worker_class, job: job)
|
|
20
|
+
return false unless job.complete?
|
|
21
|
+
|
|
22
|
+
if @expected_vars
|
|
23
|
+
if @result.is_a?(Hash)
|
|
24
|
+
values_match?(@expected_vars, @result)
|
|
25
|
+
else
|
|
26
|
+
@expected_vars == {}
|
|
27
|
+
end
|
|
28
|
+
else
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
@raised_error = e
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
chain :with_vars do |expected|
|
|
37
|
+
@expected_vars = expected
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
chain :with_no_vars do
|
|
41
|
+
@expected_vars = {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
failure_message do
|
|
45
|
+
if @raised_error
|
|
46
|
+
"expected #{actual} to complete the job, but it raised " \
|
|
47
|
+
"#{@raised_error.class}: #{@raised_error.message}"
|
|
48
|
+
elsif !job.complete?
|
|
49
|
+
"expected job to be complete, but was #{job.status}"
|
|
50
|
+
else
|
|
51
|
+
"expected result to match #{@expected_vars.inspect}, " \
|
|
52
|
+
"got #{@result.inspect}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/expectations"
|
|
4
|
+
|
|
5
|
+
# Asserts that a worker fails the job with an optional error match.
|
|
6
|
+
#
|
|
7
|
+
# Accepts the same argument forms as RSpec's +raise_error+:
|
|
8
|
+
# with_error(ErrorClass)
|
|
9
|
+
# with_error(ErrorClass, "exact message")
|
|
10
|
+
# with_error(ErrorClass, /message pattern/)
|
|
11
|
+
# with_error("exact message")
|
|
12
|
+
# with_error(/message pattern/)
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# job = build_test_job(variables: { order_id: 999 })
|
|
16
|
+
# expect(MyWorker).to fail_job(job)
|
|
17
|
+
#
|
|
18
|
+
# @example With error class
|
|
19
|
+
# expect(MyWorker).to fail_job(job).with_error(ActiveRecord::RecordNotFound)
|
|
20
|
+
#
|
|
21
|
+
# @example With error class and message
|
|
22
|
+
# expect(MyWorker).to fail_job(job).with_error(ArgumentError, /invalid/)
|
|
23
|
+
#
|
|
24
|
+
RSpec::Matchers.define :fail_job do |job|
|
|
25
|
+
match do |worker_class|
|
|
26
|
+
execute_worker(worker_class, job: job)
|
|
27
|
+
@no_error = true
|
|
28
|
+
false
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
@raised_error = e
|
|
31
|
+
job.failed? && error_matches?(e)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
chain :with_error do |expected_error_or_message, expected_message = nil|
|
|
35
|
+
case expected_error_or_message
|
|
36
|
+
when String, Regexp
|
|
37
|
+
@expected_error = StandardError
|
|
38
|
+
@expected_message = expected_error_or_message
|
|
39
|
+
else
|
|
40
|
+
@expected_error = expected_error_or_message
|
|
41
|
+
@expected_message = expected_message
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def error_matches?(error)
|
|
46
|
+
return true unless @expected_error
|
|
47
|
+
|
|
48
|
+
class_matches = @expected_error === error # rubocop:disable Style/CaseEquality
|
|
49
|
+
return false unless class_matches
|
|
50
|
+
return true unless @expected_message
|
|
51
|
+
|
|
52
|
+
case @expected_message
|
|
53
|
+
when Regexp then error.message.match?(@expected_message)
|
|
54
|
+
else error.message == @expected_message.to_s
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
failure_message do
|
|
59
|
+
if @no_error
|
|
60
|
+
"expected #{actual} to fail the job, but it completed successfully"
|
|
61
|
+
elsif !job.failed?
|
|
62
|
+
"expected job to be failed, but was #{job.status}"
|
|
63
|
+
else
|
|
64
|
+
"expected error matching #{expected_description}, " \
|
|
65
|
+
"got #{@raised_error.class}: #{@raised_error.message}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def expected_description
|
|
70
|
+
parts = []
|
|
71
|
+
parts << @expected_error.inspect if @expected_error
|
|
72
|
+
parts << @expected_message.inspect if @expected_message
|
|
73
|
+
parts.join(" with message ")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/expectations"
|
|
4
|
+
|
|
5
|
+
# Asserts that a worker throws a BPMN error on the job.
|
|
6
|
+
#
|
|
7
|
+
# Captures the error code and message passed to the stub client's
|
|
8
|
+
# +throw_bpmn_error+ call for optional verification via +with_code+.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# job = build_test_job(variables: { order_id: 999 })
|
|
12
|
+
# expect(MyWorker).to throw_bpmn_error_on(job)
|
|
13
|
+
#
|
|
14
|
+
# @example With error code
|
|
15
|
+
# expect(MyWorker).to throw_bpmn_error_on(job).with_code(:not_found)
|
|
16
|
+
#
|
|
17
|
+
# @example With error code and message
|
|
18
|
+
# expect(MyWorker).to throw_bpmn_error_on(job).with_code(:not_found, message: /missing/)
|
|
19
|
+
#
|
|
20
|
+
RSpec::Matchers.define :throw_bpmn_error_on do |job|
|
|
21
|
+
match do |worker_class|
|
|
22
|
+
client = job.instance_variable_get(:@client)
|
|
23
|
+
allow(client).to receive(:throw_bpmn_error) do |_key, code, message:|
|
|
24
|
+
@actual_code = code
|
|
25
|
+
@actual_message = message
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
execute_worker(worker_class, job: job)
|
|
29
|
+
return false unless job.error?
|
|
30
|
+
|
|
31
|
+
code_matches? && message_matches?
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
@raised_error = e
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
chain :with_code do |expected_code, opts = {}|
|
|
38
|
+
@expected_code = case expected_code
|
|
39
|
+
when Symbol then expected_code.to_s.upcase
|
|
40
|
+
when Class then expected_code.name.gsub("::", "_").underscore.upcase
|
|
41
|
+
else expected_code
|
|
42
|
+
end
|
|
43
|
+
@expected_message = opts[:message]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def code_matches?
|
|
47
|
+
return true unless @expected_code
|
|
48
|
+
|
|
49
|
+
values_match?(@expected_code, @actual_code)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def message_matches?
|
|
53
|
+
return true unless @expected_message
|
|
54
|
+
|
|
55
|
+
values_match?(@expected_message, @actual_message)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
failure_message do
|
|
59
|
+
if @raised_error
|
|
60
|
+
"expected #{actual} to throw a BPMN error, but it raised " \
|
|
61
|
+
"#{@raised_error.class}: #{@raised_error.message}"
|
|
62
|
+
elsif !job.error?
|
|
63
|
+
"expected job to be error, but was #{job.status}"
|
|
64
|
+
elsif @expected_code && !code_matches?
|
|
65
|
+
"expected BPMN error code #{@expected_code.inspect}, " \
|
|
66
|
+
"got #{@actual_code.inspect}"
|
|
67
|
+
else
|
|
68
|
+
"expected BPMN error message #{@expected_message.inspect}, " \
|
|
69
|
+
"got #{@actual_message.inspect}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/busybee/testing.rb
CHANGED
|
@@ -3,25 +3,8 @@
|
|
|
3
3
|
require "busybee/grpc"
|
|
4
4
|
|
|
5
5
|
module Busybee
|
|
6
|
-
# Testing support for BPMN workflows with RSpec.
|
|
7
|
-
#
|
|
8
|
-
# @example Configuration
|
|
9
|
-
# Busybee::Testing.configure do |config|
|
|
10
|
-
# config.activate_request_timeout = 2000
|
|
11
|
-
# end
|
|
12
|
-
#
|
|
6
|
+
# Testing support for BPMN workflows and workers with RSpec.
|
|
13
7
|
module Testing
|
|
14
|
-
class << self
|
|
15
|
-
attr_writer :activate_request_timeout
|
|
16
|
-
|
|
17
|
-
def configure
|
|
18
|
-
yield self
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def activate_request_timeout
|
|
22
|
-
@activate_request_timeout || 1000
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
8
|
end
|
|
26
9
|
end
|
|
27
10
|
|
|
@@ -33,6 +16,9 @@ if defined?(RSpec)
|
|
|
33
16
|
require "busybee/testing/matchers/have_received_headers"
|
|
34
17
|
require "busybee/testing/matchers/have_activated"
|
|
35
18
|
require "busybee/testing/matchers/have_available_jobs"
|
|
19
|
+
require "busybee/testing/matchers/fail_job"
|
|
20
|
+
require "busybee/testing/matchers/complete_job"
|
|
21
|
+
require "busybee/testing/matchers/throw_bpmn_error_on"
|
|
36
22
|
|
|
37
23
|
RSpec.configure do |config|
|
|
38
24
|
config.include Busybee::Testing::Helpers
|
data/lib/busybee/version.rb
CHANGED