riverqueue 0.12.0 → 0.14.0
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 +80 -4
- data/README.md +91 -0
- data/docs/README.md +213 -36
- data/docs/conformance.md +72 -0
- data/docs/migrations.md +20 -23
- data/docs/testing.md +1 -1
- data/lib/client.rb +103 -15
- data/lib/client_runtime.rb +434 -87
- data/lib/config.rb +61 -9
- data/lib/driver/job_row_decoder.rb +2 -2
- data/lib/driver/notification_listener.rb +61 -0
- data/lib/driver/postgres_capabilities.rb +1 -1
- data/lib/driver/runtime.rb +419 -146
- data/lib/driver.rb +1 -0
- data/lib/event.rb +8 -1
- data/lib/insert_opts.rb +8 -3
- data/lib/job.rb +5 -5
- data/lib/job_persisted_logging/plugin.rb +3 -2
- data/lib/migrator.rb +1 -1
- data/lib/periodic_cron.rb +42 -6
- data/lib/periodic_job.rb +23 -4
- data/lib/resumable.rb +24 -1
- data/lib/worker.rb +32 -2
- data/migration/README.md +4 -2
- data/migration/manifest.json +6 -7
- data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +1 -1
- data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +1 -1
- data/migration/postgresql/main/008_job_id_autoincrement.down.sql +1 -1
- data/migration/postgresql/main/008_job_id_autoincrement.up.sql +1 -1
- data/migration/sqlite/main/008_job_id_autoincrement.up.sql +1 -1
- data/sig/client.rbs +9 -3
- data/sig/driver.rbs +46 -9
- data/sig/insert_opts.rbs +2 -2
- data/sig/job.rbs +2 -0
- data/sig/runtime.rbs +76 -8
- metadata +9 -6
data/lib/client_runtime.rb
CHANGED
|
@@ -6,6 +6,21 @@ module River
|
|
|
6
6
|
class ClientRuntime
|
|
7
7
|
class Interrupted < StandardError; end
|
|
8
8
|
|
|
9
|
+
# Keep wall time for queue latency and error timestamps, and monotonic time
|
|
10
|
+
# for execution and completion durations. Externally executed jobs start
|
|
11
|
+
# with no locally observed execution time. Snapshot queue latency before
|
|
12
|
+
# retries, snoozes, or application code change the job's schedule.
|
|
13
|
+
class JobTiming
|
|
14
|
+
attr_accessor :finished_monotonic
|
|
15
|
+
attr_reader :queue_wait_duration, :started_at, :started_monotonic
|
|
16
|
+
|
|
17
|
+
def initialize(started_at, started_monotonic, scheduled_at)
|
|
18
|
+
@queue_wait_duration = started_at - scheduled_at
|
|
19
|
+
@started_at = started_at
|
|
20
|
+
@finished_monotonic = @started_monotonic = started_monotonic
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
9
24
|
attr_reader :periodic_jobs
|
|
10
25
|
|
|
11
26
|
def initialize(client, driver, config)
|
|
@@ -13,27 +28,54 @@ module River
|
|
|
13
28
|
@condition = ConditionVariable.new
|
|
14
29
|
@config = config
|
|
15
30
|
@driver = driver
|
|
31
|
+
@fetching_queues = {}
|
|
32
|
+
@leader = false
|
|
33
|
+
@maintenance_condition = ConditionVariable.new
|
|
34
|
+
@maintenance_generation = 0
|
|
16
35
|
@mutex = Mutex.new
|
|
17
|
-
@
|
|
36
|
+
@notifications_disabled = false
|
|
18
37
|
@producer_threads = {}
|
|
19
38
|
@queue_configs = config.queues.dup
|
|
39
|
+
@queue_generations = Hash.new(0)
|
|
40
|
+
@queue_paused = {}
|
|
20
41
|
@removed_queues = {}
|
|
42
|
+
@resign_requested = false
|
|
21
43
|
@running = {}
|
|
22
44
|
@started = false
|
|
23
45
|
@stopped = true
|
|
24
46
|
@subscriptions = []
|
|
25
47
|
@threads = []
|
|
48
|
+
@periodic_jobs = PeriodicJobBundle.new(config.periodic_jobs,
|
|
49
|
+
enabled: !config.leader_election_disabled, wake: method(:periodic_jobs_changed))
|
|
26
50
|
end
|
|
27
51
|
|
|
28
52
|
def finish_claimed(row, error = nil)
|
|
29
|
-
|
|
53
|
+
timing = JobTiming.new(row.attempted_at || Time.now.utc, monotonic_now, row.scheduled_at)
|
|
30
54
|
job = Job.new(@client, row)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
else
|
|
55
|
+
case error
|
|
56
|
+
when nil
|
|
34
57
|
now = Time.now.utc
|
|
35
|
-
completed = @driver.
|
|
36
|
-
|
|
58
|
+
completed = @driver.job_complete(id: row.id, finalized_at: now, now: now)
|
|
59
|
+
case completed
|
|
60
|
+
when :cancelled
|
|
61
|
+
finish_failed(row, job, JobCancelError.new, timing, cancelled: true)
|
|
62
|
+
else
|
|
63
|
+
publish(EVENT_JOB_COMPLETED, completed, timing) if completed
|
|
64
|
+
end
|
|
65
|
+
when JobCancelError
|
|
66
|
+
finish_failed(row, job, error, timing, cancelled: true)
|
|
67
|
+
when JobSnoozeError
|
|
68
|
+
finish_snoozed(row, job, error, timing)
|
|
69
|
+
when Interrupted
|
|
70
|
+
finish_interrupted(row, job, timing)
|
|
71
|
+
else
|
|
72
|
+
worker = begin
|
|
73
|
+
resolve_worker(row.kind) if !row.__decode_error && @config.workers.include?(row.kind)
|
|
74
|
+
rescue => worker_error
|
|
75
|
+
@config.logger.error("River worker initialization failed during finalization: #{worker_error.full_message}")
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
finish_failed(row, job, error, timing, worker: worker)
|
|
37
79
|
end
|
|
38
80
|
end
|
|
39
81
|
|
|
@@ -64,12 +106,21 @@ module River
|
|
|
64
106
|
|
|
65
107
|
def publish_queue(kind, queue)
|
|
66
108
|
event = Event.new(kind, nil, queue, nil)
|
|
67
|
-
@mutex.synchronize
|
|
109
|
+
subscriptions = @mutex.synchronize do
|
|
110
|
+
paused = kind == EVENT_QUEUE_PAUSED
|
|
111
|
+
return if @queue_paused[queue.name] == paused
|
|
112
|
+
|
|
113
|
+
@queue_paused[queue.name] = paused
|
|
114
|
+
@subscriptions.dup
|
|
115
|
+
end
|
|
116
|
+
subscriptions.each { |subscription| subscription.publish(event) }
|
|
68
117
|
end
|
|
69
118
|
|
|
70
119
|
def healthy?
|
|
71
120
|
@mutex.synchronize do
|
|
72
|
-
@stop_requested || (@producer_threads.all? { |name, thread| @removed_queues[name] || thread.alive? } &&
|
|
121
|
+
@stop_requested || (@producer_threads.all? { |name, thread| @removed_queues[name] || thread.alive? } &&
|
|
122
|
+
(!@maintenance_thread || @maintenance_thread.alive?) &&
|
|
123
|
+
(!@notification_thread || @notifications_disabled || @notification_thread.alive?))
|
|
73
124
|
end
|
|
74
125
|
end
|
|
75
126
|
|
|
@@ -115,6 +166,9 @@ module River
|
|
|
115
166
|
name = name.to_s
|
|
116
167
|
producer = @mutex.synchronize do
|
|
117
168
|
raise NotFoundError, "queue is not configured: #{name}" unless @queue_configs.key?(name)
|
|
169
|
+
if @producer_threads[name] == Thread.current || @running.any? { |_id, entry| entry[:queue] == name && entry[:thread] == Thread.current }
|
|
170
|
+
raise ThreadError, "cannot remove a queue from its own worker or producer"
|
|
171
|
+
end
|
|
118
172
|
|
|
119
173
|
@removed_queues[name] = true
|
|
120
174
|
@condition.broadcast
|
|
@@ -146,9 +200,10 @@ module River
|
|
|
146
200
|
end
|
|
147
201
|
|
|
148
202
|
begin
|
|
203
|
+
start_notifications
|
|
149
204
|
queues = @mutex.synchronize { @queue_configs.dup }
|
|
150
205
|
queues.each { |name, queue_config| start_producer(name, queue_config) }
|
|
151
|
-
start_maintenance unless @queue_configs.empty?
|
|
206
|
+
start_maintenance unless @queue_configs.empty? && @periodic_jobs.empty? && @config.maintenance_services.empty?
|
|
152
207
|
|
|
153
208
|
self
|
|
154
209
|
rescue
|
|
@@ -164,9 +219,13 @@ module River
|
|
|
164
219
|
def stop(cancel: false, wait: true)
|
|
165
220
|
threads = @mutex.synchronize do
|
|
166
221
|
return self if @stopped
|
|
222
|
+
if wait && (@threads.include?(Thread.current) || @running.any? { |_id, entry| entry[:thread] == Thread.current })
|
|
223
|
+
raise ThreadError, "cannot wait for client stop from its own runtime thread; use wait: false"
|
|
224
|
+
end
|
|
167
225
|
|
|
168
226
|
@stop_requested = true
|
|
169
227
|
@condition.broadcast
|
|
228
|
+
@maintenance_condition.broadcast
|
|
170
229
|
@running.values.each { |entry| entry[:thread].raise(Interrupted) if cancel && entry[:working] }
|
|
171
230
|
@threads.dup
|
|
172
231
|
end
|
|
@@ -185,6 +244,9 @@ module River
|
|
|
185
244
|
@threads.clear
|
|
186
245
|
@producer_threads.clear
|
|
187
246
|
@maintenance_thread = nil
|
|
247
|
+
@notification_thread = nil
|
|
248
|
+
@leader = false
|
|
249
|
+
@resign_requested = false
|
|
188
250
|
end
|
|
189
251
|
|
|
190
252
|
self
|
|
@@ -205,21 +267,23 @@ module River
|
|
|
205
267
|
end
|
|
206
268
|
|
|
207
269
|
def wake
|
|
208
|
-
|
|
270
|
+
wake_queue("*")
|
|
209
271
|
end
|
|
210
272
|
|
|
211
273
|
private def begin_work(id)
|
|
212
|
-
|
|
274
|
+
interruption = @mutex.synchronize do
|
|
213
275
|
entry = @running.fetch(id)
|
|
214
276
|
if @stop_requested
|
|
215
|
-
|
|
277
|
+
Interrupted
|
|
278
|
+
elsif entry[:cancelled]
|
|
279
|
+
JobCancelError
|
|
216
280
|
else
|
|
217
281
|
entry[:working] = true
|
|
218
|
-
|
|
282
|
+
nil
|
|
219
283
|
end
|
|
220
284
|
end
|
|
221
285
|
|
|
222
|
-
raise
|
|
286
|
+
raise interruption if interruption
|
|
223
287
|
end
|
|
224
288
|
|
|
225
289
|
private def check_remote_cancellations(queue)
|
|
@@ -246,79 +310,74 @@ module River
|
|
|
246
310
|
end
|
|
247
311
|
|
|
248
312
|
private def execute(row)
|
|
249
|
-
|
|
313
|
+
timing = JobTiming.new(Time.now.utc, monotonic_now, row.scheduled_at)
|
|
250
314
|
job = Job.new(@client, row)
|
|
315
|
+
worker = nil #: untyped
|
|
251
316
|
begin
|
|
252
|
-
raise row.__decode_error if row.__decode_error
|
|
253
|
-
|
|
254
|
-
worker = resolve_worker(row.kind)
|
|
255
|
-
begin_work(row.id)
|
|
256
317
|
begin
|
|
318
|
+
raise row.__decode_error if row.__decode_error
|
|
319
|
+
|
|
320
|
+
worker = resolve_worker(row.kind)
|
|
321
|
+
begin_work(row.id)
|
|
257
322
|
Thread.handle_interrupt(Interrupted => :immediate, JobCancelError => :immediate) do
|
|
258
323
|
invoke_worker(worker, job)
|
|
259
324
|
end
|
|
260
325
|
ensure
|
|
326
|
+
timing.finished_monotonic = monotonic_now
|
|
261
327
|
finish_work(row.id)
|
|
262
328
|
end
|
|
263
329
|
|
|
330
|
+
return [:completed, nil] if finish_transactional_completion(job, timing)
|
|
331
|
+
|
|
264
332
|
finalize_hooks = @config.plugins.any? { |plugin| plugin.respond_to?(:job_finalize) }
|
|
265
333
|
if finalize_hooks
|
|
266
334
|
raise JobCancelError if @driver.job_get_cancelled_ids([row.id]).include?(row.id)
|
|
267
335
|
|
|
268
336
|
if invoke_plugins(:job_finalize, job, JOB_STATE_COMPLETED).include?(:delete)
|
|
269
|
-
@driver.job_delete_if_running(row.id)
|
|
337
|
+
raise JobCancelError if @driver.job_delete_if_running(row.id) == :cancelled
|
|
338
|
+
|
|
270
339
|
return [:deleted, nil]
|
|
271
340
|
end
|
|
272
341
|
end
|
|
273
342
|
|
|
274
343
|
completed_at = Time.now.utc
|
|
275
|
-
completed =
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
case result
|
|
280
|
-
when :cancelled then raise JobCancelError
|
|
281
|
-
else result
|
|
282
|
-
end
|
|
344
|
+
completed = @driver.job_complete(id: row.id, finalized_at: completed_at, metadata: job.metadata_updates, now: completed_at)
|
|
345
|
+
case completed
|
|
346
|
+
when :cancelled then raise JobCancelError
|
|
347
|
+
else publish(EVENT_JOB_COMPLETED, completed, timing) if completed
|
|
283
348
|
end
|
|
284
|
-
publish(EVENT_JOB_COMPLETED, completed, started_at) if completed
|
|
285
349
|
|
|
286
350
|
[:completed, nil]
|
|
287
|
-
rescue JobSnoozeError => error
|
|
288
|
-
job.__capture_resumable_metadata!
|
|
289
|
-
[finish_snoozed(row, job, error, started_at), error]
|
|
290
|
-
rescue JobCancelError => error
|
|
291
|
-
job.__capture_resumable_metadata!
|
|
292
|
-
finish_failed(row, job, error, started_at, cancelled: true)
|
|
293
|
-
[:cancelled, error]
|
|
294
|
-
rescue Interrupted => error
|
|
295
|
-
job.__capture_resumable_metadata!
|
|
296
|
-
interrupted = @driver.job_set_state_if_running(
|
|
297
|
-
id: row.id,
|
|
298
|
-
attempt: [row.attempt - 1, 0].max,
|
|
299
|
-
metadata: job.metadata_updates,
|
|
300
|
-
scheduled_at: Time.now.utc,
|
|
301
|
-
state: JOB_STATE_AVAILABLE
|
|
302
|
-
)
|
|
303
|
-
publish(EVENT_JOB_INTERRUPTED, interrupted, started_at) if interrupted
|
|
304
|
-
|
|
305
|
-
[(interrupted&.state == JOB_STATE_CANCELLED) ? :cancelled : :interrupted, error]
|
|
306
351
|
rescue => error
|
|
352
|
+
return [:completed, nil] if finish_transactional_completion(job, timing)
|
|
353
|
+
|
|
307
354
|
job.__capture_resumable_metadata!
|
|
308
|
-
|
|
355
|
+
outcome = case error
|
|
356
|
+
when JobSnoozeError
|
|
357
|
+
finish_snoozed(row, job, error, timing)
|
|
358
|
+
when JobCancelError
|
|
359
|
+
finish_failed(row, job, error, timing, cancelled: true)
|
|
360
|
+
:cancelled
|
|
361
|
+
when Interrupted
|
|
362
|
+
finish_interrupted(row, job, timing)
|
|
363
|
+
else
|
|
364
|
+
finish_failed(row, job, error, timing, worker: worker)
|
|
365
|
+
end
|
|
366
|
+
[outcome, error]
|
|
309
367
|
ensure
|
|
310
368
|
@mutex.synchronize do
|
|
311
369
|
@running.delete(row.id)
|
|
370
|
+
@queue_generations[row.queue] += 1
|
|
312
371
|
@condition.broadcast
|
|
313
372
|
end
|
|
314
373
|
end
|
|
315
374
|
end
|
|
316
375
|
|
|
317
|
-
private def finish_failed(row, job, error,
|
|
376
|
+
private def finish_failed(row, job, error, timing, cancelled: false, worker: nil)
|
|
318
377
|
cancelled ||= error_handler_cancel?(error, job)
|
|
319
378
|
now = Time.now.utc
|
|
320
379
|
attempt_error = AttemptError.new(
|
|
321
|
-
at: started_at,
|
|
380
|
+
at: timing.started_at,
|
|
322
381
|
attempt: row.attempt,
|
|
323
382
|
error: error.message,
|
|
324
383
|
trace: Array(error.backtrace).join("\n")
|
|
@@ -344,7 +403,7 @@ module River
|
|
|
344
403
|
state: state
|
|
345
404
|
)
|
|
346
405
|
event = cancelled ? EVENT_JOB_CANCELLED : EVENT_JOB_FAILED
|
|
347
|
-
publish(event, updated,
|
|
406
|
+
publish(event, updated, timing) if updated
|
|
348
407
|
|
|
349
408
|
case updated&.state || state
|
|
350
409
|
when JOB_STATE_CANCELLED then :cancelled
|
|
@@ -353,10 +412,23 @@ module River
|
|
|
353
412
|
end
|
|
354
413
|
end
|
|
355
414
|
|
|
356
|
-
private def
|
|
415
|
+
private def finish_interrupted(row, job, timing)
|
|
416
|
+
interrupted = @driver.job_set_state_if_running(
|
|
417
|
+
id: row.id,
|
|
418
|
+
attempt: [row.attempt - 1, 0].max,
|
|
419
|
+
metadata: job.metadata_updates,
|
|
420
|
+
scheduled_at: Time.now.utc,
|
|
421
|
+
state: JOB_STATE_AVAILABLE
|
|
422
|
+
)
|
|
423
|
+
publish(EVENT_JOB_INTERRUPTED, interrupted, timing) if interrupted
|
|
424
|
+
|
|
425
|
+
(interrupted&.state == JOB_STATE_CANCELLED) ? :cancelled : :interrupted
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
private def finish_snoozed(row, job, error, timing)
|
|
357
429
|
scheduled_at = Time.now.utc + error.duration
|
|
358
430
|
state = (error.duration <= 5) ? JOB_STATE_AVAILABLE : JOB_STATE_SCHEDULED
|
|
359
|
-
metadata = job.metadata_updates.merge("snoozes" => row.metadata
|
|
431
|
+
metadata = job.metadata_updates.merge("snoozes" => next_snooze_count(row.metadata["snoozes"]))
|
|
360
432
|
updated = @driver.job_set_state_if_running(
|
|
361
433
|
id: row.id,
|
|
362
434
|
attempt: [row.attempt - 1, 0].max,
|
|
@@ -364,10 +436,39 @@ module River
|
|
|
364
436
|
scheduled_at: scheduled_at,
|
|
365
437
|
state: state
|
|
366
438
|
)
|
|
367
|
-
publish(EVENT_JOB_SNOOZED, updated,
|
|
439
|
+
publish(EVENT_JOB_SNOOZED, updated, timing) if updated
|
|
368
440
|
(updated&.state == JOB_STATE_CANCELLED) ? :cancelled : :snoozed
|
|
369
441
|
end
|
|
370
442
|
|
|
443
|
+
private def finish_transactional_completion(job, timing)
|
|
444
|
+
return false unless job.__completion_attempted
|
|
445
|
+
|
|
446
|
+
completed = @driver.job_get_by_id(job.row.id)
|
|
447
|
+
unless completed
|
|
448
|
+
snapshot = job.__completion_snapshot
|
|
449
|
+
return false unless snapshot
|
|
450
|
+
|
|
451
|
+
publish(EVENT_JOB_COMPLETED, snapshot, timing)
|
|
452
|
+
return true
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# A retry can replace a committed attempt before its worker or middleware
|
|
456
|
+
# returns. Only a still-running original attempt may have rolled back and
|
|
457
|
+
# need normal finalization; never finalize or publish a replacement.
|
|
458
|
+
return true unless completed.attempt == job.row.attempt && completed.attempted_at == job.row.attempted_at
|
|
459
|
+
return false if completed.state == JOB_STATE_RUNNING
|
|
460
|
+
return true unless completed.state == JOB_STATE_COMPLETED
|
|
461
|
+
|
|
462
|
+
# Work middleware (including persisted logging) can add metadata after the
|
|
463
|
+
# worker commits. Save it without changing the committed state or timestamp.
|
|
464
|
+
# Guard the write too: a retry can happen after the read above.
|
|
465
|
+
metadata = job.metadata_updates
|
|
466
|
+
completed = @driver.job_metadata_merge(job.row.id, metadata, completed) || completed unless metadata.empty?
|
|
467
|
+
|
|
468
|
+
publish(EVENT_JOB_COMPLETED, completed, timing)
|
|
469
|
+
true
|
|
470
|
+
end
|
|
471
|
+
|
|
371
472
|
private def finish_work(id)
|
|
372
473
|
@mutex.synchronize do
|
|
373
474
|
entry = @running[id]
|
|
@@ -383,15 +484,22 @@ module River
|
|
|
383
484
|
return perform_work(worker, job) if @config.plugins.empty?
|
|
384
485
|
|
|
385
486
|
operation = -> do
|
|
487
|
+
invoke_plugins(:work_begin, job)
|
|
488
|
+
|
|
386
489
|
error = nil
|
|
387
|
-
begin
|
|
388
|
-
invoke_plugins(:work_begin, job)
|
|
490
|
+
result = begin
|
|
389
491
|
perform_work(worker, job)
|
|
390
|
-
rescue =>
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
492
|
+
rescue => work_error
|
|
493
|
+
error = work_error
|
|
494
|
+
nil
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
@config.plugins.each do |plugin|
|
|
498
|
+
error = plugin.work_end(job, error) if plugin.respond_to?(:work_end)
|
|
394
499
|
end
|
|
500
|
+
raise error if error
|
|
501
|
+
|
|
502
|
+
result
|
|
395
503
|
end
|
|
396
504
|
@config.plugins.reverse_each do |plugin|
|
|
397
505
|
next unless plugin.respond_to?(:work)
|
|
@@ -415,26 +523,54 @@ module River
|
|
|
415
523
|
end
|
|
416
524
|
end
|
|
417
525
|
|
|
418
|
-
@mutex.synchronize
|
|
526
|
+
@mutex.synchronize do
|
|
527
|
+
@running[row.id] = {cancelled: @fetching_queues.fetch(row.queue).include?(row.id), queue: row.queue, thread: thread, working: false}
|
|
528
|
+
end
|
|
419
529
|
gate.push(true)
|
|
420
530
|
end
|
|
421
531
|
|
|
422
532
|
private def maintenance_loop
|
|
423
533
|
leader = false
|
|
534
|
+
next_election = 0.0
|
|
424
535
|
next_schedule = next_rescue = next_cleanup = Time.at(0)
|
|
425
536
|
until stopping?
|
|
537
|
+
generation, resign = @mutex.synchronize { [@maintenance_generation, @resign_requested] }
|
|
538
|
+
if resign && leader
|
|
539
|
+
@driver.leader_release(@config.id)
|
|
540
|
+
leader = false
|
|
541
|
+
@mutex.synchronize do
|
|
542
|
+
@leader = false
|
|
543
|
+
@resign_requested = false
|
|
544
|
+
end
|
|
545
|
+
# Give followers a chance to acquire the term we just released.
|
|
546
|
+
next_election = monotonic_now + 5
|
|
547
|
+
end
|
|
548
|
+
if !leader && (delay = next_election - monotonic_now).positive?
|
|
549
|
+
wait_for_maintenance(delay, generation)
|
|
550
|
+
next
|
|
551
|
+
end
|
|
552
|
+
|
|
426
553
|
now = Time.now.utc
|
|
427
554
|
leader = leader ? @driver.leader_renew(@config.id, now: now) : @driver.leader_acquire(@config.id, now: now)
|
|
555
|
+
@mutex.synchronize do
|
|
556
|
+
@leader = leader
|
|
557
|
+
@resign_requested = false unless leader
|
|
558
|
+
end
|
|
428
559
|
if leader
|
|
429
560
|
if now >= next_schedule
|
|
430
561
|
@driver.job_schedule(now: now)
|
|
431
562
|
run_periodic(now)
|
|
432
|
-
@config.maintenance_services.each
|
|
563
|
+
@config.maintenance_services.each do |service|
|
|
564
|
+
service.run(@client, @driver, now)
|
|
565
|
+
rescue => error
|
|
566
|
+
@config.logger.error("River maintenance service failed (#{service.class}): #{error.full_message}")
|
|
567
|
+
end
|
|
433
568
|
next_schedule = now + 5
|
|
434
569
|
end
|
|
435
570
|
|
|
436
571
|
if now >= next_rescue
|
|
437
|
-
@driver.job_rescue_stuck(horizon: now - 3_600,
|
|
572
|
+
@driver.job_rescue_stuck(horizon: now - 3_600, logger: @config.logger, now: now,
|
|
573
|
+
rescue_if: method(:rescue_job?), retry_policy: @config.retry_policy)
|
|
438
574
|
next_rescue = now + 30
|
|
439
575
|
end
|
|
440
576
|
|
|
@@ -449,7 +585,7 @@ module River
|
|
|
449
585
|
end
|
|
450
586
|
end
|
|
451
587
|
|
|
452
|
-
|
|
588
|
+
wait_for_maintenance(5, generation)
|
|
453
589
|
end
|
|
454
590
|
rescue => error
|
|
455
591
|
@config.logger.error("River maintenance stopped: #{error.full_message}")
|
|
@@ -474,17 +610,82 @@ module River
|
|
|
474
610
|
DefaultClientRetryPolicy.new.next_retry(row, error, now: now)
|
|
475
611
|
end
|
|
476
612
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
613
|
+
# Preserve Ruby's lenient counter conversion without calling to_i on
|
|
614
|
+
# booleans/collections, or accepting only a prefix of a numeric string.
|
|
615
|
+
# Only canonical non-negative integers are part of the shared protocol.
|
|
616
|
+
private def next_snooze_count(value)
|
|
617
|
+
count = case value
|
|
618
|
+
when Integer then value
|
|
619
|
+
when Float then value.finite? ? value.to_i : 0
|
|
620
|
+
when String then /\A-?[0-9]+\z/.match?(value) ? value.to_i : 0
|
|
621
|
+
when true then 1
|
|
622
|
+
else 0
|
|
623
|
+
end
|
|
624
|
+
# Go stores and increments a signed 64-bit integer.
|
|
625
|
+
((count + 1 + (1 << 63)) % (1 << 64)) - (1 << 63)
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
private def notification_loop(ready)
|
|
629
|
+
listener = nil #: untyped
|
|
630
|
+
retry_delay = 0.1
|
|
631
|
+
begin
|
|
632
|
+
until notifications_stopping?
|
|
633
|
+
begin
|
|
634
|
+
unless listener
|
|
635
|
+
listener = @driver.notification_listener
|
|
636
|
+
@mutex.synchronize { @notifications_disabled = listener.nil? }
|
|
637
|
+
ready << true if ready
|
|
638
|
+
ready = nil
|
|
639
|
+
return unless listener
|
|
640
|
+
|
|
641
|
+
# Poll durable state after reconnecting: Postgres messages sent
|
|
642
|
+
# while disconnected cannot be replayed.
|
|
643
|
+
wake
|
|
644
|
+
wake_maintenance
|
|
645
|
+
end
|
|
646
|
+
listener.poll(0.1).each { |topic, payload| receive_notification(topic, payload) }
|
|
647
|
+
retry_delay = 0.1
|
|
648
|
+
rescue => error
|
|
649
|
+
if ready
|
|
650
|
+
ready << error
|
|
651
|
+
ready = nil
|
|
652
|
+
return
|
|
653
|
+
end
|
|
654
|
+
@config.logger.error("River notification receiver failed: #{error.full_message}")
|
|
655
|
+
# SQLite's cursor survives temporary read errors. Reopening it would
|
|
656
|
+
# skip requests committed while the database was unavailable.
|
|
657
|
+
unless listener.is_a?(Driver::NotificationListener::SQLite)
|
|
658
|
+
listener&.close
|
|
659
|
+
listener = nil
|
|
660
|
+
end
|
|
661
|
+
sleep(retry_delay) unless notifications_stopping?
|
|
662
|
+
retry_delay = [retry_delay * 2, 1.0].min
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
ensure
|
|
666
|
+
ready << true if ready
|
|
667
|
+
listener&.close
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
private def notifications_stopping?
|
|
672
|
+
@mutex.synchronize do
|
|
673
|
+
@stop_requested && @running.empty? && @producer_threads.values.none?(&:alive?)
|
|
674
|
+
end
|
|
675
|
+
end
|
|
482
676
|
|
|
677
|
+
private def perform_work(worker, job)
|
|
678
|
+
timeout = worker_timeout(worker, job)
|
|
483
679
|
result = timeout ? Timeout.timeout(timeout) { worker.work(job) } : worker.work(job)
|
|
484
680
|
job.__finish_resumable_work!
|
|
485
681
|
result
|
|
486
682
|
end
|
|
487
683
|
|
|
684
|
+
private def periodic_jobs_changed
|
|
685
|
+
start_maintenance
|
|
686
|
+
wake_maintenance
|
|
687
|
+
end
|
|
688
|
+
|
|
488
689
|
private def producer_loop(queue, queue_config)
|
|
489
690
|
cooldown = queue_config.resolved_fetch_cooldown(@config)
|
|
490
691
|
# Capture registrations (including aliases) at startup, not construction.
|
|
@@ -493,9 +694,16 @@ module River
|
|
|
493
694
|
last_fetch = 0.0
|
|
494
695
|
poll_interval = queue_config.resolved_fetch_poll_interval(@config)
|
|
495
696
|
loop do
|
|
496
|
-
|
|
697
|
+
generation = @mutex.synchronize { @queue_generations[queue] }
|
|
698
|
+
draining = queue_stopping?(queue)
|
|
699
|
+
break if draining && running_count(queue).zero?
|
|
497
700
|
|
|
498
701
|
check_remote_cancellations(queue)
|
|
702
|
+
if draining
|
|
703
|
+
wait_for_running_jobs(queue, poll_interval)
|
|
704
|
+
next
|
|
705
|
+
end
|
|
706
|
+
|
|
499
707
|
capacity = queue_config.max_workers - running_count(queue)
|
|
500
708
|
if capacity.positive?
|
|
501
709
|
# Inserts, completions, and spurious condition wakes must not bypass
|
|
@@ -503,34 +711,48 @@ module River
|
|
|
503
711
|
while (sleep_for = cooldown - (monotonic_now - last_fetch)).positive? && !queue_stopping?(queue)
|
|
504
712
|
wait(sleep_for)
|
|
505
713
|
end
|
|
506
|
-
|
|
714
|
+
next if queue_stopping?(queue)
|
|
715
|
+
end
|
|
507
716
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
717
|
+
# Refresh controls even when every worker slot is occupied. A pause
|
|
718
|
+
# can also arrive during cooldown, so read immediately before fetching.
|
|
719
|
+
queue_state = @driver.queue_get(queue)
|
|
720
|
+
publish_queue(queue_state.paused_at ? EVENT_QUEUE_PAUSED : EVENT_QUEUE_RESUMED, queue_state) if queue_state
|
|
721
|
+
if capacity.positive? && !queue_state&.paused_at
|
|
722
|
+
@mutex.synchronize { @fetching_queues[queue] = [] }
|
|
723
|
+
begin
|
|
511
724
|
jobs = @driver.job_get_available(attempted_by: @config.id, max: capacity, queue: queue, **fetch_options)
|
|
512
725
|
last_fetch = monotonic_now
|
|
513
726
|
jobs.each { |job| launch(job) }
|
|
514
727
|
next unless jobs.empty?
|
|
728
|
+
ensure
|
|
729
|
+
@mutex.synchronize { @fetching_queues.delete(queue) }
|
|
515
730
|
end
|
|
516
731
|
end
|
|
517
732
|
|
|
518
|
-
|
|
733
|
+
wait_for_queue(queue, poll_interval, generation)
|
|
519
734
|
end
|
|
520
735
|
rescue => error
|
|
521
736
|
@config.logger.error("River producer for #{queue.inspect} stopped: #{error.full_message}")
|
|
522
|
-
|
|
523
|
-
|
|
737
|
+
if queue_stopping?(queue)
|
|
738
|
+
wait_for_running_jobs(queue, poll_interval || @config.fetch_poll_interval)
|
|
739
|
+
else
|
|
740
|
+
wait(poll_interval || @config.fetch_poll_interval)
|
|
741
|
+
end
|
|
742
|
+
retry unless queue_stopping?(queue) && running_count(queue).zero?
|
|
524
743
|
end
|
|
525
744
|
|
|
526
|
-
private def publish(kind, job,
|
|
745
|
+
private def publish(kind, job, timing)
|
|
527
746
|
# Another actor may have changed the row after our update. Pending,
|
|
528
747
|
# running, and unknown states do not represent a settled attempt.
|
|
529
748
|
return unless %w[available cancelled completed discarded retryable scheduled].include?(job.state)
|
|
530
749
|
|
|
531
750
|
kind = EVENT_JOB_CANCELLED if job.state == JOB_STATE_CANCELLED
|
|
532
|
-
|
|
533
|
-
|
|
751
|
+
stats = JobStatistics.new(
|
|
752
|
+
monotonic_now - timing.finished_monotonic,
|
|
753
|
+
timing.queue_wait_duration,
|
|
754
|
+
timing.finished_monotonic - timing.started_monotonic
|
|
755
|
+
)
|
|
534
756
|
event = Event.new(kind, job, nil, stats)
|
|
535
757
|
@mutex.synchronize { @subscriptions.dup }.each { |subscription| subscription.publish(event) }
|
|
536
758
|
end
|
|
@@ -539,10 +761,69 @@ module River
|
|
|
539
761
|
@mutex.synchronize { @stop_requested || @removed_queues[queue] }
|
|
540
762
|
end
|
|
541
763
|
|
|
764
|
+
private def receive_notification(topic, payload)
|
|
765
|
+
message = JSON.parse(payload)
|
|
766
|
+
return unless message.is_a?(Hash)
|
|
767
|
+
|
|
768
|
+
case topic
|
|
769
|
+
when "river_insert"
|
|
770
|
+
wake_queue(message["queue"]) if message["queue"].is_a?(String)
|
|
771
|
+
when "river_control"
|
|
772
|
+
queue = message["queue"]
|
|
773
|
+
return unless queue.is_a?(String)
|
|
774
|
+
|
|
775
|
+
case message["action"]
|
|
776
|
+
when "cancel"
|
|
777
|
+
return unless message["job_id"].is_a?(Integer)
|
|
778
|
+
|
|
779
|
+
@mutex.synchronize do
|
|
780
|
+
entry = @running[message["job_id"]]
|
|
781
|
+
if entry && entry[:queue] == queue
|
|
782
|
+
entry[:cancelled] = true
|
|
783
|
+
entry[:thread].raise(JobCancelError) if entry[:working]
|
|
784
|
+
elsif (pending = @fetching_queues[queue])
|
|
785
|
+
# A claim may have committed before its workers are registered.
|
|
786
|
+
pending << message["job_id"]
|
|
787
|
+
end
|
|
788
|
+
end
|
|
789
|
+
when "metadata_changed", "pause", "resume"
|
|
790
|
+
wake_queue(queue)
|
|
791
|
+
end
|
|
792
|
+
when "river_leadership"
|
|
793
|
+
case message["action"]
|
|
794
|
+
when "request_resign"
|
|
795
|
+
@mutex.synchronize do
|
|
796
|
+
if @leader
|
|
797
|
+
@resign_requested = true
|
|
798
|
+
@maintenance_generation += 1
|
|
799
|
+
@maintenance_condition.broadcast
|
|
800
|
+
end
|
|
801
|
+
end
|
|
802
|
+
when "resigned"
|
|
803
|
+
wake_maintenance if message["leader_id"].is_a?(String) && message["leader_id"] != @config.id
|
|
804
|
+
end
|
|
805
|
+
end
|
|
806
|
+
rescue JSON::ParserError, TypeError => error
|
|
807
|
+
@config.logger.warn("River ignored invalid notification: #{error.message}")
|
|
808
|
+
end
|
|
809
|
+
|
|
542
810
|
private def remove_subscription(subscription)
|
|
543
811
|
@mutex.synchronize { @subscriptions.delete(subscription) }
|
|
544
812
|
end
|
|
545
813
|
|
|
814
|
+
private def rescue_job?(row, now)
|
|
815
|
+
return true if row.__decode_error
|
|
816
|
+
|
|
817
|
+
worker = @config.workers[row.kind]
|
|
818
|
+
worker = worker.new if worker.is_a?(Class)
|
|
819
|
+
timeout = worker_timeout(worker, Job.new(@client, row))
|
|
820
|
+
attempted_at = row.attempted_at #: Time
|
|
821
|
+
!timeout.nil? && now - attempted_at >= timeout
|
|
822
|
+
rescue => error
|
|
823
|
+
@config.logger.error("River rescue timeout check failed; rescuing attempt: #{error.full_message}")
|
|
824
|
+
true
|
|
825
|
+
end
|
|
826
|
+
|
|
546
827
|
private def resolve_worker(kind)
|
|
547
828
|
worker = @config.workers[kind]
|
|
548
829
|
raise UnknownJobKindError, kind unless worker
|
|
@@ -557,15 +838,18 @@ module River
|
|
|
557
838
|
end
|
|
558
839
|
|
|
559
840
|
private def run_periodic(now)
|
|
560
|
-
due = @periodic_jobs.
|
|
841
|
+
due = @periodic_jobs.__due_with_times(now) do |job, error|
|
|
561
842
|
@config.logger.error("River periodic job schedule failed (id=#{job.id.inspect}): #{error.full_message}")
|
|
562
843
|
end
|
|
563
|
-
due.each do |periodic_job|
|
|
844
|
+
due.each do |periodic_job, scheduled_at|
|
|
564
845
|
value = periodic_job.constructor.call
|
|
565
846
|
next unless value
|
|
566
847
|
|
|
567
848
|
args, opts = value.is_a?(Array) ? value : [value, nil]
|
|
568
|
-
|
|
849
|
+
opts = (opts || InsertOpts.new).dup
|
|
850
|
+
opts.metadata = (opts.metadata || {}).merge("periodic" => true)
|
|
851
|
+
opts.metadata["river:periodic_job_id"] = periodic_job.id if periodic_job.id && !periodic_job.id.empty?
|
|
852
|
+
@client.__insert_with_default_scheduled_at(args, opts, scheduled_at)
|
|
569
853
|
rescue => error
|
|
570
854
|
@config.logger.error("River periodic job failed to insert: #{error.full_message}")
|
|
571
855
|
end
|
|
@@ -579,7 +863,7 @@ module River
|
|
|
579
863
|
return if @config.leader_election_disabled
|
|
580
864
|
|
|
581
865
|
@mutex.synchronize do
|
|
582
|
-
return
|
|
866
|
+
return unless @started && !@stop_requested
|
|
583
867
|
return if @maintenance_thread&.alive?
|
|
584
868
|
|
|
585
869
|
thread = Thread.new { maintenance_loop }
|
|
@@ -588,11 +872,28 @@ module River
|
|
|
588
872
|
end
|
|
589
873
|
end
|
|
590
874
|
|
|
875
|
+
private def start_notifications
|
|
876
|
+
return if @config.poll_only || !@driver.respond_to?(:notification_listener)
|
|
877
|
+
|
|
878
|
+
ready = ::Queue.new
|
|
879
|
+
@mutex.synchronize do
|
|
880
|
+
return if @stop_requested
|
|
881
|
+
|
|
882
|
+
thread = Thread.new { notification_loop(ready) }
|
|
883
|
+
@notification_thread = thread
|
|
884
|
+
@threads << thread
|
|
885
|
+
end
|
|
886
|
+
result = ready.pop
|
|
887
|
+
raise result if result.is_a?(Exception)
|
|
888
|
+
end
|
|
889
|
+
|
|
591
890
|
private def start_producer(name, queue_config)
|
|
592
|
-
@driver.queue_upsert(name)
|
|
891
|
+
queue = @driver.queue_upsert(name)
|
|
593
892
|
@mutex.synchronize do
|
|
594
893
|
return if @stop_requested || @removed_queues[name] || @producer_threads.key?(name)
|
|
595
894
|
|
|
895
|
+
@queue_paused[name] = !queue.paused_at.nil? if queue
|
|
896
|
+
|
|
596
897
|
# Shutdown must see every launched thread, including a producer whose
|
|
597
898
|
# database setup overlapped a stop or queue removal.
|
|
598
899
|
thread = Thread.new { producer_loop(name, queue_config) }
|
|
@@ -608,5 +909,51 @@ module River
|
|
|
608
909
|
private def wait(duration)
|
|
609
910
|
@mutex.synchronize { @condition.wait(@mutex, duration) unless @stop_requested }
|
|
610
911
|
end
|
|
912
|
+
|
|
913
|
+
private def wait_for_maintenance(duration, generation)
|
|
914
|
+
@mutex.synchronize do
|
|
915
|
+
@maintenance_condition.wait(@mutex, duration) if !@stop_requested && @maintenance_generation == generation
|
|
916
|
+
end
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
private def wait_for_queue(queue, duration, generation)
|
|
920
|
+
deadline = monotonic_now + duration
|
|
921
|
+
@mutex.synchronize do
|
|
922
|
+
while !@stop_requested && !@removed_queues[queue] && @queue_generations[queue] == generation && (remaining = deadline - monotonic_now).positive?
|
|
923
|
+
@condition.wait(@mutex, remaining)
|
|
924
|
+
end
|
|
925
|
+
end
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
private def wait_for_running_jobs(queue, duration)
|
|
929
|
+
@mutex.synchronize do
|
|
930
|
+
# Draining still needs a poll interval after stop is requested. Check
|
|
931
|
+
# under the same mutex as completion so the last job's wake isn't lost.
|
|
932
|
+
@condition.wait(@mutex, duration) if @running.any? { |_id, entry| entry[:queue] == queue }
|
|
933
|
+
end
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
private def wake_maintenance
|
|
937
|
+
@mutex.synchronize do
|
|
938
|
+
@maintenance_generation += 1
|
|
939
|
+
@maintenance_condition.broadcast
|
|
940
|
+
end
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
private def wake_queue(queue)
|
|
944
|
+
@mutex.synchronize do
|
|
945
|
+
@queue_configs.each_key { |name| @queue_generations[name] += 1 if queue == "*" || queue == name }
|
|
946
|
+
@condition.broadcast
|
|
947
|
+
end
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
private def worker_timeout(worker, job)
|
|
951
|
+
timeout = worker.respond_to?(:timeout) ? worker.timeout(job) : @config.job_timeout
|
|
952
|
+
timeout = Float(timeout) unless timeout.nil?
|
|
953
|
+
timeout = @config.job_timeout if timeout == 0
|
|
954
|
+
raise ArgumentError, "worker timeout must be finite and nonnegative, or nil" if timeout && (!timeout.finite? || timeout.negative?)
|
|
955
|
+
|
|
956
|
+
timeout
|
|
957
|
+
end
|
|
611
958
|
end
|
|
612
959
|
end
|