riverqueue-activerecord 0.10.1 → 0.11.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.
Files changed (3) hide show
  1. checksums.yaml +4 -4
  2. data/lib/driver.rb +236 -20
  3. metadata +4 -24
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b74ec767cc9eb7216aaad6ea31922d9c7ba2d19fd9f49700c230092001f38b6a
4
- data.tar.gz: aee6b04ec4bcfa62ab555480fc2a6c476cccb165c18392f61d697f86a03d0f1e
3
+ metadata.gz: 8316b332b5bc9bb1eae57d81751a31ef42342da5ef9a1914b3bceccb222041ac
4
+ data.tar.gz: 49784779614e66d985af52516698d1766980ec43af019c62f20a8cd8459d1a69
5
5
  SHA512:
6
- metadata.gz: 9d06c2301b8d231bd75c97c64e960b7fccdc13a9969a4a3f2fee945de12c0f08c3c028f89e16f67600e422a9b1d64cb05e513bc80f25f34af45f6fff01866981
7
- data.tar.gz: 7e5489860c578f9eabab7c7c8d81ce52446b35f6512e7bf2711121c6e1981ab79ffaf52b127998ad0135c74b796e64961acdbbe74e17f7cfc71185618651967f
6
+ metadata.gz: d3b77f7ad4752bad4401ccaad39bf66513ed84e9aa498bfe8f3f8552fd7f7c6a63a60ed4b2b8cdb3ac33ea8367698c61c35a7f790ed43d9f6e67e1729cd8ede0
7
+ data.tar.gz: 39992e6b530ed49a19deb622e2e689c0a221902fc23ef1ec8c7ca7bc54bbe9eb79e0bb2094aede12eafce8fe08497e248139260e2b9410e06eb43d85ae33bfbb
data/lib/driver.rb CHANGED
@@ -1,13 +1,65 @@
1
+ require "securerandom"
2
+
1
3
  module River::Driver
2
- # Provides a ActiveRecord driver for River.
4
+ # Provides an ActiveRecord driver for River that supports both PostgreSQL
5
+ # and SQLite.
3
6
  #
4
7
  # Used in conjunction with a River client like:
5
8
  #
6
- # DB = ActiveRecord.connect("postgres://...")
7
- # client = River::Client.new(River::Driver::ActiveRecord.new(DB))
9
+ # ActiveRecord::Base.establish_connection("postgres://...")
10
+ # client = River::Client.new(River::Driver::ActiveRecord.new)
8
11
  #
9
12
  class ActiveRecord
13
+ SQLITE_CONFLICT_WHERE = <<~SQL.chomp
14
+ unique_key IS NOT NULL
15
+ AND unique_states IS NOT NULL
16
+ AND CASE state
17
+ WHEN 'available' THEN unique_states & (1 << 0)
18
+ WHEN 'cancelled' THEN unique_states & (1 << 1)
19
+ WHEN 'completed' THEN unique_states & (1 << 2)
20
+ WHEN 'discarded' THEN unique_states & (1 << 3)
21
+ WHEN 'pending' THEN unique_states & (1 << 4)
22
+ WHEN 'retryable' THEN unique_states & (1 << 5)
23
+ WHEN 'running' THEN unique_states & (1 << 6)
24
+ WHEN 'scheduled' THEN unique_states & (1 << 7)
25
+ ELSE 0
26
+ END >= 1
27
+ SQL
28
+ private_constant :SQLITE_CONFLICT_WHERE
29
+
30
+ # SQLite 3.45+ may store JSON as binary JSONB. Always project JSON columns
31
+ # through json() so this driver can read both the current JSONB format and
32
+ # the text JSON used by River migrations through version 006. Cast times to
33
+ # text so ActiveRecord doesn't interpret timezone-less SQLite timestamps in
34
+ # the process timezone.
35
+ SQLITE_JOB_COLUMNS = <<~SQL.chomp
36
+ id,
37
+ json(args) AS args,
38
+ attempt,
39
+ CAST(attempted_at AS text) AS attempted_at,
40
+ json(attempted_by) AS attempted_by,
41
+ CAST(created_at AS text) AS created_at,
42
+ json(errors) AS errors,
43
+ CAST(finalized_at AS text) AS finalized_at,
44
+ kind,
45
+ max_attempts,
46
+ json(metadata) AS metadata,
47
+ priority,
48
+ queue,
49
+ state,
50
+ CAST(scheduled_at AS text) AS scheduled_at,
51
+ json(tags) AS tags,
52
+ unique_key,
53
+ unique_states
54
+ SQL
55
+ private_constant :SQLITE_JOB_COLUMNS
56
+
57
+ SQLITE_UNIQUE_NONCE_KEY = "river:unique_nonce"
58
+ private_constant :SQLITE_UNIQUE_NONCE_KEY
59
+
10
60
  def initialize
61
+ @is_sqlite = ::ActiveRecord::Base.connection.adapter_name.downcase.include?("sqlite")
62
+
11
63
  # It's Ruby, so we can only define a model after ActiveRecord's established a
12
64
  # connection because it's all dynamic.
13
65
  if !River::Driver::ActiveRecord.const_defined?(:RiverJob)
@@ -32,8 +84,13 @@ module River::Driver
32
84
  end
33
85
 
34
86
  def job_get_by_id(id)
35
- data_set = RiverJob.where(id: id)
36
- data_set.first ? to_job_row_from_model(data_set.first) : nil
87
+ if @is_sqlite
88
+ row = sqlite_job_rows("WHERE id = ? LIMIT 1", [id]).first
89
+ row ? sqlite_to_job_row_from_raw(row) : nil
90
+ else
91
+ data_set = RiverJob.where(id: id)
92
+ data_set.first ? to_job_row_from_model(data_set.first) : nil
93
+ end
37
94
  end
38
95
 
39
96
  def job_insert(insert_params)
@@ -41,8 +98,28 @@ module River::Driver
41
98
  end
42
99
 
43
100
  def job_insert_many(insert_params_many)
101
+ @is_sqlite ? sqlite_job_insert_many(insert_params_many) : postgres_job_insert_many(insert_params_many)
102
+ end
103
+
104
+ def job_list
105
+ if @is_sqlite
106
+ sqlite_job_rows("ORDER BY id").map { |row| sqlite_to_job_row_from_raw(row) }
107
+ else
108
+ RiverJob.order(:id).all.map { |job| to_job_row_from_model(job) }
109
+ end
110
+ end
111
+
112
+ def rollback_exception
113
+ ::ActiveRecord::Rollback
114
+ end
115
+
116
+ def transaction(&)
117
+ ::ActiveRecord::Base.transaction(requires_new: true, &)
118
+ end
119
+
120
+ private def postgres_job_insert_many(insert_params_many)
44
121
  res = RiverJob.upsert_all(
45
- insert_params_many.map { |param| insert_params_to_hash(param) },
122
+ insert_params_many.map { |param| postgres_insert_params_to_hash(param) },
46
123
  on_duplicate: Arel.sql("kind = EXCLUDED.kind"),
47
124
  returning: Arel.sql("*, (xmax != 0) AS unique_skipped_as_duplicate"),
48
125
 
@@ -53,23 +130,68 @@ module River::Driver
53
130
  # clause. The workaround is to target the index name instead of columns.
54
131
  unique_by: "river_job_unique_idx"
55
132
  )
56
- to_insert_results(res)
133
+ postgres_to_insert_results(res)
57
134
  end
58
135
 
59
- def job_list
60
- data_set = RiverJob.order(:id)
61
- data_set.all.map { |job| to_job_row_from_model(job) }
62
- end
136
+ # River's current SQLite driver uses json_each to make a batch a single,
137
+ # atomic statement. The JSON columns are converted to SQLite JSONB here,
138
+ # matching migration 007 and newer River databases.
139
+ private def sqlite_job_insert_many(insert_params_many)
140
+ return [] if insert_params_many.empty?
63
141
 
64
- def rollback_exception
65
- ::ActiveRecord::Rollback
66
- end
142
+ ::ActiveRecord::Base.transaction(requires_new: true) do
143
+ nonce = SecureRandom.hex(8)
144
+ jobs = insert_params_many.map { |param| sqlite_insert_params_to_hash(param, nonce) }
67
145
 
68
- def transaction(&)
69
- ::ActiveRecord::Base.transaction(requires_new: true, &)
146
+ sql = <<~SQL
147
+ INSERT INTO river_job (
148
+ args,
149
+ created_at,
150
+ kind,
151
+ max_attempts,
152
+ metadata,
153
+ priority,
154
+ queue,
155
+ scheduled_at,
156
+ state,
157
+ tags,
158
+ unique_key,
159
+ unique_states
160
+ )
161
+ SELECT
162
+ jsonb(json_extract(value, '$.args')),
163
+ datetime('now', 'subsec'),
164
+ cast(json_extract(value, '$.kind') AS text),
165
+ cast(json_extract(value, '$.max_attempts') AS integer),
166
+ jsonb(json_extract(value, '$.metadata')),
167
+ cast(json_extract(value, '$.priority') AS integer),
168
+ cast(json_extract(value, '$.queue') AS text),
169
+ coalesce(cast(json_extract(value, '$.scheduled_at') AS text), datetime('now', 'subsec')),
170
+ cast(json_extract(value, '$.state') AS text),
171
+ jsonb(json_extract(value, '$.tags')),
172
+ CASE
173
+ WHEN length(cast(json_extract(value, '$.unique_key') AS text)) = 0 THEN NULL
174
+ ELSE unhex(cast(json_extract(value, '$.unique_key') AS text))
175
+ END,
176
+ nullif(cast(json_extract(value, '$.unique_states') AS integer), 0)
177
+ FROM json_each(cast(? AS blob))
178
+ WHERE true
179
+ ON CONFLICT (unique_key) WHERE #{SQLITE_CONFLICT_WHERE}
180
+ DO UPDATE SET kind = EXCLUDED.kind
181
+ RETURNING #{SQLITE_JOB_COLUMNS}
182
+ SQL
183
+
184
+ rows = ::ActiveRecord::Base.connection.raw_connection.execute(sql, [JSON.dump(jobs)])
185
+ sqlite_notify_insert(insert_params_many)
186
+
187
+ rows.map do |row|
188
+ metadata = JSON.parse(row["metadata"])
189
+ [sqlite_to_job_row_from_raw(row), metadata[SQLITE_UNIQUE_NONCE_KEY] != nonce]
190
+ end
191
+ end
70
192
  end
71
193
 
72
- private def insert_params_to_hash(insert_params)
194
+ private def postgres_insert_params_to_hash(insert_params)
73
195
  {
74
196
  args: JSON.parse(insert_params.encoded_args),
75
197
  kind: insert_params.kind,
@@ -84,7 +206,27 @@ module River::Driver
84
206
  }
85
207
  end
86
208
 
209
+ private def sqlite_insert_params_to_hash(insert_params, nonce)
210
+ {
211
+ args: JSON.parse(insert_params.encoded_args),
212
+ kind: insert_params.kind,
213
+ max_attempts: insert_params.max_attempts,
214
+ metadata: {SQLITE_UNIQUE_NONCE_KEY => nonce},
215
+ priority: insert_params.priority,
216
+ queue: insert_params.queue,
217
+ scheduled_at: insert_params.scheduled_at ? format_time(insert_params.scheduled_at) : nil,
218
+ state: insert_params.state,
219
+ tags: insert_params.tags || [],
220
+ unique_key: insert_params.unique_key&.unpack1("H*"),
221
+ unique_states: insert_params.unique_states&.to_i(2)
222
+ }
223
+ end
224
+
87
225
  private def to_job_row_from_model(river_job)
226
+ @is_sqlite ? sqlite_to_job_row_from_model(river_job) : postgres_to_job_row_from_model(river_job)
227
+ end
228
+
229
+ private def postgres_to_job_row_from_model(river_job)
88
230
  # needs to be accessed through values because `errors` is shadowed by both
89
231
  # ActiveRecord and the patch above
90
232
  errors = river_job.attributes["errors"]
@@ -120,9 +262,46 @@ module River::Driver
120
262
  )
121
263
  end
122
264
 
123
- private def to_insert_results(res)
265
+ private def sqlite_to_job_row_from_model(river_job)
266
+ row = sqlite_job_rows("WHERE id = ? LIMIT 1", [river_job.id]).first
267
+ sqlite_to_job_row_from_raw(row)
268
+ end
269
+
270
+ private def sqlite_to_job_row_from_raw(row)
271
+ errors = row["errors"] ? JSON.parse(row["errors"]) : []
272
+
273
+ River::JobRow.new(
274
+ id: row["id"],
275
+ args: JSON.parse(row["args"]),
276
+ attempt: row["attempt"],
277
+ attempted_at: parse_sqlite_time(row["attempted_at"]),
278
+ attempted_by: row["attempted_by"] ? JSON.parse(row["attempted_by"]) : nil,
279
+ created_at: parse_sqlite_time(row["created_at"]),
280
+ errors: errors.map { |e|
281
+ River::AttemptError.new(
282
+ at: Time.parse(e["at"]),
283
+ attempt: e["attempt"],
284
+ error: e["error"],
285
+ trace: e["trace"]
286
+ )
287
+ },
288
+ finalized_at: parse_sqlite_time(row["finalized_at"]),
289
+ kind: row["kind"],
290
+ max_attempts: row["max_attempts"],
291
+ metadata: JSON.parse(row["metadata"]),
292
+ priority: row["priority"],
293
+ queue: row["queue"],
294
+ scheduled_at: parse_sqlite_time(row["scheduled_at"]),
295
+ state: row["state"],
296
+ tags: JSON.parse(row["tags"]),
297
+ unique_key: row["unique_key"]&.to_s,
298
+ unique_states: row["unique_states"] ? ::River::UniqueBitmask.to_states(row["unique_states"]) : nil
299
+ )
300
+ end
301
+
302
+ private def postgres_to_insert_results(res)
124
303
  res.rows.map do |row|
125
- to_job_row_from_raw(row, res.columns, res.column_types)
304
+ postgres_to_job_row_from_raw(row, res.columns, res.column_types)
126
305
  end
127
306
  end
128
307
 
@@ -132,7 +311,7 @@ module River::Driver
132
311
  # searched long and hard for a way to have the former type of method return
133
312
  # raw or the latter type of method return a model, but was unable to find
134
313
  # anything.
135
- private def to_job_row_from_raw(row, columns, column_types)
314
+ private def postgres_to_job_row_from_raw(row, columns, column_types)
136
315
  river_job = {}
137
316
 
138
317
  row.each_with_index do |val, i|
@@ -174,5 +353,42 @@ module River::Driver
174
353
  river_job["unique_skipped_as_duplicate"]
175
354
  ]
176
355
  end
356
+
357
+ private def format_time(time)
358
+ time.getutc.round(3).strftime("%Y-%m-%d %H:%M:%S.%3N")
359
+ end
360
+
361
+ private def parse_sqlite_time(value)
362
+ return nil unless value
363
+
364
+ value = value.to_s
365
+ value += " UTC" unless value.match?(/(?:Z|[+-]\d{2}:?\d{2})\z/)
366
+ Time.parse(value).utc
367
+ end
368
+
369
+ private def sqlite_job_rows(suffix, binds = [])
370
+ sql = "SELECT #{SQLITE_JOB_COLUMNS} FROM river_job #{suffix}"
371
+ ::ActiveRecord::Base.connection.raw_connection.execute(sql, binds)
372
+ end
373
+
374
+ private def sqlite_notify_insert(insert_params_many)
375
+ queues = insert_params_many
376
+ .select { |param| param.state == ::River::JOB_STATE_AVAILABLE }
377
+ .map(&:queue)
378
+ .uniq
379
+ return if queues.empty?
380
+
381
+ notifications = queues.map do |queue|
382
+ {payload: JSON.dump({queue: queue}), topic: "insert"}
383
+ end
384
+
385
+ ::ActiveRecord::Base.connection.raw_connection.execute(<<~SQL, [JSON.dump(notifications)])
386
+ INSERT INTO river_notification (payload, topic)
387
+ SELECT
388
+ json_extract(value, '$.payload'),
389
+ json_extract(value, '$.topic')
390
+ FROM json_each(cast(? AS blob))
391
+ SQL
392
+ end
177
393
  end
178
394
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: riverqueue-activerecord
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.1
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Blake Gentry
@@ -50,28 +50,8 @@ dependencies:
50
50
  - - "<"
51
51
  - !ruby/object:Gem::Version
52
52
  version: '1000'
53
- - !ruby/object:Gem::Dependency
54
- name: pg
55
- requirement: !ruby/object:Gem::Requirement
56
- requirements:
57
- - - ">"
58
- - !ruby/object:Gem::Version
59
- version: '0'
60
- - - "<"
61
- - !ruby/object:Gem::Version
62
- version: '1000'
63
- type: :runtime
64
- prerelease: false
65
- version_requirements: !ruby/object:Gem::Requirement
66
- requirements:
67
- - - ">"
68
- - !ruby/object:Gem::Version
69
- version: '0'
70
- - - "<"
71
- - !ruby/object:Gem::Version
72
- version: '1000'
73
- description: ActiveRecord driver for the River Ruby gem. Use in conjunction with the
74
- riverqueue gem to insert jobs that are worked in Go.
53
+ description: ActiveRecord PostgreSQL and SQLite driver for the River Ruby gem. Use
54
+ in conjunction with the riverqueue gem to insert jobs that are worked in Go.
75
55
  email: brandur@brandur.org
76
56
  executables: []
77
57
  extensions: []
@@ -99,5 +79,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
99
79
  requirements: []
100
80
  rubygems_version: 4.0.9
101
81
  specification_version: 4
102
- summary: ActiveRecord driver for the River Ruby gem.
82
+ summary: ActiveRecord PostgreSQL and SQLite driver for the River Ruby gem.
103
83
  test_files: []