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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +102 -0
- data/README.md +303 -74
- data/Rakefile +1 -0
- data/VERSION +1 -1
- data/bin/rubocop +5 -1
- data/lib/generators/workhorse/install_generator.rb +10 -1
- data/lib/generators/workhorse/templates/config/initializers/workhorse.rb +55 -0
- data/lib/generators/workhorse/templates/create_table_jobs.rb +11 -13
- data/lib/generators/workhorse/templates/create_table_workhorse_schedules.rb +29 -0
- data/lib/workhorse/daemon.rb +52 -6
- data/lib/workhorse/db_job.rb +58 -7
- data/lib/workhorse/enqueuer.rb +51 -8
- data/lib/workhorse/jobs/cleanup_succeeded_jobs.rb +26 -8
- data/lib/workhorse/jobs/detect_late_schedules_job.rb +59 -0
- data/lib/workhorse/notifiers/base.rb +55 -0
- data/lib/workhorse/notifiers/file_system.rb +66 -0
- data/lib/workhorse/notifiers/none.rb +8 -0
- data/lib/workhorse/notifiers/redis.rb +227 -0
- data/lib/workhorse/performer.rb +28 -0
- data/lib/workhorse/poller.rb +289 -47
- data/lib/workhorse/pool.rb +12 -6
- data/lib/workhorse/schedule.rb +288 -0
- data/lib/workhorse/schedules.rb +197 -0
- data/lib/workhorse/worker.rb +77 -27
- data/lib/workhorse.rb +136 -0
- data/test/lib/db_schema.rb +21 -1
- data/test/lib/jobs.rb +29 -0
- data/test/lib/test_helper.rb +9 -14
- data/test/workhorse/daemon_test.rb +33 -0
- data/test/workhorse/db_job_test.rb +1 -1
- data/test/workhorse/notifier_test.rb +500 -0
- data/test/workhorse/poller_test.rb +8 -4
- data/test/workhorse/schedule_test.rb +967 -0
- data/test/workhorse/worker_test.rb +92 -0
- data/workhorse.gemspec +6 -5
- 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
|