pgbus 0.9.6 → 0.9.8

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +119 -1
  4. data/Rakefile +10 -1
  5. data/app/helpers/pgbus/application_helper.rb +45 -6
  6. data/app/views/pgbus/jobs/show.html.erb +1 -1
  7. data/app/views/pgbus/processes/_processes_table.html.erb +4 -1
  8. data/config/locales/da.yml +4 -0
  9. data/config/locales/de.yml +4 -0
  10. data/config/locales/en.yml +4 -0
  11. data/config/locales/es.yml +4 -0
  12. data/config/locales/fi.yml +4 -0
  13. data/config/locales/fr.yml +4 -0
  14. data/config/locales/it.yml +4 -0
  15. data/config/locales/ja.yml +4 -0
  16. data/config/locales/nb.yml +4 -0
  17. data/config/locales/nl.yml +4 -0
  18. data/config/locales/pt.yml +4 -0
  19. data/config/locales/sv.yml +4 -0
  20. data/lib/pgbus/active_job/executor.rb +25 -4
  21. data/lib/pgbus/cli/dlq.rb +164 -0
  22. data/lib/pgbus/cli.rb +18 -1
  23. data/lib/pgbus/client/connection_health.rb +194 -0
  24. data/lib/pgbus/client.rb +592 -73
  25. data/lib/pgbus/config_loader.rb +23 -4
  26. data/lib/pgbus/configuration.rb +102 -13
  27. data/lib/pgbus/dedup_cache.rb +8 -0
  28. data/lib/pgbus/doctor.rb +250 -0
  29. data/lib/pgbus/engine.rb +15 -0
  30. data/lib/pgbus/execution_pools/async_pool.rb +7 -0
  31. data/lib/pgbus/execution_pools/thread_pool.rb +7 -0
  32. data/lib/pgbus/instrumentation.rb +1 -0
  33. data/lib/pgbus/integrations/appsignal/probe.rb +23 -1
  34. data/lib/pgbus/metrics/backend.rb +38 -0
  35. data/lib/pgbus/metrics/backends/prometheus.rb +123 -0
  36. data/lib/pgbus/metrics/backends/statsd.rb +64 -0
  37. data/lib/pgbus/metrics/prometheus_exporter.rb +34 -0
  38. data/lib/pgbus/metrics/subscriber.rb +190 -0
  39. data/lib/pgbus/metrics.rb +42 -0
  40. data/lib/pgbus/process/consumer.rb +215 -8
  41. data/lib/pgbus/process/consumer_priority.rb +34 -0
  42. data/lib/pgbus/process/dispatcher.rb +265 -41
  43. data/lib/pgbus/process/heartbeat.rb +18 -5
  44. data/lib/pgbus/process/memory_usage.rb +48 -0
  45. data/lib/pgbus/process/notify_listener.rb +26 -7
  46. data/lib/pgbus/process/notify_probe.rb +96 -0
  47. data/lib/pgbus/process/primary_validator.rb +53 -0
  48. data/lib/pgbus/process/signal_handler.rb +6 -0
  49. data/lib/pgbus/process/supervisor.rb +396 -46
  50. data/lib/pgbus/process/worker.rb +298 -35
  51. data/lib/pgbus/recurring/scheduler.rb +15 -1
  52. data/lib/pgbus/streams/turbo_broadcastable.rb +7 -5
  53. data/lib/pgbus/table_maintenance.rb +13 -2
  54. data/lib/pgbus/version.rb +1 -1
  55. data/lib/pgbus/web/data_source.rb +20 -4
  56. data/lib/pgbus/web/health_app.rb +102 -0
  57. data/lib/pgbus/web/health_server.rb +144 -0
  58. data/lib/pgbus/web/payload_filter.rb +68 -0
  59. data/lib/pgbus/web/streamer/instance.rb +55 -1
  60. data/lib/pgbus/web/streamer/listener.rb +72 -9
  61. data/lib/pgbus.rb +37 -0
  62. data/lib/rubocop/cop/pgbus/no_ruby_timeout.rb +42 -0
  63. data/lib/rubocop/pgbus.rb +5 -0
  64. data/lib/tasks/pgbus_doctor.rake +12 -0
  65. metadata +19 -1
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Metrics
5
+ # Interface every metrics backend implements. The Subscriber only ever calls
6
+ # these three methods, so a backend that satisfies them plugs in directly as
7
+ # `config.metrics_backend`.
8
+ #
9
+ # increment(name, value = 1, tags = {}) — monotonic counter add
10
+ # gauge(name, value, tags = {}) — point-in-time value
11
+ # histogram(name, value, tags = {}) — timing/size distribution sample
12
+ #
13
+ # `name` is a `pgbus_`-prefixed String; `tags` is a Hash of low-cardinality
14
+ # label => value pairs (no msg_id, no event_id).
15
+ class Backend
16
+ def increment(_name, _value = 1, _tags = {})
17
+ raise NotImplementedError, "#{self.class}#increment"
18
+ end
19
+
20
+ def gauge(_name, _value, _tags = {})
21
+ raise NotImplementedError, "#{self.class}#gauge"
22
+ end
23
+
24
+ def histogram(_name, _value, _tags = {})
25
+ raise NotImplementedError, "#{self.class}#histogram"
26
+ end
27
+
28
+ # No-op backend. Used as the resolved backend for a nil configuration so
29
+ # callers never have to nil-check, though in practice the Subscriber is
30
+ # simply not installed when metrics_backend is nil.
31
+ class Null < Backend
32
+ def increment(_name, _value = 1, _tags = {}) = nil
33
+ def gauge(_name, _value, _tags = {}) = nil
34
+ def histogram(_name, _value, _tags = {}) = nil
35
+ end
36
+ end
37
+ end
38
+ end
@@ -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,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module Metrics
5
+ # Consumes Pgbus::Instrumentation events and forwards them to the configured
6
+ # metrics backend. The event→metric mapping (names, tags, counter vs
7
+ # histogram) intentionally mirrors the AppSignal subscriber so a team can
8
+ # switch backends and keep the same dashboards.
9
+ #
10
+ # Every handler runs inside `safely`: a raising backend (Prometheus registry
11
+ # bug, StatsD socket down, custom backend) is logged and swallowed — a metrics
12
+ # failure must never propagate into the producer thread that emitted the
13
+ # event.
14
+ module Subscriber
15
+ METRIC_PREFIX = "pgbus_"
16
+ private_constant :METRIC_PREFIX
17
+
18
+ @subscriptions = []
19
+ @backend = nil
20
+
21
+ class << self
22
+ def install!(backend:)
23
+ return false if @installed
24
+
25
+ @backend = backend
26
+ @subscriptions = [
27
+ subscribe("pgbus.executor.execute") { |event| on_executor_execute(event) },
28
+ subscribe("pgbus.job_completed") { |event| on_job_completed(event) },
29
+ subscribe("pgbus.job_failed") { |event| on_job_failed(event) },
30
+ subscribe("pgbus.job_dead_lettered") { |event| on_job_dead_lettered(event) },
31
+ subscribe("pgbus.event_processed") { |event| on_event_processed(event) },
32
+ subscribe("pgbus.event_failed") { |event| on_event_failed(event) },
33
+ subscribe("pgbus.client.send_message") { |event| on_send_message(event) },
34
+ subscribe("pgbus.client.send_batch") { |event| on_send_batch(event) },
35
+ subscribe("pgbus.client.read_batch") { |event| on_read_batch(event) },
36
+ subscribe("pgbus.stream.broadcast") { |event| on_stream_broadcast(event) },
37
+ subscribe("pgbus.outbox.publish") { |event| on_outbox_publish(event) },
38
+ subscribe("pgbus.recurring.enqueue") { |event| on_recurring_enqueue(event) },
39
+ subscribe("pgbus.worker.recycle") { |event| on_worker_recycle(event) }
40
+ ]
41
+ @installed = true
42
+ end
43
+
44
+ def installed?
45
+ @installed == true
46
+ end
47
+
48
+ def reset!
49
+ @subscriptions&.each { |s| ActiveSupport::Notifications.unsubscribe(s) }
50
+ @subscriptions = []
51
+ @backend = nil
52
+ @installed = false
53
+ end
54
+
55
+ private
56
+
57
+ attr_reader :backend
58
+
59
+ def subscribe(name, &block)
60
+ ActiveSupport::Notifications.subscribe(name) do |*args|
61
+ event = ActiveSupport::Notifications::Event.new(*args)
62
+ safely { block.call(event) }
63
+ end
64
+ end
65
+
66
+ # Errors in the subscriber must never affect the producer thread.
67
+ def safely
68
+ yield
69
+ rescue StandardError => e
70
+ Pgbus.logger.warn do
71
+ "[Pgbus::Metrics] subscriber error: #{e.class}: #{e.message}"
72
+ end
73
+ end
74
+
75
+ # ── Job execution ─────────────────────────────────────────────────
76
+
77
+ def on_executor_execute(event)
78
+ payload = event.payload
79
+ backend.histogram(
80
+ "#{METRIC_PREFIX}job_duration_ms",
81
+ event.duration,
82
+ compact(queue: payload[:queue], job_class: payload[:job_class])
83
+ )
84
+ end
85
+
86
+ def on_job_completed(event)
87
+ payload = event.payload
88
+ backend.increment(
89
+ "#{METRIC_PREFIX}queue_job_count", 1,
90
+ compact(queue: payload[:queue], job_class: payload[:job_class], status: "processed")
91
+ )
92
+ end
93
+
94
+ def on_job_failed(event)
95
+ payload = event.payload
96
+ backend.increment(
97
+ "#{METRIC_PREFIX}queue_job_count", 1,
98
+ compact(queue: payload[:queue], job_class: payload[:job_class], status: "failed")
99
+ )
100
+ end
101
+
102
+ def on_job_dead_lettered(event)
103
+ payload = event.payload
104
+ backend.increment(
105
+ "#{METRIC_PREFIX}queue_job_count", 1,
106
+ compact(queue: payload[:queue], job_class: payload[:job_class], status: "dead_lettered")
107
+ )
108
+ end
109
+
110
+ # ── Event handler ─────────────────────────────────────────────────
111
+
112
+ def on_event_processed(event)
113
+ payload = event.payload
114
+ tags = compact(handler: payload[:handler], routing_key: payload[:routing_key])
115
+ backend.histogram("#{METRIC_PREFIX}event_duration_ms", event.duration, tags)
116
+ backend.increment("#{METRIC_PREFIX}event_count", 1, tags.merge(status: "processed"))
117
+ end
118
+
119
+ def on_event_failed(event)
120
+ payload = event.payload
121
+ backend.increment(
122
+ "#{METRIC_PREFIX}event_count", 1,
123
+ compact(handler: payload[:handler], routing_key: payload[:routing_key], status: "failed")
124
+ )
125
+ end
126
+
127
+ # ── Client (PGMQ wrapper) ─────────────────────────────────────────
128
+
129
+ def on_send_message(event)
130
+ backend.increment("#{METRIC_PREFIX}messages_sent", 1, compact(queue: event.payload[:queue]))
131
+ end
132
+
133
+ def on_send_batch(event)
134
+ payload = event.payload
135
+ count = payload[:count] || payload[:batch_size] || 1
136
+ backend.increment("#{METRIC_PREFIX}messages_sent", count, compact(queue: payload[:queue]))
137
+ end
138
+
139
+ def on_read_batch(event)
140
+ payload = event.payload
141
+ count = payload[:count] || payload[:fetched] || 0
142
+ backend.increment("#{METRIC_PREFIX}messages_read", count, compact(queue: payload[:queue]))
143
+ end
144
+
145
+ # ── Streams ───────────────────────────────────────────────────────
146
+
147
+ def on_stream_broadcast(event)
148
+ payload = event.payload
149
+ backend.increment(
150
+ "#{METRIC_PREFIX}stream_broadcast_count", 1,
151
+ compact(stream: payload[:stream], deferred: payload[:deferred] ? "true" : "false")
152
+ )
153
+ end
154
+
155
+ # ── Outbox ────────────────────────────────────────────────────────
156
+
157
+ def on_outbox_publish(event)
158
+ backend.increment(
159
+ "#{METRIC_PREFIX}outbox_published", 1,
160
+ compact(kind: event.payload[:kind] || "job")
161
+ )
162
+ end
163
+
164
+ # ── Recurring scheduler ───────────────────────────────────────────
165
+
166
+ def on_recurring_enqueue(event)
167
+ payload = event.payload
168
+ backend.increment(
169
+ "#{METRIC_PREFIX}recurring_enqueued", 1,
170
+ compact(task: payload[:task], class_name: payload[:class_name])
171
+ )
172
+ end
173
+
174
+ # ── Worker lifecycle ──────────────────────────────────────────────
175
+
176
+ def on_worker_recycle(event)
177
+ backend.increment(
178
+ "#{METRIC_PREFIX}worker_recycled", 1,
179
+ compact(reason: event.payload[:reason])
180
+ )
181
+ end
182
+
183
+ # Drop nil-valued tags so backends don't emit empty labels.
184
+ def compact(tags)
185
+ tags.compact
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # Backend-agnostic metrics adapter.
5
+ #
6
+ # Pgbus emits ~16 `pgbus.*` ActiveSupport::Notifications events (see
7
+ # Pgbus::Instrumentation). The AppSignal integration is one consumer of those
8
+ # events; this namespace is a second, vendor-neutral consumer that forwards the
9
+ # same event→metric mapping to a configurable backend so teams on Prometheus,
10
+ # Datadog, or plain StatsD get metrics without hand-writing subscribers.
11
+ #
12
+ # Opt in via `config.metrics_backend = :prometheus | :statsd | <Backend>`. The
13
+ # default is nil, which installs no subscription (zero overhead). Installation
14
+ # happens from the engine, mirroring the AppSignal initializer.
15
+ module Metrics
16
+ module_function
17
+
18
+ # Coerce a configuration value into a concrete Backend instance.
19
+ #
20
+ # nil -> Backend::Null (no-op)
21
+ # :prometheus -> Backends::Prometheus
22
+ # :statsd -> Backends::Statsd (host/port from configuration)
23
+ # Backend inst -> returned unchanged
24
+ def resolve_backend(value)
25
+ case value
26
+ when nil
27
+ Backend::Null.new
28
+ when :prometheus
29
+ Backends::Prometheus.new
30
+ when :statsd
31
+ config = Pgbus.configuration
32
+ Backends::Statsd.new(host: config.statsd_host, port: config.statsd_port)
33
+ when Backend
34
+ value
35
+ else
36
+ raise ArgumentError,
37
+ "metrics_backend must be nil, :prometheus, :statsd, or a " \
38
+ "Pgbus::Metrics::Backend instance (got #{value.inspect})"
39
+ end
40
+ end
41
+ end
42
+ end