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
data/lib/config.rb
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "socket"
|
|
6
|
+
|
|
7
|
+
module River
|
|
8
|
+
FETCH_COOLDOWN_DEFAULT = 0.1
|
|
9
|
+
FETCH_POLL_INTERVAL_DEFAULT = 1.0
|
|
10
|
+
JOB_TIMEOUT_DEFAULT = 60.0
|
|
11
|
+
QUEUE_NAME_REGEX = /\A[a-zA-Z0-9_\-:.]+\z/
|
|
12
|
+
QUEUE_NUM_WORKERS_MAX = 10_000
|
|
13
|
+
|
|
14
|
+
# Concurrency and polling behavior for one configured queue.
|
|
15
|
+
class QueueConfig
|
|
16
|
+
# Normalized concurrency and polling settings for the queue.
|
|
17
|
+
attr_reader :fetch_cooldown, :fetch_poll_interval, :max_workers
|
|
18
|
+
|
|
19
|
+
# Configures worker concurrency and optional polling behavior for one queue.
|
|
20
|
+
def initialize(max_workers:, fetch_cooldown: nil, fetch_poll_interval: nil)
|
|
21
|
+
@fetch_cooldown = fetch_cooldown.nil? ? nil : Float(fetch_cooldown)
|
|
22
|
+
@fetch_poll_interval = fetch_poll_interval.nil? ? nil : Float(fetch_poll_interval)
|
|
23
|
+
@max_workers = Integer(max_workers)
|
|
24
|
+
|
|
25
|
+
raise ArgumentError, "fetch intervals must be finite" unless [@fetch_cooldown, @fetch_poll_interval].compact.all?(&:finite?)
|
|
26
|
+
raise ArgumentError, "max_workers must be between 1 and #{QUEUE_NUM_WORKERS_MAX}" unless (1..QUEUE_NUM_WORKERS_MAX).cover?(@max_workers)
|
|
27
|
+
raise ArgumentError, "fetch_cooldown must be zero or greater" if @fetch_cooldown&.negative?
|
|
28
|
+
raise ArgumentError, "fetch_poll_interval must be zero or greater" if @fetch_poll_interval&.negative?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def resolved_fetch_cooldown(config)
|
|
32
|
+
fetch_cooldown || config.fetch_cooldown
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def resolved_fetch_poll_interval(config)
|
|
36
|
+
value = fetch_poll_interval || config.fetch_poll_interval
|
|
37
|
+
raise ArgumentError, "fetch_poll_interval cannot be less than fetch_cooldown" if value < resolved_fetch_cooldown(config)
|
|
38
|
+
|
|
39
|
+
value
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Complete configuration for a River client and its worker runtime.
|
|
44
|
+
class Config
|
|
45
|
+
# Configured values used by Client and its runtime.
|
|
46
|
+
attr_reader :cancelled_job_retention_period, :completed_job_retention_period,
|
|
47
|
+
:discarded_job_retention_period, :error_handler, :fetch_cooldown,
|
|
48
|
+
:fetch_only_known_kinds, :fetch_poll_interval, :id, :job_timeout,
|
|
49
|
+
:leader_election_disabled, :logger, :maintenance_services,
|
|
50
|
+
:periodic_jobs, :plugins, :queues, :retry_policy, :workers
|
|
51
|
+
|
|
52
|
+
# Creates a client configuration.
|
|
53
|
+
#
|
|
54
|
+
# Configure queues and workers here to enable job processing; a client with
|
|
55
|
+
# neither may still be used for insertion and administrative operations.
|
|
56
|
+
# +fetch_only_known_kinds+ leaves unregistered kinds untouched in shared
|
|
57
|
+
# queues. +leader_election_disabled+ prevents this client's maintenance;
|
|
58
|
+
# another eligible client must run scheduling, rescue, and cleanup.
|
|
59
|
+
def initialize(
|
|
60
|
+
queues: {},
|
|
61
|
+
workers: Workers.new,
|
|
62
|
+
id: nil,
|
|
63
|
+
fetch_cooldown: FETCH_COOLDOWN_DEFAULT,
|
|
64
|
+
fetch_only_known_kinds: false,
|
|
65
|
+
fetch_poll_interval: FETCH_POLL_INTERVAL_DEFAULT,
|
|
66
|
+
job_timeout: JOB_TIMEOUT_DEFAULT,
|
|
67
|
+
leader_election_disabled: false,
|
|
68
|
+
retry_policy: DefaultClientRetryPolicy.new,
|
|
69
|
+
error_handler: nil,
|
|
70
|
+
plugins: [],
|
|
71
|
+
maintenance_services: [],
|
|
72
|
+
periodic_jobs: [],
|
|
73
|
+
cancelled_job_retention_period: 86_400,
|
|
74
|
+
completed_job_retention_period: 86_400,
|
|
75
|
+
discarded_job_retention_period: 604_800,
|
|
76
|
+
logger: nil
|
|
77
|
+
)
|
|
78
|
+
@cancelled_job_retention_period = retention(cancelled_job_retention_period)
|
|
79
|
+
@completed_job_retention_period = retention(completed_job_retention_period)
|
|
80
|
+
@discarded_job_retention_period = retention(discarded_job_retention_period)
|
|
81
|
+
@error_handler = error_handler
|
|
82
|
+
@fetch_cooldown = Float(fetch_cooldown)
|
|
83
|
+
@fetch_only_known_kinds = fetch_only_known_kinds
|
|
84
|
+
@fetch_poll_interval = Float(fetch_poll_interval)
|
|
85
|
+
@id = id || "#{Socket.gethostname}-#{Process.pid}-#{SecureRandom.hex(6)}"
|
|
86
|
+
@job_timeout = job_timeout.nil? ? nil : Float(job_timeout)
|
|
87
|
+
@leader_election_disabled = leader_election_disabled
|
|
88
|
+
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
|
89
|
+
@maintenance_services = maintenance_services.dup.freeze
|
|
90
|
+
@periodic_jobs = periodic_jobs.dup.freeze
|
|
91
|
+
@plugins = plugins.dup.freeze
|
|
92
|
+
@queues = normalize_queues(queues).freeze
|
|
93
|
+
@retry_policy = retry_policy
|
|
94
|
+
@workers = workers
|
|
95
|
+
|
|
96
|
+
validate
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns a new Config with the supplied values replacing this config's
|
|
100
|
+
# corresponding settings.
|
|
101
|
+
def with(**overrides)
|
|
102
|
+
Config.new(id: id,
|
|
103
|
+
cancelled_job_retention_period: cancelled_job_retention_period,
|
|
104
|
+
completed_job_retention_period: completed_job_retention_period,
|
|
105
|
+
discarded_job_retention_period: discarded_job_retention_period,
|
|
106
|
+
error_handler: error_handler,
|
|
107
|
+
fetch_cooldown: fetch_cooldown,
|
|
108
|
+
fetch_only_known_kinds: fetch_only_known_kinds,
|
|
109
|
+
fetch_poll_interval: fetch_poll_interval,
|
|
110
|
+
job_timeout: job_timeout,
|
|
111
|
+
leader_election_disabled: leader_election_disabled,
|
|
112
|
+
logger: logger,
|
|
113
|
+
maintenance_services: maintenance_services,
|
|
114
|
+
periodic_jobs: periodic_jobs,
|
|
115
|
+
plugins: plugins,
|
|
116
|
+
queues: queues,
|
|
117
|
+
retry_policy: retry_policy,
|
|
118
|
+
workers: workers, **overrides)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private def normalize_queues(queues)
|
|
122
|
+
queues.to_h do |name, queue_config|
|
|
123
|
+
config = queue_config.is_a?(QueueConfig) ? queue_config : QueueConfig.new(max_workers: queue_config)
|
|
124
|
+
[name.to_s, config]
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private def retention(value)
|
|
129
|
+
return nil if value.nil? || value == -1
|
|
130
|
+
|
|
131
|
+
seconds = Float(value)
|
|
132
|
+
raise ArgumentError, "retention must be finite and nonnegative, or nil/-1 to disable" unless seconds.finite? && seconds >= 0
|
|
133
|
+
|
|
134
|
+
seconds
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private def validate
|
|
138
|
+
raise ArgumentError, "fetch intervals and job_timeout must be finite" unless [fetch_cooldown, fetch_poll_interval, job_timeout].compact.all?(&:finite?)
|
|
139
|
+
raise ArgumentError, "id must be between 1 and 127 characters" unless (1...128).cover?(id.length)
|
|
140
|
+
raise ArgumentError, "fetch_cooldown must be at least 0.001 seconds" if fetch_cooldown < 0.001
|
|
141
|
+
raise ArgumentError, "fetch_poll_interval cannot be less than fetch_cooldown" if fetch_poll_interval < fetch_cooldown
|
|
142
|
+
raise ArgumentError, "job_timeout must be greater than zero or nil" if job_timeout && job_timeout <= 0
|
|
143
|
+
raise ArgumentError, "retry_policy must respond to next_retry" unless retry_policy.respond_to?(:next_retry)
|
|
144
|
+
raise ArgumentError, "workers must be a River::Workers" unless workers.is_a?(Workers)
|
|
145
|
+
raise ArgumentError, "periodic_jobs must be empty when leader_election_disabled is true" if leader_election_disabled && !periodic_jobs.empty?
|
|
146
|
+
|
|
147
|
+
queues.each do |name, queue_config|
|
|
148
|
+
raise ArgumentError, "invalid queue name: #{name.inspect}" unless name.match?(QUEUE_NAME_REGEX) && name.length < 128
|
|
149
|
+
|
|
150
|
+
queue_config.resolved_fetch_poll_interval(self)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module River::Driver
|
|
6
|
+
# Tolerance applies only to database reads, not application JSON encoding.
|
|
7
|
+
# Historical error entries must not make an otherwise valid job unreadable.
|
|
8
|
+
class JobRowDecoder
|
|
9
|
+
ZERO_TIME = Time.utc(1).freeze
|
|
10
|
+
private_constant :ZERO_TIME
|
|
11
|
+
|
|
12
|
+
def self.attempt_error(value)
|
|
13
|
+
value = value.__getobj__ if value.respond_to?(:__getobj__)
|
|
14
|
+
fields = value.is_a?(Hash) ? value : {"error" => value}
|
|
15
|
+
River::AttemptError.new(
|
|
16
|
+
at: error_time(fields["at"]),
|
|
17
|
+
attempt: error_integer(fields["attempt"]),
|
|
18
|
+
error: error_string(fields["error"]),
|
|
19
|
+
trace: error_string(fields["trace"])
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.error_time(value)
|
|
24
|
+
return ZERO_TIME unless value.is_a?(String)
|
|
25
|
+
|
|
26
|
+
# Keep Ruby's existing acceptance of PostgreSQL-style timestamps too.
|
|
27
|
+
Time.parse(value).utc
|
|
28
|
+
rescue ArgumentError
|
|
29
|
+
ZERO_TIME
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.error_integer(value)
|
|
33
|
+
return value if value.is_a?(Integer)
|
|
34
|
+
|
|
35
|
+
if value.is_a?(String)
|
|
36
|
+
integer = Integer(value, 10, exception: false)
|
|
37
|
+
return integer if integer
|
|
38
|
+
|
|
39
|
+
value = Float(value, exception: false)
|
|
40
|
+
end
|
|
41
|
+
(value.is_a?(Float) && value.finite? && value.abs <= 2**53 && value == value.truncate) ? value.to_i : 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.error_string(value)
|
|
45
|
+
case value
|
|
46
|
+
when nil then ""
|
|
47
|
+
when String then value
|
|
48
|
+
else JSON.generate(value)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def initialize
|
|
53
|
+
@errors = []
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def json(field, raw, type: nil, strings: false, default: nil)
|
|
57
|
+
return default if raw.nil?
|
|
58
|
+
|
|
59
|
+
decoded(field, JSON.parse(raw), type: type, strings: strings, default: default)
|
|
60
|
+
rescue JSON::ParserError, TypeError => error
|
|
61
|
+
@errors << "#{field}: #{error.message}"
|
|
62
|
+
default
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def decoded(field, value, type: nil, strings: false, default: nil)
|
|
66
|
+
# Sequel's PostgreSQL JSON and array wrappers delegate to Ruby values.
|
|
67
|
+
value = value.__getobj__ if value.respond_to?(:__getobj__)
|
|
68
|
+
return default if value.nil?
|
|
69
|
+
|
|
70
|
+
raise TypeError, "expected #{type}" if type && !value.is_a?(type)
|
|
71
|
+
raise TypeError, "expected an array of strings" if strings && !value.all? { |item| item.is_a?(String) }
|
|
72
|
+
|
|
73
|
+
value
|
|
74
|
+
rescue TypeError => error
|
|
75
|
+
@errors << "#{field}: #{error.message}"
|
|
76
|
+
default
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def finish(job)
|
|
80
|
+
raise River::JobRowDecodeError.new(job, @errors.join("; ")) unless @errors.empty?
|
|
81
|
+
|
|
82
|
+
job
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module River::Driver
|
|
4
|
+
# PostgreSQL-compatible servers differ in conflict detection and notification
|
|
5
|
+
# support. Keep these rules aligned with Go's PostgresCapabilities.
|
|
6
|
+
class PostgresCapabilities
|
|
7
|
+
attr_reader :supports_listen_notify, :unique_insert_mode, :unique_insert_sql
|
|
8
|
+
|
|
9
|
+
def initialize(product:, version_num:, yb_listen_notify_enabled:)
|
|
10
|
+
product = product.downcase
|
|
11
|
+
yugabyte = product.include?("-yb") || product.include?("yugabyte")
|
|
12
|
+
@supports_listen_notify = !yugabyte || yb_listen_notify_enabled
|
|
13
|
+
@unique_insert_mode, @unique_insert_sql = if yugabyte
|
|
14
|
+
[:metadata_nonce, "false"]
|
|
15
|
+
elsif version_num >= 180_000
|
|
16
|
+
[:returning_old, "(OLD.id IS NOT NULL)"]
|
|
17
|
+
else
|
|
18
|
+
[:xmax, "(xmax != 0)"]
|
|
19
|
+
end
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Scope detection to a pool: Active Record may route one driver to different
|
|
24
|
+
# roles/shards. Failures are never cached, and no lock spans database I/O:
|
|
25
|
+
# another caller may already hold the pool's only connection.
|
|
26
|
+
class Cache
|
|
27
|
+
def initialize
|
|
28
|
+
@mutex = Mutex.new
|
|
29
|
+
@values = {}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def fetch(pool)
|
|
33
|
+
cached = @mutex.synchronize { @values[pool] }
|
|
34
|
+
return cached if cached
|
|
35
|
+
|
|
36
|
+
detected = yield
|
|
37
|
+
@mutex.synchronize { @values[pool] ||= detected }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|