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
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
3
+ require "active_support"
4
+ require "active_support/duration"
4
5
  require "base64"
5
- require "json"
6
+ require "securerandom"
6
7
  require "busybee/grpc"
7
- require_relative "activated_job"
8
+ require "busybee/serialization"
9
+ require "busybee/testing/activated_job"
10
+ require "busybee/testing/helpers/support"
11
+ require "busybee/testing/helpers/execution"
8
12
 
9
13
  module Busybee
10
14
  module Testing
@@ -13,6 +17,9 @@ module Busybee
13
17
 
14
18
  # RSpec helper methods for testing BPMN workflows against Zeebe.
15
19
  module Helpers
20
+ extend Support
21
+ include Execution
22
+
16
23
  # Deploy a BPMN process file to Zeebe.
17
24
  #
18
25
  # By default, deploys the BPMN file as-is using its original process ID.
@@ -40,11 +47,11 @@ module Busybee
40
47
  # result[:process_id] #=> "my-test-process"
41
48
  def deploy_process(path, uniquify: nil)
42
49
  if uniquify
43
- process_id = uniquify == true ? unique_process_id : uniquify
44
- bpmn_content = bpmn_with_unique_id(path, process_id)
50
+ process_id = uniquify == true ? Busybee::Testing::Helpers.unique_process_id : uniquify
51
+ bpmn_content = Busybee::Testing::Helpers.bpmn_with_unique_id(path, process_id)
45
52
  else
46
53
  bpmn_content = File.read(path)
47
- process_id = extract_process_id(bpmn_content)
54
+ process_id = Busybee::Testing::Helpers.extract_process_id(bpmn_content)
48
55
  end
49
56
 
50
57
  resource = Busybee::GRPC::Resource.new(
@@ -73,7 +80,7 @@ module Busybee
73
80
  request = Busybee::GRPC::CreateProcessInstanceRequest.new(
74
81
  bpmnProcessId: process_name,
75
82
  version: -1,
76
- variables: JSON.generate(variables)
83
+ variables: Busybee::Serialization.to_json(variables)
77
84
  )
78
85
 
79
86
  response = grpc_client.create_process_instance(request)
@@ -82,7 +89,7 @@ module Busybee
82
89
  yield @current_process_instance_key
83
90
  ensure
84
91
  if @current_process_instance_key
85
- cancel_process_instance(@current_process_instance_key)
92
+ Busybee::Testing::Helpers.cancel_process_instance(@current_process_instance_key)
86
93
  @last_process_instance_key = @current_process_instance_key
87
94
  @current_process_instance_key = nil
88
95
  end
@@ -103,6 +110,31 @@ module Busybee
103
110
  @last_process_instance_key
104
111
  end
105
112
 
113
+ # Activate a job, yield it, and complete it on block exit.
114
+ # Must be called within a with_process_instance block.
115
+ #
116
+ # @param job_type [String] job type to activate
117
+ # @yield [ActivatedJob] the activated job
118
+ def with_activated_job_instance(job_type)
119
+ job = activate_job(job_type)
120
+ @current_job_key = job.key
121
+ yield job
122
+ ensure
123
+ if @current_job_key
124
+ begin
125
+ request = Busybee::GRPC::CompleteJobRequest.new(
126
+ jobKey: @current_job_key,
127
+ variables: "{}"
128
+ )
129
+ grpc_client.complete_job(request)
130
+ rescue ::GRPC::BadStatus
131
+ # Job may have already been completed/failed in the test block - ignore
132
+ ensure
133
+ @current_job_key = nil
134
+ end
135
+ end
136
+ end
137
+
106
138
  # Checks if Zeebe is available and responsive.
107
139
  #
108
140
  # This method attempts to connect to Zeebe and call the topology endpoint
@@ -114,17 +146,18 @@ module Busybee
114
146
  request = Busybee::GRPC::TopologyRequest.new
115
147
  grpc_client.topology(request, deadline: Time.now + timeout)
116
148
  true
117
- rescue GRPC::Unavailable, GRPC::DeadlineExceeded, GRPC::Core::CallError, GRPC::Unauthenticated
149
+ rescue ::GRPC::Unavailable, ::GRPC::DeadlineExceeded, ::GRPC::Core::CallError, ::GRPC::Unauthenticated
118
150
  false
119
151
  end
120
152
 
121
153
  # Activate a single job of the given type.
122
154
  #
123
155
  # @param type [String] job type
156
+ # @param timeout [Integer, nil] request timeout in milliseconds (defaults to Busybee.default_job_request_timeout)
124
157
  # @return [ActivatedJob]
125
158
  # @raise [NoJobAvailable] if no job is available
126
- def activate_job(type)
127
- jobs = activate_jobs_raw(type, max_jobs: 1)
159
+ def activate_job(type, timeout: nil)
160
+ jobs = Busybee::Testing::Helpers.activate_jobs_raw(type, max_jobs: 1, timeout: timeout)
128
161
  raise NoJobAvailable, "No job of type '#{type}' available" if jobs.empty?
129
162
 
130
163
  ActivatedJob.new(jobs.first, client: grpc_client)
@@ -134,10 +167,11 @@ module Busybee
134
167
  #
135
168
  # @param type [String] job type
136
169
  # @param max_jobs [Integer] maximum number of jobs to activate
170
+ # @param timeout [Integer, nil] request timeout in milliseconds (defaults to Busybee.default_job_request_timeout)
137
171
  # @return [Enumerator<ActivatedJob>]
138
- def activate_jobs(type, max_jobs:)
172
+ def activate_jobs(type, max_jobs:, timeout: nil)
139
173
  Enumerator.new do |yielder|
140
- activate_jobs_raw(type, max_jobs: max_jobs).each do |raw_job|
174
+ Busybee::Testing::Helpers.activate_jobs_raw(type, max_jobs: max_jobs, timeout: timeout).each do |raw_job|
141
175
  yielder << ActivatedJob.new(raw_job, client: grpc_client)
142
176
  end
143
177
  end
@@ -153,7 +187,7 @@ module Busybee
153
187
  request = Busybee::GRPC::PublishMessageRequest.new(
154
188
  name: name,
155
189
  correlationKey: correlation_key,
156
- variables: JSON.generate(variables),
190
+ variables: Busybee::Serialization.to_json(variables),
157
191
  timeToLive: ttl_ms
158
192
  )
159
193
  grpc_client.publish_message(request)
@@ -167,7 +201,7 @@ module Busybee
167
201
  def set_variables(scope_key, variables, local: true)
168
202
  request = Busybee::GRPC::SetVariablesRequest.new(
169
203
  elementInstanceKey: scope_key,
170
- variables: JSON.generate(variables),
204
+ variables: Busybee::Serialization.to_json(variables),
171
205
  local: local
172
206
  )
173
207
  grpc_client.set_variables(request)
@@ -193,58 +227,14 @@ module Busybee
193
227
  end
194
228
  end
195
229
 
196
- private
197
-
198
- def unique_process_id
199
- "test-process-#{SecureRandom.hex(6)}"
200
- end
201
-
202
- def extract_process_id(bpmn_content)
203
- match = bpmn_content.match(/<bpmn:process id="([^"]+)"/)
204
- match ? match[1] : nil
205
- end
206
-
207
- def bpmn_with_unique_id(bpmn_path, process_id)
208
- bpmn_content = File.read(bpmn_path)
209
- bpmn_content
210
- .gsub(/(<bpmn:process id=")[^"]+/, "\\1#{process_id}")
211
- # Possessive quantifiers (++, *+) prevent polynomial backtracking
212
- .gsub(/(<bpmndi:BPMNPlane\s++[^>]*+bpmnElement=")[^"]++/, "\\1#{process_id}")
213
- end
214
-
215
- def cancel_process_instance(key)
216
- request = Busybee::GRPC::CancelProcessInstanceRequest.new(
217
- processInstanceKey: key
218
- )
219
- grpc_client.cancel_process_instance(request)
220
- true
221
- rescue ::GRPC::NotFound
222
- # Process already completed, ignore
223
- false
224
- end
225
-
226
- def activate_jobs_raw(type, max_jobs:)
227
- worker = "#{type}-#{SecureRandom.hex(4)}"
228
- request = Busybee::GRPC::ActivateJobsRequest.new(
229
- type: type,
230
- worker: worker,
231
- timeout: 30_000,
232
- maxJobsToActivate: max_jobs,
233
- requestTimeout: Busybee::Testing.activate_request_timeout
234
- )
235
-
236
- jobs = []
237
- grpc_client.activate_jobs(request).each do |response|
238
- jobs.concat(response.jobs.to_a)
239
- end
240
- jobs
241
- end
242
-
230
+ # Returns a GRPC client stub for direct Zeebe access.
231
+ # Uses Busybee.credential_type if set, autodetects from env vars otherwise.
232
+ #
233
+ # This is a public instance method that delegates to the module-level implementation.
234
+ #
235
+ # @return [Busybee::GRPC::Gateway::Stub]
243
236
  def grpc_client
244
- @grpc_client ||= Busybee::GRPC::Gateway::Stub.new(
245
- Busybee::Testing.address,
246
- :this_channel_is_insecure
247
- )
237
+ Busybee::Testing::Helpers.grpc_client
248
238
  end
249
239
  end
250
240
  end
@@ -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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rspec/expectations"
4
- require_relative "../activated_job"
4
+ require "busybee/testing/activated_job"
5
5
 
6
6
  RSpec::Matchers.define :have_activated do |job_type|
7
7
  match do |helper|
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/expectations"
4
+
5
+ # Matcher to check if jobs are available for activation.
6
+ #
7
+ # This matcher is designed to work with blocks that call `activate_job` or similar methods
8
+ # that raise `Busybee::Testing::NoJobAvailable` when no jobs are found.
9
+ #
10
+ # @example Check that jobs are available
11
+ # expect { activate_job("process-order") }.to have_available_jobs
12
+ #
13
+ # @example Check that NO jobs are available (most common usage)
14
+ # expect { activate_job("process-order") }.not_to have_available_jobs
15
+ #
16
+ RSpec::Matchers.define :have_available_jobs do
17
+ supports_block_expectations
18
+
19
+ match do |block|
20
+ block.call
21
+ @job_found = true
22
+ true
23
+ rescue Busybee::Testing::NoJobAvailable
24
+ @job_found = false
25
+ false
26
+ rescue StandardError => e
27
+ @unexpected_error = e
28
+ false
29
+ end
30
+
31
+ failure_message do
32
+ if @unexpected_error
33
+ "expected jobs to be available, but got #{@unexpected_error.class}: #{@unexpected_error.message}"
34
+ else
35
+ "expected jobs to be available, but no jobs were found (Busybee::Testing::NoJobAvailable raised)"
36
+ end
37
+ end
38
+
39
+ failure_message_when_negated do
40
+ "expected no jobs to be available, but a job was activated"
41
+ end
42
+ end
43
+
44
+ RSpec::Matchers.alias_matcher :have_an_available_job, :have_available_jobs
@@ -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
@@ -3,40 +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.address = "localhost:26500"
11
- # config.username = "demo"
12
- # config.password = "demo"
13
- # config.activate_request_timeout = 2000
14
- # end
15
- #
6
+ # Testing support for BPMN workflows and workers with RSpec.
16
7
  module Testing
17
- class << self
18
- attr_writer :address, :username, :password, :activate_request_timeout
19
-
20
- def configure
21
- yield self
22
- end
23
-
24
- def address
25
- @address || ENV["ZEEBE_ADDRESS"] || "localhost:26500"
26
- end
27
-
28
- def username
29
- @username || ENV["ZEEBE_USERNAME"] || "demo"
30
- end
31
-
32
- def password
33
- @password || ENV["ZEEBE_PASSWORD"] || "demo"
34
- end
35
-
36
- def activate_request_timeout
37
- @activate_request_timeout || 1000
38
- end
39
- end
40
8
  end
41
9
  end
42
10
 
@@ -47,6 +15,10 @@ if defined?(RSpec)
47
15
  require "busybee/testing/matchers/have_received_variables"
48
16
  require "busybee/testing/matchers/have_received_headers"
49
17
  require "busybee/testing/matchers/have_activated"
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"
50
22
 
51
23
  RSpec.configure do |config|
52
24
  config.include Busybee::Testing::Helpers
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Busybee
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end