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.
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 encoding an args struct on the Go side to be workable.
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
- insert_params = make_insert_params(args, insert_opts)
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 encoding an args struct on the Go side to be workable.
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. Raises NotFoundError if the job does not exist.
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
- # Starts polling configured queues and working jobs in background threads.
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)).to_s #: jobStateAll # rubocop:disable Layout/LeadingCommentSpace
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: insert_opts.max_attempts || args_insert_opts.max_attempts || MAX_ATTEMPTS_DEFAULT,
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: (insert_opts.queue || args_insert_opts.queue || QUEUE_DEFAULT).to_s,
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