pgbus 0.16.6 → 0.17.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.
@@ -14,6 +14,7 @@ module Pgbus
14
14
  class VersionNotFoundError < Pgbus::Error; end
15
15
 
16
16
  SCHEMA_DIR = File.expand_path("pgmq_schema", __dir__).freeze
17
+ FIXUPS_DIR = File.join(SCHEMA_DIR, "fixups").freeze
17
18
 
18
19
  class << self
19
20
  # Returns the latest vendored PGMQ version string.
@@ -23,9 +24,37 @@ module Pgbus
23
24
 
24
25
  # Returns sorted list of all vendored PGMQ versions.
25
26
  def available_versions
26
- Dir.glob(File.join(SCHEMA_DIR, "pgmq_v*.sql"))
27
- .map { |f| File.basename(f).match(/pgmq_v(.+)\.sql/)[1] }
28
- .sort_by { |v| Gem::Version.new(v) }
27
+ versions_in(SCHEMA_DIR)
28
+ end
29
+
30
+ # Versions that ship a table fixup, in version order.
31
+ #
32
+ # An upgrade drops every pgmq function and composite type and re-runs the
33
+ # target version's schema, which recreates both — but never touches an
34
+ # existing table. An upstream hop that ALTERs one (1.13.0 moves
35
+ # partitioned queues' msg_id from GENERATED ALWAYS to BY DEFAULT) needs
36
+ # its own step, and that is what these files are.
37
+ def fixup_versions
38
+ versions_in(FIXUPS_DIR)
39
+ end
40
+
41
+ # Concatenated fixups for every version in (after, upto], in version
42
+ # order. Returns "" when the hop crosses none.
43
+ #
44
+ # @param after [String, nil] the installed version; nil (no recorded
45
+ # version) applies every fixup up to the target, which is safe because
46
+ # each one is idempotent.
47
+ # @param upto [String] the version being upgraded to
48
+ def fixups_sql(after:, upto: latest_version)
49
+ ceiling = Gem::Version.new(upto)
50
+ floor = after && Gem::Version.new(after)
51
+
52
+ applicable = fixup_versions.select do |version|
53
+ candidate = Gem::Version.new(version)
54
+ candidate <= ceiling && (floor.nil? || candidate > floor)
55
+ end
56
+
57
+ applicable.map { |version| File.read(File.join(FIXUPS_DIR, "pgmq_v#{version}.sql")) }.join("\n")
29
58
  end
30
59
 
31
60
  # Returns the filesystem path to the vendored SQL file for a given version.
@@ -177,6 +206,15 @@ module Pgbus
177
206
 
178
207
  private
179
208
 
209
+ # Vendored versions discovered in a directory, in version order. Schema
210
+ # files and fixups share this so the two can never drift into different
211
+ # discovery rules.
212
+ def versions_in(dir)
213
+ Dir.glob(File.join(dir, "pgmq_v*.sql"))
214
+ .map { |f| File.basename(f).match(/pgmq_v(.+)\.sql/)[1] }
215
+ .sort_by { |v| Gem::Version.new(v) }
216
+ end
217
+
180
218
  # Strips extension-specific blocks (pg_extension_config_dump, pg_depend checks)
181
219
  # that only work when pgmq is installed as an extension.
182
220
  def strip_extension_only_blocks(sql)
@@ -78,6 +78,11 @@ module Pgbus
78
78
  )
79
79
  @registry = EventBus::Registry.instance
80
80
  @circuit_breaker = Pgbus::CircuitBreaker.new(config: config)
81
+ # Queues already warned about for an unroutable message (issue #469).
82
+ # handle_message runs on the execution pool, so this is touched from
83
+ # several threads — Concurrent::Set makes add? the atomic
84
+ # test-and-set the once-per-queue guarantee needs.
85
+ @unrouted_queues = Concurrent::Set.new
81
86
  # stat_buffer: :default means "build one iff config.stats_enabled";
82
87
  # passing an explicit value (including nil) overrides that for tests.
83
88
  @stat_buffer =
@@ -222,10 +227,12 @@ module Pgbus
222
227
  raw = JSON.parse(message.message)
223
228
  routing_key = raw.dig("headers", "routing_key") || raw["routing_key"]
224
229
 
225
- handlers = @registry.handlers_for(routing_key || "")
226
- handlers.each do |subscriber|
227
- handler = subscriber.handler_class.new
228
- handler.process(message)
230
+ handlers = @registry.handlers_for(routing_key || "", queue_name: queue_name)
231
+
232
+ if handlers.empty?
233
+ report_unrouted(queue_name, routing_key)
234
+ else
235
+ dispatch(handlers, message, queue_name)
229
236
  end
230
237
 
231
238
  Pgbus.client.archive_message(queue_name, message.msg_id.to_i)
@@ -247,6 +254,65 @@ module Pgbus
247
254
  @jobs_processed.increment
248
255
  end
249
256
 
257
+ # Run every handler that owns this message, with the message's visibility
258
+ # timeout held open for as long as they take (issue #470).
259
+ #
260
+ # Without this the consumer had no equivalent of
261
+ # ActiveJob::Executor#with_visibility_heartbeat: an event handler slower
262
+ # than config.visibility_timeout (30s by default) was redelivered *while
263
+ # still running*, a second consumer read the same envelope, and the
264
+ # holder's pending idempotency claim was indistinguishable from a claim
265
+ # left by a crash — so the handler ran twice, concurrently.
266
+ #
267
+ # The beat also refreshes the claims the handlers register, which is what
268
+ # lets Handler tell "holder still running" from "holder died": message
269
+ # visibility and claim liveness go quiet together when this process does.
270
+ #
271
+ # Tracking ends before the caller archives — a beat must never re-arm the
272
+ # VT of a message that is already gone (same rule as the executor's).
273
+ def dispatch(handlers, message, queue_name)
274
+ claim_beat = EventBus::ClaimBeat.new
275
+
276
+ VisibilityHeartbeat.track(
277
+ client: Pgbus.client,
278
+ queue_name: queue_name,
279
+ msg_id: message.msg_id.to_i,
280
+ job_class: "EventConsumer",
281
+ config: config,
282
+ on_beat: -> { claim_beat.touch! }
283
+ ) do
284
+ handlers.each { |subscriber| subscriber.handler_class.new.process(message, claim_beat: claim_beat) }
285
+ end
286
+ end
287
+
288
+ # No subscriber in this process owns +queue_name+ (a stale
289
+ # pgmq.topic_bindings row left by a renamed or removed handler, another
290
+ # app bound to the same bus), or the owner's pattern no longer matches the
291
+ # routing key. The message is archived by the caller either way — looping
292
+ # it through VT redelivery would only walk it into the DLQ — but that used
293
+ # to happen in total silence. The warning is rate-limited to once per
294
+ # queue per process so a permanently stale binding cannot flood the log;
295
+ # the instrumentation fires on every message, so the real rate stays
296
+ # visible in metrics (issue #469).
297
+ def report_unrouted(queue_name, routing_key)
298
+ first_for_queue = @unrouted_queues.add?(queue_name)
299
+
300
+ if first_for_queue
301
+ Pgbus.logger.warn do
302
+ "[Pgbus] Consumer read an unroutable event from queue #{queue_name} " \
303
+ "(routing_key=#{routing_key.inspect}): no subscriber in this process owns that queue. " \
304
+ "Archiving. This usually means a stale topic binding — a handler was renamed or removed " \
305
+ "without unbinding its queue. Further unrouted messages on this queue are not logged."
306
+ end
307
+ end
308
+
309
+ Pgbus::Instrumentation.instrument(
310
+ "pgbus.event_unrouted",
311
+ queue_name: queue_name,
312
+ routing_key: routing_key
313
+ )
314
+ end
315
+
250
316
  # Record a job stat for the handled message, mirroring the shape the
251
317
  # executor pushes (Executor#record_stat) so consumer and worker throughput
252
318
  # land in the same pgbus_job_stats table. No-op unless stats are enabled.
@@ -521,6 +587,7 @@ module Pgbus
521
587
  # wait IS its drain window — bound it by the same knob workers use
522
588
  # instead of a hardcoded 30s (issue #386).
523
589
  @pool.wait_for_termination(config.drain_timeout)
590
+ VisibilityHeartbeat.stop
524
591
  @stat_buffer&.stop
525
592
  @heartbeat&.stop
526
593
  restore_signals
data/lib/pgbus/testing.rb CHANGED
@@ -58,7 +58,7 @@ module Pgbus
58
58
  event = @mutex.synchronize { @events.first }
59
59
  break unless event
60
60
 
61
- Pgbus::EventBus::Registry.instance.handlers_for(event.routing_key).each do |subscriber|
61
+ Pgbus::EventBus::Registry.instance.subscribers_matching(event.routing_key).each do |subscriber|
62
62
  # Restore the publisher's Current (issue #431) like the consumer does.
63
63
  Pgbus::CurrentAttributes.restore(event.context) { subscriber.handler_class.new.handle(event) }
64
64
  end
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.16.6"
4
+ VERSION = "0.17.0"
5
5
  end
@@ -25,9 +25,15 @@ module Pgbus
25
25
  # out with `pgbus_visibility_heartbeat false`.
26
26
  module VisibilityHeartbeat
27
27
  # `concurrency` is `[key, duration]` for a concurrency-limited job, else
28
- # nil — one member, so the struct stays inside an 80-byte slot.
28
+ # nil. `on_beat` is an optional callable run on every extension — the event
29
+ # consumer uses it to refresh its handlers' idempotency claims (issue #470).
30
+ #
31
+ # The ninth member takes the struct out of the 80-byte slot it used to fit
32
+ # (measured: 80 → 160). That is paid at most once per in-flight message, so
33
+ # the whole table is bounded by the execution pool's capacity — a handful of
34
+ # entries per process, not one per enqueued job.
29
35
  Entry = Struct.new(:client, :queue_name, :prefixed, :msg_id, :job_class, :extended_at, :extensions,
30
- :concurrency, keyword_init: true)
36
+ :concurrency, :on_beat, keyword_init: true)
31
37
 
32
38
  # Per-job opt-out, included on ActiveJob::Base by the engine:
33
39
  #
@@ -59,13 +65,14 @@ module Pgbus
59
65
  # @param config [Pgbus::Configuration]
60
66
  # @param concurrency [Array(String, Numeric), nil] semaphore key to keep alive alongside
61
67
  # the message and how far to push its expiry on each beat
68
+ # @param on_beat [#call, nil] run after each extension; its failures are contained
62
69
  def track(client:, queue_name:, msg_id:, prefixed: true, job_class: nil, config: Pgbus.configuration,
63
- concurrency: nil)
70
+ concurrency: nil, on_beat: nil)
64
71
  return yield unless config.visibility_heartbeat
65
72
 
66
73
  entry = Entry.new(client: client, queue_name: queue_name, prefixed: prefixed, msg_id: msg_id.to_i,
67
74
  job_class: job_class, extended_at: monotonic_now, extensions: 0,
68
- concurrency: concurrency)
75
+ concurrency: concurrency, on_beat: on_beat)
69
76
  register(entry, config)
70
77
  begin
71
78
  yield
@@ -130,6 +137,7 @@ module Pgbus
130
137
  entry.extended_at = now
131
138
  entry.extensions += 1
132
139
  touch_semaphore(entry)
140
+ run_on_beat(entry)
133
141
  Instrumentation.instrument(
134
142
  "pgbus.job_visibility_extended",
135
143
  queue: entry.queue_name, job_class: entry.job_class, msg_id: entry.msg_id, vt: vt,
@@ -186,6 +194,17 @@ module Pgbus
186
194
  end
187
195
  end
188
196
 
197
+ # Same containment as touch_semaphore: a lease the beat keeps alive
198
+ # alongside the message must never cost the message its extension.
199
+ def run_on_beat(entry)
200
+ entry.on_beat&.call
201
+ rescue StandardError => e
202
+ Pgbus.logger.warn do
203
+ "[Pgbus::VisibilityHeartbeat] on_beat hook failed for msg_id=#{entry.msg_id} " \
204
+ "queue=#{entry.queue_name}: #{e.class}: #{e.message}"
205
+ end
206
+ end
207
+
189
208
  def forget_parent_entries!
190
209
  return if @pid == ::Process.pid
191
210
 
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.16.6
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -291,6 +291,7 @@ files:
291
291
  - lib/pgbus/engine.rb
292
292
  - lib/pgbus/error_reporter.rb
293
293
  - lib/pgbus/event.rb
294
+ - lib/pgbus/event_bus/claim_beat.rb
294
295
  - lib/pgbus/event_bus/handler.rb
295
296
  - lib/pgbus/event_bus/publisher.rb
296
297
  - lib/pgbus/event_bus/registry.rb
@@ -342,9 +343,11 @@ files:
342
343
  - lib/pgbus/outbox.rb
343
344
  - lib/pgbus/outbox/poller.rb
344
345
  - lib/pgbus/pgmq_schema.rb
346
+ - lib/pgbus/pgmq_schema/fixups/pgmq_v1.13.0.sql
345
347
  - lib/pgbus/pgmq_schema/pgmq_v1.11.0.sql
346
348
  - lib/pgbus/pgmq_schema/pgmq_v1.11.1.sql
347
349
  - lib/pgbus/pgmq_schema/pgmq_v1.12.0.sql
350
+ - lib/pgbus/pgmq_schema/pgmq_v1.13.0.sql
348
351
  - lib/pgbus/process/consumer.rb
349
352
  - lib/pgbus/process/consumer_priority.rb
350
353
  - lib/pgbus/process/dispatcher.rb