pgbus 0.9.7 → 0.9.9

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +367 -0
  3. data/README.md +454 -25
  4. data/Rakefile +10 -1
  5. data/app/helpers/pgbus/application_helper.rb +37 -0
  6. data/app/views/pgbus/processes/_processes_table.html.erb +4 -1
  7. data/config/locales/da.yml +4 -0
  8. data/config/locales/de.yml +4 -0
  9. data/config/locales/en.yml +4 -0
  10. data/config/locales/es.yml +4 -0
  11. data/config/locales/fi.yml +4 -0
  12. data/config/locales/fr.yml +4 -0
  13. data/config/locales/it.yml +4 -0
  14. data/config/locales/ja.yml +4 -0
  15. data/config/locales/nb.yml +4 -0
  16. data/config/locales/nl.yml +4 -0
  17. data/config/locales/pt.yml +4 -0
  18. data/config/locales/sv.yml +4 -0
  19. data/lib/generators/pgbus/{add_job_locks_generator.rb → add_uniqueness_keys_generator.rb} +5 -5
  20. data/lib/pgbus/active_job/adapter.rb +1 -1
  21. data/lib/pgbus/active_job/executor.rb +25 -4
  22. data/lib/pgbus/cli/dlq.rb +164 -0
  23. data/lib/pgbus/cli.rb +18 -1
  24. data/lib/pgbus/client/connection_health.rb +194 -0
  25. data/lib/pgbus/client.rb +592 -73
  26. data/lib/pgbus/config_loader.rb +50 -4
  27. data/lib/pgbus/configuration.rb +294 -79
  28. data/lib/pgbus/dedup_cache.rb +8 -0
  29. data/lib/pgbus/doctor.rb +275 -0
  30. data/lib/pgbus/engine.rb +15 -0
  31. data/lib/pgbus/execution_pools/async_pool.rb +9 -2
  32. data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
  33. data/lib/pgbus/generators/config_converter.rb +7 -5
  34. data/lib/pgbus/generators/migration_detector.rb +20 -16
  35. data/lib/pgbus/instrumentation.rb +3 -1
  36. data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
  37. data/lib/pgbus/integrations/appsignal/subscriber.rb +17 -2
  38. data/lib/pgbus/metrics/backend.rb +38 -0
  39. data/lib/pgbus/metrics/backends/prometheus.rb +123 -0
  40. data/lib/pgbus/metrics/backends/statsd.rb +64 -0
  41. data/lib/pgbus/metrics/prometheus_exporter.rb +34 -0
  42. data/lib/pgbus/metrics/subscriber.rb +214 -0
  43. data/lib/pgbus/metrics.rb +43 -0
  44. data/lib/pgbus/pgmq_schema/pgmq_v1.11.1.sql +2126 -0
  45. data/lib/pgbus/pgmq_schema.rb +7 -2
  46. data/lib/pgbus/process/consumer.rb +354 -18
  47. data/lib/pgbus/process/consumer_priority.rb +34 -0
  48. data/lib/pgbus/process/dispatcher.rb +265 -41
  49. data/lib/pgbus/process/heartbeat.rb +18 -5
  50. data/lib/pgbus/process/memory_usage.rb +48 -0
  51. data/lib/pgbus/process/notify_listener.rb +26 -7
  52. data/lib/pgbus/process/notify_probe.rb +96 -0
  53. data/lib/pgbus/process/primary_validator.rb +53 -0
  54. data/lib/pgbus/process/signal_handler.rb +6 -0
  55. data/lib/pgbus/process/supervisor.rb +423 -50
  56. data/lib/pgbus/process/worker.rb +288 -35
  57. data/lib/pgbus/recurring/schedule.rb +1 -2
  58. data/lib/pgbus/recurring/scheduler.rb +15 -1
  59. data/lib/pgbus/serializer.rb +4 -4
  60. data/lib/pgbus/streams/broadcastable_override.rb +0 -8
  61. data/lib/pgbus/streams/signed_name.rb +2 -2
  62. data/lib/pgbus/streams/turbo_broadcastable.rb +7 -5
  63. data/lib/pgbus/table_maintenance.rb +13 -2
  64. data/lib/pgbus/uniqueness.rb +11 -12
  65. data/lib/pgbus/version.rb +1 -1
  66. data/lib/pgbus/web/data_source.rb +36 -4
  67. data/lib/pgbus/web/health_app.rb +102 -0
  68. data/lib/pgbus/web/health_server.rb +144 -0
  69. data/lib/pgbus/web/payload_filter.rb +3 -3
  70. data/lib/pgbus/web/streamer/instance.rb +58 -2
  71. data/lib/pgbus/web/streamer/listener.rb +69 -21
  72. data/lib/pgbus.rb +77 -0
  73. data/lib/tasks/pgbus_doctor.rake +12 -0
  74. metadata +19 -4
  75. data/app/models/pgbus/job_lock.rb +0 -98
  76. data/lib/generators/pgbus/templates/add_job_locks.rb.erb +0 -21
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Metrics
5
+ module Backends
6
+ # Dependency-free, in-process Prometheus registry.
7
+ #
8
+ # Counters and gauges are stored per (name, sorted-tags) series. Histograms
9
+ # are stored as a sum+count summary — cheap, dependency-free, and enough for
10
+ # rate/avg dashboards without pre-declaring buckets. State is Mutex-guarded
11
+ # (no unsynchronized shared state), since instrumentation fires from many
12
+ # worker threads while the exporter renders from a Rack thread.
13
+ #
14
+ # Render output is Prometheus text exposition format (v0.0.4), served by
15
+ # Pgbus::Metrics::PrometheusExporter.
16
+ class Prometheus < Backend
17
+ Series = Struct.new(:type, :samples) # samples: { tags_key => value }
18
+
19
+ def initialize
20
+ super
21
+ @mutex = Mutex.new
22
+ # name => Series
23
+ @counters = {}
24
+ @gauges = {}
25
+ # name => { tags_key => { sum:, count:, tags: } }
26
+ @histograms = {}
27
+ # tags_key (sorted array) => original tags hash, for rendering labels
28
+ @tag_labels = {}
29
+ end
30
+
31
+ def increment(name, value = 1, tags = {})
32
+ key = tag_key(tags)
33
+ @mutex.synchronize do
34
+ series = (@counters[name] ||= {})
35
+ series[key] = (series[key] || 0) + value
36
+ end
37
+ nil
38
+ end
39
+
40
+ def gauge(name, value, tags = {})
41
+ key = tag_key(tags)
42
+ @mutex.synchronize do
43
+ series = (@gauges[name] ||= {})
44
+ series[key] = value
45
+ end
46
+ nil
47
+ end
48
+
49
+ def histogram(name, value, tags = {})
50
+ key = tag_key(tags)
51
+ @mutex.synchronize do
52
+ series = (@histograms[name] ||= {})
53
+ bucket = (series[key] ||= { sum: 0, count: 0 })
54
+ bucket[:sum] += value
55
+ bucket[:count] += 1
56
+ end
57
+ nil
58
+ end
59
+
60
+ # Render the full registry as Prometheus text exposition format.
61
+ def render
62
+ @mutex.synchronize do
63
+ lines = []
64
+ @counters.each { |name, series| render_simple(lines, name, "counter", series) }
65
+ @gauges.each { |name, series| render_simple(lines, name, "gauge", series) }
66
+ @histograms.each { |name, series| render_summary(lines, name, series) }
67
+ "#{lines.join("\n")}\n"
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def render_simple(lines, name, type, series)
74
+ lines << "# TYPE #{name} #{type}"
75
+ series.each do |key, value|
76
+ lines << "#{name}#{labels_for(key)} #{format_number(value)}"
77
+ end
78
+ end
79
+
80
+ def render_summary(lines, name, series)
81
+ lines << "# TYPE #{name} summary"
82
+ series.each do |key, bucket|
83
+ labels = labels_for(key)
84
+ lines << "#{name}_sum#{labels} #{format_number(bucket[:sum])}"
85
+ lines << "#{name}_count#{labels} #{bucket[:count]}"
86
+ end
87
+ end
88
+
89
+ # Stable, order-independent key for a tag set. Also memoizes the original
90
+ # (stringified) tag hash so render can reproduce the label set.
91
+ def tag_key(tags)
92
+ normalized = tags.each_with_object({}) do |(k, v), acc|
93
+ acc[k.to_s] = v.to_s
94
+ end
95
+ key = normalized.sort.freeze
96
+ @tag_labels[key] ||= normalized
97
+ key
98
+ end
99
+
100
+ def labels_for(key)
101
+ labels = @tag_labels[key]
102
+ return "" if labels.nil? || labels.empty?
103
+
104
+ inner = key.map { |k, _| %(#{k}="#{escape_label(labels[k])}") }.join(",")
105
+ "{#{inner}}"
106
+ end
107
+
108
+ # Prometheus label-value escaping: backslash, double-quote, newline.
109
+ def escape_label(value)
110
+ value.gsub("\\", "\\\\\\\\").gsub('"', '\\"').gsub("\n", '\\n')
111
+ end
112
+
113
+ def format_number(value)
114
+ if value.is_a?(Float) && value.finite? && value == value.to_i
115
+ value.to_i.to_s
116
+ else
117
+ value.to_s
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Pgbus
6
+ module Metrics
7
+ module Backends
8
+ # StatsD backend emitting UDP datagrams in DogStatsD dialect.
9
+ #
10
+ # increment -> "name:value|c"
11
+ # gauge -> "name:value|g"
12
+ # histogram -> "name:value|ms"
13
+ #
14
+ # Tags are appended as DogStatsD `|#k:v,k:v`. No gem dependency — a plain
15
+ # UDPSocket. UDP send is fire-and-forget; any socket error is logged and
16
+ # swallowed so a metrics hiccup never propagates into the producer thread.
17
+ class Statsd < Backend
18
+ def initialize(host: "127.0.0.1", port: 8125, socket: nil)
19
+ super()
20
+ @host = host
21
+ @port = port
22
+ @socket = socket
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ def increment(name, value = 1, tags = {})
27
+ emit(name, value, "c", tags)
28
+ end
29
+
30
+ def gauge(name, value, tags = {})
31
+ emit(name, value, "g", tags)
32
+ end
33
+
34
+ def histogram(name, value, tags = {})
35
+ emit(name, value, "ms", tags)
36
+ end
37
+
38
+ private
39
+
40
+ def emit(name, value, type, tags)
41
+ datagram = "#{name}:#{value}|#{type}#{format_tags(tags)}"
42
+ socket.send(datagram, 0, @host, @port)
43
+ nil
44
+ rescue StandardError => e
45
+ Pgbus.logger.warn do
46
+ "[Pgbus::Metrics::Statsd] send failed: #{e.class}: #{e.message}"
47
+ end
48
+ nil
49
+ end
50
+
51
+ def format_tags(tags)
52
+ pairs = tags.compact.map { |k, v| "#{k}:#{v}" }
53
+ pairs.empty? ? "" : "|##{pairs.join(",")}"
54
+ end
55
+
56
+ # UDPSocket is not guaranteed thread-safe for concurrent sends; guard
57
+ # lazy creation so the first burst of instrumentation can't race.
58
+ def socket
59
+ @socket || @mutex.synchronize { @socket ||= UDPSocket.new }
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Metrics
5
+ # Rack app that renders the in-process Prometheus registry as text exposition
6
+ # format. Mount it in the host Rails app (or any Rack server) so Prometheus
7
+ # can scrape it — see the README. Mirrors the "mountable Rack app" shape used
8
+ # by Pgbus::Web::StreamApp.
9
+ #
10
+ # # config/routes.rb
11
+ # mount Pgbus::Metrics::PrometheusExporter.new => "/metrics"
12
+ #
13
+ # With no injected backend it reads Pgbus.configuration.metrics_backend, so a
14
+ # single `config.metrics_backend = :prometheus` wires both the subscriber and
15
+ # the exporter to the same registry.
16
+ class PrometheusExporter
17
+ CONTENT_TYPE = "text/plain; version=0.0.4"
18
+
19
+ def initialize(backend: nil)
20
+ @backend = backend
21
+ end
22
+
23
+ def call(_env)
24
+ backend = @backend || Pgbus.configuration.metrics_backend
25
+ unless backend.is_a?(Backends::Prometheus)
26
+ return [503, { "content-type" => "text/plain" },
27
+ ["metrics_backend is not a Prometheus backend\n"]]
28
+ end
29
+
30
+ [200, { "content-type" => CONTENT_TYPE }, [backend.render]]
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Pgbus
6
+ module Metrics
7
+ # Consumes Pgbus::Instrumentation events and forwards them to the configured
8
+ # metrics backend. The event→metric mapping (names, tags, counter vs
9
+ # histogram) intentionally mirrors the AppSignal subscriber so a team can
10
+ # switch backends and keep the same dashboards.
11
+ #
12
+ # Every handler runs inside `safely`: a raising backend (Prometheus registry
13
+ # bug, StatsD socket down, custom backend) is logged and swallowed — a metrics
14
+ # failure must never propagate into the producer thread that emitted the
15
+ # event.
16
+ module Subscriber
17
+ METRIC_PREFIX = "pgbus_"
18
+ private_constant :METRIC_PREFIX
19
+
20
+ @subscriptions = []
21
+ @backend = nil
22
+
23
+ class << self
24
+ def install!(backend:)
25
+ return false if @installed
26
+
27
+ @backend = backend
28
+ @subscriptions = [
29
+ subscribe("pgbus.executor.execute") { |event| on_executor_execute(event) },
30
+ subscribe("pgbus.job_completed") { |event| on_job_completed(event) },
31
+ subscribe("pgbus.job_failed") { |event| on_job_failed(event) },
32
+ subscribe("pgbus.job_dead_lettered") { |event| on_job_dead_lettered(event) },
33
+ subscribe("pgbus.event_processed") { |event| on_event_processed(event) },
34
+ subscribe("pgbus.event_failed") { |event| on_event_failed(event) },
35
+ subscribe("pgbus.client.send_message") { |event| on_send_message(event) },
36
+ subscribe("pgbus.client.send_batch") { |event| on_send_batch(event) },
37
+ subscribe("pgbus.client.read_batch") { |event| on_read_batch(event) },
38
+ subscribe("pgbus.stream.broadcast") { |event| on_stream_broadcast(event) },
39
+ subscribe("pgbus.outbox.publish") { |event| on_outbox_publish(event) },
40
+ subscribe("pgbus.recurring.enqueue") { |event| on_recurring_enqueue(event) },
41
+ subscribe("pgbus.worker.recycle") { |event| on_worker_recycle(event) },
42
+ subscribe("pgbus.consumer.recycle") { |event| on_consumer_recycle(event) },
43
+ subscribe("pgbus.client.pool") { |event| on_client_pool(event) }
44
+ ]
45
+ @installed = true
46
+ end
47
+
48
+ def installed?
49
+ @installed == true
50
+ end
51
+
52
+ def reset!
53
+ @subscriptions&.each { |s| ActiveSupport::Notifications.unsubscribe(s) }
54
+ @subscriptions = []
55
+ @backend = nil
56
+ @installed = false
57
+ end
58
+
59
+ private
60
+
61
+ attr_reader :backend
62
+
63
+ def subscribe(name, &block)
64
+ ActiveSupport::Notifications.subscribe(name) do |*args|
65
+ event = ActiveSupport::Notifications::Event.new(*args)
66
+ safely { block.call(event) }
67
+ end
68
+ end
69
+
70
+ # Errors in the subscriber must never affect the producer thread.
71
+ def safely
72
+ yield
73
+ rescue StandardError => e
74
+ Pgbus.logger.warn do
75
+ "[Pgbus::Metrics] subscriber error: #{e.class}: #{e.message}"
76
+ end
77
+ end
78
+
79
+ # ── Job execution ─────────────────────────────────────────────────
80
+
81
+ def on_executor_execute(event)
82
+ payload = event.payload
83
+ backend.histogram(
84
+ "#{METRIC_PREFIX}job_duration_ms",
85
+ event.duration,
86
+ compact(queue: payload[:queue], job_class: payload[:job_class])
87
+ )
88
+ end
89
+
90
+ def on_job_completed(event)
91
+ payload = event.payload
92
+ backend.increment(
93
+ "#{METRIC_PREFIX}queue_job_count", 1,
94
+ compact(queue: payload[:queue], job_class: payload[:job_class], status: "processed")
95
+ )
96
+ end
97
+
98
+ def on_job_failed(event)
99
+ payload = event.payload
100
+ backend.increment(
101
+ "#{METRIC_PREFIX}queue_job_count", 1,
102
+ compact(queue: payload[:queue], job_class: payload[:job_class], status: "failed")
103
+ )
104
+ end
105
+
106
+ def on_job_dead_lettered(event)
107
+ payload = event.payload
108
+ backend.increment(
109
+ "#{METRIC_PREFIX}queue_job_count", 1,
110
+ compact(queue: payload[:queue], job_class: payload[:job_class], status: "dead_lettered")
111
+ )
112
+ end
113
+
114
+ # ── Event handler ─────────────────────────────────────────────────
115
+
116
+ def on_event_processed(event)
117
+ payload = event.payload
118
+ tags = compact(handler: payload[:handler], routing_key: payload[:routing_key])
119
+ backend.histogram("#{METRIC_PREFIX}event_duration_ms", event.duration, tags)
120
+ backend.increment("#{METRIC_PREFIX}event_count", 1, tags.merge(status: "processed"))
121
+ end
122
+
123
+ def on_event_failed(event)
124
+ payload = event.payload
125
+ backend.increment(
126
+ "#{METRIC_PREFIX}event_count", 1,
127
+ compact(handler: payload[:handler], routing_key: payload[:routing_key], status: "failed")
128
+ )
129
+ end
130
+
131
+ # ── Client (PGMQ wrapper) ─────────────────────────────────────────
132
+
133
+ def on_send_message(event)
134
+ backend.increment("#{METRIC_PREFIX}messages_sent", 1, compact(queue: event.payload[:queue]))
135
+ end
136
+
137
+ def on_send_batch(event)
138
+ payload = event.payload
139
+ count = payload[:count] || payload[:batch_size] || 1
140
+ backend.increment("#{METRIC_PREFIX}messages_sent", count, compact(queue: payload[:queue]))
141
+ end
142
+
143
+ def on_read_batch(event)
144
+ payload = event.payload
145
+ count = payload[:count] || payload[:fetched] || 0
146
+ backend.increment("#{METRIC_PREFIX}messages_read", count, compact(queue: payload[:queue]))
147
+ end
148
+
149
+ # ── Streams ───────────────────────────────────────────────────────
150
+
151
+ def on_stream_broadcast(event)
152
+ payload = event.payload
153
+ backend.increment(
154
+ "#{METRIC_PREFIX}stream_broadcast_count", 1,
155
+ compact(stream: payload[:stream], deferred: payload[:deferred] ? "true" : "false")
156
+ )
157
+ end
158
+
159
+ # ── Outbox ────────────────────────────────────────────────────────
160
+
161
+ def on_outbox_publish(event)
162
+ backend.increment(
163
+ "#{METRIC_PREFIX}outbox_published", 1,
164
+ compact(kind: event.payload[:kind] || "job")
165
+ )
166
+ end
167
+
168
+ # ── Recurring scheduler ───────────────────────────────────────────
169
+
170
+ def on_recurring_enqueue(event)
171
+ payload = event.payload
172
+ backend.increment(
173
+ "#{METRIC_PREFIX}recurring_enqueued", 1,
174
+ compact(task: payload[:task], class_name: payload[:class_name])
175
+ )
176
+ end
177
+
178
+ # ── Worker lifecycle ──────────────────────────────────────────────
179
+
180
+ def on_worker_recycle(event)
181
+ backend.increment(
182
+ "#{METRIC_PREFIX}worker_recycled", 1,
183
+ compact(reason: event.payload[:reason], kind: "worker")
184
+ )
185
+ end
186
+
187
+ def on_consumer_recycle(event)
188
+ backend.increment(
189
+ "#{METRIC_PREFIX}worker_recycled", 1,
190
+ compact(reason: event.payload[:reason], kind: "consumer")
191
+ )
192
+ end
193
+
194
+ # ── Connection pool ───────────────────────────────────────────────
195
+
196
+ def on_client_pool(event)
197
+ payload = event.payload
198
+ tags = { hostname: hostname }
199
+ backend.gauge("#{METRIC_PREFIX}pool_size", payload[:size], tags)
200
+ backend.gauge("#{METRIC_PREFIX}pool_available", payload[:available], tags)
201
+ end
202
+
203
+ def hostname
204
+ Socket.gethostname
205
+ end
206
+
207
+ # Drop nil-valued tags so backends don't emit empty labels.
208
+ def compact(tags)
209
+ tags.compact
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # Backend-agnostic metrics adapter.
5
+ #
6
+ # Pgbus emits a family of `pgbus.*` ActiveSupport::Notifications events;
7
+ # `Pgbus::Instrumentation` is the single source of truth for the catalog. The
8
+ # AppSignal integration is one consumer of those
9
+ # events; this namespace is a second, vendor-neutral consumer that forwards the
10
+ # same event→metric mapping to a configurable backend so teams on Prometheus,
11
+ # Datadog, or plain StatsD get metrics without hand-writing subscribers.
12
+ #
13
+ # Opt in via `config.metrics_backend = :prometheus | :statsd | <Backend>`. The
14
+ # default is nil, which installs no subscription (zero overhead). Installation
15
+ # happens from the engine, mirroring the AppSignal initializer.
16
+ module Metrics
17
+ module_function
18
+
19
+ # Coerce a configuration value into a concrete Backend instance.
20
+ #
21
+ # nil -> Backend::Null (no-op)
22
+ # :prometheus -> Backends::Prometheus
23
+ # :statsd -> Backends::Statsd (host/port from configuration)
24
+ # Backend inst -> returned unchanged
25
+ def resolve_backend(value)
26
+ case value
27
+ when nil
28
+ Backend::Null.new
29
+ when :prometheus
30
+ Backends::Prometheus.new
31
+ when :statsd
32
+ config = Pgbus.configuration
33
+ Backends::Statsd.new(host: config.statsd_host, port: config.statsd_port)
34
+ when Backend
35
+ value
36
+ else
37
+ raise ArgumentError,
38
+ "metrics_backend must be nil, :prometheus, :statsd, or a " \
39
+ "Pgbus::Metrics::Backend instance (got #{value.inspect})"
40
+ end
41
+ end
42
+ end
43
+ end