riverqueue 0.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b197b9ab6ebd0ca0b94b8fdde5bf071b61060c83509080fd553cf1f96f69a558
4
- data.tar.gz: 1f9b3dcf3526a1c931c6fb6bf40f32da98084836c8841a6c5447840074401b8f
3
+ metadata.gz: c22dfdcd40a71eaf7e3f9f74351b795957b2a23c7a51d5e302c7dd6d046ce3e8
4
+ data.tar.gz: 495748ff5de7b982d2d18c8ed6670865e5452c5e8198a457428e53c2af5ecf76
5
5
  SHA512:
6
- metadata.gz: '0782b30ebb1ba9f06886ada1518968bf988736a285164242ec5f03e0dae282b0f8e4ceb0a5cee5e2c21eb92a2ef05765ef3ae2d11451f414398b99fc5907a095'
7
- data.tar.gz: 8d1ea3397d795c6b17dd9cb55dcfa3312044240891abe3ed37f4bfd86ec8cba44a4f9b6e750754cb8824fbdc2f6c5162fad613f78b71055ae13ab34651a293e9
6
+ metadata.gz: 1707b4db9e48540d168af6cdd89dc98e78db8101e4cfaa1a95ede93a1e4d1bd5ab28b13e94f6ab3c106f5673dc30f4092b83d299b98a257c23384ffeb2ba170d
7
+ data.tar.gz: cffa8f11fda5a7b003413ed3dab643ce44168987dc2afe2fb0b6d5dccafcfbfe0e6a88264262502018e53a38f6f3c2b1a460fef8ccdfb7ce0c9c37999bfe3514
data/CHANGELOG.md CHANGED
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.0] - 2026-10-08
11
+
12
+ ### Added
13
+
14
+ - Expanded checks against Go-generated conformance fixtures to cover cron schedules, snooze counters, all shared metadata keys, and queue-control and leadership notification emission through both SQL drivers. [PR #1463](https://github.com/riverqueue/river/pull/1463).
15
+ - Added `CRON_TZ=` / `TZ=` prefixes, `?` wildcards, and Go-style `@every` durations to `PeriodicCron`. [PR #1463](https://github.com/riverqueue/river/pull/1463).
16
+
17
+ ### Changed
18
+
19
+ - Ruby is now developed in the main [River repository](https://github.com/riverqueue/river/tree/master/ruby). Gem names and require paths are unchanged. The four public gems (`riverqueue`, `riverqueue-activerecord`, `riverqueue-sequel`, and `riverqueue-rails`) are released together. [PR #1463](https://github.com/riverqueue/river/pull/1463).
20
+ - **Breaking:** Job insertion accepts only `available`, `pending`, and `scheduled` states. `running`, `retryable`, and terminal states are rejected, including when set by insertion hooks. [PR #1463](https://github.com/riverqueue/river/pull/1463).
21
+ - **Breaking:** Insertion rejects queue names containing characters other than ASCII letters, digits, underscores, hyphens, colons, or periods. Nonzero `UniqueOpts#by_period` values must be at least one second. [PR #1463](https://github.com/riverqueue/river/pull/1463).
22
+ - **Breaking:** Attempt counts must be between 0 and 32,767, and `max_attempts` must be between 1 and 32,767. These bounds also apply on SQLite. `job_retry` raises `ArgumentError` when another attempt would exceed the limit. [PR #1463](https://github.com/riverqueue/river/pull/1463).
23
+ - **Breaking:** Job and queue metadata updates require a Hash; JSON-encoded strings are no longer accepted. Pass an empty Hash to clear metadata. Updates to `attempted_by` require string entries. [PR #1463](https://github.com/riverqueue/river/pull/1463).
24
+
25
+ ### Fixed
26
+
27
+ - Externally claimed jobs honor worker retry hooks, including Active Job's retry policy, and use their claim timestamps for queue-wait statistics and attempt errors. Worker initialization failures are logged without losing the reported work error. [PR #1463](https://github.com/riverqueue/river/pull/1463).
28
+ - Externally reported cancellation, snooze, and interruption signals follow normal worker finalization instead of being recorded as ordinary failures. [PR #1463](https://github.com/riverqueue/river/pull/1463).
29
+ - Graceful shutdown and queue removal continue observing remote cancellation until active jobs finish, including when worker timeouts are disabled or cancellation polling temporarily fails. Draining waits respect polling intervals and wake when the last attempt finishes. [PR #1463](https://github.com/riverqueue/river/pull/1463).
30
+ - Blocking lifecycle calls from the client's own runtime threads fail immediately instead of waiting for themselves. Workers can still request nonblocking stop. [PR #1463](https://github.com/riverqueue/river/pull/1463).
31
+ - Inserts reject states other than `available`, `pending`, and `scheduled`, preventing stranded `running` jobs without attempt timestamps. Insertion hooks are checked after all callbacks run, and invalid batches roll back hook writes. [PR #1463](https://github.com/riverqueue/river/pull/1463).
32
+ - The Sequel driver preserves application `ArgumentError` exceptions raised inside SQLite transactions after rolling back, including validation errors from insertion hooks. [PR #1463](https://github.com/riverqueue/river/pull/1463).
33
+ - Job inserts and updates reject attempt limits above 32,767 consistently across databases. Attempt updates and manual retries enforce the portable counter range, and claims tolerate counters already at the limit. [PR #1463](https://github.com/riverqueue/river/pull/1463).
34
+ - Wildcard queue pause and resume publish events for every affected queue, including beyond 100 queues. Events use the update's snapshots so concurrent changes cannot replace their contents or introduce unrelated queues. [PR #1463](https://github.com/riverqueue/river/pull/1463).
35
+ - Manually retrying a cancelled job clears the old cancellation request so its next attempt can complete. [PR #1463](https://github.com/riverqueue/river/pull/1463).
36
+ - Cancellation wins atomically over discarded attempts and other state transitions, including the last attempt or a worker that disables retries. [PR #1463](https://github.com/riverqueue/river/pull/1463).
37
+ - Stuck-job rescue falls back to the default retry policy per job when an application policy fails or returns an invalid time, allowing recovery and cleanup to continue. [PR #1463](https://github.com/riverqueue/river/pull/1463).
38
+ - Clients without consumer queues run configured periodic jobs and maintenance services. Adding periodic jobs starts maintenance when needed; registration is rejected when leader election is disabled. [PR #1463](https://github.com/riverqueue/river/pull/1463).
39
+ - Bulk deletion excludes running jobs before applying its limit and locks eligible PostgreSQL rows so concurrent retries cannot cause deletion of jobs that no longer match the filters. [PR #1463](https://github.com/riverqueue/river/pull/1463).
40
+ - Stuck-job rescue respects longer or disabled client and worker timeouts and scans past protected attempts without consuming the rescue limit. [PR #1463](https://github.com/riverqueue/river/pull/1463).
41
+ - Snoozing and rescue tolerate JSON counters that overflow SQLite's floating-point representation. [PR #1463](https://github.com/riverqueue/river/pull/1463).
42
+ - Snoozing tolerates nonnumeric metadata counters instead of failing on booleans or collections. Ruby-specific recovery cases are tested independently of the shared Go fixtures. [PR #1463](https://github.com/riverqueue/river/pull/1463).
43
+ - Periodic jobs now carry `periodic: true` and, when named, `river:periodic_job_id`, while preserving constructor options and application metadata. [PR #1463](https://github.com/riverqueue/river/pull/1463).
44
+ - Queue pause, resume, metadata changes, and leader resignation now broadcast notifications for clients in other languages. Notifications commit and roll back with the corresponding database change. [PR #1463](https://github.com/riverqueue/river/pull/1463).
45
+ - Cron schedules include both occurrences of repeated daylight-saving times when given a local reference time. [PR #1463](https://github.com/riverqueue/river/pull/1463).
46
+ - Job insertion rejects invalid queue names, nonpositive attempt limits, and nonzero uniqueness periods shorter than one second before writing any jobs. [PR #1463](https://github.com/riverqueue/river/pull/1463).
47
+ - Custom maintenance service failures are logged without blocking other services, stuck-job rescue, or cleanup. [PR #1463](https://github.com/riverqueue/river/pull/1463).
48
+ - Job updates reject non-object metadata before writing to the database, preserving the existing job when invalid metadata is supplied. [PR #1463](https://github.com/riverqueue/river/pull/1463).
49
+ - Job completion atomically checks for cancellation so a concurrent cancellation cannot be overwritten by completion, including when finalization hooks are configured. [PR #1463](https://github.com/riverqueue/river/pull/1463).
50
+ - Job updates reject nonpositive attempt limits consistently on PostgreSQL and SQLite. [PR #1463](https://github.com/riverqueue/river/pull/1463).
51
+ - Job lifecycle events report worker execution and completion durations separately using a monotonic clock. [PR #1463](https://github.com/riverqueue/river/pull/1463).
52
+ - Finalization hooks that delete jobs honor concurrent cancellation and record the cancelled attempt instead of deleting it. [PR #1463](https://github.com/riverqueue/river/pull/1463).
53
+ - Queue metadata writes and job metadata merges reject non-object values before changing persisted data. Job updates also reject non-string worker IDs in attempt histories. [PR #1463](https://github.com/riverqueue/river/pull/1463).
54
+ - Unique inserts with `exclude_kind` preserve the existing job's kind when a different kind conflicts, on both databases and through both drivers. [PR #1463](https://github.com/riverqueue/river/pull/1463).
55
+ - Retrying, snoozing, and interrupting jobs preserve the original queue-wait duration in lifecycle events. [PR #1463](https://github.com/riverqueue/river/pull/1463).
56
+ - Invalid rescue counters no longer prevent other stuck jobs in the batch from being rescued. [PR #1463](https://github.com/riverqueue/river/pull/1463).
57
+ - SQLite retains only the newest 100 worker IDs when claiming a job, matching PostgreSQL. [PR #1463](https://github.com/riverqueue/river/pull/1463).
58
+ - Attempt-error serialization supports frozen timestamps and preserves the caller's timezone. [PR #1463](https://github.com/riverqueue/river/pull/1463).
59
+
10
60
  ## [0.12.0] - 2026-10-01
11
61
 
12
62
  ### Added
data/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # River for Ruby
2
+
3
+ A PostgreSQL and SQLite job queue that shares River's schema with the Go, Rust,
4
+ and JavaScript clients. Includes Active Record and Sequel drivers, plus Rails
5
+ and Active Job integration.
6
+
7
+ - [Usage and configuration](docs/README.md)
8
+ - [Development and releases](docs/development.md)
9
+ - [Conformance coverage](docs/conformance.md)
10
+ - [Migrations](docs/migrations.md)
11
+
12
+ From the repository root:
13
+
14
+ ```sh
15
+ make -C ruby install
16
+ createdb river_test
17
+ RIVER_REQUIRE_DATABASES=1 make test/ruby
18
+ make lint/ruby typecheck/ruby
19
+ ```
20
+
21
+ Ruby 3.2 or later is required. `make test/ruby/conformance` runs only the
22
+ Go-generated fixture checks and needs no PostgreSQL server.
data/docs/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # River client for Ruby [![Build Status](https://github.com/riverqueue/riverqueue-ruby/workflows/CI/badge.svg)](https://github.com/riverqueue/riverqueue-ruby/actions) [![Gem Version](https://badge.fury.io/rb/riverqueue.svg)](https://badge.fury.io/rb/riverqueue)
1
+ # River client for Ruby [![Build Status](https://github.com/riverqueue/river/actions/workflows/ruby.yaml/badge.svg)](https://github.com/riverqueue/river/actions/workflows/ruby.yaml) [![Gem Version](https://badge.fury.io/rb/riverqueue.svg)](https://badge.fury.io/rb/riverqueue)
2
2
 
3
3
  A Ruby client for [River](https://github.com/riverqueue/river), packaged in the [`riverqueue` gem](https://rubygems.org/gems/riverqueue). It inserts and works jobs using River's canonical database schema and state machine, so Ruby and Go clients can safely share a River database. Separate queues are recommended when each language recognizes different job kinds.
4
4
 
@@ -133,6 +133,12 @@ end
133
133
 
134
134
  Insertion keywords control the queue, priority, maximum attempts, schedule,
135
135
  tags, metadata, and uniqueness of a job. Priority `1` is highest.
136
+ `max_attempts` must be between 1 and 32,767 on every database.
137
+ An explicit `state` must be `:available`, `:pending`, or `:scheduled` (strings
138
+ are also accepted). Other states raise `ArgumentError` before any jobs are
139
+ inserted, including when an invalid state appears in a batch. Without an
140
+ explicit state, jobs are available immediately or scheduled when `scheduled_at`
141
+ is in the future.
136
142
 
137
143
  ```ruby
138
144
  result = client.insert(args,
@@ -224,7 +230,7 @@ order affect the hash even when the decoded JSON is equivalent.
224
230
 
225
231
  ### [Reliable execution and stuck jobs](https://riverqueue.com/docs/reliable-workers)
226
232
 
227
- Claims and state transitions are atomic in the database. If a process disappears while working, the elected maintenance client rescues stale running jobs after an hour, retrying or discarding them according to their attempt count. `attempted_by`, attempt errors, and final state remain in the canonical River row for inspection by Ruby, Go, or River UI.
233
+ Claims and state transitions are atomic in the database. If a process disappears while working, the elected maintenance client checks for stale running jobs after an hour, retrying or discarding them according to their attempt count. Rescue respects longer client and worker timeouts; disabling a job's timeout also disables automatic timeout-based rescue for it. Cancellation requests still take precedence. Keep worker registrations and timeout configuration consistent across maintenance clients. `attempted_by`, attempt errors, and final state remain in the canonical River row for inspection by Ruby, Go, or River UI.
228
234
 
229
235
  ### [Job retries](https://riverqueue.com/docs/job-retries)
230
236
 
@@ -243,12 +249,18 @@ config = River::Config.new(
243
249
  ```
244
250
 
245
251
  Use `client.job_retry(job_id)` to make a non-running job available immediately.
252
+ This clears its previous cancellation request. Once a job has reached 32,767
253
+ attempts, requesting another retry raises `ArgumentError` without changing it.
246
254
 
247
255
  A worker may implement `retry?(job, error)` and return false to discard a
248
256
  reported error immediately, without reducing the attempt budget used for crash
249
257
  recovery. The Rails integration uses this to let Active Job own application retries.
258
+ Jobs claimed and finished by batch extensions honor the registered worker's
259
+ `retry?` and `next_retry` hooks too, including workers registered as classes.
250
260
  If a retry hook or policy raises, River logs the callback error and falls back to
251
261
  retrying with the default backoff. The original work error is still recorded.
262
+ Stuck-job rescue also falls back per job when a retry policy raises or returns
263
+ an invalid time, allowing other rescues and cleanup to continue.
252
264
 
253
265
  ### [Error handling and timeouts](https://riverqueue.com/docs/error-handling)
254
266
 
@@ -310,6 +322,9 @@ config = River::Config.new(queues: {
310
322
  ```
311
323
 
312
324
  Queues may also be added and removed at runtime with `client.queue_add(name, config)` and `client.queue_remove(name)`.
325
+ Queue removal stops fetching and keeps observing cancellation requests until its
326
+ active jobs finish. Removing a queue from one of its own workers raises
327
+ `ThreadError`, because that worker cannot wait for itself to finish.
313
328
 
314
329
  ### [Pausing queues](https://riverqueue.com/docs/pausing-queues)
315
330
 
@@ -324,6 +339,9 @@ client.queue_resume "*"
324
339
  ```
325
340
 
326
341
  Use `queue_get`, `queue_list`, and `queue_update` to inspect queues and attach metadata.
342
+ Pause and resume events contain a snapshot of each affected queue. Wildcard
343
+ operations publish events for every affected queue; size subscription buffers
344
+ to accommodate the number of events you expect to receive.
327
345
 
328
346
  ### Rails and Active Job
329
347
 
@@ -340,6 +358,11 @@ the job is due, not during registration. A reusable callable can be passed as
340
358
  `constructor:` instead; don't supply both. Core periodic schedules live in the
341
359
  client process; River Pro adds durable schedules.
342
360
 
361
+ A started client can produce periodic jobs with `queues: {}` while other clients
362
+ consume them. Jobs added through `client.periodic_jobs` also start maintenance
363
+ when needed. Periodic registration requires leader election to be enabled,
364
+ including jobs registered after startup.
365
+
343
366
  ```ruby
344
367
  cleanup = River::PeriodicJob.new(
345
368
  id: :cleanup,
@@ -360,6 +383,10 @@ registrations and returns the bundle.
360
383
  `add_many` registers a batch atomically: duplicate IDs or invalid schedules leave
361
384
  the registry unchanged.
362
385
 
386
+ Inserted jobs carry `periodic: true` in metadata and, when the registration has
387
+ an ID, `river:periodic_job_id`. Constructor options and application metadata are
388
+ preserved.
389
+
363
390
  Schedules can be callbacks (`schedule: ->(now) { now + 300 }`) or objects
364
391
  implementing `next(time)`. They must return a `Time` strictly after the supplied
365
392
  time; they do not execute the job themselves. A schedule that raises at runtime
@@ -376,12 +403,15 @@ cleanup = River::PeriodicJob.new(
376
403
 
377
404
  `PeriodicCron` parses once and loads Fugit only when constructed. Fugit is not
378
405
  a runtime dependency of the River gem. The timezone defaults explicitly to UTC;
379
- provide it through `timezone:`, not inside the expression. Five-field cron,
380
- optional seconds, and aliases such as `@daily` use Fugit's syntax. Results are
381
- UTC `Time` objects. Local calendar times follow Fugit's daylight-saving rules;
382
- nonexistent spring-forward times are skipped. Test ambiguous fall-back times
383
- for your schedules. Cron does not change core scheduling durability or replay
384
- missed occurrences after downtime.
406
+ set `timezone:` or use a `CRON_TZ=` / `TZ=` prefix, which takes precedence.
407
+ Five-field cron, optional seconds, and aliases such as `@daily` use Fugit's
408
+ syntax; `?` is also accepted as a wildcard. `@every 1h30m` uses Go duration
409
+ syntax, rounded down to whole seconds with a one-second minimum. Unlike
410
+ `PeriodicInterval`, these intervals align to whole-second boundaries.
411
+ Results are UTC `Time` objects. Nonexistent spring-forward times are skipped;
412
+ both occurrences of a repeated fall-back time are scheduled. See
413
+ [conformance differences](conformance.md) before sharing schedules across
414
+ languages. Cron does not replay missed occurrences after downtime.
385
415
 
386
416
  For a one-time date, insert a job with
387
417
  `client.insert(args, scheduled_at: Time.utc(2026, 9, 20, 9))` instead of registering
@@ -534,6 +564,9 @@ A plugin may implement any combination of these methods:
534
564
 
535
565
  A single plugin can provide both styles. For example, it might use
536
566
  `insert_begin` to add metadata and `work` to time the complete work operation.
567
+ After insertion hooks run, each job must still have an initial state of
568
+ `available`, `pending`, or `scheduled`. Invalid states roll back the batch
569
+ and any database writes made by its hooks.
537
570
 
538
571
  ```ruby
539
572
  class TimingPlugin
@@ -604,7 +637,11 @@ client.job_delete_many states: [:cancelled]
604
637
 
605
638
  Reusable `JobListParams` and `JobUpdateParams` objects are also accepted as
606
639
  positional arguments, instead of keywords. For updates, omitted fields remain
607
- unchanged; an explicit `nil` clears a nullable field.
640
+ unchanged; an explicit `nil` clears a nullable field. Metadata must be a Hash;
641
+ use `metadata: {}` to clear it. Values inside the Hash may be `nil`.
642
+ Updated `max_attempts` must be between 1 and 32,767; `attempt` must be between
643
+ 0 and 32,767. Claims cap the attempt counter at 32,767 so an administratively
644
+ requeued job cannot prevent other jobs from being claimed.
608
645
 
609
646
  Bulk deletion requires at least one filter and never deletes running jobs.
610
647
  Metadata filters compare complete JSON values at each supplied top-level key,
@@ -647,6 +684,8 @@ config = River::Config.new(
647
684
  ```
648
685
 
649
686
  A custom service implements `run(client, driver, now)` and runs only while this client holds leadership.
687
+ Service errors are logged without interrupting other services, stuck-job rescue,
688
+ or cleanup. A failed service is retried on the next scheduled maintenance pass.
650
689
 
651
690
  On SQLite, the leader also removes notification outbox entries older than five
652
691
  minutes in bounded batches. Cancellation requests write Go-compatible control
@@ -682,6 +721,13 @@ The Ruby configuration file must return an unstarted client. The following clien
682
721
  methods are for applications managing their own runtime lifecycle:
683
722
 
684
723
  `client.stop` stops fetching and waits for active jobs to finish. `client.stop_and_cancel` interrupts active worker threads and returns their jobs to `available` without consuming the interrupted attempt.
724
+ While draining, the client continues polling for cancellation requests from
725
+ other clients, including for workers with no timeout. Transient polling errors
726
+ are retried until the active attempts finish.
727
+
728
+ Blocking stop calls from the client's own workers or other runtime threads
729
+ raise `ThreadError`. A worker can request shutdown with `client.stop(wait: false)`
730
+ and then finish its work.
685
731
 
686
732
  Use `client.stop(wait: false)` to request stop and return immediately without
687
733
  interrupting active workers. Call `client.stop` later to wait for draining and
@@ -714,7 +760,7 @@ limitations are resolved.
714
760
 
715
761
  ## RBS and type checking
716
762
 
717
- The gem bundles [RBS files](https://github.com/riverqueue/riverqueue-ruby/tree/master/sig) for tools such as [Steep](https://github.com/soutaro/steep) and other RBS-compatible type checkers.
763
+ The gem bundles [RBS files](https://github.com/riverqueue/river/tree/master/ruby/sig) for tools such as [Steep](https://github.com/soutaro/steep) and other RBS-compatible type checkers.
718
764
 
719
765
  ## Drivers
720
766
 
@@ -750,9 +796,10 @@ River Pro is kept in the separate, privately distributed `riverqueue-pro` gem, s
750
796
 
751
797
  ## Current differences from the Go client
752
798
 
753
- The shared PostgreSQL insert-only conformance profile is exercised through both
754
- SQL drivers. Full runtime, SQLite, and multi-engine conformance remain in
755
- progress; see the [conformance status and differences](../conformance/README.md).
799
+ Ruby checks Go-generated fixtures for unique keys, cron, snooze counters, and
800
+ shared protocol values.
801
+ See [conformance coverage and differences](conformance.md); this is not yet a
802
+ full mixed-language runtime suite.
756
803
 
757
804
  The Ruby client does not currently provide dedicated OpenTelemetry/metrics
758
805
  integrations or
@@ -0,0 +1,72 @@
1
+ # Ruby conformance
2
+
3
+ Ruby reads fixtures generated from this checkout's Go implementation, alongside
4
+ Rust and JavaScript. From the repository root:
5
+
6
+ ```sh
7
+ make -C ruby install
8
+ make test/ruby/conformance
9
+ ```
10
+
11
+ This generates `conformance/testdata/` and runs `ruby/spec/conformance_spec.rb`.
12
+ No PostgreSQL server, pinned upstream checkout, protocol adapter, or separately
13
+ maintained golden files are needed. Missing fixtures fail with instructions to
14
+ regenerate them. The regular Ruby suite includes these checks too.
15
+
16
+ The fixture checks cover:
17
+
18
+ - All unique-key cases, including typed-only cases, raw JSON tokens, selected
19
+ fields, periods, queues, and state masks.
20
+ - Job state names and unique bits, attempt-error encoding/decoding, retry bounds,
21
+ and all six shared metadata keys, including actual output, rescue, periodic,
22
+ unique insertion, and resumable-step persistence.
23
+ - All snooze-counter fixtures through real worker attempts and both SQL drivers.
24
+ - Cron occurrences, named zones, daylight-saving transitions, interval schedules,
25
+ and invalid expressions, with the Ruby API differences below checked explicitly.
26
+ - Insertion, cancellation, queue pause/resume/metadata, and leadership resignation
27
+ notification encoding through both SQL drivers, using in-memory SQLite and
28
+ the bundled canonical migrations.
29
+
30
+ The Ruby workflow runs on Ruby changes. The shared Conformance workflow also
31
+ runs these tests when Go code changes. The ordinary driver suites retain
32
+ PostgreSQL notification delivery/commit-ordering tests and shared PostgreSQL /
33
+ SQLite insertion, transaction, uniqueness, and worker tests.
34
+
35
+ This replaces the old `insert-only-v1` adapter and its pinned external harness.
36
+ It checks shared data formats, not live mixed-language worker execution. Adapter
37
+ handshake and request-schema tests were specific to that removed harness; their
38
+ protocol is not part of the Ruby API.
39
+
40
+ ## Coverage gaps and API differences
41
+
42
+ Ruby's runtime still polls job and queue state rather than consuming PostgreSQL
43
+ LISTEN events or the SQLite outbox. The fixture target checks notification
44
+ emission, not dispatch. Ruby does not emit or handle `request_resign`; other
45
+ clients' requests therefore cannot force a Ruby leader to resign. Completing
46
+ that coverage would require a notification listener and runtime dispatch path.
47
+ The ordinary driver suites verify PostgreSQL delivery, commit ordering, and
48
+ rollback for queue controls and resignations as well as insertion/cancellation.
49
+
50
+ Shared snooze-counter fixtures cover non-negative integers and absent counters.
51
+ Recovery from other JSON values is implementation-specific. Ruby's shared driver
52
+ tests cover its lenient conversion rules separately on PostgreSQL and SQLite.
53
+
54
+ Cron retains Ruby's existing Fugit extensions: six fields with seconds, Sunday
55
+ as 7, hour 24, and descending ranges. These four expressions are accepted in
56
+ Ruby and rejected in Go; the fixture suite explicitly asserts the difference.
57
+ Fugit rejects impossible calendar dates at construction instead of returning
58
+ Go's zero time. Ruby's default calendar zone remains UTC; tests explicitly pass
59
+ the reference time's fixed offset to model Go's time-location API. `CRON_TZ=` /
60
+ `TZ=` prefixes, `?`, and `@every` duration rounding now follow Go for the shared
61
+ fixtures. No new dependencies were added.
62
+
63
+ Selected unique fields use `by_args: [:field, [:nested, :field]]`, rather than Go
64
+ struct tags. Dotted string keys are literal. Cross-language unique keys require
65
+ the same encoded argument values, including escaping, numeric tokens, and
66
+ nested ordering; Ruby does not override the application's JSON serialization.
67
+
68
+ Transactions are connection-scoped blocks. Workers use exceptions and thread
69
+ interruption. List cursors encode the selected field explicitly. Historical
70
+ error timestamps accept Ruby's `Time.parse` formats, and metadata must decode
71
+ to an object. Live interoperability across these boundaries is not covered by
72
+ the fixture target.
data/docs/migrations.md CHANGED
@@ -98,19 +98,16 @@ history changes. These are not shared locks with Go's migration runner.
98
98
 
99
99
  ## Updating the bundled SQL
100
100
 
101
- `migration/manifest.json` records the upstream commit and each file's SHA-256.
102
- Synchronize or verify the public migrations against a local Go checkout:
101
+ `migration/manifest.json` records each canonical file's SHA-256. From `ruby/`,
102
+ synchronize or verify against the Go migrations in this same checkout:
103
103
 
104
104
  ```sh
105
- ruby scripts/sync_migrations.rb ../river
105
+ ruby scripts/sync_migrations.rb
106
106
  make verify
107
- make verify RIVER_PATH=/path/to/river
108
107
  ```
109
108
 
110
- `make verify` defaults to `../river` and checks SQL contents, filenames, the
111
- license, and the manifest's checksums and source revision. CI fetches the public
112
- River repository at that recorded revision and runs the same check; it does not
113
- require a sibling checkout or access to the private Pro repository.
109
+ Verification checks SQL contents, filenames, license, and manifest checksums.
110
+ CI uses the same working-tree comparison; no separate checkout is needed.
114
111
 
115
112
  Pro migrations have their own manifest and sync script in the private
116
113
  `riverqueue-ruby-pro` repository. Run that repository's `make verify` against
data/lib/client.rb CHANGED
@@ -7,6 +7,9 @@ module River
7
7
  # Default number of maximum attempts for a job.
8
8
  MAX_ATTEMPTS_DEFAULT = 25
9
9
 
10
+ # Largest attempt limit supported by all River databases.
11
+ MAX_ATTEMPTS_LIMIT = 32_767
12
+
10
13
  # Default priority for a job.
11
14
  PRIORITY_DEFAULT = 1
12
15
 
@@ -40,6 +43,8 @@ module River
40
43
 
41
44
  # Internal extension point used by separately packaged batch workers after
42
45
  # they atomically claim additional jobs alongside the batch leader.
46
+ # Errors honor the registered worker's retry hooks and River's cancellation,
47
+ # snooze, and interruption signals. Timings use the row's claim timestamp.
43
48
  def __finish_claimed_job(row, error = nil)
44
49
  @runtime.finish_claimed(row, error)
45
50
  end
@@ -223,13 +228,18 @@ module River
223
228
 
224
229
  # Makes a non-running job immediately available for another attempt and
225
230
  # returns its updated JobRow. Raises NotFoundError if it does not exist.
231
+ # Clears any cancellation request from its previous attempt. Raises
232
+ # ArgumentError if another attempt would exceed MAX_ATTEMPTS_LIMIT.
226
233
  def job_retry(id)
227
234
  @driver.job_retry(id) || raise(NotFoundError, "job not found: #{id}")
228
235
  end
229
236
 
230
237
  # Applies keyword attributes or a JobUpdateParams to a job and returns its
231
238
  # updated JobRow. Explicit nil clears nullable fields; omitted fields are
232
- # unchanged. Raises NotFoundError if the job does not exist.
239
+ # unchanged. Metadata must be a Hash; use an empty Hash to clear it.
240
+ # attempt must be between 0 and MAX_ATTEMPTS_LIMIT (32,767);
241
+ # max_attempts must be between 1 and MAX_ATTEMPTS_LIMIT.
242
+ # Raises NotFoundError if the job does not exist.
233
243
  # @type method job_update: (Integer, ?JobUpdateParams?, **untyped) -> JobRow
234
244
  def job_update(id, params = nil, **attributes)
235
245
  raise ArgumentError, "use params or keyword attributes, not both" if params && !attributes.empty?
@@ -266,9 +276,8 @@ module River
266
276
  # Pauses a named queue, or all queues when +name+ is +"*"+.
267
277
  def queue_pause(name)
268
278
  name = name.to_s
269
- @driver.queue_pause(name)
279
+ queues = @driver.queue_pause(name)
270
280
  @runtime.wake
271
- queues = (name == "*") ? @driver.queue_list : [@driver.queue_get(name)].compact
272
281
  queues.each do |queue|
273
282
  @runtime.publish_queue(EVENT_QUEUE_PAUSED, queue)
274
283
  end
@@ -278,6 +287,7 @@ module River
278
287
 
279
288
  # Removes a configured queue, waits for active jobs in it to finish, and
280
289
  # returns self.
290
+ # Raises ThreadError if called from that queue's own worker or producer.
281
291
  def queue_remove(name)
282
292
  @runtime.queue_remove(name.to_s)
283
293
  self
@@ -286,9 +296,8 @@ module River
286
296
  # Resumes a named queue, or all queues when +name+ is +"*"+.
287
297
  def queue_resume(name)
288
298
  name = name.to_s
289
- @driver.queue_resume(name)
299
+ queues = @driver.queue_resume(name)
290
300
  @runtime.wake
291
- queues = (name == "*") ? @driver.queue_list : [@driver.queue_get(name)].compact
292
301
  queues.each do |queue|
293
302
  @runtime.publish_queue(EVENT_QUEUE_RESUMED, queue)
294
303
  end
@@ -316,12 +325,14 @@ module River
316
325
  # waiting; call stop again to finish draining and release resources.
317
326
  # In-flight fetches or maintenance operations may finish. Until a waiting
318
327
  # stop completes, started? remains true and stopped? remains false.
328
+ # Raises ThreadError for a waiting call from this client's runtime threads.
319
329
  def stop(wait: true)
320
330
  @runtime.stop(wait: wait)
321
331
  self
322
332
  end
323
333
 
324
334
  # Stops fetching new jobs, interrupts active work, and returns self.
335
+ # Raises ThreadError if called from this client's own runtime threads.
325
336
  def stop_and_cancel
326
337
  @runtime.stop(cancel: true)
327
338
  self
@@ -350,6 +361,8 @@ module River
350
361
 
351
362
  EMPTY_INSERT_OPTS = InsertOpts.new.freeze
352
363
 
364
+ INITIAL_STATES = [JOB_STATE_AVAILABLE, JOB_STATE_PENDING, JOB_STATE_SCHEDULED].freeze
365
+
353
366
  REQUIRED_UNIQUE_STATES = [
354
367
  JOB_STATE_AVAILABLE,
355
368
  JOB_STATE_PENDING,
@@ -359,7 +372,7 @@ module River
359
372
 
360
373
  TAG_RE = /\A\w[\w-]+\w\z/
361
374
 
362
- private_constant :DEFAULT_UNIQUE_STATES, :EMPTY_INSERT_OPTS, :REQUIRED_UNIQUE_STATES, :TAG_RE
375
+ private_constant :DEFAULT_UNIQUE_STATES, :EMPTY_INSERT_OPTS, :INITIAL_STATES, :REQUIRED_UNIQUE_STATES, :TAG_RE
363
376
 
364
377
  private def insert_and_check_unique_job(insert_params)
365
378
  job, unique_skipped_as_duplicate = @driver.job_insert(insert_params)
@@ -380,18 +393,25 @@ module River
380
393
  EMPTY_INSERT_OPTS
381
394
  end
382
395
 
396
+ max_attempts = insert_opts.max_attempts || args_insert_opts.max_attempts || MAX_ATTEMPTS_DEFAULT
397
+ raise ArgumentError, "max_attempts must be greater than zero" unless max_attempts > 0
398
+ raise ArgumentError, "max_attempts must not exceed #{MAX_ATTEMPTS_LIMIT}" if max_attempts > MAX_ATTEMPTS_LIMIT
399
+
400
+ queue = (insert_opts.queue || args_insert_opts.queue || QUEUE_DEFAULT).to_s
401
+ raise ArgumentError, "invalid queue name: #{queue.inspect}" unless queue.match?(QUEUE_NAME_REGEX) && queue.length < 128
402
+
383
403
  scheduled_at = insert_opts.scheduled_at || args_insert_opts.scheduled_at
384
404
  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
405
+ state = validate_insert_state(insert_opts.state || args_insert_opts.state || ((scheduled_at && scheduled_at > now) ? JOB_STATE_SCHEDULED : JOB_STATE_AVAILABLE))
386
406
 
387
407
  insert_params = Driver::JobInsertParams.new(
388
408
  args: args,
389
409
  encoded_args: args_json,
390
410
  kind: args.kind.to_s,
391
- max_attempts: insert_opts.max_attempts || args_insert_opts.max_attempts || MAX_ATTEMPTS_DEFAULT,
411
+ max_attempts: max_attempts,
392
412
  metadata: (args_insert_opts.metadata || {}).merge(insert_opts.metadata || {}),
393
413
  priority: insert_opts.priority || args_insert_opts.priority || PRIORITY_DEFAULT,
394
- queue: (insert_opts.queue || args_insert_opts.queue || QUEUE_DEFAULT).to_s,
414
+ queue: queue,
395
415
  scheduled_at: scheduled_at&.getutc || now,
396
416
  state: state,
397
417
  tags: validate_tags(insert_opts.tags || args_insert_opts.tags || [])
@@ -423,6 +443,8 @@ module River
423
443
  end
424
444
 
425
445
  if unique_opts.by_period && unique_opts.by_period != 0
446
+ raise ArgumentError, "by_period should not be less than 1 second" if unique_opts.by_period < 1
447
+
426
448
  lower_period_bound = truncate_time(insert_params.scheduled_at || @time_now_utc.call, unique_opts.by_period).utc
427
449
 
428
450
  unique_key += "&period=#{lower_period_bound.strftime("%FT%TZ")}"
@@ -456,6 +478,10 @@ module River
456
478
  end
457
479
  end
458
480
 
481
+ # Hooks may change any job in the batch, including an earlier entry.
482
+ # Validate the final states only after all insertion hooks have run.
483
+ all_params.each { |params| params.state = validate_insert_state(params.state) }
484
+
459
485
  results = insert_operation.call
460
486
  results.each do |result|
461
487
  config.plugins.reverse_each do |plugin|
@@ -496,6 +522,13 @@ module River
496
522
  [int].pack("Q").unpack1("q") #: Integer # rubocop:disable Layout/LeadingCommentSpace
497
523
  end
498
524
 
525
+ private def validate_insert_state(state)
526
+ state = state.to_s #: jobStateInitial # rubocop:disable Layout/LeadingCommentSpace
527
+ raise ArgumentError, "invalid insertion state: #{state.inspect}; must be available, pending, or scheduled" unless INITIAL_STATES.include?(state)
528
+
529
+ state
530
+ end
531
+
499
532
  private def validate_tags(tags)
500
533
  tags.each do |tag|
501
534
  raise ArgumentError, "tags should be 255 characters or less" if tag.length > 255