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,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "grpc"
4
+
5
+ require "busybee/credentials"
6
+
7
+ module Busybee
8
+ class Credentials
9
+ # TLS credentials with server certificate verification.
10
+ # No client authentication (mTLS not supported in v0.2).
11
+ #
12
+ # @example With system default certificates
13
+ # credentials = Busybee::Credentials::TLS.new(cluster_address: "zeebe.example.com:443")
14
+ # stub = credentials.grpc_stub
15
+ #
16
+ # @example With custom CA certificate
17
+ # credentials = Busybee::Credentials::TLS.new(
18
+ # cluster_address: "zeebe.example.com:443",
19
+ # certificate_file: "/path/to/ca-cert.pem"
20
+ # )
21
+ # stub = credentials.grpc_stub
22
+ #
23
+ class TLS < Credentials
24
+ attr_reader :certificate_file
25
+
26
+ # @param cluster_address [String, nil] Zeebe cluster address (host:port)
27
+ # @param certificate_file [String, nil] Path to CA certificate file.
28
+ # If nil, uses system default certificates.
29
+ def initialize(cluster_address: nil, certificate_file: nil)
30
+ super(cluster_address: cluster_address)
31
+ @certificate_file = certificate_file
32
+ end
33
+
34
+ def grpc_channel_credentials
35
+ if certificate_file
36
+ ::GRPC::Core::ChannelCredentials.new(File.read(certificate_file))
37
+ else
38
+ ::GRPC::Core::ChannelCredentials.new
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "busybee"
4
+
5
+ module Busybee
6
+ # Base class for credentials. Defines interface for all credential types.
7
+ #
8
+ # Credentials objects are responsible for:
9
+ # - Knowing which cluster address to connect to
10
+ # - Providing gRPC channel credentials for authentication
11
+ # - Creating and memoizing gRPC stub instances
12
+ #
13
+ # Subclasses must implement #grpc_channel_credentials.
14
+ #
15
+ # @example Direct stub access
16
+ # credentials = Busybee::Credentials::Insecure.new
17
+ # stub = credentials.grpc_stub
18
+ # response = stub.topology(Busybee::GRPC::TopologyRequest.new)
19
+ #
20
+ class Credentials
21
+ attr_reader :cluster_address
22
+
23
+ class << self
24
+ # Factory method to build appropriate credentials based on configuration.
25
+ #
26
+ # First checks Busybee.credential_type for explicit type selection.
27
+ # If not set, autodetects credential type based on which keys are present in params.
28
+ # If no keys are given in params, attempts to load them from environment vars.
29
+ #
30
+ # @param cluster_address [String, nil] Override cluster address
31
+ # @param params [Hash] Configuration parameters (keys inform credential type selection)
32
+ # @option params [Boolean] :insecure Use insecure connection (no TLS, no auth)
33
+ # @return [Credentials] Appropriate credentials instance
34
+ #
35
+ # @example Insecure for local development
36
+ # Credentials.build(insecure: true)
37
+ #
38
+ # @example With explicit type configuration
39
+ # Busybee.credential_type = :insecure
40
+ # Credentials.build # Uses configured type
41
+ #
42
+ def build(cluster_address: nil, **params)
43
+ if params.empty?
44
+ params = extract_possible_credential_params_from_env
45
+ extracted_address = params.delete(:cluster_address) # always delete to avoid duplicate keyword arg
46
+ cluster_address ||= extracted_address # allow explicit kwarg to override env
47
+ end
48
+
49
+ case Busybee.credential_type
50
+ when :insecure
51
+ build_insecure(cluster_address: cluster_address, **params)
52
+ when :tls
53
+ build_tls(cluster_address: cluster_address, **params)
54
+ when :oauth
55
+ build_oauth(cluster_address: cluster_address, **params)
56
+ when :camunda_cloud
57
+ build_camunda_cloud(cluster_address: cluster_address, **params)
58
+ else
59
+ autodetect_credentials(cluster_address: cluster_address, **params)
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ # Autodetects credential type based on provided parameters.
66
+ # As new credential types are added, extend this method with detection logic.
67
+ #
68
+ # @raise [Busybee::CannotDetectCredentials] if params are present but don't match
69
+ # any known credential type pattern
70
+ def autodetect_credentials(cluster_address: nil, **params)
71
+ if tls_keys?(params)
72
+ build_tls(cluster_address: cluster_address, **params)
73
+ elsif oauth_keys?(params)
74
+ build_oauth(cluster_address: cluster_address, **params)
75
+ elsif camunda_cloud_keys?(params)
76
+ build_camunda_cloud(cluster_address: cluster_address, **params)
77
+ elsif insecure_fallback_allowed?(params)
78
+ build_insecure(cluster_address: cluster_address, **params)
79
+ else
80
+ raise Busybee::CannotDetectCredentials,
81
+ "Cannot detect credential type from provided params: #{params.keys.join(', ')}. " \
82
+ "Set Busybee.credential_type explicitly or provide complete params for a credential type."
83
+ end
84
+ end
85
+
86
+ # Insecure fallback is only allowed when no credential params were provided,
87
+ # or when `insecure: true` is the only param.
88
+ def insecure_fallback_allowed?(params)
89
+ params.empty? || params == { insecure: true }
90
+ end
91
+
92
+ def id_and_secret?(params)
93
+ params[:client_id] && params[:client_secret]
94
+ end
95
+
96
+ def tls_keys?(params)
97
+ !id_and_secret?(params) && (params[:certificate_file] || params[:tls])
98
+ end
99
+
100
+ def oauth_keys?(params)
101
+ id_and_secret?(params) && params[:token_url] && params[:audience]
102
+ end
103
+
104
+ def camunda_cloud_keys?(params)
105
+ id_and_secret?(params) && params[:cluster_id] && params[:region]
106
+ end
107
+
108
+ def build_insecure(cluster_address: nil, **_)
109
+ require "busybee/credentials/insecure"
110
+ Insecure.new(cluster_address: cluster_address)
111
+ end
112
+
113
+ def build_tls(cluster_address: nil, certificate_file: nil, **_)
114
+ require "busybee/credentials/tls"
115
+ TLS.new(cluster_address: cluster_address, certificate_file: certificate_file)
116
+ end
117
+
118
+ def build_oauth( # rubocop:disable Metrics/ParameterLists
119
+ cluster_address: nil,
120
+ token_url: nil,
121
+ client_id: nil,
122
+ client_secret: nil,
123
+ audience: nil,
124
+ scope: nil,
125
+ certificate_file: nil,
126
+ **_
127
+ )
128
+ require "busybee/credentials/oauth"
129
+ OAuth.new(
130
+ cluster_address: cluster_address,
131
+ token_url: token_url,
132
+ client_id: client_id,
133
+ client_secret: client_secret,
134
+ audience: audience,
135
+ scope: scope,
136
+ certificate_file: certificate_file
137
+ )
138
+ end
139
+
140
+ # NOTE: cluster_address is intentionally omitted - CamundaCloud constructs it from cluster_id and region
141
+ def build_camunda_cloud(client_id: nil, client_secret: nil, cluster_id: nil, region: nil, scope: nil, **_) # rubocop:disable Metrics/ParameterLists
142
+ require "busybee/credentials/camunda_cloud"
143
+ CamundaCloud.new(
144
+ client_id: client_id,
145
+ client_secret: client_secret,
146
+ cluster_id: cluster_id,
147
+ region: region,
148
+ scope: scope
149
+ )
150
+ end
151
+
152
+ # Attempt to extract credentials from environment variables, if present.
153
+ def extract_possible_credential_params_from_env
154
+ {
155
+ cluster_address: ENV.fetch("CLUSTER_ADDRESS", nil),
156
+ # Camunda Cloud params
157
+ client_id: ENV.fetch("CAMUNDA_CLIENT_ID", nil),
158
+ client_secret: ENV.fetch("CAMUNDA_CLIENT_SECRET", nil),
159
+ cluster_id: ENV.fetch("CAMUNDA_CLUSTER_ID", nil),
160
+ region: ENV.fetch("CAMUNDA_CLUSTER_REGION", nil),
161
+ # OAuth params
162
+ token_url: ENV.fetch("ZEEBE_TOKEN_URL", nil),
163
+ audience: ENV.fetch("ZEEBE_AUDIENCE", nil),
164
+ scope: ENV.fetch("ZEEBE_SCOPE", nil),
165
+ # TLS params
166
+ certificate_file: ENV.fetch("ZEEBE_CERTIFICATE_FILE", nil)
167
+ }.compact
168
+ end
169
+ end
170
+
171
+ # @param cluster_address [String, nil] Zeebe cluster address (host:port)
172
+ # If nil, falls back to Busybee.cluster_address
173
+ def initialize(cluster_address: nil)
174
+ @cluster_address = cluster_address || Busybee.cluster_address
175
+ end
176
+
177
+ # Returns a ready-to-use gRPC stub for the Zeebe Gateway API.
178
+ # The stub is memoized internally - callers should not cache it themselves.
179
+ # For credentials that handle token refresh (like OAuth), this ensures
180
+ # the stub can be replaced transparently when tokens are refreshed.
181
+ #
182
+ # @return [Busybee::GRPC::Gateway::Stub]
183
+ def grpc_stub
184
+ @grpc_stub ||= begin
185
+ require "busybee/grpc"
186
+ Busybee::GRPC::Gateway::Stub.new(cluster_address, grpc_channel_credentials)
187
+ end
188
+ end
189
+
190
+ # Returns gRPC channel credentials for authentication.
191
+ # Subclasses must implement this method.
192
+ #
193
+ # @return [Symbol, GRPC::Core::ChannelCredentials]
194
+ # - :this_channel_is_insecure for insecure connections
195
+ # - GRPC::Core::ChannelCredentials for TLS/OAuth
196
+ def grpc_channel_credentials
197
+ raise NotImplementedError, "#{self.class} must implement #grpc_channel_credentials"
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Busybee
4
+ # Default values for client and worker operations.
5
+ # Can be overridden per-call or via gem configuration.
6
+ module Defaults
7
+ DEFAULT_FAIL_JOB_BACKOFF_MS = 5_000
8
+ DEFAULT_GRPC_RETRY_DELAY_MS = 500
9
+ DEFAULT_JOB_REQUEST_TIMEOUT_MS = 60_000
10
+ DEFAULT_JOB_LOCK_TIMEOUT_MS = 60_000
11
+ DEFAULT_INPUT_REQUIRED = true
12
+ DEFAULT_MAX_JOBS = 25
13
+ DEFAULT_MESSAGE_TTL_MS = 10_000
14
+ DEFAULT_OUTPUT_REQUIRED = true
15
+ DEFAULT_BUFFER_THROTTLE_MS = false
16
+ DEFAULT_BACKPRESSURE_DELAY_MS = 2_000
17
+ DEFAULT_WORKER_MODE = :hybrid
18
+ DEFAULT_STREAMING_BUFFER = true
19
+ end
20
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Busybee
4
+ # Base class for all gem operation errors.
5
+ # Never raised directly; exists for `rescue Busybee::Error`.
6
+ Error = Class.new(StandardError)
7
+
8
+ # There are two error classes not contained here which serve special purposes:
9
+ # - Busybee::GRPC::Error is used by the GRPC layer to wrap GRPC::BadStatus errors
10
+ # - Busybee::Worker::Shutdown is used to signal the need to shut down a running process
11
+ # Both of these inherit from Busybee::Error. See their class files for details.
12
+
13
+ # Errors below this point are specific and semantic -- they are used in just one or two
14
+ # places, and their name tells you exactly what went wrong:
15
+
16
+ # Raised when Credentials.build cannot determine credential type from provided params.
17
+ # This happens when params are provided but don't match any known credential type pattern,
18
+ # and no explicit credential_type is configured.
19
+ CannotDetectCredentials = Class.new(Error)
20
+
21
+ # Raised when job variables or headers JSON cannot be parsed
22
+ InvalidJobJson = Class.new(Error)
23
+
24
+ # Raised when OAuth2 token endpoint returns invalid JSON
25
+ InvalidOAuthResponse = Class.new(Error)
26
+
27
+ # Raised when a Worker DSL declaration is invalid (conflicting options, bad values, etc.)
28
+ InvalidWorkerDefinition = Class.new(Error)
29
+
30
+ # Raised when required inputs are missing from job variables/headers
31
+ MissingInput = Class.new(Error)
32
+
33
+ # Raised when required outputs are missing from perform's return value
34
+ MissingOutput = Class.new(Error)
35
+
36
+ # Raised when the CLI is invoked with no worker class arguments
37
+ NoWorkersSpecified = Class.new(Error)
38
+
39
+ # Raised when attempting to complete, fail, or throw error on a job that has already been handled
40
+ JobAlreadyHandled = Class.new(Error)
41
+
42
+ # Raised when OAuth2 token refresh fails (HTTP error from token endpoint)
43
+ OAuthTokenRefreshFailed = Class.new(Error)
44
+
45
+ # Raised when attempting to iterate a stream that has been closed
46
+ StreamAlreadyClosed = Class.new(Error)
47
+
48
+ # Raised when a worker class name cannot be resolved to a constant
49
+ WorkerNotFound = Class.new(Error)
50
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "busybee/error"
4
+
5
+ module Busybee
6
+ module GRPC
7
+ # Wraps GRPC::BadStatus errors with Ruby-friendly interface.
8
+ # Preserves original error via automatic exception chaining.
9
+ #
10
+ # @example
11
+ # begin
12
+ # stub.some_call(request)
13
+ # rescue ::GRPC::BadStatus
14
+ # raise Busybee::GRPC::Error.new("Operation failed")
15
+ # end
16
+ #
17
+ class Error < Busybee::Error
18
+ def initialize(message = "GRPC request failed")
19
+ super
20
+ end
21
+
22
+ # Returns the error message, automatically incorporating GRPC error details.
23
+ # If the cause is a GRPC::BadStatus, appends "(grpc_details)" to the message.
24
+ def message
25
+ base = super
26
+
27
+ if cause.is_a?(::GRPC::BadStatus)
28
+ "#{base} (#{cause.details})"
29
+ else
30
+ base
31
+ end
32
+ end
33
+
34
+ # Returns the GRPC status code as an integer (e.g., 14 for Unavailable).
35
+ # Returns nil if the cause is not a GRPC::BadStatus error.
36
+ def grpc_code
37
+ return nil unless cause.is_a?(::GRPC::BadStatus)
38
+
39
+ cause.code
40
+ end
41
+
42
+ # Returns the GRPC status as a symbol (e.g., :unavailable).
43
+ # Returns nil if the cause is not a GRPC::BadStatus error.
44
+ def grpc_status
45
+ return nil unless cause.is_a?(::GRPC::BadStatus)
46
+
47
+ # GRPC::Unavailable -> :unavailable
48
+ cause.class.name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym
49
+ end
50
+
51
+ # Returns the GRPC error details string.
52
+ # Returns nil if the cause is not a GRPC::BadStatus error.
53
+ def grpc_details
54
+ return nil unless cause.is_a?(::GRPC::BadStatus)
55
+
56
+ cause.details
57
+ end
58
+ end
59
+ end
60
+ end
data/lib/busybee/grpc.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "grpc/gateway_pb"
4
- require_relative "grpc/gateway_services_pb"
3
+ require "busybee/grpc/gateway_pb"
4
+ require "busybee/grpc/gateway_services_pb"
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "busybee/serialization"
4
+
5
+ module Busybee
6
+ # Represents a job activated from Zeebe for processing by a worker.
7
+ #
8
+ # Wraps the raw GRPC ActivatedJob protobuf with a Ruby-idiomatic interface.
9
+ # Tracks job status to prevent double-completion bugs.
10
+ #
11
+ # @example Complete a job
12
+ # job = Busybee::Job.new(raw_job, client: client)
13
+ # job.complete!(result: "success")
14
+ #
15
+ # @example Fail a job with retry
16
+ # job.fail!("Payment gateway timeout", retries: 3, backoff: 30.seconds)
17
+ #
18
+ # @example Throw a BPMN error
19
+ # job.throw_bpmn_error!(:order_not_found, "Order #{order_id} not found")
20
+ #
21
+ class Job
22
+ attr_reader :status
23
+
24
+ # Create a new Job wrapper.
25
+ #
26
+ # @param raw_job [Busybee::GRPC::ActivatedJob] The raw GRPC job protobuf
27
+ # @param client [Busybee::Client] The client instance for completing/failing jobs
28
+ def initialize(raw_job, client:)
29
+ @raw_job = raw_job
30
+ @client = client
31
+ @status = :ready
32
+ end
33
+
34
+ # Job key (unique identifier)
35
+ # @return [Integer]
36
+ def key
37
+ @raw_job.key
38
+ end
39
+
40
+ # Job type (task definition type from BPMN)
41
+ # @return [String]
42
+ def type
43
+ @raw_job.type
44
+ end
45
+
46
+ # Process instance key
47
+ # @return [Integer]
48
+ def process_instance_key
49
+ @raw_job.processInstanceKey
50
+ end
51
+
52
+ # BPMN process ID
53
+ # @return [String]
54
+ def bpmn_process_id
55
+ @raw_job.bpmnProcessId
56
+ end
57
+
58
+ # Number of retries remaining
59
+ # @return [Integer]
60
+ def retries
61
+ @retries_override || @raw_job.retries
62
+ end
63
+
64
+ # Job deadline as a frozen Time object
65
+ # @return [Time]
66
+ def deadline
67
+ @deadline ||= Time.at(@raw_job.deadline / 1000.0).utc.freeze
68
+ end
69
+
70
+ # Job variables with indifferent access and method-style access.
71
+ # Returns a frozen hash that supports both hash[:key] and hash.key access.
72
+ # Nested hashes also support method access.
73
+ #
74
+ # @return [ActiveSupport::HashWithIndifferentAccess] frozen hash with method access
75
+ def variables
76
+ @variables ||= parse_and_freeze_hash(@raw_job.variables, "variables")
77
+ end
78
+
79
+ # Job custom headers with indifferent access and method-style access.
80
+ # Returns a frozen hash that supports both hash[:key] and hash.key access.
81
+ #
82
+ # @return [ActiveSupport::HashWithIndifferentAccess] frozen hash with method access
83
+ def headers
84
+ @headers ||= parse_and_freeze_hash(@raw_job.customHeaders, "headers")
85
+ end
86
+
87
+ # Complete the job with optional output variables.
88
+ #
89
+ # @param vars [Hash] Variables to return to the workflow engine
90
+ # @return [Object] Response from complete_job operation
91
+ # @raise [Busybee::JobAlreadyHandled] if job has already been completed, failed, or errored
92
+ def complete!(vars = {})
93
+ raise Busybee::JobAlreadyHandled, "Cannot complete job #{key} because it is already #{status}" unless ready?
94
+
95
+ @client.complete_job(key, vars: vars).tap do
96
+ @status = :complete
97
+ # [hook: job.completed]
98
+ end
99
+ end
100
+
101
+ # Fail the job with an error message.
102
+ #
103
+ # @param error_message_or_exception [String, Exception] Error message or exception
104
+ # @param retries [Integer, nil] Override retry count
105
+ # @param backoff [Integer, ActiveSupport::Duration, nil] Backoff before retry
106
+ # @return [Object] Response from fail_job operation
107
+ # @raise [Busybee::JobAlreadyHandled] if job has already been completed, failed, or errored
108
+ def fail!(error_message_or_exception, retries: nil, backoff: nil)
109
+ raise Busybee::JobAlreadyHandled, "Cannot fail job #{key} because it is already #{status}" unless ready?
110
+
111
+ message = format_error_message(error_message_or_exception)
112
+
113
+ @client.fail_job(key, message, retries: retries, backoff: backoff).tap do
114
+ @status = :failed
115
+ # [hook: job.failed]
116
+ end
117
+ end
118
+
119
+ # Throw a BPMN error to be caught by an error boundary event.
120
+ #
121
+ # @param code_or_exception [String, Symbol, Exception] Error code or exception
122
+ # @param message [String] Optional error message
123
+ # @return [Object] Response from throw_bpmn_error operation
124
+ # @raise [Busybee::JobAlreadyHandled] if job has already been completed, failed, or errored
125
+ def throw_bpmn_error!(code_or_exception, message = "")
126
+ unless ready?
127
+ raise Busybee::JobAlreadyHandled,
128
+ "Cannot throw BPMN error on job #{key} because it is already #{status}"
129
+ end
130
+
131
+ code = format_error_code(code_or_exception)
132
+ message = code_or_exception.message if code_or_exception.is_a?(Exception) && message.empty?
133
+
134
+ @client.throw_bpmn_error(key, code, message: message).tap do
135
+ @status = :error
136
+ # [hook: job.error]
137
+ end
138
+ end
139
+
140
+ # Update the retry count for this job.
141
+ #
142
+ # @param count [Integer] The new number of retries
143
+ # @return [Object] Response from update_job_retries operation
144
+ # @raise [Busybee::GRPC::Error] if the update fails
145
+ def update_retries(count)
146
+ @client.update_job_retries(key, count).tap do
147
+ @retries_override = count
148
+ end
149
+ end
150
+
151
+ # Update the timeout for this job.
152
+ #
153
+ # @param duration [Integer, ActiveSupport::Duration] New timeout in milliseconds
154
+ # @return [Object] Response from update_job_timeout operation
155
+ # @raise [Busybee::GRPC::Error] if the update fails
156
+ def update_timeout(duration)
157
+ duration_seconds = duration.is_a?(ActiveSupport::Duration) ? duration.in_seconds : duration / 1000.0
158
+ @client.update_job_timeout(key, duration).tap do
159
+ @deadline = (Time.now.utc + duration_seconds).freeze
160
+ end
161
+ end
162
+
163
+ # Is the job ready for processing?
164
+ # @return [Boolean]
165
+ def ready?
166
+ status == :ready
167
+ end
168
+
169
+ # Has the job been completed?
170
+ # @return [Boolean]
171
+ def complete?
172
+ status == :complete
173
+ end
174
+
175
+ # Has the job failed?
176
+ # @return [Boolean]
177
+ def failed?
178
+ status == :failed
179
+ end
180
+
181
+ # Has the job thrown a BPMN error?
182
+ # @return [Boolean]
183
+ def error?
184
+ status == :error
185
+ end
186
+
187
+ private
188
+
189
+ def parse_and_freeze_hash(json_string, attribute_name)
190
+ Busybee::Serialization.from_json(json_string)
191
+ rescue Busybee::InvalidJobJson => e
192
+ # Re-raise with attribute context for better error messages
193
+ message = "Failed to parse job #{attribute_name}: #{e.cause.message}"
194
+ raise Busybee::InvalidJobJson, message, e.backtrace, cause: e.cause
195
+ end
196
+
197
+ def format_error_message(error_message_or_exception)
198
+ return error_message_or_exception unless error_message_or_exception.is_a?(Exception)
199
+
200
+ message = "[#{error_message_or_exception.class.name}] #{error_message_or_exception.message}"
201
+ if (cause = error_message_or_exception.cause)
202
+ message += " (caused by: [#{cause.class.name}] #{cause.message})"
203
+ end
204
+ message
205
+ end
206
+
207
+ def format_error_code(code_or_exception)
208
+ case code_or_exception
209
+ when Symbol
210
+ code_or_exception.to_s.upcase
211
+ when Exception
212
+ # Convert MyApp::Domain::OrderNotFoundError to MY_APP_DOMAIN_ORDER_NOT_FOUND_ERROR
213
+ code_or_exception.class.name.gsub("::", "_").underscore.upcase
214
+ else
215
+ code_or_exception.to_s
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "busybee/job"
4
+ require "busybee/grpc/error"
5
+
6
+ module Busybee
7
+ # Wraps a gRPC server stream of activated jobs with a Ruby-idiomatic interface.
8
+ #
9
+ # JobStream is Enumerable, providing `each`, `map`, `select`, and other
10
+ # collection methods. Each yielded element is a {Busybee::Job} instance.
11
+ #
12
+ # @note Streams are single-pass. Once consumed via `each`, `map`, etc., the
13
+ # stream is exhausted. Subsequent iteration yields nothing. This is inherent
14
+ # to streaming. To process jobs multiple times, collect them into an array first.
15
+ #
16
+ # @example Process jobs from a stream
17
+ # stream = client.open_job_stream("send-email", job_timeout: 60.seconds)
18
+ # trap("INT") { stream.close }
19
+ #
20
+ # stream.each do |job|
21
+ # send_email(job.variables.to, job.variables.subject)
22
+ # job.complete!
23
+ # end
24
+ #
25
+ # @example Using Enumerable methods
26
+ # stream = client.open_job_stream("process-order")
27
+ # high_priority = stream.select { |job| job.variables.priority == "high" }
28
+ #
29
+ class JobStream
30
+ include Enumerable
31
+
32
+ # Create a new JobStream wrapper.
33
+ #
34
+ # @param operation [GRPC::ActiveCall::Operation] The gRPC operation (from return_op: true)
35
+ # @param client [Busybee::Client] The client for job operations
36
+ def initialize(operation, client:)
37
+ @operation = operation
38
+ @enumerator = operation.execute
39
+ @client = client
40
+ @closed = false
41
+ end
42
+
43
+ # Iterate over jobs in the stream.
44
+ #
45
+ # @yield [job] Yields each job to the block
46
+ # @yieldparam job [Busybee::Job] The activated job
47
+ # @return [Enumerator] If no block given
48
+ # @return [self] If block given
49
+ # @raise [Busybee::StreamAlreadyClosed] If the stream has been closed
50
+ # @raise [Busybee::GRPC::Error] If the stream encounters a gRPC error
51
+ def each
52
+ raise Busybee::StreamAlreadyClosed, "Cannot iterate a closed stream" if closed?
53
+ return enum_for(:each) unless block_given?
54
+
55
+ @enumerator.each do |raw_job|
56
+ yield Busybee::Job.new(raw_job, client: @client)
57
+ end
58
+ rescue ::GRPC::Cancelled
59
+ # Expected when stream is closed via #close - exit gracefully
60
+ nil
61
+ rescue ::GRPC::BadStatus
62
+ raise Busybee::GRPC::Error, "Job stream failed"
63
+ end
64
+
65
+ # Close the stream.
66
+ #
67
+ # Cancels the underlying gRPC operation. This method is idempotent;
68
+ # calling it multiple times has no additional effect.
69
+ #
70
+ # @return [void]
71
+ def close
72
+ return if @closed
73
+
74
+ @operation.cancel
75
+ @closed = true
76
+ end
77
+
78
+ # Check if the stream has been closed.
79
+ #
80
+ # @return [Boolean] true if the stream has been closed
81
+ def closed?
82
+ @closed
83
+ end
84
+ end
85
+ end