gouda 0.1.15 → 0.2.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.
data/lib/gouda/worker.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "securerandom"
4
4
  require "gouda/version"
5
+ require "async"
5
6
 
6
7
  module Gouda
7
8
  POLL_INTERVAL_DURATION_SECONDS = 1
@@ -109,83 +110,173 @@ module Gouda
109
110
  end
110
111
  end
111
112
 
112
- # Start looping, taking work from the queue and performing it, over multiple worker threads.
113
- # Once the `check_shutdown` callable returns `true` the threads will cleanly terminate and the method will return (so it is blocking).
114
- #
115
- # @param n_threads[Integer] how many _worker_ threads to start. Another thread will be started for housekeeping, so ideally this should be the size of your connection pool minus 1
116
- # @param check_shutdown[#call] A callable object (can be a Proc etc.). Once starts returning `true` the worker threads and the housekeeping thread will cleanly exit
117
- def self.worker_loop(n_threads:, check_shutdown: TrapShutdownCheck.new, queue_constraint: Gouda::AnyQueue)
118
- # We need quite a few things when starting the loop - we have to be far enough into the Rails bootup sequence
119
- # that both the application and the executor are available
113
+ # Worker class that supports both threaded and hybrid (threads + fibers) execution modes
114
+ class Worker
115
+ # Start looping, taking work from the queue and performing it, over multiple worker threads.
116
+ # Once the `check_shutdown` callable returns `true` the threads will cleanly terminate and the method will return (so it is blocking).
120
117
  #
121
- # raise "Rails is not loaded yet" unless defined?(Rails) && Rails.respond_to?(:application)
122
- # raise "Rails application is not loaded yet" unless Rails.application
123
- # raise "Rails executor not available yet" unless Rails.application.executor
118
+ # @param n_threads[Integer] how many _worker_ threads to start. Another thread will be started for housekeeping, so ideally this should be the size of your connection pool minus 1
119
+ # @param check_shutdown[#call] A callable object (can be a Proc etc.). Once starts returning `true` the worker threads and the housekeeping thread will cleanly exit
120
+ # @param use_fibers[Boolean] whether to use fibers within each thread for higher concurrency
121
+ # @param fibers_per_thread[Integer] how many fibers to run per thread (only used if use_fibers is true)
122
+ def self.worker_loop(n_threads:, check_shutdown: TrapShutdownCheck.new, queue_constraint: Gouda::AnyQueue, use_fibers: false, fibers_per_thread: 1)
123
+ check_shutdown = CombinedShutdownCheck.new(*check_shutdown) if !check_shutdown.respond_to?(:call) && check_shutdown.is_a?(Array)
124
124
 
125
- check_shutdown = CombinedShutdownCheck.new(*check_shutdown) if !check_shutdown.respond_to?(:call) && check_shutdown.is_a?(Array)
125
+ log_worker_configuration(n_threads, use_fibers, fibers_per_thread)
126
+ setup_fiber_environment if use_fibers
126
127
 
127
- worker_id = [Socket.gethostname, Process.pid, SecureRandom.uuid].join("-")
128
+ worker_id = generate_worker_id
129
+ executing_workload_ids = ThreadSafeSet.new
128
130
 
129
- executing_workload_ids = ThreadSafeSet.new
131
+ raise ArgumentError, "You need at least 1 worker thread, but you requested #{n_threads}" if n_threads < 1
130
132
 
131
- raise ArgumentError, "You need at least 1 worker thread, but you requested #{n_threads}" if n_threads < 1
132
- worker_threads = n_threads.times.map do
133
- Thread.new do
134
- worker_id_and_thread_id = [worker_id, "t0x#{Thread.current.object_id.to_s(16)}"].join("-")
135
- loop do
136
- break if check_shutdown.call
133
+ worker_threads = if use_fibers
134
+ create_hybrid_worker_threads(n_threads, fibers_per_thread, worker_id, queue_constraint, executing_workload_ids, check_shutdown)
135
+ else
136
+ create_threaded_worker_threads(n_threads, worker_id, queue_constraint, executing_workload_ids, check_shutdown)
137
+ end
138
+
139
+ run_housekeeping_loop(executing_workload_ids, check_shutdown)
140
+ ensure
141
+ worker_threads&.map(&:join)
142
+ end
137
143
 
138
- did_process = Gouda.config.app_executor.wrap do
139
- Gouda::Workload.checkout_and_perform_one(executing_on: worker_id_and_thread_id, queue_constraint: queue_constraint, in_progress: executing_workload_ids)
144
+ class << self
145
+ private
146
+
147
+ def log_worker_configuration(n_threads, use_fibers, fibers_per_thread)
148
+ if use_fibers
149
+ Gouda.logger.info("Using hybrid scheduler (threads + fibers)")
150
+ Gouda.logger.info("Worker threads: #{n_threads}")
151
+ Gouda.logger.info("Fibers per thread: #{fibers_per_thread}")
152
+ Gouda.logger.info("Total concurrency: #{n_threads * fibers_per_thread}")
153
+ else
154
+ Gouda.logger.info("Using thread-based scheduler")
155
+ Gouda.logger.info("Worker threads: #{n_threads}")
156
+ end
157
+ end
158
+
159
+ def setup_fiber_environment
160
+ # Check Rails isolation level configuration
161
+ Gouda::FiberDatabaseSupport.check_fiber_isolation_level
162
+ end
163
+
164
+ def generate_worker_id
165
+ [Socket.gethostname, Process.pid, SecureRandom.uuid].join("-")
166
+ end
167
+
168
+ def create_hybrid_worker_threads(n_threads, fibers_per_thread, worker_id, queue_constraint, executing_workload_ids, check_shutdown)
169
+ n_threads.times.map do |thread_index|
170
+ Thread.new do
171
+ # Each thread runs its own fiber scheduler
172
+ Async do |task|
173
+ # Create multiple fibers within this thread
174
+ fiber_tasks = fibers_per_thread.times.map do |fiber_index|
175
+ task.async do |worker_task|
176
+ worker_id_and_fiber_id = generate_execution_id(worker_id, thread: true, fiber: true)
177
+
178
+ run_worker_loop(worker_id_and_fiber_id, queue_constraint, executing_workload_ids, check_shutdown, worker_task)
179
+ end
180
+ end
181
+
182
+ # Wait for all fibers in this thread to complete
183
+ fiber_tasks.each(&:wait)
184
+ end
140
185
  end
186
+ end
187
+ end
141
188
 
142
- # If no job was retrieved the queue is likely empty. Relax the polling then and ease off.
143
- # If a job was retrieved it is likely that a burst has just been enqueued, and we do not
144
- # sleep but proceed to attempt to retrieve the next job right after.
145
- jitter_sleep_interval = POLL_INTERVAL_DURATION_SECONDS + (POLL_INTERVAL_DURATION_SECONDS * 0.25)
146
- sleep_with_interruptions(jitter_sleep_interval, check_shutdown) unless did_process
147
- rescue => e
148
- warn "Uncaught exception during perform (#{e.class} - #{e}"
189
+ def create_threaded_worker_threads(n_threads, worker_id, queue_constraint, executing_workload_ids, check_shutdown)
190
+ n_threads.times.map do
191
+ Thread.new do
192
+ worker_id_and_thread_id = generate_execution_id(worker_id, thread: true, fiber: false)
193
+ run_worker_loop(worker_id_and_thread_id, queue_constraint, executing_workload_ids, check_shutdown)
194
+ end
149
195
  end
150
196
  end
151
- end
152
197
 
153
- # Do the housekeeping tasks on main
154
- loop do
155
- break if check_shutdown.call
198
+ def generate_execution_id(worker_id, thread: false, fiber: false)
199
+ parts = [worker_id]
200
+ parts << "thread-#{Thread.current.object_id.to_s(16)}" if thread
201
+ parts << "fiber-#{Fiber.current.object_id.to_s(16)}" if fiber
202
+ parts.join("-")
203
+ end
156
204
 
157
- Gouda.config.app_executor.wrap do
158
- # Mark known executing jobs as such. If a worker process is killed or the machine it is running on dies,
159
- # a stale timestamp can indicate to us that the job was orphaned and is marked as "executing"
160
- # even though the worker it was running on has failed for whatever reason.
161
- # Later on we can figure out what to do with those jobs (re-enqueue them or toss them)
162
- Gouda.suppressing_sql_logs do # these updates will also be very frequent with long-running jobs
163
- Gouda::Workload.where(id: executing_workload_ids.to_a, state: "executing").update_all(executing_on: worker_id, last_execution_heartbeat_at: Time.now.utc)
205
+ def run_worker_loop(execution_id, queue_constraint, executing_workload_ids, check_shutdown, fiber_task = nil)
206
+ loop do
207
+ break if check_shutdown.call
208
+
209
+ begin
210
+ did_process = Gouda.config.app_executor.wrap do
211
+ Gouda::Workload.checkout_and_perform_one(
212
+ executing_on: execution_id,
213
+ queue_constraint: queue_constraint,
214
+ in_progress: executing_workload_ids
215
+ )
216
+ end
217
+
218
+ # If no job was retrieved, sleep with appropriate scheduler
219
+ unless did_process
220
+ jitter_sleep_interval = POLL_INTERVAL_DURATION_SECONDS + (POLL_INTERVAL_DURATION_SECONDS * 0.25)
221
+ sleep_with_interruptions(jitter_sleep_interval, check_shutdown, fiber_task)
222
+ end
223
+ rescue => e
224
+ Gouda.logger.warn "Uncaught exception during perform (#{e.class} - #{e})"
225
+ end
164
226
  end
227
+ end
228
+
229
+ def run_housekeeping_loop(executing_workload_ids, check_shutdown)
230
+ loop do
231
+ break if check_shutdown.call
165
232
 
166
- # Find jobs which just hung and clean them up (mark them as "finished" and enqueue replacement workloads if possible)
167
- Gouda::Workload.reap_zombie_workloads
168
- rescue => e
169
- Gouda.instrument(:exception, {exception: e})
170
- warn "Uncaught exception during housekeeping (#{e.class} - #{e}"
233
+ begin
234
+ Gouda.config.app_executor.wrap do
235
+ # Mark known executing jobs as such. If a worker process is killed or the machine it is running on dies,
236
+ # a stale timestamp can indicate to us that the job was orphaned and is marked as "executing"
237
+ # even though the worker it was running on has failed for whatever reason.
238
+ # Later on we can figure out what to do with those jobs (re-enqueue them or toss them)
239
+ Gouda.suppressing_sql_logs do # these updates will also be very frequent with long-running jobs
240
+ Gouda::Workload.where(id: executing_workload_ids.to_a, state: "executing").update_all(last_execution_heartbeat_at: Time.now.utc)
241
+ end
242
+
243
+ # Find jobs which just hung and clean them up (mark them as "finished" and enqueue replacement workloads if possible)
244
+ Gouda::Workload.reap_zombie_workloads
245
+ end
246
+ rescue => e
247
+ Gouda.instrument(:exception, {exception: e})
248
+ Gouda.logger.warn "Uncaught exception during housekeeping (#{e.class} - #{e})"
249
+ end
250
+
251
+ # Jitter the sleep so that the workers booted at the same time do not all dogpile
252
+ randomized_sleep_duration_s = POLL_INTERVAL_DURATION_SECONDS + (POLL_INTERVAL_DURATION_SECONDS.to_f * rand)
253
+ sleep_with_interruptions(randomized_sleep_duration_s, check_shutdown)
254
+ end
171
255
  end
172
256
 
173
- # Jitter the sleep so that the workers booted at the same time do not all dogpile
174
- randomized_sleep_duration_s = POLL_INTERVAL_DURATION_SECONDS + (POLL_INTERVAL_DURATION_SECONDS.to_f * rand)
175
- sleep_with_interruptions(randomized_sleep_duration_s, check_shutdown)
257
+ # Unified sleep method that works with both threads and fibers
258
+ def sleep_with_interruptions(n_seconds, must_abort_proc, fiber_task = nil)
259
+ start_time_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC)
260
+ check_interval_seconds = Gouda.config.polling_sleep_interval_seconds
261
+
262
+ loop do
263
+ return if must_abort_proc.call
264
+ return if Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time_seconds >= n_seconds
265
+
266
+ sleep(check_interval_seconds)
267
+ end
268
+ end
176
269
  end
177
- ensure
178
- worker_threads&.map(&:join)
179
270
  end
180
271
 
181
- def self.sleep_with_interruptions(n_seconds, must_abort_proc)
182
- start_time_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC)
183
- # remaining_seconds = n_seconds
184
- check_interval_seconds = Gouda.config.polling_sleep_interval_seconds
185
- loop do
186
- return if must_abort_proc.call
187
- return if Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time_seconds >= n_seconds
188
- sleep(check_interval_seconds)
189
- end
272
+ # Module-level convenience method that delegates to Worker.worker_loop
273
+ def self.worker_loop(n_threads:, check_shutdown: TrapShutdownCheck.new, queue_constraint: Gouda::AnyQueue, use_fibers: false, fibers_per_thread: 1)
274
+ Worker.worker_loop(
275
+ n_threads: n_threads,
276
+ check_shutdown: check_shutdown,
277
+ queue_constraint: queue_constraint,
278
+ use_fibers: use_fibers,
279
+ fibers_per_thread: fibers_per_thread
280
+ )
190
281
  end
191
282
  end
@@ -239,4 +239,21 @@ class Gouda::Workload < ActiveRecord::Base
239
239
  def active_job_data
240
240
  serialized_params.deep_dup.merge("provider_job_id" => id, "interrupted_at" => interrupted_at, "scheduler_key" => scheduler_key) # TODO: is this memory-economical?
241
241
  end
242
+
243
+ # Returns true if this workload was executed using async execution (hybrid mode with fibers)
244
+ def uses_async_execution?
245
+ executing_on&.include?("fiber-")
246
+ end
247
+
248
+ # Returns true if this workload was executed on a thread
249
+ def executed_on_thread?
250
+ executing_on&.include?("thread-")
251
+ end
252
+
253
+ # Returns the execution context type (:fiber, :thread, or :unknown)
254
+ def execution_context
255
+ return :fiber if uses_async_execution?
256
+ return :thread if executed_on_thread?
257
+ :unknown
258
+ end
242
259
  end
data/lib/gouda.rb CHANGED
@@ -32,6 +32,10 @@ module Gouda
32
32
  # that is using Gouda. The config values will be ignored though.
33
33
  config_accessor(:logger, default: nil)
34
34
  config_accessor(:log_level, default: nil)
35
+
36
+ # Fiber-specific configuration options
37
+ config_accessor(:fibers_per_thread, default: 1) # Number of fibers per worker thread
38
+ config_accessor(:use_fiber_scheduler, default: false)
35
39
  end
36
40
 
37
41
  class InterruptError < StandardError
@@ -41,6 +45,11 @@ module Gouda
41
45
  end
42
46
 
43
47
  def self.start
48
+ start_with_scheduler_type
49
+ end
50
+
51
+ # Enhanced start method that chooses between thread and thread+fiber execution
52
+ def self.start_with_scheduler_type
44
53
  queue_constraint = if ENV["GOUDA_QUEUES"]
45
54
  Gouda.parse_queue_constraint(ENV["GOUDA_QUEUES"])
46
55
  else
@@ -48,9 +57,18 @@ module Gouda
48
57
  end
49
58
 
50
59
  logger.info("Gouda version: #{Gouda::VERSION}")
51
- logger.info("Worker threads: #{Gouda.config.worker_thread_count}")
52
60
 
53
- worker_loop(n_threads: Gouda.config.worker_thread_count, queue_constraint: queue_constraint)
61
+ # Determine execution parameters based on configuration
62
+ use_fibers = Gouda.config.use_fiber_scheduler
63
+ fibers_per_thread = use_fibers ? Gouda.config.fibers_per_thread : 1
64
+
65
+ # Single worker loop call that handles both execution modes
66
+ Worker.worker_loop(
67
+ n_threads: Gouda.config.worker_thread_count,
68
+ queue_constraint: queue_constraint,
69
+ use_fibers: use_fibers,
70
+ fibers_per_thread: fibers_per_thread
71
+ )
54
72
  end
55
73
 
56
74
  def self.config
@@ -72,7 +90,7 @@ module Gouda
72
90
  # is just an ActiveJob adapter and the Workload is just an ActiveRecord, in the end.
73
91
  # So it should be up to the developer of the app, not to us, to set the logger up
74
92
  # and configure out. There are also gems such as "stackdriver" from Google which
75
- # rather unceremonously overwrite the Rails logger with their own. If that happens,
93
+ # rather unceremoniously overwrite the Rails logger with their own. If that happens,
76
94
  # it is the choice of the user to do so - and we should honor that choice. Same for
77
95
  # the logging level - the Rails logger level must take precendence. Same for logger
78
96
  # broadcasts which get set up, for example, by the Rails console when you start it.
@@ -87,7 +105,7 @@ module Gouda
87
105
  # in a side-thread inside Puma - the output might be quite annoying. So silence the
88
106
  # logger when we poll, but just to INFO. Omitting DEBUG-level messages gets rid of the SQL.
89
107
  if Gouda::Workload.logger
90
- Gouda::Workload.logger.silence(Logger::DEBUG, &)
108
+ Gouda::Workload.logger.silence(Logger::INFO, &)
91
109
  else
92
110
  # In tests (and at earlier stages of the Rails boot cycle) the global ActiveRecord logger may be nil
93
111
  yield
@@ -139,4 +157,57 @@ module Gouda
139
157
  t.timestamps
140
158
  end
141
159
  end
160
+
161
+ def self.setup_fiber_environment
162
+ # Check Rails isolation level configuration (non-destructive)
163
+ Gouda::FiberDatabaseSupport.check_fiber_isolation_level
164
+ end
165
+
166
+ # Database configuration helpers for fiber mode
167
+ module FiberDatabaseSupport
168
+ def self.check_fiber_isolation_level
169
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
170
+
171
+ begin
172
+ current_isolation = ActiveSupport.isolation_level
173
+
174
+ # Check if we're using PostgreSQL
175
+ using_postgresql = false
176
+ begin
177
+ if defined?(ActiveRecord::Base) && ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
178
+ using_postgresql = true
179
+ end
180
+ rescue
181
+ # If we can't determine the adapter, assume we might be using PostgreSQL to be safe
182
+ using_postgresql = true
183
+ end
184
+
185
+ if current_isolation != :fiber && using_postgresql
186
+ logger.warn("=" * 80)
187
+ logger.warn("FIBER SCHEDULER CONFIGURATION WARNING")
188
+ logger.warn("=" * 80)
189
+ logger.warn("Gouda fiber mode is enabled with PostgreSQL but Rails isolation level is set to: #{current_isolation}")
190
+ logger.warn("For optimal fiber-based performance and to avoid potential issues with PostgreSQL,")
191
+ logger.warn("you should set the Rails isolation level to :fiber")
192
+ logger.warn("")
193
+ logger.warn("Add this to your config/application.rb:")
194
+ logger.warn(" config.active_support.isolation_level = :fiber")
195
+ logger.warn("")
196
+ logger.warn("This ensures ActiveRecord connection pools work correctly with fibers")
197
+ logger.warn("and PostgreSQL connections, and can prevent segfaults with Ruby 3.4+.")
198
+ logger.warn("=" * 80)
199
+ elsif current_isolation == :fiber
200
+ logger.info("Rails isolation level correctly set to :fiber for fiber-based execution")
201
+ elsif !using_postgresql
202
+ logger.info("Non-PostgreSQL database detected - isolation level configuration may not be required")
203
+ end
204
+ rescue => e
205
+ logger.warn("Could not check Rails isolation level: #{e.message}")
206
+ end
207
+ end
208
+
209
+ def self.logger
210
+ Gouda.logger
211
+ end
212
+ end
142
213
  end