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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +8 -0
- data/Rakefile +3 -168
- data/lib/generators/pgbus/templates/upgrade_pgmq.rb.erb +22 -0
- data/lib/pgbus/event_bus/claim_beat.rb +80 -0
- data/lib/pgbus/event_bus/handler.rb +123 -34
- data/lib/pgbus/event_bus/publisher.rb +1 -1
- data/lib/pgbus/event_bus/registry.rb +24 -1
- data/lib/pgbus/instrumentation.rb +4 -0
- data/lib/pgbus/metrics/subscriber.rb +14 -0
- data/lib/pgbus/pgmq_schema/fixups/pgmq_v1.13.0.sql +49 -0
- data/lib/pgbus/pgmq_schema/pgmq_v1.13.0.sql +2193 -0
- data/lib/pgbus/pgmq_schema.rb +41 -3
- data/lib/pgbus/process/consumer.rb +71 -4
- data/lib/pgbus/testing.rb +1 -1
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/visibility_heartbeat.rb +23 -4
- metadata +4 -1
data/lib/pgbus/pgmq_schema.rb
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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.
|
|
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
|
@@ -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
|
|
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.
|
|
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
|