pgbus 0.16.4 → 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.
@@ -86,6 +86,8 @@ sv:
86
86
  enqueued: Inlagda
87
87
  failed_dlq: Misslyckade / DLQ
88
88
  failed_dlq_labels: Misslyckade / Dead Lettered
89
+ parked_jobs: Parkerade jobb
90
+ parked_jobs_oldest: äldst %{age}
89
91
  processes: Processer
90
92
  queues: Köer
91
93
  recurring: Återkommande
@@ -404,6 +406,43 @@ sv:
404
406
  toggle_dark_mode: Växla mörkt läge
405
407
  toggle_menu: Växla meny
406
408
  locks:
409
+ concurrency:
410
+ cards:
411
+ keys_at_limit: Nycklar vid gränsen
412
+ keys_at_limit_hint: ingen ledig plats
413
+ oldest_wait: Längsta väntan
414
+ oldest_wait_hint: längst ett jobb har väntat
415
+ parked_jobs: Parkerade jobb
416
+ parked_jobs_hint: väntar på en plats
417
+ slots_held: Hållna platser
418
+ slots_held_hint: över alla nycklar
419
+ description: Nycklar deklarerade med limits_concurrency, platserna de håller och jobben som parkerats bakom dem
420
+ discard_parked: Kasta parkerade
421
+ discard_parked_confirm:
422
+ one: Kasta det 1 jobb som parkerats bakom denna nyckel? Det kommer aldrig att köras.
423
+ other: Kasta de %{count} jobb som parkerats bakom denna nyckel? De kommer aldrig att köras.
424
+ empty: Inga samtidighetsnycklar används
425
+ headers:
426
+ in_use: Används
427
+ key: Nyckel
428
+ lease: Lås
429
+ oldest_wait: Längsta väntan
430
+ parked: Parkerade
431
+ key_released:
432
+ one: Frigjorde %{key} och befordrade 1 jobb.
433
+ other: Frigjorde %{key} och befordrade %{count} jobb.
434
+ lease:
435
+ expired: Utgånget
436
+ live: Aktivt
437
+ none: Ingen innehavare
438
+ no_key: Ingen samtidighetsnyckel angiven.
439
+ parked_discarded:
440
+ one: Kastade 1 parkerat jobb.
441
+ other: Kastade %{count} parkerade jobb.
442
+ release: Frigör
443
+ release_confirm: Frigör %{key} och befordra dess parkerade jobb?
444
+ release_confirm_live: Låset på %{key} är fortfarande färskt, så ett jobb körs troligen än. Att frigöra låter ett annat starta bredvid det. Fortsätta?
445
+ title: Samtidighet
407
446
  index:
408
447
  all_locks_discarded:
409
448
  one: Kasserade 1 lås.
@@ -427,7 +466,9 @@ sv:
427
466
  one: Kasserade 1 lås.
428
467
  other: Kasserade %{count} lås.
429
468
  none_selected: Inga lås valda.
430
- title: Unikhetsnycklar
469
+ page_description: Unikhetsnycklar och samtidighetsplatser som håller tillbaka jobb
470
+ title: Lås
471
+ uniqueness_title: Unikhetsnycklar
431
472
  outbox:
432
473
  index:
433
474
  description: Transaktionella utboxposter som väntar på publicering till PGMQ
data/config/routes.rb CHANGED
@@ -81,6 +81,11 @@ Pgbus::Engine.routes.draw do
81
81
  collection do
82
82
  post :discard_selected
83
83
  post :discard_all
84
+ # Collection routes, not member: a concurrency key is a free-form
85
+ # string that may contain ".", "/" or ":" and does not belong in a
86
+ # path segment. Both take the key as a form param.
87
+ post :release_key
88
+ post :discard_parked
84
89
  end
85
90
  end
86
91
  resource :insights, only: [:show], controller: "insights"
@@ -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
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.4"
4
+ VERSION = "0.16.5"
5
5
  end
@@ -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.4
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