pgbus 0.16.4 → 0.16.6

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -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/uniqueness_key.rb +20 -0
  6. data/app/views/pgbus/dashboard/_stats_cards.html.erb +14 -1
  7. data/app/views/pgbus/locks/_concurrency.html.erb +94 -0
  8. data/app/views/pgbus/locks/_uniqueness.html.erb +80 -0
  9. data/app/views/pgbus/locks/index.html.erb +5 -76
  10. data/config/locales/da.yml +42 -1
  11. data/config/locales/de.yml +42 -1
  12. data/config/locales/en.yml +42 -1
  13. data/config/locales/es.yml +42 -1
  14. data/config/locales/fi.yml +42 -1
  15. data/config/locales/fr.yml +42 -1
  16. data/config/locales/it.yml +42 -1
  17. data/config/locales/ja.yml +42 -1
  18. data/config/locales/nb.yml +42 -1
  19. data/config/locales/nl.yml +42 -1
  20. data/config/locales/pt.yml +42 -1
  21. data/config/locales/sv.yml +42 -1
  22. data/config/routes.rb +5 -0
  23. data/lib/pgbus/event_bus/handler.rb +17 -3
  24. data/lib/pgbus/event_bus/stale_connection_retry.rb +85 -0
  25. data/lib/pgbus/instrumentation.rb +3 -0
  26. data/lib/pgbus/integrations/appsignal/dashboard.json +38 -0
  27. data/lib/pgbus/integrations/appsignal/probe.rb +10 -0
  28. data/lib/pgbus/mcp/base_tool.rb +6 -1
  29. data/lib/pgbus/mcp/server.rb +2 -1
  30. data/lib/pgbus/mcp/tools/concurrency_tool.rb +32 -0
  31. data/lib/pgbus/mcp.rb +1 -0
  32. data/lib/pgbus/streams/broadcast_opts.rb +70 -0
  33. data/lib/pgbus/streams/broadcastable_override.rb +7 -20
  34. data/lib/pgbus/streams/turbo_broadcastable.rb +65 -4
  35. data/lib/pgbus/version.rb +1 -1
  36. data/lib/pgbus/web/data_source.rb +210 -1
  37. data/lib/pgbus/web/metrics_serializer.rb +51 -3
  38. metadata +6 -1
@@ -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.6
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
@@ -292,6 +294,7 @@ files:
292
294
  - lib/pgbus/event_bus/handler.rb
293
295
  - lib/pgbus/event_bus/publisher.rb
294
296
  - lib/pgbus/event_bus/registry.rb
297
+ - lib/pgbus/event_bus/stale_connection_retry.rb
295
298
  - lib/pgbus/event_bus/subscriber.rb
296
299
  - lib/pgbus/execution_pools.rb
297
300
  - lib/pgbus/execution_pools/async_pool.rb
@@ -317,6 +320,7 @@ files:
317
320
  - lib/pgbus/mcp/redactor.rb
318
321
  - lib/pgbus/mcp/runner.rb
319
322
  - lib/pgbus/mcp/server.rb
323
+ - lib/pgbus/mcp/tools/concurrency_tool.rb
320
324
  - lib/pgbus/mcp/tools/dlq_detail_tool.rb
321
325
  - lib/pgbus/mcp/tools/dlq_tool.rb
322
326
  - lib/pgbus/mcp/tools/health_tool.rb
@@ -372,6 +376,7 @@ files:
372
376
  - lib/pgbus/serializer.rb
373
377
  - lib/pgbus/stat_buffer.rb
374
378
  - lib/pgbus/streams.rb
379
+ - lib/pgbus/streams/broadcast_opts.rb
375
380
  - lib/pgbus/streams/broadcastable_override.rb
376
381
  - lib/pgbus/streams/coalescer.rb
377
382
  - lib/pgbus/streams/cursor.rb