riverqueue 0.11.0 → 0.12.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 +132 -0
- data/LICENSE +374 -0
- data/docs/README.md +765 -0
- data/docs/migrations.md +121 -0
- data/docs/testing.md +173 -0
- data/docs/workers.md +126 -0
- data/exe/river +7 -0
- data/lib/cli.rb +55 -0
- data/lib/client.rb +314 -54
- data/lib/client_runtime.rb +612 -0
- data/lib/config.rb +154 -0
- data/lib/driver/job_row_decoder.rb +85 -0
- data/lib/driver/postgres_capabilities.rb +41 -0
- data/lib/driver/runtime.rb +661 -0
- data/lib/driver.rb +13 -6
- data/lib/errors.rb +83 -0
- data/lib/event.rb +78 -0
- data/lib/insert_opts.rb +40 -11
- data/lib/job.rb +28 -13
- data/lib/job_persisted_logging/plugin.rb +108 -0
- data/lib/migration_cli.rb +90 -0
- data/lib/migrator.rb +189 -0
- data/lib/params.rb +96 -0
- data/lib/periodic_cron.rb +24 -0
- data/lib/periodic_job.rb +135 -0
- data/lib/resumable.rb +148 -0
- data/lib/riverqueue/testing/minitest.rb +18 -0
- data/lib/riverqueue/testing/rspec.rb +109 -0
- data/lib/riverqueue/testing.rb +134 -0
- data/lib/riverqueue.rb +16 -0
- data/lib/unique_args.rb +97 -0
- data/lib/unique_bitmask.rb +14 -12
- data/lib/worker.rb +152 -0
- data/lib/worker_runner.rb +106 -0
- data/migration/LICENSE +374 -0
- data/migration/README.md +11 -0
- data/migration/manifest.json +38 -0
- data/migration/postgresql/main/001_create_river_migration.down.sql +1 -0
- data/migration/postgresql/main/001_create_river_migration.up.sql +8 -0
- data/migration/postgresql/main/002_initial_schema.down.sql +5 -0
- data/migration/postgresql/main/002_initial_schema.up.sql +96 -0
- data/migration/postgresql/main/003_river_job_tags_non_null.down.sql +3 -0
- data/migration/postgresql/main/003_river_job_tags_non_null.up.sql +3 -0
- data/migration/postgresql/main/004_pending_and_more.down.sql +42 -0
- data/migration/postgresql/main/004_pending_and_more.up.sql +45 -0
- data/migration/postgresql/main/005_migration_unique_client.down.sql +57 -0
- data/migration/postgresql/main/005_migration_unique_client.up.sql +79 -0
- data/migration/postgresql/main/006_bulk_unique.down.sql +16 -0
- data/migration/postgresql/main/006_bulk_unique.up.sql +40 -0
- data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +56 -0
- data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +44 -0
- data/migration/postgresql/main/008_job_id_autoincrement.down.sql +3 -0
- data/migration/postgresql/main/008_job_id_autoincrement.up.sql +3 -0
- data/migration/sqlite/main/001_create_river_migration.down.sql +1 -0
- data/migration/sqlite/main/001_create_river_migration.up.sql +8 -0
- data/migration/sqlite/main/002_initial_schema.down.sql +8 -0
- data/migration/sqlite/main/002_initial_schema.up.sql +19 -0
- data/migration/sqlite/main/003_river_job_tags_non_null.down.sql +6 -0
- data/migration/sqlite/main/003_river_job_tags_non_null.up.sql +6 -0
- data/migration/sqlite/main/004_pending_and_more.down.sql +26 -0
- data/migration/sqlite/main/004_pending_and_more.up.sql +33 -0
- data/migration/sqlite/main/005_migration_unique_client.down.sql +37 -0
- data/migration/sqlite/main/005_migration_unique_client.up.sql +64 -0
- data/migration/sqlite/main/006_bulk_unique.down.sql +7 -0
- data/migration/sqlite/main/006_bulk_unique.up.sql +63 -0
- data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +255 -0
- data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +261 -0
- data/migration/sqlite/main/008_job_id_autoincrement.down.sql +121 -0
- data/migration/sqlite/main/008_job_id_autoincrement.up.sql +123 -0
- data/sig/client.rbs +95 -0
- data/sig/driver.rbs +170 -0
- data/sig/insert_opts.rbs +25 -0
- data/sig/job.rbs +72 -0
- data/sig/job_persisted_logging/plugin.rbs +29 -0
- data/sig/migrator.rbs +44 -0
- data/sig/riverqueue.rbs +2 -0
- data/sig/runtime.rbs +382 -0
- data/sig/testing.rbs +95 -0
- data/sig/unique_args.rbs +8 -0
- data/sig/unique_bitmask.rbs +8 -0
- data/sig/worker_runner.rbs +22 -0
- metadata +165 -10
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module River
|
|
6
|
+
class ClientRuntime
|
|
7
|
+
class Interrupted < StandardError; end
|
|
8
|
+
|
|
9
|
+
attr_reader :periodic_jobs
|
|
10
|
+
|
|
11
|
+
def initialize(client, driver, config)
|
|
12
|
+
@client = client
|
|
13
|
+
@condition = ConditionVariable.new
|
|
14
|
+
@config = config
|
|
15
|
+
@driver = driver
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@periodic_jobs = PeriodicJobBundle.new(config.periodic_jobs, wake: method(:wake))
|
|
18
|
+
@producer_threads = {}
|
|
19
|
+
@queue_configs = config.queues.dup
|
|
20
|
+
@removed_queues = {}
|
|
21
|
+
@running = {}
|
|
22
|
+
@started = false
|
|
23
|
+
@stopped = true
|
|
24
|
+
@subscriptions = []
|
|
25
|
+
@threads = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def finish_claimed(row, error = nil)
|
|
29
|
+
started_at = Time.now.utc
|
|
30
|
+
job = Job.new(@client, row)
|
|
31
|
+
if error
|
|
32
|
+
finish_failed(row, job, error, started_at)
|
|
33
|
+
else
|
|
34
|
+
now = Time.now.utc
|
|
35
|
+
completed = @driver.job_set_state_if_running(id: row.id, finalized_at: now, now: now, state: JOB_STATE_COMPLETED)
|
|
36
|
+
publish(EVENT_JOB_COMPLETED, completed, started_at) if completed
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def perform_job(id, allow_scheduled: false)
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
raise ClientAlreadyStartedError, "synchronous execution requires an idle, stopped client" if @started || @performing
|
|
43
|
+
|
|
44
|
+
@performing = true
|
|
45
|
+
@stop_requested = false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
row = @driver.job_claim(id: id, allow_scheduled: allow_scheduled, attempted_by: @config.id)
|
|
50
|
+
raise ArgumentError, "job #{id} is missing, not runnable, or scheduled in the future" unless row
|
|
51
|
+
|
|
52
|
+
@mutex.synchronize { @running[row.id] = {queue: row.queue, thread: Thread.current, working: false} }
|
|
53
|
+
outcome, error = execute(row)
|
|
54
|
+
final_row = begin
|
|
55
|
+
@driver.job_get_by_id(id)
|
|
56
|
+
rescue JobRowDecodeError => decode_error
|
|
57
|
+
decode_error.job
|
|
58
|
+
end
|
|
59
|
+
[final_row, error, outcome]
|
|
60
|
+
ensure
|
|
61
|
+
@mutex.synchronize { @performing = false }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def publish_queue(kind, queue)
|
|
66
|
+
event = Event.new(kind, nil, queue, nil)
|
|
67
|
+
@mutex.synchronize { @subscriptions.dup }.each { |subscription| subscription.publish(event) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def healthy?
|
|
71
|
+
@mutex.synchronize do
|
|
72
|
+
@stop_requested || (@producer_threads.all? { |name, thread| @removed_queues[name] || thread.alive? } && (!@maintenance_thread || @maintenance_thread.alive?))
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def interrupt_workers
|
|
77
|
+
@mutex.synchronize do
|
|
78
|
+
@running.values.each { |entry| entry[:thread].raise(Interrupted) if entry[:working] }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def queue_add(name, queue_config)
|
|
83
|
+
name = name.to_s
|
|
84
|
+
queue_config = QueueConfig.new(max_workers: queue_config) unless queue_config.is_a?(QueueConfig)
|
|
85
|
+
raise ArgumentError, "invalid queue name: #{name.inspect}" unless name.match?(QUEUE_NAME_REGEX) && name.length < 128
|
|
86
|
+
|
|
87
|
+
queue_config.resolved_fetch_poll_interval(@config)
|
|
88
|
+
should_start = @mutex.synchronize do
|
|
89
|
+
raise ArgumentError, "queue is already configured: #{name}" if @queue_configs.key?(name)
|
|
90
|
+
|
|
91
|
+
@queue_configs[name] = queue_config
|
|
92
|
+
@removed_queues.delete(name)
|
|
93
|
+
@started && !@stop_requested
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if should_start
|
|
97
|
+
begin
|
|
98
|
+
start_producer(name, queue_config)
|
|
99
|
+
rescue
|
|
100
|
+
@mutex.synchronize do
|
|
101
|
+
if @queue_configs[name].equal?(queue_config) && !@producer_threads.key?(name)
|
|
102
|
+
@queue_configs.delete(name)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
raise
|
|
106
|
+
end
|
|
107
|
+
start_maintenance
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
wake
|
|
111
|
+
queue_config
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def queue_remove(name)
|
|
115
|
+
name = name.to_s
|
|
116
|
+
producer = @mutex.synchronize do
|
|
117
|
+
raise NotFoundError, "queue is not configured: #{name}" unless @queue_configs.key?(name)
|
|
118
|
+
|
|
119
|
+
@removed_queues[name] = true
|
|
120
|
+
@condition.broadcast
|
|
121
|
+
@producer_threads[name]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
producer&.join
|
|
125
|
+
running = @mutex.synchronize do
|
|
126
|
+
@running.values.filter_map { |entry| entry[:thread] if entry[:queue] == name }
|
|
127
|
+
end
|
|
128
|
+
running.each(&:join)
|
|
129
|
+
|
|
130
|
+
@mutex.synchronize do
|
|
131
|
+
@queue_configs.delete(name)
|
|
132
|
+
@producer_threads.delete(name)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def start
|
|
139
|
+
@driver.init_driver if @driver.respond_to?(:init_driver)
|
|
140
|
+
@mutex.synchronize do
|
|
141
|
+
raise ClientAlreadyStartedError, "client is already started" if @started || @performing
|
|
142
|
+
|
|
143
|
+
@started = true
|
|
144
|
+
@stop_requested = false
|
|
145
|
+
@stopped = false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
begin
|
|
149
|
+
queues = @mutex.synchronize { @queue_configs.dup }
|
|
150
|
+
queues.each { |name, queue_config| start_producer(name, queue_config) }
|
|
151
|
+
start_maintenance unless @queue_configs.empty?
|
|
152
|
+
|
|
153
|
+
self
|
|
154
|
+
rescue
|
|
155
|
+
stop(cancel: true)
|
|
156
|
+
raise
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def started?
|
|
161
|
+
@mutex.synchronize { @started }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def stop(cancel: false, wait: true)
|
|
165
|
+
threads = @mutex.synchronize do
|
|
166
|
+
return self if @stopped
|
|
167
|
+
|
|
168
|
+
@stop_requested = true
|
|
169
|
+
@condition.broadcast
|
|
170
|
+
@running.values.each { |entry| entry[:thread].raise(Interrupted) if cancel && entry[:working] }
|
|
171
|
+
@threads.dup
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
return self unless wait
|
|
175
|
+
|
|
176
|
+
threads.each(&:join)
|
|
177
|
+
|
|
178
|
+
running_threads = @mutex.synchronize { @running.values.map { |entry| entry[:thread] } }
|
|
179
|
+
running_threads.each(&:join)
|
|
180
|
+
|
|
181
|
+
@driver.leader_release(@config.id) unless @config.leader_election_disabled
|
|
182
|
+
@mutex.synchronize do
|
|
183
|
+
@started = false
|
|
184
|
+
@stopped = true
|
|
185
|
+
@threads.clear
|
|
186
|
+
@producer_threads.clear
|
|
187
|
+
@maintenance_thread = nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
self
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def stopped?
|
|
194
|
+
@mutex.synchronize { @stopped }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def subscribe(kinds, buffer_size: 100)
|
|
198
|
+
subscription = Subscription.new(
|
|
199
|
+
kinds,
|
|
200
|
+
buffer_size: buffer_size,
|
|
201
|
+
on_close: method(:remove_subscription)
|
|
202
|
+
)
|
|
203
|
+
@mutex.synchronize { @subscriptions << subscription }
|
|
204
|
+
subscription
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def wake
|
|
208
|
+
@mutex.synchronize { @condition.broadcast }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private def begin_work(id)
|
|
212
|
+
should_interrupt = @mutex.synchronize do
|
|
213
|
+
entry = @running.fetch(id)
|
|
214
|
+
if @stop_requested
|
|
215
|
+
true
|
|
216
|
+
else
|
|
217
|
+
entry[:working] = true
|
|
218
|
+
false
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
raise Interrupted if should_interrupt
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private def check_remote_cancellations(queue)
|
|
226
|
+
entries = @mutex.synchronize { @running.select { |_id, entry| entry[:queue] == queue && entry[:working] } }
|
|
227
|
+
return if entries.empty?
|
|
228
|
+
|
|
229
|
+
@driver.job_get_cancelled_ids(entries.keys).each do |id|
|
|
230
|
+
entries.fetch(id)[:thread].raise(JobCancelError)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private def error_handler_cancel?(error, job)
|
|
235
|
+
return false unless @config.error_handler
|
|
236
|
+
|
|
237
|
+
result = if @config.error_handler.respond_to?(:handle_error)
|
|
238
|
+
@config.error_handler.handle_error(error, job)
|
|
239
|
+
else
|
|
240
|
+
@config.error_handler.call(error, job)
|
|
241
|
+
end
|
|
242
|
+
result == :cancel || result == true
|
|
243
|
+
rescue => handler_error
|
|
244
|
+
@config.logger.error("River error handler failed: #{handler_error.full_message}")
|
|
245
|
+
false
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private def execute(row)
|
|
249
|
+
started_at = Time.now.utc
|
|
250
|
+
job = Job.new(@client, row)
|
|
251
|
+
begin
|
|
252
|
+
raise row.__decode_error if row.__decode_error
|
|
253
|
+
|
|
254
|
+
worker = resolve_worker(row.kind)
|
|
255
|
+
begin_work(row.id)
|
|
256
|
+
begin
|
|
257
|
+
Thread.handle_interrupt(Interrupted => :immediate, JobCancelError => :immediate) do
|
|
258
|
+
invoke_worker(worker, job)
|
|
259
|
+
end
|
|
260
|
+
ensure
|
|
261
|
+
finish_work(row.id)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
finalize_hooks = @config.plugins.any? { |plugin| plugin.respond_to?(:job_finalize) }
|
|
265
|
+
if finalize_hooks
|
|
266
|
+
raise JobCancelError if @driver.job_get_cancelled_ids([row.id]).include?(row.id)
|
|
267
|
+
|
|
268
|
+
if invoke_plugins(:job_finalize, job, JOB_STATE_COMPLETED).include?(:delete)
|
|
269
|
+
@driver.job_delete_if_running(row.id)
|
|
270
|
+
return [:deleted, nil]
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
completed_at = Time.now.utc
|
|
275
|
+
completed = if finalize_hooks
|
|
276
|
+
@driver.job_set_state_if_running(id: row.id, finalized_at: completed_at, metadata: job.metadata_updates, now: completed_at, state: JOB_STATE_COMPLETED)
|
|
277
|
+
else
|
|
278
|
+
result = @driver.job_complete(id: row.id, finalized_at: completed_at, metadata: job.metadata_updates, now: completed_at)
|
|
279
|
+
case result
|
|
280
|
+
when :cancelled then raise JobCancelError
|
|
281
|
+
else result
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
publish(EVENT_JOB_COMPLETED, completed, started_at) if completed
|
|
285
|
+
|
|
286
|
+
[: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
|
+
rescue => error
|
|
307
|
+
job.__capture_resumable_metadata!
|
|
308
|
+
[finish_failed(row, job, error, started_at, worker: worker), error]
|
|
309
|
+
ensure
|
|
310
|
+
@mutex.synchronize do
|
|
311
|
+
@running.delete(row.id)
|
|
312
|
+
@condition.broadcast
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
private def finish_failed(row, job, error, started_at, cancelled: false, worker: nil)
|
|
318
|
+
cancelled ||= error_handler_cancel?(error, job)
|
|
319
|
+
now = Time.now.utc
|
|
320
|
+
attempt_error = AttemptError.new(
|
|
321
|
+
at: started_at,
|
|
322
|
+
attempt: row.attempt,
|
|
323
|
+
error: error.message,
|
|
324
|
+
trace: Array(error.backtrace).join("\n")
|
|
325
|
+
)
|
|
326
|
+
final = cancelled || row.attempt >= row.max_attempts || !retry_allowed?(worker, job, error)
|
|
327
|
+
state = if cancelled
|
|
328
|
+
JOB_STATE_CANCELLED
|
|
329
|
+
elsif final
|
|
330
|
+
JOB_STATE_DISCARDED
|
|
331
|
+
else
|
|
332
|
+
JOB_STATE_RETRYABLE
|
|
333
|
+
end
|
|
334
|
+
scheduled_at = final ? nil : next_retry(row, error, now, worker: worker)
|
|
335
|
+
state = JOB_STATE_AVAILABLE if scheduled_at && scheduled_at <= now + 5
|
|
336
|
+
|
|
337
|
+
updated = @driver.job_set_state_if_running(
|
|
338
|
+
id: row.id,
|
|
339
|
+
error: attempt_error,
|
|
340
|
+
finalized_at: final ? now : nil,
|
|
341
|
+
metadata: job.metadata_updates,
|
|
342
|
+
now: now,
|
|
343
|
+
scheduled_at: scheduled_at,
|
|
344
|
+
state: state
|
|
345
|
+
)
|
|
346
|
+
event = cancelled ? EVENT_JOB_CANCELLED : EVENT_JOB_FAILED
|
|
347
|
+
publish(event, updated, started_at) if updated
|
|
348
|
+
|
|
349
|
+
case updated&.state || state
|
|
350
|
+
when JOB_STATE_CANCELLED then :cancelled
|
|
351
|
+
when JOB_STATE_DISCARDED then :discarded
|
|
352
|
+
else :retried
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
private def finish_snoozed(row, job, error, started_at)
|
|
357
|
+
scheduled_at = Time.now.utc + error.duration
|
|
358
|
+
state = (error.duration <= 5) ? JOB_STATE_AVAILABLE : JOB_STATE_SCHEDULED
|
|
359
|
+
metadata = job.metadata_updates.merge("snoozes" => row.metadata.fetch("snoozes", 0).to_i + 1)
|
|
360
|
+
updated = @driver.job_set_state_if_running(
|
|
361
|
+
id: row.id,
|
|
362
|
+
attempt: [row.attempt - 1, 0].max,
|
|
363
|
+
metadata: metadata,
|
|
364
|
+
scheduled_at: scheduled_at,
|
|
365
|
+
state: state
|
|
366
|
+
)
|
|
367
|
+
publish(EVENT_JOB_SNOOZED, updated, started_at) if updated
|
|
368
|
+
(updated&.state == JOB_STATE_CANCELLED) ? :cancelled : :snoozed
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
private def finish_work(id)
|
|
372
|
+
@mutex.synchronize do
|
|
373
|
+
entry = @running[id]
|
|
374
|
+
entry[:working] = false if entry
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
private def invoke_plugins(name, ...)
|
|
379
|
+
@config.plugins.filter_map { |plugin| plugin.public_send(name, ...) if plugin.respond_to?(name) }
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
private def invoke_worker(worker, job)
|
|
383
|
+
return perform_work(worker, job) if @config.plugins.empty?
|
|
384
|
+
|
|
385
|
+
operation = -> do
|
|
386
|
+
error = nil
|
|
387
|
+
begin
|
|
388
|
+
invoke_plugins(:work_begin, job)
|
|
389
|
+
perform_work(worker, job)
|
|
390
|
+
rescue => error
|
|
391
|
+
raise
|
|
392
|
+
ensure
|
|
393
|
+
invoke_plugins(:work_end, job, error)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
@config.plugins.reverse_each do |plugin|
|
|
397
|
+
next unless plugin.respond_to?(:work)
|
|
398
|
+
|
|
399
|
+
next_operation = operation
|
|
400
|
+
operation = -> { plugin.work(job, next_operation) }
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
operation.call
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
private def launch(row)
|
|
407
|
+
gate = ::Queue.new
|
|
408
|
+
thread = Thread.new do
|
|
409
|
+
gate.pop
|
|
410
|
+
begin
|
|
411
|
+
Thread.handle_interrupt(Interrupted => :never, JobCancelError => :never) { execute(row) }
|
|
412
|
+
rescue Interrupted, JobCancelError
|
|
413
|
+
# A late asynchronous interrupt may become pending after work has
|
|
414
|
+
# already finalized. The database transition won that race.
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
@mutex.synchronize { @running[row.id] = {queue: row.queue, thread: thread, working: false} }
|
|
419
|
+
gate.push(true)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
private def maintenance_loop
|
|
423
|
+
leader = false
|
|
424
|
+
next_schedule = next_rescue = next_cleanup = Time.at(0)
|
|
425
|
+
until stopping?
|
|
426
|
+
now = Time.now.utc
|
|
427
|
+
leader = leader ? @driver.leader_renew(@config.id, now: now) : @driver.leader_acquire(@config.id, now: now)
|
|
428
|
+
if leader
|
|
429
|
+
if now >= next_schedule
|
|
430
|
+
@driver.job_schedule(now: now)
|
|
431
|
+
run_periodic(now)
|
|
432
|
+
@config.maintenance_services.each { |service| service.run(@client, @driver, now) }
|
|
433
|
+
next_schedule = now + 5
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
if now >= next_rescue
|
|
437
|
+
@driver.job_rescue_stuck(horizon: now - 3_600, now: now, retry_policy: @config.retry_policy)
|
|
438
|
+
next_rescue = now + 30
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
if now >= next_cleanup
|
|
442
|
+
@driver.job_delete_finalized(now: now, retention: {
|
|
443
|
+
JOB_STATE_CANCELLED => @config.cancelled_job_retention_period,
|
|
444
|
+
JOB_STATE_COMPLETED => @config.completed_job_retention_period,
|
|
445
|
+
JOB_STATE_DISCARDED => @config.discarded_job_retention_period
|
|
446
|
+
})
|
|
447
|
+
@driver.notification_delete_before(horizon: now - 300) if @driver.respond_to?(:notification_delete_before)
|
|
448
|
+
next_cleanup = now + 30
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
wait(5)
|
|
453
|
+
end
|
|
454
|
+
rescue => error
|
|
455
|
+
@config.logger.error("River maintenance stopped: #{error.full_message}")
|
|
456
|
+
wait(5)
|
|
457
|
+
retry unless stopping?
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
private def monotonic_now
|
|
461
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
private def next_retry(row, error, now, worker: nil)
|
|
465
|
+
worker ||= @config.workers[row.kind] unless row.__decode_error
|
|
466
|
+
custom = worker.next_retry(row, error) if worker.respond_to?(:next_retry)
|
|
467
|
+
|
|
468
|
+
retry_at = custom || @config.retry_policy.next_retry(row, error, now: now)
|
|
469
|
+
raise ArgumentError, "next_retry must return a Time" unless retry_at.is_a?(Time)
|
|
470
|
+
|
|
471
|
+
(retry_at < now) ? DefaultClientRetryPolicy.new.next_retry(row, error, now: now) : retry_at
|
|
472
|
+
rescue => retry_error
|
|
473
|
+
@config.logger.error("River retry scheduling failed; using default backoff: #{retry_error.full_message}")
|
|
474
|
+
DefaultClientRetryPolicy.new.next_retry(row, error, now: now)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
private def perform_work(worker, job)
|
|
478
|
+
timeout = worker.respond_to?(:timeout) ? worker.timeout(job) : @config.job_timeout
|
|
479
|
+
timeout = Float(timeout) unless timeout.nil?
|
|
480
|
+
timeout = @config.job_timeout if timeout == 0
|
|
481
|
+
raise ArgumentError, "worker timeout must be finite and nonnegative, or nil" if timeout && (!timeout.finite? || timeout.negative?)
|
|
482
|
+
|
|
483
|
+
result = timeout ? Timeout.timeout(timeout) { worker.work(job) } : worker.work(job)
|
|
484
|
+
job.__finish_resumable_work!
|
|
485
|
+
result
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
private def producer_loop(queue, queue_config)
|
|
489
|
+
cooldown = queue_config.resolved_fetch_cooldown(@config)
|
|
490
|
+
# Capture registrations (including aliases) at startup, not construction.
|
|
491
|
+
fetch_options = {} #: Hash[Symbol, Array[String]]
|
|
492
|
+
fetch_options[:kinds] = @config.workers.kinds if @config.fetch_only_known_kinds
|
|
493
|
+
last_fetch = 0.0
|
|
494
|
+
poll_interval = queue_config.resolved_fetch_poll_interval(@config)
|
|
495
|
+
loop do
|
|
496
|
+
break if queue_stopping?(queue)
|
|
497
|
+
|
|
498
|
+
check_remote_cancellations(queue)
|
|
499
|
+
capacity = queue_config.max_workers - running_count(queue)
|
|
500
|
+
if capacity.positive?
|
|
501
|
+
# Inserts, completions, and spurious condition wakes must not bypass
|
|
502
|
+
# the minimum interval between database fetches.
|
|
503
|
+
while (sleep_for = cooldown - (monotonic_now - last_fetch)).positive? && !queue_stopping?(queue)
|
|
504
|
+
wait(sleep_for)
|
|
505
|
+
end
|
|
506
|
+
break if queue_stopping?(queue)
|
|
507
|
+
|
|
508
|
+
# A pause may arrive during cooldown; only read the queue immediately
|
|
509
|
+
# before fetching, and avoid the read entirely when all slots are busy.
|
|
510
|
+
unless @driver.queue_get(queue)&.paused_at
|
|
511
|
+
jobs = @driver.job_get_available(attempted_by: @config.id, max: capacity, queue: queue, **fetch_options)
|
|
512
|
+
last_fetch = monotonic_now
|
|
513
|
+
jobs.each { |job| launch(job) }
|
|
514
|
+
next unless jobs.empty?
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
wait(poll_interval)
|
|
519
|
+
end
|
|
520
|
+
rescue => error
|
|
521
|
+
@config.logger.error("River producer for #{queue.inspect} stopped: #{error.full_message}")
|
|
522
|
+
wait(poll_interval || @config.fetch_poll_interval)
|
|
523
|
+
retry unless queue_stopping?(queue)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
private def publish(kind, job, started_at)
|
|
527
|
+
# Another actor may have changed the row after our update. Pending,
|
|
528
|
+
# running, and unknown states do not represent a settled attempt.
|
|
529
|
+
return unless %w[available cancelled completed discarded retryable scheduled].include?(job.state)
|
|
530
|
+
|
|
531
|
+
kind = EVENT_JOB_CANCELLED if job.state == JOB_STATE_CANCELLED
|
|
532
|
+
completed_at = Time.now.utc
|
|
533
|
+
stats = JobStatistics.new(0, started_at - job.scheduled_at, completed_at - started_at)
|
|
534
|
+
event = Event.new(kind, job, nil, stats)
|
|
535
|
+
@mutex.synchronize { @subscriptions.dup }.each { |subscription| subscription.publish(event) }
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
private def queue_stopping?(queue)
|
|
539
|
+
@mutex.synchronize { @stop_requested || @removed_queues[queue] }
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
private def remove_subscription(subscription)
|
|
543
|
+
@mutex.synchronize { @subscriptions.delete(subscription) }
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
private def resolve_worker(kind)
|
|
547
|
+
worker = @config.workers[kind]
|
|
548
|
+
raise UnknownJobKindError, kind unless worker
|
|
549
|
+
worker.is_a?(Class) ? worker.new : worker
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
private def retry_allowed?(worker, job, error)
|
|
553
|
+
!worker.respond_to?(:retry?) || worker.retry?(job, error)
|
|
554
|
+
rescue => retry_error
|
|
555
|
+
@config.logger.error("River retry? hook failed; allowing retry: #{retry_error.full_message}")
|
|
556
|
+
true
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
private def run_periodic(now)
|
|
560
|
+
due = @periodic_jobs.due(now) do |job, error|
|
|
561
|
+
@config.logger.error("River periodic job schedule failed (id=#{job.id.inspect}): #{error.full_message}")
|
|
562
|
+
end
|
|
563
|
+
due.each do |periodic_job|
|
|
564
|
+
value = periodic_job.constructor.call
|
|
565
|
+
next unless value
|
|
566
|
+
|
|
567
|
+
args, opts = value.is_a?(Array) ? value : [value, nil]
|
|
568
|
+
@client.insert(args, insert_opts: opts || InsertOpts.new)
|
|
569
|
+
rescue => error
|
|
570
|
+
@config.logger.error("River periodic job failed to insert: #{error.full_message}")
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
private def running_count(queue)
|
|
575
|
+
@mutex.synchronize { @running.count { |_id, entry| entry[:queue] == queue } }
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
private def start_maintenance
|
|
579
|
+
return if @config.leader_election_disabled
|
|
580
|
+
|
|
581
|
+
@mutex.synchronize do
|
|
582
|
+
return if @stop_requested
|
|
583
|
+
return if @maintenance_thread&.alive?
|
|
584
|
+
|
|
585
|
+
thread = Thread.new { maintenance_loop }
|
|
586
|
+
@maintenance_thread = thread
|
|
587
|
+
@threads << thread
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
private def start_producer(name, queue_config)
|
|
592
|
+
@driver.queue_upsert(name)
|
|
593
|
+
@mutex.synchronize do
|
|
594
|
+
return if @stop_requested || @removed_queues[name] || @producer_threads.key?(name)
|
|
595
|
+
|
|
596
|
+
# Shutdown must see every launched thread, including a producer whose
|
|
597
|
+
# database setup overlapped a stop or queue removal.
|
|
598
|
+
thread = Thread.new { producer_loop(name, queue_config) }
|
|
599
|
+
@producer_threads[name] = thread
|
|
600
|
+
@threads << thread
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
private def stopping?
|
|
605
|
+
@mutex.synchronize { @stop_requested }
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
private def wait(duration)
|
|
609
|
+
@mutex.synchronize { @condition.wait(@mutex, duration) unless @stop_requested }
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
end
|