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,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
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/core_ext/hash/indifferent_access"
|
|
5
|
+
require "active_support/core_ext/string/inflections"
|
|
6
|
+
require "active_support/json"
|
|
7
|
+
require "busybee/error"
|
|
8
|
+
require "json"
|
|
9
|
+
|
|
10
|
+
module Busybee
|
|
11
|
+
# Centralized JSON serialization and deserialization for GRPC communication.
|
|
12
|
+
#
|
|
13
|
+
# Provides consistent handling of Ruby objects to/from JSON strings,
|
|
14
|
+
# with proper as_json support for serialization and indifferent access
|
|
15
|
+
# with method-style accessors for deserialization.
|
|
16
|
+
module Serialization
|
|
17
|
+
# Serialize a Ruby object to JSON string for GRPC.
|
|
18
|
+
#
|
|
19
|
+
# Calls as_json first so objects with custom serialization
|
|
20
|
+
# (like ActiveRecord models, Time, etc.) work correctly.
|
|
21
|
+
#
|
|
22
|
+
# @param obj [Object] The object to serialize (typically a Hash)
|
|
23
|
+
# @return [String] JSON string
|
|
24
|
+
def self.to_json(obj)
|
|
25
|
+
JSON.generate(obj.as_json)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Deserialize a JSON string from GRPC to a frozen Ruby hash.
|
|
29
|
+
#
|
|
30
|
+
# Returns a frozen HashWithIndifferentAccess with method-style access
|
|
31
|
+
# (via HashAccess module). Nested hashes and arrays are also frozen
|
|
32
|
+
# and support method-style access.
|
|
33
|
+
#
|
|
34
|
+
# @param json_string [String, nil] The JSON string to parse
|
|
35
|
+
# @return [ActiveSupport::HashWithIndifferentAccess] frozen hash with method access
|
|
36
|
+
# @raise [Busybee::InvalidJobJson] if JSON is invalid
|
|
37
|
+
def self.from_json(json_string)
|
|
38
|
+
if json_string.nil? || json_string.empty?
|
|
39
|
+
return ActiveSupport::HashWithIndifferentAccess.new.extend(HashAccess).freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
parsed = JSON.parse(json_string)
|
|
43
|
+
deep_freeze_and_extend(parsed.with_indifferent_access)
|
|
44
|
+
rescue JSON::ParserError => e
|
|
45
|
+
raise Busybee::InvalidJobJson, "Failed to parse JSON: #{e.message}", e.backtrace, cause: e
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Recursively freeze and extend hashes with HashAccess.
|
|
49
|
+
#
|
|
50
|
+
# @param obj [Object] The object to process
|
|
51
|
+
# @return [Object] The frozen, extended object
|
|
52
|
+
def self.deep_freeze_and_extend(obj)
|
|
53
|
+
case obj
|
|
54
|
+
when Hash
|
|
55
|
+
obj.extend(HashAccess)
|
|
56
|
+
obj.each_value { |value| deep_freeze_and_extend(value) }
|
|
57
|
+
obj.freeze
|
|
58
|
+
when Array
|
|
59
|
+
obj.each { |element| deep_freeze_and_extend(element) }
|
|
60
|
+
obj.freeze
|
|
61
|
+
else
|
|
62
|
+
obj.freeze if obj.respond_to?(:freeze)
|
|
63
|
+
obj
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private_class_method :deep_freeze_and_extend
|
|
68
|
+
|
|
69
|
+
# Module that adds method-style access to hashes with camelCase to snake_case conversion.
|
|
70
|
+
#
|
|
71
|
+
# Allows accessing hash keys as methods:
|
|
72
|
+
# hash.order_id # looks for "order_id" or "orderId"
|
|
73
|
+
# hash.customer_name # looks for "customer_name" or "customerName"
|
|
74
|
+
#
|
|
75
|
+
# Recursively applied to nested hashes.
|
|
76
|
+
module HashAccess
|
|
77
|
+
def method_missing(method_name, *args, **kwargs, &block)
|
|
78
|
+
return super if args.any? || kwargs.any? || block
|
|
79
|
+
|
|
80
|
+
# Convert snake_case method name to potential camelCase keys
|
|
81
|
+
snake_key = method_name.to_s
|
|
82
|
+
camel_key = snake_key.camelize(:lower)
|
|
83
|
+
|
|
84
|
+
if key?(snake_key)
|
|
85
|
+
self[snake_key]
|
|
86
|
+
elsif key?(camel_key)
|
|
87
|
+
self[camel_key]
|
|
88
|
+
else
|
|
89
|
+
super
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
94
|
+
snake_key = method_name.to_s
|
|
95
|
+
camel_key = snake_key.camelize(:lower)
|
|
96
|
+
key?(snake_key) || key?(camel_key) || super
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
3
|
require "rspec/matchers"
|
|
5
4
|
require "busybee/grpc"
|
|
5
|
+
require "busybee/serialization"
|
|
6
6
|
|
|
7
7
|
module Busybee
|
|
8
8
|
module Testing
|
|
@@ -32,22 +32,34 @@ module Busybee
|
|
|
32
32
|
raw.key
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
def
|
|
36
|
-
raw.
|
|
35
|
+
def type
|
|
36
|
+
raw.type
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
def
|
|
40
|
-
|
|
39
|
+
def process_instance_key
|
|
40
|
+
raw.processInstanceKey
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
def
|
|
44
|
-
|
|
43
|
+
def bpmn_process_id
|
|
44
|
+
raw.bpmnProcessId
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def retries
|
|
48
48
|
raw.retries
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
def deadline
|
|
52
|
+
raw.deadline
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def variables
|
|
56
|
+
@variables ||= Busybee::Serialization.from_json(raw.variables)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def headers
|
|
60
|
+
@headers ||= Busybee::Serialization.from_json(raw.customHeaders)
|
|
61
|
+
end
|
|
62
|
+
|
|
51
63
|
# Assert that job variables include the expected values.
|
|
52
64
|
# Raises RSpec expectation failure if not matched.
|
|
53
65
|
#
|
|
@@ -75,7 +87,7 @@ module Busybee
|
|
|
75
87
|
def mark_completed(variables = {})
|
|
76
88
|
request = Busybee::GRPC::CompleteJobRequest.new(
|
|
77
89
|
jobKey: key,
|
|
78
|
-
variables:
|
|
90
|
+
variables: Busybee::Serialization.to_json(variables)
|
|
79
91
|
)
|
|
80
92
|
client.complete_job(request)
|
|
81
93
|
self
|
|
@@ -130,6 +142,19 @@ module Busybee
|
|
|
130
142
|
self
|
|
131
143
|
end
|
|
132
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
|
+
|
|
133
158
|
private
|
|
134
159
|
|
|
135
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
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/duration"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "busybee/grpc"
|
|
8
|
+
|
|
9
|
+
module Busybee
|
|
10
|
+
module Testing
|
|
11
|
+
module Helpers
|
|
12
|
+
# These methods are available *on Helpers,* as module-level methods, which keeps them
|
|
13
|
+
# isolated from the test context of the public helper methods which consume them.
|
|
14
|
+
module Support
|
|
15
|
+
def unique_process_id
|
|
16
|
+
"test-process-#{SecureRandom.hex(6)}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def extract_process_id(bpmn_content)
|
|
20
|
+
match = bpmn_content.match(/<bpmn:process id="([^"]+)"/)
|
|
21
|
+
match ? match[1] : nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def bpmn_with_unique_id(bpmn_path, process_id)
|
|
25
|
+
bpmn_content = File.read(bpmn_path)
|
|
26
|
+
bpmn_content.
|
|
27
|
+
gsub(/(<bpmn:process id=")[^"]+/, "\\1#{process_id}").
|
|
28
|
+
# Possessive quantifiers (++, *+) prevent polynomial backtracking
|
|
29
|
+
gsub(/(<bpmndi:BPMNPlane\s++[^>]*+bpmnElement=")[^"]++/, "\\1#{process_id}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def cancel_process_instance(key)
|
|
33
|
+
request = Busybee::GRPC::CancelProcessInstanceRequest.new(
|
|
34
|
+
processInstanceKey: key
|
|
35
|
+
)
|
|
36
|
+
grpc_client.cancel_process_instance(request)
|
|
37
|
+
true
|
|
38
|
+
rescue ::GRPC::NotFound
|
|
39
|
+
# Process already completed, ignore
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def activate_jobs_raw(type, max_jobs:, timeout: nil)
|
|
44
|
+
worker = "#{type}-#{SecureRandom.hex(4)}"
|
|
45
|
+
|
|
46
|
+
request_timeout = timeout || Busybee.default_job_request_timeout
|
|
47
|
+
request_timeout_ms = if request_timeout.is_a?(ActiveSupport::Duration)
|
|
48
|
+
request_timeout.in_milliseconds.to_i
|
|
49
|
+
else
|
|
50
|
+
request_timeout.to_i
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
request = Busybee::GRPC::ActivateJobsRequest.new(
|
|
54
|
+
type: type,
|
|
55
|
+
worker: worker,
|
|
56
|
+
timeout: 30_000,
|
|
57
|
+
maxJobsToActivate: max_jobs,
|
|
58
|
+
requestTimeout: request_timeout_ms
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
jobs = []
|
|
62
|
+
grpc_client.activate_jobs(request).each do |response|
|
|
63
|
+
jobs.concat(response.jobs.to_a)
|
|
64
|
+
end
|
|
65
|
+
jobs
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# This is the central grpc_client implementation (the one you should usually mock in a test).
|
|
69
|
+
# The actual public helper instance method delegates to this. It uses Busybee.credential_type
|
|
70
|
+
# if set, and attempts to autodetect from env vars otherwise.
|
|
71
|
+
def grpc_client
|
|
72
|
+
require "busybee/credentials"
|
|
73
|
+
Busybee::Credentials.build.grpc_stub
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|