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,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"