pgbus 0.16.3 → 0.16.5

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/app/controllers/pgbus/locks_controller.rb +27 -0
  4. data/app/frontend/pgbus/style.css +1 -1
  5. data/app/models/pgbus/blocked_execution.rb +33 -7
  6. data/app/models/pgbus/semaphore.rb +8 -3
  7. data/app/models/pgbus/uniqueness_key.rb +36 -0
  8. data/app/views/pgbus/dashboard/_stats_cards.html.erb +14 -1
  9. data/app/views/pgbus/locks/_concurrency.html.erb +94 -0
  10. data/app/views/pgbus/locks/_uniqueness.html.erb +80 -0
  11. data/app/views/pgbus/locks/index.html.erb +5 -76
  12. data/config/locales/da.yml +42 -1
  13. data/config/locales/de.yml +42 -1
  14. data/config/locales/en.yml +42 -1
  15. data/config/locales/es.yml +42 -1
  16. data/config/locales/fi.yml +42 -1
  17. data/config/locales/fr.yml +42 -1
  18. data/config/locales/it.yml +42 -1
  19. data/config/locales/ja.yml +42 -1
  20. data/config/locales/nb.yml +42 -1
  21. data/config/locales/nl.yml +42 -1
  22. data/config/locales/pt.yml +42 -1
  23. data/config/locales/sv.yml +42 -1
  24. data/config/routes.rb +5 -0
  25. data/lib/pgbus/active_job/adapter.rb +83 -9
  26. data/lib/pgbus/active_job/executor.rb +55 -15
  27. data/lib/pgbus/concurrency/blocked_execution.rb +59 -18
  28. data/lib/pgbus/concurrency/semaphore.rb +34 -0
  29. data/lib/pgbus/concurrency.rb +36 -2
  30. data/lib/pgbus/instrumentation.rb +3 -0
  31. data/lib/pgbus/integrations/appsignal/dashboard.json +38 -0
  32. data/lib/pgbus/integrations/appsignal/probe.rb +10 -0
  33. data/lib/pgbus/mcp/base_tool.rb +6 -1
  34. data/lib/pgbus/mcp/server.rb +2 -1
  35. data/lib/pgbus/mcp/tools/concurrency_tool.rb +32 -0
  36. data/lib/pgbus/mcp.rb +1 -0
  37. data/lib/pgbus/process/dispatcher.rb +6 -13
  38. data/lib/pgbus/version.rb +1 -1
  39. data/lib/pgbus/visibility_heartbeat.rb +24 -3
  40. data/lib/pgbus/web/data_source.rb +210 -1
  41. data/lib/pgbus/web/metrics_serializer.rb +51 -3
  42. metadata +4 -1
@@ -7,6 +7,10 @@ module Pgbus
7
7
  extend ActiveSupport::Concern
8
8
 
9
9
  METADATA_KEY = "pgbus_concurrency_key"
10
+ # How long a slot holder may go silent (no visibility heartbeat) before
11
+ # its slot is presumed dead. Also the limit/duration used to promote a
12
+ # parked job whose class no longer resolves.
13
+ DEFAULT_DURATION = 15 * 60
10
14
 
11
15
  class_methods do
12
16
  # Limit concurrent execution of jobs with the same key.
@@ -20,9 +24,12 @@ module Pgbus
20
24
  # Default: the ENQUEUED job's class name (resolved at
21
25
  # resolve time, so an inherited declaration keys each
22
26
  # subclass separately — issue #357).
23
- # duration: Safety expiry for semaphore (default: 15 minutes)
27
+ # duration: How long a running holder may go without a visibility
28
+ # heartbeat before its slot is presumed dead and swept
29
+ # (default: 15 minutes). Not a cap on run time: the
30
+ # heartbeat keeps the semaphore alive while the job runs.
24
31
  # on_conflict: What to do when limit is reached — :block, :discard, or :raise (default: :block)
25
- def limits_concurrency(to:, key: nil, duration: 15 * 60, on_conflict: :block) # rubocop:disable Naming/MethodParameterName
32
+ def limits_concurrency(to:, key: nil, duration: DEFAULT_DURATION, on_conflict: :block) # rubocop:disable Naming/MethodParameterName
26
33
  raise ArgumentError, "to: must be a positive integer" unless to.is_a?(Integer) && to.positive?
27
34
  raise ArgumentError, "on_conflict must be :block, :discard, or :raise" unless %i[block discard raise].include?(on_conflict)
28
35
  raise ArgumentError, "duration must be a positive number" unless duration.is_a?(Numeric) && duration.positive?
@@ -71,6 +78,33 @@ module Pgbus
71
78
  def extract_key(payload)
72
79
  payload[METADATA_KEY]
73
80
  end
81
+
82
+ # Limit and duration for a job class. A class with no concurrency
83
+ # config — or one that no longer resolves — gets `limit: nil`, meaning
84
+ # "whatever limit the semaphore row already records". Forcing 1 here
85
+ # would refuse every promotion for a `to: 3` key that still holds two
86
+ # slots, so its parked jobs could never reach the executor (which is
87
+ # what dead-letters a missing class).
88
+ def config_for(job_class)
89
+ config = job_class.respond_to?(:pgbus_concurrency) && job_class.pgbus_concurrency
90
+ return { limit: nil, duration: DEFAULT_DURATION } unless config
91
+
92
+ { limit: config[:limit], duration: config[:duration] }
93
+ end
94
+
95
+ # A slot's lease is only renewed by the visibility heartbeat, which
96
+ # first beats one interval after the job starts. A `duration` shorter
97
+ # than that would expire before the first touch and let the sweep
98
+ # promote beside a running job, so it is floored at two intervals.
99
+ def effective_duration(duration, config: Pgbus.configuration)
100
+ [duration, config.effective_visibility_heartbeat_interval * 2].max
101
+ end
102
+
103
+ def config_for_payload(payload)
104
+ config_for(Object.const_get(payload["job_class"].to_s))
105
+ rescue NameError
106
+ config_for(nil)
107
+ end
74
108
  end
75
109
  end
76
110
  end
@@ -29,6 +29,9 @@ module Pgbus
29
29
  # pgbus.serializer.deserialize — job/event deserialization
30
30
  # pgbus.batch_finished — batch flipped to finished
31
31
  # payload: batch_id, total_jobs, completed_jobs, failed_jobs
32
+ # pgbus.blocked_execution_discarded — a parked job was discarded from the
33
+ # dashboard; it will never run
34
+ # payload: concurrency_key, job_class, job_id
32
35
  # pgbus.batch_sweep — dispatcher stalled-batch sweep
33
36
  # payload: stalled_for, stale_executions, orphan_rows,
34
37
  # started_batches, finished_batches
@@ -355,6 +355,44 @@
355
355
  "tags": []
356
356
  }
357
357
  ]
358
+ },
359
+ {
360
+ "type": "timeseries",
361
+ "display": "LINE",
362
+ "title": "Concurrency",
363
+ "description": "Jobs parked behind a concurrency key, how long the oldest has waited, and slots held across all keys.",
364
+ "line_label": "%name%",
365
+ "format": "number",
366
+ "draw_null_as_zero": true,
367
+ "metrics": [
368
+ {
369
+ "name": "pgbus_concurrency_blocked_executions",
370
+ "fields": [
371
+ {
372
+ "field": "gauge"
373
+ }
374
+ ],
375
+ "tags": []
376
+ },
377
+ {
378
+ "name": "pgbus_concurrency_blocked_oldest_age_seconds",
379
+ "fields": [
380
+ {
381
+ "field": "gauge"
382
+ }
383
+ ],
384
+ "tags": []
385
+ },
386
+ {
387
+ "name": "pgbus_concurrency_slots_held",
388
+ "fields": [
389
+ {
390
+ "field": "gauge"
391
+ }
392
+ ],
393
+ "tags": []
394
+ }
395
+ ]
358
396
  }
359
397
  ]
360
398
  }
@@ -60,6 +60,11 @@ module Pgbus
60
60
  def call
61
61
  return unless data_source
62
62
 
63
+ # One Runner lives for the life of the process, so its DataSource
64
+ # does too — without this, every memoized read (queue metrics, the
65
+ # concurrency aggregates) would report the first minute's numbers
66
+ # forever.
67
+ data_source.reset_cache! if data_source.respond_to?(:reset_cache!)
63
68
  track_queues
64
69
  track_processes
65
70
  track_summary
@@ -116,6 +121,11 @@ module Pgbus
116
121
  gauge "total_dead_tuples", stats[:total_dead_tuples]
117
122
  gauge "tables_needing_vacuum", stats[:tables_needing_vacuum]
118
123
  gauge "oldest_transaction_age_seconds", stats[:oldest_transaction_age_sec]
124
+ # Unlabelled, same rationale as the /pgbus/api/metrics family:
125
+ # concurrency keys are per-record and would be unbounded as tags.
126
+ gauge "concurrency_blocked_executions", stats[:parked_total]
127
+ gauge "concurrency_blocked_oldest_age_seconds", stats[:oldest_parked_age_sec]
128
+ gauge "concurrency_slots_held", stats[:slots_held]
119
129
  rescue StandardError => e
120
130
  log_failure("summary metrics", e)
121
131
  end
@@ -36,8 +36,13 @@ module Pgbus
36
36
  # Pull the injected DataSource (or build a default one). Kept as a
37
37
  # class method because MCP tool entry points (`self.call`) are class
38
38
  # methods.
39
+ # The server injects ONE DataSource for the life of the process, so its
40
+ # per-request memos have to be dropped per tool call — otherwise every
41
+ # call after the first replays the first call's snapshot.
39
42
  def data_source_from(server_context)
40
- (server_context && server_context[:data_source]) || Pgbus::Web::DataSource.new
43
+ data_source = (server_context && server_context[:data_source]) || Pgbus::Web::DataSource.new
44
+ data_source.reset_cache! if data_source.respond_to?(:reset_cache!)
45
+ data_source
41
46
  end
42
47
 
43
48
  # Whether payloads may be returned for this call. Honors a per-call
@@ -23,6 +23,7 @@ module Pgbus
23
23
  Tools::DlqTool,
24
24
  Tools::DlqDetailTool,
25
25
  Tools::LocksTool,
26
+ Tools::ConcurrencyTool,
26
27
  Tools::ThroughputTool,
27
28
  Tools::StatsTool,
28
29
  Tools::RecurringTool
@@ -32,7 +33,7 @@ module Pgbus
32
33
  Read-only diagnostic tools for a pgbus (PostgreSQL/PGMQ) deployment.
33
34
  Start with pgbus_health for a one-call OK/DEGRADED/STALLED verdict,
34
35
  then drill in with pgbus_queues, pgbus_processes, pgbus_jobs,
35
- pgbus_dlq, and pgbus_locks. No tool mutates state. Message payloads
36
+ pgbus_dlq, pgbus_locks, and pgbus_concurrency. No tool mutates state. Message payloads
36
37
  are redacted unless the server was started with payloads explicitly
37
38
  allowed.
38
39
  TEXT
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ module MCP
5
+ module Tools
6
+ # Reports concurrency-key pressure: how many jobs are parked behind a
7
+ # `limits_concurrency` key, how long the oldest has waited, how many slots
8
+ # are held, and the per-key detail behind those numbers. Maps to
9
+ # DataSource#concurrency_stats. Carries no job payloads — only key names,
10
+ # counts and lease state — so there is nothing to redact.
11
+ class ConcurrencyTool < BaseTool
12
+ tool_name "pgbus_concurrency"
13
+ title "Pgbus Concurrency"
14
+ description <<~DESC
15
+ Report concurrency keys and the jobs parked behind them: total parked
16
+ jobs, the oldest parked job's wait in seconds, slots held, and keys at
17
+ their limit — plus up to 100 key rows with value/limit, lease expiry,
18
+ whether the lease is still fresh, parked count and oldest wait. A key
19
+ with a stale lease and a parked backlog is a stuck pipeline: its holder
20
+ died before releasing the slot.
21
+ DESC
22
+
23
+ input_schema(properties: {}, required: [])
24
+
25
+ def self.call(server_context: nil)
26
+ data_source = data_source_from(server_context)
27
+ json_response(data_source.concurrency_stats)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
data/lib/pgbus/mcp.rb CHANGED
@@ -24,6 +24,7 @@ module Pgbus
24
24
  pgbus/mcp/tools/dlq_tool
25
25
  pgbus/mcp/tools/dlq_detail_tool
26
26
  pgbus/mcp/tools/locks_tool
27
+ pgbus/mcp/tools/concurrency_tool
27
28
  pgbus/mcp/tools/throughput_tool
28
29
  pgbus/mcp/tools/stats_tool
29
30
  pgbus/mcp/tools/recurring_tool
@@ -355,25 +355,18 @@ module Pgbus
355
355
  end
356
356
 
357
357
  def cleanup_concurrency
358
- expired_keys = Concurrency::Semaphore.expire_stale
359
- expired_keys.each do |row|
360
- release_blocked_for_key(row["key"])
361
- end
358
+ expired = Concurrency::Semaphore.expire_stale
359
+ Pgbus.logger.debug { "[Pgbus] Swept #{expired.size} expired semaphores" } if expired.any?
362
360
 
363
- orphaned = Concurrency::BlockedExecution.expire_stale
364
- Pgbus.logger.debug { "[Pgbus] Expired #{orphaned} orphaned blocked executions" } if orphaned.positive?
361
+ # A parked job is never dropped: the sweep only promotes what can take
362
+ # a slot now behind a swept semaphore or a promote that failed.
363
+ promoted = Concurrency::BlockedExecution.promote_pending(client: Pgbus.client)
364
+ Pgbus.logger.debug { "[Pgbus] Promoted #{promoted} blocked executions" } if promoted.positive?
365
365
  rescue StandardError => e
366
366
  log_maintenance_failure("Concurrency cleanup", e)
367
367
  e
368
368
  end
369
369
 
370
- def release_blocked_for_key(key)
371
- promoted = Concurrency::BlockedExecution.promote_next(key, client: Pgbus.client)
372
- Pgbus.logger.debug { "[Pgbus] Released blocked execution for key: #{key}" } if promoted
373
- rescue StandardError => e
374
- log_maintenance_failure("Releasing blocked execution for #{key}", e)
375
- end
376
-
377
370
  def cleanup_batches
378
371
  retention = config.batch_retention
379
372
  return unless retention&.positive?
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.3"
4
+ VERSION = "0.16.5"
5
5
  end
@@ -24,8 +24,10 @@ module Pgbus
24
24
  # cadence with `config.visibility_heartbeat_interval`, or opt a job class
25
25
  # out with `pgbus_visibility_heartbeat false`.
26
26
  module VisibilityHeartbeat
27
+ # `concurrency` is `[key, duration]` for a concurrency-limited job, else
28
+ # nil — one member, so the struct stays inside an 80-byte slot.
27
29
  Entry = Struct.new(:client, :queue_name, :prefixed, :msg_id, :job_class, :extended_at, :extensions,
28
- keyword_init: true)
30
+ :concurrency, keyword_init: true)
29
31
 
30
32
  # Per-job opt-out, included on ActiveJob::Base by the engine:
31
33
  #
@@ -55,11 +57,15 @@ module Pgbus
55
57
  # @param prefixed [Boolean] whether queue_name still needs the prefix
56
58
  # @param job_class [String, nil] for logging and instrumentation
57
59
  # @param config [Pgbus::Configuration]
58
- def track(client:, queue_name:, msg_id:, prefixed: true, job_class: nil, config: Pgbus.configuration)
60
+ # @param concurrency [Array(String, Numeric), nil] semaphore key to keep alive alongside
61
+ # the message and how far to push its expiry on each beat
62
+ def track(client:, queue_name:, msg_id:, prefixed: true, job_class: nil, config: Pgbus.configuration,
63
+ concurrency: nil)
59
64
  return yield unless config.visibility_heartbeat
60
65
 
61
66
  entry = Entry.new(client: client, queue_name: queue_name, prefixed: prefixed, msg_id: msg_id.to_i,
62
- job_class: job_class, extended_at: monotonic_now, extensions: 0)
67
+ job_class: job_class, extended_at: monotonic_now, extensions: 0,
68
+ concurrency: concurrency)
63
69
  register(entry, config)
64
70
  begin
65
71
  yield
@@ -123,6 +129,7 @@ module Pgbus
123
129
  entry.client.set_visibility_timeout(entry.queue_name, entry.msg_id, vt: vt, prefixed: entry.prefixed)
124
130
  entry.extended_at = now
125
131
  entry.extensions += 1
132
+ touch_semaphore(entry)
126
133
  Instrumentation.instrument(
127
134
  "pgbus.job_visibility_extended",
128
135
  queue: entry.queue_name, job_class: entry.job_class, msg_id: entry.msg_id, vt: vt,
@@ -165,6 +172,20 @@ module Pgbus
165
172
 
166
173
  # Entries registered before a fork belong to the parent's jobs; the
167
174
  # thread did not survive the fork either.
175
+ # A live holder keeps its semaphore from being swept, so `duration`
176
+ # bounds heartbeat silence rather than run time. Its own rescue: a
177
+ # failed touch must not cost the message its visibility extension.
178
+ def touch_semaphore(entry)
179
+ key, duration = entry.concurrency
180
+ return unless key
181
+
182
+ Concurrency::Semaphore.touch(key, duration || Concurrency::DEFAULT_DURATION)
183
+ rescue StandardError => e
184
+ Pgbus.logger.warn do
185
+ "[Pgbus::VisibilityHeartbeat] could not touch semaphore #{key}: #{e.class}: #{e.message}"
186
+ end
187
+ end
188
+
168
189
  def forget_parent_entries!
169
190
  return if @pid == ::Process.pid
170
191
 
@@ -5,6 +5,11 @@ require "time"
5
5
  module Pgbus
6
6
  module Web
7
7
  class DataSource
8
+ # Ceiling on how many parked jobs one dashboard release promotes, so a
9
+ # key with thousands parked cannot hold the request open. The dispatcher
10
+ # sweep picks up whatever is left on its next pass.
11
+ PROMOTE_CAP = 100
12
+
8
13
  def initialize(client: Pgbus.client)
9
14
  @client = client
10
15
  @last_throughput_snapshot = nil
@@ -35,7 +40,18 @@ module Pgbus
35
40
  total_dead_tuples: health[:total_dead_tuples],
36
41
  tables_needing_vacuum: health[:tables_needing_vacuum],
37
42
  oldest_transaction_age_sec: health[:oldest_transaction_age_sec]
38
- }
43
+ }.merge(concurrency_summary)
44
+ end
45
+
46
+ # Drop the per-instance memos. The memos assume one instance per web
47
+ # request; the AppSignal probe and the MCP server both hold ONE DataSource
48
+ # for the life of the process, so without this their gauges and tool
49
+ # responses would freeze at the first read. Both call this per iteration
50
+ # / per tool call.
51
+ def reset_cache!
52
+ @queues_with_metrics = nil
53
+ @concurrency_summary = nil
54
+ self
39
55
  end
40
56
 
41
57
  # Queues — query via ActiveRecord for reliability in web processes
@@ -667,6 +683,83 @@ module Pgbus
667
683
  []
668
684
  end
669
685
 
686
+ # Concurrency keys — the `limits_concurrency` slot table and the jobs
687
+ # parked behind it. Aggregate numbers plus up to 100 key rows, busiest
688
+ # first. The two tables are joined FULL OUTER, not LEFT: a key can have
689
+ # parked rows and no semaphore (its holder died and the sweep removed the
690
+ # row) or a semaphore and nothing parked — an operator needs to see both.
691
+ def concurrency_stats
692
+ concurrency_summary.merge(keys: concurrency_keys)
693
+ end
694
+
695
+ # Drop a key's semaphore row and promote whatever can now run.
696
+ #
697
+ # Promotion goes through Concurrency::BlockedExecution.promote_next, which
698
+ # takes each slot through the same guarded upsert an enqueue uses — a
699
+ # dashboard release can no more over-admit than an enqueue can. Re-sending
700
+ # the parked payloads directly would reintroduce exactly the over-admission
701
+ # issue #460 closed. Returns the number of jobs promoted.
702
+ def release_concurrency_key(key)
703
+ return 0 if key.to_s.strip.empty?
704
+
705
+ # Zero the held slots rather than DELETE the row: the row is where the
706
+ # key's limit is recorded, and `Semaphore.acquire!` falls back to a
707
+ # limit of 1 on a fresh row. Deleting it would silently demote a
708
+ # `to: 3` key to 1 whenever the parked job's class no longer resolves
709
+ # (the payload then carries no limit for promotion to use). A row left
710
+ # at 0 is reaped by the dispatcher's `Semaphore.expired` sweep.
711
+ Pgbus::Semaphore.where(key: key).update_all(value: 0)
712
+ promoted = 0
713
+ promoted += 1 while promoted < PROMOTE_CAP &&
714
+ Concurrency::BlockedExecution.promote_next(key, client: @client)
715
+
716
+ # Nothing took the freed slot, so mark the empty row expired: the
717
+ # dispatcher's `Concurrency::Semaphore.expire_stale` matches on
718
+ # expires_at ALONE, so a zeroed row would otherwise sit on the Locks
719
+ # page as a phantom 0/N key until its original lease ran out — up to
720
+ # the key's whole `duration`. Guarded on value = 0 so a row a promotion
721
+ # just filled is never expired out from under its holder.
722
+ Pgbus::Semaphore.where(key: key, value: 0).update_all(expires_at: Time.current)
723
+ promoted
724
+ rescue StandardError => e
725
+ Pgbus.logger.debug { "[Pgbus::Web] Error releasing concurrency key #{key}: #{e.message}" }
726
+ 0
727
+ end
728
+
729
+ # Drop every job parked behind a key. The jobs never run, so the
730
+ # bookkeeping they would have resolved has to be resolved here: a parked
731
+ # batch child is marked failed (otherwise its batch waits forever and
732
+ # on_failure never fires — issue #413) and an :until_executed uniqueness
733
+ # lock is released (otherwise it is orphaned, since no executor will ever
734
+ # release it — issue #423). Returns the number of jobs discarded.
735
+ def discard_parked_jobs(key)
736
+ return 0 if key.to_s.strip.empty?
737
+
738
+ rows = []
739
+ Pgbus::BlockedExecution.transaction do
740
+ # SKIP LOCKED so this can never race a concurrent promote, which
741
+ # deletes its row under the same lock before enqueueing it.
742
+ rows = Pgbus::BlockedExecution.for_key(key).lock("FOR UPDATE SKIP LOCKED").to_a
743
+ Pgbus::BlockedExecution.where(id: rows.map(&:id)).delete_all if rows.any?
744
+ end
745
+
746
+ # Cleanup runs after the claim commits, because the SKIP LOCKED claim
747
+ # only holds inside the transaction. The residual: a process killed
748
+ # between the commit and the cleanup leaves a resolved-nothing orphan
749
+ # that Batch::Sweep un-counts rather than fails. Running the cleanup
750
+ # inside the transaction would swap that for the failure this codebase
751
+ # has already judged worse (Concurrency::BlockedExecution#backfill) —
752
+ # `Batch.job_discarded` can enqueue a batch callback, and a callback
753
+ # fired for a batch that then rolls back is not recoverable, while an
754
+ # orphan row is. Each row is cleaned under its own rescue, so one bad
755
+ # payload never costs the rest.
756
+ rows.each { |row| cleanup_discarded_parked_job(row) }
757
+ rows.size
758
+ rescue StandardError => e
759
+ Pgbus.logger.debug { "[Pgbus::Web] Error discarding parked jobs for #{key}: #{e.message}" }
760
+ 0
761
+ end
762
+
670
763
  # Batches
671
764
  def batches(limit: 100)
672
765
  records = BatchEntry.order(created_at: :desc).limit(limit).to_a
@@ -1026,6 +1119,122 @@ module Pgbus
1026
1119
  Pgbus::BusRecord.connection
1027
1120
  end
1028
1121
 
1122
+ # Aggregate concurrency numbers, memoized for the lifetime of this
1123
+ # instance: summary_stats, the metrics serializer and the AppSignal probe
1124
+ # all read them on the same request, and concurrency_stats merges them in
1125
+ # alongside the key rows. Mutations redirect to a fresh request with a new
1126
+ # instance, so the memo never serves stale numbers.
1127
+ def concurrency_summary
1128
+ @concurrency_summary ||= fetch_concurrency_summary
1129
+ end
1130
+
1131
+ def fetch_concurrency_summary
1132
+ row = connection.select_all(<<~SQL, "Pgbus Concurrency Summary").to_a.first || {}
1133
+ SELECT
1134
+ (SELECT COUNT(*) FROM pgbus_blocked_executions) AS parked_total,
1135
+ (SELECT EXTRACT(EPOCH FROM (now() - MIN(created_at)))::bigint
1136
+ FROM pgbus_blocked_executions) AS oldest_parked_age_sec,
1137
+ (SELECT COALESCE(SUM(value), 0) FROM pgbus_semaphores) AS slots_held,
1138
+ (SELECT COUNT(*) FROM pgbus_semaphores WHERE value >= max_value) AS keys_at_limit
1139
+ SQL
1140
+
1141
+ {
1142
+ parked_total: row["parked_total"].to_i,
1143
+ oldest_parked_age_sec: row["oldest_parked_age_sec"]&.to_i,
1144
+ slots_held: row["slots_held"].to_i,
1145
+ keys_at_limit: row["keys_at_limit"].to_i
1146
+ }
1147
+ rescue StandardError => e
1148
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching concurrency summary: #{e.message}" }
1149
+ { parked_total: 0, oldest_parked_age_sec: nil, slots_held: 0, keys_at_limit: 0 }
1150
+ end
1151
+
1152
+ # `lease_fresh` is the one fact an operator needs before releasing a key:
1153
+ # a live lease means a holder is probably still running, so releasing lets
1154
+ # another job start beside it.
1155
+ def concurrency_keys(limit: 100)
1156
+ rows = connection.select_all(<<~SQL, "Pgbus Concurrency Keys")
1157
+ SELECT COALESCE(s.key, b.concurrency_key) AS key,
1158
+ s.value AS value,
1159
+ s.max_value AS max_value,
1160
+ s.expires_at AS expires_at,
1161
+ (s.value > 0 AND s.expires_at > now()) AS lease_fresh,
1162
+ COALESCE(b.parked_count, 0) AS parked_count,
1163
+ EXTRACT(EPOCH FROM (now() - b.oldest_parked_at))::bigint AS oldest_parked_age_sec
1164
+ FROM pgbus_semaphores s
1165
+ FULL OUTER JOIN (
1166
+ SELECT concurrency_key, COUNT(*) AS parked_count, MIN(created_at) AS oldest_parked_at
1167
+ FROM pgbus_blocked_executions
1168
+ GROUP BY concurrency_key
1169
+ ) b ON s.key = b.concurrency_key
1170
+ ORDER BY COALESCE(b.parked_count, 0) DESC, s.expires_at ASC NULLS LAST
1171
+ LIMIT #{limit.to_i}
1172
+ SQL
1173
+
1174
+ rows.to_a.map { |row| format_concurrency_key(row) }
1175
+ rescue StandardError => e
1176
+ Pgbus.logger.debug { "[Pgbus::Web] Error fetching concurrency keys: #{e.message}" }
1177
+ []
1178
+ end
1179
+
1180
+ def format_concurrency_key(row)
1181
+ {
1182
+ key: row["key"],
1183
+ value: row["value"]&.to_i,
1184
+ max_value: row["max_value"]&.to_i,
1185
+ expires_at: row["expires_at"],
1186
+ lease_fresh: [true, "t"].include?(row["lease_fresh"]),
1187
+ parked_count: row["parked_count"].to_i,
1188
+ oldest_parked_age_sec: row["oldest_parked_age_sec"]&.to_i
1189
+ }
1190
+ end
1191
+
1192
+ # Resolve the bookkeeping a discarded parked job will never resolve
1193
+ # itself. Per-row rather than batched: one bad payload must not stop the
1194
+ # rest from being cleaned up, and the rows are already deleted.
1195
+ def cleanup_discarded_parked_job(row)
1196
+ payload = decode_parked_payload(row.payload)
1197
+ batch_id = payload[Batch::METADATA_KEY]
1198
+ Batch.job_discarded(batch_id, job_id: payload["job_id"]) if batch_id
1199
+
1200
+ release_parked_uniqueness_lock(payload, row.created_at)
1201
+
1202
+ Pgbus.logger.warn do
1203
+ "[Pgbus::Web] Discarded parked job #{payload["job_class"]} (#{payload["job_id"]}) " \
1204
+ "for concurrency key #{row.concurrency_key}"
1205
+ end
1206
+ Instrumentation.instrument(
1207
+ "pgbus.blocked_execution_discarded",
1208
+ concurrency_key: row.concurrency_key,
1209
+ job_class: payload["job_class"],
1210
+ job_id: payload["job_id"]
1211
+ )
1212
+ rescue StandardError => e
1213
+ Pgbus.logger.warn { "[Pgbus::Web] Parked job cleanup failed: #{e.class}: #{e.message}" }
1214
+ end
1215
+
1216
+ # release_if_unbound!, not release_lock: a parked job's lock is unbound (it
1217
+ # never got a msg_id). If the reaper already removed it and a successor
1218
+ # took the same key, that successor's lock must survive — msg_id = 0
1219
+ # excludes one that was sent, and the parked row's own created_at excludes
1220
+ # one acquired after this row was parked.
1221
+ def release_parked_uniqueness_lock(payload, parked_at)
1222
+ key = payload[Uniqueness::METADATA_KEY]
1223
+ return unless key && payload[Uniqueness::STRATEGY_KEY].to_s == "until_executed"
1224
+
1225
+ UniquenessKey.release_if_unbound!(key, acquired_before: parked_at)
1226
+ end
1227
+
1228
+ # A row parked before the double-encoding fix stores a jsonb *string*
1229
+ # holding the document — the same two-pass unwrap BlockedExecution
1230
+ # .release_next! does.
1231
+ def decode_parked_payload(payload)
1232
+ 2.times { payload = JSON.parse(payload) if payload.is_a?(String) }
1233
+ payload.is_a?(Hash) ? payload : {}
1234
+ rescue JSON::ParserError
1235
+ {}
1236
+ end
1237
+
1029
1238
  # Single query to fetch pg_stat_user_tables stats for all queue and
1030
1239
  # archive tables. Avoids 2*N catalog queries on the dashboard.
1031
1240
  def fetch_all_table_stats
@@ -22,9 +22,14 @@ module Pgbus
22
22
  append_queue_metrics(lines)
23
23
  append_job_metrics(lines)
24
24
  append_process_metrics(lines)
25
- append_summary_metrics(lines)
25
+ # One summary read, shared: summary_stats is not memoized, so calling it
26
+ # per family would repeat the queue/health/process queries and advance
27
+ # the throughput snapshot a second time within one scrape.
28
+ summary = summary_stats
29
+ append_summary_metrics(lines, summary)
26
30
  append_stream_metrics(lines)
27
31
  append_health_metrics(lines)
32
+ append_concurrency_metrics(lines, summary)
28
33
  "#{lines.join("\n")}\n"
29
34
  end
30
35
 
@@ -147,8 +152,18 @@ module Pgbus
147
152
  Pgbus.logger.debug { "[Pgbus::Metrics] Error serializing process metrics: #{e.message}" }
148
153
  end
149
154
 
150
- def append_summary_metrics(lines)
151
- stats = @data_source.summary_stats
155
+ # nil when the read raised — each family then skips rather than blanking
156
+ # the whole scrape.
157
+ def summary_stats
158
+ @data_source.summary_stats
159
+ rescue StandardError => e
160
+ Pgbus.logger.debug { "[Pgbus::Metrics] Error reading summary stats: #{e.message}" }
161
+ nil
162
+ end
163
+
164
+ def append_summary_metrics(lines, stats)
165
+ return unless stats
166
+
152
167
  gauge(lines, "pgbus_failed_events_total", "Total failed events") do
153
168
  [[stats[:failed_count]]]
154
169
  end
@@ -219,6 +234,39 @@ module Pgbus
219
234
  Pgbus.logger.debug { "[Pgbus::Metrics] Error serializing health metrics: #{e.message}" }
220
235
  end
221
236
 
237
+ # Concurrency slot pressure: how many jobs are parked, how long the oldest
238
+ # has waited, how many slots are held right now. Unlabelled on purpose —
239
+ # concurrency keys are per-record (one per order, one per sync group), so a
240
+ # per-key label would make an unbounded series. The per-key detail lives on
241
+ # the Locks page and in the pgbus_concurrency MCP tool, both bounded.
242
+ #
243
+ # Omitted entirely when neither table holds anything: an install that does
244
+ # not use limits_concurrency reports nothing rather than a flat zero line.
245
+ def append_concurrency_metrics(lines, stats)
246
+ return unless stats
247
+ return if stats[:parked_total].to_i.zero? && stats[:slots_held].to_i.zero? &&
248
+ stats[:keys_at_limit].to_i.zero? && stats[:oldest_parked_age_sec].nil?
249
+
250
+ gauge(lines, "pgbus_concurrency_blocked_executions",
251
+ "Jobs parked behind a concurrency key, waiting for a slot") do
252
+ [[stats[:parked_total].to_i]]
253
+ end
254
+
255
+ if stats[:oldest_parked_age_sec]
256
+ gauge(lines, "pgbus_concurrency_blocked_oldest_age_seconds",
257
+ "How long the longest-waiting parked job has waited") do
258
+ [[stats[:oldest_parked_age_sec]]]
259
+ end
260
+ end
261
+
262
+ gauge(lines, "pgbus_concurrency_slots_held",
263
+ "Concurrency slots currently held across all keys") do
264
+ [[stats[:slots_held].to_i]]
265
+ end
266
+ rescue StandardError => e
267
+ Pgbus.logger.debug { "[Pgbus::Metrics] Error serializing concurrency metrics: #{e.message}" }
268
+ end
269
+
222
270
  # Emits a Prometheus gauge metric family. The block must return an array
223
271
  # of [value] or [value, { label: "val" }] pairs.
224
272
  def gauge(lines, name, help)
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.3
4
+ version: 0.16.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -185,6 +185,8 @@ files:
185
185
  - app/views/pgbus/jobs/_failed_table.html.erb
186
186
  - app/views/pgbus/jobs/index.html.erb
187
187
  - app/views/pgbus/jobs/show.html.erb
188
+ - app/views/pgbus/locks/_concurrency.html.erb
189
+ - app/views/pgbus/locks/_uniqueness.html.erb
188
190
  - app/views/pgbus/locks/index.html.erb
189
191
  - app/views/pgbus/outbox/index.html.erb
190
192
  - app/views/pgbus/processes/_processes_table.html.erb
@@ -317,6 +319,7 @@ files:
317
319
  - lib/pgbus/mcp/redactor.rb
318
320
  - lib/pgbus/mcp/runner.rb
319
321
  - lib/pgbus/mcp/server.rb
322
+ - lib/pgbus/mcp/tools/concurrency_tool.rb
320
323
  - lib/pgbus/mcp/tools/dlq_detail_tool.rb
321
324
  - lib/pgbus/mcp/tools/dlq_tool.rb
322
325
  - lib/pgbus/mcp/tools/health_tool.rb