riverqueue 0.12.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.
@@ -65,13 +65,22 @@ module River::Driver
65
65
  runtime_claim_jobs(predicate, 1, attempted_by, now).first
66
66
  end
67
67
 
68
- # Drivers may combine the cancellation probe and completion atomically.
68
+ # The cancellation predicate must be part of the completion update so it is
69
+ # checked against the row we lock, including concurrent cancellations.
69
70
  # :cancelled asks the runtime to apply its normal cancellation/error hooks.
70
71
  def job_complete(id:, finalized_at:, metadata: nil, now: Time.now.utc)
71
72
  id = Integer(id)
72
- return :cancelled if job_get_cancelled_ids([id]).include?(id)
73
+ assignments = ["state = 'completed'", "finalized_at = #{runtime_time(finalized_at)}"]
74
+ assignments << "metadata = #{runtime_merge_metadata(metadata)}" unless metadata.nil? || metadata.empty?
75
+
76
+ updated_id = runtime_returning_ids(<<~SQL).first
77
+ UPDATE river_job SET #{assignments.join(", ")}
78
+ WHERE id = #{id} AND state = 'running' AND NOT (#{runtime_cancel_attempted})
79
+ RETURNING id
80
+ SQL
81
+ return runtime_read_job(updated_id) if updated_id
73
82
 
74
- job_set_state_if_running(id: id, finalized_at: finalized_at, metadata: metadata, now: now, state: "completed")
83
+ :cancelled if job_get_cancelled_ids([id]).include?(id)
75
84
  end
76
85
 
77
86
  def job_delete(id)
@@ -104,13 +113,24 @@ module River::Driver
104
113
  SQL
105
114
  end
106
115
 
116
+ # A finalization hook's deletion must honor the same cancellation marker as
117
+ # completion. Return :cancelled so the runtime can record the cancellation.
107
118
  def job_delete_if_running(id)
108
- runtime_returning_ids("DELETE FROM river_job WHERE id = #{Integer(id)} AND state = 'running' RETURNING id").any?
119
+ id = Integer(id)
120
+ deleted = runtime_returning_ids(<<~SQL).any?
121
+ DELETE FROM river_job
122
+ WHERE id = #{id} AND state = 'running' AND NOT (#{runtime_cancel_attempted})
123
+ RETURNING id
124
+ SQL
125
+ return true if deleted
126
+ return :cancelled if job_get_cancelled_ids([id]).include?(id)
127
+
128
+ false
109
129
  end
110
130
 
111
131
  def job_delete_many(params)
112
132
  transaction do
113
- jobs = job_list(params).reject { |job| job.state == River::JOB_STATE_RUNNING }
133
+ jobs = runtime_job_list(params, for_delete: true)
114
134
  next [] if jobs.empty?
115
135
 
116
136
  ids = jobs.map(&:id)
@@ -142,36 +162,7 @@ module River::Driver
142
162
  params ||= River::JobListParams.new
143
163
  return runtime_job_list_without_params if params == :all
144
164
 
145
- clauses = [] #: Array[String]
146
- clauses << runtime_cursor_clause(params) if params.after
147
- clauses << "id #{(params.sort_order == :asc) ? ">" : "<"} #{Integer(params.after_id)}" if params.after_id
148
- clauses << runtime_in_clause("id", params.ids.map { |value| Integer(value) }) if params.ids&.any?
149
- clauses << runtime_in_clause("kind", params.kinds) if params.kinds&.any?
150
- clauses << runtime_in_clause("priority", params.priorities.map { |value| Integer(value) }) if params.priorities&.any?
151
- clauses << runtime_in_clause("queue", params.queues) if params.queues&.any?
152
- clauses << runtime_in_clause("state", params.states) if params.states&.any?
153
-
154
- finalized = params.sort_by == :finalized_at && params.states&.length == 1 &&
155
- %w[cancelled completed discarded].include?(params.states.first)
156
- # Schemas require finalized timestamps for terminal states. Spell this out
157
- # so PostgreSQL can use the partial (state, finalized_at) index.
158
- clauses << "finalized_at IS NOT NULL" if finalized
159
- # Explicit NULLS LAST prevents a backward index scan, even when there are
160
- # no nulls. Only request it for timestamps that can actually be null.
161
- null_order = (params.sort_by == :finalized_at && !finalized) ? " NULLS LAST" : ""
162
-
163
- params.metadata&.each { |key, value| clauses << runtime_metadata_equals(key, value) }
164
- Array(params.tags_all).each { |tag| clauses << runtime_tag_contains(tag) }
165
- if params.tags_any&.any?
166
- clauses << "(" + params.tags_any.map { |tag| runtime_tag_contains(tag) }.join(" OR ") + ")"
167
- end
168
-
169
- where = clauses.empty? ? "" : "WHERE #{clauses.join(" AND ")}"
170
- runtime_job_rows(<<~SQL)
171
- #{where}
172
- ORDER BY #{params.sort_by} #{params.sort_order.to_s.upcase}#{null_order}, id #{params.sort_order.to_s.upcase}
173
- LIMIT #{params.limit}
174
- SQL
165
+ runtime_job_list(params)
175
166
  end
176
167
 
177
168
  def job_metadata_merge(id, metadata)
@@ -184,56 +175,81 @@ module River::Driver
184
175
  updated_id ? job_get_by_id(updated_id) : nil
185
176
  end
186
177
 
187
- def job_rescue_stuck(horizon:, retry_policy:, now: Time.now.utc, max: 1_000)
178
+ def job_rescue_stuck(horizon:, retry_policy:, now: Time.now.utc, max: 1_000, logger: nil, rescue_if: nil)
179
+ max = Integer(max)
188
180
  transaction do
189
- # Select only stuck jobs before applying the limit, and hold their locks
190
- # until rescue finishes so a newer attempt cannot be rescued by mistake.
181
+ # Lock each candidate until its transition finishes. Continue past jobs
182
+ # that are still within their timeouts without consuming the rescue limit.
183
+ after_id = rescued = 0
191
184
  lock = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : ""
192
- ids = runtime_returning_ids(<<~SQL)
193
- SELECT id FROM river_job
194
- WHERE state = 'running' AND attempted_at < #{runtime_time(horizon)}
195
- ORDER BY id LIMIT #{Integer(max)} #{lock}
196
- SQL
197
- jobs = ids.map { |id| runtime_read_job(id) }
198
- jobs.each do |job|
199
- cancelled = job.metadata.key?("cancel_attempted_at")
200
- final = cancelled || job.attempt >= job.max_attempts
201
- state = if cancelled
202
- River::JOB_STATE_CANCELLED
203
- elsif final
204
- River::JOB_STATE_DISCARDED
205
- else
206
- River::JOB_STATE_RETRYABLE
185
+ while rescued < max
186
+ ids = runtime_returning_ids(<<~SQL)
187
+ SELECT id FROM river_job
188
+ WHERE state = 'running' AND attempted_at < #{runtime_time(horizon)} AND id > #{after_id}
189
+ ORDER BY id LIMIT #{max - rescued} #{lock}
190
+ SQL
191
+ break if ids.empty?
192
+
193
+ after_id = ids.last
194
+ ids.each do |id|
195
+ job = runtime_read_job(id)
196
+ cancelled = job.metadata.key?("cancel_attempted_at")
197
+ next if !cancelled && rescue_if && !rescue_if.call(job, now)
198
+ final = cancelled || job.attempt >= job.max_attempts
199
+ state = if cancelled
200
+ River::JOB_STATE_CANCELLED
201
+ elsif final
202
+ River::JOB_STATE_DISCARDED
203
+ else
204
+ River::JOB_STATE_RETRYABLE
205
+ end
206
+ error = River::AttemptError.new(at: now, attempt: job.attempt, error: "Stuck job rescued by River", trace: "")
207
+ rescue_count = job.metadata["river:rescue_count"]
208
+ rescue_count = case rescue_count
209
+ when Integer then rescue_count
210
+ when Float then rescue_count.finite? ? rescue_count.to_i : 0
211
+ else 0
212
+ end
213
+ job_set_state_if_running(
214
+ id: job.id,
215
+ error: error,
216
+ finalized_at: final ? now : nil,
217
+ metadata: {"river:rescue_count" => rescue_count + 1},
218
+ now: now,
219
+ scheduled_at: final ? nil : runtime_rescue_retry(job, error, retry_policy, now, logger),
220
+ state: state
221
+ )
222
+ rescued += 1
207
223
  end
208
- error = River::AttemptError.new(at: now, attempt: job.attempt, error: "Stuck job rescued by River", trace: "")
209
- job_set_state_if_running(
210
- id: job.id,
211
- error: error,
212
- finalized_at: final ? now : nil,
213
- metadata: {"river:rescue_count" => job.metadata.fetch("river:rescue_count", 0).to_i + 1},
214
- now: now,
215
- scheduled_at: final ? nil : retry_policy.next_retry(job, error, now: now),
216
- state: state
217
- )
218
224
  end
219
225
 
220
- jobs.length
226
+ rescued
221
227
  end
222
228
  end
223
229
 
224
230
  def job_retry(id, now: Time.now.utc)
225
- updated_id = runtime_returning_ids(<<~SQL).first
226
- UPDATE river_job
227
- SET state = 'available',
228
- max_attempts = CASE WHEN attempt = max_attempts THEN max_attempts + 1 ELSE max_attempts END,
229
- finalized_at = NULL,
230
- scheduled_at = #{runtime_time(now)}
231
- WHERE id = #{Integer(id)}
232
- AND state != 'running'
233
- AND (state != 'available' OR scheduled_at > #{runtime_time(now)})
234
- RETURNING id
235
- SQL
236
- job_get_by_id(updated_id || id)
231
+ updated_id, job = transaction do
232
+ # Check the counter in the write itself so a concurrent claim cannot
233
+ # advance it between validation and the update.
234
+ updated_id = runtime_returning_ids(<<~SQL).first
235
+ UPDATE river_job
236
+ SET state = 'available',
237
+ max_attempts = CASE WHEN attempt = max_attempts THEN max_attempts + 1 ELSE max_attempts END,
238
+ metadata = #{runtime_postgres? ? "metadata - 'cancel_attempted_at'" : "jsonb_remove(metadata, '$.cancel_attempted_at')"},
239
+ finalized_at = NULL,
240
+ scheduled_at = #{runtime_time(now)}
241
+ WHERE id = #{Integer(id)}
242
+ AND state != 'running'
243
+ AND (state != 'available' OR scheduled_at > #{runtime_time(now)})
244
+ AND attempt < #{River::MAX_ATTEMPTS_LIMIT}
245
+ RETURNING id
246
+ SQL
247
+ [updated_id, job_get_by_id(updated_id || id)]
248
+ end
249
+ if !updated_id && job && job.state != "running" && (job.state != "available" || job.scheduled_at > now) && job.attempt >= River::MAX_ATTEMPTS_LIMIT
250
+ raise ArgumentError, "cannot retry a job with #{River::MAX_ATTEMPTS_LIMIT} or more attempts"
251
+ end
252
+ job
237
253
  end
238
254
 
239
255
  def job_schedule(now: Time.now.utc, max: 1_000)
@@ -267,8 +283,9 @@ module River::Driver
267
283
  def job_set_state_if_running(id:, state:, now: Time.now.utc, attempt: nil,
268
284
  error: nil, finalized_at: nil, metadata: nil, scheduled_at: nil)
269
285
  state = state.to_s
270
- retrying = [River::JOB_STATE_AVAILABLE, River::JOB_STATE_RETRYABLE, River::JOB_STATE_SCHEDULED].include?(state)
271
- cancel_path = retrying ? runtime_cancel_attempted : "false"
286
+ # Cancellation wins over every attempt outcome, including exhausted
287
+ # retries, direct completion, and transitions requested by extensions.
288
+ cancel_path = runtime_cancel_attempted
272
289
 
273
290
  assignments = [] #: Array[String]
274
291
  assignments << "attempt = CASE WHEN NOT (#{cancel_path}) THEN #{Integer(attempt)} ELSE attempt END" unless attempt.nil?
@@ -319,7 +336,10 @@ module River::Driver
319
336
  end
320
337
 
321
338
  def leader_release(id)
322
- runtime_execute("DELETE FROM river_leader WHERE leader_id = #{runtime_quote(id)}")
339
+ transaction do
340
+ deleted = runtime_query_rows("DELETE FROM river_leader WHERE leader_id = #{runtime_quote(id)} RETURNING leader_id")
341
+ runtime_notify("river_leadership", action: "resigned", leader_id: id) unless deleted.empty?
342
+ end
323
343
  end
324
344
 
325
345
  def leader_renew(id, ttl: 30, now: Time.now.utc)
@@ -356,34 +376,53 @@ module River::Driver
356
376
  end
357
377
 
358
378
  def queue_pause(name, now: Time.now.utc)
359
- filter = (name == "*") ? "true" : "name = #{runtime_quote(name)}"
360
- runtime_execute(<<~SQL)
361
- UPDATE river_queue
362
- SET paused_at = CASE WHEN paused_at IS NULL THEN #{runtime_time(now)} ELSE paused_at END,
363
- updated_at = CASE WHEN paused_at IS NULL THEN #{runtime_time(now)} ELSE updated_at END
364
- WHERE #{filter}
365
- SQL
379
+ transaction do
380
+ filter = (name == "*") ? "true" : "name = #{runtime_quote(name)}"
381
+ changed = runtime_query_rows(<<~SQL)
382
+ UPDATE river_queue
383
+ SET paused_at = CASE WHEN paused_at IS NULL THEN #{runtime_time(now)} ELSE paused_at END,
384
+ updated_at = CASE WHEN paused_at IS NULL THEN #{runtime_time(now)} ELSE updated_at END
385
+ WHERE #{filter}
386
+ RETURNING #{runtime_queue_columns}
387
+ SQL
388
+ runtime_notify("river_control", action: "pause", queue: name) unless changed.empty?
389
+ changed.map { |row| runtime_queue_from_row(row) }
390
+ end
366
391
  end
367
392
 
368
393
  def queue_resume(name, now: Time.now.utc)
369
- filter = (name == "*") ? "true" : "name = #{runtime_quote(name)}"
370
- runtime_execute(<<~SQL)
371
- UPDATE river_queue
372
- SET updated_at = CASE WHEN paused_at IS NOT NULL THEN #{runtime_time(now)} ELSE updated_at END,
373
- paused_at = NULL
374
- WHERE #{filter}
375
- SQL
394
+ transaction do
395
+ filter = (name == "*") ? "true" : "name = #{runtime_quote(name)}"
396
+ changed = runtime_query_rows(<<~SQL)
397
+ UPDATE river_queue
398
+ SET updated_at = CASE WHEN paused_at IS NOT NULL THEN #{runtime_time(now)} ELSE updated_at END,
399
+ paused_at = NULL
400
+ WHERE #{filter}
401
+ RETURNING #{runtime_queue_columns}
402
+ SQL
403
+ runtime_notify("river_control", action: "resume", queue: name) unless changed.empty?
404
+ changed.map { |row| runtime_queue_from_row(row) }
405
+ end
376
406
  end
377
407
 
378
408
  def queue_update(name, metadata:, now: Time.now.utc)
379
- id = runtime_query_rows(<<~SQL).first
380
- UPDATE river_queue SET metadata = #{runtime_json(metadata)}, updated_at = #{runtime_time(now)}
381
- WHERE name = #{runtime_quote(name)} RETURNING name
382
- SQL
383
- id ? queue_get(name) : nil
409
+ raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
410
+
411
+ transaction do
412
+ row = runtime_query_rows(<<~SQL).first
413
+ UPDATE river_queue SET metadata = #{runtime_json(metadata)}, updated_at = #{runtime_time(now)}
414
+ WHERE name = #{runtime_quote(name)} RETURNING name
415
+ SQL
416
+ if row
417
+ runtime_notify("river_control", action: "metadata_changed", queue: name, metadata: metadata)
418
+ queue_get(name)
419
+ end
420
+ end
384
421
  end
385
422
 
386
423
  def queue_upsert(name, metadata: {}, now: Time.now.utc)
424
+ raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
425
+
387
426
  runtime_execute(<<~SQL)
388
427
  INSERT INTO river_queue (name, created_at, metadata, updated_at)
389
428
  VALUES (#{runtime_quote(name)}, #{runtime_time(now)}, #{runtime_json(metadata)}, #{runtime_time(now)})
@@ -419,17 +458,27 @@ module River::Driver
419
458
  "array_append(CASE WHEN cardinality(attempted_by) >= 100 THEN attempted_by[(cardinality(attempted_by) - 98):] ELSE attempted_by END, #{runtime_quote(attempted_by)})"
420
459
  else
421
460
  # Preserve corrupt history so decoding can fail this attempt instead
422
- # of silently repairing it or aborting the entire claim batch.
461
+ # of silently repairing it or aborting the entire claim batch. Trim
462
+ # to the newest 99 entries before appending, preserving JSON types.
423
463
  <<~SQL
424
464
  CASE WHEN NOT json_valid(attempted_by, 10) AND attempted_by IS NOT NULL THEN attempted_by
425
- ELSE jsonb(json_insert(json(coalesce(attempted_by, jsonb('[]'))), '$[#]', #{runtime_quote(attempted_by)})) END
465
+ ELSE jsonb(json_insert(json(
466
+ CASE WHEN json_array_length(attempted_by) >= 100 THEN (
467
+ SELECT json_group_array(json(value)) FROM (
468
+ SELECT attempted_by -> ('$[' || key || ']') AS value FROM json_each(attempted_by)
469
+ WHERE key >= json_array_length(attempted_by) - 99 ORDER BY key
470
+ )
471
+ ) ELSE coalesce(attempted_by, jsonb('[]')) END
472
+ ), '$[#]', #{runtime_quote(attempted_by)})) END
426
473
  SQL
427
474
  end
428
475
 
429
476
  lock_clause = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : ""
477
+ # An administratively requeued job may already be at the counter limit.
478
+ # Saturate instead of letting that row abort the entire claim batch.
430
479
  ids = runtime_returning_ids(<<~SQL)
431
480
  UPDATE river_job
432
- SET attempt = attempt + 1,
481
+ SET attempt = CASE WHEN attempt < #{River::MAX_ATTEMPTS_LIMIT} THEN attempt + 1 ELSE attempt END,
433
482
  attempted_at = #{runtime_time(now)},
434
483
  attempted_by = #{attempted_by_sql},
435
484
  state = 'running'
@@ -531,12 +580,49 @@ module River::Driver
531
580
  "(#{column} IS NULL OR #{column} #{comparison} #{value} OR (#{column} = #{value} AND #{id_clause}))"
532
581
  end
533
582
 
583
+ private def runtime_job_list(params, for_delete: false)
584
+ clauses = [] #: Array[String]
585
+ clauses << "state != 'running'" if for_delete
586
+ clauses << runtime_cursor_clause(params) if params.after
587
+ clauses << "id #{(params.sort_order == :asc) ? ">" : "<"} #{Integer(params.after_id)}" if params.after_id
588
+ clauses << runtime_in_clause("id", params.ids.map { |value| Integer(value) }) if params.ids&.any?
589
+ clauses << runtime_in_clause("kind", params.kinds) if params.kinds&.any?
590
+ clauses << runtime_in_clause("priority", params.priorities.map { |value| Integer(value) }) if params.priorities&.any?
591
+ clauses << runtime_in_clause("queue", params.queues) if params.queues&.any?
592
+ clauses << runtime_in_clause("state", params.states) if params.states&.any?
593
+
594
+ finalized = params.sort_by == :finalized_at && params.states&.length == 1 &&
595
+ %w[cancelled completed discarded].include?(params.states.first)
596
+ # Schemas require finalized timestamps for terminal states. Spell this out
597
+ # so PostgreSQL can use the partial (state, finalized_at) index.
598
+ clauses << "finalized_at IS NOT NULL" if finalized
599
+ # Explicit NULLS LAST prevents a backward index scan, even when there are
600
+ # no nulls. Only request it for timestamps that can actually be null.
601
+ null_order = (params.sort_by == :finalized_at && !finalized) ? " NULLS LAST" : ""
602
+
603
+ params.metadata&.each { |key, value| clauses << runtime_metadata_equals(key, value) }
604
+ Array(params.tags_all).each { |tag| clauses << runtime_tag_contains(tag) }
605
+ if params.tags_any&.any?
606
+ clauses << "(" + params.tags_any.map { |tag| runtime_tag_contains(tag) }.join(" OR ") + ")"
607
+ end
608
+
609
+ where = clauses.empty? ? "" : "WHERE #{clauses.join(" AND ")}"
610
+ runtime_job_rows(<<~SQL)
611
+ #{where}
612
+ ORDER BY #{params.sort_by} #{params.sort_order.to_s.upcase}#{null_order}, id #{params.sort_order.to_s.upcase}
613
+ LIMIT #{params.limit}
614
+ #{"FOR UPDATE SKIP LOCKED" if for_delete && runtime_postgres?}
615
+ SQL
616
+ end
617
+
534
618
  private def runtime_json(value)
535
619
  encoded = value.is_a?(String) ? value : JSON.generate(value)
536
620
  runtime_postgres? ? "#{runtime_quote(encoded)}::jsonb" : "jsonb(#{runtime_quote(encoded)})"
537
621
  end
538
622
 
539
623
  private def runtime_merge_metadata(metadata)
624
+ raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
625
+
540
626
  if runtime_postgres?
541
627
  "metadata || #{runtime_json(metadata)}"
542
628
  else
@@ -611,6 +697,17 @@ module River::Driver
611
697
  )
612
698
  end
613
699
 
700
+ private def runtime_rescue_retry(job, error, retry_policy, now, logger)
701
+ retry_at = retry_policy.next_retry(job, error, now: now)
702
+ raise ArgumentError, "next_retry must return a Time" unless retry_at.is_a?(Time)
703
+ raise ArgumentError, "next_retry must not return a past Time" if retry_at < now
704
+
705
+ retry_at
706
+ rescue => retry_error
707
+ logger&.error("River rescue retry scheduling failed; using default backoff: #{retry_error.full_message}")
708
+ River::DefaultClientRetryPolicy.new.next_retry(job, error, now: now)
709
+ end
710
+
614
711
  private def runtime_returning_ids(sql)
615
712
  runtime_query_rows(sql).map { |row| runtime_value(row, :id).to_i }
616
713
  end
@@ -641,17 +738,31 @@ module River::Driver
641
738
 
642
739
  private def runtime_update_value(field, value)
643
740
  case field
644
- when :attempt, :max_attempts
645
- Integer(value).to_s
741
+ when :attempt
742
+ attempt = Integer(value)
743
+ raise ArgumentError, "attempt must be between 0 and #{River::MAX_ATTEMPTS_LIMIT}" unless (0..River::MAX_ATTEMPTS_LIMIT).cover?(attempt)
744
+
745
+ attempt.to_s
646
746
  when :attempted_at, :finalized_at
647
747
  value ? runtime_time(value) : "NULL"
648
748
  when :attempted_by
649
- runtime_postgres? ? "ARRAY[#{Array(value).map { |item| runtime_quote(item) }.join(",")}]::text[]" : runtime_json(Array(value))
749
+ values = Array(value) #: Array[untyped]
750
+ raise ArgumentError, "attempted_by must contain only Strings" unless values.all? { |item| item.is_a?(String) }
751
+
752
+ runtime_postgres? ? "ARRAY[#{values.map { |item| runtime_quote(item) }.join(",")}]::text[]" : runtime_json(values)
650
753
  when :errors
651
754
  input_values = Array(value) #: Array[untyped]
652
755
  values = input_values.map { |error| error.respond_to?(:to_h) ? error.to_h : error }
653
756
  runtime_postgres? ? "ARRAY[#{values.map { |item| runtime_json(item) }.join(",")}]::jsonb[]" : runtime_json(values)
757
+ when :max_attempts
758
+ max_attempts = Integer(value)
759
+ raise ArgumentError, "max_attempts must be greater than zero" unless max_attempts > 0
760
+ raise ArgumentError, "max_attempts must not exceed #{River::MAX_ATTEMPTS_LIMIT}" if max_attempts > River::MAX_ATTEMPTS_LIMIT
761
+
762
+ max_attempts.to_s
654
763
  when :metadata
764
+ raise ArgumentError, "metadata must be a Hash" unless value.is_a?(Hash)
765
+
655
766
  runtime_json(value)
656
767
  when :state
657
768
  runtime_state(value)
data/lib/event.rb CHANGED
@@ -12,7 +12,14 @@ module River
12
12
  # Event emitted by a Client and delivered through a Subscription.
13
13
  Event = Data.define(:kind, :job, :queue, :stats)
14
14
 
15
- # Timing statistics attached to job lifecycle events.
15
+ # Timing statistics attached to job lifecycle events, in seconds.
16
+ # run_duration measures worker execution including work hooks;
17
+ # complete_duration measures finalization after execution finishes.
18
+ # queue_wait_duration uses the schedule at the start of execution, before
19
+ # retries or snoozes change it.
20
+ # Execution and completion durations use a monotonic clock. Externally
21
+ # executed jobs have zero run_duration because this runtime does not observe
22
+ # their execution; their queue wait ends at the row's attempted_at timestamp.
16
23
  JobStatistics = Data.define(:complete_duration, :queue_wait_duration, :run_duration)
17
24
 
18
25
  # Bounded stream of selected Client lifecycle events.
data/lib/insert_opts.rb CHANGED
@@ -7,6 +7,8 @@ module River
7
7
  class InsertOpts
8
8
  # The maximum number of total attempts (including both the original run and
9
9
  # all retries) before a job is abandoned and set as discarded.
10
+ # Must be between 1 and MAX_ATTEMPTS_LIMIT (32,767).
11
+ # Defaults to MAX_ATTEMPTS_DEFAULT.
10
12
  attr_accessor :max_attempts
11
13
 
12
14
  # Arbitrary metadata to merge into the persisted job. River and River Pro
@@ -23,6 +25,7 @@ module River
23
25
  attr_accessor :priority
24
26
 
25
27
  # The name of the job queue in which to insert the job, as a symbol or string.
28
+ # Must contain 1 to 127 ASCII letters, digits, underscores, hyphens, colons, or periods.
26
29
  #
27
30
  # Defaults to QUEUE_DEFAULT.
28
31
  attr_accessor :queue
@@ -38,7 +41,8 @@ module River
38
41
  attr_accessor :scheduled_at
39
42
 
40
43
  # Initial state. Primarily intended for extensions such as workflows and
41
- # sequences, which insert blocked jobs as `:pending`. Accepts symbols or strings.
44
+ # sequences, which insert blocked jobs as `:pending`. Accepts only
45
+ # `available`, `pending`, or `scheduled`, as symbols or strings.
42
46
  attr_accessor :state
43
47
 
44
48
  # An arbitrary list of keywords to add to the job. They have no functional
@@ -109,7 +113,8 @@ module River
109
113
  # in UTC. Equivalent times in different zones produce the same unique key.
110
114
  #
111
115
  # The period should be specified in seconds. So a job that's unique every 15
112
- # minute period would have a value of 900.
116
+ # minute period would have a value of 900. Nonzero periods must be at least
117
+ # one second; zero disables period-based uniqueness.
113
118
  #
114
119
  # Default is no unique period, meaning that as long as any other unique
115
120
  # property is enabled, uniqueness will be enforced across all jobs of the
data/lib/job.rb CHANGED
@@ -202,7 +202,7 @@ module River
202
202
 
203
203
  # Returns the database-compatible representation of this attempt error.
204
204
  def to_h
205
- {at: at.utc.iso8601(6), attempt: attempt, error: error, trace: trace}
205
+ {at: at.getutc.iso8601(6), attempt: attempt, error: error, trace: trace}
206
206
  end
207
207
  end
208
208
  end
data/lib/periodic_cron.rb CHANGED
@@ -4,21 +4,56 @@ module River
4
4
  # A cron schedule for PeriodicJob. Add the optional +fugit+ gem to use it.
5
5
  class PeriodicCron
6
6
  # Parses a cron expression once. +timezone+ defaults to UTC; use an IANA
7
- # name such as America/New_York for local calendar schedules. Specify the
8
- # timezone here, not inside +expression+. Fugit is loaded only on construction.
7
+ # name or a fixed offset for local schedules. A CRON_TZ= or TZ= prefix takes
8
+ # precedence. Supports Go's @every durations, rounded down to whole seconds
9
+ # with a one-second minimum. Fugit is loaded only on construction.
9
10
  def initialize(expression, timezone: "UTC")
10
11
  require "fugit"
11
12
 
12
13
  raise ArgumentError, "cron expression must be a String" unless expression.is_a?(String)
13
14
  raise ArgumentError, "timezone must be a nonempty name without whitespace" unless timezone.is_a?(String) && /\A\S+\z/.match?(timezone)
14
15
 
15
- @cron = Object.const_get(:Fugit).const_get(:Cron).do_parse("#{expression} #{timezone}")
16
+ expression = expression.strip
17
+ if (prefix = /\A(?:CRON_TZ|TZ)=(\S+)\s+(.+)\z/.match(expression))
18
+ timezone = prefix[1].to_s
19
+ expression = prefix[2].to_s
20
+ end
21
+ # Validate the zone even for interval schedules, which don't use it.
22
+ cron_class = Object.const_get(:Fugit).const_get(:Cron)
23
+ cron_class.do_parse("* * * * * #{timezone}")
24
+ @interval = nil
25
+ if expression.start_with?("@every ")
26
+ @interval = parse_interval(expression.delete_prefix("@every "))
27
+ @cron = nil
28
+ else
29
+ @cron = cron_class.do_parse("#{expression.tr("?", "*")} #{timezone}")
30
+ end
16
31
  end
17
32
 
18
- # Returns a UTC Time strictly after +time+. Calendar and daylight-saving
19
- # rules are provided by Fugit; this helper does not enqueue or persist jobs.
33
+ # Returns a UTC Time strictly after +time+. Interval schedules align to
34
+ # whole seconds, as Go does. Calendar and daylight-saving rules use Fugit.
20
35
  def next(time)
21
- @cron.next_time(time).to_t.getutc
36
+ interval = @interval
37
+ return Time.at(time.to_i + interval).utc if interval
38
+
39
+ # Keep the reference in UTC so Fugit does not skip a repeated local wall
40
+ # time when daylight saving ends. The schedule owns the calendar zone.
41
+ @cron.next_time(time.getutc).to_t.getutc
42
+ end
43
+
44
+ private def parse_interval(duration)
45
+ # Go's time.ParseDuration syntax, deliberately excluding Fugit's extra
46
+ # units (days/months) and natural-language interval expressions.
47
+ sign = duration.start_with?("-") ? -1 : 1
48
+ duration = duration.sub(/\A[+-]/, "")
49
+ parts = duration.scan(/((?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))(ns|us|µs|μs|ms|s|m|h)/) #: Array[[String, String]] # rubocop:disable Layout/LeadingCommentSpace
50
+ valid = duration == "0" || (!parts.empty? && parts.map(&:join).join == duration)
51
+ raise ArgumentError, "invalid @every duration" unless valid
52
+ units = {"ns" => 1, "us" => 1_000, "µs" => 1_000, "μs" => 1_000, "ms" => 1_000_000, "s" => 1_000_000_000, "m" => 60_000_000_000, "h" => 3_600_000_000_000}
53
+ nanos = parts.sum { |number, unit| (number.to_r * units.fetch(unit)).to_i } * sign
54
+ raise ArgumentError, "@every duration exceeds Go's duration range" unless (-(1 << 63)...(1 << 63)).cover?(nanos)
55
+
56
+ [nanos.div(1_000_000_000), 1].max
22
57
  end
23
58
  end
24
59
  end
data/lib/periodic_job.rb CHANGED
@@ -56,7 +56,8 @@ module River
56
56
 
57
57
  # Thread-safe collection used to change a client's periodic jobs at runtime.
58
58
  class PeriodicJobBundle
59
- def initialize(jobs, wake:)
59
+ def initialize(jobs, wake:, enabled: true)
60
+ @enabled = enabled
60
61
  @jobs = {}
61
62
  @mutex = Mutex.new
62
63
  @next_handle = 0
@@ -72,6 +73,8 @@ module River
72
73
  # Adds periodic jobs atomically and returns their handles in input order.
73
74
  # Invalid schedules or duplicate IDs leave the registry unchanged.
74
75
  def add_many(jobs)
76
+ raise ArgumentError, "periodic jobs require leader election" if !@enabled && !jobs.empty?
77
+
75
78
  now = Time.now.utc
76
79
  entries = jobs.map { |job| {job: job, next_at: job.run_on_start ? now : job.next_at(now)} }
77
80
  handles = @mutex.synchronize do
@@ -117,6 +120,11 @@ module River
117
120
  end
118
121
  end
119
122
 
123
+ # Returns true when the bundle has no registered periodic jobs.
124
+ def empty?
125
+ @mutex.synchronize { @jobs.empty? }
126
+ end
127
+
120
128
  # Removes the job associated with +handle+, returning the PeriodicJob or
121
129
  # nil when no such handle exists.
122
130
  def remove(handle)
data/migration/README.md CHANGED
@@ -5,7 +5,9 @@ https://github.com/riverqueue/river, distributed under the upstream Mozilla
5
5
  Public License 2.0 (see LICENSE). The Ruby implementation remains licensed
6
6
  under the repository's MPL-2.0 license.
7
7
 
8
- The bundle matches River v0.48.0 through main migration 008.
9
- manifest.json records the source commit and SHA-256 of every SQL file.
8
+ The bundle includes main migrations through version 008.
9
+ manifest.json records the SHA-256 of every SQL file in the repository's
10
+ riverdriver/ directory.
11
+ Verification compares against this checkout, not a pinned upstream revision.
10
12
  Update or verify these files with scripts/sync_migrations.rb; do not edit SQL
11
13
  here independently of upstream. Private Pro migrations are not included here.
@@ -33,6 +33,5 @@
33
33
  "sqlite/main/008_job_id_autoincrement.down.sql": "04871283fe5d4cab4ac70da28d8509aa7ba765994d528c2dcc7ebe0ee596710c",
34
34
  "sqlite/main/008_job_id_autoincrement.up.sql": "049c9bf615f24a326bcc31ddc87b46f76e3de11e3eea3b2b9e1358131b3f3bed"
35
35
  },
36
- "repository": "river",
37
- "revision": "c2c380490d14df8efc28b86fbe7be1cf93930405"
36
+ "source": "riverdriver/"
38
37
  }