riverqueue 0.15.0 → 0.16.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 +12 -0
- data/docs/README.md +17 -1
- data/lib/client.rb +4 -0
- data/lib/client_runtime.rb +3 -3
- data/lib/driver/runtime.rb +137 -97
- data/lib/params.rb +6 -3
- data/lib/worker.rb +6 -1
- data/sig/client.rbs +1 -1
- data/sig/driver.rbs +5 -0
- data/sig/runtime.rbs +4 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 70cd35bf366c6911d61d8adf29777d699b4dd24c8641eeae53d520c2719a573f
|
|
4
|
+
data.tar.gz: bf715e145cd3a1f6bc089cd5796d3e02313e89a72c52cbd8868c8e6866905b23
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e46f580c619455d5d15fbb9e7e0e1295b2d82f1e72fa395aac9648aa8066031a965a0bf2651630a1efa93b289235296717af90aee9ed5bc7caf54b51cea25376
|
|
7
|
+
data.tar.gz: 6470ef7fdd259235f4de8c34e8098b629934908202ce2f8f455357fb335504af66980780befe160fd45420afef3a034937cbfb59ffda40adfd639354a217d51c
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.16.0] - 2026-11-04
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add `output:` to `Client#job_update` and `River::JobUpdateParams` to persist job output immediately with an atomic metadata update that preserves other keys. Explicit `output: nil` stores JSON null; when `metadata:` is also supplied, output is applied to that replacement metadata. [PR #1568](https://github.com/riverqueue/river/pull/1568).
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Reduce database overhead in the Active Record and Sequel drivers: use `RETURNING` to avoid redundant row fetches, claim batches and clean up finalized jobs in one statement, and omit unnecessary transactions. Return Active Record connections to the pool after operations and avoid allocating models for job reads. Avoid slow Postgres fetching-index scans for updates and deletes targeting a job by ID when state statistics are stale. [PR #1574](https://github.com/riverqueue/river/pull/1574).
|
|
19
|
+
- Prevent ordinary job wakeups from interrupting maintenance error backoff and causing rapid retries during persistent failures. [PR #1574](https://github.com/riverqueue/river/pull/1574).
|
|
20
|
+
- Enforce the 32 MiB encoded UTF-8 JSON limit for `Job#output=`, preserving previous output when validation fails. [PR #1569](https://github.com/riverqueue/river/pull/1569).
|
|
21
|
+
|
|
10
22
|
## [0.15.0] - 2026-10-30
|
|
11
23
|
|
|
12
24
|
### Added
|
data/docs/README.md
CHANGED
|
@@ -546,7 +546,7 @@ remain idempotent.
|
|
|
546
546
|
|
|
547
547
|
### [Recorded output and metadata](https://riverqueue.com/docs/recorded-output)
|
|
548
548
|
|
|
549
|
-
Assign `job.output` to
|
|
549
|
+
Assign `job.output` to buffer JSON-compatible output under `metadata["output"]` until the attempt finishes. Use `job.update_metadata` for other metadata that should be committed with the attempt's final transition.
|
|
550
550
|
|
|
551
551
|
Values are validated and copied when assigned. Invalid JSON fails the attempt
|
|
552
552
|
normally without preventing its error from being recorded. `job.metadata` and
|
|
@@ -559,6 +559,22 @@ def work(job)
|
|
|
559
559
|
end
|
|
560
560
|
```
|
|
561
561
|
|
|
562
|
+
To save output immediately, including between resumable steps, use `job_update`:
|
|
563
|
+
|
|
564
|
+
```ruby
|
|
565
|
+
job.client.job_update(job.id, output: {normalized_text: normalized})
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
This writes `metadata["output"]` atomically while preserving other metadata keys,
|
|
569
|
+
including resumable progress. It participates in an enclosing transaction, so
|
|
570
|
+
commit that transaction before relying on the saved result. Read saved output
|
|
571
|
+
through `client.job_get(job.id).metadata["output"]`. `job.output =` remains buffered;
|
|
572
|
+
an output assigned there can overwrite an immediate update when the attempt ends.
|
|
573
|
+
|
|
574
|
+
Omitting `output:` leaves it unchanged; `output: nil` stores JSON null. When
|
|
575
|
+
`metadata:` and `output:` are both supplied, metadata replaces the existing Hash
|
|
576
|
+
and the explicit output value overrides its `"output"` key.
|
|
577
|
+
|
|
562
578
|
### Job-persisted logging
|
|
563
579
|
|
|
564
580
|
Add `River::JobPersistedLogging::Plugin` to save worker logs with each job attempt,
|
data/lib/client.rb
CHANGED
|
@@ -283,6 +283,10 @@ module River
|
|
|
283
283
|
# Applies keyword attributes or a JobUpdateParams to a job and returns its
|
|
284
284
|
# updated JobRow. Explicit nil clears nullable fields; omitted fields are
|
|
285
285
|
# unchanged. Metadata must be a Hash; use an empty Hash to clear it.
|
|
286
|
+
# Output is written immediately under metadata["output"], preserving other
|
|
287
|
+
# keys. Explicit output: nil stores JSON null. If metadata is also supplied,
|
|
288
|
+
# it replaces the existing metadata and output overrides its "output" key.
|
|
289
|
+
# The write participates in an enclosing transaction, if any.
|
|
286
290
|
# attempt must be between 0 and MAX_ATTEMPTS_LIMIT (32,767);
|
|
287
291
|
# max_attempts must be between 1 and MAX_ATTEMPTS_LIMIT.
|
|
288
292
|
# Raises NotFoundError if the job does not exist.
|
data/lib/client_runtime.rb
CHANGED
|
@@ -589,7 +589,7 @@ module River
|
|
|
589
589
|
end
|
|
590
590
|
rescue => error
|
|
591
591
|
@config.logger.error("River maintenance stopped: #{error.full_message}")
|
|
592
|
-
wait(5)
|
|
592
|
+
wait(5, condition: @maintenance_condition)
|
|
593
593
|
retry unless stopping?
|
|
594
594
|
end
|
|
595
595
|
|
|
@@ -906,8 +906,8 @@ module River
|
|
|
906
906
|
@mutex.synchronize { @stop_requested }
|
|
907
907
|
end
|
|
908
908
|
|
|
909
|
-
private def wait(duration)
|
|
910
|
-
@mutex.synchronize {
|
|
909
|
+
private def wait(duration, condition: @condition)
|
|
910
|
+
@mutex.synchronize { condition.wait(@mutex, duration) unless @stop_requested }
|
|
911
911
|
end
|
|
912
912
|
|
|
913
913
|
private def wait_for_maintenance(duration, generation)
|
data/lib/driver/runtime.rb
CHANGED
|
@@ -47,7 +47,7 @@ module River::Driver
|
|
|
47
47
|
def job_cancel(id, now: Time.now.utc)
|
|
48
48
|
original_id = Integer(id)
|
|
49
49
|
transaction do
|
|
50
|
-
|
|
50
|
+
updated = runtime_query_jobs(<<~SQL).first
|
|
51
51
|
UPDATE river_job
|
|
52
52
|
SET state = CASE WHEN state = 'running' THEN state ELSE 'cancelled' END,
|
|
53
53
|
finalized_at = CASE WHEN state = 'running' THEN finalized_at ELSE #{runtime_time(now)} END,
|
|
@@ -55,12 +55,12 @@ module River::Driver
|
|
|
55
55
|
WHERE id = #{original_id}
|
|
56
56
|
AND state NOT IN ('cancelled', 'completed', 'discarded')
|
|
57
57
|
AND finalized_at IS NULL
|
|
58
|
-
RETURNING
|
|
58
|
+
RETURNING #{runtime_job_columns}
|
|
59
59
|
SQL
|
|
60
|
-
row =
|
|
60
|
+
row = updated || job_get_by_id(original_id)
|
|
61
61
|
# Other engines interrupt running workers through this notification;
|
|
62
62
|
# the durable marker remains Ruby's polling fallback.
|
|
63
|
-
runtime_notify("river_control", action: "cancel", job_id: row.id, queue: row.queue) if
|
|
63
|
+
runtime_notify("river_control", action: "cancel", job_id: row.id, queue: row.queue) if updated
|
|
64
64
|
row
|
|
65
65
|
end
|
|
66
66
|
end
|
|
@@ -82,25 +82,19 @@ module River::Driver
|
|
|
82
82
|
assignments = ["state = 'completed'", "finalized_at = #{runtime_time(finalized_at)}"]
|
|
83
83
|
assignments << "metadata = #{runtime_merge_metadata(metadata)}" unless metadata.nil? || metadata.empty?
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
next runtime_read_job(updated_id) if updated_id
|
|
85
|
+
updated = runtime_query_jobs(<<~SQL, tolerate_decode_errors: true).first
|
|
86
|
+
UPDATE river_job SET #{assignments.join(", ")}
|
|
87
|
+
WHERE #{runtime_running_job_predicate(id, expected_job: expected_job, uncancelled: true)}
|
|
88
|
+
RETURNING #{runtime_job_columns}
|
|
89
|
+
SQL
|
|
90
|
+
return updated if updated
|
|
92
91
|
|
|
93
|
-
|
|
94
|
-
end
|
|
92
|
+
:cancelled if runtime_returning_ids("SELECT id FROM river_job WHERE #{predicate} AND (#{runtime_cancel_attempted})").any?
|
|
95
93
|
end
|
|
96
94
|
|
|
97
95
|
def job_delete(id)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return existing if existing.state == River::JOB_STATE_RUNNING
|
|
101
|
-
|
|
102
|
-
deleted_id = runtime_returning_ids("DELETE FROM river_job WHERE id = #{Integer(id)} AND state != 'running' RETURNING id").first
|
|
103
|
-
deleted_id ? existing : job_get_by_id(id)
|
|
96
|
+
deleted = runtime_query_jobs("DELETE FROM river_job WHERE id = #{Integer(id)} AND state != 'running' RETURNING #{runtime_job_columns}").first
|
|
97
|
+
deleted || job_get_by_id(id)
|
|
104
98
|
end
|
|
105
99
|
|
|
106
100
|
def job_delete_finalized(retention:, now: Time.now.utc, max: 1_000)
|
|
@@ -112,14 +106,11 @@ module River::Driver
|
|
|
112
106
|
|
|
113
107
|
return 0 if clauses.empty?
|
|
114
108
|
|
|
115
|
-
ids = runtime_query_rows(<<~SQL).map { |row| runtime_value(row, :id).to_i }
|
|
116
|
-
SELECT id FROM river_job WHERE #{clauses.join(" OR ")} ORDER BY id LIMIT #{Integer(max)}
|
|
117
|
-
SQL
|
|
118
|
-
return 0 if ids.empty?
|
|
119
|
-
|
|
120
109
|
runtime_returning_ids(<<~SQL).length
|
|
121
110
|
DELETE FROM river_job
|
|
122
|
-
WHERE id IN (
|
|
111
|
+
WHERE id IN (
|
|
112
|
+
SELECT id FROM river_job WHERE #{clauses.join(" OR ")} ORDER BY id LIMIT #{Integer(max)}
|
|
113
|
+
) AND (#{clauses.join(" OR ")})
|
|
123
114
|
RETURNING id
|
|
124
115
|
SQL
|
|
125
116
|
end
|
|
@@ -130,7 +121,7 @@ module River::Driver
|
|
|
130
121
|
id = Integer(id)
|
|
131
122
|
deleted = runtime_returning_ids(<<~SQL).any?
|
|
132
123
|
DELETE FROM river_job
|
|
133
|
-
WHERE
|
|
124
|
+
WHERE #{runtime_running_job_predicate(id, uncancelled: true)}
|
|
134
125
|
RETURNING id
|
|
135
126
|
SQL
|
|
136
127
|
return true if deleted
|
|
@@ -177,23 +168,20 @@ module River::Driver
|
|
|
177
168
|
end
|
|
178
169
|
|
|
179
170
|
# When supplied, expected_job prevents metadata from crossing into another
|
|
180
|
-
# attempt or state.
|
|
171
|
+
# attempt or state. RETURNING provides the snapshot from the update itself.
|
|
181
172
|
def job_metadata_merge(id, metadata, expected_job = nil)
|
|
182
173
|
predicate = "id = #{Integer(id)}"
|
|
183
174
|
if expected_job
|
|
184
175
|
predicate += " AND #{runtime_job_attempt_predicate(expected_job)}"
|
|
185
|
-
predicate += " AND
|
|
176
|
+
predicate += " AND #{runtime_job_state_column} = #{runtime_quote(expected_job.state)}"
|
|
186
177
|
end
|
|
187
178
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
SQL
|
|
195
|
-
updated_id ? job_get_by_id(updated_id) : nil
|
|
196
|
-
end
|
|
179
|
+
runtime_query_jobs(<<~SQL).first
|
|
180
|
+
UPDATE river_job
|
|
181
|
+
SET metadata = #{runtime_merge_metadata(metadata)}
|
|
182
|
+
WHERE #{predicate}
|
|
183
|
+
RETURNING #{runtime_job_columns}
|
|
184
|
+
SQL
|
|
197
185
|
end
|
|
198
186
|
|
|
199
187
|
def job_rescue_stuck(horizon:, retry_policy:, now: Time.now.utc, max: 1_000, logger: nil, rescue_if: nil)
|
|
@@ -249,10 +237,10 @@ module River::Driver
|
|
|
249
237
|
end
|
|
250
238
|
|
|
251
239
|
def job_retry(id, now: Time.now.utc)
|
|
252
|
-
|
|
240
|
+
updated, job = transaction do
|
|
253
241
|
# Check the counter in the write itself so a concurrent claim cannot
|
|
254
242
|
# advance it between validation and the update.
|
|
255
|
-
|
|
243
|
+
updated = runtime_query_jobs(<<~SQL).first
|
|
256
244
|
UPDATE river_job
|
|
257
245
|
SET state = 'available',
|
|
258
246
|
max_attempts = CASE WHEN attempt = max_attempts THEN max_attempts + 1 ELSE max_attempts END,
|
|
@@ -263,13 +251,13 @@ module River::Driver
|
|
|
263
251
|
AND state != 'running'
|
|
264
252
|
AND (state != 'available' OR scheduled_at > #{runtime_time(now)})
|
|
265
253
|
AND attempt < #{River::MAX_ATTEMPTS_LIMIT}
|
|
266
|
-
RETURNING
|
|
254
|
+
RETURNING #{runtime_job_columns}
|
|
267
255
|
SQL
|
|
268
|
-
job =
|
|
269
|
-
runtime_notify("river_insert", queue: job.queue) if
|
|
270
|
-
[
|
|
256
|
+
job = updated || job_get_by_id(id)
|
|
257
|
+
runtime_notify("river_insert", queue: job.queue) if updated && job
|
|
258
|
+
[updated, job]
|
|
271
259
|
end
|
|
272
|
-
if !
|
|
260
|
+
if !updated && job && job.state != "running" && (job.state != "available" || job.scheduled_at > now) && job.attempt >= River::MAX_ATTEMPTS_LIMIT
|
|
273
261
|
raise ArgumentError, "cannot retry a job with #{River::MAX_ATTEMPTS_LIMIT} or more attempts"
|
|
274
262
|
end
|
|
275
263
|
job
|
|
@@ -307,40 +295,59 @@ module River::Driver
|
|
|
307
295
|
|
|
308
296
|
assignments << "state = CASE WHEN #{cancel_path} THEN 'cancelled' ELSE #{runtime_state(state)} END"
|
|
309
297
|
|
|
310
|
-
|
|
298
|
+
runtime_query_jobs(<<~SQL, tolerate_decode_errors: true).first
|
|
311
299
|
UPDATE river_job
|
|
312
300
|
SET #{assignments.join(",\n ")}
|
|
313
|
-
WHERE
|
|
314
|
-
RETURNING
|
|
301
|
+
WHERE #{runtime_running_job_predicate(id)}
|
|
302
|
+
RETURNING #{runtime_job_columns}
|
|
315
303
|
SQL
|
|
316
|
-
id ? runtime_read_job(id) : nil
|
|
317
304
|
end
|
|
318
305
|
|
|
319
306
|
def job_update(id, params)
|
|
320
|
-
|
|
307
|
+
values = params.each.to_h
|
|
308
|
+
assignments = [] #: Array[String]
|
|
309
|
+
if values.key?(:output)
|
|
310
|
+
output = values.delete(:output)
|
|
311
|
+
if values.key?(:metadata)
|
|
312
|
+
metadata = values[:metadata]
|
|
313
|
+
raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
|
|
314
|
+
|
|
315
|
+
values[:metadata] = metadata.transform_keys(&:to_s).merge("output" => output)
|
|
316
|
+
else
|
|
317
|
+
# Merge against the database row in the UPDATE, preserving metadata
|
|
318
|
+
# written since the caller last read the job.
|
|
319
|
+
assignments << "metadata = #{runtime_merge_metadata("output" => output)}"
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
values.each do |field, value|
|
|
321
323
|
raise ArgumentError, "unknown update field: #{field}" unless River::JobUpdateParams.method_defined?(field)
|
|
322
324
|
|
|
323
|
-
"#{field} = #{runtime_update_value(field, value)}"
|
|
325
|
+
assignments << "#{field} = #{runtime_update_value(field, value)}"
|
|
324
326
|
end
|
|
325
327
|
|
|
326
328
|
return job_get_by_id(id) if assignments.empty?
|
|
327
329
|
|
|
328
|
-
|
|
330
|
+
runtime_query_jobs(<<~SQL).first
|
|
329
331
|
UPDATE river_job SET #{assignments.join(", ")}
|
|
330
332
|
WHERE id = #{Integer(id)}
|
|
331
|
-
RETURNING
|
|
333
|
+
RETURNING #{runtime_job_columns}
|
|
332
334
|
SQL
|
|
333
|
-
updated_id ? job_get_by_id(updated_id) : nil
|
|
334
335
|
end
|
|
335
336
|
|
|
336
337
|
def leader_acquire(id, ttl: 30, now: Time.now.utc)
|
|
337
338
|
transaction do
|
|
338
339
|
runtime_execute("DELETE FROM river_leader WHERE expires_at < #{runtime_time(now)}")
|
|
339
|
-
|
|
340
|
+
sql = <<~SQL
|
|
340
341
|
INSERT INTO river_leader (leader_id, elected_at, expires_at)
|
|
341
342
|
VALUES (#{runtime_quote(id)}, #{runtime_time(now)}, #{runtime_time(now + ttl)})
|
|
342
343
|
ON CONFLICT (name) DO NOTHING
|
|
343
344
|
SQL
|
|
345
|
+
if runtime_postgres?
|
|
346
|
+
next true if runtime_query_rows("#{sql} RETURNING leader_id").any?
|
|
347
|
+
else
|
|
348
|
+
# RETURNING saved a statement but did not improve SQLite timings.
|
|
349
|
+
runtime_execute(sql)
|
|
350
|
+
end
|
|
344
351
|
runtime_query_rows("SELECT leader_id FROM river_leader WHERE leader_id = #{runtime_quote(id)}").any?
|
|
345
352
|
end
|
|
346
353
|
end
|
|
@@ -431,11 +438,11 @@ module River::Driver
|
|
|
431
438
|
transaction do
|
|
432
439
|
row = runtime_query_rows(<<~SQL).first
|
|
433
440
|
UPDATE river_queue SET metadata = #{runtime_json(metadata)}, updated_at = #{runtime_time(now)}
|
|
434
|
-
WHERE name = #{runtime_quote(name)} RETURNING
|
|
441
|
+
WHERE name = #{runtime_quote(name)} RETURNING #{runtime_queue_columns}
|
|
435
442
|
SQL
|
|
436
443
|
if row
|
|
437
444
|
runtime_notify("river_control", action: "metadata_changed", queue: name, metadata: metadata)
|
|
438
|
-
|
|
445
|
+
runtime_queue_from_row(row)
|
|
439
446
|
end
|
|
440
447
|
end
|
|
441
448
|
end
|
|
@@ -443,17 +450,18 @@ module River::Driver
|
|
|
443
450
|
def queue_upsert(name, metadata: {}, now: Time.now.utc)
|
|
444
451
|
raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
|
|
445
452
|
|
|
446
|
-
|
|
453
|
+
row = runtime_query_rows(<<~SQL).first
|
|
447
454
|
INSERT INTO river_queue (name, created_at, metadata, updated_at)
|
|
448
455
|
VALUES (#{runtime_quote(name)}, #{runtime_time(now)}, #{runtime_json(metadata)}, #{runtime_time(now)})
|
|
449
456
|
ON CONFLICT (name) DO UPDATE SET updated_at = excluded.updated_at
|
|
457
|
+
RETURNING #{runtime_queue_columns}
|
|
450
458
|
SQL
|
|
451
|
-
|
|
459
|
+
runtime_queue_from_row(row)
|
|
452
460
|
end
|
|
453
461
|
|
|
454
462
|
# Delivery follows the caller's transaction, just like job insertion.
|
|
455
463
|
def request_resign
|
|
456
|
-
|
|
464
|
+
runtime_notify("river_leadership", action: "request_resign", leader_id: "")
|
|
457
465
|
nil
|
|
458
466
|
end
|
|
459
467
|
|
|
@@ -488,46 +496,43 @@ module River::Driver
|
|
|
488
496
|
end
|
|
489
497
|
|
|
490
498
|
private def runtime_claim_jobs(predicate, max, attempted_by, now)
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
SELECT
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
), '$[#]', #{runtime_quote(attempted_by)})) END
|
|
508
|
-
SQL
|
|
509
|
-
end
|
|
510
|
-
|
|
511
|
-
lock_clause = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : ""
|
|
512
|
-
# An administratively requeued job may already be at the counter limit.
|
|
513
|
-
# Saturate instead of letting that row abort the entire claim batch.
|
|
514
|
-
ids = runtime_returning_ids(<<~SQL)
|
|
515
|
-
UPDATE river_job
|
|
516
|
-
SET attempt = CASE WHEN attempt < #{River::MAX_ATTEMPTS_LIMIT} THEN attempt + 1 ELSE attempt END,
|
|
517
|
-
attempted_at = #{runtime_time(now)},
|
|
518
|
-
attempted_by = #{attempted_by_sql},
|
|
519
|
-
state = 'running'
|
|
520
|
-
WHERE id IN (
|
|
521
|
-
SELECT id FROM river_job
|
|
522
|
-
WHERE #{predicate}
|
|
523
|
-
ORDER BY priority ASC, scheduled_at ASC, id ASC
|
|
524
|
-
LIMIT #{Integer(max)}
|
|
525
|
-
#{lock_clause}
|
|
526
|
-
)
|
|
527
|
-
RETURNING id
|
|
499
|
+
attempted_by_sql = if runtime_postgres?
|
|
500
|
+
"array_append(CASE WHEN cardinality(attempted_by) >= 100 THEN attempted_by[(cardinality(attempted_by) - 98):] ELSE attempted_by END, #{runtime_quote(attempted_by)})"
|
|
501
|
+
else
|
|
502
|
+
# Preserve corrupt history so decoding can fail this attempt instead
|
|
503
|
+
# of silently repairing it or aborting the entire claim batch. Trim
|
|
504
|
+
# to the newest 99 entries before appending, preserving JSON types.
|
|
505
|
+
<<~SQL
|
|
506
|
+
CASE WHEN NOT json_valid(attempted_by, 10) AND attempted_by IS NOT NULL THEN attempted_by
|
|
507
|
+
ELSE jsonb(json_insert(json(
|
|
508
|
+
CASE WHEN json_array_length(attempted_by) >= 100 THEN (
|
|
509
|
+
SELECT json_group_array(json(value)) FROM (
|
|
510
|
+
SELECT attempted_by -> ('$[' || key || ']') AS value FROM json_each(attempted_by)
|
|
511
|
+
WHERE key >= json_array_length(attempted_by) - 99 ORDER BY key
|
|
512
|
+
)
|
|
513
|
+
) ELSE coalesce(attempted_by, jsonb('[]')) END
|
|
514
|
+
), '$[#]', #{runtime_quote(attempted_by)})) END
|
|
528
515
|
SQL
|
|
529
|
-
ids.map { |id| runtime_read_job(id) }
|
|
530
516
|
end
|
|
517
|
+
|
|
518
|
+
lock_clause = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : ""
|
|
519
|
+
# An administratively requeued job may already be at the counter limit.
|
|
520
|
+
# Saturate instead of letting that row abort the entire claim batch.
|
|
521
|
+
runtime_query_jobs(<<~SQL, tolerate_decode_errors: true)
|
|
522
|
+
UPDATE river_job
|
|
523
|
+
SET attempt = CASE WHEN attempt < #{River::MAX_ATTEMPTS_LIMIT} THEN attempt + 1 ELSE attempt END,
|
|
524
|
+
attempted_at = #{runtime_time(now)},
|
|
525
|
+
attempted_by = #{attempted_by_sql},
|
|
526
|
+
state = 'running'
|
|
527
|
+
WHERE id IN (
|
|
528
|
+
SELECT id FROM river_job
|
|
529
|
+
WHERE #{predicate}
|
|
530
|
+
ORDER BY priority ASC, scheduled_at ASC, id ASC
|
|
531
|
+
LIMIT #{Integer(max)}
|
|
532
|
+
#{lock_clause}
|
|
533
|
+
)
|
|
534
|
+
RETURNING #{runtime_job_columns}
|
|
535
|
+
SQL
|
|
531
536
|
end
|
|
532
537
|
|
|
533
538
|
private def runtime_read_job(id)
|
|
@@ -615,6 +620,12 @@ module River::Driver
|
|
|
615
620
|
"(#{column} IS NULL OR #{column} #{comparison} #{value} OR (#{column} = #{value} AND #{id_clause}))"
|
|
616
621
|
end
|
|
617
622
|
|
|
623
|
+
private def runtime_insert_transaction(params)
|
|
624
|
+
notify = params.any? { |param| param.state == River::JOB_STATE_AVAILABLE } &&
|
|
625
|
+
(!runtime_postgres? || postgres_capabilities.supports_listen_notify)
|
|
626
|
+
notify ? transaction { yield } : yield
|
|
627
|
+
end
|
|
628
|
+
|
|
618
629
|
private def runtime_job_attempt_predicate(job)
|
|
619
630
|
"attempt = #{Integer(job.attempt)} AND attempted_at IS NOT DISTINCT FROM #{runtime_nullable_time(job.attempted_at)}"
|
|
620
631
|
end
|
|
@@ -654,6 +665,13 @@ module River::Driver
|
|
|
654
665
|
SQL
|
|
655
666
|
end
|
|
656
667
|
|
|
668
|
+
# Use this only for state checks on mutations already targeted by ID.
|
|
669
|
+
# Casting the enum prevents a fetching-index scan when state statistics
|
|
670
|
+
# are stale; Postgres can still use the primary key to find the target.
|
|
671
|
+
private def runtime_job_state_column
|
|
672
|
+
runtime_postgres? ? "state::text" : "state"
|
|
673
|
+
end
|
|
674
|
+
|
|
657
675
|
private def runtime_json(value)
|
|
658
676
|
encoded = value.is_a?(String) ? value : JSON.generate(value)
|
|
659
677
|
runtime_postgres? ? "#{runtime_quote(encoded)}::jsonb" : "jsonb(#{runtime_quote(encoded)})"
|
|
@@ -735,6 +753,18 @@ module River::Driver
|
|
|
735
753
|
end
|
|
736
754
|
end
|
|
737
755
|
|
|
756
|
+
# Runtime transitions must retain corrupt rows so the worker can fail the
|
|
757
|
+
# affected attempt without losing the rest of a claimed batch.
|
|
758
|
+
private def runtime_query_jobs(sql, tolerate_decode_errors: false)
|
|
759
|
+
runtime_query_rows(sql).map do |row|
|
|
760
|
+
runtime_postgres? ? runtime_decode_postgres_job(row) : runtime_decode_sqlite_job(row)
|
|
761
|
+
rescue River::JobRowDecodeError => error
|
|
762
|
+
raise unless tolerate_decode_errors
|
|
763
|
+
|
|
764
|
+
error.job
|
|
765
|
+
end
|
|
766
|
+
end
|
|
767
|
+
|
|
738
768
|
private def runtime_queue_columns
|
|
739
769
|
runtime_postgres? ? "name, created_at, metadata, paused_at, updated_at" : "name, CAST(created_at AS text) AS created_at, json(metadata) AS metadata, CAST(paused_at AS text) AS paused_at, CAST(updated_at AS text) AS updated_at"
|
|
740
770
|
end
|
|
@@ -766,6 +796,16 @@ module River::Driver
|
|
|
766
796
|
runtime_query_rows(sql).map { |row| runtime_value(row, :id).to_i }
|
|
767
797
|
end
|
|
768
798
|
|
|
799
|
+
# UPDATE/DELETE lock and recheck the attempt and cancellation predicates
|
|
800
|
+
# after waiting for a concurrent change to the target row.
|
|
801
|
+
private def runtime_running_job_predicate(id, expected_job: nil, uncancelled: false)
|
|
802
|
+
predicate = "id = #{Integer(id)}"
|
|
803
|
+
predicate += " AND #{runtime_job_attempt_predicate(expected_job)}" if expected_job
|
|
804
|
+
predicate += " AND #{runtime_job_state_column} = 'running'"
|
|
805
|
+
predicate += " AND NOT (#{runtime_cancel_attempted})" if uncancelled
|
|
806
|
+
predicate
|
|
807
|
+
end
|
|
808
|
+
|
|
769
809
|
# Finds conflicts and changes the whole pass in one statement.
|
|
770
810
|
private def runtime_schedule_postgres(now, max)
|
|
771
811
|
now = runtime_time(now)
|
data/lib/params.rb
CHANGED
|
@@ -68,12 +68,14 @@ module River
|
|
|
68
68
|
|
|
69
69
|
# Values to apply, with omitted fields represented internally by UNSET.
|
|
70
70
|
attr_reader :attempt, :attempted_at, :attempted_by, :errors, :finalized_at,
|
|
71
|
-
:max_attempts, :metadata, :state
|
|
71
|
+
:max_attempts, :metadata, :output, :state
|
|
72
72
|
|
|
73
|
-
# Creates a partial set of updates for a persisted job.
|
|
73
|
+
# Creates a partial set of updates for a persisted job. Output replaces only
|
|
74
|
+
# metadata["output"] unless metadata is also supplied, in which case output
|
|
75
|
+
# is applied to that replacement Hash. Explicit nil stores JSON null.
|
|
74
76
|
def initialize(attempt: UNSET, attempted_at: UNSET, attempted_by: UNSET,
|
|
75
77
|
errors: UNSET, finalized_at: UNSET, max_attempts: UNSET, metadata: UNSET,
|
|
76
|
-
state: UNSET)
|
|
78
|
+
output: UNSET, state: UNSET)
|
|
77
79
|
@attempt = attempt
|
|
78
80
|
@attempted_at = attempted_at
|
|
79
81
|
@attempted_by = attempted_by
|
|
@@ -81,6 +83,7 @@ module River
|
|
|
81
83
|
@finalized_at = finalized_at
|
|
82
84
|
@max_attempts = max_attempts
|
|
83
85
|
@metadata = metadata
|
|
86
|
+
@output = output
|
|
84
87
|
@state = state.is_a?(Symbol) ? state.to_s : state
|
|
85
88
|
end
|
|
86
89
|
|
data/lib/worker.rb
CHANGED
|
@@ -139,8 +139,13 @@ module River
|
|
|
139
139
|
end
|
|
140
140
|
|
|
141
141
|
# Stores a worker result under the conventional +"output"+ metadata key.
|
|
142
|
+
# Raises ArgumentError if its encoded UTF-8 JSON exceeds 32 MiB, leaving any
|
|
143
|
+
# previously recorded output unchanged.
|
|
142
144
|
def output=(value)
|
|
143
|
-
|
|
145
|
+
encoded = JSON.generate(value)
|
|
146
|
+
raise ArgumentError, "output is too large: #{encoded.bytesize} bytes (max 32 MiB)" if encoded.bytesize > 32 * 1024 * 1024
|
|
147
|
+
|
|
148
|
+
@metadata_updates["output"] = JSON.parse(encoded)
|
|
144
149
|
end
|
|
145
150
|
|
|
146
151
|
def respond_to_missing?(name, include_private = false)
|
data/sig/client.rbs
CHANGED
|
@@ -39,7 +39,7 @@ module River
|
|
|
39
39
|
def job_retry: (Integer) -> JobRow
|
|
40
40
|
def job_update: (Integer, ?JobUpdateParams?, ?attempt: untyped, ?attempted_at: untyped,
|
|
41
41
|
?attempted_by: untyped, ?errors: untyped, ?finalized_at: untyped, ?max_attempts: untyped,
|
|
42
|
-
?metadata: untyped, ?state: untyped) -> JobRow
|
|
42
|
+
?metadata: untyped, ?output: untyped, ?state: untyped) -> JobRow
|
|
43
43
|
def periodic_jobs: () -> PeriodicJobBundle
|
|
44
44
|
def queue_add: (String | Symbol, QueueConfig | Integer) -> self
|
|
45
45
|
def queue_get: (String | Symbol) -> Queue
|
data/sig/driver.rbs
CHANGED
|
@@ -172,10 +172,13 @@ module River
|
|
|
172
172
|
def runtime_cursor_clause: (untyped) -> String
|
|
173
173
|
def runtime_execute: (String) -> untyped
|
|
174
174
|
def runtime_in_clause: (untyped, untyped) -> untyped
|
|
175
|
+
def runtime_insert_transaction: (Array[JobInsertParams]) { () -> untyped } -> untyped
|
|
175
176
|
def runtime_job_attempt_predicate: (untyped) -> String
|
|
177
|
+
def runtime_job_columns: () -> String
|
|
176
178
|
def runtime_job_list_without_params: () -> untyped
|
|
177
179
|
def runtime_job_list: (JobListParams, ?for_delete: bool) -> Array[JobRow]
|
|
178
180
|
def runtime_job_rows: (String) -> untyped
|
|
181
|
+
def runtime_job_state_column: () -> String
|
|
179
182
|
def runtime_json: (untyped) -> untyped
|
|
180
183
|
def runtime_legacy_subtransactions?: () -> bool
|
|
181
184
|
def runtime_merge_metadata: (untyped) -> untyped
|
|
@@ -188,12 +191,14 @@ module River
|
|
|
188
191
|
def runtime_parse_json: (untyped) -> untyped
|
|
189
192
|
def runtime_parse_time: (untyped) -> untyped
|
|
190
193
|
def runtime_postgres?: () -> bool
|
|
194
|
+
def runtime_query_jobs: (String, ?tolerate_decode_errors: bool) -> Array[JobRow]
|
|
191
195
|
def runtime_query_rows: (String) -> untyped
|
|
192
196
|
def runtime_queue_columns: () -> untyped
|
|
193
197
|
def runtime_queue_from_row: (untyped) -> untyped
|
|
194
198
|
def runtime_quote: (untyped) -> String
|
|
195
199
|
def runtime_rescue_retry: (untyped, untyped, untyped, untyped, untyped) -> Time
|
|
196
200
|
def runtime_returning_ids: (untyped) -> untyped
|
|
201
|
+
def runtime_running_job_predicate: (untyped, ?expected_job: untyped, ?uncancelled: bool) -> String
|
|
197
202
|
def runtime_schedule_postgres: (Time, Integer) -> Array[String]
|
|
198
203
|
def runtime_schedule_sqlite: (Time, Integer) -> Array[String]
|
|
199
204
|
def runtime_state: (untyped) -> untyped
|
data/sig/runtime.rbs
CHANGED
|
@@ -290,6 +290,7 @@ module River
|
|
|
290
290
|
@finalized_at: untyped
|
|
291
291
|
@max_attempts: untyped
|
|
292
292
|
@metadata: untyped
|
|
293
|
+
@output: untyped
|
|
293
294
|
@state: untyped
|
|
294
295
|
attr_reader attempt: untyped
|
|
295
296
|
attr_reader attempted_at: untyped
|
|
@@ -298,10 +299,11 @@ module River
|
|
|
298
299
|
attr_reader finalized_at: untyped
|
|
299
300
|
attr_reader max_attempts: untyped
|
|
300
301
|
attr_reader metadata: untyped
|
|
302
|
+
attr_reader output: untyped
|
|
301
303
|
attr_reader state: untyped
|
|
302
304
|
def initialize: (?attempt: untyped, ?attempted_at: untyped, ?attempted_by: untyped,
|
|
303
305
|
?errors: untyped, ?finalized_at: untyped, ?max_attempts: untyped,
|
|
304
|
-
?metadata: untyped, ?state: untyped) -> void
|
|
306
|
+
?metadata: untyped, ?output: untyped, ?state: untyped) -> void
|
|
305
307
|
def each: () { (Symbol, untyped) -> void } -> untyped
|
|
306
308
|
| () -> Enumerator[[Symbol, untyped], untyped]
|
|
307
309
|
end
|
|
@@ -439,7 +441,7 @@ module River
|
|
|
439
441
|
def start_notifications: () -> untyped
|
|
440
442
|
def start_producer: (String, QueueConfig) -> untyped
|
|
441
443
|
def stopping?: () -> bool
|
|
442
|
-
def wait: (Float | Integer) -> untyped
|
|
444
|
+
def wait: (Float | Integer, ?condition: ConditionVariable) -> untyped
|
|
443
445
|
def wait_for_maintenance: (Float | Integer, Integer) -> untyped
|
|
444
446
|
def wait_for_queue: (String, Float | Integer, Integer) -> untyped
|
|
445
447
|
def wait_for_running_jobs: (String, Float | Integer) -> untyped
|