riverqueue 0.11.0 → 0.12.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +132 -0
  3. data/LICENSE +374 -0
  4. data/docs/README.md +765 -0
  5. data/docs/migrations.md +121 -0
  6. data/docs/testing.md +173 -0
  7. data/docs/workers.md +126 -0
  8. data/exe/river +7 -0
  9. data/lib/cli.rb +55 -0
  10. data/lib/client.rb +314 -54
  11. data/lib/client_runtime.rb +612 -0
  12. data/lib/config.rb +154 -0
  13. data/lib/driver/job_row_decoder.rb +85 -0
  14. data/lib/driver/postgres_capabilities.rb +41 -0
  15. data/lib/driver/runtime.rb +661 -0
  16. data/lib/driver.rb +13 -6
  17. data/lib/errors.rb +83 -0
  18. data/lib/event.rb +78 -0
  19. data/lib/insert_opts.rb +40 -11
  20. data/lib/job.rb +28 -13
  21. data/lib/job_persisted_logging/plugin.rb +108 -0
  22. data/lib/migration_cli.rb +90 -0
  23. data/lib/migrator.rb +189 -0
  24. data/lib/params.rb +96 -0
  25. data/lib/periodic_cron.rb +24 -0
  26. data/lib/periodic_job.rb +135 -0
  27. data/lib/resumable.rb +148 -0
  28. data/lib/riverqueue/testing/minitest.rb +18 -0
  29. data/lib/riverqueue/testing/rspec.rb +109 -0
  30. data/lib/riverqueue/testing.rb +134 -0
  31. data/lib/riverqueue.rb +16 -0
  32. data/lib/unique_args.rb +97 -0
  33. data/lib/unique_bitmask.rb +14 -12
  34. data/lib/worker.rb +152 -0
  35. data/lib/worker_runner.rb +106 -0
  36. data/migration/LICENSE +374 -0
  37. data/migration/README.md +11 -0
  38. data/migration/manifest.json +38 -0
  39. data/migration/postgresql/main/001_create_river_migration.down.sql +1 -0
  40. data/migration/postgresql/main/001_create_river_migration.up.sql +8 -0
  41. data/migration/postgresql/main/002_initial_schema.down.sql +5 -0
  42. data/migration/postgresql/main/002_initial_schema.up.sql +96 -0
  43. data/migration/postgresql/main/003_river_job_tags_non_null.down.sql +3 -0
  44. data/migration/postgresql/main/003_river_job_tags_non_null.up.sql +3 -0
  45. data/migration/postgresql/main/004_pending_and_more.down.sql +42 -0
  46. data/migration/postgresql/main/004_pending_and_more.up.sql +45 -0
  47. data/migration/postgresql/main/005_migration_unique_client.down.sql +57 -0
  48. data/migration/postgresql/main/005_migration_unique_client.up.sql +79 -0
  49. data/migration/postgresql/main/006_bulk_unique.down.sql +16 -0
  50. data/migration/postgresql/main/006_bulk_unique.up.sql +40 -0
  51. data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +56 -0
  52. data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +44 -0
  53. data/migration/postgresql/main/008_job_id_autoincrement.down.sql +3 -0
  54. data/migration/postgresql/main/008_job_id_autoincrement.up.sql +3 -0
  55. data/migration/sqlite/main/001_create_river_migration.down.sql +1 -0
  56. data/migration/sqlite/main/001_create_river_migration.up.sql +8 -0
  57. data/migration/sqlite/main/002_initial_schema.down.sql +8 -0
  58. data/migration/sqlite/main/002_initial_schema.up.sql +19 -0
  59. data/migration/sqlite/main/003_river_job_tags_non_null.down.sql +6 -0
  60. data/migration/sqlite/main/003_river_job_tags_non_null.up.sql +6 -0
  61. data/migration/sqlite/main/004_pending_and_more.down.sql +26 -0
  62. data/migration/sqlite/main/004_pending_and_more.up.sql +33 -0
  63. data/migration/sqlite/main/005_migration_unique_client.down.sql +37 -0
  64. data/migration/sqlite/main/005_migration_unique_client.up.sql +64 -0
  65. data/migration/sqlite/main/006_bulk_unique.down.sql +7 -0
  66. data/migration/sqlite/main/006_bulk_unique.up.sql +63 -0
  67. data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +255 -0
  68. data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +261 -0
  69. data/migration/sqlite/main/008_job_id_autoincrement.down.sql +121 -0
  70. data/migration/sqlite/main/008_job_id_autoincrement.up.sql +123 -0
  71. data/sig/client.rbs +95 -0
  72. data/sig/driver.rbs +170 -0
  73. data/sig/insert_opts.rbs +25 -0
  74. data/sig/job.rbs +72 -0
  75. data/sig/job_persisted_logging/plugin.rbs +29 -0
  76. data/sig/migrator.rbs +44 -0
  77. data/sig/riverqueue.rbs +2 -0
  78. data/sig/runtime.rbs +382 -0
  79. data/sig/testing.rbs +95 -0
  80. data/sig/unique_args.rbs +8 -0
  81. data/sig/unique_bitmask.rbs +8 -0
  82. data/sig/worker_runner.rbs +22 -0
  83. metadata +165 -10
data/lib/client.rb CHANGED
@@ -1,4 +1,6 @@
1
- require "digest"
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha2"
2
4
  require "time"
3
5
 
4
6
  module River
@@ -11,10 +13,7 @@ module River
11
13
  # Default queue for a job.
12
14
  QUEUE_DEFAULT = "default"
13
15
 
14
- # Provides a client for River that inserts jobs. Unlike the Go version of the
15
- # River client, this one can insert jobs only. Jobs can only be worked from Go
16
- # code, so job arg kinds and JSON encoding details must be shared between Ruby
17
- # and Go code.
16
+ # Provides a River client for inserting and working jobs.
18
17
  #
19
18
  # Used in conjunction with a River driver like:
20
19
  #
@@ -24,11 +23,38 @@ module River
24
23
  # River drivers are found in separate gems like `riverqueue-sequel` to help
25
24
  # minimize transient dependencies.
26
25
  class Client
27
- def initialize(driver)
26
+ # Configuration used by this client.
27
+ attr_reader :config
28
+
29
+ # Database driver used by this client.
30
+ attr_reader :driver
31
+
32
+ # Creates a client backed by the given database driver. Pass a Config to
33
+ # customize workers, queues, plugins, and runtime behavior.
34
+ def initialize(driver, config: nil)
35
+ @config = config || Config.new
28
36
  @driver = driver
37
+ @runtime = ClientRuntime.new(self, driver, @config)
29
38
  @time_now_utc = -> { Time.now.utc } # for test time stubbing
30
39
  end
31
40
 
41
+ # Internal extension point used by separately packaged batch workers after
42
+ # they atomically claim additional jobs alongside the batch leader.
43
+ def __finish_claimed_job(row, error = nil)
44
+ @runtime.finish_claimed(row, error)
45
+ end
46
+
47
+ def __perform_job(id, allow_scheduled: false)
48
+ @runtime.perform_job(id, allow_scheduled: allow_scheduled)
49
+ end
50
+
51
+ def __interrupt_workers = @runtime.interrupt_workers
52
+
53
+ def __runtime_healthy? = @runtime.healthy?
54
+
55
+ # Returns the client ID recorded on jobs while this client works them.
56
+ def id = config.id
57
+
32
58
  # Inserts a new job for work given a job args implementation and insertion
33
59
  # options (which may be omitted).
34
60
  #
@@ -37,14 +63,14 @@ module River
37
63
  # insert_res = client.insert(SimpleArgs.new(job_num: 1))
38
64
  # insert_res.job # inserted job row
39
65
  #
40
- # With insert opts:
66
+ # With keyword options:
41
67
  #
42
- # insert_res = client.insert(SimpleArgs.new(job_num: 1), insert_opts: InsertOpts.new(queue: "high_priority"))
68
+ # insert_res = client.insert(SimpleArgs.new(job_num: 1), queue: :high_priority)
43
69
  # insert_res.job # inserted job row
44
70
  #
45
71
  # Job arg implementations are expected to respond to:
46
72
  #
47
- # * `#kind`: A string that uniquely identifies the job in the database.
73
+ # * `#kind`: A symbol or string identifying the job kind in the database.
48
74
  # * `#to_json`: Encodes the args to JSON for persistence in the database.
49
75
  # Must match encoding an args struct on the Go side to be workable.
50
76
  #
@@ -64,15 +90,19 @@ module River
64
90
  #
65
91
  # def kind = "simple"
66
92
  #
67
- # def to_json = JSON.dump({job_num: job_num})
93
+ # def to_json = JSON.generate({job_num: job_num})
68
94
  # end
69
95
  #
70
96
  # See also JobArgsHash for an easy way to insert a job from a hash.
71
97
  #
72
- # Returns an instance of InsertResult.
73
- def insert(args, insert_opts: EMPTY_INSERT_OPTS)
98
+ # Returns an instance of JobInsertResult. A reusable InsertOpts may instead
99
+ # be supplied as +insert_opts:+; do not combine it with keyword options.
100
+ # @type method insert: (jobArgs, ?insert_opts: InsertOpts?, **untyped) -> JobInsertResult
101
+ def insert(args, insert_opts: nil, **options)
102
+ raise ArgumentError, "use insert_opts or keyword options, not both" if insert_opts && !options.empty?
103
+ insert_opts ||= options.empty? ? EMPTY_INSERT_OPTS : InsertOpts.new(**options)
74
104
  insert_params = make_insert_params(args, insert_opts)
75
- insert_and_check_unique_job(insert_params)
105
+ run_insert_plugins([insert_params]) { [insert_and_check_unique_job(insert_params)] }.first
76
106
  end
77
107
 
78
108
  # Inserts many new jobs as part of a single batch operation for improved
@@ -83,21 +113,21 @@ module River
83
113
  #
84
114
  # With job args:
85
115
  #
86
- # num_inserted = client.insert_many([
116
+ # insert_results = client.insert_many([
87
117
  # SimpleArgs.new(job_num: 1),
88
118
  # SimpleArgs.new(job_num: 2)
89
119
  # ])
90
120
  #
91
121
  # With InsertManyParams:
92
122
  #
93
- # num_inserted = client.insert_many([
94
- # River::InsertManyParams.new(SimpleArgs.new(job_num: 1), insert_opts: InsertOpts.new(max_attempts: 5)),
95
- # River::InsertManyParams.new(SimpleArgs.new(job_num: 2), insert_opts: InsertOpts.new(queue: "high_priority"))
123
+ # insert_results = client.insert_many([
124
+ # River::InsertManyParams.new(SimpleArgs.new(job_num: 1), max_attempts: 5),
125
+ # River::InsertManyParams.new(SimpleArgs.new(job_num: 2), queue: :high_priority)
96
126
  # ])
97
127
  #
98
128
  # Job arg implementations are expected to respond to:
99
129
  #
100
- # * `#kind`: A string that uniquely identifies the job in the database.
130
+ # * `#kind`: A symbol or string identifying the job kind in the database.
101
131
  # * `#to_json`: Encodes the args to JSON for persistence in the database.
102
132
  # Must match encoding an args struct on the Go side to be workable.
103
133
  #
@@ -112,13 +142,18 @@ module River
112
142
  #
113
143
  # def kind = "simple"
114
144
  #
115
- # def to_json = JSON.dump({job_num: job_num})
145
+ # def to_json = JSON.generate({job_num: job_num})
116
146
  # end
117
147
  #
118
148
  # See also JobArgsHash for an easy way to insert a job from a hash.
119
149
  #
120
- # Returns the number of jobs inserted.
150
+ # Returns one JobInsertResult for each argument, in input order.
151
+ # Raises ArgumentError if the batch is empty.
152
+ # A batch cannot contain repeated unique keys in their enforced states;
153
+ # the whole batch fails instead of partially inserting it.
121
154
  def insert_many(args)
155
+ raise ArgumentError, "no jobs to insert" if args.empty?
156
+
122
157
  all_params = args.map do |arg|
123
158
  if arg.is_a?(InsertManyParams)
124
159
  make_insert_params(arg.args, arg.insert_opts || EMPTY_INSERT_OPTS)
@@ -127,10 +162,179 @@ module River
127
162
  end
128
163
  end
129
164
 
130
- @driver.job_insert_many(all_params)
131
- .map do |job, unique_skipped_as_duplicate|
132
- InsertResult.new(job, unique_skipped_as_duplicated: unique_skipped_as_duplicate)
165
+ run_insert_plugins(all_params) do
166
+ @driver.job_insert_many(all_params)
167
+ .map do |job, unique_skipped_as_duplicate|
168
+ JobInsertResult.new(job, unique_skipped_as_duplicated: unique_skipped_as_duplicate)
169
+ end
170
+ end
171
+ end
172
+
173
+ # Cancels a job by ID and returns its updated JobRow.
174
+ #
175
+ # Raises NotFoundError if the job does not exist.
176
+ def job_cancel(id)
177
+ @driver.job_cancel(id) || raise(NotFoundError, "job not found: #{id}")
178
+ end
179
+
180
+ # Deletes a job by ID and returns its former JobRow.
181
+ #
182
+ # Raises NotFoundError if the job does not exist and JobRunningError if it
183
+ # is currently running.
184
+ def job_delete(id)
185
+ job = @driver.job_delete(id) || raise(NotFoundError, "job not found: #{id}")
186
+ raise JobRunningError, "running jobs cannot be deleted" if job.state == JOB_STATE_RUNNING
187
+
188
+ job
189
+ end
190
+
191
+ # Deletes jobs matching the supplied filters and returns a
192
+ # JobDeleteManyResult. At least one filter is required. Accepts keyword
193
+ # filters or a reusable JobListParams, but not both.
194
+ # @type method job_delete_many: (?JobListParams?, **untyped) -> untyped
195
+ def job_delete_many(params = nil, **filters)
196
+ raise ArgumentError, "use params or keyword filters, not both" if params && !filters.empty?
197
+ params ||= JobListParams.new(**filters)
198
+ raise ArgumentError, "delete with no filters is not allowed" unless params.filters?
199
+
200
+ JobDeleteManyResult.new(@driver.job_delete_many(params))
201
+ end
202
+
203
+ # Fetches a job by ID.
204
+ #
205
+ # Raises NotFoundError if the job does not exist.
206
+ def job_get(id)
207
+ @driver.job_get_by_id(id) || raise(NotFoundError, "job not found: #{id}")
208
+ end
209
+
210
+ # Lists jobs matching the supplied filters and ordering.
211
+ #
212
+ # The returned JobListResult includes a cursor suitable for the next page.
213
+ # Accepts keyword filters or a reusable JobListParams, but not both.
214
+ # @type method job_list: (?JobListParams?, **untyped) -> untyped
215
+ def job_list(params = nil, **filters)
216
+ raise ArgumentError, "use params or keyword filters, not both" if params && !filters.empty?
217
+ params ||= JobListParams.new(**filters)
218
+ jobs = @driver.job_list(params)
219
+ last = jobs.last
220
+ cursor = last && JobListCursor.new(id: last.id, sort_by: params.sort_by, sort_order: params.sort_order, value: last.public_send(params.sort_by))
221
+ JobListResult.new(jobs, cursor)
222
+ end
223
+
224
+ # Makes a non-running job immediately available for another attempt and
225
+ # returns its updated JobRow. Raises NotFoundError if it does not exist.
226
+ def job_retry(id)
227
+ @driver.job_retry(id) || raise(NotFoundError, "job not found: #{id}")
228
+ end
229
+
230
+ # Applies keyword attributes or a JobUpdateParams to a job and returns its
231
+ # updated JobRow. Explicit nil clears nullable fields; omitted fields are
232
+ # unchanged. Raises NotFoundError if the job does not exist.
233
+ # @type method job_update: (Integer, ?JobUpdateParams?, **untyped) -> JobRow
234
+ def job_update(id, params = nil, **attributes)
235
+ raise ArgumentError, "use params or keyword attributes, not both" if params && !attributes.empty?
236
+ params ||= JobUpdateParams.new(**attributes)
237
+ @driver.job_update(id, params) || raise(NotFoundError, "job not found: #{id}")
238
+ end
239
+
240
+ # Returns the live PeriodicJobBundle used to add and remove periodic jobs.
241
+ def periodic_jobs
242
+ raise ArgumentError, "cannot modify periodic jobs when leader_election_disabled is true" if @config.leader_election_disabled
243
+
244
+ @runtime.periodic_jobs
245
+ end
246
+
247
+ # Adds a queue to the client and returns self. If the client is running, it
248
+ # begins working the queue immediately.
249
+ def queue_add(name, queue_config)
250
+ @runtime.queue_add(name.to_s, queue_config)
251
+ self
252
+ end
253
+
254
+ # Fetches a queue by name.
255
+ #
256
+ # Raises NotFoundError if the queue does not exist.
257
+ def queue_get(name)
258
+ @driver.queue_get(name.to_s) || raise(NotFoundError, "queue not found: #{name}")
259
+ end
260
+
261
+ # Lists up to +max+ known queues.
262
+ def queue_list(max: 100)
263
+ QueueListResult.new(@driver.queue_list(max: max))
264
+ end
265
+
266
+ # Pauses a named queue, or all queues when +name+ is +"*"+.
267
+ def queue_pause(name)
268
+ name = name.to_s
269
+ @driver.queue_pause(name)
270
+ @runtime.wake
271
+ queues = (name == "*") ? @driver.queue_list : [@driver.queue_get(name)].compact
272
+ queues.each do |queue|
273
+ @runtime.publish_queue(EVENT_QUEUE_PAUSED, queue)
133
274
  end
275
+
276
+ true
277
+ end
278
+
279
+ # Removes a configured queue, waits for active jobs in it to finish, and
280
+ # returns self.
281
+ def queue_remove(name)
282
+ @runtime.queue_remove(name.to_s)
283
+ self
284
+ end
285
+
286
+ # Resumes a named queue, or all queues when +name+ is +"*"+.
287
+ def queue_resume(name)
288
+ name = name.to_s
289
+ @driver.queue_resume(name)
290
+ @runtime.wake
291
+ queues = (name == "*") ? @driver.queue_list : [@driver.queue_get(name)].compact
292
+ queues.each do |queue|
293
+ @runtime.publish_queue(EVENT_QUEUE_RESUMED, queue)
294
+ end
295
+
296
+ true
297
+ end
298
+
299
+ # Replaces a queue's metadata and returns the updated Queue.
300
+ def queue_update(name, metadata:)
301
+ @driver.queue_update(name.to_s, metadata: metadata) || raise(NotFoundError, "queue not found: #{name}")
302
+ end
303
+
304
+ # Starts polling configured queues and working jobs in background threads.
305
+ # Returns self.
306
+ def start
307
+ @runtime.start
308
+ self
309
+ end
310
+
311
+ # Returns true while the client runtime is started.
312
+ def started? = @runtime.started?
313
+
314
+ # Stops fetching and producing periodic jobs, waits for active jobs to
315
+ # finish, and returns self. Pass wait: false to request stop without
316
+ # waiting; call stop again to finish draining and release resources.
317
+ # In-flight fetches or maintenance operations may finish. Until a waiting
318
+ # stop completes, started? remains true and stopped? remains false.
319
+ def stop(wait: true)
320
+ @runtime.stop(wait: wait)
321
+ self
322
+ end
323
+
324
+ # Stops fetching new jobs, interrupts active work, and returns self.
325
+ def stop_and_cancel
326
+ @runtime.stop(cancel: true)
327
+ self
328
+ end
329
+
330
+ # Returns true when the client runtime is fully stopped.
331
+ def stopped? = @runtime.stopped?
332
+
333
+ # Subscribes to event kinds and returns a Subscription.
334
+ #
335
+ # Call Subscription#close when the subscription is no longer needed.
336
+ def subscribe(*kinds, buffer_size: 100)
337
+ @runtime.subscribe(kinds, buffer_size: buffer_size)
134
338
  end
135
339
 
136
340
  # Default states that are used during a unique insert. Can be overridden by
@@ -143,7 +347,8 @@ module River
143
347
  JOB_STATE_RUNNING,
144
348
  JOB_STATE_SCHEDULED
145
349
  ].freeze
146
- private_constant :DEFAULT_UNIQUE_STATES
350
+
351
+ EMPTY_INSERT_OPTS = InsertOpts.new.freeze
147
352
 
148
353
  REQUIRED_UNIQUE_STATES = [
149
354
  JOB_STATE_AVAILABLE,
@@ -151,22 +356,22 @@ module River
151
356
  JOB_STATE_RUNNING,
152
357
  JOB_STATE_SCHEDULED
153
358
  ].freeze
154
- private_constant :REQUIRED_UNIQUE_STATES
155
359
 
156
- EMPTY_INSERT_OPTS = InsertOpts.new.freeze
157
- private_constant :EMPTY_INSERT_OPTS
360
+ TAG_RE = /\A\w[\w-]+\w\z/
361
+
362
+ private_constant :DEFAULT_UNIQUE_STATES, :EMPTY_INSERT_OPTS, :REQUIRED_UNIQUE_STATES, :TAG_RE
158
363
 
159
364
  private def insert_and_check_unique_job(insert_params)
160
365
  job, unique_skipped_as_duplicate = @driver.job_insert(insert_params)
161
- InsertResult.new(job, unique_skipped_as_duplicated: unique_skipped_as_duplicate)
366
+ JobInsertResult.new(job, unique_skipped_as_duplicated: unique_skipped_as_duplicate)
162
367
  end
163
368
 
164
369
  private def make_insert_params(args, insert_opts)
165
- raise "args should respond to `#kind`" if !args.respond_to?(:kind)
370
+ raise ArgumentError, "args should respond to `#kind`" if !args.respond_to?(:kind)
166
371
 
167
372
  # ~all objects in Ruby respond to `#to_json`, so check non-nil instead.
168
373
  args_json = args.to_json
169
- raise "args should return non-nil from `#to_json`" if !args_json
374
+ raise ArgumentError, "args should return non-nil from `#to_json`" if !args_json
170
375
 
171
376
  args_insert_opts = if args.respond_to?(:insert_opts)
172
377
  args_with_insert_opts = args #: _JobArgsWithInsertOpts # rubocop:disable Layout/LeadingCommentSpace
@@ -176,24 +381,29 @@ module River
176
381
  end
177
382
 
178
383
  scheduled_at = insert_opts.scheduled_at || args_insert_opts.scheduled_at
384
+ now = @time_now_utc.call
385
+ state = (insert_opts.state || args_insert_opts.state || ((scheduled_at && scheduled_at > now) ? JOB_STATE_SCHEDULED : JOB_STATE_AVAILABLE)).to_s #: jobStateAll # rubocop:disable Layout/LeadingCommentSpace
179
386
 
180
387
  insert_params = Driver::JobInsertParams.new(
388
+ args: args,
181
389
  encoded_args: args_json,
182
- kind: args.kind,
390
+ kind: args.kind.to_s,
183
391
  max_attempts: insert_opts.max_attempts || args_insert_opts.max_attempts || MAX_ATTEMPTS_DEFAULT,
392
+ metadata: (args_insert_opts.metadata || {}).merge(insert_opts.metadata || {}),
184
393
  priority: insert_opts.priority || args_insert_opts.priority || PRIORITY_DEFAULT,
185
- queue: insert_opts.queue || args_insert_opts.queue || QUEUE_DEFAULT,
186
- scheduled_at: scheduled_at&.utc || Time.now,
187
- state: scheduled_at ? JOB_STATE_SCHEDULED : JOB_STATE_AVAILABLE,
394
+ queue: (insert_opts.queue || args_insert_opts.queue || QUEUE_DEFAULT).to_s,
395
+ scheduled_at: scheduled_at&.getutc || now,
396
+ state: state,
188
397
  tags: validate_tags(insert_opts.tags || args_insert_opts.tags || [])
189
398
  )
190
399
 
191
400
  unique_opts = insert_opts.unique_opts || args_insert_opts.unique_opts
192
- if unique_opts
401
+ if unique_opts && !unique_opts.empty?
193
402
  unique_key, unique_states = make_unique_key_and_bitmask(insert_params, unique_opts)
194
403
  insert_params.unique_key = unique_key
195
404
  insert_params.unique_states = unique_states
196
405
  end
406
+
197
407
  insert_params
198
408
  end
199
409
 
@@ -208,18 +418,11 @@ module River
208
418
  end
209
419
 
210
420
  if unique_opts.by_args
211
- parsed_args = JSON.parse(insert_params.encoded_args)
212
- filtered_args = if unique_opts.by_args.is_a?(Array)
213
- parsed_args.slice(*unique_opts.by_args)
214
- else
215
- parsed_args
216
- end
217
-
218
- encoded_args = JSON.generate(filtered_args.sort.to_h)
421
+ encoded_args = UniqueArgs.encode(insert_params.encoded_args, unique_opts.by_args)
219
422
  unique_key += "&args=#{encoded_args}"
220
423
  end
221
424
 
222
- if unique_opts.by_period
425
+ if unique_opts.by_period && unique_opts.by_period != 0
223
426
  lower_period_bound = truncate_time(insert_params.scheduled_at || @time_now_utc.call, unique_opts.by_period).utc
224
427
 
225
428
  unique_key += "&period=#{lower_period_bound.strftime("%FT%TZ")}"
@@ -229,18 +432,62 @@ module River
229
432
  unique_key += "&queue=#{insert_params.queue}"
230
433
  end
231
434
 
435
+ # Without kind or another dimension, every job would share the same key.
436
+ raise ArgumentError, "exclude_kind requires by_args, by_queue, or by_period" if unique_key.empty?
437
+
232
438
  unique_key_hash = Digest::SHA256.digest(unique_key)
233
- unique_states = validate_unique_states(unique_opts.by_state || DEFAULT_UNIQUE_STATES)
439
+ states = unique_opts.by_state
440
+ unique_states = validate_unique_states((states && !states.empty?) ? states : DEFAULT_UNIQUE_STATES)
234
441
 
235
442
  [unique_key_hash, UniqueBitmask.from_states(unique_states)]
236
443
  end
237
444
 
445
+ private def run_insert_plugins(all_params, &insert_operation)
446
+ unless config.plugins.any? { |plugin| plugin.respond_to?(:insert_begin) || plugin.respond_to?(:insert_end) || plugin.respond_to?(:insert_many) }
447
+ results = insert_operation.call
448
+ @runtime.wake
449
+ return results
450
+ end
451
+
452
+ operation = -> do
453
+ all_params.each do |insert_params|
454
+ config.plugins.each do |plugin|
455
+ plugin.insert_begin(insert_params) if plugin.respond_to?(:insert_begin)
456
+ end
457
+ end
458
+
459
+ results = insert_operation.call
460
+ results.each do |result|
461
+ config.plugins.reverse_each do |plugin|
462
+ plugin.insert_end(result) if plugin.respond_to?(:insert_end)
463
+ end
464
+ end
465
+
466
+ results
467
+ end
468
+
469
+ config.plugins.reverse_each do |plugin|
470
+ next unless plugin.respond_to?(:insert_many)
471
+
472
+ next_operation = operation
473
+ operation = -> { plugin.insert_many(all_params, next_operation) }
474
+ end
475
+
476
+ results = @driver.transaction { operation.call }
477
+ @runtime.wake
478
+ results
479
+ end
480
+
238
481
  # Truncates the given time down to the interval. For example:
239
482
  #
240
483
  # Thu Jan 15 21:26:36 UTC 2024 @ 15 minutes ->
241
484
  # Thu Jan 15 21:15:00 UTC 2024
242
485
  private def truncate_time(time, interval_seconds)
243
- Time.at((time.to_f / interval_seconds).floor * interval_seconds)
486
+ # Go truncates from year 1, not the Unix epoch. Rational arithmetic also
487
+ # avoids floating-point rounding across a period boundary.
488
+ epoch_offset = 62_135_596_800
489
+ interval = interval_seconds.to_r
490
+ Time.at(((time.to_r + epoch_offset) / interval).floor * interval - epoch_offset)
244
491
  end
245
492
 
246
493
  # Moves an integer that may occupy the entire uint64 space to one that's
@@ -249,9 +496,6 @@ module River
249
496
  [int].pack("Q").unpack1("q") #: Integer # rubocop:disable Layout/LeadingCommentSpace
250
497
  end
251
498
 
252
- TAG_RE = /\A\w[\w-]+\w\z/
253
- private_constant :TAG_RE
254
-
255
499
  private def validate_tags(tags)
256
500
  tags.each do |tag|
257
501
  raise ArgumentError, "tags should be 255 characters or less" if tag.length > 255
@@ -260,9 +504,11 @@ module River
260
504
  end
261
505
 
262
506
  private def validate_unique_states(states)
507
+ states = states.map(&:to_s) #: Array[jobStateAll] # rubocop:disable Layout/LeadingCommentSpace
263
508
  REQUIRED_UNIQUE_STATES.each do |required_state|
264
509
  raise ArgumentError, "by_state should include required state #{required_state}" unless states.include?(required_state)
265
510
  end
511
+
266
512
  states
267
513
  end
268
514
  end
@@ -276,25 +522,39 @@ module River
276
522
  # Insertion options to use with the insert.
277
523
  attr_reader :insert_opts
278
524
 
279
- def initialize(args, insert_opts: nil)
525
+ # Pairs job arguments with per-job keyword options for Client#insert_many.
526
+ # A reusable InsertOpts may be supplied instead as +insert_opts:+.
527
+ # @type method initialize: (jobArgs, ?insert_opts: InsertOpts?, **untyped) -> void
528
+ def initialize(args, insert_opts: nil, **options)
529
+ raise ArgumentError, "use insert_opts or keyword options, not both" if insert_opts && !options.empty?
530
+
280
531
  @args = args
281
- @insert_opts = insert_opts
532
+ @insert_opts = insert_opts || (InsertOpts.new(**options) unless options.empty?)
282
533
  end
283
534
  end
284
535
 
285
536
  # Result of a single insertion.
286
- class InsertResult
537
+ class JobInsertResult
287
538
  # Inserted job row, or an existing job row if insert was skipped due to a
288
539
  # previously existing unique job.
289
540
  attr_reader :job
290
541
 
291
542
  # True if for a unique job, the insertion was skipped due to an equivalent
292
543
  # job matching unique property already being present.
293
- attr_reader :unique_skipped_as_duplicated
544
+ def unique_skipped_as_duplicate? = @unique_skipped_as_duplicate
294
545
 
546
+ # Compatibility with the spelling used in earlier releases.
547
+ alias_method :unique_skipped_as_duplicated, :unique_skipped_as_duplicate?
548
+
549
+ # Creates an insertion result. Applications normally receive instances from
550
+ # Client#insert or Client#insert_many. The keyword retains its original
551
+ # spelling for compatibility with earlier releases.
295
552
  def initialize(job, unique_skipped_as_duplicated:)
296
553
  @job = job
297
- @unique_skipped_as_duplicated = unique_skipped_as_duplicated
554
+ @unique_skipped_as_duplicate = unique_skipped_as_duplicated
298
555
  end
299
556
  end
557
+
558
+ # Compatibility with the insertion result class name used in earlier releases.
559
+ InsertResult = JobInsertResult
300
560
  end