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.
@@ -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
@@ -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.1.15
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-03-12 00:00:00.000000000 Z
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