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.
- checksums.yaml +4 -4
- data/Appraisals +2 -0
- data/CHANGELOG.md +8 -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 +75 -4
- data/test/gouda/worker_test.rb +324 -0
- data/test/gouda/workload_test.rb +61 -0
- metadata +19 -2
data/test/gouda/worker_test.rb
CHANGED
@@ -37,6 +37,39 @@ class GoudaWorkerTest < ActiveSupport::TestCase
|
|
37
37
|
end
|
38
38
|
end
|
39
39
|
|
40
|
+
class FiberTestJob < ActiveJob::Base
|
41
|
+
self.queue_adapter = :gouda
|
42
|
+
|
43
|
+
def perform(value = "F")
|
44
|
+
File.open(PATH_TO_TEST_FILE.call, "a") do |f|
|
45
|
+
f.write(value)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
class JobWithQueue < ActiveJob::Base
|
51
|
+
self.queue_adapter = :gouda
|
52
|
+
queue_as :test_queue
|
53
|
+
|
54
|
+
def perform
|
55
|
+
File.open(PATH_TO_TEST_FILE.call, "a") do |f|
|
56
|
+
f.write("Q")
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
setup do
|
62
|
+
# Clean up test file before each test
|
63
|
+
File.delete(PATH_TO_TEST_FILE.call) if File.exist?(PATH_TO_TEST_FILE.call)
|
64
|
+
end
|
65
|
+
|
66
|
+
teardown do
|
67
|
+
# Clean up test file after each test
|
68
|
+
File.delete(PATH_TO_TEST_FILE.call) if File.exist?(PATH_TO_TEST_FILE.call)
|
69
|
+
end
|
70
|
+
|
71
|
+
# === Thread-based execution tests ===
|
72
|
+
|
40
73
|
test "runs workloads from all queues without a queue constraint" do
|
41
74
|
Gouda.in_bulk do
|
42
75
|
6.times { SimpleJob.perform_later }
|
@@ -113,4 +146,295 @@ class GoudaWorkerTest < ActiveSupport::TestCase
|
|
113
146
|
# then should have executed normally and marked "finished".
|
114
147
|
assert_equal 3, Gouda::Workload.where(state: "finished").count
|
115
148
|
end
|
149
|
+
|
150
|
+
# === Configuration tests ===
|
151
|
+
|
152
|
+
test "hybrid worker can be configured" do
|
153
|
+
original_use_fiber_scheduler = Gouda.config.use_fiber_scheduler
|
154
|
+
original_fibers_per_thread = Gouda.config.fibers_per_thread
|
155
|
+
|
156
|
+
Gouda.configure do |config|
|
157
|
+
config.use_fiber_scheduler = true
|
158
|
+
config.fibers_per_thread = 5
|
159
|
+
end
|
160
|
+
|
161
|
+
assert_equal true, Gouda.config.use_fiber_scheduler
|
162
|
+
assert_equal 5, Gouda.config.fibers_per_thread
|
163
|
+
ensure
|
164
|
+
# Reset configuration
|
165
|
+
Gouda.config.use_fiber_scheduler = original_use_fiber_scheduler
|
166
|
+
Gouda.config.fibers_per_thread = original_fibers_per_thread
|
167
|
+
end
|
168
|
+
|
169
|
+
test "ThreadSafeSet works correctly" do
|
170
|
+
set = Gouda::ThreadSafeSet.new
|
171
|
+
|
172
|
+
assert_equal [], set.to_a
|
173
|
+
|
174
|
+
set.add("test1")
|
175
|
+
set.add("test2")
|
176
|
+
assert_equal 2, set.to_a.size
|
177
|
+
assert_includes set.to_a, "test1"
|
178
|
+
assert_includes set.to_a, "test2"
|
179
|
+
|
180
|
+
set.delete("test1")
|
181
|
+
assert_equal 1, set.to_a.size
|
182
|
+
assert_includes set.to_a, "test2"
|
183
|
+
refute_includes set.to_a, "test1"
|
184
|
+
end
|
185
|
+
|
186
|
+
# === Hybrid execution tests (threads + fibers) ===
|
187
|
+
|
188
|
+
test "hybrid worker runs workloads from all queues without a queue constraint" do
|
189
|
+
skip "This test requires async gem and may not work in CI environment" unless defined?(Async)
|
190
|
+
|
191
|
+
Gouda.in_bulk do
|
192
|
+
6.times { FiberTestJob.perform_later("A") }
|
193
|
+
6.times { FiberTestJob.set(queue: "urgent").perform_later("B") }
|
194
|
+
end
|
195
|
+
assert_equal 12, Gouda::Workload.where(state: "enqueued").count
|
196
|
+
|
197
|
+
# Use hybrid worker (threads + fibers) with empty queue shutdown check
|
198
|
+
Gouda.worker_loop(
|
199
|
+
n_threads: 1,
|
200
|
+
use_fibers: true,
|
201
|
+
fibers_per_thread: 2,
|
202
|
+
check_shutdown: Gouda::EmptyQueueShutdownCheck.new
|
203
|
+
)
|
204
|
+
|
205
|
+
# Check that jobs were executed (allow for some variation due to timing)
|
206
|
+
file_size = File.exist?(PATH_TO_TEST_FILE.call) ? File.size(PATH_TO_TEST_FILE.call) : 0
|
207
|
+
assert_operator file_size, :>=, 10, "Expected at least 10 jobs to complete, got #{file_size}"
|
208
|
+
|
209
|
+
# Check database state
|
210
|
+
finished_count = Gouda::Workload.where(state: "finished").count
|
211
|
+
enqueued_count = Gouda::Workload.where(state: "enqueued").count
|
212
|
+
|
213
|
+
assert_operator finished_count, :>=, 10, "Expected at least 10 finished jobs, got #{finished_count}"
|
214
|
+
assert_operator enqueued_count, :<=, 2, "Expected at most 2 enqueued jobs remaining, got #{enqueued_count}"
|
215
|
+
end
|
216
|
+
|
217
|
+
test "hybrid worker does not run workloads destined for a different queue" do
|
218
|
+
skip "This test requires async gem and may not work in CI environment" unless defined?(Async)
|
219
|
+
|
220
|
+
only_from_test_queue = Gouda.parse_queue_constraint("test_queue")
|
221
|
+
test_queue_has_no_jobs = Gouda::EmptyQueueShutdownCheck.new(only_from_test_queue)
|
222
|
+
|
223
|
+
Gouda.in_bulk do
|
224
|
+
12.times { FiberTestJob.set(queue: "other_queue").perform_later("X") }
|
225
|
+
end
|
226
|
+
assert_equal 12, Gouda::Workload.where(state: "enqueued").count
|
227
|
+
|
228
|
+
Gouda.worker_loop(
|
229
|
+
n_threads: 1,
|
230
|
+
use_fibers: true,
|
231
|
+
fibers_per_thread: 1,
|
232
|
+
queue_constraint: only_from_test_queue,
|
233
|
+
check_shutdown: test_queue_has_no_jobs
|
234
|
+
)
|
235
|
+
|
236
|
+
assert_equal 12, Gouda::Workload.where(state: "enqueued").count
|
237
|
+
assert_equal 0, Gouda::Workload.where(state: "finished").count
|
238
|
+
|
239
|
+
# File should not exist because no jobs were executed
|
240
|
+
refute File.exist?(PATH_TO_TEST_FILE.call)
|
241
|
+
end
|
242
|
+
|
243
|
+
test "hybrid worker identifies itself with fiber ID in executing_on field" do
|
244
|
+
skip "This test requires async gem and may not work in CI environment" unless defined?(Async)
|
245
|
+
|
246
|
+
FiberTestJob.perform_later("I")
|
247
|
+
|
248
|
+
# Use empty queue shutdown instead of timer to ensure job completes
|
249
|
+
Gouda.worker_loop(
|
250
|
+
n_threads: 1,
|
251
|
+
use_fibers: true,
|
252
|
+
fibers_per_thread: 1,
|
253
|
+
check_shutdown: Gouda::EmptyQueueShutdownCheck.new
|
254
|
+
)
|
255
|
+
|
256
|
+
finished_workload = Gouda::Workload.finished.last
|
257
|
+
assert_not_nil finished_workload
|
258
|
+
|
259
|
+
# Check that executing_on contains fiber identifier (should contain 'fiber-' for fiber)
|
260
|
+
assert_includes finished_workload.executing_on, "fiber-"
|
261
|
+
|
262
|
+
# Verify the job executed
|
263
|
+
assert_equal 1, File.size(PATH_TO_TEST_FILE.call)
|
264
|
+
end
|
265
|
+
|
266
|
+
test "hybrid worker handles multiple fibers concurrently" do
|
267
|
+
skip "This test requires async gem and may not work in CI environment" unless defined?(Async)
|
268
|
+
|
269
|
+
# Enqueue more jobs than we have fibers to test concurrency
|
270
|
+
Gouda.in_bulk do
|
271
|
+
8.times { |i| FiberTestJob.perform_later(i.to_s) }
|
272
|
+
end
|
273
|
+
|
274
|
+
assert_equal 8, Gouda::Workload.where(state: "enqueued").count
|
275
|
+
|
276
|
+
Gouda.worker_loop(
|
277
|
+
n_threads: 1,
|
278
|
+
use_fibers: true,
|
279
|
+
fibers_per_thread: 3, # Use 3 fibers to handle 8 jobs
|
280
|
+
check_shutdown: Gouda::EmptyQueueShutdownCheck.new
|
281
|
+
)
|
282
|
+
|
283
|
+
# Check that jobs were executed (allow for some variation due to timing)
|
284
|
+
file_size = File.exist?(PATH_TO_TEST_FILE.call) ? File.size(PATH_TO_TEST_FILE.call) : 0
|
285
|
+
assert_operator file_size, :>=, 6, "Expected at least 6 jobs to complete, got #{file_size}"
|
286
|
+
|
287
|
+
# Check database state
|
288
|
+
finished_count = Gouda::Workload.where(state: "finished").count
|
289
|
+
enqueued_count = Gouda::Workload.where(state: "enqueued").count
|
290
|
+
|
291
|
+
assert_operator finished_count, :>=, 6, "Expected at least 6 finished jobs, got #{finished_count}"
|
292
|
+
assert_operator enqueued_count, :<=, 2, "Expected at most 2 enqueued jobs remaining, got #{enqueued_count}"
|
293
|
+
|
294
|
+
# Check that fiber IDs were used
|
295
|
+
executing_on_values = Gouda::Workload.finished.pluck(:executing_on).uniq
|
296
|
+
assert_operator executing_on_values.size, :>=, 1
|
297
|
+
executing_on_values.each do |executing_on|
|
298
|
+
assert_includes executing_on, "fiber-", "Expected fiber identifier in executing_on: #{executing_on}"
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
test "start_with_scheduler_type chooses correct worker based on configuration" do
|
303
|
+
original_use_fiber_scheduler = Gouda.config.use_fiber_scheduler
|
304
|
+
|
305
|
+
# Test with fiber scheduler enabled - we'll just check that jobs get processed
|
306
|
+
# since we can't easily mock the worker_loop methods
|
307
|
+
Gouda.configure do |config|
|
308
|
+
config.use_fiber_scheduler = true
|
309
|
+
end
|
310
|
+
|
311
|
+
assert_equal true, Gouda.config.use_fiber_scheduler
|
312
|
+
|
313
|
+
# Test with fiber scheduler disabled
|
314
|
+
Gouda.configure do |config|
|
315
|
+
config.use_fiber_scheduler = false
|
316
|
+
end
|
317
|
+
|
318
|
+
assert_equal false, Gouda.config.use_fiber_scheduler
|
319
|
+
ensure
|
320
|
+
Gouda.config.use_fiber_scheduler = original_use_fiber_scheduler
|
321
|
+
end
|
322
|
+
|
323
|
+
# === Rails isolation level tests ===
|
324
|
+
|
325
|
+
test "warns when Rails isolation level is not set to fiber" do
|
326
|
+
# Skip if we don't have Rails or ActiveSupport configured
|
327
|
+
skip "Test requires Rails environment" unless defined?(Rails) && Rails.respond_to?(:application)
|
328
|
+
|
329
|
+
original_isolation = begin
|
330
|
+
ActiveSupport.isolation_level
|
331
|
+
rescue
|
332
|
+
nil
|
333
|
+
end
|
334
|
+
|
335
|
+
# Set isolation level to something other than :fiber
|
336
|
+
ActiveSupport.isolation_level = :thread
|
337
|
+
|
338
|
+
# Capture log output using a simple array instead of StringIO
|
339
|
+
original_logger = Gouda.instance_variable_get(:@fallback_gouda_logger)
|
340
|
+
|
341
|
+
# Create a logger that captures messages in our array
|
342
|
+
test_logger = ActiveSupport::TaggedLogging.new(Logger.new("File::NULL"))
|
343
|
+
test_logger.level = Logger::WARN
|
344
|
+
|
345
|
+
# Override the warn method to capture messages
|
346
|
+
def test_logger.warn(message)
|
347
|
+
@captured_messages ||= []
|
348
|
+
@captured_messages << message
|
349
|
+
end
|
350
|
+
|
351
|
+
def test_logger.captured_messages
|
352
|
+
@captured_messages || []
|
353
|
+
end
|
354
|
+
|
355
|
+
Gouda.instance_variable_set(:@fallback_gouda_logger, test_logger)
|
356
|
+
|
357
|
+
# This should trigger the warning
|
358
|
+
Gouda::FiberDatabaseSupport.check_fiber_isolation_level
|
359
|
+
|
360
|
+
captured_messages = test_logger.captured_messages
|
361
|
+
|
362
|
+
# Check that the warning was logged
|
363
|
+
warning_found = captured_messages.any? { |msg| msg.include?("FIBER SCHEDULER CONFIGURATION WARNING") }
|
364
|
+
assert warning_found, "Expected fiber scheduler warning to be logged"
|
365
|
+
|
366
|
+
isolation_warning_found = captured_messages.any? { |msg| msg.include?("Rails isolation level is set to: thread") }
|
367
|
+
assert isolation_warning_found, "Expected isolation level warning to be logged"
|
368
|
+
|
369
|
+
postgres_warning_found = captured_messages.any? { |msg| msg.include?("prevent segfaults with Ruby 3.4+ and PostgreSQL") }
|
370
|
+
assert postgres_warning_found, "Expected PostgreSQL-specific warning to be logged"
|
371
|
+
ensure
|
372
|
+
# Restore original isolation level if it was set
|
373
|
+
if original_isolation
|
374
|
+
ActiveSupport.isolation_level = original_isolation
|
375
|
+
end
|
376
|
+
# Restore original logger
|
377
|
+
if original_logger
|
378
|
+
Gouda.instance_variable_set(:@fallback_gouda_logger, original_logger)
|
379
|
+
end
|
380
|
+
end
|
381
|
+
|
382
|
+
test "does not warn when Rails isolation level is correctly set to fiber" do
|
383
|
+
# Skip if we don't have Rails or ActiveSupport configured
|
384
|
+
skip "Test requires Rails environment" unless defined?(Rails) && Rails.respond_to?(:application)
|
385
|
+
|
386
|
+
original_isolation = begin
|
387
|
+
ActiveSupport.isolation_level
|
388
|
+
rescue
|
389
|
+
nil
|
390
|
+
end
|
391
|
+
|
392
|
+
# Set isolation level to :fiber
|
393
|
+
ActiveSupport.isolation_level = :fiber
|
394
|
+
|
395
|
+
# Capture log output using a simple array instead of StringIO
|
396
|
+
original_logger = Gouda.instance_variable_get(:@fallback_gouda_logger)
|
397
|
+
|
398
|
+
# Create a logger that captures messages in our array
|
399
|
+
test_logger = ActiveSupport::TaggedLogging.new(Logger.new("File::NULL"))
|
400
|
+
test_logger.level = Logger::INFO
|
401
|
+
|
402
|
+
# Override the info method to capture messages
|
403
|
+
def test_logger.info(message)
|
404
|
+
@captured_messages ||= []
|
405
|
+
@captured_messages << message
|
406
|
+
end
|
407
|
+
|
408
|
+
def test_logger.warn(message)
|
409
|
+
@captured_messages ||= []
|
410
|
+
@captured_messages << message
|
411
|
+
end
|
412
|
+
|
413
|
+
def test_logger.captured_messages
|
414
|
+
@captured_messages || []
|
415
|
+
end
|
416
|
+
|
417
|
+
Gouda.instance_variable_set(:@fallback_gouda_logger, test_logger)
|
418
|
+
|
419
|
+
# This should not trigger a warning
|
420
|
+
Gouda::FiberDatabaseSupport.check_fiber_isolation_level
|
421
|
+
|
422
|
+
captured_messages = test_logger.captured_messages
|
423
|
+
|
424
|
+
# Check that no warning was logged, but info message was
|
425
|
+
warning_found = captured_messages.any? { |msg| msg.include?("FIBER SCHEDULER CONFIGURATION WARNING") }
|
426
|
+
refute warning_found, "Did not expect fiber scheduler warning to be logged"
|
427
|
+
|
428
|
+
info_found = captured_messages.any? { |msg| msg.include?("Rails isolation level correctly set to :fiber") }
|
429
|
+
assert info_found, "Expected confirmation info message to be logged"
|
430
|
+
ensure
|
431
|
+
# Restore original isolation level if it was set
|
432
|
+
if original_isolation
|
433
|
+
ActiveSupport.isolation_level = original_isolation
|
434
|
+
end
|
435
|
+
# Restore original logger
|
436
|
+
if original_logger
|
437
|
+
Gouda.instance_variable_set(:@fallback_gouda_logger, original_logger)
|
438
|
+
end
|
439
|
+
end
|
116
440
|
end
|
data/test/gouda/workload_test.rb
CHANGED
@@ -34,6 +34,67 @@ class GoudaWorkloadTest < ActiveSupport::TestCase
|
|
34
34
|
assert_equal Time.now.utc, workload.execution_finished_at
|
35
35
|
end
|
36
36
|
|
37
|
+
test "execution context detection methods" do
|
38
|
+
# Test thread execution detection
|
39
|
+
thread_workload = Gouda::Workload.create!(
|
40
|
+
id: SecureRandom.uuid,
|
41
|
+
active_job_id: SecureRandom.uuid,
|
42
|
+
active_job_class_name: "TestJob",
|
43
|
+
queue_name: "default",
|
44
|
+
scheduled_at: Time.now.utc,
|
45
|
+
serialized_params: {},
|
46
|
+
executing_on: "hostname-1234-uuid-thread-abc123"
|
47
|
+
)
|
48
|
+
|
49
|
+
assert thread_workload.executed_on_thread?
|
50
|
+
refute thread_workload.uses_async_execution?
|
51
|
+
assert_equal :thread, thread_workload.execution_context
|
52
|
+
|
53
|
+
# Test fiber execution detection
|
54
|
+
fiber_workload = Gouda::Workload.create!(
|
55
|
+
id: SecureRandom.uuid,
|
56
|
+
active_job_id: SecureRandom.uuid,
|
57
|
+
active_job_class_name: "TestJob",
|
58
|
+
queue_name: "default",
|
59
|
+
scheduled_at: Time.now.utc,
|
60
|
+
serialized_params: {},
|
61
|
+
executing_on: "hostname-1234-uuid-fiber-def456"
|
62
|
+
)
|
63
|
+
|
64
|
+
assert fiber_workload.uses_async_execution?
|
65
|
+
refute fiber_workload.executed_on_thread?
|
66
|
+
assert_equal :fiber, fiber_workload.execution_context
|
67
|
+
|
68
|
+
# Test unknown execution context
|
69
|
+
unknown_workload = Gouda::Workload.create!(
|
70
|
+
id: SecureRandom.uuid,
|
71
|
+
active_job_id: SecureRandom.uuid,
|
72
|
+
active_job_class_name: "TestJob",
|
73
|
+
queue_name: "default",
|
74
|
+
scheduled_at: Time.now.utc,
|
75
|
+
serialized_params: {},
|
76
|
+
executing_on: "legacy-format-without-context"
|
77
|
+
)
|
78
|
+
|
79
|
+
refute unknown_workload.executed_on_thread?
|
80
|
+
refute unknown_workload.uses_async_execution?
|
81
|
+
assert_equal :unknown, unknown_workload.execution_context
|
82
|
+
|
83
|
+
# Test nil executing_on
|
84
|
+
nil_workload = Gouda::Workload.create!(
|
85
|
+
id: SecureRandom.uuid,
|
86
|
+
active_job_id: SecureRandom.uuid,
|
87
|
+
active_job_class_name: "TestJob",
|
88
|
+
queue_name: "default",
|
89
|
+
scheduled_at: Time.now.utc,
|
90
|
+
serialized_params: {}
|
91
|
+
)
|
92
|
+
|
93
|
+
refute nil_workload.executed_on_thread?
|
94
|
+
refute nil_workload.uses_async_execution?
|
95
|
+
assert_equal :unknown, nil_workload.execution_context
|
96
|
+
end
|
97
|
+
|
37
98
|
def create_enqueued_workload
|
38
99
|
now = Time.now.utc
|
39
100
|
test_job = TestJob.new
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: gouda
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Sebastian van Hesteren
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2025-
|
12
|
+
date: 2025-08-04 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: activerecord
|
@@ -81,6 +81,20 @@ dependencies:
|
|
81
81
|
- - "~>"
|
82
82
|
- !ruby/object:Gem::Version
|
83
83
|
version: '1.10'
|
84
|
+
- !ruby/object:Gem::Dependency
|
85
|
+
name: async
|
86
|
+
requirement: !ruby/object:Gem::Requirement
|
87
|
+
requirements:
|
88
|
+
- - "~>"
|
89
|
+
- !ruby/object:Gem::Version
|
90
|
+
version: '2.25'
|
91
|
+
type: :runtime
|
92
|
+
prerelease: false
|
93
|
+
version_requirements: !ruby/object:Gem::Requirement
|
94
|
+
requirements:
|
95
|
+
- - "~>"
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: '2.25'
|
84
98
|
- !ruby/object:Gem::Dependency
|
85
99
|
name: standard
|
86
100
|
requirement: !ruby/object:Gem::Requirement
|
@@ -169,6 +183,9 @@ files:
|
|
169
183
|
- LICENSE.txt
|
170
184
|
- README.md
|
171
185
|
- Rakefile
|
186
|
+
- examples/async_job_example.rb
|
187
|
+
- examples/fiber_configuration.rb
|
188
|
+
- examples/fiber_demo.rb
|
172
189
|
- gemfiles/rails_7.gemfile
|
173
190
|
- gemfiles/rails_7.gemfile.lock
|
174
191
|
- gemfiles/rails_8.gemfile
|