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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +22 -0
- data/docs/README.md +60 -13
- data/docs/conformance.md +72 -0
- data/docs/migrations.md +5 -8
- data/lib/client.rb +42 -9
- data/lib/client_runtime.rb +167 -58
- data/lib/driver/runtime.rb +215 -104
- data/lib/event.rb +8 -1
- data/lib/insert_opts.rb +7 -2
- data/lib/job.rb +1 -1
- data/lib/periodic_cron.rb +41 -6
- data/lib/periodic_job.rb +9 -1
- data/migration/README.md +4 -2
- data/migration/manifest.json +1 -2
- data/sig/client.rbs +5 -2
- data/sig/driver.rbs +6 -4
- data/sig/insert_opts.rbs +2 -2
- data/sig/job.rbs +2 -0
- data/sig/runtime.rbs +24 -4
- metadata +6 -4
data/lib/driver/runtime.rb
CHANGED
|
@@ -65,13 +65,22 @@ module River::Driver
|
|
|
65
65
|
runtime_claim_jobs(predicate, 1, attempted_by, now).first
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
#
|
|
190
|
-
#
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
226
|
+
rescued
|
|
221
227
|
end
|
|
222
228
|
end
|
|
223
229
|
|
|
224
230
|
def job_retry(id, now: Time.now.utc)
|
|
225
|
-
updated_id =
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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(
|
|
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
|
|
645
|
-
Integer(value)
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
8
|
-
#
|
|
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
|
-
|
|
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+.
|
|
19
|
-
#
|
|
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
|
-
@
|
|
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
|
|
9
|
-
manifest.json records the
|
|
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.
|
data/migration/manifest.json
CHANGED
|
@@ -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
|
-
"
|
|
37
|
-
"revision": "c2c380490d14df8efc28b86fbe7be1cf93930405"
|
|
36
|
+
"source": "riverdriver/"
|
|
38
37
|
}
|