riverqueue 0.11.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 +182 -0
- data/LICENSE +374 -0
- data/README.md +22 -0
- data/docs/README.md +812 -0
- data/docs/conformance.md +72 -0
- data/docs/migrations.md +118 -0
- data/docs/testing.md +173 -0
- data/docs/workers.md +126 -0
- data/exe/river +7 -0
- data/lib/cli.rb +55 -0
- data/lib/client.rb +347 -54
- data/lib/client_runtime.rb +721 -0
- data/lib/config.rb +154 -0
- data/lib/driver/job_row_decoder.rb +85 -0
- data/lib/driver/postgres_capabilities.rb +41 -0
- data/lib/driver/runtime.rb +772 -0
- data/lib/driver.rb +13 -6
- data/lib/errors.rb +83 -0
- data/lib/event.rb +85 -0
- data/lib/insert_opts.rb +46 -12
- data/lib/job.rb +28 -13
- data/lib/job_persisted_logging/plugin.rb +108 -0
- data/lib/migration_cli.rb +90 -0
- data/lib/migrator.rb +189 -0
- data/lib/params.rb +96 -0
- data/lib/periodic_cron.rb +59 -0
- data/lib/periodic_job.rb +143 -0
- data/lib/resumable.rb +148 -0
- data/lib/riverqueue/testing/minitest.rb +18 -0
- data/lib/riverqueue/testing/rspec.rb +109 -0
- data/lib/riverqueue/testing.rb +134 -0
- data/lib/riverqueue.rb +16 -0
- data/lib/unique_args.rb +97 -0
- data/lib/unique_bitmask.rb +14 -12
- data/lib/worker.rb +152 -0
- data/lib/worker_runner.rb +106 -0
- data/migration/LICENSE +374 -0
- data/migration/README.md +13 -0
- data/migration/manifest.json +37 -0
- data/migration/postgresql/main/001_create_river_migration.down.sql +1 -0
- data/migration/postgresql/main/001_create_river_migration.up.sql +8 -0
- data/migration/postgresql/main/002_initial_schema.down.sql +5 -0
- data/migration/postgresql/main/002_initial_schema.up.sql +96 -0
- data/migration/postgresql/main/003_river_job_tags_non_null.down.sql +3 -0
- data/migration/postgresql/main/003_river_job_tags_non_null.up.sql +3 -0
- data/migration/postgresql/main/004_pending_and_more.down.sql +42 -0
- data/migration/postgresql/main/004_pending_and_more.up.sql +45 -0
- data/migration/postgresql/main/005_migration_unique_client.down.sql +57 -0
- data/migration/postgresql/main/005_migration_unique_client.up.sql +79 -0
- data/migration/postgresql/main/006_bulk_unique.down.sql +16 -0
- data/migration/postgresql/main/006_bulk_unique.up.sql +40 -0
- data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +56 -0
- data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +44 -0
- data/migration/postgresql/main/008_job_id_autoincrement.down.sql +3 -0
- data/migration/postgresql/main/008_job_id_autoincrement.up.sql +3 -0
- data/migration/sqlite/main/001_create_river_migration.down.sql +1 -0
- data/migration/sqlite/main/001_create_river_migration.up.sql +8 -0
- data/migration/sqlite/main/002_initial_schema.down.sql +8 -0
- data/migration/sqlite/main/002_initial_schema.up.sql +19 -0
- data/migration/sqlite/main/003_river_job_tags_non_null.down.sql +6 -0
- data/migration/sqlite/main/003_river_job_tags_non_null.up.sql +6 -0
- data/migration/sqlite/main/004_pending_and_more.down.sql +26 -0
- data/migration/sqlite/main/004_pending_and_more.up.sql +33 -0
- data/migration/sqlite/main/005_migration_unique_client.down.sql +37 -0
- data/migration/sqlite/main/005_migration_unique_client.up.sql +64 -0
- data/migration/sqlite/main/006_bulk_unique.down.sql +7 -0
- data/migration/sqlite/main/006_bulk_unique.up.sql +63 -0
- data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +255 -0
- data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +261 -0
- data/migration/sqlite/main/008_job_id_autoincrement.down.sql +121 -0
- data/migration/sqlite/main/008_job_id_autoincrement.up.sql +123 -0
- data/sig/client.rbs +98 -0
- data/sig/driver.rbs +172 -0
- data/sig/insert_opts.rbs +25 -0
- data/sig/job.rbs +74 -0
- data/sig/job_persisted_logging/plugin.rbs +29 -0
- data/sig/migrator.rbs +44 -0
- data/sig/riverqueue.rbs +2 -0
- data/sig/runtime.rbs +402 -0
- data/sig/testing.rbs +95 -0
- data/sig/unique_args.rbs +8 -0
- data/sig/unique_bitmask.rbs +8 -0
- data/sig/worker_runner.rbs +22 -0
- metadata +170 -13
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module River::Driver
|
|
6
|
+
# Database operations used by River's worker runtime. Driver gems implement a
|
|
7
|
+
# small set of raw SQL primitives and include this module so the state machine
|
|
8
|
+
# stays identical across ActiveRecord and Sequel.
|
|
9
|
+
module Runtime
|
|
10
|
+
def init_driver
|
|
11
|
+
postgres_capabilities if runtime_postgres?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def postgres_capabilities
|
|
15
|
+
@postgres_capabilities_cache.fetch(runtime_connection_pool) do
|
|
16
|
+
row = runtime_query_rows(<<~SQL).first
|
|
17
|
+
SELECT version()::text AS product,
|
|
18
|
+
current_setting('server_version_num')::int AS version_num,
|
|
19
|
+
coalesce(current_setting('yb_enable_listen_notify', true), 'off')::boolean AS yb_listen_notify_enabled
|
|
20
|
+
SQL
|
|
21
|
+
PostgresCapabilities.new(
|
|
22
|
+
product: runtime_value(row, :product),
|
|
23
|
+
version_num: runtime_value(row, :version_num),
|
|
24
|
+
yb_listen_notify_enabled: runtime_value(row, :yb_listen_notify_enabled) == true
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Notifications and rows must commit together. The insertion drivers call
|
|
30
|
+
# this inside their transaction; PostgreSQL defers delivery until commit.
|
|
31
|
+
private def postgres_notify_insert(params)
|
|
32
|
+
queues = params.select { |param| param.state == River::JOB_STATE_AVAILABLE }.map(&:queue).uniq
|
|
33
|
+
return if queues.empty?
|
|
34
|
+
return unless postgres_capabilities.supports_listen_notify
|
|
35
|
+
|
|
36
|
+
payloads = queues.map { |queue| "(#{runtime_quote(JSON.generate(queue: queue))})" }.join(",")
|
|
37
|
+
runtime_execute("SELECT pg_notify(current_schema() || '.river_insert', payload) FROM (VALUES #{payloads}) AS notifications(payload)")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def job_cancel(id, now: Time.now.utc)
|
|
41
|
+
original_id = Integer(id)
|
|
42
|
+
transaction do
|
|
43
|
+
updated_id = runtime_returning_ids(<<~SQL).first
|
|
44
|
+
UPDATE river_job
|
|
45
|
+
SET state = CASE WHEN state = 'running' THEN state ELSE 'cancelled' END,
|
|
46
|
+
finalized_at = CASE WHEN state = 'running' THEN finalized_at ELSE #{runtime_time(now)} END,
|
|
47
|
+
metadata = #{runtime_merge_metadata("cancel_attempted_at" => now.iso8601(6))}
|
|
48
|
+
WHERE id = #{original_id}
|
|
49
|
+
AND state NOT IN ('cancelled', 'completed', 'discarded')
|
|
50
|
+
AND finalized_at IS NULL
|
|
51
|
+
RETURNING id
|
|
52
|
+
SQL
|
|
53
|
+
row = job_get_by_id(updated_id || original_id)
|
|
54
|
+
# Other engines interrupt running workers through this notification;
|
|
55
|
+
# the durable marker remains Ruby's polling fallback.
|
|
56
|
+
runtime_notify("river_control", action: "cancel", job_id: row.id, queue: row.queue) if updated_id
|
|
57
|
+
row
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def job_claim(id:, attempted_by:, allow_scheduled: false, now: Time.now.utc)
|
|
62
|
+
predicate = "id = #{Integer(id)} AND state IN ('available', 'retryable', 'scheduled')"
|
|
63
|
+
predicate += " AND scheduled_at <= #{runtime_time(now)}" unless allow_scheduled
|
|
64
|
+
|
|
65
|
+
runtime_claim_jobs(predicate, 1, attempted_by, now).first
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The cancellation predicate must be part of the completion update so it is
|
|
69
|
+
# checked against the row we lock, including concurrent cancellations.
|
|
70
|
+
# :cancelled asks the runtime to apply its normal cancellation/error hooks.
|
|
71
|
+
def job_complete(id:, finalized_at:, metadata: nil, now: Time.now.utc)
|
|
72
|
+
id = Integer(id)
|
|
73
|
+
assignments = ["state = 'completed'", "finalized_at = #{runtime_time(finalized_at)}"]
|
|
74
|
+
assignments << "metadata = #{runtime_merge_metadata(metadata)}" unless metadata.nil? || metadata.empty?
|
|
75
|
+
|
|
76
|
+
updated_id = runtime_returning_ids(<<~SQL).first
|
|
77
|
+
UPDATE river_job SET #{assignments.join(", ")}
|
|
78
|
+
WHERE id = #{id} AND state = 'running' AND NOT (#{runtime_cancel_attempted})
|
|
79
|
+
RETURNING id
|
|
80
|
+
SQL
|
|
81
|
+
return runtime_read_job(updated_id) if updated_id
|
|
82
|
+
|
|
83
|
+
:cancelled if job_get_cancelled_ids([id]).include?(id)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def job_delete(id)
|
|
87
|
+
existing = job_get_by_id(id)
|
|
88
|
+
return nil unless existing
|
|
89
|
+
return existing if existing.state == River::JOB_STATE_RUNNING
|
|
90
|
+
|
|
91
|
+
deleted_id = runtime_returning_ids("DELETE FROM river_job WHERE id = #{Integer(id)} AND state != 'running' RETURNING id").first
|
|
92
|
+
deleted_id ? existing : job_get_by_id(id)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def job_delete_finalized(retention:, now: Time.now.utc, max: 1_000)
|
|
96
|
+
clauses = retention.filter_map do |state, seconds|
|
|
97
|
+
next unless seconds
|
|
98
|
+
|
|
99
|
+
"(state = #{runtime_quote(state.to_s)} AND finalized_at < #{runtime_time(now - seconds)})"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
return 0 if clauses.empty?
|
|
103
|
+
|
|
104
|
+
ids = runtime_query_rows(<<~SQL).map { |row| runtime_value(row, :id).to_i }
|
|
105
|
+
SELECT id FROM river_job WHERE #{clauses.join(" OR ")} ORDER BY id LIMIT #{Integer(max)}
|
|
106
|
+
SQL
|
|
107
|
+
return 0 if ids.empty?
|
|
108
|
+
|
|
109
|
+
runtime_returning_ids(<<~SQL).length
|
|
110
|
+
DELETE FROM river_job
|
|
111
|
+
WHERE id IN (#{ids.join(",")}) AND (#{clauses.join(" OR ")})
|
|
112
|
+
RETURNING id
|
|
113
|
+
SQL
|
|
114
|
+
end
|
|
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.
|
|
118
|
+
def job_delete_if_running(id)
|
|
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
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def job_delete_many(params)
|
|
132
|
+
transaction do
|
|
133
|
+
jobs = runtime_job_list(params, for_delete: true)
|
|
134
|
+
next [] if jobs.empty?
|
|
135
|
+
|
|
136
|
+
ids = jobs.map(&:id)
|
|
137
|
+
deleted = runtime_returning_ids(<<~SQL)
|
|
138
|
+
DELETE FROM river_job
|
|
139
|
+
WHERE id IN (#{ids.join(",")}) AND state != 'running'
|
|
140
|
+
RETURNING id
|
|
141
|
+
SQL
|
|
142
|
+
jobs.select { |job| deleted.include?(job.id) }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def job_get_available(queue:, max:, attempted_by:, kinds: nil, now: Time.now.utc)
|
|
147
|
+
return [] if kinds&.empty?
|
|
148
|
+
|
|
149
|
+
predicate = "state = 'available' AND queue = #{runtime_quote(queue)} AND scheduled_at <= #{runtime_time(now)}"
|
|
150
|
+
predicate += " AND #{runtime_in_clause("kind", kinds)}" if kinds
|
|
151
|
+
runtime_claim_jobs(predicate, max, attempted_by, now)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def job_get_cancelled_ids(ids)
|
|
155
|
+
return [] if ids.empty?
|
|
156
|
+
|
|
157
|
+
runtime_query_rows("SELECT id FROM river_job WHERE id IN (#{ids.map { |id| Integer(id) }.join(",")}) AND (#{runtime_cancel_attempted}) ORDER BY id")
|
|
158
|
+
.map { |row| runtime_value(row, :id).to_i }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def job_list(params = nil)
|
|
162
|
+
params ||= River::JobListParams.new
|
|
163
|
+
return runtime_job_list_without_params if params == :all
|
|
164
|
+
|
|
165
|
+
runtime_job_list(params)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def job_metadata_merge(id, metadata)
|
|
169
|
+
updated_id = runtime_returning_ids(<<~SQL).first
|
|
170
|
+
UPDATE river_job
|
|
171
|
+
SET metadata = #{runtime_merge_metadata(metadata)}
|
|
172
|
+
WHERE id = #{Integer(id)}
|
|
173
|
+
RETURNING id
|
|
174
|
+
SQL
|
|
175
|
+
updated_id ? job_get_by_id(updated_id) : nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def job_rescue_stuck(horizon:, retry_policy:, now: Time.now.utc, max: 1_000, logger: nil, rescue_if: nil)
|
|
179
|
+
max = Integer(max)
|
|
180
|
+
transaction do
|
|
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
|
|
184
|
+
lock = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : ""
|
|
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
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
rescued
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def job_retry(id, now: Time.now.utc)
|
|
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
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def job_schedule(now: Time.now.utc, max: 1_000)
|
|
256
|
+
transaction do
|
|
257
|
+
# Hold each selected row until its transition (including uniqueness
|
|
258
|
+
# conflict handling) finishes. A concurrent retry may change its due time.
|
|
259
|
+
lock = runtime_postgres? ? "FOR UPDATE SKIP LOCKED" : ""
|
|
260
|
+
ids = runtime_query_rows(<<~SQL).map { |row| runtime_value(row, :id).to_i }
|
|
261
|
+
SELECT id FROM river_job
|
|
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
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def job_set_state_if_running(id:, state:, now: Time.now.utc, attempt: nil,
|
|
284
|
+
error: nil, finalized_at: nil, metadata: nil, scheduled_at: nil)
|
|
285
|
+
state = state.to_s
|
|
286
|
+
# Cancellation wins over every attempt outcome, including exhausted
|
|
287
|
+
# retries, direct completion, and transitions requested by extensions.
|
|
288
|
+
cancel_path = runtime_cancel_attempted
|
|
289
|
+
|
|
290
|
+
assignments = [] #: Array[String]
|
|
291
|
+
assignments << "attempt = CASE WHEN NOT (#{cancel_path}) THEN #{Integer(attempt)} ELSE attempt END" unless attempt.nil?
|
|
292
|
+
assignments << "errors = #{runtime_append_error(error)}" if error
|
|
293
|
+
|
|
294
|
+
assignments << "finalized_at = CASE WHEN #{cancel_path} THEN #{runtime_time(now)} ELSE #{runtime_nullable_time(finalized_at)} END"
|
|
295
|
+
assignments << "metadata = #{runtime_merge_metadata(metadata)}" unless metadata.nil? || metadata.empty?
|
|
296
|
+
assignments << "scheduled_at = CASE WHEN NOT (#{cancel_path}) THEN #{runtime_time(scheduled_at)} ELSE scheduled_at END" if scheduled_at
|
|
297
|
+
|
|
298
|
+
assignments << "state = CASE WHEN #{cancel_path} THEN 'cancelled' ELSE #{runtime_state(state)} END"
|
|
299
|
+
|
|
300
|
+
id = runtime_returning_ids(<<~SQL).first
|
|
301
|
+
UPDATE river_job
|
|
302
|
+
SET #{assignments.join(",\n ")}
|
|
303
|
+
WHERE id = #{Integer(id)} AND state = 'running'
|
|
304
|
+
RETURNING id
|
|
305
|
+
SQL
|
|
306
|
+
id ? runtime_read_job(id) : nil
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def job_update(id, params)
|
|
310
|
+
assignments = params.each.map do |field, value|
|
|
311
|
+
raise ArgumentError, "unknown update field: #{field}" unless River::JobUpdateParams.method_defined?(field)
|
|
312
|
+
|
|
313
|
+
"#{field} = #{runtime_update_value(field, value)}"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
return job_get_by_id(id) if assignments.empty?
|
|
317
|
+
|
|
318
|
+
updated_id = runtime_returning_ids(<<~SQL).first
|
|
319
|
+
UPDATE river_job SET #{assignments.join(", ")}
|
|
320
|
+
WHERE id = #{Integer(id)}
|
|
321
|
+
RETURNING id
|
|
322
|
+
SQL
|
|
323
|
+
updated_id ? job_get_by_id(updated_id) : nil
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def leader_acquire(id, ttl: 30, now: Time.now.utc)
|
|
327
|
+
transaction do
|
|
328
|
+
runtime_execute("DELETE FROM river_leader WHERE expires_at < #{runtime_time(now)}")
|
|
329
|
+
runtime_execute(<<~SQL)
|
|
330
|
+
INSERT INTO river_leader (leader_id, elected_at, expires_at)
|
|
331
|
+
VALUES (#{runtime_quote(id)}, #{runtime_time(now)}, #{runtime_time(now + ttl)})
|
|
332
|
+
ON CONFLICT (name) DO NOTHING
|
|
333
|
+
SQL
|
|
334
|
+
runtime_query_rows("SELECT leader_id FROM river_leader WHERE leader_id = #{runtime_quote(id)}").any?
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def leader_release(id)
|
|
339
|
+
transaction do
|
|
340
|
+
deleted = runtime_query_rows("DELETE FROM river_leader WHERE leader_id = #{runtime_quote(id)} RETURNING leader_id")
|
|
341
|
+
runtime_notify("river_leadership", action: "resigned", leader_id: id) unless deleted.empty?
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def leader_renew(id, ttl: 30, now: Time.now.utc)
|
|
346
|
+
runtime_query_rows(<<~SQL).any?
|
|
347
|
+
UPDATE river_leader SET expires_at = #{runtime_time(now + ttl)}
|
|
348
|
+
WHERE leader_id = #{runtime_quote(id)} AND expires_at >= #{runtime_time(now)}
|
|
349
|
+
RETURNING leader_id
|
|
350
|
+
SQL
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Bounded batches release SQLite's writer lock between maintenance passes.
|
|
354
|
+
def notification_delete_before(horizon:, max: 10_000)
|
|
355
|
+
return 0 if runtime_postgres?
|
|
356
|
+
|
|
357
|
+
runtime_returning_ids(<<~SQL).length
|
|
358
|
+
DELETE FROM river_notification
|
|
359
|
+
WHERE id IN (
|
|
360
|
+
SELECT id FROM river_notification
|
|
361
|
+
WHERE created_at < #{runtime_time(horizon)}
|
|
362
|
+
ORDER BY created_at, id LIMIT #{Integer(max)}
|
|
363
|
+
)
|
|
364
|
+
RETURNING id
|
|
365
|
+
SQL
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def queue_get(name)
|
|
369
|
+
row = runtime_query_rows("SELECT #{runtime_queue_columns} FROM river_queue WHERE name = #{runtime_quote(name)}").first
|
|
370
|
+
runtime_queue_from_row(row)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def queue_list(max: 100)
|
|
374
|
+
runtime_query_rows("SELECT #{runtime_queue_columns} FROM river_queue ORDER BY name LIMIT #{Integer(max)}")
|
|
375
|
+
.map { |row| runtime_queue_from_row(row) }
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def queue_pause(name, now: Time.now.utc)
|
|
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
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def queue_resume(name, now: Time.now.utc)
|
|
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
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def queue_update(name, metadata:, now: Time.now.utc)
|
|
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
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def queue_upsert(name, metadata: {}, now: Time.now.utc)
|
|
424
|
+
raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
|
|
425
|
+
|
|
426
|
+
runtime_execute(<<~SQL)
|
|
427
|
+
INSERT INTO river_queue (name, created_at, metadata, updated_at)
|
|
428
|
+
VALUES (#{runtime_quote(name)}, #{runtime_time(now)}, #{runtime_json(metadata)}, #{runtime_time(now)})
|
|
429
|
+
ON CONFLICT (name) DO UPDATE SET updated_at = excluded.updated_at
|
|
430
|
+
SQL
|
|
431
|
+
queue_get(name)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
private def runtime_append_error(error)
|
|
435
|
+
value = error.respond_to?(:to_h) ? error.to_h : error
|
|
436
|
+
if runtime_postgres?
|
|
437
|
+
"array_append(errors, #{runtime_json(value)})"
|
|
438
|
+
else
|
|
439
|
+
encoded = "json(#{runtime_quote(JSON.generate(value))})"
|
|
440
|
+
# Damaged JSONB may contain invalid UTF-8. Preserve its bytes as hex
|
|
441
|
+
# rather than creating an unreadable JSON string by casting it to text.
|
|
442
|
+
<<~SQL
|
|
443
|
+
CASE WHEN NOT json_valid(errors, 10) AND errors IS NOT NULL THEN
|
|
444
|
+
jsonb(json_array(CASE WHEN typeof(errors) = 'blob' THEN 'invalid JSONB: ' || hex(errors) ELSE errors END, #{encoded}))
|
|
445
|
+
WHEN coalesce(json_type(errors), 'array') <> 'array' THEN jsonb(json_array(json(errors), #{encoded}))
|
|
446
|
+
ELSE jsonb(json_insert(json(coalesce(errors, jsonb('[]'))), '$[#]', #{encoded})) END
|
|
447
|
+
SQL
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
private def runtime_cancel_attempted
|
|
452
|
+
runtime_postgres? ? "metadata ? 'cancel_attempted_at'" : "(CASE WHEN json_valid(metadata, 10) THEN metadata -> 'cancel_attempted_at' END) IS NOT NULL"
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
private def runtime_claim_jobs(predicate, max, attempted_by, now)
|
|
456
|
+
transaction do
|
|
457
|
+
attempted_by_sql = if runtime_postgres?
|
|
458
|
+
"array_append(CASE WHEN cardinality(attempted_by) >= 100 THEN attempted_by[(cardinality(attempted_by) - 98):] ELSE attempted_by END, #{runtime_quote(attempted_by)})"
|
|
459
|
+
else
|
|
460
|
+
# Preserve corrupt history so decoding can fail this attempt instead
|
|
461
|
+
# of silently repairing it or aborting the entire claim batch. Trim
|
|
462
|
+
# to the newest 99 entries before appending, preserving JSON types.
|
|
463
|
+
<<~SQL
|
|
464
|
+
CASE WHEN NOT json_valid(attempted_by, 10) AND attempted_by IS NOT NULL THEN attempted_by
|
|
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
|
|
473
|
+
SQL
|
|
474
|
+
end
|
|
475
|
+
|
|
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.
|
|
479
|
+
ids = runtime_returning_ids(<<~SQL)
|
|
480
|
+
UPDATE river_job
|
|
481
|
+
SET attempt = CASE WHEN attempt < #{River::MAX_ATTEMPTS_LIMIT} THEN attempt + 1 ELSE attempt END,
|
|
482
|
+
attempted_at = #{runtime_time(now)},
|
|
483
|
+
attempted_by = #{attempted_by_sql},
|
|
484
|
+
state = 'running'
|
|
485
|
+
WHERE id IN (
|
|
486
|
+
SELECT id FROM river_job
|
|
487
|
+
WHERE #{predicate}
|
|
488
|
+
ORDER BY priority ASC, scheduled_at ASC, id ASC
|
|
489
|
+
LIMIT #{Integer(max)}
|
|
490
|
+
#{lock_clause}
|
|
491
|
+
)
|
|
492
|
+
RETURNING id
|
|
493
|
+
SQL
|
|
494
|
+
ids.map { |id| runtime_read_job(id) }
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
private def runtime_read_job(id)
|
|
499
|
+
job_get_by_id(id)
|
|
500
|
+
rescue River::JobRowDecodeError => error
|
|
501
|
+
error.job
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
private def runtime_decode_postgres_job(row)
|
|
505
|
+
row = row.transform_keys(&:to_sym)
|
|
506
|
+
decoder = JobRowDecoder.new
|
|
507
|
+
decoder.finish(River::JobRow.new(
|
|
508
|
+
id: row[:id], args: decoder.decoded(:args, row[:args], default: {}),
|
|
509
|
+
attempt: row[:attempt], attempted_at: row[:attempted_at]&.getutc,
|
|
510
|
+
attempted_by: decoder.decoded(:attempted_by, row[:attempted_by], type: Array, strings: true),
|
|
511
|
+
created_at: row[:created_at].getutc,
|
|
512
|
+
errors: row[:errors]&.map { |error| JobRowDecoder.attempt_error(error) },
|
|
513
|
+
finalized_at: row[:finalized_at]&.getutc, kind: row[:kind], max_attempts: row[:max_attempts],
|
|
514
|
+
metadata: decoder.decoded(:metadata, row[:metadata], type: Hash, default: {}),
|
|
515
|
+
priority: row[:priority], queue: row[:queue], scheduled_at: row[:scheduled_at].getutc,
|
|
516
|
+
state: row[:state], tags: decoder.decoded(:tags, row[:tags], type: Array, strings: true),
|
|
517
|
+
unique_key: row[:unique_key]&.to_s,
|
|
518
|
+
unique_states: row[:unique_states] ? River::UniqueBitmask.to_states(row[:unique_states].to_i(2)) : nil
|
|
519
|
+
))
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
private def runtime_decode_sqlite_job(row)
|
|
523
|
+
row = row.transform_keys(&:to_sym)
|
|
524
|
+
decoder = JobRowDecoder.new
|
|
525
|
+
errors = decoder.json(:errors, row[:errors], type: Array, default: [])
|
|
526
|
+
decoder.finish(River::JobRow.new(
|
|
527
|
+
id: row[:id], args: decoder.json(:args, row[:args], default: {}),
|
|
528
|
+
attempt: row[:attempt], attempted_at: runtime_parse_time(row[:attempted_at]),
|
|
529
|
+
attempted_by: decoder.json(:attempted_by, row[:attempted_by], type: Array, strings: true),
|
|
530
|
+
created_at: runtime_parse_time(row[:created_at]),
|
|
531
|
+
errors: errors.map { |error| JobRowDecoder.attempt_error(error) },
|
|
532
|
+
finalized_at: runtime_parse_time(row[:finalized_at]), kind: row[:kind], max_attempts: row[:max_attempts],
|
|
533
|
+
metadata: decoder.json(:metadata, row[:metadata], type: Hash, default: {}),
|
|
534
|
+
priority: row[:priority], queue: row[:queue], scheduled_at: runtime_parse_time(row[:scheduled_at]),
|
|
535
|
+
state: row[:state], tags: decoder.json(:tags, row[:tags], type: Array, strings: true),
|
|
536
|
+
unique_key: row[:unique_key]&.to_s,
|
|
537
|
+
unique_states: row[:unique_states] ? River::UniqueBitmask.to_states(row[:unique_states]) : nil
|
|
538
|
+
))
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
private def runtime_in_clause(column, values)
|
|
542
|
+
"#{column} IN (#{values.map { |value| runtime_quote(value) }.join(",")})"
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
private def runtime_notify(topic, payload)
|
|
546
|
+
encoded = runtime_quote(JSON.generate(payload))
|
|
547
|
+
if runtime_postgres?
|
|
548
|
+
return unless postgres_capabilities.supports_listen_notify
|
|
549
|
+
|
|
550
|
+
runtime_execute("SELECT pg_notify(current_schema() || '.' || #{runtime_quote(topic)}, #{encoded})")
|
|
551
|
+
else
|
|
552
|
+
runtime_execute("INSERT INTO river_notification (topic, payload) VALUES (#{runtime_quote(topic)}, #{encoded})")
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
private def sqlite_insert_nonces(params)
|
|
557
|
+
keys = {} #: Hash[String, bool]
|
|
558
|
+
params.map do |param|
|
|
559
|
+
key = param.unique_key
|
|
560
|
+
if key && !key.empty? && River::UniqueBitmask.to_states((param.unique_states || "0").to_i(2)).include?(param.state)
|
|
561
|
+
# PostgreSQL rejects batches that upsert the same indexed row twice.
|
|
562
|
+
raise ArgumentError, "unique key appears more than once in batch" if keys[key]
|
|
563
|
+
|
|
564
|
+
keys[key] = true
|
|
565
|
+
end
|
|
566
|
+
SecureRandom.hex(8)
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
private def runtime_cursor_clause(params)
|
|
571
|
+
cursor = params.after
|
|
572
|
+
comparison = (params.sort_order == :asc) ? ">" : "<"
|
|
573
|
+
id_clause = "id #{comparison} #{Integer(cursor.id)}"
|
|
574
|
+
return id_clause if params.sort_by == :id
|
|
575
|
+
|
|
576
|
+
column = params.sort_by
|
|
577
|
+
return "(#{column} IS NULL AND #{id_clause})" if cursor.value.nil?
|
|
578
|
+
|
|
579
|
+
value = runtime_time(cursor.value)
|
|
580
|
+
"(#{column} IS NULL OR #{column} #{comparison} #{value} OR (#{column} = #{value} AND #{id_clause}))"
|
|
581
|
+
end
|
|
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
|
+
|
|
618
|
+
private def runtime_json(value)
|
|
619
|
+
encoded = value.is_a?(String) ? value : JSON.generate(value)
|
|
620
|
+
runtime_postgres? ? "#{runtime_quote(encoded)}::jsonb" : "jsonb(#{runtime_quote(encoded)})"
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
private def runtime_merge_metadata(metadata)
|
|
624
|
+
raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
|
|
625
|
+
|
|
626
|
+
if runtime_postgres?
|
|
627
|
+
"metadata || #{runtime_json(metadata)}"
|
|
628
|
+
else
|
|
629
|
+
# PostgreSQL's || replaces top-level values, including JSON null.
|
|
630
|
+
# JSON Merge Patch would recursively merge objects and delete nulls.
|
|
631
|
+
merged = metadata.reduce("metadata") do |expression, (key, value)|
|
|
632
|
+
path = runtime_quote("$.#{JSON.generate(key.to_s)}")
|
|
633
|
+
"jsonb_set(#{expression}, #{path}, jsonb(#{runtime_quote(JSON.generate(value))}))"
|
|
634
|
+
end
|
|
635
|
+
"CASE WHEN json_valid(metadata, 10) THEN #{merged} ELSE metadata END"
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
private def runtime_metadata_equals(key, value)
|
|
640
|
+
encoded = runtime_quote(JSON.generate(value))
|
|
641
|
+
if runtime_postgres?
|
|
642
|
+
"metadata -> #{runtime_quote(key.to_s)} = #{encoded}::jsonb"
|
|
643
|
+
else
|
|
644
|
+
# Compare JSON trees so object key order is immaterial, while strings,
|
|
645
|
+
# booleans, null, and missing keys remain distinct. json_each also treats
|
|
646
|
+
# the requested metadata key literally instead of as a JSON path.
|
|
647
|
+
columns = "fullkey, CASE WHEN type IN ('integer', 'real') THEN 'number' ELSE type END, atom"
|
|
648
|
+
actual = <<~SQL
|
|
649
|
+
SELECT #{columns} FROM json_tree(CASE entry.type
|
|
650
|
+
WHEN 'text' THEN json_quote(entry.value)
|
|
651
|
+
WHEN 'null' THEN 'null'
|
|
652
|
+
WHEN 'true' THEN 'true'
|
|
653
|
+
WHEN 'false' THEN 'false'
|
|
654
|
+
ELSE entry.value END)
|
|
655
|
+
SQL
|
|
656
|
+
expected = "SELECT #{columns} FROM json_tree(#{encoded})"
|
|
657
|
+
<<~SQL
|
|
658
|
+
EXISTS (SELECT 1 FROM json_each(metadata) AS entry
|
|
659
|
+
WHERE entry.key = #{runtime_quote(key.to_s)}
|
|
660
|
+
AND NOT EXISTS (#{actual} EXCEPT #{expected})
|
|
661
|
+
AND NOT EXISTS (#{expected} EXCEPT #{actual}))
|
|
662
|
+
SQL
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
private def runtime_nullable_time(value)
|
|
667
|
+
value ? runtime_time(value) : "NULL"
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
private def runtime_parse_json(value)
|
|
671
|
+
value.is_a?(String) ? JSON.parse(value) : value.to_h
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
private def runtime_parse_time(value)
|
|
675
|
+
return nil unless value
|
|
676
|
+
|
|
677
|
+
if value.respond_to?(:getutc)
|
|
678
|
+
value.getutc
|
|
679
|
+
else
|
|
680
|
+
Time.parse(value.to_s + (value.to_s.match?(/(?:Z|[+-]\d{2}:?\d{2})\z/) ? "" : " UTC")).utc
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
private def runtime_queue_columns
|
|
685
|
+
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"
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
private def runtime_queue_from_row(row)
|
|
689
|
+
return nil unless row
|
|
690
|
+
|
|
691
|
+
River::Queue.new(
|
|
692
|
+
runtime_value(row, :name),
|
|
693
|
+
runtime_parse_time(runtime_value(row, :created_at)),
|
|
694
|
+
runtime_parse_json(runtime_value(row, :metadata)),
|
|
695
|
+
runtime_parse_time(runtime_value(row, :paused_at)),
|
|
696
|
+
runtime_parse_time(runtime_value(row, :updated_at))
|
|
697
|
+
)
|
|
698
|
+
end
|
|
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
|
+
|
|
711
|
+
private def runtime_returning_ids(sql)
|
|
712
|
+
runtime_query_rows(sql).map { |row| runtime_value(row, :id).to_i }
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
private def runtime_state(value)
|
|
716
|
+
runtime_postgres? ? "#{runtime_quote(value)}::river_job_state" : runtime_quote(value)
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
private def runtime_tag_contains(tag)
|
|
720
|
+
if runtime_postgres?
|
|
721
|
+
"tags @> ARRAY[#{runtime_quote(tag)}]::varchar[]"
|
|
722
|
+
else
|
|
723
|
+
"EXISTS (SELECT 1 FROM json_each(json(tags)) WHERE value = #{runtime_quote(tag)})"
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
private def runtime_time(value)
|
|
728
|
+
raise ArgumentError, "time cannot be nil" unless value
|
|
729
|
+
|
|
730
|
+
cast = runtime_postgres? ? "::timestamptz" : ""
|
|
731
|
+
encoded = if runtime_postgres?
|
|
732
|
+
value.getutc.iso8601(6)
|
|
733
|
+
else
|
|
734
|
+
value.getutc.round(3).strftime("%Y-%m-%d %H:%M:%S.%3N")
|
|
735
|
+
end
|
|
736
|
+
"#{runtime_quote(encoded)}#{cast}"
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
private def runtime_update_value(field, value)
|
|
740
|
+
case field
|
|
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
|
|
746
|
+
when :attempted_at, :finalized_at
|
|
747
|
+
value ? runtime_time(value) : "NULL"
|
|
748
|
+
when :attempted_by
|
|
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)
|
|
753
|
+
when :errors
|
|
754
|
+
input_values = Array(value) #: Array[untyped]
|
|
755
|
+
values = input_values.map { |error| error.respond_to?(:to_h) ? error.to_h : error }
|
|
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
|
|
763
|
+
when :metadata
|
|
764
|
+
raise ArgumentError, "metadata must be a Hash" unless value.is_a?(Hash)
|
|
765
|
+
|
|
766
|
+
runtime_json(value)
|
|
767
|
+
when :state
|
|
768
|
+
runtime_state(value)
|
|
769
|
+
end
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
end
|