busybee 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +71 -7
- data/README.md +70 -42
- data/docs/client/quick_start.md +279 -0
- data/docs/client.md +825 -0
- data/docs/configuration.md +550 -0
- data/docs/grpc.md +50 -25
- data/docs/testing.md +118 -28
- data/docs/workers.md +982 -0
- data/exe/busybee +6 -0
- data/lib/busybee/cli.rb +173 -0
- data/lib/busybee/client/error_handling.rb +37 -0
- data/lib/busybee/client/job_operations.rb +236 -0
- data/lib/busybee/client/message_operations.rb +84 -0
- data/lib/busybee/client/process_operations.rb +108 -0
- data/lib/busybee/client/variable_operations.rb +64 -0
- data/lib/busybee/client.rb +87 -0
- data/lib/busybee/configure.rb +290 -0
- data/lib/busybee/credentials/camunda_cloud.rb +58 -0
- data/lib/busybee/credentials/insecure.rb +24 -0
- data/lib/busybee/credentials/oauth.rb +157 -0
- data/lib/busybee/credentials/tls.rb +43 -0
- data/lib/busybee/credentials.rb +200 -0
- data/lib/busybee/defaults.rb +20 -0
- data/lib/busybee/error.rb +50 -0
- data/lib/busybee/grpc/error.rb +60 -0
- data/lib/busybee/grpc.rb +2 -2
- data/lib/busybee/job.rb +219 -0
- data/lib/busybee/job_stream.rb +85 -0
- data/lib/busybee/logging.rb +61 -0
- data/lib/busybee/railtie.rb +113 -0
- data/lib/busybee/runner/hybrid.rb +64 -0
- data/lib/busybee/runner/multi.rb +101 -0
- data/lib/busybee/runner/polling.rb +54 -0
- data/lib/busybee/runner/streaming.rb +159 -0
- data/lib/busybee/runner.rb +97 -0
- data/lib/busybee/runtime_config.rb +184 -0
- data/lib/busybee/serialization.rb +100 -0
- data/lib/busybee/testing/activated_job.rb +33 -8
- data/lib/busybee/testing/helpers/execution.rb +139 -0
- data/lib/busybee/testing/helpers/support.rb +78 -0
- data/lib/busybee/testing/helpers.rb +56 -66
- data/lib/busybee/testing/matchers/complete_job.rb +55 -0
- data/lib/busybee/testing/matchers/fail_job.rb +75 -0
- data/lib/busybee/testing/matchers/have_activated.rb +1 -1
- data/lib/busybee/testing/matchers/have_available_jobs.rb +44 -0
- data/lib/busybee/testing/matchers/throw_bpmn_error_on.rb +72 -0
- data/lib/busybee/testing.rb +5 -33
- data/lib/busybee/version.rb +1 -1
- data/lib/busybee/worker/configuration.rb +287 -0
- data/lib/busybee/worker/dsl.rb +187 -0
- data/lib/busybee/worker/shutdown.rb +27 -0
- data/lib/busybee/worker.rb +130 -0
- data/lib/busybee.rb +134 -2
- metadata +80 -3
|
@@ -0,0 +1,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
data/lib/busybee/job.rb
ADDED
|
@@ -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
|