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
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/expectations"
4
+ require "riverqueue/testing"
5
+
6
+ module River
7
+ module Testing
8
+ # Include through RSpec.configure { |c| c.include River::Testing::RSpec }.
9
+ module RSpec
10
+ # Matches a client with at least one persisted job matching the attributes.
11
+ # Includes all job states unless state: is specified.
12
+ def have_job(**attributes)
13
+ JobMatcher.new(1, attributes).at_least(1)
14
+ end
15
+
16
+ # Matches a block inserting exactly one matching persisted job.
17
+ def insert_job(client, **attributes)
18
+ insert_jobs(client, **attributes)
19
+ end
20
+
21
+ # Matches a block inserting count matching rows (one by default).
22
+ # Attributes accept composable RSpec matchers. Negation always requires zero
23
+ # matching rows, regardless of the configured positive count.
24
+ def insert_jobs(client, count: 1, **attributes)
25
+ InsertionMatcher.new(client, count, attributes)
26
+ end
27
+
28
+ class JobMatcher
29
+ include ::RSpec::Matchers::Composable
30
+
31
+ def initialize(count, attributes)
32
+ @attributes = Testing.normalize_attributes(attributes)
33
+ exactly(count)
34
+ end
35
+
36
+ # Requires at least count matching jobs.
37
+ def at_least(count)
38
+ set_count(count, :>=, "at least")
39
+ end
40
+
41
+ # Requires at most count matching jobs.
42
+ def at_most(count)
43
+ set_count(count, :<=, "at most")
44
+ end
45
+
46
+ def description = "#{verb} #{@count_description} #{@count} River jobs matching #{surface_descriptions_in(@attributes).inspect}"
47
+
48
+ def does_not_match?(actual)
49
+ matching_rows(actual).empty?
50
+ end
51
+
52
+ # Requires exactly count matching jobs.
53
+ def exactly(count)
54
+ set_count(count, :==, "exactly")
55
+ end
56
+
57
+ def failure_message = "Expected to #{description}, got #{@rows.length}: #{@rows.map(&:id).inspect}"
58
+
59
+ def failure_message_when_negated = "Expected no matching River jobs for #{surface_descriptions_in(@attributes).inspect}, got #{@rows.length}: #{@rows.map(&:id).inspect}"
60
+
61
+ def matches?(actual)
62
+ matching_rows(actual).length.public_send(@comparison, @count)
63
+ end
64
+
65
+ def supports_block_expectations? = false
66
+
67
+ def supports_value_expectations? = true
68
+
69
+ private def candidates(actual)
70
+ Testing.jobs(actual)
71
+ end
72
+
73
+ private def matching_rows(actual)
74
+ @rows = candidates(actual).select do |row|
75
+ @attributes.all? { |key, expected| values_match?(expected, row.public_send(key)) }
76
+ end
77
+ end
78
+
79
+ private def set_count(count, comparison, description)
80
+ raise ArgumentError, "count must be a nonnegative integer" unless count.is_a?(Integer) && count >= 0
81
+
82
+ @comparison = comparison
83
+ @count = count
84
+ @count_description = description
85
+ self
86
+ end
87
+
88
+ private def verb = "have"
89
+ end
90
+
91
+ class InsertionMatcher < JobMatcher
92
+ def initialize(client, count, attributes)
93
+ @client = client
94
+ super(count, attributes)
95
+ end
96
+
97
+ def supports_block_expectations? = true
98
+
99
+ def supports_value_expectations? = false
100
+
101
+ private def candidates(operation)
102
+ Testing.inserted_jobs(@client, &operation)
103
+ end
104
+
105
+ private def verb = "insert"
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "riverqueue"
4
+
5
+ module River
6
+ # Database-backed test helpers. Require explicitly; no test framework is loaded.
7
+ module Testing
8
+ class AssertionError < StandardError; end
9
+ class DrainLimitError < StandardError; end
10
+
11
+ JOB_ATTRIBUTES = %i[id args attempt attempted_at attempted_by created_at errors finalized_at kind max_attempts metadata priority queue scheduled_at state tags unique_key unique_states].freeze
12
+
13
+ ExecutionResult = Data.define(:id, :error, :job, :outcome)
14
+
15
+ # Framework-neutral assertions, also used by the optional integrations.
16
+ module Assertions
17
+ # Asserts that a synchronous attempt was cancelled and returns its result.
18
+ def assert_job_cancelled(result)
19
+ river_assert(result.outcome == :cancelled, "Expected cancelled River job, got #{result.outcome}: #{result.error.inspect}")
20
+ result
21
+ end
22
+
23
+ # Asserts that a synchronous attempt completed and returns its result.
24
+ def assert_job_completed(result)
25
+ river_assert(result.outcome == :completed, "Expected completed River job, got #{result.outcome}: #{result.error.inspect}")
26
+ result
27
+ end
28
+
29
+ # Asserts that a synchronous attempt exhausted retries and returns its result.
30
+ def assert_job_discarded(result)
31
+ river_assert(result.outcome == :discarded, "Expected discarded River job, got #{result.outcome}: #{result.error.inspect}")
32
+ result
33
+ end
34
+
35
+ # Asserts that exactly one matching new row was inserted by the block and
36
+ # returns it. Attributes (including args) use exact equality.
37
+ def assert_job_inserted(client, **attributes, &block)
38
+ assert_jobs_inserted(client, count: 1, **attributes, &block).fetch(0)
39
+ end
40
+
41
+ # Asserts the number of matching new rows, returning those rows. Existing
42
+ # rows returned by uniqueness checks do not count as insertions.
43
+ def assert_jobs_inserted(client, count:, **attributes, &block)
44
+ raise ArgumentError, "count must be a nonnegative integer" unless count.is_a?(Integer) && count >= 0
45
+
46
+ rows = Testing.inserted_jobs(client, **attributes, &block)
47
+ message = "Expected #{count} new River jobs matching #{attributes.inspect}, got #{rows.length}: #{rows.map(&:id).inspect}"
48
+ river_assert(rows.length == count, message)
49
+ rows
50
+ end
51
+
52
+ # Asserts that the block inserted no matching rows.
53
+ def assert_no_jobs_inserted(client, **attributes, &block)
54
+ assert_jobs_inserted(client, count: 0, **attributes, &block)
55
+ end
56
+
57
+ private def river_assert(condition, message)
58
+ raise AssertionError, message unless condition
59
+ end
60
+ end
61
+
62
+ class << self
63
+ # Runs eligible jobs in priority order, including jobs inserted by workers.
64
+ # Never sleeps or starts maintenance. Raises if runnable work remains after
65
+ # max_jobs attempts. Use an isolated database with no background consumers.
66
+ def drain(client, queue:, max_jobs: 100)
67
+ raise ArgumentError, "max_jobs must be a positive integer" unless max_jobs.is_a?(Integer) && max_jobs.positive?
68
+ queue = queue.to_s
69
+ # @type var results: Array[ExecutionResult]
70
+ results = []
71
+ loop do
72
+ now = Time.now.utc
73
+ row = jobs(client).select { |job| job.queue == queue && %w[available retryable scheduled].include?(job.state) && job.scheduled_at <= now }
74
+ .min_by { |job| [job.priority, job.scheduled_at, job.id] }
75
+
76
+ return results unless row
77
+ raise DrainLimitError, "River drain reached #{max_jobs} attempts with runnable jobs remaining in #{queue.inspect}" if results.length == max_jobs
78
+
79
+ results << perform_job(client, row.id)
80
+ end
81
+ end
82
+
83
+ # Returns matching rows newly persisted by a block, using an ID snapshot
84
+ # rather than a count or sequence high-water mark. Rolls back no data.
85
+ def inserted_jobs(client, **attributes)
86
+ raise ArgumentError, "a block is required" unless block_given?
87
+
88
+ attributes = normalize_attributes(attributes)
89
+
90
+ before = jobs(client).to_h { |job| [job.id, true] }
91
+ yield
92
+ jobs(client).select do |job|
93
+ !before.key?(job.id) && attributes.all? { |key, value| job.public_send(key) == value }
94
+ end
95
+ end
96
+
97
+ # Returns all persisted jobs, in every state, using paginated reads.
98
+ # Requires an isolated test database without concurrent consumers.
99
+ def jobs(client)
100
+ # @type var result: Array[JobRow]
101
+ result = []
102
+ # @type var cursor: Integer?
103
+ cursor = nil
104
+ loop do
105
+ page = client.job_list(JobListParams.new(after_id: cursor, limit: 100)).jobs
106
+ result.concat(page)
107
+ return result if page.length < 100
108
+
109
+ cursor = page.fetch(-1).id
110
+ end
111
+ end
112
+
113
+ # Internal shared normalization for assertions and RSpec matchers. Only
114
+ # symbolic identifiers are coerced; JSON values and matchers stay intact.
115
+ def normalize_attributes(attributes)
116
+ unknown = attributes.keys - JOB_ATTRIBUTES
117
+ raise ArgumentError, "unknown job attributes: #{unknown.join(", ")}" unless unknown.empty?
118
+
119
+ attributes.to_h do |key, value|
120
+ [key, (value.is_a?(Symbol) && %i[kind queue state].include?(key)) ? value.to_s : value]
121
+ end
122
+ end
123
+
124
+ # Performs one attempt on the calling thread using the actual runtime,
125
+ # returning the persisted row, original exception, and symbolic outcome.
126
+ # Future jobs require allow_scheduled: true; terminal/running/pending jobs
127
+ # cannot be claimed. This intentionally bypasses queue pause and capacity.
128
+ def perform_job(client, id, allow_scheduled: false)
129
+ job, error, outcome = client.__perform_job(id, allow_scheduled: allow_scheduled)
130
+ ExecutionResult.new(id: id, error: error, job: job, outcome: outcome)
131
+ end
132
+ end
133
+ end
134
+ end
data/lib/riverqueue.rb CHANGED
@@ -1,11 +1,27 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "json"
4
+ require "securerandom"
2
5
 
6
+ require_relative "errors"
3
7
  require_relative "insert_opts"
4
8
  require_relative "job"
9
+ require_relative "worker"
10
+ require_relative "job_persisted_logging/plugin"
11
+ require_relative "resumable"
12
+ require_relative "config"
13
+ require_relative "event"
14
+ require_relative "params"
15
+ require_relative "periodic_cron"
16
+ require_relative "periodic_job"
17
+ require_relative "client_runtime"
5
18
 
6
19
  require_relative "client"
20
+ require_relative "worker_runner"
7
21
  require_relative "driver"
8
22
  require_relative "unique_bitmask"
23
+ require_relative "unique_args"
24
+ require_relative "migrator"
9
25
 
10
26
  module River
11
27
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module River
4
+ # Internal codec for the argument portion of Go-compatible unique keys.
5
+ # Only top-level keys are sorted. Decoding and re-encoding values would lose
6
+ # number lexemes, escaping, duplicate keys, and nested object ordering.
7
+ module UniqueArgs
8
+ def self.encode(json, fields)
9
+ value = JSON.parse(json)
10
+ if !fields.is_a?(Array) || fields.empty?
11
+ return "{}" if value.is_a?(Array) && value.empty?
12
+ raise ArgumentError, "unique args must encode a JSON object" unless value.is_a?(Hash)
13
+
14
+ return encode_object(members(json).sort.to_h)
15
+ end
16
+
17
+ paths = fields.map do |field|
18
+ path = Array(field).map(&:to_s)
19
+ raise ArgumentError, "unique argument paths must not be empty" if path.empty?
20
+
21
+ path
22
+ end
23
+ selected = {} #: Hash[String, untyped]
24
+ paths.sort_by { |path| [path.join("."), path.map { |part| part.gsub(/([\\.])/) { |char| "\\" + char } }.join(".")] }.each do |path|
25
+ raw = json
26
+ path.each do |part|
27
+ raw = members(raw)[part]
28
+ break unless raw
29
+ end
30
+ next unless raw
31
+
32
+ target = selected
33
+ covered = false
34
+ path.take(path.length - 1).each do |part|
35
+ if target[part].is_a?(String)
36
+ covered = true
37
+ break # Selecting a whole object already includes its children.
38
+ end
39
+ target = (target[part] ||= {})
40
+ end
41
+ target[path.last] = raw unless covered
42
+ end
43
+ # Go's selected-field builder starts with nil bytes, not an empty object.
44
+ selected.empty? ? "" : encode_object(selected)
45
+ end
46
+
47
+ # The caller validates JSON first. Values remain slices of the original
48
+ # string; this is a token walk, not a second JSON decoder. Go takes the first
49
+ # occurrence of a duplicate top-level key.
50
+ def self.members(json)
51
+ return {} unless json.lstrip.start_with?("{")
52
+
53
+ result = {} #: Hash[String, String]
54
+ depth = 0
55
+ key = ""
56
+ start = nil #: Integer?
57
+ expecting_value = false
58
+ # Anchoring prevents repeated searches through an unterminated string.
59
+ # \K keeps leading whitespace out of the offsets used to slice raw values.
60
+ json.scan(/\G\s*\K(?:"(?:[^"\\]|\\.)*"|[{}\[\],:]|[^\s{}\[\],:"]+)/) do |scanned|
61
+ token = scanned #: String
62
+ match = Regexp.last_match #: MatchData
63
+ if depth == 1
64
+ if start && [",", "}"].include?(token)
65
+ raw = json[start...match.begin(0)] #: String
66
+ result[key] ||= raw.rstrip
67
+ start = nil
68
+ elsif expecting_value
69
+ start = match.begin(0)
70
+ expecting_value = false
71
+ elsif token == ":"
72
+ expecting_value = true
73
+ elsif token.start_with?('"') && !start
74
+ key = JSON.parse(token)
75
+ end
76
+ end
77
+ depth += 1 if token == "{" || token == "["
78
+ depth -= 1 if token == "}" || token == "]"
79
+ end
80
+ result
81
+ end
82
+
83
+ def self.encode_object(object)
84
+ "{" + object.map { |key, value| "#{encode_key(key)}:#{value.is_a?(Hash) ? encode_object(value) : value}" }.join(",") + "}"
85
+ end
86
+
87
+ # Match sjson's fast path for printable ASCII keys and encoding/json's
88
+ # HTML escaping for all other keys. Values are never passed through here.
89
+ def self.encode_key(key)
90
+ return '"' + key + '"' unless key.match?(/[\x00-\x1f\u0080-\u{10ffff}"\\]/)
91
+
92
+ JSON.generate(key).gsub(/[<>&\u2028\u2029]/) { |char| "\\u%04x" % char.ord }
93
+ end
94
+
95
+ private_class_method :encode_object, :encode_key
96
+ end
97
+ end
@@ -1,17 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module River
2
4
  class UniqueBitmask
3
- JOB_STATE_BIT_POSITIONS = {
4
- ::River::JOB_STATE_AVAILABLE => 7,
5
- ::River::JOB_STATE_CANCELLED => 6,
6
- ::River::JOB_STATE_COMPLETED => 5,
7
- ::River::JOB_STATE_DISCARDED => 4,
8
- ::River::JOB_STATE_PENDING => 3,
9
- ::River::JOB_STATE_RETRYABLE => 2,
10
- ::River::JOB_STATE_RUNNING => 1,
11
- ::River::JOB_STATE_SCHEDULED => 0
12
- }.freeze
13
- private_constant :JOB_STATE_BIT_POSITIONS
14
-
15
5
  def self.from_states(states)
16
6
  val = 0
17
7
 
@@ -37,5 +27,17 @@ module River
37
27
 
38
28
  states.sort
39
29
  end
30
+
31
+ JOB_STATE_BIT_POSITIONS = {
32
+ ::River::JOB_STATE_AVAILABLE => 7,
33
+ ::River::JOB_STATE_CANCELLED => 6,
34
+ ::River::JOB_STATE_COMPLETED => 5,
35
+ ::River::JOB_STATE_DISCARDED => 4,
36
+ ::River::JOB_STATE_PENDING => 3,
37
+ ::River::JOB_STATE_RETRYABLE => 2,
38
+ ::River::JOB_STATE_RUNNING => 1,
39
+ ::River::JOB_STATE_SCHEDULED => 0
40
+ }.freeze
41
+ private_constant :JOB_STATE_BIT_POSITIONS
40
42
  end
41
43
  end
data/lib/worker.rb ADDED
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module River
4
+ # Registry that maps job kinds to worker objects.
5
+ class Workers
6
+ # Creates an empty worker registry.
7
+ def initialize
8
+ @workers = {}
9
+ end
10
+
11
+ # Registers a worker for a job kind and optional aliases, returning self.
12
+ #
13
+ # With one argument, the kind is read from +worker.kind+ or
14
+ # +worker.class.kind+. Pass a kind and worker separately to override it.
15
+ # Kinds and aliases accept symbols or strings.
16
+ def add(kind_or_worker, worker = nil, aliases: [])
17
+ if worker
18
+ kind = kind_or_worker.to_s
19
+ else
20
+ worker = kind_or_worker
21
+ kind = worker.respond_to?(:kind) ? worker.kind.to_s : worker.class.kind.to_s
22
+ end
23
+
24
+ candidates = [kind] + aliases.map(&:to_s)
25
+ candidates.each do |candidate|
26
+ raise ArgumentError, "worker for kind #{candidate.inspect} is already registered" if @workers.key?(candidate)
27
+ end
28
+
29
+ candidates.each do |candidate|
30
+ @workers[candidate] = worker
31
+ end
32
+
33
+ self
34
+ end
35
+
36
+ # Returns the worker registered for +kind+, or nil when none is registered.
37
+ def [](kind)
38
+ @workers[kind.to_s]
39
+ end
40
+
41
+ # Returns a registered worker. Like Hash#fetch, raises KeyError when missing
42
+ # unless a default argument or fallback block is supplied.
43
+ def fetch(kind, ...)
44
+ workers = @workers #: untyped
45
+ workers.fetch(kind.to_s, ...)
46
+ end
47
+
48
+ # Returns true if a worker is registered for +kind+.
49
+ def include?(kind)
50
+ @workers.key?(kind.to_s)
51
+ end
52
+
53
+ # Returns the registered job kinds, including aliases.
54
+ def kinds
55
+ @workers.keys.freeze
56
+ end
57
+ end
58
+
59
+ # A job being worked, with access to its persisted row and attempt-local
60
+ # metadata changes.
61
+ class Job
62
+ # Client working this job.
63
+ attr_reader :client
64
+
65
+ # Persisted JobRow claimed for this attempt.
66
+ attr_reader :row
67
+
68
+ def initialize(client, row)
69
+ @client = client
70
+ @logger = nil
71
+ @metadata_updates = {}
72
+ @row = row
73
+ initialize_resumable_state
74
+ end
75
+
76
+ # Returns the job arguments decoded from their persisted JSON.
77
+ def args = row.args
78
+
79
+ # Returns the attempt-local logger supplied by JobPersistedLogging::Plugin.
80
+ # Output is saved to job metadata when the attempt finishes, not streamed to the DB.
81
+ def logger
82
+ @logger || raise(Error, "job.logger requires River::JobPersistedLogging::Plugin in Config#plugins")
83
+ end
84
+
85
+ # Internal middleware boundary; never replaces the client's process logger.
86
+ def __with_logger(logger)
87
+ previous = @logger
88
+ @logger = logger
89
+ begin
90
+ yield
91
+ ensure
92
+ @logger = previous
93
+ end
94
+ end
95
+
96
+ # Returns a snapshot of persisted metadata merged with this attempt's changes.
97
+ def metadata = JSON.parse(JSON.generate(row.metadata.merge(@metadata_updates)))
98
+
99
+ # Returns a copy of metadata changes waiting to be persisted when work
100
+ # finishes.
101
+ def metadata_updates
102
+ JSON.parse(JSON.generate(@metadata_updates))
103
+ end
104
+
105
+ def method_missing(name, ...)
106
+ return row.public_send(name, ...) if row.respond_to?(name)
107
+
108
+ super
109
+ end
110
+
111
+ # Stores a worker result under the conventional +"output"+ metadata key.
112
+ def output=(value)
113
+ update_metadata("output" => value)
114
+ end
115
+
116
+ def respond_to_missing?(name, include_private = false)
117
+ row.respond_to?(name, include_private) || super
118
+ end
119
+
120
+ # Merges JSON-compatible values into the job metadata to be persisted when
121
+ # work finishes. Values are validated and copied immediately, so invalid JSON
122
+ # fails the work without also preventing its failure from being recorded.
123
+ # Keys are converted to strings. Returns self.
124
+ def update_metadata(values)
125
+ @metadata_updates.merge!(JSON.parse(JSON.generate(values.transform_keys(&:to_s))))
126
+ self
127
+ end
128
+ end
129
+
130
+ # Default retry schedule used when a worker does not provide its own policy.
131
+ class DefaultClientRetryPolicy
132
+ # Match Go's maximum signed 64-bit nanosecond duration exactly.
133
+ MAX_DELAY = Rational((1 << 63) - 1, 1_000_000_000)
134
+ private_constant :MAX_DELAY
135
+
136
+ # Creates River's default quartic-backoff retry policy. A Random source may
137
+ # be injected to make jitter deterministic.
138
+ def initialize(random: Random)
139
+ @random = random
140
+ end
141
+
142
+ # Returns the Time at which +job+ should next be attempted.
143
+ def next_retry(job, _error = nil, now: Time.now.utc)
144
+ error_count = Array(job.errors).length + 1
145
+ seconds = Integer(error_count**4)
146
+ return now + MAX_DELAY if seconds >= MAX_DELAY
147
+
148
+ delay = seconds + (seconds * (@random.rand * 0.2 - 0.1))
149
+ now + [delay, MAX_DELAY].min
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/wait"
4
+
5
+ module River
6
+ # Owns a dedicated worker process. Call run on its main thread, after boot.
7
+ # A supervisor should restart failed processes. An unresponsive stop ends
8
+ # the process with exit!(1), bypassing application at_exit handlers.
9
+ class WorkerRunner
10
+ # Grace and finalization deadlines are finite, nonnegative seconds.
11
+ def initialize(client, finalization_timeout: 5, out: $stdout, stop_timeout: 30)
12
+ @client = client
13
+ @finalization_timeout = Float(finalization_timeout, exception: true) #: Float
14
+ @out = out
15
+ @stop_timeout = Float(stop_timeout, exception: true) #: Float
16
+ [@finalization_timeout, @stop_timeout].each do |value|
17
+ raise ArgumentError, "stop deadlines must be finite and nonnegative" unless value.finite? && value >= 0
18
+ end
19
+ end
20
+
21
+ # Starts the client and blocks until stopped. Returns 0 for a clean drain,
22
+ # 1 for a runtime failure or interrupted stop. Restores signal handlers.
23
+ def run
24
+ raise ArgumentError, "worker runner must run on the main thread of the main Ractor" unless Thread.current == Thread.main && Ractor.current == Ractor.main
25
+ raise ArgumentError, "worker runner requires a stopped client" if @client.started?
26
+ raise ArgumentError, "configure at least one worker queue" if @client.config.queues.empty?
27
+
28
+ reader, writer = IO.pipe
29
+ handlers = {} #: Hash[String, untyped]
30
+ %w[INT TERM TSTP].each do |signal|
31
+ handlers[signal] = Signal.trap(signal) { writer.write_nonblock((signal == "TSTP") ? "Q" : "S", exception: false) }
32
+ end
33
+
34
+ begin
35
+ log("starting")
36
+ @client.job_list(JobListParams.new(limit: 1))
37
+ @client.start
38
+ log("ready pid=#{Process.pid}")
39
+
40
+ supervise(reader)
41
+ ensure
42
+ handlers.each { |signal, handler| Signal.trap(signal, handler) }
43
+ reader.close
44
+ writer.close
45
+ end
46
+ end
47
+
48
+ private def log(message)
49
+ @out.puts("River worker: #{message}")
50
+ @out.flush
51
+ end
52
+
53
+ private def monotonic_now
54
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
55
+ end
56
+
57
+ private def supervise(reader)
58
+ deadline = 0.0
59
+ failed = false
60
+ interrupted = false
61
+ stopper = nil #: Thread?
62
+ loop do
63
+ signals = reader.wait_readable(0.1) ? reader.read_nonblock(4_096) : ""
64
+ if signals.include?("Q")
65
+ @client.stop(wait: false)
66
+ log("stop requested; no new work will be fetched")
67
+ end
68
+
69
+ unless @client.__runtime_healthy?
70
+ log("runtime thread exited unexpectedly")
71
+ failed = true
72
+ end
73
+
74
+ if !stopper && (signals.include?("S") || failed)
75
+ @client.stop(wait: false)
76
+ log("draining active attempts")
77
+ deadline = monotonic_now + @stop_timeout
78
+ stopper = Thread.new { @client.stop }
79
+ stopper.report_on_exception = false
80
+ # Consume only the first stop request; a second escalates immediately.
81
+ signals = signals.sub("S", "")
82
+ end
83
+
84
+ next unless stopper
85
+
86
+ if stopper.join(0)
87
+ stopper.value
88
+ log("stopped")
89
+ return (failed || interrupted) ? 1 : 0
90
+ end
91
+
92
+ next unless signals.include?("S") || monotonic_now >= deadline
93
+
94
+ if interrupted
95
+ log("finalization deadline exceeded; forcing process exit")
96
+ Process.exit!(1)
97
+ end
98
+
99
+ interrupted = true
100
+ log("interrupting active attempts")
101
+ @client.__interrupt_workers
102
+ deadline = monotonic_now + @finalization_timeout
103
+ end
104
+ end
105
+ end
106
+ end