riverqueue 0.11.0 → 0.13.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 +182 -0
- data/LICENSE +374 -0
- data/README.md +22 -0
- data/docs/README.md +812 -0
- data/docs/conformance.md +72 -0
- data/docs/migrations.md +118 -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 +347 -54
- data/lib/client_runtime.rb +721 -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 +772 -0
- data/lib/driver.rb +13 -6
- data/lib/errors.rb +83 -0
- data/lib/event.rb +85 -0
- data/lib/insert_opts.rb +46 -12
- 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 +59 -0
- data/lib/periodic_job.rb +143 -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 +13 -0
- data/migration/manifest.json +37 -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 +98 -0
- data/sig/driver.rbs +172 -0
- data/sig/insert_opts.rbs +25 -0
- data/sig/job.rbs +74 -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 +402 -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 +170 -13
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module River
|
|
6
|
+
class ClientRuntime
|
|
7
|
+
class Interrupted < StandardError; end
|
|
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
|
+
|
|
24
|
+
attr_reader :periodic_jobs
|
|
25
|
+
|
|
26
|
+
def initialize(client, driver, config)
|
|
27
|
+
@client = client
|
|
28
|
+
@condition = ConditionVariable.new
|
|
29
|
+
@config = config
|
|
30
|
+
@driver = driver
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
@producer_threads = {}
|
|
33
|
+
@queue_configs = config.queues.dup
|
|
34
|
+
@removed_queues = {}
|
|
35
|
+
@running = {}
|
|
36
|
+
@started = false
|
|
37
|
+
@stopped = true
|
|
38
|
+
@subscriptions = []
|
|
39
|
+
@threads = []
|
|
40
|
+
@periodic_jobs = PeriodicJobBundle.new(config.periodic_jobs,
|
|
41
|
+
enabled: !config.leader_election_disabled, wake: method(:periodic_jobs_changed))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def finish_claimed(row, error = nil)
|
|
45
|
+
timing = JobTiming.new(row.attempted_at || Time.now.utc, monotonic_now, row.scheduled_at)
|
|
46
|
+
job = Job.new(@client, row)
|
|
47
|
+
case error
|
|
48
|
+
when nil
|
|
49
|
+
now = Time.now.utc
|
|
50
|
+
completed = @driver.job_complete(id: row.id, finalized_at: now, now: now)
|
|
51
|
+
case completed
|
|
52
|
+
when :cancelled
|
|
53
|
+
finish_failed(row, job, JobCancelError.new, timing, cancelled: true)
|
|
54
|
+
else
|
|
55
|
+
publish(EVENT_JOB_COMPLETED, completed, timing) if completed
|
|
56
|
+
end
|
|
57
|
+
when JobCancelError
|
|
58
|
+
finish_failed(row, job, error, timing, cancelled: true)
|
|
59
|
+
when JobSnoozeError
|
|
60
|
+
finish_snoozed(row, job, error, timing)
|
|
61
|
+
when Interrupted
|
|
62
|
+
finish_interrupted(row, job, timing)
|
|
63
|
+
else
|
|
64
|
+
worker = begin
|
|
65
|
+
resolve_worker(row.kind) if !row.__decode_error && @config.workers.include?(row.kind)
|
|
66
|
+
rescue => worker_error
|
|
67
|
+
@config.logger.error("River worker initialization failed during finalization: #{worker_error.full_message}")
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
finish_failed(row, job, error, timing, worker: worker)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def perform_job(id, allow_scheduled: false)
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
raise ClientAlreadyStartedError, "synchronous execution requires an idle, stopped client" if @started || @performing
|
|
77
|
+
|
|
78
|
+
@performing = true
|
|
79
|
+
@stop_requested = false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
begin
|
|
83
|
+
row = @driver.job_claim(id: id, allow_scheduled: allow_scheduled, attempted_by: @config.id)
|
|
84
|
+
raise ArgumentError, "job #{id} is missing, not runnable, or scheduled in the future" unless row
|
|
85
|
+
|
|
86
|
+
@mutex.synchronize { @running[row.id] = {queue: row.queue, thread: Thread.current, working: false} }
|
|
87
|
+
outcome, error = execute(row)
|
|
88
|
+
final_row = begin
|
|
89
|
+
@driver.job_get_by_id(id)
|
|
90
|
+
rescue JobRowDecodeError => decode_error
|
|
91
|
+
decode_error.job
|
|
92
|
+
end
|
|
93
|
+
[final_row, error, outcome]
|
|
94
|
+
ensure
|
|
95
|
+
@mutex.synchronize { @performing = false }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def publish_queue(kind, queue)
|
|
100
|
+
event = Event.new(kind, nil, queue, nil)
|
|
101
|
+
@mutex.synchronize { @subscriptions.dup }.each { |subscription| subscription.publish(event) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def healthy?
|
|
105
|
+
@mutex.synchronize do
|
|
106
|
+
@stop_requested || (@producer_threads.all? { |name, thread| @removed_queues[name] || thread.alive? } && (!@maintenance_thread || @maintenance_thread.alive?))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def interrupt_workers
|
|
111
|
+
@mutex.synchronize do
|
|
112
|
+
@running.values.each { |entry| entry[:thread].raise(Interrupted) if entry[:working] }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def queue_add(name, queue_config)
|
|
117
|
+
name = name.to_s
|
|
118
|
+
queue_config = QueueConfig.new(max_workers: queue_config) unless queue_config.is_a?(QueueConfig)
|
|
119
|
+
raise ArgumentError, "invalid queue name: #{name.inspect}" unless name.match?(QUEUE_NAME_REGEX) && name.length < 128
|
|
120
|
+
|
|
121
|
+
queue_config.resolved_fetch_poll_interval(@config)
|
|
122
|
+
should_start = @mutex.synchronize do
|
|
123
|
+
raise ArgumentError, "queue is already configured: #{name}" if @queue_configs.key?(name)
|
|
124
|
+
|
|
125
|
+
@queue_configs[name] = queue_config
|
|
126
|
+
@removed_queues.delete(name)
|
|
127
|
+
@started && !@stop_requested
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if should_start
|
|
131
|
+
begin
|
|
132
|
+
start_producer(name, queue_config)
|
|
133
|
+
rescue
|
|
134
|
+
@mutex.synchronize do
|
|
135
|
+
if @queue_configs[name].equal?(queue_config) && !@producer_threads.key?(name)
|
|
136
|
+
@queue_configs.delete(name)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
raise
|
|
140
|
+
end
|
|
141
|
+
start_maintenance
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
wake
|
|
145
|
+
queue_config
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def queue_remove(name)
|
|
149
|
+
name = name.to_s
|
|
150
|
+
producer = @mutex.synchronize do
|
|
151
|
+
raise NotFoundError, "queue is not configured: #{name}" unless @queue_configs.key?(name)
|
|
152
|
+
if @producer_threads[name] == Thread.current || @running.any? { |_id, entry| entry[:queue] == name && entry[:thread] == Thread.current }
|
|
153
|
+
raise ThreadError, "cannot remove a queue from its own worker or producer"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
@removed_queues[name] = true
|
|
157
|
+
@condition.broadcast
|
|
158
|
+
@producer_threads[name]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
producer&.join
|
|
162
|
+
running = @mutex.synchronize do
|
|
163
|
+
@running.values.filter_map { |entry| entry[:thread] if entry[:queue] == name }
|
|
164
|
+
end
|
|
165
|
+
running.each(&:join)
|
|
166
|
+
|
|
167
|
+
@mutex.synchronize do
|
|
168
|
+
@queue_configs.delete(name)
|
|
169
|
+
@producer_threads.delete(name)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
true
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def start
|
|
176
|
+
@driver.init_driver if @driver.respond_to?(:init_driver)
|
|
177
|
+
@mutex.synchronize do
|
|
178
|
+
raise ClientAlreadyStartedError, "client is already started" if @started || @performing
|
|
179
|
+
|
|
180
|
+
@started = true
|
|
181
|
+
@stop_requested = false
|
|
182
|
+
@stopped = false
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
begin
|
|
186
|
+
queues = @mutex.synchronize { @queue_configs.dup }
|
|
187
|
+
queues.each { |name, queue_config| start_producer(name, queue_config) }
|
|
188
|
+
start_maintenance unless @queue_configs.empty? && @periodic_jobs.empty? && @config.maintenance_services.empty?
|
|
189
|
+
|
|
190
|
+
self
|
|
191
|
+
rescue
|
|
192
|
+
stop(cancel: true)
|
|
193
|
+
raise
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def started?
|
|
198
|
+
@mutex.synchronize { @started }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def stop(cancel: false, wait: true)
|
|
202
|
+
threads = @mutex.synchronize do
|
|
203
|
+
return self if @stopped
|
|
204
|
+
if wait && (@threads.include?(Thread.current) || @running.any? { |_id, entry| entry[:thread] == Thread.current })
|
|
205
|
+
raise ThreadError, "cannot wait for client stop from its own runtime thread; use wait: false"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
@stop_requested = true
|
|
209
|
+
@condition.broadcast
|
|
210
|
+
@running.values.each { |entry| entry[:thread].raise(Interrupted) if cancel && entry[:working] }
|
|
211
|
+
@threads.dup
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
return self unless wait
|
|
215
|
+
|
|
216
|
+
threads.each(&:join)
|
|
217
|
+
|
|
218
|
+
running_threads = @mutex.synchronize { @running.values.map { |entry| entry[:thread] } }
|
|
219
|
+
running_threads.each(&:join)
|
|
220
|
+
|
|
221
|
+
@driver.leader_release(@config.id) unless @config.leader_election_disabled
|
|
222
|
+
@mutex.synchronize do
|
|
223
|
+
@started = false
|
|
224
|
+
@stopped = true
|
|
225
|
+
@threads.clear
|
|
226
|
+
@producer_threads.clear
|
|
227
|
+
@maintenance_thread = nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
self
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def stopped?
|
|
234
|
+
@mutex.synchronize { @stopped }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def subscribe(kinds, buffer_size: 100)
|
|
238
|
+
subscription = Subscription.new(
|
|
239
|
+
kinds,
|
|
240
|
+
buffer_size: buffer_size,
|
|
241
|
+
on_close: method(:remove_subscription)
|
|
242
|
+
)
|
|
243
|
+
@mutex.synchronize { @subscriptions << subscription }
|
|
244
|
+
subscription
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def wake
|
|
248
|
+
@mutex.synchronize { @condition.broadcast }
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
private def begin_work(id)
|
|
252
|
+
should_interrupt = @mutex.synchronize do
|
|
253
|
+
entry = @running.fetch(id)
|
|
254
|
+
if @stop_requested
|
|
255
|
+
true
|
|
256
|
+
else
|
|
257
|
+
entry[:working] = true
|
|
258
|
+
false
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
raise Interrupted if should_interrupt
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
private def check_remote_cancellations(queue)
|
|
266
|
+
entries = @mutex.synchronize { @running.select { |_id, entry| entry[:queue] == queue && entry[:working] } }
|
|
267
|
+
return if entries.empty?
|
|
268
|
+
|
|
269
|
+
@driver.job_get_cancelled_ids(entries.keys).each do |id|
|
|
270
|
+
entries.fetch(id)[:thread].raise(JobCancelError)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
private def error_handler_cancel?(error, job)
|
|
275
|
+
return false unless @config.error_handler
|
|
276
|
+
|
|
277
|
+
result = if @config.error_handler.respond_to?(:handle_error)
|
|
278
|
+
@config.error_handler.handle_error(error, job)
|
|
279
|
+
else
|
|
280
|
+
@config.error_handler.call(error, job)
|
|
281
|
+
end
|
|
282
|
+
result == :cancel || result == true
|
|
283
|
+
rescue => handler_error
|
|
284
|
+
@config.logger.error("River error handler failed: #{handler_error.full_message}")
|
|
285
|
+
false
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
private def execute(row)
|
|
289
|
+
timing = JobTiming.new(Time.now.utc, monotonic_now, row.scheduled_at)
|
|
290
|
+
job = Job.new(@client, row)
|
|
291
|
+
worker = nil #: untyped
|
|
292
|
+
begin
|
|
293
|
+
begin
|
|
294
|
+
raise row.__decode_error if row.__decode_error
|
|
295
|
+
|
|
296
|
+
worker = resolve_worker(row.kind)
|
|
297
|
+
begin_work(row.id)
|
|
298
|
+
Thread.handle_interrupt(Interrupted => :immediate, JobCancelError => :immediate) do
|
|
299
|
+
invoke_worker(worker, job)
|
|
300
|
+
end
|
|
301
|
+
ensure
|
|
302
|
+
timing.finished_monotonic = monotonic_now
|
|
303
|
+
finish_work(row.id)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
finalize_hooks = @config.plugins.any? { |plugin| plugin.respond_to?(:job_finalize) }
|
|
307
|
+
if finalize_hooks
|
|
308
|
+
raise JobCancelError if @driver.job_get_cancelled_ids([row.id]).include?(row.id)
|
|
309
|
+
|
|
310
|
+
if invoke_plugins(:job_finalize, job, JOB_STATE_COMPLETED).include?(:delete)
|
|
311
|
+
raise JobCancelError if @driver.job_delete_if_running(row.id) == :cancelled
|
|
312
|
+
|
|
313
|
+
return [:deleted, nil]
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
completed_at = Time.now.utc
|
|
318
|
+
completed = @driver.job_complete(id: row.id, finalized_at: completed_at, metadata: job.metadata_updates, now: completed_at)
|
|
319
|
+
case completed
|
|
320
|
+
when :cancelled then raise JobCancelError
|
|
321
|
+
else publish(EVENT_JOB_COMPLETED, completed, timing) if completed
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
[:completed, nil]
|
|
325
|
+
rescue JobSnoozeError => error
|
|
326
|
+
job.__capture_resumable_metadata!
|
|
327
|
+
[finish_snoozed(row, job, error, timing), error]
|
|
328
|
+
rescue JobCancelError => error
|
|
329
|
+
job.__capture_resumable_metadata!
|
|
330
|
+
finish_failed(row, job, error, timing, cancelled: true)
|
|
331
|
+
[:cancelled, error]
|
|
332
|
+
rescue Interrupted => error
|
|
333
|
+
job.__capture_resumable_metadata!
|
|
334
|
+
[finish_interrupted(row, job, timing), error]
|
|
335
|
+
rescue => error
|
|
336
|
+
job.__capture_resumable_metadata!
|
|
337
|
+
[finish_failed(row, job, error, timing, worker: worker), error]
|
|
338
|
+
ensure
|
|
339
|
+
@mutex.synchronize do
|
|
340
|
+
@running.delete(row.id)
|
|
341
|
+
@condition.broadcast
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
private def finish_failed(row, job, error, timing, cancelled: false, worker: nil)
|
|
347
|
+
cancelled ||= error_handler_cancel?(error, job)
|
|
348
|
+
now = Time.now.utc
|
|
349
|
+
attempt_error = AttemptError.new(
|
|
350
|
+
at: timing.started_at,
|
|
351
|
+
attempt: row.attempt,
|
|
352
|
+
error: error.message,
|
|
353
|
+
trace: Array(error.backtrace).join("\n")
|
|
354
|
+
)
|
|
355
|
+
final = cancelled || row.attempt >= row.max_attempts || !retry_allowed?(worker, job, error)
|
|
356
|
+
state = if cancelled
|
|
357
|
+
JOB_STATE_CANCELLED
|
|
358
|
+
elsif final
|
|
359
|
+
JOB_STATE_DISCARDED
|
|
360
|
+
else
|
|
361
|
+
JOB_STATE_RETRYABLE
|
|
362
|
+
end
|
|
363
|
+
scheduled_at = final ? nil : next_retry(row, error, now, worker: worker)
|
|
364
|
+
state = JOB_STATE_AVAILABLE if scheduled_at && scheduled_at <= now + 5
|
|
365
|
+
|
|
366
|
+
updated = @driver.job_set_state_if_running(
|
|
367
|
+
id: row.id,
|
|
368
|
+
error: attempt_error,
|
|
369
|
+
finalized_at: final ? now : nil,
|
|
370
|
+
metadata: job.metadata_updates,
|
|
371
|
+
now: now,
|
|
372
|
+
scheduled_at: scheduled_at,
|
|
373
|
+
state: state
|
|
374
|
+
)
|
|
375
|
+
event = cancelled ? EVENT_JOB_CANCELLED : EVENT_JOB_FAILED
|
|
376
|
+
publish(event, updated, timing) if updated
|
|
377
|
+
|
|
378
|
+
case updated&.state || state
|
|
379
|
+
when JOB_STATE_CANCELLED then :cancelled
|
|
380
|
+
when JOB_STATE_DISCARDED then :discarded
|
|
381
|
+
else :retried
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
private def finish_interrupted(row, job, timing)
|
|
386
|
+
interrupted = @driver.job_set_state_if_running(
|
|
387
|
+
id: row.id,
|
|
388
|
+
attempt: [row.attempt - 1, 0].max,
|
|
389
|
+
metadata: job.metadata_updates,
|
|
390
|
+
scheduled_at: Time.now.utc,
|
|
391
|
+
state: JOB_STATE_AVAILABLE
|
|
392
|
+
)
|
|
393
|
+
publish(EVENT_JOB_INTERRUPTED, interrupted, timing) if interrupted
|
|
394
|
+
|
|
395
|
+
(interrupted&.state == JOB_STATE_CANCELLED) ? :cancelled : :interrupted
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
private def finish_snoozed(row, job, error, timing)
|
|
399
|
+
scheduled_at = Time.now.utc + error.duration
|
|
400
|
+
state = (error.duration <= 5) ? JOB_STATE_AVAILABLE : JOB_STATE_SCHEDULED
|
|
401
|
+
metadata = job.metadata_updates.merge("snoozes" => next_snooze_count(row.metadata["snoozes"]))
|
|
402
|
+
updated = @driver.job_set_state_if_running(
|
|
403
|
+
id: row.id,
|
|
404
|
+
attempt: [row.attempt - 1, 0].max,
|
|
405
|
+
metadata: metadata,
|
|
406
|
+
scheduled_at: scheduled_at,
|
|
407
|
+
state: state
|
|
408
|
+
)
|
|
409
|
+
publish(EVENT_JOB_SNOOZED, updated, timing) if updated
|
|
410
|
+
(updated&.state == JOB_STATE_CANCELLED) ? :cancelled : :snoozed
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
private def finish_work(id)
|
|
414
|
+
@mutex.synchronize do
|
|
415
|
+
entry = @running[id]
|
|
416
|
+
entry[:working] = false if entry
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
private def invoke_plugins(name, ...)
|
|
421
|
+
@config.plugins.filter_map { |plugin| plugin.public_send(name, ...) if plugin.respond_to?(name) }
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
private def invoke_worker(worker, job)
|
|
425
|
+
return perform_work(worker, job) if @config.plugins.empty?
|
|
426
|
+
|
|
427
|
+
operation = -> do
|
|
428
|
+
error = nil
|
|
429
|
+
begin
|
|
430
|
+
invoke_plugins(:work_begin, job)
|
|
431
|
+
perform_work(worker, job)
|
|
432
|
+
rescue => error
|
|
433
|
+
raise
|
|
434
|
+
ensure
|
|
435
|
+
invoke_plugins(:work_end, job, error)
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
@config.plugins.reverse_each do |plugin|
|
|
439
|
+
next unless plugin.respond_to?(:work)
|
|
440
|
+
|
|
441
|
+
next_operation = operation
|
|
442
|
+
operation = -> { plugin.work(job, next_operation) }
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
operation.call
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
private def launch(row)
|
|
449
|
+
gate = ::Queue.new
|
|
450
|
+
thread = Thread.new do
|
|
451
|
+
gate.pop
|
|
452
|
+
begin
|
|
453
|
+
Thread.handle_interrupt(Interrupted => :never, JobCancelError => :never) { execute(row) }
|
|
454
|
+
rescue Interrupted, JobCancelError
|
|
455
|
+
# A late asynchronous interrupt may become pending after work has
|
|
456
|
+
# already finalized. The database transition won that race.
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
@mutex.synchronize { @running[row.id] = {queue: row.queue, thread: thread, working: false} }
|
|
461
|
+
gate.push(true)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
private def maintenance_loop
|
|
465
|
+
leader = false
|
|
466
|
+
next_schedule = next_rescue = next_cleanup = Time.at(0)
|
|
467
|
+
until stopping?
|
|
468
|
+
now = Time.now.utc
|
|
469
|
+
leader = leader ? @driver.leader_renew(@config.id, now: now) : @driver.leader_acquire(@config.id, now: now)
|
|
470
|
+
if leader
|
|
471
|
+
if now >= next_schedule
|
|
472
|
+
@driver.job_schedule(now: now)
|
|
473
|
+
run_periodic(now)
|
|
474
|
+
@config.maintenance_services.each do |service|
|
|
475
|
+
service.run(@client, @driver, now)
|
|
476
|
+
rescue => error
|
|
477
|
+
@config.logger.error("River maintenance service failed (#{service.class}): #{error.full_message}")
|
|
478
|
+
end
|
|
479
|
+
next_schedule = now + 5
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
if now >= next_rescue
|
|
483
|
+
@driver.job_rescue_stuck(horizon: now - 3_600, logger: @config.logger, now: now,
|
|
484
|
+
rescue_if: method(:rescue_job?), retry_policy: @config.retry_policy)
|
|
485
|
+
next_rescue = now + 30
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
if now >= next_cleanup
|
|
489
|
+
@driver.job_delete_finalized(now: now, retention: {
|
|
490
|
+
JOB_STATE_CANCELLED => @config.cancelled_job_retention_period,
|
|
491
|
+
JOB_STATE_COMPLETED => @config.completed_job_retention_period,
|
|
492
|
+
JOB_STATE_DISCARDED => @config.discarded_job_retention_period
|
|
493
|
+
})
|
|
494
|
+
@driver.notification_delete_before(horizon: now - 300) if @driver.respond_to?(:notification_delete_before)
|
|
495
|
+
next_cleanup = now + 30
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
wait(5)
|
|
500
|
+
end
|
|
501
|
+
rescue => error
|
|
502
|
+
@config.logger.error("River maintenance stopped: #{error.full_message}")
|
|
503
|
+
wait(5)
|
|
504
|
+
retry unless stopping?
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
private def monotonic_now
|
|
508
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
private def next_retry(row, error, now, worker: nil)
|
|
512
|
+
worker ||= @config.workers[row.kind] unless row.__decode_error
|
|
513
|
+
custom = worker.next_retry(row, error) if worker.respond_to?(:next_retry)
|
|
514
|
+
|
|
515
|
+
retry_at = custom || @config.retry_policy.next_retry(row, error, now: now)
|
|
516
|
+
raise ArgumentError, "next_retry must return a Time" unless retry_at.is_a?(Time)
|
|
517
|
+
|
|
518
|
+
(retry_at < now) ? DefaultClientRetryPolicy.new.next_retry(row, error, now: now) : retry_at
|
|
519
|
+
rescue => retry_error
|
|
520
|
+
@config.logger.error("River retry scheduling failed; using default backoff: #{retry_error.full_message}")
|
|
521
|
+
DefaultClientRetryPolicy.new.next_retry(row, error, now: now)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Preserve Ruby's lenient counter conversion without calling to_i on
|
|
525
|
+
# booleans/collections, or accepting only a prefix of a numeric string.
|
|
526
|
+
# Only canonical non-negative integers are part of the shared protocol.
|
|
527
|
+
private def next_snooze_count(value)
|
|
528
|
+
count = case value
|
|
529
|
+
when Integer then value
|
|
530
|
+
when Float then value.finite? ? value.to_i : 0
|
|
531
|
+
when String then /\A-?[0-9]+\z/.match?(value) ? value.to_i : 0
|
|
532
|
+
when true then 1
|
|
533
|
+
else 0
|
|
534
|
+
end
|
|
535
|
+
# Go stores and increments a signed 64-bit integer.
|
|
536
|
+
((count + 1 + (1 << 63)) % (1 << 64)) - (1 << 63)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
private def perform_work(worker, job)
|
|
540
|
+
timeout = worker_timeout(worker, job)
|
|
541
|
+
result = timeout ? Timeout.timeout(timeout) { worker.work(job) } : worker.work(job)
|
|
542
|
+
job.__finish_resumable_work!
|
|
543
|
+
result
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
private def periodic_jobs_changed
|
|
547
|
+
start_maintenance
|
|
548
|
+
wake
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
private def producer_loop(queue, queue_config)
|
|
552
|
+
cooldown = queue_config.resolved_fetch_cooldown(@config)
|
|
553
|
+
# Capture registrations (including aliases) at startup, not construction.
|
|
554
|
+
fetch_options = {} #: Hash[Symbol, Array[String]]
|
|
555
|
+
fetch_options[:kinds] = @config.workers.kinds if @config.fetch_only_known_kinds
|
|
556
|
+
last_fetch = 0.0
|
|
557
|
+
poll_interval = queue_config.resolved_fetch_poll_interval(@config)
|
|
558
|
+
loop do
|
|
559
|
+
draining = queue_stopping?(queue)
|
|
560
|
+
break if draining && running_count(queue).zero?
|
|
561
|
+
|
|
562
|
+
check_remote_cancellations(queue)
|
|
563
|
+
if draining
|
|
564
|
+
wait_for_running_jobs(queue, poll_interval)
|
|
565
|
+
next
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
capacity = queue_config.max_workers - running_count(queue)
|
|
569
|
+
if capacity.positive?
|
|
570
|
+
# Inserts, completions, and spurious condition wakes must not bypass
|
|
571
|
+
# the minimum interval between database fetches.
|
|
572
|
+
while (sleep_for = cooldown - (monotonic_now - last_fetch)).positive? && !queue_stopping?(queue)
|
|
573
|
+
wait(sleep_for)
|
|
574
|
+
end
|
|
575
|
+
next if queue_stopping?(queue)
|
|
576
|
+
|
|
577
|
+
# A pause may arrive during cooldown; only read the queue immediately
|
|
578
|
+
# before fetching, and avoid the read entirely when all slots are busy.
|
|
579
|
+
unless @driver.queue_get(queue)&.paused_at
|
|
580
|
+
jobs = @driver.job_get_available(attempted_by: @config.id, max: capacity, queue: queue, **fetch_options)
|
|
581
|
+
last_fetch = monotonic_now
|
|
582
|
+
jobs.each { |job| launch(job) }
|
|
583
|
+
next unless jobs.empty?
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
wait(poll_interval)
|
|
588
|
+
end
|
|
589
|
+
rescue => error
|
|
590
|
+
@config.logger.error("River producer for #{queue.inspect} stopped: #{error.full_message}")
|
|
591
|
+
if queue_stopping?(queue)
|
|
592
|
+
wait_for_running_jobs(queue, poll_interval || @config.fetch_poll_interval)
|
|
593
|
+
else
|
|
594
|
+
wait(poll_interval || @config.fetch_poll_interval)
|
|
595
|
+
end
|
|
596
|
+
retry unless queue_stopping?(queue) && running_count(queue).zero?
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
private def publish(kind, job, timing)
|
|
600
|
+
# Another actor may have changed the row after our update. Pending,
|
|
601
|
+
# running, and unknown states do not represent a settled attempt.
|
|
602
|
+
return unless %w[available cancelled completed discarded retryable scheduled].include?(job.state)
|
|
603
|
+
|
|
604
|
+
kind = EVENT_JOB_CANCELLED if job.state == JOB_STATE_CANCELLED
|
|
605
|
+
stats = JobStatistics.new(
|
|
606
|
+
monotonic_now - timing.finished_monotonic,
|
|
607
|
+
timing.queue_wait_duration,
|
|
608
|
+
timing.finished_monotonic - timing.started_monotonic
|
|
609
|
+
)
|
|
610
|
+
event = Event.new(kind, job, nil, stats)
|
|
611
|
+
@mutex.synchronize { @subscriptions.dup }.each { |subscription| subscription.publish(event) }
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
private def queue_stopping?(queue)
|
|
615
|
+
@mutex.synchronize { @stop_requested || @removed_queues[queue] }
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
private def remove_subscription(subscription)
|
|
619
|
+
@mutex.synchronize { @subscriptions.delete(subscription) }
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
private def rescue_job?(row, now)
|
|
623
|
+
return true if row.__decode_error
|
|
624
|
+
|
|
625
|
+
worker = @config.workers[row.kind]
|
|
626
|
+
worker = worker.new if worker.is_a?(Class)
|
|
627
|
+
timeout = worker_timeout(worker, Job.new(@client, row))
|
|
628
|
+
attempted_at = row.attempted_at #: Time
|
|
629
|
+
!timeout.nil? && now - attempted_at >= timeout
|
|
630
|
+
rescue => error
|
|
631
|
+
@config.logger.error("River rescue timeout check failed; rescuing attempt: #{error.full_message}")
|
|
632
|
+
true
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
private def resolve_worker(kind)
|
|
636
|
+
worker = @config.workers[kind]
|
|
637
|
+
raise UnknownJobKindError, kind unless worker
|
|
638
|
+
worker.is_a?(Class) ? worker.new : worker
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
private def retry_allowed?(worker, job, error)
|
|
642
|
+
!worker.respond_to?(:retry?) || worker.retry?(job, error)
|
|
643
|
+
rescue => retry_error
|
|
644
|
+
@config.logger.error("River retry? hook failed; allowing retry: #{retry_error.full_message}")
|
|
645
|
+
true
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
private def run_periodic(now)
|
|
649
|
+
due = @periodic_jobs.due(now) do |job, error|
|
|
650
|
+
@config.logger.error("River periodic job schedule failed (id=#{job.id.inspect}): #{error.full_message}")
|
|
651
|
+
end
|
|
652
|
+
due.each do |periodic_job|
|
|
653
|
+
value = periodic_job.constructor.call
|
|
654
|
+
next unless value
|
|
655
|
+
|
|
656
|
+
args, opts = value.is_a?(Array) ? value : [value, nil]
|
|
657
|
+
opts = (opts || InsertOpts.new).dup
|
|
658
|
+
opts.metadata = (opts.metadata || {}).merge("periodic" => true)
|
|
659
|
+
opts.metadata["river:periodic_job_id"] = periodic_job.id if periodic_job.id && !periodic_job.id.empty?
|
|
660
|
+
@client.insert(args, insert_opts: opts)
|
|
661
|
+
rescue => error
|
|
662
|
+
@config.logger.error("River periodic job failed to insert: #{error.full_message}")
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
private def running_count(queue)
|
|
667
|
+
@mutex.synchronize { @running.count { |_id, entry| entry[:queue] == queue } }
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
private def start_maintenance
|
|
671
|
+
return if @config.leader_election_disabled
|
|
672
|
+
|
|
673
|
+
@mutex.synchronize do
|
|
674
|
+
return unless @started && !@stop_requested
|
|
675
|
+
return if @maintenance_thread&.alive?
|
|
676
|
+
|
|
677
|
+
thread = Thread.new { maintenance_loop }
|
|
678
|
+
@maintenance_thread = thread
|
|
679
|
+
@threads << thread
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
private def start_producer(name, queue_config)
|
|
684
|
+
@driver.queue_upsert(name)
|
|
685
|
+
@mutex.synchronize do
|
|
686
|
+
return if @stop_requested || @removed_queues[name] || @producer_threads.key?(name)
|
|
687
|
+
|
|
688
|
+
# Shutdown must see every launched thread, including a producer whose
|
|
689
|
+
# database setup overlapped a stop or queue removal.
|
|
690
|
+
thread = Thread.new { producer_loop(name, queue_config) }
|
|
691
|
+
@producer_threads[name] = thread
|
|
692
|
+
@threads << thread
|
|
693
|
+
end
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
private def stopping?
|
|
697
|
+
@mutex.synchronize { @stop_requested }
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
private def wait(duration)
|
|
701
|
+
@mutex.synchronize { @condition.wait(@mutex, duration) unless @stop_requested }
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
private def wait_for_running_jobs(queue, duration)
|
|
705
|
+
@mutex.synchronize do
|
|
706
|
+
# Draining still needs a poll interval after stop is requested. Check
|
|
707
|
+
# under the same mutex as completion so the last job's wake isn't lost.
|
|
708
|
+
@condition.wait(@mutex, duration) if @running.any? { |_id, entry| entry[:queue] == queue }
|
|
709
|
+
end
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
private def worker_timeout(worker, job)
|
|
713
|
+
timeout = worker.respond_to?(:timeout) ? worker.timeout(job) : @config.job_timeout
|
|
714
|
+
timeout = Float(timeout) unless timeout.nil?
|
|
715
|
+
timeout = @config.job_timeout if timeout == 0
|
|
716
|
+
raise ArgumentError, "worker timeout must be finite and nonnegative, or nil" if timeout && (!timeout.finite? || timeout.negative?)
|
|
717
|
+
|
|
718
|
+
timeout
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
end
|