riverqueue 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +132 -0
  3. data/LICENSE +374 -0
  4. data/docs/README.md +765 -0
  5. data/docs/migrations.md +121 -0
  6. data/docs/testing.md +173 -0
  7. data/docs/workers.md +126 -0
  8. data/exe/river +7 -0
  9. data/lib/cli.rb +55 -0
  10. data/lib/client.rb +314 -54
  11. data/lib/client_runtime.rb +612 -0
  12. data/lib/config.rb +154 -0
  13. data/lib/driver/job_row_decoder.rb +85 -0
  14. data/lib/driver/postgres_capabilities.rb +41 -0
  15. data/lib/driver/runtime.rb +661 -0
  16. data/lib/driver.rb +13 -6
  17. data/lib/errors.rb +83 -0
  18. data/lib/event.rb +78 -0
  19. data/lib/insert_opts.rb +40 -11
  20. data/lib/job.rb +28 -13
  21. data/lib/job_persisted_logging/plugin.rb +108 -0
  22. data/lib/migration_cli.rb +90 -0
  23. data/lib/migrator.rb +189 -0
  24. data/lib/params.rb +96 -0
  25. data/lib/periodic_cron.rb +24 -0
  26. data/lib/periodic_job.rb +135 -0
  27. data/lib/resumable.rb +148 -0
  28. data/lib/riverqueue/testing/minitest.rb +18 -0
  29. data/lib/riverqueue/testing/rspec.rb +109 -0
  30. data/lib/riverqueue/testing.rb +134 -0
  31. data/lib/riverqueue.rb +16 -0
  32. data/lib/unique_args.rb +97 -0
  33. data/lib/unique_bitmask.rb +14 -12
  34. data/lib/worker.rb +152 -0
  35. data/lib/worker_runner.rb +106 -0
  36. data/migration/LICENSE +374 -0
  37. data/migration/README.md +11 -0
  38. data/migration/manifest.json +38 -0
  39. data/migration/postgresql/main/001_create_river_migration.down.sql +1 -0
  40. data/migration/postgresql/main/001_create_river_migration.up.sql +8 -0
  41. data/migration/postgresql/main/002_initial_schema.down.sql +5 -0
  42. data/migration/postgresql/main/002_initial_schema.up.sql +96 -0
  43. data/migration/postgresql/main/003_river_job_tags_non_null.down.sql +3 -0
  44. data/migration/postgresql/main/003_river_job_tags_non_null.up.sql +3 -0
  45. data/migration/postgresql/main/004_pending_and_more.down.sql +42 -0
  46. data/migration/postgresql/main/004_pending_and_more.up.sql +45 -0
  47. data/migration/postgresql/main/005_migration_unique_client.down.sql +57 -0
  48. data/migration/postgresql/main/005_migration_unique_client.up.sql +79 -0
  49. data/migration/postgresql/main/006_bulk_unique.down.sql +16 -0
  50. data/migration/postgresql/main/006_bulk_unique.up.sql +40 -0
  51. data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +56 -0
  52. data/migration/postgresql/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +44 -0
  53. data/migration/postgresql/main/008_job_id_autoincrement.down.sql +3 -0
  54. data/migration/postgresql/main/008_job_id_autoincrement.up.sql +3 -0
  55. data/migration/sqlite/main/001_create_river_migration.down.sql +1 -0
  56. data/migration/sqlite/main/001_create_river_migration.up.sql +8 -0
  57. data/migration/sqlite/main/002_initial_schema.down.sql +8 -0
  58. data/migration/sqlite/main/002_initial_schema.up.sql +19 -0
  59. data/migration/sqlite/main/003_river_job_tags_non_null.down.sql +6 -0
  60. data/migration/sqlite/main/003_river_job_tags_non_null.up.sql +6 -0
  61. data/migration/sqlite/main/004_pending_and_more.down.sql +26 -0
  62. data/migration/sqlite/main/004_pending_and_more.up.sql +33 -0
  63. data/migration/sqlite/main/005_migration_unique_client.down.sql +37 -0
  64. data/migration/sqlite/main/005_migration_unique_client.up.sql +64 -0
  65. data/migration/sqlite/main/006_bulk_unique.down.sql +7 -0
  66. data/migration/sqlite/main/006_bulk_unique.up.sql +63 -0
  67. data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.down.sql +255 -0
  68. data/migration/sqlite/main/007_notification_outbox_sqlite_jsonb_and_sql_cleanup.up.sql +261 -0
  69. data/migration/sqlite/main/008_job_id_autoincrement.down.sql +121 -0
  70. data/migration/sqlite/main/008_job_id_autoincrement.up.sql +123 -0
  71. data/sig/client.rbs +95 -0
  72. data/sig/driver.rbs +170 -0
  73. data/sig/insert_opts.rbs +25 -0
  74. data/sig/job.rbs +72 -0
  75. data/sig/job_persisted_logging/plugin.rbs +29 -0
  76. data/sig/migrator.rbs +44 -0
  77. data/sig/riverqueue.rbs +2 -0
  78. data/sig/runtime.rbs +382 -0
  79. data/sig/testing.rbs +95 -0
  80. data/sig/unique_args.rbs +8 -0
  81. data/sig/unique_bitmask.rbs +8 -0
  82. data/sig/worker_runner.rbs +22 -0
  83. metadata +165 -10
data/lib/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