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.rb
CHANGED
|
@@ -7,6 +7,9 @@ module River
|
|
|
7
7
|
# Default number of maximum attempts for a job.
|
|
8
8
|
MAX_ATTEMPTS_DEFAULT = 25
|
|
9
9
|
|
|
10
|
+
# Largest attempt limit supported by all River databases.
|
|
11
|
+
MAX_ATTEMPTS_LIMIT = 32_767
|
|
12
|
+
|
|
10
13
|
# Default priority for a job.
|
|
11
14
|
PRIORITY_DEFAULT = 1
|
|
12
15
|
|
|
@@ -40,10 +43,19 @@ module River
|
|
|
40
43
|
|
|
41
44
|
# Internal extension point used by separately packaged batch workers after
|
|
42
45
|
# they atomically claim additional jobs alongside the batch leader.
|
|
46
|
+
# Errors honor the registered worker's retry hooks and River's cancellation,
|
|
47
|
+
# snooze, and interruption signals. Timings use the row's claim timestamp.
|
|
43
48
|
def __finish_claimed_job(row, error = nil)
|
|
44
49
|
@runtime.finish_claimed(row, error)
|
|
45
50
|
end
|
|
46
51
|
|
|
52
|
+
# Internal scheduler entry point. Prepare ordinary insertion options and
|
|
53
|
+
# uniqueness before applying an occurrence fallback to the stored schedule.
|
|
54
|
+
def __insert_with_default_scheduled_at(args, insert_opts, default_scheduled_at)
|
|
55
|
+
insert_params = make_insert_params(args, insert_opts, default_scheduled_at)
|
|
56
|
+
run_insert_plugins([insert_params]) { [insert_and_check_unique_job(insert_params)] }.first
|
|
57
|
+
end
|
|
58
|
+
|
|
47
59
|
def __perform_job(id, allow_scheduled: false)
|
|
48
60
|
@runtime.perform_job(id, allow_scheduled: allow_scheduled)
|
|
49
61
|
end
|
|
@@ -72,7 +84,8 @@ module River
|
|
|
72
84
|
#
|
|
73
85
|
# * `#kind`: A symbol or string identifying the job kind in the database.
|
|
74
86
|
# * `#to_json`: Encodes the args to JSON for persistence in the database.
|
|
75
|
-
# Must match
|
|
87
|
+
# Must match the JSON expected by whichever River client works the
|
|
88
|
+
# job.
|
|
76
89
|
#
|
|
77
90
|
# They may also respond to `#insert_opts` which is expected to return an
|
|
78
91
|
# `InsertOpts` that contains options that will apply to all jobs of this
|
|
@@ -101,8 +114,7 @@ module River
|
|
|
101
114
|
def insert(args, insert_opts: nil, **options)
|
|
102
115
|
raise ArgumentError, "use insert_opts or keyword options, not both" if insert_opts && !options.empty?
|
|
103
116
|
insert_opts ||= options.empty? ? EMPTY_INSERT_OPTS : InsertOpts.new(**options)
|
|
104
|
-
|
|
105
|
-
run_insert_plugins([insert_params]) { [insert_and_check_unique_job(insert_params)] }.first
|
|
117
|
+
__insert_with_default_scheduled_at(args, insert_opts, nil)
|
|
106
118
|
end
|
|
107
119
|
|
|
108
120
|
# Inserts many new jobs as part of a single batch operation for improved
|
|
@@ -129,7 +141,8 @@ module River
|
|
|
129
141
|
#
|
|
130
142
|
# * `#kind`: A symbol or string identifying the job kind in the database.
|
|
131
143
|
# * `#to_json`: Encodes the args to JSON for persistence in the database.
|
|
132
|
-
# Must match
|
|
144
|
+
# Must match the JSON expected by whichever River client works the
|
|
145
|
+
# job.
|
|
133
146
|
#
|
|
134
147
|
# For example:
|
|
135
148
|
#
|
|
@@ -177,6 +190,44 @@ module River
|
|
|
177
190
|
@driver.job_cancel(id) || raise(NotFoundError, "job not found: #{id}")
|
|
178
191
|
end
|
|
179
192
|
|
|
193
|
+
# Completes a running Job and returns its updated JobRow, including pending
|
|
194
|
+
# metadata and output. The original JobRow is not modified.
|
|
195
|
+
#
|
|
196
|
+
# Requires and joins the caller's transaction on the same Active Record
|
|
197
|
+
# connection or Sequel database. Commit before returning from work;
|
|
198
|
+
# propagate rollback errors so the runtime can retry the job. A committed
|
|
199
|
+
# completion takes precedence over any subsequent worker error.
|
|
200
|
+
#
|
|
201
|
+
# Raises NotFoundError for a missing job, JobCancelError for a pending
|
|
202
|
+
# cancellation, and Error if there is no active transaction or the job is
|
|
203
|
+
# no longer running or completed.
|
|
204
|
+
def job_complete_tx(job)
|
|
205
|
+
raise ArgumentError, "job must be a River::Job" unless job.is_a?(Job)
|
|
206
|
+
raise ArgumentError, "job must belong to this client" unless job.client.equal?(self)
|
|
207
|
+
raise Error, "job must be running" unless job.row.state == JOB_STATE_RUNNING
|
|
208
|
+
raise Error, "job_complete_tx requires an active transaction on the driver's connection" unless @driver.in_transaction?
|
|
209
|
+
|
|
210
|
+
# Mark the attempt before the write: an interrupt can arrive after the
|
|
211
|
+
# database commits but before the driver returns. The runtime checks the
|
|
212
|
+
# persisted state because the caller may still roll back this transaction.
|
|
213
|
+
job.__completion_attempted = true
|
|
214
|
+
now = @time_now_utc.call
|
|
215
|
+
completed = @driver.job_complete(id: job.row.id, finalized_at: now, expected_job: job.row, metadata: job.metadata_updates, now: now)
|
|
216
|
+
raise JobCancelError if completed == :cancelled
|
|
217
|
+
|
|
218
|
+
completed ||= job_get(job.row.id)
|
|
219
|
+
unless completed.attempt == job.row.attempt && completed.attempted_at == job.row.attempted_at
|
|
220
|
+
raise Error, "job attempt is no longer current"
|
|
221
|
+
end
|
|
222
|
+
raise Error, "job must be running" unless completed.state == JOB_STATE_COMPLETED
|
|
223
|
+
|
|
224
|
+
# Retention or application code may delete the row before work returns.
|
|
225
|
+
# Only retain a snapshot after the caller's outer transaction commits;
|
|
226
|
+
# rolled-back transactions and savepoints must not report completion.
|
|
227
|
+
@driver.after_commit { job.__completion_snapshot = completed }
|
|
228
|
+
completed
|
|
229
|
+
end
|
|
230
|
+
|
|
180
231
|
# Deletes a job by ID and returns its former JobRow.
|
|
181
232
|
#
|
|
182
233
|
# Raises NotFoundError if the job does not exist and JobRunningError if it
|
|
@@ -223,13 +274,18 @@ module River
|
|
|
223
274
|
|
|
224
275
|
# Makes a non-running job immediately available for another attempt and
|
|
225
276
|
# returns its updated JobRow. Raises NotFoundError if it does not exist.
|
|
277
|
+
# Clears any cancellation request from its previous attempt. Raises
|
|
278
|
+
# ArgumentError if another attempt would exceed MAX_ATTEMPTS_LIMIT.
|
|
226
279
|
def job_retry(id)
|
|
227
280
|
@driver.job_retry(id) || raise(NotFoundError, "job not found: #{id}")
|
|
228
281
|
end
|
|
229
282
|
|
|
230
283
|
# Applies keyword attributes or a JobUpdateParams to a job and returns its
|
|
231
284
|
# updated JobRow. Explicit nil clears nullable fields; omitted fields are
|
|
232
|
-
# unchanged.
|
|
285
|
+
# unchanged. Metadata must be a Hash; use an empty Hash to clear it.
|
|
286
|
+
# attempt must be between 0 and MAX_ATTEMPTS_LIMIT (32,767);
|
|
287
|
+
# max_attempts must be between 1 and MAX_ATTEMPTS_LIMIT.
|
|
288
|
+
# Raises NotFoundError if the job does not exist.
|
|
233
289
|
# @type method job_update: (Integer, ?JobUpdateParams?, **untyped) -> JobRow
|
|
234
290
|
def job_update(id, params = nil, **attributes)
|
|
235
291
|
raise ArgumentError, "use params or keyword attributes, not both" if params && !attributes.empty?
|
|
@@ -266,9 +322,8 @@ module River
|
|
|
266
322
|
# Pauses a named queue, or all queues when +name+ is +"*"+.
|
|
267
323
|
def queue_pause(name)
|
|
268
324
|
name = name.to_s
|
|
269
|
-
@driver.queue_pause(name)
|
|
325
|
+
queues = @driver.queue_pause(name)
|
|
270
326
|
@runtime.wake
|
|
271
|
-
queues = (name == "*") ? @driver.queue_list : [@driver.queue_get(name)].compact
|
|
272
327
|
queues.each do |queue|
|
|
273
328
|
@runtime.publish_queue(EVENT_QUEUE_PAUSED, queue)
|
|
274
329
|
end
|
|
@@ -278,6 +333,7 @@ module River
|
|
|
278
333
|
|
|
279
334
|
# Removes a configured queue, waits for active jobs in it to finish, and
|
|
280
335
|
# returns self.
|
|
336
|
+
# Raises ThreadError if called from that queue's own worker or producer.
|
|
281
337
|
def queue_remove(name)
|
|
282
338
|
@runtime.queue_remove(name.to_s)
|
|
283
339
|
self
|
|
@@ -286,9 +342,8 @@ module River
|
|
|
286
342
|
# Resumes a named queue, or all queues when +name+ is +"*"+.
|
|
287
343
|
def queue_resume(name)
|
|
288
344
|
name = name.to_s
|
|
289
|
-
@driver.queue_resume(name)
|
|
345
|
+
queues = @driver.queue_resume(name)
|
|
290
346
|
@runtime.wake
|
|
291
|
-
queues = (name == "*") ? @driver.queue_list : [@driver.queue_get(name)].compact
|
|
292
347
|
queues.each do |queue|
|
|
293
348
|
@runtime.publish_queue(EVENT_QUEUE_RESUMED, queue)
|
|
294
349
|
end
|
|
@@ -301,7 +356,13 @@ module River
|
|
|
301
356
|
@driver.queue_update(name.to_s, metadata: metadata) || raise(NotFoundError, "queue not found: #{name}")
|
|
302
357
|
end
|
|
303
358
|
|
|
304
|
-
#
|
|
359
|
+
# Requests that the current leader resign. Delivery waits for the caller's
|
|
360
|
+
# Active Record or Sequel transaction to commit, if one is open.
|
|
361
|
+
def request_resign
|
|
362
|
+
@driver.request_resign
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Starts notification receiving, polling, and working configured queues.
|
|
305
366
|
# Returns self.
|
|
306
367
|
def start
|
|
307
368
|
@runtime.start
|
|
@@ -316,12 +377,14 @@ module River
|
|
|
316
377
|
# waiting; call stop again to finish draining and release resources.
|
|
317
378
|
# In-flight fetches or maintenance operations may finish. Until a waiting
|
|
318
379
|
# stop completes, started? remains true and stopped? remains false.
|
|
380
|
+
# Raises ThreadError for a waiting call from this client's runtime threads.
|
|
319
381
|
def stop(wait: true)
|
|
320
382
|
@runtime.stop(wait: wait)
|
|
321
383
|
self
|
|
322
384
|
end
|
|
323
385
|
|
|
324
386
|
# Stops fetching new jobs, interrupts active work, and returns self.
|
|
387
|
+
# Raises ThreadError if called from this client's own runtime threads.
|
|
325
388
|
def stop_and_cancel
|
|
326
389
|
@runtime.stop(cancel: true)
|
|
327
390
|
self
|
|
@@ -350,6 +413,8 @@ module River
|
|
|
350
413
|
|
|
351
414
|
EMPTY_INSERT_OPTS = InsertOpts.new.freeze
|
|
352
415
|
|
|
416
|
+
INITIAL_STATES = [JOB_STATE_AVAILABLE, JOB_STATE_PENDING, JOB_STATE_SCHEDULED].freeze
|
|
417
|
+
|
|
353
418
|
REQUIRED_UNIQUE_STATES = [
|
|
354
419
|
JOB_STATE_AVAILABLE,
|
|
355
420
|
JOB_STATE_PENDING,
|
|
@@ -359,14 +424,14 @@ module River
|
|
|
359
424
|
|
|
360
425
|
TAG_RE = /\A\w[\w-]+\w\z/
|
|
361
426
|
|
|
362
|
-
private_constant :DEFAULT_UNIQUE_STATES, :EMPTY_INSERT_OPTS, :REQUIRED_UNIQUE_STATES, :TAG_RE
|
|
427
|
+
private_constant :DEFAULT_UNIQUE_STATES, :EMPTY_INSERT_OPTS, :INITIAL_STATES, :REQUIRED_UNIQUE_STATES, :TAG_RE
|
|
363
428
|
|
|
364
429
|
private def insert_and_check_unique_job(insert_params)
|
|
365
430
|
job, unique_skipped_as_duplicate = @driver.job_insert(insert_params)
|
|
366
431
|
JobInsertResult.new(job, unique_skipped_as_duplicated: unique_skipped_as_duplicate)
|
|
367
432
|
end
|
|
368
433
|
|
|
369
|
-
private def make_insert_params(args, insert_opts)
|
|
434
|
+
private def make_insert_params(args, insert_opts, default_scheduled_at = nil)
|
|
370
435
|
raise ArgumentError, "args should respond to `#kind`" if !args.respond_to?(:kind)
|
|
371
436
|
|
|
372
437
|
# ~all objects in Ruby respond to `#to_json`, so check non-nil instead.
|
|
@@ -380,18 +445,25 @@ module River
|
|
|
380
445
|
EMPTY_INSERT_OPTS
|
|
381
446
|
end
|
|
382
447
|
|
|
448
|
+
max_attempts = insert_opts.max_attempts || args_insert_opts.max_attempts || MAX_ATTEMPTS_DEFAULT
|
|
449
|
+
raise ArgumentError, "max_attempts must be greater than zero" unless max_attempts > 0
|
|
450
|
+
raise ArgumentError, "max_attempts must not exceed #{MAX_ATTEMPTS_LIMIT}" if max_attempts > MAX_ATTEMPTS_LIMIT
|
|
451
|
+
|
|
452
|
+
queue = (insert_opts.queue || args_insert_opts.queue || QUEUE_DEFAULT).to_s
|
|
453
|
+
raise ArgumentError, "invalid queue name: #{queue.inspect}" unless queue.match?(QUEUE_NAME_REGEX) && queue.length < 128
|
|
454
|
+
|
|
383
455
|
scheduled_at = insert_opts.scheduled_at || args_insert_opts.scheduled_at
|
|
384
456
|
now = @time_now_utc.call
|
|
385
|
-
state = (insert_opts.state || args_insert_opts.state || ((scheduled_at && scheduled_at > now) ? JOB_STATE_SCHEDULED : JOB_STATE_AVAILABLE))
|
|
457
|
+
state = validate_insert_state(insert_opts.state || args_insert_opts.state || ((scheduled_at && scheduled_at > now) ? JOB_STATE_SCHEDULED : JOB_STATE_AVAILABLE))
|
|
386
458
|
|
|
387
459
|
insert_params = Driver::JobInsertParams.new(
|
|
388
460
|
args: args,
|
|
389
461
|
encoded_args: args_json,
|
|
390
462
|
kind: args.kind.to_s,
|
|
391
|
-
max_attempts:
|
|
463
|
+
max_attempts: max_attempts,
|
|
392
464
|
metadata: (args_insert_opts.metadata || {}).merge(insert_opts.metadata || {}),
|
|
393
465
|
priority: insert_opts.priority || args_insert_opts.priority || PRIORITY_DEFAULT,
|
|
394
|
-
queue:
|
|
466
|
+
queue: queue,
|
|
395
467
|
scheduled_at: scheduled_at&.getutc || now,
|
|
396
468
|
state: state,
|
|
397
469
|
tags: validate_tags(insert_opts.tags || args_insert_opts.tags || [])
|
|
@@ -404,6 +476,9 @@ module River
|
|
|
404
476
|
insert_params.unique_states = unique_states
|
|
405
477
|
end
|
|
406
478
|
|
|
479
|
+
if scheduled_at.nil? && default_scheduled_at
|
|
480
|
+
insert_params.scheduled_at = default_scheduled_at.getutc
|
|
481
|
+
end
|
|
407
482
|
insert_params
|
|
408
483
|
end
|
|
409
484
|
|
|
@@ -423,6 +498,8 @@ module River
|
|
|
423
498
|
end
|
|
424
499
|
|
|
425
500
|
if unique_opts.by_period && unique_opts.by_period != 0
|
|
501
|
+
raise ArgumentError, "by_period should not be less than 1 second" if unique_opts.by_period < 1
|
|
502
|
+
|
|
426
503
|
lower_period_bound = truncate_time(insert_params.scheduled_at || @time_now_utc.call, unique_opts.by_period).utc
|
|
427
504
|
|
|
428
505
|
unique_key += "&period=#{lower_period_bound.strftime("%FT%TZ")}"
|
|
@@ -456,6 +533,10 @@ module River
|
|
|
456
533
|
end
|
|
457
534
|
end
|
|
458
535
|
|
|
536
|
+
# Hooks may change any job in the batch, including an earlier entry.
|
|
537
|
+
# Validate the final states only after all insertion hooks have run.
|
|
538
|
+
all_params.each { |params| params.state = validate_insert_state(params.state) }
|
|
539
|
+
|
|
459
540
|
results = insert_operation.call
|
|
460
541
|
results.each do |result|
|
|
461
542
|
config.plugins.reverse_each do |plugin|
|
|
@@ -496,6 +577,13 @@ module River
|
|
|
496
577
|
[int].pack("Q").unpack1("q") #: Integer # rubocop:disable Layout/LeadingCommentSpace
|
|
497
578
|
end
|
|
498
579
|
|
|
580
|
+
private def validate_insert_state(state)
|
|
581
|
+
state = state.to_s #: jobStateInitial # rubocop:disable Layout/LeadingCommentSpace
|
|
582
|
+
raise ArgumentError, "invalid insertion state: #{state.inspect}; must be available, pending, or scheduled" unless INITIAL_STATES.include?(state)
|
|
583
|
+
|
|
584
|
+
state
|
|
585
|
+
end
|
|
586
|
+
|
|
499
587
|
private def validate_tags(tags)
|
|
500
588
|
tags.each do |tag|
|
|
501
589
|
raise ArgumentError, "tags should be 255 characters or less" if tag.length > 255
|