pgbus 0.6.8 → 0.7.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/README.md +166 -0
  3. data/lib/generators/pgbus/add_failed_events_index_generator.rb +4 -11
  4. data/lib/generators/pgbus/add_job_locks_generator.rb +4 -11
  5. data/lib/generators/pgbus/add_job_stats_generator.rb +4 -11
  6. data/lib/generators/pgbus/add_job_stats_latency_generator.rb +4 -11
  7. data/lib/generators/pgbus/add_job_stats_queue_index_generator.rb +4 -11
  8. data/lib/generators/pgbus/add_outbox_generator.rb +4 -11
  9. data/lib/generators/pgbus/add_presence_generator.rb +4 -11
  10. data/lib/generators/pgbus/add_queue_states_generator.rb +4 -11
  11. data/lib/generators/pgbus/add_recurring_generator.rb +4 -11
  12. data/lib/generators/pgbus/add_stream_stats_generator.rb +4 -11
  13. data/lib/generators/pgbus/install_generator.rb +4 -11
  14. data/lib/generators/pgbus/migrate_job_locks_generator.rb +4 -11
  15. data/lib/generators/pgbus/migration_path.rb +28 -0
  16. data/lib/generators/pgbus/tune_autovacuum_generator.rb +4 -11
  17. data/lib/generators/pgbus/upgrade_pgmq_generator.rb +4 -11
  18. data/lib/pgbus/active_job/executor.rb +3 -1
  19. data/lib/pgbus/circuit_breaker.rb +1 -1
  20. data/lib/pgbus/client.rb +10 -3
  21. data/lib/pgbus/configuration.rb +22 -0
  22. data/lib/pgbus/engine.rb +1 -0
  23. data/lib/pgbus/error_reporter.rb +48 -0
  24. data/lib/pgbus/failed_event_recorder.rb +1 -8
  25. data/lib/pgbus/log_formatter.rb +96 -0
  26. data/lib/pgbus/outbox/poller.rb +4 -4
  27. data/lib/pgbus/process/consumer.rb +5 -1
  28. data/lib/pgbus/process/dispatcher.rb +1 -1
  29. data/lib/pgbus/process/supervisor.rb +6 -6
  30. data/lib/pgbus/process/worker.rb +8 -3
  31. data/lib/pgbus/queue_name_validator.rb +28 -9
  32. data/lib/pgbus/streams/key.rb +173 -0
  33. data/lib/pgbus/streams/streamable.rb +57 -0
  34. data/lib/pgbus/streams.rb +37 -0
  35. data/lib/pgbus/version.rb +1 -1
  36. data/lib/pgbus.rb +14 -0
  37. data/lib/tasks/pgbus_autovacuum.rake +40 -0
  38. metadata +7 -1
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Pgbus
6
+ module Streams
7
+ # Short, pgbus-safe stream identifiers.
8
+ #
9
+ # PGMQ queue names are bounded by two ceilings: PostgreSQL's
10
+ # NAMEDATALEN (63 chars for `pgmq.q_<name>`) and pgmq-ruby's own
11
+ # stricter runtime check (`length >= 48` in
12
+ # `PGMQ::Client#validate_queue_name!`). The effective budget is the
13
+ # lower of the two, exposed as `QueueNameValidator::MAX_QUEUE_NAME_LENGTH`
14
+ # (currently 47). Any stream name composed from UUID primary keys
15
+ # and turbo-rails-style dom ids blows past that budget almost
16
+ # immediately:
17
+ #
18
+ # "gid://app/Ai::Chat/9c14e8b2-94c3-4c6f-8ca1-f50d2f5e22ca:messages"
19
+ # # => 63 chars, already too long before the "pgbus_" prefix is added.
20
+ #
21
+ # `stream_key` produces a deterministic short form suitable as a
22
+ # pgbus stream identifier. It normalizes each part, joins with ":",
23
+ # and enforces the queue-name budget (derived from the configured
24
+ # `queue_prefix`) at the call site — raising ArgumentError rather
25
+ # than letting the failure surface as an opaque QueueNameValidator
26
+ # error deep inside `Pgbus.stream(...).broadcast(...)`.
27
+ #
28
+ # Silent truncation is intentionally NOT supported: trimming a
29
+ # too-long key to fit would reintroduce the collision risk that the
30
+ # 64-bit digest is chosen to eliminate. Callers who overflow should
31
+ # shorten their own identifiers or adopt `Pgbus::Streams::Streamable`
32
+ # on their ActiveRecord models.
33
+ #
34
+ # Usage:
35
+ #
36
+ # Pgbus.stream_key(chat, :messages)
37
+ # # => "ai_chat_3a4f9c21b7d20e18:messages"
38
+ #
39
+ # Pgbus.stream_key([user, :notifications])
40
+ # # => "user_5fa83c91d44a2701:notifications"
41
+ #
42
+ # Pgbus.stream(Pgbus.stream_key(chat, :messages)).broadcast("<turbo-stream/>")
43
+ #
44
+ # Collision horizon: the 64-bit SHA-256 prefix gives a birthday bound
45
+ # of roughly 5 billion records per model class before a 50% chance of
46
+ # collision. For multi-tenant apps where a collision would mean two
47
+ # records share a stream (and receive each other's broadcasts), this
48
+ # is wide enough in practice. Callers with higher sensitivity can
49
+ # pass `digest_bits: 128`.
50
+ module Key
51
+ DEFAULT_DIGEST_BITS = 64
52
+
53
+ module_function
54
+
55
+ # Compose a short pgbus-safe stream name from any mix of records,
56
+ # strings, symbols, and arrays. Returns the joined key when it fits
57
+ # the pgbus queue-name budget; raises ArgumentError otherwise.
58
+ #
59
+ # Fragments must not contain `:` — it's the join separator, so
60
+ # `stream_key("a:b", :c)` and `stream_key("a", "b:c")` would both
61
+ # produce `"a:b:c"` and collapse two logically distinct streams
62
+ # onto one queue. Colons inside fragments (typically from a
63
+ # `to_stream_key`/`to_gid_param` implementation that forgot to
64
+ # sanitize) raise an ArgumentError at the call site.
65
+ def stream_key(*parts, digest_bits: DEFAULT_DIGEST_BITS)
66
+ fragments = Array(parts).flatten.map { |part| normalize(part, digest_bits: digest_bits) }
67
+ fragments.each { |fragment| reject_colons!(fragment) }
68
+ key = fragments.join(":")
69
+ budget = queue_name_budget
70
+ return key if key.length <= budget
71
+
72
+ raise ArgumentError,
73
+ "stream_key #{key.inspect} is #{key.length} chars, " \
74
+ "exceeds pgbus budget of #{budget} " \
75
+ "(queue_prefix=#{Pgbus.configuration.queue_prefix.inspect}, " \
76
+ "pgbus_max_queue_name_length=#{QueueNameValidator::MAX_QUEUE_NAME_LENGTH}). " \
77
+ "Shorten the streamables or use Pgbus::Streams::Streamable on the model."
78
+ end
79
+
80
+ # 64-bit (default) SHA-256 prefix of the record's primary key. Stdlib
81
+ # only, deterministic, and fixed-length. CRC32's 32-bit output is
82
+ # intentionally not used here: its ~77k-row birthday bound is too
83
+ # tight for a multi-tenant stream identifier where a collision would
84
+ # route two records' broadcasts to the same queue.
85
+ # Full output size of the backing digest, in bits. Capping
86
+ # digest_bits here matters because `SHA256.hexdigest` only
87
+ # produces 64 hex chars (256 bits) no matter what — slicing
88
+ # `[0, 128]` just returns all 64 chars — so a caller asking for
89
+ # `digest_bits: 512` would silently get the same output as
90
+ # `digest_bits: 256` and walk away believing they'd widened the
91
+ # collision horizon. Raise instead.
92
+ MAX_DIGEST_BITS = ::Digest::SHA256.new.digest_length * 8 # => 256
93
+
94
+ def short_id(record, digest_bits: DEFAULT_DIGEST_BITS)
95
+ unless digest_bits.is_a?(Integer) && digest_bits.positive? &&
96
+ (digest_bits % 4).zero? && digest_bits <= MAX_DIGEST_BITS
97
+ raise ArgumentError,
98
+ "digest_bits must be a positive multiple of 4 and <= #{MAX_DIGEST_BITS} " \
99
+ "(SHA-256 produces #{MAX_DIGEST_BITS} bits; asking for more would silently truncate)"
100
+ end
101
+
102
+ # Unpersisted records all share id=nil, which hashes to a single
103
+ # constant digest and would collapse every new instance of the
104
+ # same class into one stream. Fail loud at the first unsaved
105
+ # call site — the whole point of the 64-bit digest is to
106
+ # eliminate collisions, so silently producing a shared key here
107
+ # would reintroduce exactly what it was chosen to prevent.
108
+ if record.id.nil?
109
+ raise ArgumentError,
110
+ "#{record.class.name} must be persisted before generating a stream key " \
111
+ "(record.id is nil — all unsaved records would collide on one stream)"
112
+ end
113
+
114
+ hex_chars = digest_bits / 4
115
+ ::Digest::SHA256.hexdigest(record.id.to_s)[0, hex_chars]
116
+ end
117
+
118
+ # Normalize a single streamable fragment to a pgbus-safe string.
119
+ # Mirrors the shape accepted by Turbo::Streams::StreamName and
120
+ # Pgbus::Streams::Stream.name_from so the two code paths agree
121
+ # on the wire format.
122
+ #
123
+ # - Strings and symbols pass through verbatim.
124
+ # - ActiveRecord models become "<param_key>_<short_id>".
125
+ # - Anything else responding to `to_gid_param` / `to_param` falls
126
+ # back to that; a UUID primary key would still overflow, which
127
+ # is why AR models are hashed above.
128
+ def normalize(part, digest_bits: DEFAULT_DIGEST_BITS)
129
+ case part
130
+ when String, Symbol
131
+ part.to_s
132
+ else
133
+ if defined?(::ActiveRecord::Base) && part.is_a?(::ActiveRecord::Base)
134
+ "#{part.class.model_name.param_key}_#{short_id(part, digest_bits: digest_bits)}"
135
+ elsif part.respond_to?(:to_stream_key)
136
+ part.to_stream_key
137
+ elsif part.respond_to?(:to_gid_param)
138
+ part.to_gid_param
139
+ elsif part.respond_to?(:to_param)
140
+ part.to_param
141
+ else
142
+ part.to_s
143
+ end
144
+ end
145
+ end
146
+
147
+ # Budget = effective pgbus queue-name limit - "<queue_prefix>_"
148
+ # length. Computed at call time (not a constant) so apps that
149
+ # override `config.queue_prefix` get the correct budget
150
+ # automatically.
151
+ def queue_name_budget
152
+ QueueNameValidator::MAX_QUEUE_NAME_LENGTH -
153
+ Pgbus.configuration.queue_prefix.length - 1
154
+ end
155
+
156
+ # Raises if a normalized fragment contains the `:` separator.
157
+ # Kept private-ish via module_function so the guard is shared
158
+ # between stream_key and any future composer without becoming
159
+ # part of the public surface.
160
+ def reject_colons!(fragment)
161
+ return unless fragment.include?(":")
162
+
163
+ raise ArgumentError,
164
+ "stream_key fragment #{fragment.inspect} contains ':' which is the " \
165
+ "join separator — two calls with different colon placements would " \
166
+ "collapse to the same key (e.g. stream_key('a:b', :c) vs " \
167
+ "stream_key('a', 'b:c') both produce 'a:b:c'). Strip or replace " \
168
+ "colons in the offending streamable before calling stream_key."
169
+ end
170
+ private_class_method :reject_colons!
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Streams
5
+ # ActiveRecord concern that adds `short_id` and `to_stream_key`
6
+ # instance methods for producing pgbus-safe stream identifiers from
7
+ # records whose primary key is a UUID (or any other long string).
8
+ #
9
+ # Intended for inclusion in `ApplicationRecord`:
10
+ #
11
+ # class ApplicationRecord < ActiveRecord::Base
12
+ # primary_abstract_class
13
+ # include Pgbus::Streams::Streamable
14
+ # end
15
+ #
16
+ # Every subclass then gets:
17
+ #
18
+ # chat.short_id
19
+ # # => "3a4f9c21b7d20e18"
20
+ #
21
+ # chat.to_stream_key
22
+ # # => "ai_chat_3a4f9c21b7d20e18"
23
+ #
24
+ # Pgbus.stream_key(chat, :messages)
25
+ # # => "ai_chat_3a4f9c21b7d20e18:messages"
26
+ #
27
+ # The mixin is intentionally thin: it delegates to `Pgbus::Streams::Key`
28
+ # so the digest policy lives in one place.
29
+ #
30
+ # `#to_stream_key` calls `Key.short_id(self)` directly rather than
31
+ # dispatching through `#short_id`. Ruby does NOT warn when a class
32
+ # defines an instance method and a later `include` adds a module
33
+ # with the same name — the class method silently wins. A host app
34
+ # that already defines its own `#short_id` (returning, say, a
35
+ # display-friendly abbreviation) would therefore hijack
36
+ # `to_stream_key` without any indication, producing stream keys
37
+ # the wire format never promised. Calling `Key.short_id(self)`
38
+ # explicitly bypasses instance-method lookup and guarantees the
39
+ # advertised digest regardless of what the host class does with
40
+ # the unqualified name.
41
+ module Streamable
42
+ # Returns a short SHA-256 prefix (64 bits / 16 hex chars by default)
43
+ # of this record's primary key. See `Pgbus::Streams::Key.short_id`
44
+ # for the digest policy and collision horizon.
45
+ def short_id(digest_bits: Key::DEFAULT_DIGEST_BITS)
46
+ Key.short_id(self, digest_bits: digest_bits)
47
+ end
48
+
49
+ # Returns a stable, pgbus-safe identifier of the form
50
+ # `<model_key>_<short_id>` suitable for passing directly to
51
+ # `Pgbus.stream(...)` or composing with `Pgbus.stream_key`.
52
+ def to_stream_key
53
+ "#{self.class.model_name.param_key}_#{Key.short_id(self)}"
54
+ end
55
+ end
56
+ end
57
+ end
data/lib/pgbus/streams.rb CHANGED
@@ -6,6 +6,15 @@ module Pgbus
6
6
  # which returns a `Pgbus::Streams::Stream` providing `#broadcast`,
7
7
  # `#current_msg_id`, and `#read_after`.
8
8
  module Streams
9
+ # Raised when a composed stream name would overflow PGMQ's queue-name
10
+ # budget (derived from PostgreSQL's NAMEDATALEN=64, minus PGMQ's `q_`
11
+ # table prefix, minus the configured queue_prefix + separator).
12
+ #
13
+ # Inherits from ArgumentError so existing rescues of the underlying
14
+ # QueueNameValidator error keep working; callers that want to handle
15
+ # this specifically can rescue Pgbus::Streams::StreamNameTooLong.
16
+ class StreamNameTooLong < ArgumentError; end
17
+
9
18
  # Process-wide registry of server-side audience filter predicates.
10
19
  # Register filters at boot time via:
11
20
  # Pgbus::Streams.filters.register(:admin_only) { |user| user.admin? }
@@ -30,6 +39,7 @@ module Pgbus
30
39
 
31
40
  def initialize(streamables, client: Pgbus.client)
32
41
  @name = self.class.name_from(streamables)
42
+ self.class.validate_name_length!(@name, streamables)
33
43
  @client = client
34
44
  @ensured = false
35
45
  @ensure_mutex = Mutex.new
@@ -108,6 +118,33 @@ module Pgbus
108
118
  end
109
119
  end
110
120
 
121
+ # Enforces the pgbus queue-name budget at the Stream-construction
122
+ # boundary so a forgotten call site fails with an actionable error
123
+ # (pointing at the offending streamables and suggesting
124
+ # `Pgbus.stream_key`) instead of an opaque QueueNameValidator
125
+ # failure three frames deep in Client#ensure_stream_queue.
126
+ #
127
+ # The budget is computed from `config.queue_prefix` at call time
128
+ # so apps that override the prefix get the correct limit. Does not
129
+ # mutate the name — silent truncation is a footgun for
130
+ # multi-tenant apps where collisions would mix broadcasts across
131
+ # records. Callers who need a short, safe identifier should use
132
+ # `Pgbus.stream_key(...)` or include `Pgbus::Streams::Streamable`
133
+ # on their ActiveRecord models.
134
+ def self.validate_name_length!(name, streamables)
135
+ budget = Key.queue_name_budget
136
+ return if name.length <= budget
137
+
138
+ raise StreamNameTooLong,
139
+ "Stream name #{name.inspect} is #{name.length} chars, " \
140
+ "exceeds pgbus budget of #{budget} " \
141
+ "(queue_prefix=#{Pgbus.configuration.queue_prefix.inspect}, " \
142
+ "pgbus_max_queue_name_length=#{QueueNameValidator::MAX_QUEUE_NAME_LENGTH}). " \
143
+ "Streamables: #{streamables.inspect}. " \
144
+ "Use Pgbus.stream_key(*streamables) to produce a safe short name, " \
145
+ "or include Pgbus::Streams::Streamable on the model."
146
+ end
147
+
111
148
  private
112
149
 
113
150
  def ensure_queue!
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.6.8"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/pgbus.rb CHANGED
@@ -105,6 +105,20 @@ module Pgbus
105
105
  @stream_cache.compute_if_absent(name) { Streams::Stream.new(streamables) }
106
106
  end
107
107
 
108
+ # Compose a short, pgbus-safe stream identifier from any mix of
109
+ # records, strings, symbols, and arrays. Delegates to
110
+ # `Pgbus::Streams::Key.stream_key`; raises `ArgumentError` if the
111
+ # resulting key would overflow the pgbus queue-name budget. See
112
+ # `lib/pgbus/streams/key.rb` for the digest policy and rationale.
113
+ #
114
+ # Pgbus.stream_key(chat, :messages)
115
+ # # => "ai_chat_3a4f9c21b7d20e18:messages"
116
+ #
117
+ # Pgbus.stream(Pgbus.stream_key(chat, :messages)).broadcast(html)
118
+ def stream_key(*parts, **)
119
+ Streams::Key.stream_key(*parts, **)
120
+ end
121
+
108
122
  def reset!
109
123
  @client&.close
110
124
  @client = nil
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :pgbus do
4
+ desc "Apply autovacuum tuning to PGMQ queue/archive tables and high-churn pgbus tables"
5
+ task tune_autovacuum: :environment do
6
+ require "pgbus/autovacuum_tuning"
7
+
8
+ conn = Pgbus.configuration.connects_to ? Pgbus::BusRecord.connection : ActiveRecord::Base.connection
9
+
10
+ # Only run if pgmq schema exists (tables may not be created yet during
11
+ # initial setup — the install migration handles tuning itself).
12
+ pgmq_exists = conn.select_value(
13
+ "SELECT 1 FROM information_schema.schemata WHERE schema_name = 'pgmq'"
14
+ )
15
+
16
+ unless pgmq_exists
17
+ puts "[pgbus] PGMQ schema not found — skipping autovacuum tuning."
18
+ next
19
+ end
20
+
21
+ puts "[pgbus] Applying autovacuum tuning to PGMQ queue/archive tables..."
22
+ conn.execute(Pgbus::AutovacuumTuning.sql_for_all_queues)
23
+
24
+ puts "[pgbus] Applying autovacuum tuning to high-churn pgbus tables..."
25
+ conn.execute(Pgbus::AutovacuumTuning.sql_for_high_churn_tables)
26
+
27
+ puts "[pgbus] Autovacuum tuning complete."
28
+ end
29
+ end
30
+
31
+ # Reapply autovacuum settings after schema:load since Ruby-format schema.rb
32
+ # does not preserve ALTER TABLE ... SET (reloptions). This is a no-op if the
33
+ # tables don't exist yet (IF EXISTS guards in the SQL).
34
+ %w[db:schema:load db:schema:load:pgbus].each do |task_name|
35
+ next unless Rake::Task.task_defined?(task_name)
36
+
37
+ Rake::Task[task_name].enhance do
38
+ Rake::Task["pgbus:tune_autovacuum"].invoke if Rake::Task.task_defined?("pgbus:tune_autovacuum")
39
+ end
40
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.8
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -215,6 +215,7 @@ files:
215
215
  - lib/generators/pgbus/add_stream_stats_generator.rb
216
216
  - lib/generators/pgbus/install_generator.rb
217
217
  - lib/generators/pgbus/migrate_job_locks_generator.rb
218
+ - lib/generators/pgbus/migration_path.rb
218
219
  - lib/generators/pgbus/templates/add_failed_events_unique_index.rb.erb
219
220
  - lib/generators/pgbus/templates/add_job_locks.rb.erb
220
221
  - lib/generators/pgbus/templates/add_job_stats.rb.erb
@@ -255,6 +256,7 @@ files:
255
256
  - lib/pgbus/configuration/capsule_dsl.rb
256
257
  - lib/pgbus/dedup_cache.rb
257
258
  - lib/pgbus/engine.rb
259
+ - lib/pgbus/error_reporter.rb
258
260
  - lib/pgbus/event.rb
259
261
  - lib/pgbus/event_bus/handler.rb
260
262
  - lib/pgbus/event_bus/publisher.rb
@@ -268,6 +270,7 @@ files:
268
270
  - lib/pgbus/generators/database_target_detector.rb
269
271
  - lib/pgbus/generators/migration_detector.rb
270
272
  - lib/pgbus/instrumentation.rb
273
+ - lib/pgbus/log_formatter.rb
271
274
  - lib/pgbus/outbox.rb
272
275
  - lib/pgbus/outbox/poller.rb
273
276
  - lib/pgbus/pgmq_schema.rb
@@ -298,8 +301,10 @@ files:
298
301
  - lib/pgbus/streams/cursor.rb
299
302
  - lib/pgbus/streams/envelope.rb
300
303
  - lib/pgbus/streams/filters.rb
304
+ - lib/pgbus/streams/key.rb
301
305
  - lib/pgbus/streams/presence.rb
302
306
  - lib/pgbus/streams/signed_name.rb
307
+ - lib/pgbus/streams/streamable.rb
303
308
  - lib/pgbus/streams/turbo_broadcastable.rb
304
309
  - lib/pgbus/streams/turbo_stream_override.rb
305
310
  - lib/pgbus/streams/watermark_cache_middleware.rb
@@ -319,6 +324,7 @@ files:
319
324
  - lib/pgbus/web/streamer/registry.rb
320
325
  - lib/pgbus/web/streamer/stream_event_dispatcher.rb
321
326
  - lib/puma/plugin/pgbus_streams.rb
327
+ - lib/tasks/pgbus_autovacuum.rake
322
328
  - lib/tasks/pgbus_pgmq.rake
323
329
  - lib/tasks/pgbus_streams.rake
324
330
  homepage: https://github.com/mhenrixon/pgbus