gouda 0.1.3 → 0.1.5

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.
@@ -0,0 +1,686 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gouda/test_helper"
4
+ require "csv"
5
+
6
+ class GoudaTest < ActiveSupport::TestCase
7
+ include AssertHelper
8
+
9
+ setup do
10
+ Thread.current[:gouda_test_side_effects] = []
11
+ @adapter ||= Gouda::Adapter.new
12
+ Gouda::Railtie.initializers.each(&:run)
13
+ end
14
+
15
+ class GoudaTestJob < ActiveJob::Base
16
+ self.queue_adapter = Gouda::Adapter.new
17
+ end
18
+
19
+ class IncrementJob < GoudaTestJob
20
+ def perform
21
+ Thread.current[:gouda_test_side_effects] << "did-run"
22
+ end
23
+ end
24
+
25
+ class StandardJob < GoudaTestJob
26
+ def perform
27
+ "perform result of #{self.class}"
28
+ end
29
+ end
30
+
31
+ class JobThatAlwaysFails < GoudaTestJob
32
+ def perform
33
+ raise "Some drama"
34
+ end
35
+ end
36
+
37
+ class JobWithEnqueueConcurrency < GoudaTestJob
38
+ def perform
39
+ "perform result of #{self.class}"
40
+ end
41
+
42
+ def enqueue_concurrency_key
43
+ "conc"
44
+ end
45
+ end
46
+
47
+ class JobWithEnqueueConcurrencyAndCursor < GoudaTestJob
48
+ attr_accessor :cursor_position
49
+
50
+ def perform
51
+ "perform result of #{self.class}"
52
+ end
53
+
54
+ def enqueue_concurrency_key
55
+ "conc1"
56
+ end
57
+ end
58
+
59
+ class JobWithEnqueueConcurrencyViaGoudaAndEnqueueLimit < GoudaTestJob
60
+ include Gouda::ActiveJobExtensions::Concurrency
61
+ gouda_control_concurrency_with(enqueue_limit: 1, key: -> { self.class.to_s })
62
+ def perform
63
+ "perform result of #{self.class}"
64
+ end
65
+ end
66
+
67
+ class JobWithEnqueueConcurrencyViaGoudaAndTotalLimit < GoudaTestJob
68
+ include Gouda::ActiveJobExtensions::Concurrency
69
+ gouda_control_concurrency_with(total_limit: 1, key: -> { self.class.to_s })
70
+ def perform
71
+ "perform result of #{self.class}"
72
+ end
73
+ end
74
+
75
+ class JobWithExecutionConcurrency < GoudaTestJob
76
+ def perform
77
+ "perform result of #{self.class}"
78
+ end
79
+
80
+ def execution_concurrency_key
81
+ "there-can-be-just-one"
82
+ end
83
+ end
84
+
85
+ class JobWithExecutionConcurrencyViaGoudaAndTotalLimit < GoudaTestJob
86
+ include Gouda::ActiveJobExtensions::Concurrency
87
+ gouda_control_concurrency_with(total_limit: 1, key: -> { self.class.to_s })
88
+ def perform
89
+ "perform result of #{self.class}"
90
+ end
91
+ end
92
+
93
+ class JobWithExecutionConcurrencyViaGoudaAndPerformLimit < GoudaTestJob
94
+ include Gouda::ActiveJobExtensions::Concurrency
95
+ gouda_control_concurrency_with(perform_limit: 1, key: -> { self.class.to_s })
96
+ def perform
97
+ "perform result of #{self.class}"
98
+ end
99
+ end
100
+
101
+ class HeavyJob < GoudaTestJob
102
+ queue_as :heavy
103
+
104
+ def perform
105
+ "Heavy work"
106
+ end
107
+ end
108
+
109
+ class JobThatReenqueuesItself < GoudaTestJob
110
+ def perform
111
+ enqueue
112
+ enqueue
113
+ enqueue
114
+ enqueue
115
+ end
116
+ end
117
+
118
+ class JobThatRetriesItself < GoudaTestJob
119
+ class Unfortunateness < StandardError
120
+ end
121
+
122
+ retry_on Unfortunateness, attempts: 5, wait: 0
123
+
124
+ def perform
125
+ raise Unfortunateness, "Tranquilo!"
126
+ end
127
+ end
128
+
129
+ class JobWithEnqueueCallback < GoudaTestJob
130
+ after_enqueue do |job|
131
+ Thread.current[:gouda_test_side_effects] << "after-enq"
132
+ end
133
+
134
+ def perform
135
+ "whatever"
136
+ end
137
+ end
138
+
139
+ test "is able to enqueue jobs" do
140
+ assert_changes_by(-> { Gouda::Workload.count }, exactly: 12) do
141
+ some_jobs = 10.times.map { StandardJob.new }
142
+ @adapter.enqueue_all(some_jobs)
143
+ @adapter.enqueue(StandardJob.new)
144
+ @adapter.enqueue_at(StandardJob.new, _at_epoch = 1)
145
+ end
146
+ end
147
+
148
+ test "reports jobs waiting to start" do
149
+ assert_equal 0, Gouda::Workload.waiting_to_start.count
150
+ StandardJob.perform_later
151
+ assert_equal 1, Gouda::Workload.waiting_to_start.count
152
+
153
+ StandardJob.set(wait: -200).perform_later
154
+ assert_equal 2, Gouda::Workload.waiting_to_start.count
155
+
156
+ StandardJob.set(wait: 200).perform_later
157
+ assert_equal 2, Gouda::Workload.waiting_to_start.count
158
+
159
+ StandardJob.set(queue: "another").perform_later
160
+ assert_equal 2, Gouda::Workload.waiting_to_start(queue_constraint: Gouda::ExceptQueueConstraint.new(["another"])).count
161
+ end
162
+
163
+ test "skips jobs with the same enqueue concurrency key, both within the same bulk and separately" do
164
+ some_jobs = 12.times.map { JobWithEnqueueConcurrency.new }
165
+ assert_changes_by(-> { Gouda::Workload.count }, exactly: 1) do
166
+ @adapter.enqueue_all(some_jobs)
167
+ end
168
+ enqueued, not_enqueued = some_jobs.partition(&:successfully_enqueued?)
169
+ assert_equal 1, enqueued.length
170
+ assert_nil enqueued.first.enqueue_error
171
+ assert_kind_of ActiveJob::EnqueueError, not_enqueued.first.enqueue_error
172
+
173
+ # Attempt to enqueue one more - now it won't be filtered out at insert_all but
174
+ # instead by the DB
175
+ one_more = JobWithEnqueueConcurrency.new
176
+ @adapter.enqueue_all([one_more])
177
+ refute_predicate one_more, :successfully_enqueued?
178
+ assert_kind_of ActiveJob::EnqueueError, one_more.enqueue_error
179
+
180
+ # Mark the first job as done and make sure the next one enqueues correctly
181
+ # (the presence of a job which has executed should not make the unique constraint fire)
182
+ Gouda::Workload.update_all(state: "finished")
183
+ one_more_still = JobWithEnqueueConcurrency.new
184
+ @adapter.enqueue_all([one_more_still])
185
+ assert_predicate one_more_still, :successfully_enqueued?
186
+ end
187
+
188
+ test "allows 2 jobs with the same enqueue concurrency key if one of them comes from the scheduler" do
189
+ scheduled_job = JobWithEnqueueConcurrency.new
190
+ scheduled_job.scheduler_key = "sometime"
191
+
192
+ immediate_job = JobWithEnqueueConcurrency.new
193
+ another_immediate_job = JobWithEnqueueConcurrency.new
194
+
195
+ another_scheduled_job = JobWithEnqueueConcurrency.new
196
+ another_scheduled_job.scheduler_key = "sometime"
197
+
198
+ yet_another_scheduled_job = JobWithEnqueueConcurrency.new
199
+ yet_another_scheduled_job.scheduler_key = "some other time" # Different cron task, but still should not enqueue
200
+
201
+ some_jobs = [immediate_job, another_immediate_job, scheduled_job, another_scheduled_job, yet_another_scheduled_job]
202
+ assert_changes_by(-> { Gouda::Workload.count }, exactly: 2) do
203
+ @adapter.enqueue_all(some_jobs)
204
+ end
205
+
206
+ _enqueued, not_enqueued = some_jobs.partition(&:successfully_enqueued?)
207
+ assert_equal 3, not_enqueued.length
208
+ assert_same_set [another_immediate_job, another_scheduled_job, yet_another_scheduled_job], not_enqueued
209
+ end
210
+
211
+ test "allows multiple jobs with the same enqueue concurrency but different job-iteration cursor position" do
212
+ first_job = JobWithEnqueueConcurrencyAndCursor.new
213
+ second_job = JobWithEnqueueConcurrencyAndCursor.new
214
+ second_job.cursor_position = "123"
215
+
216
+ third_job_dupe = JobWithEnqueueConcurrencyAndCursor.new
217
+ third_job_dupe.cursor_position = "123"
218
+
219
+ fourth_job = JobWithEnqueueConcurrencyAndCursor.new
220
+ fourth_job.cursor_position = "456"
221
+
222
+ some_jobs = [first_job, second_job, third_job_dupe, fourth_job]
223
+ assert_changes_by(-> { Gouda::Workload.count }, exactly: 3) do
224
+ @adapter.enqueue_all(some_jobs)
225
+ end
226
+
227
+ _enqueued, not_enqueued = some_jobs.partition(&:successfully_enqueued?)
228
+ assert_same_set [third_job_dupe], not_enqueued
229
+ end
230
+
231
+ test "skips jobs with the same enqueue concurrency key enqueued over multiple bulks" do
232
+ assert_changes_by(-> { Gouda::Workload.count }, exactly: 2) do
233
+ some_jobs = 12.times.map { JobWithEnqueueConcurrency.new } + [StandardJob.new]
234
+ @adapter.enqueue_all(some_jobs)
235
+ end
236
+
237
+ assert_changes_by(-> { Gouda::Workload.count }, exactly: 1) do
238
+ some_jobs = 12.times.map { JobWithEnqueueConcurrency.new } + [StandardJob.new]
239
+ @adapter.enqueue_all(some_jobs)
240
+ end
241
+ end
242
+
243
+ test "skips jobs with the same enqueue concurrency key picked from Gouda settings" do
244
+ assert_changes_by(-> { Gouda::Workload.count }, exactly: 2) do
245
+ some_jobs = 12.times.map { JobWithEnqueueConcurrencyViaGoudaAndTotalLimit.new } + [StandardJob.new]
246
+ @adapter.enqueue_all(some_jobs)
247
+ end
248
+
249
+ assert_changes_by(-> { Gouda::Workload.count }, exactly: 2) do
250
+ some_jobs = 12.times.map { JobWithEnqueueConcurrencyViaGoudaAndEnqueueLimit.new } + [StandardJob.new]
251
+ @adapter.enqueue_all(some_jobs)
252
+ end
253
+ end
254
+
255
+ test "executes a job" do
256
+ job = StandardJob.new
257
+ @adapter.enqueue_all([job])
258
+
259
+ assert_equal 1, Gouda::Workload.where(state: "enqueued").count
260
+
261
+ execution_result = Gouda::Workload.checkout_and_perform_one(executing_on: "Unit test")
262
+ assert_equal "perform result of GoudaTest::StandardJob", execution_result
263
+
264
+ assert_equal 1, Gouda::Workload.where(state: "finished").count
265
+
266
+ execution_result = Gouda::Workload.checkout_and_perform_one(executing_on: "Unit test") # No more jobs to perform
267
+ assert_nil execution_result
268
+ end
269
+
270
+ test "marks a job as failed if it raises an exception" do
271
+ job = JobThatAlwaysFails.new
272
+ @adapter.enqueue_all([job])
273
+
274
+ assert_equal 1, Gouda::Workload.where(state: "enqueued").count
275
+ result = Gouda::Workload.checkout_and_perform_one(executing_on: "Unit test")
276
+ assert_kind_of StandardError, result
277
+
278
+ assert_equal 1, Gouda::Workload.where(state: "finished").count
279
+ execution_result = Gouda::Workload.checkout_and_perform_one(executing_on: "Unit test") # No more jobs to perform
280
+ assert_nil execution_result
281
+ end
282
+
283
+ test "executes enqueue callbacks" do
284
+ Gouda.in_bulk do
285
+ 3.times { JobWithEnqueueCallback.perform_later }
286
+ end
287
+ assert_equal ["after-enq", "after-enq", "after-enq"], Thread.current[:gouda_test_side_effects]
288
+ end
289
+
290
+ test "does not execute multiple jobs with the same execution concurrency key" do
291
+ @adapter.enqueue_all([JobWithExecutionConcurrency.new, JobWithExecutionConcurrency.new])
292
+
293
+ assert_equal 2, Gouda::Workload.where(state: "enqueued").count
294
+
295
+ first_worker = Fiber.new do
296
+ loop do
297
+ gouda_or_nil = Gouda::Workload.checkout_and_lock_one(executing_on: "worker1")
298
+ Fiber.yield(gouda_or_nil)
299
+ end
300
+ end
301
+
302
+ second_worker = Fiber.new do
303
+ loop do
304
+ gouda_or_nil = Gouda::Workload.checkout_and_lock_one(executing_on: "worker2")
305
+ Fiber.yield(gouda_or_nil)
306
+ end
307
+ end
308
+
309
+ first_job = first_worker.resume
310
+ second_job = second_worker.resume
311
+ assert_kind_of Gouda::Workload, first_job
312
+ assert_nil second_job
313
+
314
+ first_job.perform_and_update_state!
315
+ second_job = second_worker.resume
316
+ assert_kind_of Gouda::Workload, second_job
317
+
318
+ assert_nil first_worker.resume
319
+ assert_nil second_worker.resume
320
+ end
321
+
322
+ test "extracts execution concurrency key using Gouda configuration" do
323
+ @adapter.enqueue_all([JobWithExecutionConcurrencyViaGoudaAndPerformLimit.new, JobWithExecutionConcurrencyViaGoudaAndTotalLimit.new])
324
+
325
+ expected_keys = [
326
+ "GoudaTest::JobWithExecutionConcurrencyViaGoudaAndPerformLimit",
327
+ "GoudaTest::JobWithExecutionConcurrencyViaGoudaAndTotalLimit"
328
+ ]
329
+ assert_same_set expected_keys, Gouda::Workload.pluck(:execution_concurrency_key)
330
+ end
331
+
332
+ test "enqueues a job with a set scheduled_at" do
333
+ job = StandardJob.new
334
+ job.scheduled_at = Time.now.utc + 2
335
+ assert_changes_by(-> { Gouda::Workload.count }, exactly: 1) do
336
+ @adapter.enqueue_all([job])
337
+ end
338
+
339
+ # That job is delayed and will not SELECT now
340
+ assert_nil Gouda::Workload.checkout_and_lock_one(executing_on: "worker1")
341
+ sleep 10
342
+ assert_kind_of Gouda::Workload, Gouda::Workload.checkout_and_lock_one(executing_on: "worker1")
343
+ end
344
+
345
+ test "enqueues a job with a `wait` via enqueue_at" do
346
+ job = StandardJob.new
347
+ assert_changes_by(-> { Gouda::Workload.count }, exactly: 1) do
348
+ @adapter.enqueue_at(job, _epoch = (Time.now.to_i + 2))
349
+ end
350
+
351
+ # That job is delayed and will not SELECT now
352
+ assert_nil Gouda::Workload.checkout_and_lock_one(executing_on: "worker1")
353
+ sleep 10
354
+ assert_kind_of Gouda::Workload, Gouda::Workload.checkout_and_lock_one(executing_on: "worker1")
355
+ end
356
+
357
+ test "is able to perform_later without a wait:" do
358
+ assert_changes_by(-> { Gouda::Workload.count }, exactly: 1) do
359
+ StandardJob.perform_later
360
+ end
361
+ maybe_job = Gouda::Workload.checkout_and_lock_one(executing_on: "worker1")
362
+ assert_kind_of Gouda::Workload, maybe_job
363
+ end
364
+
365
+ test "is able to perform_later with wait:" do
366
+ assert_changes_by(-> { Gouda::Workload.count }, exactly: 1) do
367
+ StandardJob.set(wait: 4).perform_later
368
+ end
369
+ # The job will not be selected as it has to wait
370
+ assert_nil Gouda::Workload.checkout_and_lock_one(executing_on: "worker1")
371
+ end
372
+
373
+ test "is able to bulk-enqueue" do
374
+ assert_changes_by(-> { Gouda::Workload.count }, exactly: 11) do
375
+ Gouda.in_bulk do
376
+ 4.times { StandardJob.perform_later }
377
+ Gouda.in_bulk do
378
+ 4.times { StandardJob.perform_later }
379
+ Gouda.in_bulk do
380
+ 3.times { StandardJob.perform_later }
381
+ end
382
+ end
383
+ end
384
+ end
385
+ end
386
+
387
+ test "sets the correct queue with perform_later" do
388
+ assert_changes_by(-> { Gouda::Workload.count }, exactly: 1) do
389
+ HeavyJob.perform_later
390
+ end
391
+ gouda = Gouda::Workload.checkout_and_lock_one(executing_on: "worker1")
392
+ assert_equal "heavy", gouda.queue_name
393
+ end
394
+
395
+ test "selects jobs in priority order" do
396
+ priorities_ascending = [0, 4, 10, 25]
397
+ assert_changes_by(-> { Gouda::Workload.count }, exactly: 30) do
398
+ jobs_with_priorities = 30.times.map do
399
+ StandardJob.new.tap do |j|
400
+ j.priority = priorities_ascending.sample(random: case_random)
401
+ end
402
+ end
403
+ @adapter.enqueue_all(jobs_with_priorities)
404
+ end
405
+
406
+ checked_out_priorities = []
407
+ 30.times do
408
+ gouda = Gouda::Workload.checkout_and_lock_one(executing_on: "worker1")
409
+ checked_out_priorities << gouda.priority
410
+ end
411
+ assert_equal priorities_ascending, checked_out_priorities.uniq
412
+ end
413
+
414
+ test "is able to perform a job which re-enqueues itself" do
415
+ assert_changes_by(-> { Gouda::Workload.count }, exactly: 5) do
416
+ JobThatReenqueuesItself.perform_later
417
+ Gouda::Workload.checkout_and_perform_one(executing_on: "Unit test")
418
+ end
419
+
420
+ assert_equal 4, Gouda::Workload.where(state: "enqueued").count
421
+ assert_equal 1, Gouda::Workload.where(state: "finished").count
422
+ assert_equal 1, Gouda::Workload.select("DISTINCT active_job_id").count
423
+ end
424
+
425
+ test "is able to perform a job which retries itself" do
426
+ assert_changes_by(-> { Gouda::Workload.count }, exactly: 5) do
427
+ JobThatRetriesItself.perform_later
428
+ 5.times do
429
+ Gouda::Workload.checkout_and_perform_one(executing_on: "Unit test")
430
+ end
431
+ end
432
+
433
+ assert_equal 5, Gouda::Workload.where(state: "finished").count
434
+ end
435
+
436
+ test "saves the time of interruption upon enqueue when the interruption timestamp ivar is set" do
437
+ freeze_time
438
+ t = Time.now.utc
439
+ job = StandardJob.new
440
+ job.interrupted_at = t
441
+ job.enqueue
442
+
443
+ last_inserted_workload = Gouda::Workload.last
444
+ assert_equal last_inserted_workload.interrupted_at, t
445
+ end
446
+
447
+ test "raises in interrupt error when the the interruption timestamp ivar is set" do
448
+ freeze_time
449
+ t = Time.now.utc
450
+ job = StandardJob.new
451
+ job.interrupted_at = t
452
+
453
+ assert_raises Gouda::InterruptError do
454
+ job.perform_now
455
+ end
456
+ end
457
+
458
+ test "selects jobs according to the queue constraint" do
459
+ queues = %w[foo bar baz]
460
+ 20.times do
461
+ StandardJob.set(queue: queues.sample(random: case_random)).perform_later
462
+ end
463
+
464
+ only_bad = Gouda::OnlyQueuesConstraint.new(["bad"])
465
+ assert_nil Gouda::Workload.checkout_and_lock_one(executing_on: "worker1", queue_constraint: only_bad)
466
+
467
+ only_foo = Gouda::OnlyQueuesConstraint.new(["foo"])
468
+ selected_job = Gouda::Workload.checkout_and_lock_one(executing_on: "worker1", queue_constraint: only_foo)
469
+ assert_equal "foo", selected_job.queue_name
470
+
471
+ only_foo_and_baz = Gouda::OnlyQueuesConstraint.new(["foo", "baz"])
472
+ selected_job = Gouda::Workload.checkout_and_lock_one(executing_on: "worker1", queue_constraint: only_foo_and_baz)
473
+ assert ["foo", "baz"].include?(selected_job.queue_name)
474
+
475
+ except_foo_and_baz = Gouda::ExceptQueueConstraint.new(["foo", "baz"])
476
+ selected_job = Gouda::Workload.checkout_and_lock_one(executing_on: "worker1", queue_constraint: except_foo_and_baz)
477
+ assert_equal "bar", selected_job.queue_name
478
+ end
479
+
480
+ test "has reasonable performance with a pre-seeded queue" do
481
+ # slow_test!
482
+
483
+ Gouda.in_bulk do
484
+ with_sample_job_delays(n_jobs: 100_000) do |delay|
485
+ StandardJob.set(wait: delay).perform_later
486
+ end
487
+ end
488
+ end
489
+
490
+ test "prunes the finished workloads from the table" do
491
+ Gouda.in_bulk do
492
+ 250.times do
493
+ StandardJob.perform_later
494
+ end
495
+ end
496
+ Gouda.config.preserve_job_records = false
497
+ Gouda::Workload.update_all(execution_finished_at: 3.days.ago, state: "finished")
498
+
499
+ Gouda.in_bulk do
500
+ 2.times do
501
+ StandardJob.perform_later
502
+ end
503
+ end
504
+
505
+ Gouda::Workload.prune
506
+ assert_equal 2, Gouda::Workload.count
507
+ end
508
+
509
+ test "sets the error column on a job that fails" do
510
+ JobThatRetriesItself.perform_later
511
+ Gouda::Workload.checkout_and_perform_one(executing_on: "Unit test")
512
+
513
+ errored = Gouda::Workload.where.not(error: {}).first
514
+ assert_equal errored.error["message"], "Tranquilo!"
515
+ assert_equal errored.error["class_name"], "GoudaTest::JobThatRetriesItself::Unfortunateness"
516
+ refute_empty errored.error["backtrace"]
517
+ end
518
+
519
+ test "has reasonable performance" do
520
+ skip "This test is very intense (and slow!)"
521
+
522
+ Dir.mktmpdir do |tmpdir_path|
523
+ n_bulks = 100
524
+ n_workers = 15
525
+ bulk_size = 1000
526
+
527
+ # Fill up the queue with data
528
+ rng = Random.new
529
+ n_bulks.times do |n|
530
+ active_jobs = bulk_size.times.map do
531
+ StandardJob.new.tap do |job|
532
+ job.scheduled_at = Time.now.utc + rng.rand(3.0)
533
+ end
534
+ end
535
+ @adapter.enqueue_all(active_jobs)
536
+ end
537
+
538
+ # Running the processing in threads is not very conductive to performance
539
+ # measurement as there is the GIL to contend with, and this test gets real busy.
540
+ # let's use processes instead.
541
+ consumer_pids = n_workers.times.map do |n|
542
+ fork do
543
+ File.open(File.join(tmpdir_path, "bj_test_stat_#{n}.bin"), "w") do |f|
544
+ ActiveRecord::Base.connection.execute("SET statement_timeout TO '30s'")
545
+ loop do
546
+ t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
547
+
548
+ did_perform = Gouda::Workload.checkout_and_perform_one(executing_on: "perf")
549
+ break unless did_perform
550
+
551
+ dt = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t
552
+ f.puts(dt.to_s)
553
+ end
554
+ end
555
+ end
556
+ end
557
+
558
+ # Let the cosumers run for 5 seconds
559
+ sleep 5
560
+
561
+ # Then terminate them
562
+ consumer_pids.each do |pid|
563
+ Process.kill("TERM", pid)
564
+ Process.wait(pid)
565
+ end
566
+ # and collect stats
567
+ times_to_consume = Dir.glob(File.join(tmpdir_path, "bj_test_stat_*.*")).flat_map do |path|
568
+ values = File.read(path).split("\n").map { |sample| sample.to_f }
569
+ values.tap { File.unlink(path) }
570
+ end
571
+
572
+ warn "Take+perform with #{n_workers} workers and #{n_bulks * bulk_size} jobs in queue: #{sample_description(times_to_consume)}"
573
+ assert_equal n_bulks * bulk_size, Gouda::Workload.count
574
+ end
575
+ end
576
+
577
+ test "actually executes workloads" do
578
+ 4.times { StandardJob.perform_later }
579
+ 2.times { JobThatAlwaysFails.perform_later }
580
+ 4.times { JobWithExecutionConcurrency.perform_later }
581
+ 6.times { JobWithEnqueueConcurrency.perform_later }
582
+
583
+ Gouda.worker_loop(n_threads: 3, check_shutdown: Gouda::EmptyQueueShutdownCheck.new)
584
+ assert_equal 11, Gouda::Workload.where(state: "finished").count
585
+ end
586
+
587
+ test "parses queue constraints" do
588
+ constraint = Gouda.parse_queue_constraint("*")
589
+ assert_equal Gouda::AnyQueue, constraint
590
+
591
+ constraint = Gouda.parse_queue_constraint("-heavy")
592
+ assert_kind_of Gouda::ExceptQueueConstraint, constraint
593
+ assert_equal "queue_name NOT IN ('heavy')", constraint.to_sql.strip
594
+
595
+ constraint = Gouda.parse_queue_constraint("foo,bar")
596
+ assert_kind_of Gouda::OnlyQueuesConstraint, constraint
597
+ assert_equal "queue_name IN ('foo','bar')", constraint.to_sql.strip
598
+ end
599
+
600
+ test "checkout and performs from the right queue only" do
601
+ heavy_constraint = Gouda.parse_queue_constraint("heavy")
602
+ except_heavy_constraint = Gouda.parse_queue_constraint("-heavy")
603
+ heavy = HeavyJob.perform_later
604
+ light = StandardJob.perform_later
605
+
606
+ assert_same_set Gouda::Workload.enqueued.pluck(:active_job_id), [heavy.job_id, light.job_id]
607
+ assert_changes -> { Gouda::Workload.finished.count }, to: 1 do
608
+ Gouda::Workload.checkout_and_perform_one(executing_on: "test", queue_constraint: heavy_constraint)
609
+ end
610
+ assert_no_changes -> { Gouda::Workload.finished.count } do
611
+ Gouda::Workload.checkout_and_perform_one(executing_on: "test", queue_constraint: heavy_constraint)
612
+ end
613
+
614
+ assert_equal 1, Gouda::Workload.finished.count
615
+ assert_equal heavy.job_id, Gouda::Workload.finished.first.active_job_id
616
+
617
+ heavy2 = HeavyJob.perform_later
618
+ assert_same_set Gouda::Workload.enqueued.pluck(:active_job_id), [heavy2.job_id, light.job_id]
619
+
620
+ assert_changes -> { Gouda::Workload.finished.count }, to: 2 do
621
+ Gouda::Workload.checkout_and_perform_one(executing_on: "test", queue_constraint: except_heavy_constraint)
622
+ end
623
+ assert_no_changes -> { Gouda::Workload.finished.count } do
624
+ Gouda::Workload.checkout_and_perform_one(executing_on: "test", queue_constraint: except_heavy_constraint)
625
+ end
626
+
627
+ assert_equal 2, Gouda::Workload.finished.count
628
+ assert_same_set [heavy.job_id, light.job_id], Gouda::Workload.finished.pluck(:active_job_id)
629
+ end
630
+
631
+ test "picks up fused jobs, but marks them as finished right away without execuing the job code" do
632
+ IncrementJob.perform_later
633
+
634
+ assert_changes -> { Gouda::Workload.finished.count }, to: 1 do
635
+ Gouda::Workload.checkout_and_perform_one(executing_on: "test")
636
+ end
637
+
638
+ Gouda::JobFuse.create!(active_job_class_name: "GoudaTest::IncrementJob")
639
+ IncrementJob.perform_later
640
+
641
+ assert_changes -> { Gouda::Workload.finished.count }, to: 2 do
642
+ Gouda::Workload.checkout_and_perform_one(executing_on: "test")
643
+ end
644
+
645
+ assert_equal ["did-run"], Thread.current[:gouda_test_side_effects]
646
+ end
647
+
648
+ def sample_description(sample)
649
+ values = sample.map(&:to_f).sort
650
+
651
+ max = values.last #=> 9.0
652
+ n = values.size # => 9
653
+ values.map!(&:to_f) # => [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]
654
+ mean = values.reduce(&:+) / n # => 5.0
655
+ sum_sqr = values.map { |x| x * x }.reduce(&:+) # => 285.0
656
+ std_dev = Math.sqrt((sum_sqr - n * mean * mean) / (n - 1)) # => 2.7386127875258306
657
+
658
+ percentile = ->(fraction) {
659
+ if values.length > 1
660
+ k = (fraction * (values.length - 1) + 1).floor - 1
661
+ f = (fraction * (values.length - 1) + 1).modulo(1)
662
+ values[k] + (f * (values[k + 1] - values[k]))
663
+ else
664
+ values.first
665
+ end
666
+ }
667
+ p90 = percentile.call(0.9)
668
+ p95 = percentile.call(0.95)
669
+ p99 = percentile.call(0.99)
670
+
671
+ "sample_size: %d, mean: %0.3f, max: %0.3f, p90: %0.3f, p95: %0.3f, p99: %0.3f, stddev: %0.3f" % [sample.length, mean, max, p90, p95, p99, std_dev]
672
+ end
673
+
674
+ def with_sample_job_delays(n_jobs:)
675
+ rows = CSV.read(File.dirname(__FILE__) + "/seconds_to_start_distribution.csv")
676
+ rows.shift
677
+ count_to_delay_seconds = rows.map { |(a, b)| [a.to_i, b.to_i] }
678
+ total_records = count_to_delay_seconds.map(&:first).sum
679
+ scaling_factor = n_jobs / total_records.to_f
680
+ count_to_delay_seconds.each do |(initial_count, delay_seconds)|
681
+ (scaling_factor * initial_count).round.times do
682
+ yield(delay_seconds)
683
+ end
684
+ end
685
+ end
686
+ end