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
data/exe/busybee ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "busybee"
5
+
6
+ Busybee::CLI.main(ARGV)
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Busybee
6
+ class CLI
7
+ attr_reader :runtime_config, :worker_class_names, :worker_classes
8
+
9
+ def self.main(args)
10
+ new(args).run
11
+ end
12
+
13
+ def initialize(args)
14
+ @worker_class_names = []
15
+ @parsed_options = {}
16
+ parse_options!(args.dup)
17
+ load_environment!
18
+ extract_workers_from_yaml! if @parsed_options[:config_file]
19
+ load_workers!
20
+ build_config!
21
+ apply_global_config!
22
+ end
23
+
24
+ def run
25
+ @runner = Runner.for(*@worker_classes, runtime_config: @runtime_config, client: client)
26
+ setup_signal_handlers!
27
+ @runner.run!
28
+ end
29
+
30
+ private
31
+
32
+ def client
33
+ @client ||= Busybee::Client.new
34
+ end
35
+
36
+ def handle_signal(_signal)
37
+ if @runner.stopping?
38
+ @runner.kill!
39
+ exit!(1)
40
+ else
41
+ @runner.stop!
42
+ end
43
+ end
44
+
45
+ def setup_signal_handlers!
46
+ %w[INT QUIT TERM].each do |signal|
47
+ trap(signal) do
48
+ Thread.new { handle_signal(signal) }.join
49
+ end
50
+ end
51
+ end
52
+
53
+ def load_environment!
54
+ return if ENV["BUSYBEE_SKIP_RAILS"]
55
+
56
+ begin
57
+ require "rails"
58
+ rescue LoadError
59
+ return
60
+ end
61
+
62
+ require "./config/environment"
63
+ rescue StandardError, LoadError => e
64
+ Busybee.logger&.error("Failed to load Rails environment: [#{e.class}] #{e.message}. " \
65
+ "Set BUSYBEE_SKIP_RAILS=1 to skip Rails loading.")
66
+ end
67
+
68
+ def extract_workers_from_yaml!
69
+ @yaml_kwargs = RuntimeConfig.parse_yaml(@parsed_options[:config_file])
70
+ @worker_class_names = (@yaml_kwargs[:workers] || {}).keys
71
+ end
72
+
73
+ def build_config!
74
+ if @yaml_kwargs
75
+ kwargs = @yaml_kwargs.dup
76
+ # Merge CLI process-wide flags into YAML-sourced kwargs
77
+ %i[log_format worker_name cluster_address].each do |field|
78
+ kwargs[field] = @parsed_options[field] if @parsed_options[field]
79
+ end
80
+ @runtime_config = RuntimeConfig.new(**kwargs)
81
+ else
82
+ @runtime_config = RuntimeConfig.new(**@parsed_options)
83
+ end
84
+ end
85
+
86
+ def load_workers!
87
+ raise Busybee::NoWorkersSpecified, "No worker classes specified" if @worker_class_names.empty?
88
+
89
+ @worker_classes = @worker_class_names.map do |name|
90
+ Kernel.const_get(name)
91
+ rescue NameError => e
92
+ raise Busybee::WorkerNotFound, "Could not load worker class '#{name}': #{e.message}"
93
+ end
94
+ end
95
+
96
+ def parse_options!(args)
97
+ option_parser.parse!(args)
98
+ @worker_class_names = args
99
+ validate_config_exclusions!
100
+ end
101
+
102
+ def validate_config_exclusions!
103
+ return unless @parsed_options[:config_file]
104
+
105
+ if @parsed_options[:worker_mode]
106
+ raise ArgumentError, "--config and --worker-mode are mutually exclusive. " \
107
+ "Set worker_mode in the YAML config file instead."
108
+ end
109
+
110
+ return if @worker_class_names.empty?
111
+
112
+ raise ArgumentError, "--config and positional worker args are mutually exclusive. " \
113
+ "List workers in the YAML config file instead."
114
+ end
115
+
116
+ def option_parser # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
117
+ OptionParser.new do |opts|
118
+ opts.banner = "Usage: busybee [options] WorkerClass [WorkerClass ...]"
119
+
120
+ opts.on("-v", "--version", "Print version and exit") do
121
+ puts Busybee::VERSION
122
+ exit
123
+ end
124
+
125
+ opts.on("-h", "--help", "Print this help message and exit") do
126
+ puts opts
127
+ exit
128
+ end
129
+
130
+ opts.on("-c", "--config FILE", "YAML configuration file") do |file|
131
+ @parsed_options[:config_file] = file
132
+ end
133
+
134
+ opts.on("-m", "--worker-mode MODE", "Worker mode (polling, streaming, hybrid)") do |mode|
135
+ @parsed_options[:worker_mode] = mode.to_sym
136
+ end
137
+
138
+ opts.on("-l", "--log-format FORMAT", "Log format (text, json)") do |format|
139
+ @parsed_options[:log_format] = format.to_sym
140
+ end
141
+
142
+ opts.on("-n", "--worker-name NAME", "Worker name for identification") do |name|
143
+ @parsed_options[:worker_name] = name
144
+ end
145
+
146
+ opts.on("-a", "--cluster-address ADDR", "Cluster address (host:port)") do |addr|
147
+ @parsed_options[:cluster_address] = addr
148
+ end
149
+ end
150
+ end
151
+
152
+ # Applies process-wide flags to gem-level config before runners start.
153
+ # Only sets values that were explicitly provided via CLI flags.
154
+ # Logs when overriding a value already set (e.g., by the Railtie).
155
+ def apply_global_config!
156
+ apply_global_setting!(:log_format)
157
+ apply_global_setting!(:worker_name)
158
+ apply_global_setting!(:cluster_address)
159
+ end
160
+
161
+ def apply_global_setting!(field)
162
+ cli_value = @runtime_config.public_send(field)
163
+ return unless cli_value
164
+
165
+ existing = Busybee.instance_variable_get(:"@#{field}")
166
+ if existing
167
+ flag = "--#{field.to_s.tr('_', '-')}"
168
+ Busybee.logger&.info("#{flag} overriding configured value: #{existing.inspect} → #{cli_value.inspect}")
169
+ end
170
+ Busybee.public_send(:"#{field}=", cli_value)
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "busybee"
4
+ require "busybee/grpc/error"
5
+ require "busybee/logging"
6
+
7
+ module Busybee
8
+ class Client
9
+ # Provides GRPC error wrapping and optional retry logic.
10
+ module ErrorHandling
11
+ # Execute a block with optional GRPC retry and error wrapping.
12
+ # @yield Block that makes GRPC call
13
+ # @return Result of the block
14
+ # @raise [Busybee::GRPC::Error] Wrapped GRPC error
15
+ def with_retry
16
+ attempts = 0
17
+ max_attempts = Busybee.grpc_retry_enabled ? 2 : 1
18
+
19
+ begin
20
+ attempts += 1
21
+ yield
22
+ rescue *Busybee.grpc_retry_errors => e
23
+ if attempts < max_attempts
24
+ Busybee::Logging.warn("GRPC call failed, retrying in #{Busybee.grpc_retry_delay_ms}ms",
25
+ error_class: e.class.name)
26
+ sleep(Busybee.grpc_retry_delay_ms / 1000.0)
27
+ retry
28
+ end
29
+ message = attempts > 1 ? "GRPC call failed after retry" : "GRPC call failed"
30
+ raise Busybee::GRPC::Error, message
31
+ rescue ::GRPC::BadStatus => e
32
+ raise Busybee::GRPC::Error, "GRPC call failed"
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/duration"
5
+ require "busybee/grpc"
6
+ require "busybee/serialization"
7
+
8
+ module Busybee
9
+ class Client
10
+ # Job completion, failure, and error throwing operations.
11
+ module JobOperations
12
+ # Complete a job with optional output variables.
13
+ #
14
+ # @param job_key [Integer] The unique job identifier
15
+ # @param vars [Hash] Variables to return to the workflow engine
16
+ # @return [Object] Response from the gateway (truthy)
17
+ # @raise [Busybee::GRPC::Error] if completion fails
18
+ #
19
+ # @example Complete a job without variables
20
+ # client.complete_job(123456)
21
+ #
22
+ # @example Complete a job with variables
23
+ # client.complete_job(123456, vars: { result: "success", orderId: 999 })
24
+ #
25
+ def complete_job(job_key, vars: {})
26
+ request = Busybee::GRPC::CompleteJobRequest.new(
27
+ jobKey: job_key.to_i,
28
+ variables: Busybee::Serialization.to_json(vars)
29
+ )
30
+
31
+ with_retry do
32
+ stub.complete_job(request)
33
+ end
34
+ end
35
+
36
+ # Fail a job with an error message.
37
+ #
38
+ # @param job_key [Integer] The unique job identifier
39
+ # @param error_message [String] Error message describing the failure
40
+ # @param retries [Integer, nil] Override the number of remaining retries
41
+ # @param backoff [Integer, ActiveSupport::Duration, nil] Delay before retry in milliseconds
42
+ # @return [Object] Response from the gateway (truthy)
43
+ # @raise [Busybee::GRPC::Error] if failure operation fails
44
+ #
45
+ # @example Fail a job with default backoff
46
+ # client.fail_job(123456, "Payment gateway timeout")
47
+ #
48
+ # @example Fail with custom retry count
49
+ # client.fail_job(123456, "Transient error", retries: 3)
50
+ #
51
+ # @example Fail with custom backoff duration
52
+ # client.fail_job(123456, "Rate limited", backoff: 30.seconds)
53
+ #
54
+ def fail_job(job_key, error_message, retries: nil, backoff: nil)
55
+ backoff_ms = backoff || Busybee.default_fail_job_backoff
56
+ backoff_ms = backoff_ms.is_a?(ActiveSupport::Duration) ? backoff_ms.in_milliseconds.to_i : backoff_ms.to_i
57
+
58
+ request = Busybee::GRPC::FailJobRequest.new(
59
+ jobKey: job_key.to_i,
60
+ errorMessage: error_message.to_s,
61
+ retryBackOff: backoff_ms
62
+ )
63
+
64
+ request.retries = retries.to_i if retries
65
+
66
+ with_retry do
67
+ stub.fail_job(request)
68
+ end
69
+ end
70
+
71
+ # Throw a BPMN error to be caught by an error boundary event.
72
+ #
73
+ # @param job_key [Integer] The unique job identifier
74
+ # @param error_code [String] BPMN error code (typically UPPERCASE_SNAKE_CASE)
75
+ # @param message [String] Optional error message for context
76
+ # @return [Object] Response from the gateway (truthy)
77
+ # @raise [Busybee::GRPC::Error] if throw operation fails
78
+ #
79
+ # @example Throw a BPMN error
80
+ # client.throw_bpmn_error(123456, "ORDER_NOT_FOUND", message: "Order 550e8400 not found")
81
+ #
82
+ # @example Throw a BPMN error without message
83
+ # client.throw_bpmn_error(123456, "PAYMENT_FAILED")
84
+ #
85
+ def throw_bpmn_error(job_key, error_code, message: "")
86
+ request = Busybee::GRPC::ThrowErrorRequest.new(
87
+ jobKey: job_key.to_i,
88
+ errorCode: error_code.to_s,
89
+ errorMessage: message.to_s
90
+ )
91
+
92
+ with_retry do
93
+ stub.throw_error(request)
94
+ end
95
+ end
96
+
97
+ # Update the retry count for a job.
98
+ #
99
+ # @param job_key [Integer] The unique job identifier
100
+ # @param retries [Integer] The new number of retries
101
+ # @return [Object] Response from the gateway (truthy)
102
+ # @raise [Busybee::GRPC::Error] if update operation fails
103
+ #
104
+ # @example Update job retries
105
+ # client.update_job_retries(123456, 5)
106
+ #
107
+ def update_job_retries(job_key, retries)
108
+ request = Busybee::GRPC::UpdateJobRetriesRequest.new(
109
+ jobKey: job_key.to_i,
110
+ retries: retries.to_i
111
+ )
112
+
113
+ with_retry do
114
+ stub.update_job_retries(request)
115
+ end
116
+ end
117
+
118
+ # Update the timeout for a job.
119
+ #
120
+ # @param job_key [Integer] The unique job identifier
121
+ # @param timeout [Integer, ActiveSupport::Duration] New timeout in milliseconds
122
+ # @return [Object] Response from the gateway (truthy)
123
+ # @raise [Busybee::GRPC::Error] if update operation fails
124
+ #
125
+ # @example Update job timeout with milliseconds
126
+ # client.update_job_timeout(123456, 30_000)
127
+ #
128
+ # @example Update job timeout with Duration
129
+ # client.update_job_timeout(123456, 30.seconds)
130
+ #
131
+ def update_job_timeout(job_key, timeout)
132
+ timeout_ms = timeout.is_a?(ActiveSupport::Duration) ? timeout.in_milliseconds.to_i : timeout.to_i
133
+
134
+ request = Busybee::GRPC::UpdateJobTimeoutRequest.new(
135
+ jobKey: job_key.to_i,
136
+ timeout: timeout_ms
137
+ )
138
+
139
+ with_retry do
140
+ stub.update_job_timeout(request)
141
+ end
142
+ end
143
+
144
+ # Activate and process jobs with a block (bounded, non-streaming).
145
+ #
146
+ # @param job_type [String] The job type to activate
147
+ # @param max_jobs [Integer] Maximum number of jobs to activate
148
+ # @param job_timeout [Integer, ActiveSupport::Duration, nil] Job lock timeout in milliseconds
149
+ # (defaults to Busybee.default_job_lock_timeout)
150
+ # @param request_timeout [Integer, ActiveSupport::Duration, nil] Request timeout in milliseconds
151
+ # (defaults to Busybee.default_job_request_timeout)
152
+ # @yield [job] Yields each activated job to the block
153
+ # @yieldparam job [Busybee::Job] The activated job
154
+ # @return [Integer] Count of jobs processed
155
+ # @raise [ArgumentError] if no block given
156
+ # @raise [Busybee::GRPC::Error] if activation fails
157
+ #
158
+ # @example Process jobs
159
+ # client.with_each_job("send-email") do |job|
160
+ # send_email(job.variables.email, job.variables.subject)
161
+ # job.complete!
162
+ # end
163
+ #
164
+ def with_each_job(job_type, max_jobs: Busybee::Defaults::DEFAULT_MAX_JOBS, # rubocop:disable Metrics/AbcSize
165
+ job_timeout: nil,
166
+ request_timeout: nil)
167
+ raise ArgumentError, "block required" unless block_given?
168
+
169
+ job_timeout ||= Busybee.default_job_lock_timeout
170
+ request_timeout ||= Busybee.default_job_request_timeout
171
+
172
+ request = Busybee::GRPC::ActivateJobsRequest.new(
173
+ type: job_type.to_s,
174
+ worker: Busybee.worker_name,
175
+ maxJobsToActivate: max_jobs.to_i,
176
+ timeout: milliseconds_from(job_timeout),
177
+ requestTimeout: milliseconds_from(request_timeout)
178
+ )
179
+
180
+ count = 0
181
+ responses = with_retry { stub.activate_jobs(request) }
182
+
183
+ responses.each do |response|
184
+ response.jobs.each do |raw_job|
185
+ job = Busybee::Job.new(raw_job, client: self)
186
+ yield job
187
+ count += 1
188
+ end
189
+ end
190
+
191
+ count
192
+ end
193
+
194
+ # Open a long-lived stream for job activation.
195
+ #
196
+ # Unlike {#with_each_job}, this method returns a {Busybee::JobStream} that
197
+ # continuously receives jobs as they become available. The stream remains
198
+ # open until explicitly closed via {Busybee::JobStream#close}.
199
+ #
200
+ # @note The stream blocks while iterating. Call {Busybee::JobStream#close}
201
+ # from another thread or use a signal handler to terminate the stream.
202
+ #
203
+ # @param job_type [String] The job type to activate
204
+ # @param job_timeout [Integer, ActiveSupport::Duration, nil] Job lock timeout in milliseconds
205
+ # (defaults to Busybee.default_job_lock_timeout)
206
+ # @return [Busybee::JobStream] Stream of activated jobs
207
+ # @raise [Busybee::GRPC::Error] if stream creation fails
208
+ #
209
+ # @example Process jobs with graceful shutdown
210
+ # stream = client.open_job_stream("send-email", job_timeout: 60.seconds)
211
+ # trap("INT") { stream.close }
212
+ #
213
+ # stream.each do |job|
214
+ # send_email(job.variables)
215
+ # job.complete!
216
+ # end
217
+ #
218
+ def open_job_stream(job_type, job_timeout: nil)
219
+ job_timeout ||= Busybee.default_job_lock_timeout
220
+
221
+ request = Busybee::GRPC::StreamActivatedJobsRequest.new(
222
+ type: job_type.to_s,
223
+ worker: Busybee.worker_name,
224
+ timeout: milliseconds_from(job_timeout)
225
+ )
226
+
227
+ with_retry do
228
+ Busybee::JobStream.new(
229
+ stub.stream_activated_jobs(request, return_op: true),
230
+ client: self
231
+ )
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/duration"
5
+ require "busybee/grpc"
6
+ require "busybee/serialization"
7
+
8
+ module Busybee
9
+ class Client
10
+ # Message and signal operations for process communication.
11
+ module MessageOperations
12
+ # Publish a message to trigger message catch events in process instances.
13
+ #
14
+ # @param name [String] The message name
15
+ # @param correlation_key [String] Correlation key to match against process instances
16
+ # @param ttl [Integer, ActiveSupport::Duration, nil] Time-to-live in milliseconds or Duration object
17
+ # (defaults to Busybee.default_message_ttl)
18
+ # @param vars [Hash] Variables to pass with the message
19
+ # @param tenant_id [String, nil] Tenant ID for multi-tenancy
20
+ # @return [Integer] The message key
21
+ # @raise [ArgumentError] if vars is not a Hash
22
+ # @raise [Busybee::GRPC::Error] if publishing fails
23
+ #
24
+ # @example Publish a message with default TTL
25
+ # key = client.publish_message("order-ready", correlation_key: "order-123")
26
+ # # => 12345
27
+ #
28
+ # @example With explicit TTL and variables
29
+ # client.publish_message("order-ready",
30
+ # correlation_key: "order-123",
31
+ # ttl: 30.seconds,
32
+ # vars: { orderId: 123 })
33
+ #
34
+ def publish_message(name, correlation_key:, vars: {}, ttl: nil, tenant_id: nil)
35
+ raise ArgumentError, "vars must be a Hash" unless vars.is_a?(Hash)
36
+
37
+ ttl ||= Busybee.default_message_ttl
38
+ ttl_ms = ttl.is_a?(ActiveSupport::Duration) ? ttl.in_milliseconds.to_i : ttl.to_i
39
+
40
+ request = Busybee::GRPC::PublishMessageRequest.new(
41
+ name: name.to_s,
42
+ correlationKey: correlation_key.to_s,
43
+ variables: Busybee::Serialization.to_json(vars),
44
+ timeToLive: ttl_ms,
45
+ tenantId: tenant_id&.to_s
46
+ )
47
+
48
+ with_retry do
49
+ stub.publish_message(request).key
50
+ end
51
+ end
52
+
53
+ # Broadcast a signal to all process instances with matching signal catch events.
54
+ #
55
+ # @param signal_name [String] The signal name
56
+ # @param vars [Hash] Variables to pass with the signal
57
+ # @param tenant_id [String, nil] Tenant ID for multi-tenancy
58
+ # @return [Integer] The signal key
59
+ # @raise [ArgumentError] if vars is not a Hash
60
+ # @raise [Busybee::GRPC::Error] if broadcasting fails
61
+ #
62
+ # @example Broadcast a signal
63
+ # key = client.broadcast_signal("cancel-all-orders")
64
+ # # => 54321
65
+ #
66
+ # @example With variables
67
+ # client.broadcast_signal("cancel-all-orders", vars: { reason: "system-maintenance" })
68
+ #
69
+ def broadcast_signal(signal_name, vars: {}, tenant_id: nil)
70
+ raise ArgumentError, "vars must be a Hash" unless vars.is_a?(Hash)
71
+
72
+ request = Busybee::GRPC::BroadcastSignalRequest.new(
73
+ signalName: signal_name.to_s,
74
+ variables: Busybee::Serialization.to_json(vars),
75
+ tenantId: tenant_id&.to_s
76
+ )
77
+
78
+ with_retry do
79
+ stub.broadcast_signal(request).key
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "busybee/grpc"
4
+ require "busybee/serialization"
5
+
6
+ module Busybee
7
+ class Client
8
+ # Process deployment, instance creation, and cancellation operations.
9
+ module ProcessOperations
10
+ # Deploy one or more BPMN files.
11
+ #
12
+ # @param paths [Array<String>] Paths to BPMN files
13
+ # @param tenant_id [String, nil] Tenant ID for multi-tenancy
14
+ # @return [Hash{String => Integer}] Map of bpmn_process_id => process_definition_key
15
+ # @raise [Busybee::GRPC::Error] if deployment fails
16
+ #
17
+ # @example Deploy a single file
18
+ # client.deploy_process("workflows/order.bpmn")
19
+ # # => { "order-fulfillment" => 2251799813685249 }
20
+ #
21
+ # @example Deploy multiple files
22
+ # client.deploy_process("order.bpmn", "payment.bpmn")
23
+ # # => { "order-fulfillment" => 123, "payment-process" => 456 }
24
+ #
25
+ def deploy_process(*paths, tenant_id: nil) # rubocop:disable Metrics/AbcSize
26
+ resources = paths.map do |path|
27
+ Busybee::GRPC::Resource.new(
28
+ name: File.basename(path),
29
+ content: File.read(path)
30
+ )
31
+ end
32
+
33
+ request = Busybee::GRPC::DeployResourceRequest.new(
34
+ resources: resources,
35
+ tenantId: tenant_id&.to_s
36
+ )
37
+
38
+ with_retry do
39
+ response = stub.deploy_resource(request)
40
+ response.deployments.each_with_object({}) do |deployment, result|
41
+ result[deployment.process.bpmnProcessId] = deployment.process.processDefinitionKey
42
+ end
43
+ end
44
+ end
45
+
46
+ # Start a process instance.
47
+ #
48
+ # @param bpmn_process_id [String] The BPMN process ID to start
49
+ # @param vars [Hash] Variables to pass to the process instance
50
+ # @param version [Integer, Symbol, nil] Process version (:latest, nil, or specific version number)
51
+ # @param tenant_id [String, nil] Tenant ID for multi-tenancy
52
+ # @return [Integer] The process_instance_key
53
+ # @raise [ArgumentError] if vars is not a Hash
54
+ # @raise [Busybee::GRPC::Error] if starting the process fails
55
+ #
56
+ # @example Start a process instance with variables
57
+ # key = client.start_instance("order-fulfillment", vars: { orderId: 123 })
58
+ # # => 67890
59
+ #
60
+ def start_instance(bpmn_process_id, vars: {}, version: :latest, tenant_id: nil)
61
+ raise ArgumentError, "vars must be a Hash" unless vars.is_a?(Hash)
62
+
63
+ request = Busybee::GRPC::CreateProcessInstanceRequest.new(
64
+ bpmnProcessId: bpmn_process_id.to_s,
65
+ variables: Busybee::Serialization.to_json(vars),
66
+ version: version == :latest || version.nil? ? -1 : version.to_i,
67
+ tenantId: tenant_id&.to_s
68
+ )
69
+
70
+ with_retry do
71
+ stub.create_process_instance(request).processInstanceKey
72
+ end
73
+ end
74
+ alias start_process_instance start_instance
75
+
76
+ # Cancel a running process instance.
77
+ #
78
+ # @param process_instance_key [Integer, String] The process instance key to cancel
79
+ # @param ignore_missing [Boolean] If true, return false instead of raising when instance not found
80
+ # @return [Boolean] true if cancelled, false if not found and ignore_missing is true
81
+ # @raise [Busybee::GRPC::Error] if cancellation fails (unless ignore_missing for NotFound)
82
+ #
83
+ # @example Cancel an instance
84
+ # client.cancel_instance(67890)
85
+ # # => true
86
+ #
87
+ # @example Safely cancel without error if missing
88
+ # client.cancel_instance(99999, ignore_missing: true)
89
+ # # => false
90
+ #
91
+ def cancel_instance(process_instance_key, ignore_missing: false)
92
+ request = Busybee::GRPC::CancelProcessInstanceRequest.new(
93
+ processInstanceKey: process_instance_key.to_i
94
+ )
95
+
96
+ with_retry do
97
+ stub.cancel_process_instance(request)
98
+ true
99
+ end
100
+ rescue Busybee::GRPC::Error => e
101
+ raise unless ignore_missing && e.grpc_status == :not_found
102
+
103
+ false
104
+ end
105
+ alias cancel_process_instance cancel_instance
106
+ end
107
+ end
108
+ end