busybee 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -7
  3. data/README.md +70 -42
  4. data/docs/client/quick_start.md +279 -0
  5. data/docs/client.md +825 -0
  6. data/docs/configuration.md +550 -0
  7. data/docs/grpc.md +50 -25
  8. data/docs/testing.md +118 -28
  9. data/docs/workers.md +982 -0
  10. data/exe/busybee +6 -0
  11. data/lib/busybee/cli.rb +173 -0
  12. data/lib/busybee/client/error_handling.rb +37 -0
  13. data/lib/busybee/client/job_operations.rb +236 -0
  14. data/lib/busybee/client/message_operations.rb +84 -0
  15. data/lib/busybee/client/process_operations.rb +108 -0
  16. data/lib/busybee/client/variable_operations.rb +64 -0
  17. data/lib/busybee/client.rb +87 -0
  18. data/lib/busybee/configure.rb +290 -0
  19. data/lib/busybee/credentials/camunda_cloud.rb +58 -0
  20. data/lib/busybee/credentials/insecure.rb +24 -0
  21. data/lib/busybee/credentials/oauth.rb +157 -0
  22. data/lib/busybee/credentials/tls.rb +43 -0
  23. data/lib/busybee/credentials.rb +200 -0
  24. data/lib/busybee/defaults.rb +20 -0
  25. data/lib/busybee/error.rb +50 -0
  26. data/lib/busybee/grpc/error.rb +60 -0
  27. data/lib/busybee/grpc.rb +2 -2
  28. data/lib/busybee/job.rb +219 -0
  29. data/lib/busybee/job_stream.rb +85 -0
  30. data/lib/busybee/logging.rb +61 -0
  31. data/lib/busybee/railtie.rb +113 -0
  32. data/lib/busybee/runner/hybrid.rb +64 -0
  33. data/lib/busybee/runner/multi.rb +101 -0
  34. data/lib/busybee/runner/polling.rb +54 -0
  35. data/lib/busybee/runner/streaming.rb +159 -0
  36. data/lib/busybee/runner.rb +97 -0
  37. data/lib/busybee/runtime_config.rb +184 -0
  38. data/lib/busybee/serialization.rb +100 -0
  39. data/lib/busybee/testing/activated_job.rb +33 -8
  40. data/lib/busybee/testing/helpers/execution.rb +139 -0
  41. data/lib/busybee/testing/helpers/support.rb +78 -0
  42. data/lib/busybee/testing/helpers.rb +56 -66
  43. data/lib/busybee/testing/matchers/complete_job.rb +55 -0
  44. data/lib/busybee/testing/matchers/fail_job.rb +75 -0
  45. data/lib/busybee/testing/matchers/have_activated.rb +1 -1
  46. data/lib/busybee/testing/matchers/have_available_jobs.rb +44 -0
  47. data/lib/busybee/testing/matchers/throw_bpmn_error_on.rb +72 -0
  48. data/lib/busybee/testing.rb +5 -33
  49. data/lib/busybee/version.rb +1 -1
  50. data/lib/busybee/worker/configuration.rb +287 -0
  51. data/lib/busybee/worker/dsl.rb +187 -0
  52. data/lib/busybee/worker/shutdown.rb +27 -0
  53. data/lib/busybee/worker.rb +130 -0
  54. data/lib/busybee.rb +134 -2
  55. metadata +80 -3
@@ -0,0 +1,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 process_instance_key
36
- raw.processInstanceKey
35
+ def type
36
+ raw.type
37
37
  end
38
38
 
39
- def variables
40
- @variables ||= JSON.parse(raw.variables)
39
+ def process_instance_key
40
+ raw.processInstanceKey
41
41
  end
42
42
 
43
- def headers
44
- @headers ||= JSON.parse(raw.customHeaders)
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: JSON.generate(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