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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c862e762c9679461633535e60ce0063ea7cad3fbe42d1f4b35c97350826ee3e0
4
- data.tar.gz: 9a80c1852a2d587408fdc8e4c0c0c6b1af20bba5c60c0b777787f87248eb4cc9
3
+ metadata.gz: 70cd35bf366c6911d61d8adf29777d699b4dd24c8641eeae53d520c2719a573f
4
+ data.tar.gz: bf715e145cd3a1f6bc089cd5796d3e02313e89a72c52cbd8868c8e6866905b23
5
5
  SHA512:
6
- metadata.gz: 1b189d118fbb38fbf14f961ce645505c0a47e28421c1b0af11154c2dde5d6d057a4ddf25f88b4fc5a3d8f602c931db792281d89a73c8d0dab230fb9266aab1d4
7
- data.tar.gz: ebf174f14f464c6be842647ddfc3a23a5ee3b33ffd84ce8c10b471849192bedcbc1e05db8e15d457ccf6cfbdfe8e3c04090882c636abb9fa2b6111ad2ffd483b
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 store JSON-compatible output under `metadata["output"]`. Use `job.update_metadata` for other metadata that should be committed with the attempt's final transition.
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.
@@ -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 { @condition.wait(@mutex, duration) unless @stop_requested }
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)
@@ -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
- updated_id = runtime_returning_ids(<<~SQL).first
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 id
58
+ RETURNING #{runtime_job_columns}
59
59
  SQL
60
- row = job_get_by_id(updated_id || original_id)
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 updated_id
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
- transaction do
86
- updated_id = runtime_returning_ids(<<~SQL).first
87
- UPDATE river_job SET #{assignments.join(", ")}
88
- WHERE #{predicate} AND state = 'running' AND NOT (#{runtime_cancel_attempted})
89
- RETURNING id
90
- SQL
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
- :cancelled if runtime_returning_ids("SELECT id FROM river_job WHERE #{predicate} AND (#{runtime_cancel_attempted})").any?
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
- existing = job_get_by_id(id)
99
- return nil unless existing
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 (#{ids.join(",")}) AND (#{clauses.join(" OR ")})
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 id = #{id} AND state = 'running' AND NOT (#{runtime_cancel_attempted})
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. Keep the update and returned snapshot in one transaction.
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 state = #{runtime_quote(expected_job.state)}"
176
+ predicate += " AND #{runtime_job_state_column} = #{runtime_quote(expected_job.state)}"
186
177
  end
187
178
 
188
- transaction do
189
- updated_id = runtime_returning_ids(<<~SQL).first
190
- UPDATE river_job
191
- SET metadata = #{runtime_merge_metadata(metadata)}
192
- WHERE #{predicate}
193
- RETURNING id
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
- updated_id, job = transaction do
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
- updated_id = runtime_returning_ids(<<~SQL).first
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 id
254
+ RETURNING #{runtime_job_columns}
267
255
  SQL
268
- job = job_get_by_id(updated_id || id)
269
- runtime_notify("river_insert", queue: job.queue) if updated_id && job
270
- [updated_id, job]
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 !updated_id && job && job.state != "running" && (job.state != "available" || job.scheduled_at > now) && job.attempt >= River::MAX_ATTEMPTS_LIMIT
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
- id = runtime_returning_ids(<<~SQL).first
298
+ runtime_query_jobs(<<~SQL, tolerate_decode_errors: true).first
311
299
  UPDATE river_job
312
300
  SET #{assignments.join(",\n ")}
313
- WHERE id = #{Integer(id)} AND state = 'running'
314
- RETURNING id
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
- assignments = params.each.map do |field, value|
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
- updated_id = runtime_returning_ids(<<~SQL).first
330
+ runtime_query_jobs(<<~SQL).first
329
331
  UPDATE river_job SET #{assignments.join(", ")}
330
332
  WHERE id = #{Integer(id)}
331
- RETURNING id
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
- runtime_execute(<<~SQL)
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 name
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
- queue_get(name)
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
- runtime_execute(<<~SQL)
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
- queue_get(name)
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
- transaction { runtime_notify("river_leadership", action: "request_resign", leader_id: "") }
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
- transaction do
492
- attempted_by_sql = if runtime_postgres?
493
- "array_append(CASE WHEN cardinality(attempted_by) >= 100 THEN attempted_by[(cardinality(attempted_by) - 98):] ELSE attempted_by END, #{runtime_quote(attempted_by)})"
494
- else
495
- # Preserve corrupt history so decoding can fail this attempt instead
496
- # of silently repairing it or aborting the entire claim batch. Trim
497
- # to the newest 99 entries before appending, preserving JSON types.
498
- <<~SQL
499
- CASE WHEN NOT json_valid(attempted_by, 10) AND attempted_by IS NOT NULL THEN attempted_by
500
- ELSE jsonb(json_insert(json(
501
- CASE WHEN json_array_length(attempted_by) >= 100 THEN (
502
- SELECT json_group_array(json(value)) FROM (
503
- SELECT attempted_by -> ('$[' || key || ']') AS value FROM json_each(attempted_by)
504
- WHERE key >= json_array_length(attempted_by) - 99 ORDER BY key
505
- )
506
- ) ELSE coalesce(attempted_by, jsonb('[]')) END
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
- update_metadata("output" => value)
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: riverqueue
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Blake Gentry