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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +132 -0
- data/LICENSE +374 -0
- data/docs/README.md +765 -0
- data/docs/migrations.md +121 -0
- data/docs/testing.md +173 -0
- data/docs/workers.md +126 -0
- data/exe/river +7 -0
- data/lib/cli.rb +55 -0
- data/lib/client.rb +314 -54
- data/lib/client_runtime.rb +612 -0
- data/lib/config.rb +154 -0
- data/lib/driver/job_row_decoder.rb +85 -0
- data/lib/driver/postgres_capabilities.rb +41 -0
- data/lib/driver/runtime.rb +661 -0
- data/lib/driver.rb +13 -6
- data/lib/errors.rb +83 -0
- data/lib/event.rb +78 -0
- data/lib/insert_opts.rb +40 -11
- data/lib/job.rb +28 -13
- data/lib/job_persisted_logging/plugin.rb +108 -0
- data/lib/migration_cli.rb +90 -0
- data/lib/migrator.rb +189 -0
- data/lib/params.rb +96 -0
- data/lib/periodic_cron.rb +24 -0
- data/lib/periodic_job.rb +135 -0
- data/lib/resumable.rb +148 -0
- data/lib/riverqueue/testing/minitest.rb +18 -0
- data/lib/riverqueue/testing/rspec.rb +109 -0
- data/lib/riverqueue/testing.rb +134 -0
- data/lib/riverqueue.rb +16 -0
- data/lib/unique_args.rb +97 -0
- data/lib/unique_bitmask.rb +14 -12
- data/lib/worker.rb +152 -0
- data/lib/worker_runner.rb +106 -0
- data/migration/LICENSE +374 -0
- data/migration/README.md +11 -0
- data/migration/manifest.json +38 -0
- data/migration/postgresql/main/001_create_river_migration.down.sql +1 -0
- data/migration/postgresql/main/001_create_river_migration.up.sql +8 -0
- data/migration/postgresql/main/002_initial_schema.down.sql +5 -0
- data/migration/postgresql/main/002_initial_schema.up.sql +96 -0
- data/migration/postgresql/main/003_river_job_tags_non_null.down.sql +3 -0
- data/migration/postgresql/main/003_river_job_tags_non_null.up.sql +3 -0
- data/migration/postgresql/main/004_pending_and_more.down.sql +42 -0
- data/migration/postgresql/main/004_pending_and_more.up.sql +45 -0
- data/migration/postgresql/main/005_migration_unique_client.down.sql +57 -0
- data/migration/postgresql/main/005_migration_unique_client.up.sql +79 -0
- data/migration/postgresql/main/006_bulk_unique.down.sql +16 -0
- data/migration/postgresql/main/006_bulk_unique.up.sql +40 -0
- data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +56 -0
- data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +44 -0
- data/migration/postgresql/main/008_job_id_autoincrement.down.sql +3 -0
- data/migration/postgresql/main/008_job_id_autoincrement.up.sql +3 -0
- data/migration/sqlite/main/001_create_river_migration.down.sql +1 -0
- data/migration/sqlite/main/001_create_river_migration.up.sql +8 -0
- data/migration/sqlite/main/002_initial_schema.down.sql +8 -0
- data/migration/sqlite/main/002_initial_schema.up.sql +19 -0
- data/migration/sqlite/main/003_river_job_tags_non_null.down.sql +6 -0
- data/migration/sqlite/main/003_river_job_tags_non_null.up.sql +6 -0
- data/migration/sqlite/main/004_pending_and_more.down.sql +26 -0
- data/migration/sqlite/main/004_pending_and_more.up.sql +33 -0
- data/migration/sqlite/main/005_migration_unique_client.down.sql +37 -0
- data/migration/sqlite/main/005_migration_unique_client.up.sql +64 -0
- data/migration/sqlite/main/006_bulk_unique.down.sql +7 -0
- data/migration/sqlite/main/006_bulk_unique.up.sql +63 -0
- data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +255 -0
- data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +261 -0
- data/migration/sqlite/main/008_job_id_autoincrement.down.sql +121 -0
- data/migration/sqlite/main/008_job_id_autoincrement.up.sql +123 -0
- data/sig/client.rbs +95 -0
- data/sig/driver.rbs +170 -0
- data/sig/insert_opts.rbs +25 -0
- data/sig/job.rbs +72 -0
- data/sig/job_persisted_logging/plugin.rbs +29 -0
- data/sig/migrator.rbs +44 -0
- data/sig/riverqueue.rbs +2 -0
- data/sig/runtime.rbs +382 -0
- data/sig/testing.rbs +95 -0
- data/sig/unique_args.rbs +8 -0
- data/sig/unique_bitmask.rbs +8 -0
- data/sig/worker_runner.rbs +22 -0
- metadata +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
|
data/lib/unique_args.rb
ADDED
|
@@ -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
|
data/lib/unique_bitmask.rb
CHANGED
|
@@ -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
|