riverqueue 0.12.0 → 0.14.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.
data/lib/config.rb CHANGED
@@ -40,22 +40,52 @@ module River
40
40
  end
41
41
  end
42
42
 
43
+ # Lazily generates a client ID once per process. The process ID check
44
+ # regenerates it in forked children without needing a fork hook.
45
+ class DefaultClientID # :nodoc:
46
+ HOSTNAME_MAX_LENGTH = 80
47
+
48
+ def initialize
49
+ @mutex = Mutex.new
50
+ @state = nil
51
+ end
52
+
53
+ def value
54
+ pid = Process.pid
55
+ @state&.[](pid) || @mutex.synchronize { @state&.[](pid) || generate(pid) }
56
+ end
57
+
58
+ # The PID and ID are swapped in as one frozen hash so a lock-free reader
59
+ # never pairs one process's PID with another's ID.
60
+ private def generate(pid)
61
+ id = "#{Socket.gethostname[0, HOSTNAME_MAX_LENGTH]}-#{pid}-#{SecureRandom.hex(6)}".freeze
62
+ @state = {pid => id}.freeze
63
+ id
64
+ end
65
+ end
66
+ private_constant :DefaultClientID
67
+
43
68
  # Complete configuration for a River client and its worker runtime.
44
69
  class Config
45
70
  # Configured values used by Client and its runtime.
46
71
  attr_reader :cancelled_job_retention_period, :completed_job_retention_period,
47
72
  :discarded_job_retention_period, :error_handler, :fetch_cooldown,
48
- :fetch_only_known_kinds, :fetch_poll_interval, :id, :job_timeout,
73
+ :fetch_only_known_kinds, :fetch_poll_interval, :job_timeout,
49
74
  :leader_election_disabled, :logger, :maintenance_services,
50
- :periodic_jobs, :plugins, :queues, :retry_policy, :workers
75
+ :periodic_jobs, :plugins, :poll_only, :queues, :retry_policy, :workers
51
76
 
52
77
  # Creates a client configuration.
53
78
  #
54
79
  # Configure queues and workers here to enable job processing; a client with
55
80
  # 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.
81
+ # An explicit +id+ is used exactly as given and must be unique among all
82
+ # running clients. Without one, each process using this config generates
83
+ # its own ID; see #id. +fetch_only_known_kinds+ leaves unregistered kinds
84
+ # untouched in shared queues. +leader_election_disabled+ prevents this
85
+ # client's maintenance; another eligible client must run scheduling,
86
+ # rescue, and cleanup.
87
+ # +poll_only+ disables notification receiving, including leadership
88
+ # resignation requests. Job and queue state are still polled.
59
89
  def initialize(
60
90
  queues: {},
61
91
  workers: Workers.new,
@@ -70,6 +100,7 @@ module River
70
100
  plugins: [],
71
101
  maintenance_services: [],
72
102
  periodic_jobs: [],
103
+ poll_only: false,
73
104
  cancelled_job_retention_period: 86_400,
74
105
  completed_job_retention_period: 86_400,
75
106
  discarded_job_retention_period: 604_800,
@@ -82,13 +113,15 @@ module River
82
113
  @fetch_cooldown = Float(fetch_cooldown)
83
114
  @fetch_only_known_kinds = fetch_only_known_kinds
84
115
  @fetch_poll_interval = Float(fetch_poll_interval)
85
- @id = id || "#{Socket.gethostname}-#{Process.pid}-#{SecureRandom.hex(6)}"
116
+ @default_id = DefaultClientID.new
117
+ @id = id
86
118
  @job_timeout = job_timeout.nil? ? nil : Float(job_timeout)
87
119
  @leader_election_disabled = leader_election_disabled
88
120
  @logger = logger || Logger.new($stdout, level: Logger::WARN)
89
121
  @maintenance_services = maintenance_services.dup.freeze
90
122
  @periodic_jobs = periodic_jobs.dup.freeze
91
123
  @plugins = plugins.dup.freeze
124
+ @poll_only = poll_only
92
125
  @queues = normalize_queues(queues).freeze
93
126
  @retry_policy = retry_policy
94
127
  @workers = workers
@@ -96,10 +129,22 @@ module River
96
129
  validate
97
130
  end
98
131
 
132
+ # Returns the client ID recorded in +attempted_by+ and used for leadership.
133
+ #
134
+ # An explicitly configured ID is returned unchanged. Otherwise, the ID is
135
+ # generated on first use in each process and stays stable for the life of
136
+ # that process, so clients built from a config before a fork (for example,
137
+ # by a preloading Puma, Unicorn, or Pitchfork server) get distinct IDs in
138
+ # each forked child instead of sharing the parent's.
139
+ def id
140
+ @id || @default_id.value
141
+ end
142
+
99
143
  # Returns a new Config with the supplied values replacing this config's
100
- # corresponding settings.
144
+ # corresponding settings. Without an +id+ override, the copy shares this
145
+ # config's ID, including its per-process generated default.
101
146
  def with(**overrides)
102
- Config.new(id: id,
147
+ copy = Config.new(id: @id,
103
148
  cancelled_job_retention_period: cancelled_job_retention_period,
104
149
  completed_job_retention_period: completed_job_retention_period,
105
150
  discarded_job_retention_period: discarded_job_retention_period,
@@ -113,9 +158,16 @@ module River
113
158
  maintenance_services: maintenance_services,
114
159
  periodic_jobs: periodic_jobs,
115
160
  plugins: plugins,
161
+ poll_only: poll_only,
116
162
  queues: queues,
117
163
  retry_policy: retry_policy,
118
164
  workers: workers, **overrides)
165
+ copy.adopt_default_id(@default_id) unless overrides.key?(:id)
166
+ copy
167
+ end
168
+
169
+ protected def adopt_default_id(default_id)
170
+ @default_id = default_id
119
171
  end
120
172
 
121
173
  private def normalize_queues(queues)
@@ -136,7 +188,7 @@ module River
136
188
 
137
189
  private def validate
138
190
  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)
191
+ raise ArgumentError, "id must be between 1 and 127 characters" if (explicit_id = @id) && !(1...128).cover?(explicit_id.length)
140
192
  raise ArgumentError, "fetch_cooldown must be at least 0.001 seconds" if fetch_cooldown < 0.001
141
193
  raise ArgumentError, "fetch_poll_interval cannot be less than fetch_cooldown" if fetch_poll_interval < fetch_cooldown
142
194
  raise ArgumentError, "job_timeout must be greater than zero or nil" if job_timeout && job_timeout <= 0
@@ -23,7 +23,7 @@ module River::Driver
23
23
  def self.error_time(value)
24
24
  return ZERO_TIME unless value.is_a?(String)
25
25
 
26
- # Keep Ruby's existing acceptance of PostgreSQL-style timestamps too.
26
+ # Keep Ruby's existing acceptance of Postgres-style timestamps too.
27
27
  Time.parse(value).utc
28
28
  rescue ArgumentError
29
29
  ZERO_TIME
@@ -63,7 +63,7 @@ module River::Driver
63
63
  end
64
64
 
65
65
  def decoded(field, value, type: nil, strings: false, default: nil)
66
- # Sequel's PostgreSQL JSON and array wrappers delegate to Ruby values.
66
+ # Sequel's Postgres JSON and array wrappers delegate to Ruby values.
67
67
  value = value.__getobj__ if value.respond_to?(:__getobj__)
68
68
  return default if value.nil?
69
69
 
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module River::Driver
4
+ # Internal notification transports. Each client owns its listener; SQLite
5
+ # cursors never consume or delete another client's notifications.
6
+ module NotificationListener
7
+ TOPICS = %w[river_control river_insert river_leadership].freeze
8
+
9
+ class Postgres
10
+ def initialize(connection, schema)
11
+ @connection = connection
12
+ @channels = TOPICS.to_h { |topic| ["#{schema}.#{topic}", topic] }
13
+ @channels.each_key { |channel| @connection.exec("LISTEN #{@connection.escape_identifier(channel)}") }
14
+ rescue
15
+ close
16
+ raise
17
+ end
18
+
19
+ def close
20
+ @connection.close unless @connection.finished?
21
+ end
22
+
23
+ def poll(timeout)
24
+ notifications = [] #: Array[untyped]
25
+ @connection.wait_for_notify(timeout) do |channel, _pid, payload|
26
+ notifications << [@channels[channel], payload]
27
+ end
28
+ # Bound each batch so a busy sender cannot prevent shutdown.
29
+ while notifications.length < 1_000 && (notification = @connection.notifies)
30
+ notifications << [@channels[notification[:relname]], notification[:extra]]
31
+ end
32
+ notifications
33
+ end
34
+ end
35
+
36
+ class SQLite
37
+ def initialize(query)
38
+ @query = query
39
+ # Historical requests must not resign a newly started client.
40
+ @last_id = @query.call("SELECT coalesce(max(id), 0) AS id FROM river_notification").first.transform_keys(&:to_sym).fetch(:id).to_i
41
+ end
42
+
43
+ def close
44
+ end
45
+
46
+ def poll(timeout)
47
+ rows = @query.call("SELECT id, topic, payload FROM river_notification WHERE id > #{@last_id} ORDER BY id LIMIT 1000")
48
+ if rows.empty?
49
+ sleep(timeout)
50
+ return []
51
+ end
52
+
53
+ rows.map do |row|
54
+ row = row.transform_keys(&:to_sym)
55
+ @last_id = row.fetch(:id).to_i
56
+ [row.fetch(:topic), row.fetch(:payload)]
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module River::Driver
4
- # PostgreSQL-compatible servers differ in conflict detection and notification
4
+ # Postgres-compatible servers differ in conflict detection and notification
5
5
  # support. Keep these rules aligned with Go's PostgresCapabilities.
6
6
  class PostgresCapabilities
7
7
  attr_reader :supports_listen_notify, :unique_insert_mode, :unique_insert_sql