workhorse 1.5.2 → 2.0.0.rc0

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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -0
  3. data/README.md +303 -74
  4. data/Rakefile +1 -0
  5. data/VERSION +1 -1
  6. data/bin/rubocop +5 -1
  7. data/lib/generators/workhorse/install_generator.rb +10 -1
  8. data/lib/generators/workhorse/templates/config/initializers/workhorse.rb +55 -0
  9. data/lib/generators/workhorse/templates/create_table_jobs.rb +11 -13
  10. data/lib/generators/workhorse/templates/create_table_workhorse_schedules.rb +29 -0
  11. data/lib/workhorse/daemon.rb +52 -6
  12. data/lib/workhorse/db_job.rb +58 -7
  13. data/lib/workhorse/enqueuer.rb +51 -8
  14. data/lib/workhorse/jobs/cleanup_succeeded_jobs.rb +26 -8
  15. data/lib/workhorse/jobs/detect_late_schedules_job.rb +59 -0
  16. data/lib/workhorse/notifiers/base.rb +55 -0
  17. data/lib/workhorse/notifiers/file_system.rb +66 -0
  18. data/lib/workhorse/notifiers/none.rb +8 -0
  19. data/lib/workhorse/notifiers/redis.rb +227 -0
  20. data/lib/workhorse/performer.rb +28 -0
  21. data/lib/workhorse/poller.rb +289 -47
  22. data/lib/workhorse/pool.rb +12 -6
  23. data/lib/workhorse/schedule.rb +288 -0
  24. data/lib/workhorse/schedules.rb +197 -0
  25. data/lib/workhorse/worker.rb +77 -27
  26. data/lib/workhorse.rb +136 -0
  27. data/test/lib/db_schema.rb +21 -1
  28. data/test/lib/jobs.rb +29 -0
  29. data/test/lib/test_helper.rb +9 -14
  30. data/test/workhorse/daemon_test.rb +33 -0
  31. data/test/workhorse/db_job_test.rb +1 -1
  32. data/test/workhorse/notifier_test.rb +500 -0
  33. data/test/workhorse/poller_test.rb +8 -4
  34. data/test/workhorse/schedule_test.rb +967 -0
  35. data/test/workhorse/worker_test.rb +92 -0
  36. data/workhorse.gemspec +6 -5
  37. metadata +29 -3
@@ -0,0 +1,967 @@
1
+ require 'test_helper'
2
+
3
+ class Workhorse::ScheduleTest < WorkhorseTest
4
+ def setup
5
+ super
6
+ Workhorse::Schedules.reset!
7
+ Workhorse::Schedule.delete_all
8
+ @on_job_expired = Workhorse.on_job_expired
9
+ @on_job_late = Workhorse.on_job_late
10
+ end
11
+
12
+ def teardown
13
+ Workhorse::Schedules.reset!
14
+ Workhorse::Schedule.delete_all
15
+ Workhorse.on_job_expired = @on_job_expired
16
+ Workhorse.on_job_late = @on_job_late
17
+ end
18
+
19
+ # Registry
20
+
21
+ def test_registering_a_schedule
22
+ define_schedule 'cleanup', cron: '10 0 * * *'
23
+
24
+ definition = Workhorse::Schedules['cleanup']
25
+
26
+ assert_equal 'cleanup', definition.key
27
+ assert_equal 'BasicJob', definition.job
28
+ assert_equal :run_once, definition.catch_up
29
+ end
30
+
31
+ def test_duplicate_keys_are_rejected
32
+ define_schedule 'cleanup', cron: '10 0 * * *'
33
+
34
+ assert_raises ArgumentError do
35
+ define_schedule 'cleanup', cron: '20 0 * * *'
36
+ end
37
+ end
38
+
39
+ def test_invalid_cron_is_rejected
40
+ error = assert_raises ArgumentError do
41
+ define_schedule 'broken', cron: 'not a cron'
42
+ end
43
+
44
+ assert_match(/not a valid cron expression/, error.message)
45
+ end
46
+
47
+ def test_invalid_catch_up_policy_is_rejected
48
+ assert_raises ArgumentError do
49
+ define_schedule 'broken', cron: '* * * * *', catch_up: :whenever
50
+ end
51
+ end
52
+
53
+ def test_skip_without_grace_is_rejected
54
+ error = assert_raises ArgumentError do
55
+ define_schedule 'digest', cron: '0 8 * * *', catch_up: :skip
56
+ end
57
+
58
+ assert_match(/requires a grace period/, error.message)
59
+ end
60
+
61
+ # Reconciliation
62
+
63
+ def test_reconcile_inserts_without_firing_immediately
64
+ now = Time.new(2026, 9, 28, 10, 0, 0)
65
+ define_schedule 'nightly', cron: '0 3 * * *'
66
+
67
+ Workhorse::Schedule.reconcile!(now)
68
+
69
+ schedule = Workhorse::Schedule.sole
70
+
71
+ assert_equal 'nightly', schedule.key
72
+ assert_operator schedule.next_at, :>, now
73
+ assert_empty Workhorse::Schedule.due(now)
74
+ end
75
+
76
+ def test_reconcile_recomputes_when_the_cron_changes
77
+ now = Time.new(2026, 9, 28, 10, 0, 0)
78
+ define_schedule 'nightly', cron: '0 3 * * *'
79
+ Workhorse::Schedule.reconcile!(now)
80
+ before = Workhorse::Schedule.sole.next_at
81
+
82
+ Workhorse::Schedules.reset!
83
+ define_schedule 'nightly', cron: '0 4 * * *'
84
+ Workhorse::Schedule.reconcile!(now)
85
+
86
+ schedule = Workhorse::Schedule.sole
87
+
88
+ assert_equal '0 4 * * *', schedule.cron
89
+ refute_equal before, schedule.next_at
90
+ end
91
+
92
+ def test_reconcile_recomputes_when_the_timezone_changes
93
+ now = Time.new(2026, 9, 28, 10, 0, 0)
94
+ define_schedule 'nightly', cron: '0 3 * * *'
95
+ Workhorse::Schedule.reconcile!(now)
96
+
97
+ Workhorse::Schedules.reset!
98
+ define_schedule 'nightly', cron: '0 3 * * *', timezone: 'Asia/Tokyo'
99
+ Workhorse::Schedule.reconcile!(now)
100
+
101
+ schedule = Workhorse::Schedule.sole
102
+
103
+ assert_equal 'Asia/Tokyo', schedule.timezone
104
+ assert_equal ActiveSupport::TimeZone['Asia/Tokyo'].local(2026, 9, 29, 3, 0, 0), schedule.next_at
105
+ end
106
+
107
+ # Reconciliation must not push an unchanged schedule's occurrence forward,
108
+ # or restarting workers often enough would keep it from ever being due.
109
+ def test_reconcile_leaves_an_unchanged_schedule_alone
110
+ now = Time.new(2026, 9, 28, 10, 0, 0)
111
+ define_schedule 'nightly', cron: '0 3 * * *'
112
+ Workhorse::Schedule.reconcile!(now)
113
+ before = Workhorse::Schedule.sole.next_at
114
+
115
+ Workhorse::Schedule.reconcile!(now + 3600)
116
+
117
+ assert_equal before, Workhorse::Schedule.sole.next_at
118
+ end
119
+
120
+ # Removal is deliberately not immediate, see
121
+ # test_reconcile_keeps_a_schedule_another_version_still_declares.
122
+ def test_reconcile_keeps_a_schedule_that_was_just_removed
123
+ define_schedule 'a', cron: '0 3 * * *'
124
+ define_schedule 'b', cron: '0 4 * * *'
125
+ Workhorse::Schedule.reconcile!
126
+
127
+ assert_equal %w[a b], Workhorse::Schedule.order(:key).pluck(:key)
128
+
129
+ Workhorse::Schedules.reset!
130
+ define_schedule 'a', cron: '0 3 * * *'
131
+ Workhorse::Schedule.reconcile!
132
+
133
+ assert_equal %w[a b], Workhorse::Schedule.order(:key).pluck(:key)
134
+ end
135
+
136
+ def test_reconcile_deletes_every_schedule_once_none_is_declared
137
+ define_schedule 'a', cron: '0 3 * * *'
138
+ Workhorse::Schedule.reconcile!
139
+ Workhorse::Schedule.update_all(updated_at: Time.now - (2 * 24 * 60 * 60))
140
+
141
+ Workhorse::Schedules.reset!
142
+ Workhorse::Schedule.reconcile!
143
+
144
+ assert_equal 0, Workhorse::Schedule.count
145
+ end
146
+
147
+ # Catch-up policies
148
+
149
+ def test_nothing_is_due_before_the_occurrence
150
+ now = Time.new(2026, 9, 28, 10, 0, 0)
151
+ schedule = persisted_schedule('every_ten', cron: '*/10 * * * *', next_at: now + 300)
152
+
153
+ occurrences, = schedule.pending_occurrences(now)
154
+
155
+ assert_empty occurrences
156
+ end
157
+
158
+ def test_an_occurrence_missed_during_an_outage_is_not_lost
159
+ now = Time.new(2026, 9, 28, 10, 0, 0)
160
+ schedule = persisted_schedule('nightly', cron: '0 3 * * *', next_at: Time.new(2026, 9, 28, 3, 0, 0))
161
+
162
+ occurrences, next_at = schedule.pending_occurrences(now)
163
+
164
+ assert_equal [Time.new(2026, 9, 28, 3, 0, 0)], occurrences
165
+ assert_equal Time.new(2026, 9, 29, 3, 0, 0), next_at
166
+ end
167
+
168
+ def test_run_once_collapses_missed_occurrences
169
+ now = Time.new(2026, 9, 28, 10, 0, 0)
170
+ schedule = persisted_schedule(
171
+ 'every_ten', cron: '*/10 * * * *', catch_up: :run_once, next_at: now - 3600
172
+ )
173
+
174
+ occurrences, next_at = schedule.pending_occurrences(now)
175
+
176
+ assert_equal 1, occurrences.size
177
+ assert_equal Time.new(2026, 9, 28, 10, 0, 0), occurrences.first
178
+ assert_operator next_at, :>, now
179
+ end
180
+
181
+ def test_run_materializes_every_missed_occurrence_up_to_the_cap
182
+ now = Time.new(2026, 9, 28, 10, 0, 0)
183
+ schedule = persisted_schedule(
184
+ 'every_ten', cron: '*/10 * * * *', catch_up: :run, next_at: now - 3600
185
+ )
186
+
187
+ occurrences, = schedule.pending_occurrences(now)
188
+
189
+ assert_equal 7, occurrences.size
190
+ assert_equal occurrences.sort, occurrences
191
+ end
192
+
193
+ def test_run_is_capped_by_max_catch_up
194
+ now = Time.new(2026, 9, 28, 10, 0, 0)
195
+ schedule = persisted_schedule(
196
+ 'every_minute', cron: '* * * * *', catch_up: :run, max_catch_up: 3, next_at: now - 3600
197
+ )
198
+
199
+ occurrences, = schedule.pending_occurrences(now)
200
+
201
+ assert_equal 3, occurrences.size
202
+ # The most recent ones, not the oldest.
203
+ assert_equal Time.new(2026, 9, 28, 10, 0, 0), occurrences.last
204
+ end
205
+
206
+ def test_skip_runs_an_occurrence_that_is_within_grace
207
+ now = Time.new(2026, 9, 28, 8, 5, 0)
208
+ schedule = persisted_schedule(
209
+ 'digest', cron: '0 8 * * *', catch_up: :skip, grace: 15 * 60,
210
+ next_at: Time.new(2026, 9, 28, 8, 0, 0)
211
+ )
212
+
213
+ occurrences, = schedule.pending_occurrences(now)
214
+
215
+ assert_equal [Time.new(2026, 9, 28, 8, 0, 0)], occurrences
216
+ end
217
+
218
+ def test_skip_drops_an_occurrence_that_is_past_grace
219
+ now = Time.new(2026, 9, 28, 14, 0, 0)
220
+ schedule = persisted_schedule(
221
+ 'digest', cron: '0 8 * * *', catch_up: :skip, grace: 15 * 60,
222
+ next_at: Time.new(2026, 9, 28, 8, 0, 0)
223
+ )
224
+
225
+ occurrences, next_at = schedule.pending_occurrences(now)
226
+
227
+ assert_empty occurrences
228
+ assert_equal Time.new(2026, 9, 29, 8, 0, 0), next_at
229
+ end
230
+
231
+ # Europe/Zurich moves from 02:00 to 03:00 on 2027-03-28, so an 02:30
232
+ # schedule has no occurrence that day.
233
+ def test_daylight_saving_spring_forward_skips_the_missing_hour
234
+ zone = ActiveSupport::TimeZone['Europe/Zurich']
235
+ schedule = persisted_schedule(
236
+ 'nightly', cron: '30 2 * * *', timezone: 'Europe/Zurich',
237
+ next_at: zone.local(2027, 3, 27, 2, 30, 0)
238
+ )
239
+
240
+ days = 3.times.map do
241
+ _occurrences, next_at = schedule.pending_occurrences(schedule.next_at)
242
+ schedule.update!(next_at: next_at)
243
+ next_at.in_time_zone('Europe/Zurich').day
244
+ end
245
+
246
+ assert_equal [29, 30, 31], days
247
+ end
248
+
249
+ # Claiming
250
+
251
+ def test_only_one_claim_of_the_same_occurrence_succeeds
252
+ schedule = persisted_schedule('nightly', cron: '0 3 * * *', next_at: Time.now - 60)
253
+ other = Workhorse::Schedule.find(schedule.id)
254
+
255
+ assert schedule.claim!(Time.now + 3600)
256
+ refute other.claim!(Time.now + 7200), 'a stale claim must not succeed'
257
+ end
258
+
259
+ # Enqueuing
260
+
261
+ def test_enqueue_records_the_intended_time
262
+ occurrence = Time.now.round - 300
263
+ schedule = persisted_schedule('nightly', cron: '0 3 * * *', next_at: occurrence)
264
+
265
+ db_job = schedule.enqueue!(occurrence)
266
+
267
+ assert_equal occurrence, db_job.perform_at
268
+ assert_equal 'BasicJob (nightly)', db_job.description
269
+ assert_equal db_job.id, schedule.reload.last_job_id
270
+ assert_equal occurrence, schedule.last_occurrence
271
+ end
272
+
273
+ def test_enqueue_applies_max_lateness_and_expiry
274
+ occurrence = Time.now.round - 300
275
+ schedule = persisted_schedule(
276
+ 'nightly', cron: '0 3 * * *', next_at: occurrence,
277
+ max_lateness: 60, expires_after: 900
278
+ )
279
+
280
+ db_job = schedule.enqueue!(occurrence)
281
+
282
+ assert_equal 60, db_job.max_lateness
283
+ assert_equal occurrence + 900, db_job.expires_at
284
+ end
285
+
286
+ def test_enqueue_works_for_active_job_classes
287
+ occurrence = Time.now.round
288
+ schedule = persisted_schedule(
289
+ 'aj', cron: '0 3 * * *', next_at: occurrence, job: 'ScheduledActiveJob', priority: 5
290
+ )
291
+
292
+ db_job = schedule.enqueue!(occurrence)
293
+
294
+ assert_equal 5, db_job.priority
295
+ assert_equal occurrence, db_job.perform_at
296
+ end
297
+
298
+ def test_enqueue_works_for_rails_ops_operations
299
+ DummyScheduledOp.results.clear
300
+ occurrence = Time.now.round
301
+ schedule = persisted_schedule(
302
+ 'op', cron: '0 3 * * *', next_at: occurrence, job: 'DummyScheduledOp', params: { foo: :bar }
303
+ )
304
+
305
+ db_job = schedule.enqueue!(occurrence)
306
+ handler = Marshal.load(db_job.handler) # rubocop:disable Security/MarshalLoad
307
+
308
+ assert_equal Workhorse::Jobs::RunRailsOp, handler.class
309
+ assert_equal occurrence, db_job.perform_at
310
+
311
+ work 2, polling_interval: 0.2, pool_size: 1
312
+
313
+ assert_equal 'succeeded', db_job.reload.state
314
+ assert_equal [{ foo: :bar }], DummyScheduledOp.results.to_a
315
+ end
316
+
317
+ def test_enqueue_passes_params
318
+ occurrence = Time.now.round
319
+ schedule = persisted_schedule(
320
+ 'nightly', cron: '0 3 * * *', next_at: occurrence, params: { some_param: 'x', sleep_time: 0 }
321
+ )
322
+
323
+ db_job = schedule.enqueue!(occurrence)
324
+
325
+ job = Marshal.load(db_job.handler) # rubocop:disable Security/MarshalLoad
326
+
327
+ assert_equal 'x', job.instance_variable_get(:@some_param)
328
+ end
329
+
330
+ # Poller integration
331
+
332
+ def test_a_worker_materializes_and_performs_a_due_schedule
333
+ define_schedule 'due_now', cron: '0 3 * * *'
334
+ Workhorse::Schedule.reconcile!
335
+ Workhorse::Schedule.sole.update!(next_at: yesterday_at_three)
336
+
337
+ work 2, polling_interval: 0.2, pool_size: 1
338
+
339
+ assert_equal 1, Workhorse::DbJob.succeeded.count
340
+ assert_operator Workhorse::Schedule.sole.next_at, :>, Time.now
341
+ end
342
+
343
+ def test_a_disabled_schedule_is_not_materialized
344
+ define_schedule 'due_now', cron: '0 3 * * *'
345
+ Workhorse::Schedule.reconcile!
346
+ Workhorse::Schedule.sole.update!(next_at: yesterday_at_three, enabled: false)
347
+
348
+ work 1, polling_interval: 0.2, pool_size: 1
349
+
350
+ assert_equal 0, Workhorse::DbJob.count
351
+ end
352
+
353
+ def test_a_failing_schedule_does_not_stop_the_others
354
+ define_schedule 'broken', cron: '0 3 * * *', job: 'ThisClassDoesNotExist'
355
+ define_schedule 'fine', cron: '0 3 * * *'
356
+ Workhorse::Schedule.reconcile!
357
+ Workhorse::Schedule.update_all(next_at: yesterday_at_three)
358
+
359
+ exceptions = []
360
+ with_exception_handler ->(e) { exceptions << e } do
361
+ work 2, polling_interval: 0.2, pool_size: 1
362
+ end
363
+
364
+ assert_equal 1, Workhorse::DbJob.succeeded.count
365
+ assert exceptions.any? { |e| e.is_a?(NameError) }, "expected a NameError, got #{exceptions.map(&:class)}"
366
+ end
367
+
368
+ # An occurrence must not be consumed unless the job for it exists. Were the
369
+ # claim to commit on its own, a schedule whose enqueuing always fails would
370
+ # advance past every occurrence while DetectLateSchedulesJob stayed green.
371
+ def test_a_claim_is_rolled_back_when_enqueuing_fails
372
+ define_schedule 'due_now', cron: '0 3 * * *', job: 'ThisClassDoesNotExist'
373
+ Workhorse::Schedule.reconcile!
374
+ schedule = Workhorse::Schedule.sole
375
+ schedule.update!(next_at: yesterday_at_three)
376
+ before = schedule.reload.next_at
377
+
378
+ with_exception_handler ->(_e) {} do
379
+ work 1, polling_interval: 0.2, pool_size: 1
380
+ end
381
+
382
+ assert_equal 0, Workhorse::DbJob.count
383
+ assert_equal before, schedule.reload.next_at, 'the occurrence must still be pending'
384
+ end
385
+
386
+ def test_a_missing_schedules_table_does_not_shut_the_worker_down
387
+ define_schedule 'due_now', cron: '* * * * *'
388
+ exceptions = []
389
+
390
+ with_renamed_schedules_table do
391
+ log = capture_log do |logger|
392
+ with_exception_handler ->(e) { exceptions << e } do
393
+ with_worker(polling_interval: 0.2, pool_size: 1, auto_terminate: false, logger: logger) do |w|
394
+ job = Workhorse.enqueue BasicJob.new(sleep_time: 0)
395
+
396
+ with_retries { assert_equal 'succeeded', job.reload.state }
397
+
398
+ assert_equal :running, w.state
399
+ end
400
+ end
401
+ end
402
+
403
+ assert_match(/workhorse_schedules table does not exist/, log)
404
+ end
405
+
406
+ assert exceptions.any? { |e| e.message =~ /workhorse_schedules table does not exist/ },
407
+ 'the missing table must be reported, not only logged'
408
+ end
409
+
410
+ # A rolling deployment runs both versions at once.
411
+ def test_reconcile_keeps_a_schedule_another_version_still_declares
412
+ define_schedule 'only_in_the_old_version', cron: '0 3 * * *'
413
+ Workhorse::Schedule.reconcile!
414
+ before = Workhorse::Schedule.sole.next_at
415
+
416
+ # The other version, which does not know this schedule, starts up.
417
+ Workhorse::Schedules.reset!
418
+ define_schedule 'only_in_the_new_version', cron: '0 4 * * *'
419
+ Workhorse::Schedule.reconcile!
420
+
421
+ assert_equal %w[only_in_the_new_version only_in_the_old_version],
422
+ Workhorse::Schedule.order(:key).pluck(:key)
423
+ assert_equal before, Workhorse::Schedule.find_by(key: 'only_in_the_old_version').next_at
424
+ end
425
+
426
+ def test_reconcile_deletes_a_schedule_nothing_has_declared_for_a_day
427
+ define_schedule 'gone', cron: '0 3 * * *'
428
+ Workhorse::Schedule.reconcile!
429
+ Workhorse::Schedule.sole.update_columns(updated_at: Time.now - (2 * 24 * 60 * 60))
430
+
431
+ Workhorse::Schedules.reset!
432
+ Workhorse::Schedule.reconcile!
433
+
434
+ assert_equal 0, Workhorse::Schedule.count
435
+ end
436
+
437
+ def test_detect_late_schedules_ignores_rows_without_a_declaration
438
+ persisted_schedule('known', cron: '0 3 * * *', next_at: Time.now - 3600)
439
+ Workhorse::Schedule.create!(key: 'orphan', cron: '0 3 * * *', next_at: Time.now - 3600)
440
+
441
+ error = assert_raises RuntimeError do
442
+ Workhorse::Jobs::DetectLateSchedulesJob.new(threshold: 60).perform
443
+ end
444
+
445
+ assert_match(/"known"/, error.message)
446
+ refute_match(/orphan/, error.message)
447
+ end
448
+
449
+ # Expiry
450
+
451
+ def test_a_job_past_its_deadline_is_expired_instead_of_performed
452
+ expired = []
453
+ Workhorse.on_job_expired = proc { |db_job| expired << db_job.id }
454
+
455
+ job = Workhorse.enqueue BasicJob.new(sleep_time: 0), expires_at: Time.now - 60
456
+
457
+ work 1, polling_interval: 0.2, pool_size: 1
458
+
459
+ assert_equal 'expired', job.reload.state
460
+ assert_nil job.started_at
461
+ assert_equal [job.id], expired
462
+ end
463
+
464
+ def test_a_job_within_its_deadline_is_performed
465
+ job = Workhorse.enqueue BasicJob.new(sleep_time: 0), expires_at: Time.now + 3600
466
+
467
+ work 2, polling_interval: 0.2, pool_size: 1
468
+
469
+ assert_equal 'succeeded', job.reload.state
470
+ end
471
+
472
+ def test_a_raising_expiry_callback_does_not_take_the_worker_down
473
+ Workhorse.on_job_expired = proc { fail 'callback is broken' }
474
+
475
+ job = Workhorse.enqueue BasicJob.new(sleep_time: 0), expires_at: Time.now - 60
476
+ other = nil
477
+
478
+ exceptions = []
479
+ with_exception_handler ->(e) { exceptions << e } do
480
+ with_worker(polling_interval: 0.2, pool_size: 1, auto_terminate: false) do |w|
481
+ with_retries { assert_equal 'expired', job.reload.state }
482
+
483
+ other = Workhorse.enqueue BasicJob.new(sleep_time: 0)
484
+ with_retries { assert_equal 'succeeded', other.reload.state }
485
+
486
+ assert_equal :running, w.state
487
+ end
488
+ end
489
+
490
+ assert(exceptions.any? { |e| e.message == 'callback is broken' })
491
+ end
492
+
493
+ # Lateness
494
+
495
+ def test_a_late_job_reports_its_lateness
496
+ late = []
497
+ Workhorse.on_job_late = proc { |db_job, lateness| late << [db_job.id, lateness] }
498
+
499
+ job = Workhorse.enqueue BasicJob.new(sleep_time: 0), perform_at: Time.now - 300, max_lateness: 60
500
+
501
+ work 2, polling_interval: 0.2, pool_size: 1
502
+
503
+ assert_equal 'succeeded', job.reload.state
504
+ assert_equal 1, late.size
505
+ assert_equal job.id, late.first.first
506
+ assert_operator late.first.last, :>=, 300
507
+ end
508
+
509
+ def test_a_punctual_job_reports_nothing
510
+ late = []
511
+ Workhorse.on_job_late = proc { |db_job, lateness| late << [db_job.id, lateness] }
512
+
513
+ job = Workhorse.enqueue BasicJob.new(sleep_time: 0), perform_at: Time.now, max_lateness: 3600
514
+
515
+ work 2, polling_interval: 0.2, pool_size: 1
516
+
517
+ assert_equal 'succeeded', job.reload.state
518
+ assert_empty late
519
+ end
520
+
521
+ def test_a_raising_lateness_callback_does_not_fail_the_job
522
+ Workhorse.on_job_late = proc { fail 'callback is broken' }
523
+
524
+ job = Workhorse.enqueue BasicJob.new(sleep_time: 0), perform_at: Time.now - 300, max_lateness: 60
525
+
526
+ exceptions = []
527
+ with_exception_handler ->(e) { exceptions << e } do
528
+ work 2, polling_interval: 0.2, pool_size: 1
529
+ end
530
+
531
+ assert_equal 'succeeded', job.reload.state
532
+ assert(exceptions.any? { |e| e.message == 'callback is broken' })
533
+ end
534
+
535
+ # Detection job
536
+
537
+ def test_detect_late_schedules_passes_when_up_to_date
538
+ persisted_schedule('nightly', cron: '0 3 * * *', next_at: Time.now + 3600)
539
+
540
+ assert_nothing_raised do
541
+ Workhorse::Jobs::DetectLateSchedulesJob.new.perform
542
+ end
543
+ end
544
+
545
+ def test_detect_late_schedules_fails_when_overdue
546
+ persisted_schedule('nightly', cron: '0 3 * * *', next_at: Time.now - 3600)
547
+
548
+ error = assert_raises RuntimeError do
549
+ Workhorse::Jobs::DetectLateSchedulesJob.new(threshold: 60).perform
550
+ end
551
+
552
+ assert_match(/"nightly"/, error.message)
553
+ assert_match(/not being materialized/, error.message)
554
+ end
555
+
556
+ def test_detect_late_schedules_ignores_disabled_schedules
557
+ persisted_schedule('nightly', cron: '0 3 * * *', next_at: Time.now - 3600).update!(enabled: false)
558
+
559
+ assert_nothing_raised do
560
+ Workhorse::Jobs::DetectLateSchedulesJob.new(threshold: 60).perform
561
+ end
562
+ end
563
+
564
+ # The expiry sweep is capped per poll, so the deadline has to be enforced by
565
+ # the job selection as well: otherwise a backlog larger than the cap leaves
566
+ # the surplus selectable and performed in the very same poll, past its
567
+ # deadline.
568
+ def test_a_backlog_larger_than_the_expiry_cap_is_still_not_performed
569
+ total = Workhorse::Poller::MAX_EXPIRIES_PER_POLL + 5
570
+
571
+ total.times { Workhorse.enqueue BasicJob.new(sleep_time: 0), expires_at: Time.now - 3600 }
572
+
573
+ work 2, polling_interval: 0.2, pool_size: 1
574
+
575
+ assert_equal 0, Workhorse::DbJob.succeeded.count, 'no job past its deadline may be performed'
576
+ assert_equal total, Workhorse::DbJob.expired.count
577
+ end
578
+
579
+ # Capped, and draining in deadline order so that "the rest is expired by
580
+ # the following polls" is not left to the optimiser.
581
+ def test_the_expiry_sweep_is_capped_per_poll_and_takes_the_oldest_first
582
+ surplus = 5
583
+ total = Workhorse::Poller::MAX_EXPIRIES_PER_POLL + surplus
584
+
585
+ total.times do |i|
586
+ Workhorse.enqueue BasicJob.new(sleep_time: 0), expires_at: Time.now - ((total - i) * 60)
587
+ end
588
+
589
+ newest = Workhorse::DbJob.order(:expires_at).last(surplus).map(&:id)
590
+
591
+ Workhorse::Worker.new(polling_interval: 60, pool_size: 1).poller.send(:expire_due_jobs)
592
+
593
+ assert_equal Workhorse::Poller::MAX_EXPIRIES_PER_POLL, Workhorse::DbJob.expired.count
594
+ assert_equal newest.sort, Workhorse::DbJob.waiting.pluck(:id).sort
595
+ end
596
+
597
+ def test_a_materialized_job_past_its_expiry_is_not_performed
598
+ expired = []
599
+ Workhorse.on_job_expired = proc { |db_job| expired << db_job.id }
600
+
601
+ define_schedule 'digest', cron: '0 3 * * *', expires_after: 60
602
+ Workhorse::Schedule.reconcile!
603
+ Workhorse::Schedule.sole.update!(next_at: yesterday_at_three)
604
+
605
+ work 2, polling_interval: 0.2, pool_size: 1
606
+
607
+ db_job = Workhorse::DbJob.sole
608
+
609
+ assert_equal 'expired', db_job.state
610
+ assert_nil db_job.started_at
611
+ assert_equal [db_job.id], expired
612
+ end
613
+
614
+ # The row that exists during every rolling deployment. Were the guard to
615
+ # regress, every poll would raise on nil and be swallowed.
616
+ def test_a_schedule_without_a_declaration_is_stepped_over
617
+ define_schedule 'known', cron: '0 3 * * *'
618
+ Workhorse::Schedule.reconcile!
619
+ Workhorse::Schedule.create!(key: 'orphan', cron: '0 3 * * *', next_at: yesterday_at_three)
620
+
621
+ exceptions = []
622
+ with_exception_handler ->(e) { exceptions << e } do
623
+ work 1, polling_interval: 0.2, pool_size: 1
624
+ end
625
+
626
+ assert_equal 0, Workhorse::DbJob.count
627
+ assert_empty exceptions
628
+ end
629
+
630
+ def test_a_permanently_broken_schedule_is_reported_once
631
+ define_schedule 'broken', cron: '0 3 * * *', job: 'ThisClassDoesNotExist'
632
+ Workhorse::Schedule.reconcile!
633
+ Workhorse::Schedule.sole.update!(next_at: yesterday_at_three)
634
+
635
+ exceptions = []
636
+ with_exception_handler ->(e) { exceptions << e } do
637
+ work 2, polling_interval: 0.2, pool_size: 1
638
+ end
639
+
640
+ assert_equal 1, exceptions.size, "expected one report, got #{exceptions.size}"
641
+ end
642
+
643
+ def test_max_catch_up_is_validated
644
+ assert_raises ArgumentError do
645
+ define_schedule 'bad', cron: '* * * * *', catch_up: :run, max_catch_up: nil
646
+ end
647
+
648
+ Workhorse::Schedules.reset!
649
+
650
+ assert_raises ArgumentError do
651
+ define_schedule 'bad', cron: '* * * * *', catch_up: :run, max_catch_up: 0
652
+ end
653
+ end
654
+
655
+ def test_an_occurrence_matching_now_is_recorded_without_its_fraction
656
+ now = Time.at((Time.now.to_i / 60) * 60).utc + 0.4
657
+ schedule = persisted_schedule('every_minute', cron: '* * * * *', next_at: now - 300)
658
+
659
+ occurrences, = schedule.pending_occurrences(now)
660
+
661
+ assert_equal 0, occurrences.last.usec
662
+ end
663
+
664
+ def test_occurrences_can_be_computed_during_the_fall_back_hour
665
+ previous_tz = ENV.fetch('TZ', nil)
666
+ # EtOrbi resolves a Time's zone from the process's, so the moment has to
667
+ # be built while that zone is the ambiguous one.
668
+ ENV['TZ'] = 'Europe/Zurich'
669
+
670
+ ambiguous = ActiveSupport::TimeZone['Europe/Zurich'].parse('2026-10-25T02:30:00+02:00').to_time
671
+ schedule = persisted_schedule(
672
+ 'nightly', cron: '0 3 * * *', timezone: 'Europe/Zurich', next_at: ambiguous - 3600
673
+ )
674
+
675
+ assert_nothing_raised do
676
+ schedule.pending_occurrences(ambiguous)
677
+ end
678
+ ensure
679
+ ENV['TZ'] = previous_tz
680
+ end
681
+
682
+ def test_nothing_due_leaves_the_next_occurrence_where_it_is
683
+ future = Time.now + 3600
684
+ schedule = persisted_schedule('nightly', cron: '0 3 * * *', next_at: future)
685
+
686
+ occurrences, next_at = schedule.pending_occurrences(Time.now)
687
+
688
+ assert_empty occurrences
689
+ assert_equal schedule.next_at, next_at, 'the schedule must not be rewound'
690
+ end
691
+
692
+ def test_cleanup_removes_succeeded_and_expired_jobs_by_default
693
+ old = Time.now - (30 * 24 * 60 * 60)
694
+ kept = []
695
+ removed = []
696
+
697
+ { 'succeeded' => removed, 'expired' => removed, 'failed' => kept }.each do |state, bucket|
698
+ job = Workhorse.enqueue BasicJob.new(sleep_time: 0)
699
+ job.update_columns(state: state, updated_at: old)
700
+ bucket << job.id
701
+ end
702
+
703
+ recent = Workhorse.enqueue BasicJob.new(sleep_time: 0)
704
+ recent.update_columns(state: 'succeeded', updated_at: Time.now)
705
+ kept << recent.id
706
+
707
+ Workhorse::Jobs::CleanupSucceededJobs.new.perform
708
+
709
+ assert_equal kept.sort, Workhorse::DbJob.pluck(:id).sort
710
+ end
711
+
712
+ def test_cleanup_can_be_restricted_to_succeeded_jobs
713
+ old = Time.now - (30 * 24 * 60 * 60)
714
+ expired = Workhorse.enqueue BasicJob.new(sleep_time: 0)
715
+ succeeded = Workhorse.enqueue BasicJob.new(sleep_time: 0)
716
+ expired.update_columns(state: 'expired', updated_at: old)
717
+ succeeded.update_columns(state: 'succeeded', updated_at: old)
718
+
719
+ Workhorse::Jobs::CleanupSucceededJobs.new(states: [Workhorse::DbJob::STATE_SUCCEEDED]).perform
720
+
721
+ assert_equal [expired.id], Workhorse::DbJob.pluck(:id)
722
+ end
723
+
724
+ def test_cleanup_enqueued_before_the_upgrade_still_deletes
725
+ old = Time.now - (30 * 24 * 60 * 60)
726
+ job = Workhorse.enqueue BasicJob.new(sleep_time: 0)
727
+ job.update_columns(state: 'succeeded', updated_at: old)
728
+
729
+ cleanup = Workhorse::Jobs::CleanupSucceededJobs.new
730
+ cleanup.remove_instance_variable(:@states)
731
+ cleanup.perform
732
+
733
+ assert_equal 0, Workhorse::DbJob.count
734
+ end
735
+
736
+ # The guards that let an installation upgrade the gem before the migration.
737
+ def test_a_worker_runs_normally_without_the_expiry_columns
738
+ without_expiry_columns do
739
+ job = Workhorse.enqueue BasicJob.new(sleep_time: 0)
740
+
741
+ work 2, polling_interval: 0.2, pool_size: 1
742
+
743
+ assert_equal 'succeeded', job.reload.state
744
+ end
745
+ end
746
+
747
+ def test_detect_late_schedules_can_be_restricted_to_keys
748
+ persisted_schedule('checked', cron: '0 3 * * *', next_at: Time.now - 3600)
749
+ persisted_schedule('ignored', cron: '0 4 * * *', next_at: Time.now - 3600)
750
+
751
+ error = assert_raises RuntimeError do
752
+ Workhorse::Jobs::DetectLateSchedulesJob.new(threshold: 60, keys: ['checked']).perform
753
+ end
754
+
755
+ assert_match(/"checked"/, error.message)
756
+ refute_match(/ignored/, error.message)
757
+ end
758
+
759
+ # Two workers starting together both insert the same schedule; the loser of
760
+ # the race must carry on rather than fail its whole reconciliation.
761
+ def test_reconcile_tolerates_a_concurrent_insert
762
+ define_schedule 'nightly', cron: '0 3 * * *'
763
+ define_schedule 'other', cron: '0 4 * * *'
764
+
765
+ Workhorse::Schedule.create!(key: 'nightly', cron: '0 3 * * *', next_at: Time.now + 3600)
766
+
767
+ # As it looks to a worker whose SELECT ran before the other's INSERT.
768
+ with_return_value(Workhorse::Schedule, :find_by, nil) do
769
+ assert_nothing_raised { Workhorse::Schedule.reconcile! }
770
+ end
771
+
772
+ assert_equal %w[nightly other], Workhorse::Schedule.order(:key).pluck(:key)
773
+ end
774
+
775
+ # The clocks go back on 2026-10-25 in this zone, so 02:30 happens twice in
776
+ # wall-clock terms. The schedule must still produce one occurrence that day.
777
+ def test_daylight_saving_fall_back_does_not_repeat_an_occurrence
778
+ previous_tz = ENV.fetch('TZ', nil)
779
+ ENV['TZ'] = 'Europe/Zurich'
780
+ zone = ActiveSupport::TimeZone['Europe/Zurich']
781
+ schedule = persisted_schedule(
782
+ 'nightly', cron: '30 2 * * *', timezone: 'Europe/Zurich', catch_up: :run,
783
+ next_at: zone.parse('2026-10-24T02:30:00').to_time
784
+ )
785
+
786
+ occurrences, = schedule.pending_occurrences(zone.parse('2026-10-26T12:00:00').to_time)
787
+ days = occurrences.map { |o| o.in_time_zone(zone).day }
788
+
789
+ assert_equal [24, 25, 26], days
790
+ assert_equal days.uniq, days, 'no day may produce two occurrences'
791
+ ensure
792
+ ENV['TZ'] = previous_tz
793
+ end
794
+
795
+ # The default policy takes one occurrence per call, so the two instants of
796
+ # the repeated hour arrive in separate calls where the within-call
797
+ # deduplication cannot see them.
798
+ def test_daylight_saving_fall_back_does_not_repeat_for_the_default_policy
799
+ zone = ActiveSupport::TimeZone['Europe/Zurich']
800
+ schedule = persisted_schedule(
801
+ 'nightly', cron: '30 2 * * *', timezone: 'Europe/Zurich',
802
+ next_at: zone.parse('2026-10-24T02:30:00').to_time
803
+ )
804
+
805
+ fired = step_through(schedule, until_after: zone.parse('2026-10-27T00:00:00').to_time)
806
+ days = fired.map { |o| o.in_time_zone(zone).day }
807
+
808
+ assert_equal [24, 25, 26], days
809
+ end
810
+
811
+ # An hour holding several occurrences replays all of them, which a single
812
+ # step over the repeat does not cover.
813
+ def test_daylight_saving_fall_back_with_several_occurrences_in_the_hour
814
+ previous_tz = ENV.fetch('TZ', nil)
815
+ ENV['TZ'] = 'Europe/Zurich'
816
+ zone = ActiveSupport::TimeZone['Europe/Zurich']
817
+ schedule = persisted_schedule(
818
+ 'twice', cron: '0,30 2 * * *', timezone: 'Europe/Zurich',
819
+ next_at: zone.parse('2026-10-25T02:00:00').to_time
820
+ )
821
+
822
+ fired = step_through(schedule, until_after: zone.parse('2026-10-25T23:00:00').to_time)
823
+ locals = fired.map { |o| o.in_time_zone(zone).strftime('%H:%M %Z') }
824
+
825
+ assert_equal ['02:00 CEST', '02:30 CEST'], locals
826
+ ensure
827
+ ENV['TZ'] = previous_tz
828
+ end
829
+
830
+ # The zone may be given as a trailing token of the expression instead of as
831
+ # an option, and the wall-clock comparison has to read either.
832
+ def test_daylight_saving_fall_back_with_the_zone_inside_the_expression
833
+ previous_tz = ENV.fetch('TZ', nil)
834
+ ENV['TZ'] = 'UTC'
835
+ zone = ActiveSupport::TimeZone['Europe/Zurich']
836
+ schedule = persisted_schedule(
837
+ 'nightly', cron: '30 2 * * * Europe/Zurich',
838
+ next_at: zone.parse('2026-10-24T02:30:00').to_time
839
+ )
840
+
841
+ fired = step_through(schedule, until_after: zone.parse('2026-10-27T00:00:00').to_time)
842
+
843
+ assert_equal([24, 25, 26], fired.map { |o| o.in_time_zone(zone).day })
844
+ ensure
845
+ ENV['TZ'] = previous_tz
846
+ end
847
+
848
+ # An expression with a wildcard hour ticks on every real instant, so both
849
+ # halves of the repeated hour are genuine and must both be kept.
850
+ def test_daylight_saving_fall_back_keeps_both_ticks_of_an_hourly_schedule
851
+ zone = ActiveSupport::TimeZone['Europe/Zurich']
852
+ schedule = persisted_schedule(
853
+ 'hourly', cron: '30 * * * *', timezone: 'Europe/Zurich', catch_up: :run,
854
+ next_at: zone.parse('2026-10-25T00:30:00').to_time
855
+ )
856
+
857
+ occurrences, = schedule.pending_occurrences(zone.parse('2026-10-25T05:00:00').to_time)
858
+ locals = occurrences.map { |o| o.in_time_zone(zone).strftime('%H:%M %Z') }
859
+
860
+ repeated = locals.select { |l| l.start_with?('02:30') }
861
+
862
+ assert_equal ['02:30 CEST', '02:30 CET'], repeated
863
+ assert_equal 6, occurrences.size
864
+ end
865
+
866
+ def test_enqueue_uses_the_configured_queue_and_description
867
+ occurrence = Time.now.round
868
+ schedule = persisted_schedule(
869
+ 'nightly', cron: '0 3 * * *', next_at: occurrence,
870
+ queue: :reports, description: 'Nightly report'
871
+ )
872
+
873
+ db_job = schedule.enqueue!(occurrence)
874
+
875
+ assert_equal 'reports', db_job.queue
876
+ assert_equal 'Nightly report', db_job.description
877
+ end
878
+
879
+ # ActiveJob defaults its queue_name to "default", so a scheduled ActiveJob
880
+ # lands in a *named* queue even when the schedule names none - and named
881
+ # queues run one job at a time, unlike the nil queue a plain job gets.
882
+ def test_a_scheduled_active_job_lands_in_the_active_job_queue
883
+ occurrence = Time.now.round
884
+ aj = persisted_schedule('aj', cron: '0 3 * * *', next_at: occurrence, job: 'ScheduledActiveJob')
885
+ plain = persisted_schedule('plain', cron: '0 4 * * *', next_at: occurrence)
886
+
887
+ assert_equal 'default', aj.enqueue!(occurrence).queue
888
+ assert_nil plain.enqueue!(occurrence).queue
889
+ end
890
+
891
+ private
892
+
893
+ # Renamed rather than removed: dropping a column silently takes it out of
894
+ # the composite index too, and adding it back does not restore it, leaving
895
+ # every later test querying a differently indexed table.
896
+ def without_expiry_columns
897
+ connection = ActiveRecord::Base.connection
898
+ connection.rename_column :jobs, :expires_at, :expires_at_hidden
899
+ connection.rename_column :jobs, :max_lateness, :max_lateness_hidden
900
+ Workhorse::DbJob.reset_column_information
901
+ yield
902
+ ensure
903
+ connection.rename_column :jobs, :expires_at_hidden, :expires_at
904
+ connection.rename_column :jobs, :max_lateness_hidden, :max_lateness
905
+ Workhorse::DbJob.reset_column_information
906
+ end
907
+
908
+ # A real past occurrence of '0 3 * * *'. Seeding with an arbitrary past
909
+ # moment would yield no occurrences at all, as the walk stops at next_at.
910
+ def yesterday_at_three
911
+ return (Time.now - (24 * 60 * 60)).change(hour: 3)
912
+ end
913
+
914
+ def define_schedule(key, job: 'BasicJob', **options)
915
+ Workhorse.schedules do
916
+ schedule key, job: job, params: { sleep_time: 0 }, **options
917
+ end
918
+ end
919
+
920
+ # Registers a schedule and puts its row in place, bypassing reconciliation
921
+ # so that `next_at` can be set to whatever the case under test needs.
922
+ def persisted_schedule(key, cron:, next_at:, timezone: nil, **options)
923
+ define_schedule(key, cron: cron, timezone: timezone, **options)
924
+
925
+ return Workhorse::Schedule.create!(
926
+ key: key, cron: cron, timezone: timezone, next_at: next_at
927
+ )
928
+ end
929
+
930
+ # Hides the schedules table for the duration of the block, standing in for
931
+ # an installation that declares schedules but has not run the migration.
932
+ def with_renamed_schedules_table
933
+ connection = ActiveRecord::Base.connection
934
+ connection.rename_table :workhorse_schedules, :workhorse_schedules_hidden
935
+ Workhorse::Schedule.reset_column_information
936
+ yield
937
+ ensure
938
+ connection.rename_table :workhorse_schedules_hidden, :workhorse_schedules
939
+ Workhorse::Schedule.reset_column_information
940
+ end
941
+
942
+ # Materializes occurrence by occurrence the way a worker polling at each
943
+ # next_at does, rather than resolving a backlog in one call.
944
+ def step_through(schedule, until_after:)
945
+ fired = []
946
+ cursor = schedule.next_at
947
+
948
+ 40.times do
949
+ break if cursor > until_after
950
+
951
+ occurrences, next_at = schedule.pending_occurrences(cursor)
952
+ fired.concat(occurrences)
953
+ schedule.update!(next_at: next_at)
954
+ cursor = next_at
955
+ end
956
+
957
+ return fired
958
+ end
959
+
960
+ def with_exception_handler(handler)
961
+ previous = Workhorse.on_exception
962
+ Workhorse.on_exception = handler
963
+ yield
964
+ ensure
965
+ Workhorse.on_exception = previous
966
+ end
967
+ end