pgbus 0.9.10 → 0.10.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -7
  3. data/README.md +34 -7
  4. data/Rakefile +22 -1
  5. data/app/models/pgbus/stream_queue.rb +87 -0
  6. data/lib/generators/pgbus/add_stream_queues_generator.rb +50 -0
  7. data/lib/generators/pgbus/install_generator.rb +3 -3
  8. data/lib/generators/pgbus/templates/add_stream_queues.rb.erb +11 -0
  9. data/lib/generators/pgbus/templates/initializer.rb.erb +62 -0
  10. data/lib/generators/pgbus/templates/migration.rb.erb +14 -0
  11. data/lib/generators/pgbus/update_generator.rb +6 -66
  12. data/lib/pgbus/client/ensure_stream_queue.rb +15 -1
  13. data/lib/pgbus/client/read_after.rb +7 -6
  14. data/lib/pgbus/client/resizable_pool.rb +160 -0
  15. data/lib/pgbus/client.rb +261 -4
  16. data/lib/pgbus/configuration.rb +244 -11
  17. data/lib/pgbus/doctor.rb +73 -2
  18. data/lib/pgbus/engine.rb +30 -3
  19. data/lib/pgbus/generators/migration_detector.rb +7 -0
  20. data/lib/pgbus/process/dispatcher.rb +89 -19
  21. data/lib/pgbus/process/notify_listener.rb +18 -2
  22. data/lib/pgbus/process/worker.rb +19 -3
  23. data/lib/pgbus/streams/pool_autoscaler.rb +202 -0
  24. data/lib/pgbus/streams/pool_trigger.rb +124 -0
  25. data/lib/pgbus/streams/turbo_broadcastable.rb +23 -0
  26. data/lib/pgbus/streams.rb +2 -2
  27. data/lib/pgbus/version.rb +1 -1
  28. data/lib/pgbus/web/streamer/connection.rb +22 -6
  29. data/lib/pgbus/web/streamer/falcon_connection.rb +17 -5
  30. data/lib/pgbus/web/streamer/instance.rb +75 -5
  31. data/lib/pgbus/web/streamer/io_writer.rb +11 -5
  32. data/lib/pgbus/web/streamer/listener.rb +61 -1
  33. data/lib/pgbus/web/streamer/outbound_pump.rb +260 -0
  34. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +89 -7
  35. metadata +9 -4
  36. data/lib/generators/pgbus/templates/pgbus.yml.erb +0 -76
  37. data/lib/pgbus/config_loader.rb +0 -73
  38. data/lib/pgbus/generators/config_converter.rb +0 -345
@@ -1,345 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
-
5
- module Pgbus
6
- module Generators
7
- # Converts a +config/pgbus.yml+ file into a Ruby initializer
8
- # (+config/initializers/pgbus.rb+) using the Ruby DSL added in
9
- # Pgbus 0.5+ — capsule string DSL, ActiveSupport::Duration coercion,
10
- # auto-tuned pool size, named capsules, etc.
11
- #
12
- # Drops settings that:
13
- # - match the gem default (no point restating it)
14
- # - are deprecated (settings that no longer exist in the public API —
15
- # see DEPRECATED_SETTINGS; pool_size is NOT one of these, it's a
16
- # live accessor that overrides auto-tuning and is preserved)
17
- #
18
- # Converts seconds to durations when they evenly divide into a clean
19
- # unit (7 days, 30 days, 10 minutes). Falls back to the raw integer
20
- # otherwise.
21
- #
22
- # When the YAML has multiple environments with different values for
23
- # the same setting, emits Rails.env-aware code:
24
- #
25
- # - 2 envs with same value → unconditional line
26
- # - 2 envs, one differs → `unless Rails.env.X?` modifier
27
- # - 3+ envs with differences → `case Rails.env when ... end`
28
- #
29
- # Backwards compatible: the original YAML file is NOT touched. The
30
- # generator's CLI wrapper writes the new initializer and tells the
31
- # user to delete the YAML when ready.
32
- class ConfigConverter
33
- class Error < Pgbus::Error; end
34
-
35
- # Setters that accept ActiveSupport::Duration (PR 5).
36
- DURATION_SETTINGS = %w[
37
- visibility_timeout archive_retention idempotency_ttl
38
- outbox_retention stats_retention recurring_execution_retention
39
- ].freeze
40
-
41
- # Settings that no longer exist in the public API. The converter
42
- # silently drops these from the generated initializer so users on
43
- # legacy YAML get a clean migration. NOTE: pool_size is intentionally
44
- # NOT here — it's a live Configuration#pool_size accessor users set to
45
- # override auto-tuning, so it must survive the conversion unchanged.
46
- #
47
- # - notify_throttle_ms -> Pgbus::Client::NOTIFY_THROTTLE_MS
48
- # - circuit_breaker_* -> Pgbus::CircuitBreaker constants
49
- # - archive_compaction_* -> Pgbus::Process::Dispatcher constants
50
- # - dead_letter_queue_suffix -> Pgbus::DEAD_LETTER_SUFFIX (frozen)
51
- DEPRECATED_SETTINGS = %w[
52
- notify_throttle_ms
53
- circuit_breaker_threshold circuit_breaker_base_backoff circuit_breaker_max_backoff
54
- archive_compaction_interval archive_compaction_batch_size
55
- dead_letter_queue_suffix
56
- ].freeze
57
-
58
- # Settings whose default we know how to compute by inspecting
59
- # Pgbus::Configuration.new. Any setting not listed here is emitted
60
- # as-is (we can't tell if it matches the default).
61
- KNOWN_SETTINGS = %w[
62
- queue_prefix default_queue pool_timeout listen_notify
63
- visibility_timeout max_retries idempotency_ttl
64
- max_jobs_per_worker max_memory_mb max_worker_lifetime
65
- dispatch_interval prefetch_limit
66
- circuit_breaker_enabled
67
- archive_retention
68
- outbox_enabled outbox_poll_interval outbox_batch_size outbox_retention
69
- stats_enabled stats_retention
70
- recurring_schedule_interval recurring_execution_retention skip_recurring
71
- polling_interval default_priority priority_levels
72
- return_to_app_url workers
73
- ].freeze
74
-
75
- def self.from_yaml(path)
76
- raise Error, "config file not found: #{path}" unless File.exist?(path)
77
-
78
- parsed = YAML.safe_load_file(path, aliases: true, permitted_classes: [Symbol])
79
- from_hash(parsed)
80
- end
81
-
82
- def self.from_hash(envs_hash)
83
- new(envs_hash).render
84
- end
85
-
86
- def initialize(envs_hash)
87
- @envs = (envs_hash || {}).reject { |env, _| env.start_with?("default") }
88
- @envs = { "production" => envs_hash } if @envs.empty? && envs_hash
89
- @defaults = build_defaults
90
- end
91
-
92
- def render
93
- lines = []
94
- lines << "# frozen_string_literal: true"
95
- lines << "#"
96
- lines << "# Generated by `rails generate pgbus:update` from config/pgbus.yml."
97
- lines << "# Review and adjust as needed, then delete config/pgbus.yml."
98
- lines << ""
99
- lines << "Pgbus.configure do |c|"
100
-
101
- body = render_body
102
- body.each { |line| lines << " #{line}" }
103
-
104
- lines << "end"
105
- "#{lines.join("\n")}\n"
106
- end
107
-
108
- private
109
-
110
- def build_defaults
111
- config = Pgbus::Configuration.new
112
- KNOWN_SETTINGS.each_with_object({}) do |key, h|
113
- h[key] = config.public_send(key) if config.respond_to?(key)
114
- end
115
- end
116
-
117
- def render_body
118
- all_settings = collect_all_settings
119
- return [] if all_settings.empty?
120
-
121
- constant_settings, varying_settings = partition_by_variance(all_settings)
122
- special_keys = %w[workers]
123
-
124
- lines = []
125
- # Special: workers comes first (most user-visible)
126
- special_keys.each do |key|
127
- rendered = render_workers(constant_settings[key], varying_settings[key])
128
- lines.concat(rendered) if rendered
129
- end
130
-
131
- constant_settings.each do |key, value|
132
- next if special_keys.include?(key)
133
- next if drop?(key, value)
134
-
135
- lines << render_setting(key, value)
136
- end
137
-
138
- varying_settings.each do |key, env_values|
139
- next if special_keys.include?(key)
140
- next if env_values.values.all? { |v| drop?(key, v) }
141
-
142
- lines.concat(render_varying_setting(key, env_values))
143
- end
144
-
145
- lines
146
- end
147
-
148
- def collect_all_settings
149
- all_keys = @envs.values.flat_map { |env_settings| env_settings&.keys || [] }.uniq
150
- all_keys.to_h do |key|
151
- [key, @envs.transform_values { |env_settings| env_settings&.fetch(key, :__missing__) }]
152
- end
153
- end
154
-
155
- # Returns [constant_settings, varying_settings].
156
- # constant_settings: { "key" => value } (same value in EVERY env)
157
- # varying_settings: { "key" => { env => value, ... } }
158
- #
159
- # A setting is only "constant" when it is present in every env
160
- # and all envs agree on the value. If any env is missing the
161
- # setting entirely (e.g. `polling_interval: 0.01` set only under
162
- # `test:`), emitting it as an unconditional line would silently
163
- # apply the value to envs that never asked for it — see #93.
164
- def partition_by_variance(all_settings)
165
- constant = {}
166
- varying = {}
167
- all_settings.each do |key, env_values|
168
- present_values = env_values.reject { |_, v| v == :__missing__ }
169
- unique_values = present_values.values.uniq
170
- all_envs_present = present_values.size == env_values.size
171
-
172
- if all_envs_present && unique_values.size <= 1
173
- constant[key] = unique_values.first
174
- else
175
- varying[key] = present_values
176
- end
177
- end
178
- [constant, varying]
179
- end
180
-
181
- def drop?(key, value)
182
- return true if DEPRECATED_SETTINGS.include?(key)
183
- return true if value == :__missing__
184
- return true if @defaults.key?(key) && @defaults[key] == value
185
-
186
- false
187
- end
188
-
189
- def render_setting(key, value)
190
- "c.#{key} = #{render_value(key, value)}"
191
- end
192
-
193
- def render_varying_setting(key, env_values)
194
- envs = env_values.keys
195
-
196
- # Single-env coverage: the setting exists in exactly one env.
197
- # Emit an `if Rails.env.X?` modifier rather than a case block
198
- # so other envs fall back to the gem default. This is the
199
- # fix for #93 — without it, a `test:`-only `polling_interval`
200
- # would leak into dev and prod as an unconditional assignment.
201
- if envs.size == 1
202
- env = envs.first
203
- value = env_values[env]
204
- return ["c.#{key} = #{render_value(key, value)} if Rails.env.#{env}?"]
205
- end
206
-
207
- if envs.size == 2 && envs.include?("development")
208
- # Special case: "everything except dev" — common pattern
209
- non_dev_value = env_values.except("development").values.first
210
- dev_value = env_values["development"]
211
- return ["c.#{key} = #{render_value(key, non_dev_value)} unless Rails.env.development?"] if dev_value.nil? && non_dev_value
212
- end
213
-
214
- # Fallback: case Rails.env block — `when` clauses indented one
215
- # level inside the case, `end` flush with `c.X` (standard Ruby
216
- # formatting for assigned case expressions).
217
- lines = ["c.#{key} = case Rails.env"]
218
- env_values.each do |env, value|
219
- lines << " when \"#{env}\" then #{render_value(key, value)}"
220
- end
221
- lines << "end"
222
- lines
223
- end
224
-
225
- def render_workers(constant_workers, varying_workers)
226
- if constant_workers && !varying_workers
227
- rendered = render_workers_value(constant_workers)
228
- return rendered unless rendered.nil? || rendered.empty?
229
- end
230
-
231
- if varying_workers
232
- # Different worker config per env. when clauses indented one
233
- # level inside the case, end flush with c.workers (standard
234
- # Ruby formatting for assigned case expressions).
235
- lines = ["c.workers = case Rails.env"]
236
- varying_workers.each do |env, workers|
237
- string_form = workers_as_string(workers)
238
- value = string_form ? string_form.inspect : workers.inspect
239
- lines << " when \"#{env}\" then #{value}"
240
- end
241
- lines << "end"
242
- return lines
243
- end
244
-
245
- nil
246
- end
247
-
248
- def render_workers_value(workers)
249
- return nil if workers.nil? || workers == :__missing__
250
-
251
- # Drop if matches the gem default ([{queues: %w[default], threads: 5}]).
252
- # Compare via normalized form (string keys both sides) so YAML's
253
- # string-keyed hashes match the symbol-keyed default.
254
- return nil if normalize_workers(workers) == normalize_workers(@defaults["workers"])
255
-
256
- if workers_simple?(workers)
257
- string_form = workers_as_string(workers)
258
- ["c.workers = #{string_form.inspect}"]
259
- else
260
- workers.map.with_index do |capsule, idx|
261
- render_capsule_call(capsule, idx)
262
- end
263
- end
264
- end
265
-
266
- # Normalize a workers array to symbol-keyed hashes with array-of-string
267
- # queues for stable comparison. Doesn't mutate input.
268
- def normalize_workers(workers)
269
- return nil if workers.nil?
270
-
271
- workers.map do |capsule|
272
- {
273
- queues: Array(capsule[:queues] || capsule["queues"]).map(&:to_s),
274
- threads: (capsule[:threads] || capsule["threads"] || 5).to_i
275
- }
276
- end
277
- end
278
-
279
- def workers_simple?(workers)
280
- workers.all? do |capsule|
281
- extras = capsule.keys.map(&:to_s) - %w[queues threads]
282
- extras.empty?
283
- end
284
- end
285
-
286
- def workers_as_string(workers)
287
- workers.map do |capsule|
288
- queues = (capsule["queues"] || capsule[:queues]).join(", ")
289
- threads = capsule["threads"] || capsule[:threads] || 5
290
- "#{queues}: #{threads}"
291
- end.join("; ")
292
- end
293
-
294
- def render_capsule_call(capsule, _idx)
295
- queues = capsule["queues"] || capsule[:queues]
296
- threads = capsule["threads"] || capsule[:threads] || 5
297
- name = capsule["name"] || capsule[:name] || queues.first
298
- opts = capsule.reject { |k, _| %w[queues threads name].include?(k.to_s) }
299
-
300
- parts = ["queues: #{queues.inspect}", "threads: #{threads}"]
301
- opts.each { |k, v| parts << "#{k}: #{v.inspect}" }
302
-
303
- "c.capsule :#{name}, #{parts.join(", ")}"
304
- end
305
-
306
- def render_value(key, value)
307
- return "nil" if value.nil?
308
-
309
- if DURATION_SETTINGS.include?(key) && value.is_a?(Integer) && value.positive?
310
- duration_form = format_duration(value)
311
- return duration_form if duration_form
312
- end
313
-
314
- if value.is_a?(Integer) && value >= 1000
315
- format_integer_with_underscores(value)
316
- else
317
- value.inspect
318
- end
319
- end
320
-
321
- def format_duration(seconds)
322
- units = [
323
- [86_400, "day", "days"],
324
- [3600, "hour", "hours"],
325
- [60, "minute", "minutes"],
326
- [1, "second", "seconds"]
327
- ]
328
-
329
- units.each do |unit_seconds, singular, plural|
330
- next unless (seconds % unit_seconds).zero?
331
-
332
- count = seconds / unit_seconds
333
- unit_name = count == 1 ? singular : plural
334
- return "#{count}.#{unit_name}"
335
- end
336
-
337
- nil
338
- end
339
-
340
- def format_integer_with_underscores(int)
341
- int.to_s.reverse.scan(/\d{1,3}/).join("_").reverse
342
- end
343
- end
344
- end
345
- end