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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/CHANGELOG.md +12 -0
- data/README.md +9 -6
- data/gouda.gemspec +4 -4
- data/lib/active_job/queue_adapters/gouda_adapter.rb +7 -5
- data/lib/gouda/adapter.rb +2 -1
- data/lib/gouda/bulk.rb +16 -0
- data/lib/gouda/queue_constraints.rb +25 -21
- data/lib/gouda/railtie.rb +3 -1
- data/lib/gouda/scheduler.rb +6 -0
- data/lib/gouda/version.rb +1 -1
- data/test/gouda/concurrency_extension_test.rb +160 -0
- data/test/gouda/gouda_test.rb +686 -0
- data/test/gouda/scheduler_test.rb +208 -0
- data/test/gouda/seconds_to_start_distribution.csv +280 -0
- data/test/gouda/test_helper.rb +70 -0
- data/test/gouda/worker_test.rb +116 -0
- data/test/gouda/workload_test.rb +67 -0
- data/test/support/assert_helper.rb +51 -0
- metadata +13 -5
@@ -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
|