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,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "busybee"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Busybee
|
|
7
|
+
# Centralized logging with prefixing and optional JSON formatting.
|
|
8
|
+
# Thread-safe: a mutex serializes format + write so concurrent threads
|
|
9
|
+
# cannot interleave within a single log line.
|
|
10
|
+
module Logging
|
|
11
|
+
PREFIX = "[busybee]"
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def debug(message, **context)
|
|
15
|
+
log(:debug, message, **context)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def info(message, **context)
|
|
19
|
+
log(:info, message, **context)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def warn(message, **context)
|
|
23
|
+
log(:warn, message, **context)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def error(message, **context)
|
|
27
|
+
log(:error, message, **context)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def mutex = @mutex ||= Mutex.new
|
|
33
|
+
|
|
34
|
+
def log(level, message, **context)
|
|
35
|
+
return unless Busybee.logger
|
|
36
|
+
|
|
37
|
+
mutex.synchronize do
|
|
38
|
+
formatted = case Busybee.log_format
|
|
39
|
+
when :text then format_text(message, **context)
|
|
40
|
+
when :json then format_json(level, message, **context)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Busybee.logger.public_send(level, formatted)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def format_text(message, **context)
|
|
48
|
+
formatted = "#{PREFIX} #{message}"
|
|
49
|
+
formatted += " (#{context.map { |k, v| "#{k}: #{v.inspect}" }.join(', ')})" if context.any?
|
|
50
|
+
formatted
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def format_json(level, message, **context)
|
|
54
|
+
context.merge(
|
|
55
|
+
message: "#{PREFIX} #{message}",
|
|
56
|
+
level: level.to_s
|
|
57
|
+
).to_json
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
require "active_support/core_ext/object/blank"
|
|
5
|
+
require "busybee"
|
|
6
|
+
|
|
7
|
+
module Busybee
|
|
8
|
+
# Rails integration for Busybee.
|
|
9
|
+
# Automatically configures Busybee from Rails configuration.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic configuration
|
|
12
|
+
# config.x.busybee.cluster_address = "localhost:26500"
|
|
13
|
+
# config.x.busybee.credential_type = :insecure
|
|
14
|
+
#
|
|
15
|
+
# @example Camunda Cloud with Rails credentials
|
|
16
|
+
# config.x.busybee.credential_type = :camunda_cloud
|
|
17
|
+
# config.x.busybee.client_id = Rails.application.credentials.zeebe[:client_id]
|
|
18
|
+
# config.x.busybee.client_secret = Rails.application.credentials.zeebe[:client_secret]
|
|
19
|
+
# config.x.busybee.cluster_id = Rails.application.credentials.zeebe[:cluster_id]
|
|
20
|
+
# config.x.busybee.region = "bru-2"
|
|
21
|
+
#
|
|
22
|
+
# @example Explicit credentials object
|
|
23
|
+
# config.x.busybee.credentials = MyCustomCredentials.new(...)
|
|
24
|
+
#
|
|
25
|
+
class Railtie < Rails::Railtie
|
|
26
|
+
# Credential parameters that can be configured via config.x.busybee.*
|
|
27
|
+
CREDENTIAL_PARAMS = %i[
|
|
28
|
+
cluster_address client_id client_secret cluster_id region
|
|
29
|
+
token_url audience scope certificate_file
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
initializer "busybee.configure" do
|
|
33
|
+
Busybee.configure do |config|
|
|
34
|
+
busybee_conf = Rails.configuration.x.busybee
|
|
35
|
+
|
|
36
|
+
# Logger: Rails.logger by default, custom logger if set, nil if explicitly false
|
|
37
|
+
config.logger = case busybee_conf&.logger
|
|
38
|
+
when false then nil
|
|
39
|
+
when nil, true then Rails.logger
|
|
40
|
+
else busybee_conf.logger
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
next unless busybee_conf.presence
|
|
44
|
+
|
|
45
|
+
config.log_format = busybee_conf.log_format.presence if busybee_conf.log_format.presence
|
|
46
|
+
config.cluster_address = busybee_conf.cluster_address.presence
|
|
47
|
+
config.worker_name = busybee_conf.worker_name.presence
|
|
48
|
+
|
|
49
|
+
# Credentials: explicit object, or build from type + params
|
|
50
|
+
Busybee::Railtie.configure_credentials(config, busybee_conf)
|
|
51
|
+
|
|
52
|
+
# GRPC retry configuration
|
|
53
|
+
config.grpc_retry_enabled = !!busybee_conf.grpc_retry_enabled unless busybee_conf.grpc_retry_enabled.nil?
|
|
54
|
+
config.grpc_retry_delay_ms = busybee_conf.grpc_retry_delay_ms if busybee_conf.grpc_retry_delay_ms.presence
|
|
55
|
+
config.grpc_retry_errors = Array(busybee_conf.grpc_retry_errors) if busybee_conf.grpc_retry_errors.presence
|
|
56
|
+
|
|
57
|
+
# Client API method defaults
|
|
58
|
+
%i[default_message_ttl default_fail_job_backoff
|
|
59
|
+
default_job_request_timeout default_job_lock_timeout].each do |attr|
|
|
60
|
+
value = busybee_conf.public_send(attr)
|
|
61
|
+
config.public_send(:"#{attr}=", value) if value.presence
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Worker defaults
|
|
65
|
+
Busybee::Railtie.configure_worker_defaults(config, busybee_conf)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @api private
|
|
70
|
+
def self.configure_worker_defaults(config, busybee_conf) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
71
|
+
unless busybee_conf.default_input_required.nil?
|
|
72
|
+
config.default_input_required = !!busybee_conf.default_input_required
|
|
73
|
+
end
|
|
74
|
+
config.default_max_jobs = busybee_conf.default_max_jobs if busybee_conf.default_max_jobs.presence
|
|
75
|
+
unless busybee_conf.default_output_required.nil?
|
|
76
|
+
config.default_output_required = !!busybee_conf.default_output_required
|
|
77
|
+
end
|
|
78
|
+
config.default_buffer = !!busybee_conf.default_buffer unless busybee_conf.default_buffer.nil?
|
|
79
|
+
unless busybee_conf.default_buffer_throttle.nil?
|
|
80
|
+
config.default_buffer_throttle = busybee_conf.default_buffer_throttle
|
|
81
|
+
end
|
|
82
|
+
config.default_worker_mode = busybee_conf.default_worker_mode if busybee_conf.default_worker_mode.presence
|
|
83
|
+
if busybee_conf.default_backpressure_delay.presence
|
|
84
|
+
config.default_backpressure_delay = busybee_conf.default_backpressure_delay
|
|
85
|
+
end
|
|
86
|
+
config.shutdown_on_errors = busybee_conf.shutdown_on_errors if busybee_conf.shutdown_on_errors.presence
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @api private
|
|
90
|
+
def self.configure_credentials(config, busybee_conf)
|
|
91
|
+
# Option 1: Explicit credentials object
|
|
92
|
+
if busybee_conf.credentials.is_a?(Busybee::Credentials)
|
|
93
|
+
config.credentials = busybee_conf.credentials
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Option 2: Build credentials from type + params (or autodetect from params alone)
|
|
98
|
+
credential_type = busybee_conf.credential_type.presence
|
|
99
|
+
credential_params = extract_credential_params(busybee_conf)
|
|
100
|
+
|
|
101
|
+
config.credential_type = credential_type if credential_type
|
|
102
|
+
config.credentials = Busybee::Credentials.build(**credential_params) if credential_params.any?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @api private
|
|
106
|
+
def self.extract_credential_params(busybee_conf)
|
|
107
|
+
CREDENTIAL_PARAMS.each_with_object({}) do |param, hash|
|
|
108
|
+
value = busybee_conf.public_send(param)
|
|
109
|
+
hash[param] = value unless value.nil?
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Busybee
|
|
4
|
+
class Runner
|
|
5
|
+
# Hybrid runner — combines polling and streaming for best-of-both-worlds job processing.
|
|
6
|
+
# Subclasses Streaming, adding a drain phase: opens a stream first (captures all new jobs),
|
|
7
|
+
# drains the backlog via polling, then transitions to buffer-only processing.
|
|
8
|
+
# The pump thread reads from the stream into a thread-safe buffer; the main thread does
|
|
9
|
+
# all perform_job calls (sequential guarantee).
|
|
10
|
+
class Hybrid < Streaming
|
|
11
|
+
BACKPRESSURE_ERRORS = [::GRPC::ResourceExhausted].freeze
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
# Always uses pump thread + buffer — the drain phase requires it.
|
|
16
|
+
def buffer?
|
|
17
|
+
true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Override to insert drain phase between pump start and buffer processing.
|
|
21
|
+
def run_with_buffer
|
|
22
|
+
@pump_thread = Thread.new { pump_stream_into_buffer }
|
|
23
|
+
|
|
24
|
+
# Phase 2: Drain backlog via polling (main thread, sequential, returns when backlog is empty)
|
|
25
|
+
drain_backlog_while_also_processing_buffer
|
|
26
|
+
|
|
27
|
+
# Phase 3: Process from buffer only (main thread, sequential, returns when stream is cancelled)
|
|
28
|
+
process_buffered_jobs(blocking: true)
|
|
29
|
+
|
|
30
|
+
err = @shutdown_error.get
|
|
31
|
+
raise err if err
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def drain_options
|
|
35
|
+
@drain_options ||= @runtime_config.polling_options.merge(request_timeout: -1)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def drain_backlog_while_also_processing_buffer # rubocop:disable Metrics/AbcSize
|
|
39
|
+
loop do
|
|
40
|
+
break if stopping?
|
|
41
|
+
|
|
42
|
+
polled_count = @client.with_each_job(job_type, **drain_options) do |job|
|
|
43
|
+
if stopping?
|
|
44
|
+
handle_shutdown_job(job)
|
|
45
|
+
else
|
|
46
|
+
@worker_class.perform_job(job)
|
|
47
|
+
# After each polled job, drain any stream jobs that arrived —
|
|
48
|
+
# always prioritize keeping up with the stream over working through backlog.
|
|
49
|
+
process_buffered_jobs(blocking: false)
|
|
50
|
+
end
|
|
51
|
+
rescue Busybee::Worker::Shutdown => e
|
|
52
|
+
@shutdown_error.update { |prev| prev || e }
|
|
53
|
+
stop!
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
break if polled_count < drain_options[:max_jobs] # Caught up: fewer than requested
|
|
57
|
+
rescue *BACKPRESSURE_ERRORS
|
|
58
|
+
# [hook: runner.backpressure]
|
|
59
|
+
sleep @runtime_config.backpressure_delay
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Busybee
|
|
6
|
+
class Runner
|
|
7
|
+
# Multi runner — manages multiple worker types in a single process.
|
|
8
|
+
# Each worker gets its own child runner (Polling/Streaming/Hybrid) running
|
|
9
|
+
# in a dedicated thread via Concurrent::FixedThreadPool.
|
|
10
|
+
#
|
|
11
|
+
# Provides the same interface as single-worker runners (run!, stop!, kill!)
|
|
12
|
+
# so the CLI can treat all runner types uniformly.
|
|
13
|
+
class Multi < Runner
|
|
14
|
+
attr_reader :runners
|
|
15
|
+
|
|
16
|
+
def initialize(worker_classes, runtime_config: nil, client: nil)
|
|
17
|
+
super(client: client)
|
|
18
|
+
runtime_config ||= RuntimeConfig.new
|
|
19
|
+
@runners = worker_classes.map do |worker_class|
|
|
20
|
+
resolved = runtime_config.resolve_for(worker_class)
|
|
21
|
+
Runner.for(worker_class, runtime_config: resolved, client: @client)
|
|
22
|
+
end
|
|
23
|
+
@thread_pool = Concurrent::FixedThreadPool.new(worker_classes.length)
|
|
24
|
+
@thread_error = Concurrent::AtomicReference.new(nil)
|
|
25
|
+
|
|
26
|
+
check_connection_pool_size(worker_classes)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run!
|
|
30
|
+
return if stopping?
|
|
31
|
+
|
|
32
|
+
@running.make_true
|
|
33
|
+
post_runners_to_pool
|
|
34
|
+
@thread_pool.wait_for_termination
|
|
35
|
+
|
|
36
|
+
err = @thread_error.get
|
|
37
|
+
raise err if err
|
|
38
|
+
ensure
|
|
39
|
+
@running.make_false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def stop!
|
|
43
|
+
super
|
|
44
|
+
@runners.each(&:stop!)
|
|
45
|
+
@thread_pool.shutdown
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def stopping?
|
|
49
|
+
@runners.all?(&:stopping?)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def kill!
|
|
53
|
+
super
|
|
54
|
+
@runners.each(&:kill!)
|
|
55
|
+
@thread_pool.kill
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def post_runners_to_pool
|
|
61
|
+
@runners.each do |runner|
|
|
62
|
+
@thread_pool.post do
|
|
63
|
+
runner.run!
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
@thread_error.update { |prev| prev || e }
|
|
66
|
+
Busybee.logger&.error(
|
|
67
|
+
"Error in runner for #{runner_worker_name(runner)}: " \
|
|
68
|
+
"[#{e.class}] #{e.message}"
|
|
69
|
+
)
|
|
70
|
+
stop!
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def runner_worker_name(runner)
|
|
76
|
+
runner.instance_variable_get(:@worker_class)&.name || "(anonymous worker)"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def check_connection_pool_size(worker_classes)
|
|
80
|
+
return unless defined?(ActiveRecord::Base)
|
|
81
|
+
|
|
82
|
+
pool_size = ActiveRecord::Base.connection_pool.size
|
|
83
|
+
worker_count = worker_classes.length
|
|
84
|
+
|
|
85
|
+
if pool_size < worker_count
|
|
86
|
+
Busybee.logger&.error(
|
|
87
|
+
"Process #{Process.pid} is running #{worker_count} workers but the database " \
|
|
88
|
+
"connection pool size is only #{pool_size}. This may cause connection timeout " \
|
|
89
|
+
"errors. Adjust the pool size (usually via RAILS_MAX_THREADS) or reduce the " \
|
|
90
|
+
"number of workers in this process."
|
|
91
|
+
)
|
|
92
|
+
else
|
|
93
|
+
Busybee.logger&.info(
|
|
94
|
+
"Process #{Process.pid} will run #{worker_count} workers with a database " \
|
|
95
|
+
"connection pool size of #{pool_size}."
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Busybee
|
|
4
|
+
class Runner
|
|
5
|
+
# Polling runner — fetches jobs via client.with_each_job in a loop.
|
|
6
|
+
# Each iteration long-polls the gateway for available jobs, yields them
|
|
7
|
+
# sequentially to the worker's perform_job, and handles shutdown/errors.
|
|
8
|
+
class Polling < Runner
|
|
9
|
+
BACKPRESSURE_ERRORS = [::GRPC::ResourceExhausted].freeze
|
|
10
|
+
|
|
11
|
+
def run!
|
|
12
|
+
return if stopping?
|
|
13
|
+
|
|
14
|
+
@running.make_true
|
|
15
|
+
# [hook: runner.started]
|
|
16
|
+
shutdown_error = nil
|
|
17
|
+
|
|
18
|
+
loop do
|
|
19
|
+
break if stopping?
|
|
20
|
+
|
|
21
|
+
process_all_available_jobs { |e| shutdown_error = e }
|
|
22
|
+
rescue *BACKPRESSURE_ERRORS
|
|
23
|
+
# [hook: runner.backpressure]
|
|
24
|
+
sleep @runtime_config.backpressure_delay
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
raise shutdown_error if shutdown_error
|
|
28
|
+
ensure
|
|
29
|
+
# [hook: runner.stopping]
|
|
30
|
+
@running.make_false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def process_all_available_jobs
|
|
36
|
+
@client.with_each_job(job_type, **@runtime_config.polling_options) do |job|
|
|
37
|
+
if stopping?
|
|
38
|
+
handle_shutdown_job(job)
|
|
39
|
+
else
|
|
40
|
+
@worker_class.perform_job(job)
|
|
41
|
+
end
|
|
42
|
+
rescue Busybee::Worker::Shutdown => e
|
|
43
|
+
# [hook: runner.shutdown]
|
|
44
|
+
yield e
|
|
45
|
+
stop!
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def job_type
|
|
50
|
+
@worker_class.configuration.job_type
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Busybee
|
|
4
|
+
class Runner
|
|
5
|
+
# Streaming runner — receives jobs via client.open_job_stream.
|
|
6
|
+
# The stream continuously pushes newly-activated jobs from the gateway.
|
|
7
|
+
# Note: streams only receive jobs created after the stream opens;
|
|
8
|
+
# pre-existing jobs require polling to retrieve.
|
|
9
|
+
#
|
|
10
|
+
# Two modes:
|
|
11
|
+
# - buffer: true (default) — A pump thread reads from the stream into a Queue;
|
|
12
|
+
# the main thread pops and processes sequentially. Better shutdown responsiveness
|
|
13
|
+
# and enables pump delay configuration.
|
|
14
|
+
# - buffer: false — stream.each calls perform_job inline on the main thread.
|
|
15
|
+
# Simpler model for workers that don't need buffer features.
|
|
16
|
+
class Streaming < Runner
|
|
17
|
+
def initialize(worker_class, runtime_config: nil, client: nil)
|
|
18
|
+
super
|
|
19
|
+
return unless buffer?
|
|
20
|
+
|
|
21
|
+
@job_buffer = Queue.new
|
|
22
|
+
@shutdown_error = Concurrent::AtomicReference.new(nil)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run!
|
|
26
|
+
return if stopping?
|
|
27
|
+
|
|
28
|
+
@running.make_true
|
|
29
|
+
# [hook: runner.started]
|
|
30
|
+
|
|
31
|
+
@stream = @client.open_job_stream(job_type, job_timeout: @runtime_config.job_timeout)
|
|
32
|
+
|
|
33
|
+
if buffer?
|
|
34
|
+
run_with_buffer
|
|
35
|
+
else
|
|
36
|
+
run_inline
|
|
37
|
+
end
|
|
38
|
+
ensure
|
|
39
|
+
# [hook: runner.stopping]
|
|
40
|
+
@stream&.close
|
|
41
|
+
if buffer?
|
|
42
|
+
@pump_thread&.join(5)
|
|
43
|
+
handle_remaining_jobs_in_buffer
|
|
44
|
+
end
|
|
45
|
+
@running.make_false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def stop!
|
|
49
|
+
super
|
|
50
|
+
@stream&.close # unblocks stream.each via GRPC::Cancelled
|
|
51
|
+
@job_buffer&.push(:stop) if buffer?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def kill!
|
|
55
|
+
super
|
|
56
|
+
@pump_thread&.kill
|
|
57
|
+
return unless buffer?
|
|
58
|
+
|
|
59
|
+
@job_buffer.clear
|
|
60
|
+
@job_buffer.push(:stop)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def run_with_buffer
|
|
66
|
+
@pump_thread = Thread.new { pump_stream_into_buffer }
|
|
67
|
+
process_buffered_jobs(blocking: true)
|
|
68
|
+
|
|
69
|
+
err = @shutdown_error.get
|
|
70
|
+
raise err if err
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def run_inline
|
|
74
|
+
shutdown_error = nil
|
|
75
|
+
|
|
76
|
+
@stream.each do |job|
|
|
77
|
+
if stopping?
|
|
78
|
+
handle_shutdown_job(job)
|
|
79
|
+
break
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
@worker_class.perform_job(job)
|
|
83
|
+
rescue Busybee::Worker::Shutdown => e
|
|
84
|
+
# [hook: runner.shutdown]
|
|
85
|
+
shutdown_error = e
|
|
86
|
+
stop!
|
|
87
|
+
break
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
raise shutdown_error if shutdown_error
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def pump_stream_into_buffer
|
|
94
|
+
delay = @runtime_config.buffer_throttle
|
|
95
|
+
|
|
96
|
+
@stream.each do |job|
|
|
97
|
+
break if stopping?
|
|
98
|
+
|
|
99
|
+
@job_buffer.push(job)
|
|
100
|
+
sleep(delay.to_f / 1000) if delay
|
|
101
|
+
end
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
# Stream error (e.g., GRPC::Error). Store and stop so main thread can re-raise.
|
|
104
|
+
# Normal close via stop! produces GRPC::Cancelled, which JobStream#each absorbs —
|
|
105
|
+
# so this only fires on genuine failures.
|
|
106
|
+
@shutdown_error.update { |prev| prev || e }
|
|
107
|
+
ensure
|
|
108
|
+
# Stream ended — either naturally (external close, server-side close),
|
|
109
|
+
# via error (handled above), or because stop! was already called.
|
|
110
|
+
# In all cases, stop! to unblock the main thread's buffer pop.
|
|
111
|
+
# Idempotent when stop! was already called.
|
|
112
|
+
stop!
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Process jobs from the buffer.
|
|
116
|
+
# blocking: false — drains all currently-buffered jobs, returns if/when empty.
|
|
117
|
+
# blocking: true — blocks on pop until :stop sentinel or stopping?.
|
|
118
|
+
def process_buffered_jobs(blocking:)
|
|
119
|
+
loop do
|
|
120
|
+
break if stopping?
|
|
121
|
+
|
|
122
|
+
job = @job_buffer.pop(!blocking)
|
|
123
|
+
break if job == :stop
|
|
124
|
+
|
|
125
|
+
if stopping?
|
|
126
|
+
handle_shutdown_job(job)
|
|
127
|
+
else
|
|
128
|
+
@worker_class.perform_job(job)
|
|
129
|
+
end
|
|
130
|
+
rescue ThreadError
|
|
131
|
+
break # buffer empty (non-blocking only)
|
|
132
|
+
rescue Busybee::Worker::Shutdown => e
|
|
133
|
+
@shutdown_error.update { |prev| prev || e }
|
|
134
|
+
stop!
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Drain remaining buffer during shutdown, failing all jobs.
|
|
139
|
+
def handle_remaining_jobs_in_buffer
|
|
140
|
+
loop do
|
|
141
|
+
job = @job_buffer.pop(true) # non-blocking
|
|
142
|
+
next if job == :stop
|
|
143
|
+
|
|
144
|
+
handle_shutdown_job(job)
|
|
145
|
+
rescue ThreadError
|
|
146
|
+
break # buffer empty
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def buffer?
|
|
151
|
+
@runtime_config.buffer
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def job_type
|
|
155
|
+
@worker_class.configuration.job_type
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Busybee
|
|
6
|
+
# Base class for all runner types. Provides shared interface (run!, stop!, stopping?,
|
|
7
|
+
# running?, kill!) and the Runner.for factory method for mode resolution.
|
|
8
|
+
#
|
|
9
|
+
# Subclasses must implement #run! to define their job-fetching loop.
|
|
10
|
+
class Runner
|
|
11
|
+
def initialize(worker_class = nil, runtime_config: nil, client: nil)
|
|
12
|
+
@worker_class = worker_class
|
|
13
|
+
@runtime_config = runtime_config
|
|
14
|
+
@client = client || Busybee::Client.new
|
|
15
|
+
@stop_requested = Concurrent::AtomicBoolean.new(false)
|
|
16
|
+
@running = Concurrent::AtomicBoolean.new(false)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Blocks until stopped or error. Subclasses must implement.
|
|
20
|
+
def run!
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Signals graceful shutdown. Thread-safe (called from signal handler).
|
|
25
|
+
def stop!
|
|
26
|
+
@stop_requested.make_true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# True if stop! has been called.
|
|
30
|
+
def stopping?
|
|
31
|
+
@stop_requested.true?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# True if run! is actively executing.
|
|
35
|
+
def running?
|
|
36
|
+
@running.true?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Force shutdown. Base: calls stop!. Multi overrides to also kill thread pool.
|
|
40
|
+
def kill!
|
|
41
|
+
stop!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# Factory method. Resolves worker mode via RuntimeConfig and returns
|
|
46
|
+
# the appropriate runner instance.
|
|
47
|
+
#
|
|
48
|
+
# Mode resolution (lowest to highest priority):
|
|
49
|
+
# 1. Gem default: Busybee.default_worker_mode
|
|
50
|
+
# 2. Worker DSL: worker_class.configuration.worker_mode
|
|
51
|
+
# 3. RuntimeConfig override (global or per-worker)
|
|
52
|
+
def for(*worker_classes, runtime_config: nil, client: nil)
|
|
53
|
+
runtime_config ||= RuntimeConfig.new
|
|
54
|
+
client ||= Busybee::Client.new
|
|
55
|
+
|
|
56
|
+
if worker_classes.length > 1
|
|
57
|
+
Multi.new(worker_classes, runtime_config: runtime_config, client: client)
|
|
58
|
+
else
|
|
59
|
+
resolved = runtime_config.resolve_for(worker_classes.first)
|
|
60
|
+
runner_class_for(resolved).new(worker_classes.first, runtime_config: resolved, client: client)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def runner_class_for(resolved_config)
|
|
67
|
+
case resolved_config.worker_mode
|
|
68
|
+
when :polling then Polling
|
|
69
|
+
when :streaming then Streaming
|
|
70
|
+
when :hybrid then Hybrid
|
|
71
|
+
else
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"Invalid worker mode: #{resolved_config.worker_mode.inspect}. Valid: :polling, :streaming, :hybrid"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Fails a job during graceful shutdown, preserving its retry count.
|
|
81
|
+
# Uses the worker's configured backoff (or gem default).
|
|
82
|
+
def handle_shutdown_job(job)
|
|
83
|
+
job.fail!(
|
|
84
|
+
"Worker shutting down",
|
|
85
|
+
retries: job.retries,
|
|
86
|
+
backoff: @runtime_config.backoff
|
|
87
|
+
)
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
Busybee.logger&.warn("Failed to fail job #{job.key} during shutdown: #{e.message}")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
require "busybee/runner/polling"
|
|
95
|
+
require "busybee/runner/streaming"
|
|
96
|
+
require "busybee/runner/hybrid"
|
|
97
|
+
require "busybee/runner/multi"
|