gouda 0.1.2 → 0.1.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -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