pgbus 0.23.2 → 0.24.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae461d658279ccfb2b4d1f6a9518ff278637c48beb703556363be63207273e1d
4
- data.tar.gz: 5cc7d7fed584a4d05564f184262f8f298384683498b448e79f87efc45f72fbe5
3
+ metadata.gz: d4d5cae2b9bd98099c3a64a88d9671f66b60dcc108d826095d5d97b9fafde703
4
+ data.tar.gz: 8c6533b41dc54cdca5f344718c3280059e1f930269ddfcf75f65426efb08a14e
5
5
  SHA512:
6
- metadata.gz: 10d5c9068040692f84b8aefb60d864bdfa9b35160fe3d733b66de36aeffe93653c50ab792f8603523cda3b4646974af6f0961e039a4ae7fc57e2f7ebe575f37c
7
- data.tar.gz: 7797c3f4134d55036e0678faf4cb3fe0cb1724e0bd6ac3c2c0d70c10a70a2b35a54fb987ae8c7caa46193ccdaf0e92f934bf86065daed179edc4aa67e2876fe8
6
+ metadata.gz: 80f77818760dd8c1290808ea1fa62ef42c7b5fb9490e65f93766648df2e769a152bcdbf8e9362edc878f08177dde4230e632ffdaebbb46139099255ffa2db8b5
7
+ data.tar.gz: 332be03140b01225aba67f96e57842459c57c103ed0578c7d4a4d446209250bc77d27563268c1b7773d6b1d844a115e2bae7676fc385fb1e8695d846621bb1af
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.24.0] - 2026-11-11
4
+
5
+ ### Added
6
+
7
+ - **`Pgbus::Client#unbind_topic` and `Registry#prune_stale_bindings!`: a narrowed subscription can drop its old topic binding (issue #657).** A subscriber queue is named after its handler, so narrowing a handler's subscription (from `"#"` to `"order.created"`, say) bound the new pattern on the same queue and left the old `pgmq.topic_bindings` row: every publish only the old pattern matched still wrote a message into that queue, which the consumer archived as unrouted, and the only way out was raw SQL against the pgmq schema. `Client#unbind_topic(pattern, queue_name)` removes one binding from a logical queue name (prefixed as `#bind_topic` does, with the same stale-connection retry) and returns `true`, or `false` when there was none; it never creates the queue. `Client#topic_patterns(queue_name)` lists the patterns bound to a queue. `Pgbus::EventBus::Registry.instance.prune_stale_bindings!` binds each registered subscriber queue's declared patterns first, so a queue is never left without a binding, then unbinds every other pattern on it, and returns `{ queue_name => [removed patterns] }` for the queues it changed (`{}` on a second run). Queues with no subscriber registered in the process are not touched, so run it with every subscriber registered: a pattern only an unloaded subscriber declares counts as stale. Queues are compared by their physical name, so a Symbol `queue_name:` and two spellings that name one table (`"Audit-Q"`, `"audit_q"`) count as one queue and keep each other's patterns; the result is keyed by the first registered spelling. `queue_names:` (logical names, not the prefixed ones `#event_queue_names` returns) limits it to the named queues, `queue_names: []` prunes nothing, and a name no registered subscriber uses raises `ArgumentError` before anything changes, so a typo or a process without the handlers loaded cannot strip a live queue; a removed handler's queue is cleaned up with `Client#topic_patterns` and `Client#unbind_topic`. `setup_all!` does not call it: during a rolling deploy a process still on the old release owns the old pattern, so run it as an explicit step (a deploy task, a rake task) once every process runs the new subscriptions. Refs #657.
8
+
9
+ ## [0.23.2] - 2026-11-06
10
+
3
11
  ### Fixed
4
12
 
5
13
  - **A `<pgbus-stream-source>` moved in the DOM resumes from the last durable id it received, so it no longer replays and re-applies frames it already applied (issue #644).** `connectedCallback` reset the resume cursor from the `since-id` attribute on every connect, and `since-id` is the render-time watermark, never updated afterwards. An element disconnected and reconnected as the same instance (Turbo Drive carrying a `data-turbo-permanent` element into the next page's body, or app code calling `appendChild` / `replaceWith` / `insertBefore` on it) reconnected with `?since=<render-time watermark>`, the server replayed every durable frame since the page rendered, and each `replace` / `update` ran again over whatever the target had become since. The element now keeps the cursor it earned on the same `src`: a connect sends `?since=` with the greater of that cursor and `since-id` (both durable msg_ids of one stream, compared as `BigInt`; when only one parses as an integer, that one), so a re-render that carries a newer watermark still wins. A cursor earned on another `src` (the element was re-pointed) is dropped and `since-id` is used alone. The old connection's read loop now stops on its own abort signal, checked between the frames of one network chunk too, so a `pgbus:message` listener that moves the element mid-chunk no longer has the rest of that chunk delivered by both the old loop and the new connection's replay. The first connect is unchanged, and an ephemeral frame still never moves the cursor (issue #621). The workaround of writing `since-id` from a `pgbus:message` listener is no longer needed. No new API. Refs #644.
data/lib/pgbus/client.rb CHANGED
@@ -1098,6 +1098,25 @@ module Pgbus
1098
1098
  end
1099
1099
  end
1100
1100
 
1101
+ # Removes the +pattern+ binding from the logical +queue_name+ (issue #657).
1102
+ # True when a binding was removed, false when there was none. Never creates
1103
+ # the queue.
1104
+ def unbind_topic(pattern, queue_name)
1105
+ full_name = config.queue_name(queue_name)
1106
+ with_stale_connection_retry do
1107
+ synchronized { @pgmq.unbind_topic(pattern, full_name) }
1108
+ end
1109
+ end
1110
+
1111
+ # The patterns bound to the logical +queue_name+ in pgmq.topic_bindings
1112
+ # (issue #657).
1113
+ def topic_patterns(queue_name)
1114
+ full_name = config.queue_name(queue_name)
1115
+ with_stale_connection_retry do
1116
+ synchronized { @pgmq.list_topic_bindings(queue_name: full_name).map { |binding| binding[:pattern] } }
1117
+ end
1118
+ end
1119
+
1101
1120
  def publish_to_topic(routing_key, payload, headers: nil, delay: 0)
1102
1121
  with_stale_connection_retry do
1103
1122
  synchronized do
@@ -62,6 +62,40 @@ module Pgbus
62
62
  end
63
63
  end
64
64
 
65
+ # Makes each subscriber queue's pgmq.topic_bindings equal to the patterns
66
+ # registered for it (issue #657). A subscriber queue is named after its
67
+ # handler, so narrowing a subscription ("#" → "order.created") binds the
68
+ # new pattern on the same queue and leaves the old row, which keeps
69
+ # copying events the consumer archives as unrouted.
70
+ #
71
+ # For every registered queue (or only +queue_names+, logical names, not
72
+ # the prefixed ones #event_queue_names returns): bind the patterns its
73
+ # subscribers declare first, so the queue is never left without a
74
+ # binding, then unbind every other pattern bound to it. Run it with every
75
+ # subscriber registered: a pattern only an unloaded subscriber declares
76
+ # counts as stale. +queue_names: []+ prunes nothing; a name no registered
77
+ # subscriber uses raises ArgumentError before anything changes (a typo, a
78
+ # prefixed name or a process without the handlers loaded would otherwise
79
+ # strip a live queue). A removed handler's queue is cleaned up with
80
+ # Pgbus.client.topic_patterns and Pgbus.client.unbind_topic.
81
+ #
82
+ # Returns { queue_name => [removed patterns] } for the queues it changed;
83
+ # a second run returns {}.
84
+ #
85
+ # Deliberately not part of setup_all!: during a rolling deploy a process
86
+ # still on the old release owns the old pattern, so pruning is an explicit
87
+ # step to run once every process runs the new subscriptions.
88
+ def prune_stale_bindings!(queue_names: nil)
89
+ declared = declared_bindings
90
+ targets = queue_names.nil? ? declared.keys : physical_targets(queue_names, declared)
91
+
92
+ targets.each_with_object({}) do |physical, removed|
93
+ queue_name, patterns = declared.fetch(physical)
94
+ stale = prune_queue_bindings(queue_name, patterns)
95
+ removed[queue_name] = stale unless stale.empty?
96
+ end
97
+ end
98
+
65
99
  # Subscribers a message read from +queue_name+ must be dispatched to
66
100
  # (issue #469). Every subscriber gets its own queue, so a topic with N
67
101
  # matching subscribers produces N queue copies of each event; selecting by
@@ -133,6 +167,49 @@ module Pgbus
133
167
 
134
168
  private
135
169
 
170
+ # { physical queue name => [logical name, declared patterns] }. Keyed by
171
+ # the physical name so a Symbol and a String, or two spellings
172
+ # QueueNameValidator folds to one table ("Audit-Q", "audit_q"), count as
173
+ # one queue; the logical name is the first registered spelling.
174
+ def declared_bindings
175
+ subscribers = @mutex.synchronize { @subscribers.dup }
176
+ subscribers.each_with_object({}) do |subscriber, declared|
177
+ physical = Pgbus.configuration.queue_name(subscriber.queue_name)
178
+ entry = declared[physical] ||= [subscriber.queue_name.to_s, []]
179
+ entry[1] |= [subscriber.pattern]
180
+ end
181
+ end
182
+
183
+ # The physical names of +queue_names+; raises before any change when one
184
+ # names no registered subscriber queue.
185
+ def physical_targets(queue_names, declared)
186
+ targets = queue_names.to_h { |name| [name, Pgbus.configuration.queue_name(name)] }
187
+ unknown = targets.reject { |_name, physical| declared.key?(physical) }.keys
188
+ unless unknown.empty?
189
+ raise ArgumentError, "No event subscriber is registered on queue(s) #{unknown.join(", ")}; " \
190
+ "prune_stale_bindings! takes logical subscriber queue names"
191
+ end
192
+
193
+ targets.values.uniq
194
+ end
195
+
196
+ # Binds +patterns+ to +queue_name+, unbinds every other pattern bound to
197
+ # it, and returns the patterns this call removed (one another process
198
+ # removed first is not reported).
199
+ def prune_queue_bindings(queue_name, patterns)
200
+ client = Pgbus.client
201
+ patterns.each { |pattern| client.bind_topic(pattern, queue_name) }
202
+
203
+ stale = client.topic_patterns(queue_name) - patterns
204
+ removed = stale.select { |pattern| client.unbind_topic(pattern, queue_name) }
205
+ unless removed.empty?
206
+ Pgbus.logger.info do
207
+ "[Pgbus] Unbound stale topic patterns #{removed.inspect} from event queue #{queue_name}"
208
+ end
209
+ end
210
+ removed
211
+ end
212
+
136
213
  # True when running inside a rake schema/asset task, where setup_all!(safe:)
137
214
  # should skip rather than open a connection. Delegates to the shared,
138
215
  # public detector (issue #409) so there is a single implementation.
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.23.2"
4
+ VERSION = "0.24.0"
5
5
  end
data/sig/pgbus/client.rbs CHANGED
@@ -71,7 +71,9 @@ module Pgbus
71
71
  def streams_swap_stats: () -> untyped
72
72
  def synchronizing?: () -> bool
73
73
  def target_queue: (untyped queue_name, ?untyped priority) -> untyped
74
+ def topic_patterns: (untyped queue_name) -> Array[String]
74
75
  def transaction: () ?{ (*untyped) -> untyped } -> untyped
76
+ def unbind_topic: (untyped pattern, untyped queue_name) -> bool
75
77
  def uniqueness_keys_present: (untyped lock_keys) -> untyped
76
78
  def update_notify_insert: (untyped queue_name, throttle_interval_ms: untyped) -> untyped
77
79
  def verify_connection!: () -> untyped
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.23.2
4
+ version: 0.24.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson