gouda 0.1.16 → 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.
- checksums.yaml +4 -4
- data/Appraisals +2 -0
- data/CHANGELOG.md +4 -0
- data/Gemfile +1 -0
- data/README.md +117 -3
- data/examples/async_job_example.rb +116 -0
- data/examples/fiber_configuration.rb +45 -0
- data/examples/fiber_demo.rb +118 -0
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +21 -1
- data/gemfiles/rails_8.gemfile +1 -0
- data/gemfiles/rails_8.gemfile.lock +21 -1
- data/gouda.gemspec +1 -0
- data/lib/gouda/railtie.rb +2 -0
- data/lib/gouda/version.rb +1 -1
- data/lib/gouda/worker.rb +150 -59
- data/lib/gouda/workload.rb +17 -0
- data/lib/gouda.rb +74 -3
- data/test/gouda/worker_test.rb +324 -0
- data/test/gouda/workload_test.rb +61 -0
- metadata +19 -2
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
|
-
#
|
113
|
-
|
114
|
-
|
115
|
-
|
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
|
-
#
|
122
|
-
#
|
123
|
-
#
|
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
|
-
|
125
|
+
log_worker_configuration(n_threads, use_fibers, fibers_per_thread)
|
126
|
+
setup_fiber_environment if use_fibers
|
126
127
|
|
127
|
-
|
128
|
+
worker_id = generate_worker_id
|
129
|
+
executing_workload_ids = ThreadSafeSet.new
|
128
130
|
|
129
|
-
|
131
|
+
raise ArgumentError, "You need at least 1 worker thread, but you requested #{n_threads}" if n_threads < 1
|
130
132
|
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
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
|
-
|
139
|
-
|
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
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
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
|
-
|
154
|
-
|
155
|
-
|
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
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
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
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
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
|
-
#
|
174
|
-
|
175
|
-
|
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
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
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
|
data/lib/gouda/workload.rb
CHANGED
@@ -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
|
-
|
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
|
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.
|
@@ -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
|