riverqueue 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -11
- data/README.md +83 -14
- data/docs/README.md +160 -30
- data/docs/conformance.md +11 -11
- data/docs/migrations.md +16 -16
- data/docs/testing.md +1 -1
- data/lib/client.rb +61 -6
- data/lib/client_runtime.rb +273 -35
- data/lib/config.rb +61 -9
- data/lib/driver/job_row_decoder.rb +2 -2
- data/lib/driver/notification_listener.rb +61 -0
- data/lib/driver/postgres_capabilities.rb +1 -1
- data/lib/driver/runtime.rb +214 -52
- data/lib/driver.rb +1 -0
- data/lib/insert_opts.rb +1 -1
- data/lib/job.rb +4 -4
- data/lib/job_persisted_logging/plugin.rb +3 -2
- data/lib/migrator.rb +1 -1
- data/lib/periodic_cron.rb +5 -4
- data/lib/periodic_job.rb +14 -3
- data/lib/resumable.rb +24 -1
- data/lib/worker.rb +32 -2
- data/migration/manifest.json +5 -5
- data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +1 -1
- data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +1 -1
- data/migration/postgresql/main/008_job_id_autoincrement.down.sql +1 -1
- data/migration/postgresql/main/008_job_id_autoincrement.up.sql +1 -1
- data/migration/sqlite/main/008_job_id_autoincrement.up.sql +1 -1
- data/sig/client.rbs +4 -1
- data/sig/driver.rbs +40 -5
- data/sig/runtime.rbs +52 -4
- metadata +4 -3
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module River::Driver
|
|
4
|
+
# Internal notification transports. Each client owns its listener; SQLite
|
|
5
|
+
# cursors never consume or delete another client's notifications.
|
|
6
|
+
module NotificationListener
|
|
7
|
+
TOPICS = %w[river_control river_insert river_leadership].freeze
|
|
8
|
+
|
|
9
|
+
class Postgres
|
|
10
|
+
def initialize(connection, schema)
|
|
11
|
+
@connection = connection
|
|
12
|
+
@channels = TOPICS.to_h { |topic| ["#{schema}.#{topic}", topic] }
|
|
13
|
+
@channels.each_key { |channel| @connection.exec("LISTEN #{@connection.escape_identifier(channel)}") }
|
|
14
|
+
rescue
|
|
15
|
+
close
|
|
16
|
+
raise
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def close
|
|
20
|
+
@connection.close unless @connection.finished?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def poll(timeout)
|
|
24
|
+
notifications = [] #: Array[untyped]
|
|
25
|
+
@connection.wait_for_notify(timeout) do |channel, _pid, payload|
|
|
26
|
+
notifications << [@channels[channel], payload]
|
|
27
|
+
end
|
|
28
|
+
# Bound each batch so a busy sender cannot prevent shutdown.
|
|
29
|
+
while notifications.length < 1_000 && (notification = @connection.notifies)
|
|
30
|
+
notifications << [@channels[notification[:relname]], notification[:extra]]
|
|
31
|
+
end
|
|
32
|
+
notifications
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class SQLite
|
|
37
|
+
def initialize(query)
|
|
38
|
+
@query = query
|
|
39
|
+
# Historical requests must not resign a newly started client.
|
|
40
|
+
@last_id = @query.call("SELECT coalesce(max(id), 0) AS id FROM river_notification").first.transform_keys(&:to_sym).fetch(:id).to_i
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def close
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def poll(timeout)
|
|
47
|
+
rows = @query.call("SELECT id, topic, payload FROM river_notification WHERE id > #{@last_id} ORDER BY id LIMIT 1000")
|
|
48
|
+
if rows.empty?
|
|
49
|
+
sleep(timeout)
|
|
50
|
+
return []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
rows.map do |row|
|
|
54
|
+
row = row.transform_keys(&:to_sym)
|
|
55
|
+
@last_id = row.fetch(:id).to_i
|
|
56
|
+
[row.fetch(:topic), row.fetch(:payload)]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module River::Driver
|
|
4
|
-
#
|
|
4
|
+
# Postgres-compatible servers differ in conflict detection and notification
|
|
5
5
|
# support. Keep these rules aligned with Go's PostgresCapabilities.
|
|
6
6
|
class PostgresCapabilities
|
|
7
7
|
attr_reader :supports_listen_notify, :unique_insert_mode, :unique_insert_sql
|
data/lib/driver/runtime.rb
CHANGED
|
@@ -5,12 +5,19 @@ require "time"
|
|
|
5
5
|
module River::Driver
|
|
6
6
|
# Database operations used by River's worker runtime. Driver gems implement a
|
|
7
7
|
# small set of raw SQL primitives and include this module so the state machine
|
|
8
|
-
# stays identical across ActiveRecord and Sequel.
|
|
8
|
+
# stays identical across ActiveRecord and Sequel. Those private primitives
|
|
9
|
+
# provide execution, quoting, row access, and connection management inside
|
|
10
|
+
# driver modules; callers use the driver's public operations and Ruby values.
|
|
9
11
|
module Runtime
|
|
10
12
|
def init_driver
|
|
11
13
|
postgres_capabilities if runtime_postgres?
|
|
12
14
|
end
|
|
13
15
|
|
|
16
|
+
# Returns the UTC timestamp that writing and reading +time+ preserves.
|
|
17
|
+
def normalize_time(time)
|
|
18
|
+
runtime_postgres? ? time.getutc.floor(6) : time.getutc.round(3)
|
|
19
|
+
end
|
|
20
|
+
|
|
14
21
|
def postgres_capabilities
|
|
15
22
|
@postgres_capabilities_cache.fetch(runtime_connection_pool) do
|
|
16
23
|
row = runtime_query_rows(<<~SQL).first
|
|
@@ -27,7 +34,7 @@ module River::Driver
|
|
|
27
34
|
end
|
|
28
35
|
|
|
29
36
|
# Notifications and rows must commit together. The insertion drivers call
|
|
30
|
-
# this inside their transaction;
|
|
37
|
+
# this inside their transaction; Postgres defers delivery until commit.
|
|
31
38
|
private def postgres_notify_insert(params)
|
|
32
39
|
queues = params.select { |param| param.state == River::JOB_STATE_AVAILABLE }.map(&:queue).uniq
|
|
33
40
|
return if queues.empty?
|
|
@@ -68,19 +75,23 @@ module River::Driver
|
|
|
68
75
|
# The cancellation predicate must be part of the completion update so it is
|
|
69
76
|
# checked against the row we lock, including concurrent cancellations.
|
|
70
77
|
# :cancelled asks the runtime to apply its normal cancellation/error hooks.
|
|
71
|
-
def job_complete(id:, finalized_at:, metadata: nil, now: Time.now.utc)
|
|
78
|
+
def job_complete(id:, finalized_at:, expected_job: nil, metadata: nil, now: Time.now.utc)
|
|
72
79
|
id = Integer(id)
|
|
80
|
+
predicate = "id = #{id}"
|
|
81
|
+
predicate += " AND #{runtime_job_attempt_predicate(expected_job)}" if expected_job
|
|
73
82
|
assignments = ["state = 'completed'", "finalized_at = #{runtime_time(finalized_at)}"]
|
|
74
83
|
assignments << "metadata = #{runtime_merge_metadata(metadata)}" unless metadata.nil? || metadata.empty?
|
|
75
84
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
82
92
|
|
|
83
|
-
|
|
93
|
+
:cancelled if runtime_returning_ids("SELECT id FROM river_job WHERE #{predicate} AND (#{runtime_cancel_attempted})").any?
|
|
94
|
+
end
|
|
84
95
|
end
|
|
85
96
|
|
|
86
97
|
def job_delete(id)
|
|
@@ -165,14 +176,24 @@ module River::Driver
|
|
|
165
176
|
runtime_job_list(params)
|
|
166
177
|
end
|
|
167
178
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
179
|
+
# When supplied, expected_job prevents metadata from crossing into another
|
|
180
|
+
# attempt or state. Keep the update and returned snapshot in one transaction.
|
|
181
|
+
def job_metadata_merge(id, metadata, expected_job = nil)
|
|
182
|
+
predicate = "id = #{Integer(id)}"
|
|
183
|
+
if expected_job
|
|
184
|
+
predicate += " AND #{runtime_job_attempt_predicate(expected_job)}"
|
|
185
|
+
predicate += " AND state = #{runtime_quote(expected_job.state)}"
|
|
186
|
+
end
|
|
187
|
+
|
|
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
|
|
176
197
|
end
|
|
177
198
|
|
|
178
199
|
def job_rescue_stuck(horizon:, retry_policy:, now: Time.now.utc, max: 1_000, logger: nil, rescue_if: nil)
|
|
@@ -244,7 +265,9 @@ module River::Driver
|
|
|
244
265
|
AND attempt < #{River::MAX_ATTEMPTS_LIMIT}
|
|
245
266
|
RETURNING id
|
|
246
267
|
SQL
|
|
247
|
-
|
|
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]
|
|
248
271
|
end
|
|
249
272
|
if !updated_id && job && job.state != "running" && (job.state != "available" || job.scheduled_at > now) && job.attempt >= River::MAX_ATTEMPTS_LIMIT
|
|
250
273
|
raise ArgumentError, "cannot retry a job with #{River::MAX_ATTEMPTS_LIMIT} or more attempts"
|
|
@@ -252,31 +275,18 @@ module River::Driver
|
|
|
252
275
|
job
|
|
253
276
|
end
|
|
254
277
|
|
|
278
|
+
# Makes due scheduled and retryable jobs available. A job is discarded
|
|
279
|
+
# instead if another row already holds its unique key, or if an earlier
|
|
280
|
+
# job in the same pass shares that key. The queries find these conflicts
|
|
281
|
+
# before they write, so the pass never needs a savepoint to recover from a
|
|
282
|
+
# failed write.
|
|
255
283
|
def job_schedule(now: Time.now.utc, max: 1_000)
|
|
256
284
|
transaction do
|
|
257
|
-
|
|
258
|
-
#
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
WHERE state IN ('retryable', 'scheduled') AND scheduled_at <= #{runtime_time(now)}
|
|
263
|
-
ORDER BY priority, scheduled_at, id
|
|
264
|
-
LIMIT #{Integer(max)} #{lock}
|
|
265
|
-
SQL
|
|
266
|
-
ids.each do |id|
|
|
267
|
-
transaction do
|
|
268
|
-
runtime_execute("UPDATE river_job SET state = 'available' WHERE id = #{id} AND state IN ('retryable', 'scheduled')")
|
|
269
|
-
end
|
|
270
|
-
rescue runtime_unique_violation_class
|
|
271
|
-
runtime_execute(<<~SQL)
|
|
272
|
-
UPDATE river_job
|
|
273
|
-
SET state = 'discarded', finalized_at = #{runtime_time(now)},
|
|
274
|
-
metadata = #{runtime_merge_metadata("unique_key_conflict" => "scheduler_discarded")}
|
|
275
|
-
WHERE id = #{id}
|
|
276
|
-
SQL
|
|
277
|
-
end
|
|
278
|
-
|
|
279
|
-
ids.length
|
|
285
|
+
queues = runtime_postgres? ? runtime_schedule_postgres(now, Integer(max)) : runtime_schedule_sqlite(now, Integer(max))
|
|
286
|
+
# Notify each changed queue, including queues with only discarded
|
|
287
|
+
# jobs, as Go does.
|
|
288
|
+
queues.uniq.each { |queue| runtime_notify("river_insert", queue: queue) }
|
|
289
|
+
queues.length
|
|
280
290
|
end
|
|
281
291
|
end
|
|
282
292
|
|
|
@@ -365,6 +375,16 @@ module River::Driver
|
|
|
365
375
|
SQL
|
|
366
376
|
end
|
|
367
377
|
|
|
378
|
+
# Opens a receiver independent of application transactions. Postgres uses
|
|
379
|
+
# one dedicated connection; SQLite keeps a cursor in its shared outbox.
|
|
380
|
+
def notification_listener
|
|
381
|
+
return NotificationListener::SQLite.new(method(:notification_query)) unless runtime_postgres?
|
|
382
|
+
return unless postgres_capabilities.supports_listen_notify
|
|
383
|
+
|
|
384
|
+
connection, schema = runtime_notification_connection
|
|
385
|
+
NotificationListener::Postgres.new(connection, schema)
|
|
386
|
+
end
|
|
387
|
+
|
|
368
388
|
def queue_get(name)
|
|
369
389
|
row = runtime_query_rows("SELECT #{runtime_queue_columns} FROM river_queue WHERE name = #{runtime_quote(name)}").first
|
|
370
390
|
runtime_queue_from_row(row)
|
|
@@ -431,6 +451,21 @@ module River::Driver
|
|
|
431
451
|
queue_get(name)
|
|
432
452
|
end
|
|
433
453
|
|
|
454
|
+
# Delivery follows the caller's transaction, just like job insertion.
|
|
455
|
+
def request_resign
|
|
456
|
+
transaction { runtime_notify("river_leadership", action: "request_resign", leader_id: "") }
|
|
457
|
+
nil
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
private def notification_query(sql)
|
|
461
|
+
runtime_query_rows(sql)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Smallest timestamp increment preserved by this driver, in seconds.
|
|
465
|
+
def time_precision
|
|
466
|
+
runtime_postgres? ? Rational(1, 1_000_000) : Rational(1, 1_000)
|
|
467
|
+
end
|
|
468
|
+
|
|
434
469
|
private def runtime_append_error(error)
|
|
435
470
|
value = error.respond_to?(:to_h) ? error.to_h : error
|
|
436
471
|
if runtime_postgres?
|
|
@@ -558,7 +593,7 @@ module River::Driver
|
|
|
558
593
|
params.map do |param|
|
|
559
594
|
key = param.unique_key
|
|
560
595
|
if key && !key.empty? && River::UniqueBitmask.to_states((param.unique_states || "0").to_i(2)).include?(param.state)
|
|
561
|
-
#
|
|
596
|
+
# Postgres rejects batches that upsert the same indexed row twice.
|
|
562
597
|
raise ArgumentError, "unique key appears more than once in batch" if keys[key]
|
|
563
598
|
|
|
564
599
|
keys[key] = true
|
|
@@ -580,6 +615,10 @@ module River::Driver
|
|
|
580
615
|
"(#{column} IS NULL OR #{column} #{comparison} #{value} OR (#{column} = #{value} AND #{id_clause}))"
|
|
581
616
|
end
|
|
582
617
|
|
|
618
|
+
private def runtime_job_attempt_predicate(job)
|
|
619
|
+
"attempt = #{Integer(job.attempt)} AND attempted_at IS NOT DISTINCT FROM #{runtime_nullable_time(job.attempted_at)}"
|
|
620
|
+
end
|
|
621
|
+
|
|
583
622
|
private def runtime_job_list(params, for_delete: false)
|
|
584
623
|
clauses = [] #: Array[String]
|
|
585
624
|
clauses << "state != 'running'" if for_delete
|
|
@@ -594,7 +633,7 @@ module River::Driver
|
|
|
594
633
|
finalized = params.sort_by == :finalized_at && params.states&.length == 1 &&
|
|
595
634
|
%w[cancelled completed discarded].include?(params.states.first)
|
|
596
635
|
# Schemas require finalized timestamps for terminal states. Spell this out
|
|
597
|
-
# so
|
|
636
|
+
# so Postgres can use the partial (state, finalized_at) index.
|
|
598
637
|
clauses << "finalized_at IS NOT NULL" if finalized
|
|
599
638
|
# Explicit NULLS LAST prevents a backward index scan, even when there are
|
|
600
639
|
# no nulls. Only request it for timestamps that can actually be null.
|
|
@@ -620,18 +659,33 @@ module River::Driver
|
|
|
620
659
|
runtime_postgres? ? "#{runtime_quote(encoded)}::jsonb" : "jsonb(#{runtime_quote(encoded)})"
|
|
621
660
|
end
|
|
622
661
|
|
|
662
|
+
# Temporary fallback: when true, transaction helpers open a savepoint
|
|
663
|
+
# inside an open transaction instead of joining it.
|
|
664
|
+
private def runtime_legacy_subtransactions?
|
|
665
|
+
%w[1 true].include?(ENV.fetch("RIVER_USE_LEGACY_SUBTRANSACTIONS", ""))
|
|
666
|
+
end
|
|
667
|
+
|
|
623
668
|
private def runtime_merge_metadata(metadata)
|
|
624
669
|
raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
|
|
625
670
|
|
|
626
671
|
if runtime_postgres?
|
|
627
672
|
"metadata || #{runtime_json(metadata)}"
|
|
628
673
|
else
|
|
629
|
-
#
|
|
630
|
-
#
|
|
631
|
-
merged =
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
674
|
+
# Keep the SQL shape fixed regardless of the number of updated keys.
|
|
675
|
+
# -> preserves JSON booleans and large numeric tokens during aggregation.
|
|
676
|
+
merged = <<~SQL.strip
|
|
677
|
+
(WITH updates(doc) AS (SELECT #{runtime_json(metadata)})
|
|
678
|
+
SELECT CASE WHEN json_type(metadata) = 'object' THEN (
|
|
679
|
+
SELECT jsonb_group_object(key, jsonb(doc -> fullkey))
|
|
680
|
+
FROM (
|
|
681
|
+
SELECT key, fullkey, metadata AS doc FROM json_each(metadata)
|
|
682
|
+
WHERE key NOT IN (SELECT key FROM updates, json_each(doc))
|
|
683
|
+
UNION ALL
|
|
684
|
+
SELECT key, fullkey, doc FROM updates, json_each(doc)
|
|
685
|
+
)
|
|
686
|
+
) WHEN json_type(metadata) = 'array' THEN jsonb_insert(metadata, '$[#]', doc)
|
|
687
|
+
ELSE jsonb_array(json(metadata), doc) END FROM updates)
|
|
688
|
+
SQL
|
|
635
689
|
"CASE WHEN json_valid(metadata, 10) THEN #{merged} ELSE metadata END"
|
|
636
690
|
end
|
|
637
691
|
end
|
|
@@ -712,6 +766,114 @@ module River::Driver
|
|
|
712
766
|
runtime_query_rows(sql).map { |row| runtime_value(row, :id).to_i }
|
|
713
767
|
end
|
|
714
768
|
|
|
769
|
+
# Finds conflicts and changes the whole pass in one statement.
|
|
770
|
+
private def runtime_schedule_postgres(now, max)
|
|
771
|
+
now = runtime_time(now)
|
|
772
|
+
runtime_query_rows(<<~SQL).map { |row| runtime_value(row, :queue) }
|
|
773
|
+
WITH jobs_to_schedule AS (
|
|
774
|
+
SELECT id, unique_key, unique_states, priority, scheduled_at
|
|
775
|
+
FROM river_job
|
|
776
|
+
WHERE state IN ('retryable', 'scheduled')
|
|
777
|
+
AND priority >= 0
|
|
778
|
+
AND queue IS NOT NULL
|
|
779
|
+
AND scheduled_at <= #{now}
|
|
780
|
+
ORDER BY priority, scheduled_at, id
|
|
781
|
+
LIMIT #{max}
|
|
782
|
+
FOR UPDATE
|
|
783
|
+
),
|
|
784
|
+
jobs_with_rownum AS (
|
|
785
|
+
SELECT
|
|
786
|
+
*,
|
|
787
|
+
CASE
|
|
788
|
+
WHEN unique_key IS NOT NULL AND unique_states IS NOT NULL THEN
|
|
789
|
+
ROW_NUMBER() OVER (PARTITION BY unique_key ORDER BY priority, scheduled_at, id)
|
|
790
|
+
END AS row_num
|
|
791
|
+
FROM jobs_to_schedule
|
|
792
|
+
),
|
|
793
|
+
unique_conflicts AS (
|
|
794
|
+
SELECT river_job.unique_key
|
|
795
|
+
FROM river_job
|
|
796
|
+
JOIN jobs_with_rownum
|
|
797
|
+
ON river_job.unique_key = jobs_with_rownum.unique_key
|
|
798
|
+
AND river_job.id != jobs_with_rownum.id
|
|
799
|
+
WHERE river_job.unique_key IS NOT NULL
|
|
800
|
+
AND river_job.unique_states IS NOT NULL
|
|
801
|
+
AND river_job_state_in_bitmask(river_job.unique_states, river_job.state)
|
|
802
|
+
),
|
|
803
|
+
job_updates AS (
|
|
804
|
+
SELECT
|
|
805
|
+
job.id,
|
|
806
|
+
job.row_num IS NOT NULL AND (uc.unique_key IS NOT NULL OR job.row_num > 1) AS discard
|
|
807
|
+
FROM jobs_with_rownum job
|
|
808
|
+
LEFT JOIN unique_conflicts uc ON job.unique_key = uc.unique_key
|
|
809
|
+
)
|
|
810
|
+
UPDATE river_job
|
|
811
|
+
SET state = CASE WHEN job_updates.discard THEN 'discarded'::river_job_state ELSE 'available'::river_job_state END,
|
|
812
|
+
finalized_at = CASE WHEN job_updates.discard THEN #{now} ELSE river_job.finalized_at END,
|
|
813
|
+
metadata = CASE WHEN job_updates.discard THEN river_job.metadata || '{"unique_key_conflict": "scheduler_discarded"}'::jsonb
|
|
814
|
+
ELSE river_job.metadata END
|
|
815
|
+
FROM job_updates
|
|
816
|
+
WHERE river_job.id = job_updates.id
|
|
817
|
+
RETURNING river_job.queue
|
|
818
|
+
SQL
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
# Follows Go's SQLite driver: check each unique job in order, and make it
|
|
822
|
+
# available before checking the next one, so a later job with the same
|
|
823
|
+
# key sees it as a conflict. SQLite allows one writer at a time. Once this
|
|
824
|
+
# pass writes, other connections cannot write until it ends. If another
|
|
825
|
+
# connection writes first, this pass's next write fails instead of using
|
|
826
|
+
# stale rows.
|
|
827
|
+
private def runtime_schedule_sqlite(now, max)
|
|
828
|
+
rows = runtime_query_rows(<<~SQL)
|
|
829
|
+
SELECT id, queue, unique_key IS NOT NULL AS has_unique_key
|
|
830
|
+
FROM river_job
|
|
831
|
+
WHERE state IN ('retryable', 'scheduled') AND scheduled_at <= #{runtime_time(now)}
|
|
832
|
+
ORDER BY priority, scheduled_at, id
|
|
833
|
+
LIMIT #{max}
|
|
834
|
+
SQL
|
|
835
|
+
available = [] #: Array[Integer]
|
|
836
|
+
discarded = [] #: Array[Integer]
|
|
837
|
+
rows.each do |row|
|
|
838
|
+
id = runtime_value(row, :id).to_i
|
|
839
|
+
next available << id if runtime_value(row, :has_unique_key).to_i.zero?
|
|
840
|
+
|
|
841
|
+
conflict = runtime_query_rows(<<~SQL).any?
|
|
842
|
+
SELECT 1 FROM river_job
|
|
843
|
+
WHERE id <> #{id}
|
|
844
|
+
AND unique_key = (SELECT job.unique_key FROM river_job AS job WHERE job.id = #{id})
|
|
845
|
+
AND unique_states IS NOT NULL
|
|
846
|
+
AND CASE state
|
|
847
|
+
WHEN 'available' THEN unique_states & (1 << 0)
|
|
848
|
+
WHEN 'cancelled' THEN unique_states & (1 << 1)
|
|
849
|
+
WHEN 'completed' THEN unique_states & (1 << 2)
|
|
850
|
+
WHEN 'discarded' THEN unique_states & (1 << 3)
|
|
851
|
+
WHEN 'pending' THEN unique_states & (1 << 4)
|
|
852
|
+
WHEN 'retryable' THEN unique_states & (1 << 5)
|
|
853
|
+
WHEN 'running' THEN unique_states & (1 << 6)
|
|
854
|
+
WHEN 'scheduled' THEN unique_states & (1 << 7)
|
|
855
|
+
ELSE 0
|
|
856
|
+
END >= 1
|
|
857
|
+
LIMIT 1
|
|
858
|
+
SQL
|
|
859
|
+
next discarded << id if conflict
|
|
860
|
+
|
|
861
|
+
runtime_execute("UPDATE river_job SET state = 'available' WHERE id = #{id}")
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
unless discarded.empty?
|
|
865
|
+
runtime_execute(<<~SQL)
|
|
866
|
+
UPDATE river_job
|
|
867
|
+
SET state = 'discarded', finalized_at = #{runtime_time(now)},
|
|
868
|
+
metadata = #{runtime_merge_metadata("unique_key_conflict" => "scheduler_discarded")}
|
|
869
|
+
WHERE id IN (#{discarded.join(",")})
|
|
870
|
+
SQL
|
|
871
|
+
end
|
|
872
|
+
runtime_execute("UPDATE river_job SET state = 'available' WHERE id IN (#{available.join(",")})") unless available.empty?
|
|
873
|
+
|
|
874
|
+
rows.map { |row| runtime_value(row, :queue) }
|
|
875
|
+
end
|
|
876
|
+
|
|
715
877
|
private def runtime_state(value)
|
|
716
878
|
runtime_postgres? ? "#{runtime_quote(value)}::river_job_state" : runtime_quote(value)
|
|
717
879
|
end
|
|
@@ -729,9 +891,9 @@ module River::Driver
|
|
|
729
891
|
|
|
730
892
|
cast = runtime_postgres? ? "::timestamptz" : ""
|
|
731
893
|
encoded = if runtime_postgres?
|
|
732
|
-
value.
|
|
894
|
+
normalize_time(value).iso8601(6)
|
|
733
895
|
else
|
|
734
|
-
value
|
|
896
|
+
normalize_time(value).strftime("%Y-%m-%d %H:%M:%S.%3N")
|
|
735
897
|
end
|
|
736
898
|
"#{runtime_quote(encoded)}#{cast}"
|
|
737
899
|
end
|
data/lib/driver.rb
CHANGED
data/lib/insert_opts.rb
CHANGED
|
@@ -94,7 +94,7 @@ module River
|
|
|
94
94
|
# be inserted as a new job.
|
|
95
95
|
class UniqueOpts
|
|
96
96
|
# Empty options don't enable uniqueness. Zero periods and empty state lists
|
|
97
|
-
# have the same meaning as their unset equivalents
|
|
97
|
+
# have the same meaning as their unset equivalents.
|
|
98
98
|
def empty?
|
|
99
99
|
!by_args && !by_queue && !exclude_kind && (!by_period || by_period == 0) && (!by_state || by_state.empty?)
|
|
100
100
|
end
|
data/lib/job.rb
CHANGED
|
@@ -59,7 +59,7 @@ module River
|
|
|
59
59
|
|
|
60
60
|
# The set of worker IDs that have worked this job. A worker ID differs
|
|
61
61
|
# between different programs, but is shared by all executors within any
|
|
62
|
-
# given one. (i.e. Different
|
|
62
|
+
# given one. (i.e. Different processes have different IDs, but IDs are
|
|
63
63
|
# shared within any given process.) A process generates a new ID based on
|
|
64
64
|
# host and current time when it starts up.
|
|
65
65
|
attr_accessor :attempted_by
|
|
@@ -173,7 +173,7 @@ module River
|
|
|
173
173
|
# that occurred.
|
|
174
174
|
class AttemptError
|
|
175
175
|
# The time at which the error occurred. Unreadable historical timestamps
|
|
176
|
-
# become Time.utc(1),
|
|
176
|
+
# become Time.utc(1), the zero time 0001-01-01T00:00:00Z.
|
|
177
177
|
attr_accessor :at
|
|
178
178
|
|
|
179
179
|
# The attempt number on which the error occurred (maps to #attempt on a job
|
|
@@ -184,8 +184,8 @@ module River
|
|
|
184
184
|
# value in case of a panic.
|
|
185
185
|
attr_accessor :error
|
|
186
186
|
|
|
187
|
-
# Contains a stack trace from a job that panicked.
|
|
188
|
-
#
|
|
187
|
+
# Contains a stack trace from a job that raised or panicked. Ruby records
|
|
188
|
+
# the exception backtrace; other River clients record their own format.
|
|
189
189
|
attr_accessor :trace
|
|
190
190
|
|
|
191
191
|
def initialize(
|
|
@@ -5,7 +5,8 @@ require "logger"
|
|
|
5
5
|
module River
|
|
6
6
|
module JobPersistedLogging
|
|
7
7
|
# Captures job.logger output and persists it with the attempt's state change,
|
|
8
|
-
# including failed attempts. Uses
|
|
8
|
+
# including failed attempts. Uses the shared `river:log` metadata format
|
|
9
|
+
# that River UI displays.
|
|
9
10
|
# Register through Config#plugins, preferably before other work middleware.
|
|
10
11
|
class Plugin
|
|
11
12
|
# Limits are positive byte counts. History is capped at 64 MiB, dropping
|
|
@@ -84,7 +85,7 @@ module River
|
|
|
84
85
|
@mutex.synchronize do
|
|
85
86
|
@closed = true
|
|
86
87
|
# Drop invalid/incomplete UTF-8 sequences (including a character cut
|
|
87
|
-
# at the byte limit) and NULs, which
|
|
88
|
+
# at the byte limit) and NULs, which Postgres JSONB cannot represent.
|
|
88
89
|
[@data.dup.force_encoding(Encoding::UTF_8).scrub("").delete("\0"), @truncated]
|
|
89
90
|
end
|
|
90
91
|
end
|
data/lib/migrator.rb
CHANGED
|
@@ -9,7 +9,7 @@ module River
|
|
|
9
9
|
|
|
10
10
|
attr_reader :migrations
|
|
11
11
|
|
|
12
|
-
# Creates a migrator for an installed River driver.
|
|
12
|
+
# Creates a migrator for an installed River driver. Postgres schemas must
|
|
13
13
|
# already exist and use simple SQL identifiers; SQLite uses its main schema.
|
|
14
14
|
# The optional migrations_path is a root containing backend/line/*.sql.
|
|
15
15
|
def initialize(driver, line: "main", migrations_path: File.expand_path("../migration", __dir__.to_s), schema: nil)
|
data/lib/periodic_cron.rb
CHANGED
|
@@ -5,8 +5,9 @@ module River
|
|
|
5
5
|
class PeriodicCron
|
|
6
6
|
# Parses a cron expression once. +timezone+ defaults to UTC; use an IANA
|
|
7
7
|
# name or a fixed offset for local schedules. A CRON_TZ= or TZ= prefix takes
|
|
8
|
-
# precedence. Supports
|
|
9
|
-
# with a one-second minimum. Fugit is loaded only on
|
|
8
|
+
# precedence. Supports @every durations such as `1h30m` or `500ms`, rounded
|
|
9
|
+
# down to whole seconds with a one-second minimum. Fugit is loaded only on
|
|
10
|
+
# construction.
|
|
10
11
|
def initialize(expression, timezone: "UTC")
|
|
11
12
|
require "fugit"
|
|
12
13
|
|
|
@@ -31,7 +32,7 @@ module River
|
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
# Returns a UTC Time strictly after +time+. Interval schedules align to
|
|
34
|
-
# whole seconds
|
|
35
|
+
# whole seconds. Calendar and daylight-saving rules use Fugit.
|
|
35
36
|
def next(time)
|
|
36
37
|
interval = @interval
|
|
37
38
|
return Time.at(time.to_i + interval).utc if interval
|
|
@@ -51,7 +52,7 @@ module River
|
|
|
51
52
|
raise ArgumentError, "invalid @every duration" unless valid
|
|
52
53
|
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
54
|
nanos = parts.sum { |number, unit| (number.to_r * units.fetch(unit)).to_i } * sign
|
|
54
|
-
raise ArgumentError, "@every duration
|
|
55
|
+
raise ArgumentError, "@every duration must fit in signed 64-bit nanoseconds" unless (-(1 << 63)...(1 << 63)).cover?(nanos)
|
|
55
56
|
|
|
56
57
|
[nanos.div(1_000_000_000), 1].max
|
|
57
58
|
end
|
data/lib/periodic_job.rb
CHANGED
|
@@ -76,7 +76,10 @@ module River
|
|
|
76
76
|
raise ArgumentError, "periodic jobs require leader election" if !@enabled && !jobs.empty?
|
|
77
77
|
|
|
78
78
|
now = Time.now.utc
|
|
79
|
-
entries = jobs.map
|
|
79
|
+
entries = jobs.map do |job|
|
|
80
|
+
run_on_start = job.run_on_start
|
|
81
|
+
{job: job, next_at: run_on_start ? now : job.next_at(now), run_on_start: run_on_start}
|
|
82
|
+
end
|
|
80
83
|
handles = @mutex.synchronize do
|
|
81
84
|
ids = @jobs.values.to_h { |entry| [entry[:job].id, true] }
|
|
82
85
|
jobs.each do |job|
|
|
@@ -100,7 +103,9 @@ module River
|
|
|
100
103
|
self
|
|
101
104
|
end
|
|
102
105
|
|
|
103
|
-
|
|
106
|
+
# Internal scheduler entry point that retains each claimed occurrence's
|
|
107
|
+
# time even after advancing its registration.
|
|
108
|
+
def __due_with_times(now)
|
|
104
109
|
# Application schedules may reenter the registry, so evaluate outside its
|
|
105
110
|
# lock and only advance registrations that haven't been removed or claimed.
|
|
106
111
|
entries = @mutex.synchronize { @jobs.map { |handle, entry| [handle, entry, entry[:next_at]] } }
|
|
@@ -110,8 +115,10 @@ module River
|
|
|
110
115
|
following = entry[:job].next_at(now)
|
|
111
116
|
@mutex.synchronize do
|
|
112
117
|
next unless @jobs[handle].equal?(entry) && entry[:next_at] == next_at
|
|
118
|
+
scheduled_at = entry[:run_on_start] ? now : next_at
|
|
119
|
+
entry[:run_on_start] = false
|
|
113
120
|
entry[:next_at] = following
|
|
114
|
-
entry[:job]
|
|
121
|
+
[entry[:job], scheduled_at]
|
|
115
122
|
end
|
|
116
123
|
rescue => error
|
|
117
124
|
raise unless block_given?
|
|
@@ -120,6 +127,10 @@ module River
|
|
|
120
127
|
end
|
|
121
128
|
end
|
|
122
129
|
|
|
130
|
+
def due(now, &block)
|
|
131
|
+
__due_with_times(now, &block).map(&:first)
|
|
132
|
+
end
|
|
133
|
+
|
|
123
134
|
# Returns true when the bundle has no registered periodic jobs.
|
|
124
135
|
def empty?
|
|
125
136
|
@mutex.synchronize { @jobs.empty? }
|