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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/app/controllers/pgbus/locks_controller.rb +27 -0
- data/app/frontend/pgbus/style.css +1 -1
- data/app/models/pgbus/blocked_execution.rb +33 -7
- data/app/models/pgbus/semaphore.rb +8 -3
- data/app/models/pgbus/uniqueness_key.rb +36 -0
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +14 -1
- data/app/views/pgbus/locks/_concurrency.html.erb +94 -0
- data/app/views/pgbus/locks/_uniqueness.html.erb +80 -0
- data/app/views/pgbus/locks/index.html.erb +5 -76
- data/config/locales/da.yml +42 -1
- data/config/locales/de.yml +42 -1
- data/config/locales/en.yml +42 -1
- data/config/locales/es.yml +42 -1
- data/config/locales/fi.yml +42 -1
- data/config/locales/fr.yml +42 -1
- data/config/locales/it.yml +42 -1
- data/config/locales/ja.yml +42 -1
- data/config/locales/nb.yml +42 -1
- data/config/locales/nl.yml +42 -1
- data/config/locales/pt.yml +42 -1
- data/config/locales/sv.yml +42 -1
- data/config/routes.rb +5 -0
- data/lib/pgbus/active_job/adapter.rb +83 -9
- data/lib/pgbus/active_job/executor.rb +55 -15
- data/lib/pgbus/concurrency/blocked_execution.rb +59 -18
- data/lib/pgbus/concurrency/semaphore.rb +34 -0
- data/lib/pgbus/concurrency.rb +36 -2
- data/lib/pgbus/instrumentation.rb +3 -0
- data/lib/pgbus/integrations/appsignal/dashboard.json +38 -0
- data/lib/pgbus/integrations/appsignal/probe.rb +10 -0
- data/lib/pgbus/mcp/base_tool.rb +6 -1
- data/lib/pgbus/mcp/server.rb +2 -1
- data/lib/pgbus/mcp/tools/concurrency_tool.rb +32 -0
- data/lib/pgbus/mcp.rb +1 -0
- data/lib/pgbus/process/dispatcher.rb +6 -13
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/visibility_heartbeat.rb +24 -3
- data/lib/pgbus/web/data_source.rb +210 -1
- data/lib/pgbus/web/metrics_serializer.rb +51 -3
- metadata +4 -1
data/lib/pgbus/concurrency.rb
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
data/lib/pgbus/mcp/base_tool.rb
CHANGED
|
@@ -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
|
data/lib/pgbus/mcp/server.rb
CHANGED
|
@@ -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
|
|
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
|
@@ -355,25 +355,18 @@ module Pgbus
|
|
|
355
355
|
end
|
|
356
356
|
|
|
357
357
|
def cleanup_concurrency
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
+
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
|