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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/app/controllers/pgbus/locks_controller.rb +27 -0
- data/app/frontend/pgbus/style.css +1 -1
- data/app/models/pgbus/uniqueness_key.rb +20 -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/event_bus/handler.rb +17 -3
- data/lib/pgbus/event_bus/stale_connection_retry.rb +85 -0
- 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/streams/broadcast_opts.rb +70 -0
- data/lib/pgbus/streams/broadcastable_override.rb +7 -20
- data/lib/pgbus/streams/turbo_broadcastable.rb +65 -4
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source.rb +210 -1
- data/lib/pgbus/web/metrics_serializer.rb +51 -3
- metadata +6 -1
data/config/locales/pt.yml
CHANGED
|
@@ -86,6 +86,8 @@ pt:
|
|
|
86
86
|
enqueued: Enfileirado
|
|
87
87
|
failed_dlq: Falhou / DLQ
|
|
88
88
|
failed_dlq_labels: Falhados / Dead Lettered
|
|
89
|
+
parked_jobs: Trabalhos estacionados
|
|
90
|
+
parked_jobs_oldest: mais antigo %{age}
|
|
89
91
|
processes: Processos
|
|
90
92
|
queues: Filas
|
|
91
93
|
recurring: Recorrente
|
|
@@ -404,6 +406,43 @@ pt:
|
|
|
404
406
|
toggle_dark_mode: Alternar modo escuro
|
|
405
407
|
toggle_menu: Alternar menu
|
|
406
408
|
locks:
|
|
409
|
+
concurrency:
|
|
410
|
+
cards:
|
|
411
|
+
keys_at_limit: Chaves no limite
|
|
412
|
+
keys_at_limit_hint: sem vaga livre
|
|
413
|
+
oldest_wait: Espera mais longa
|
|
414
|
+
oldest_wait_hint: o trabalho que espera há mais tempo
|
|
415
|
+
parked_jobs: Trabalhos estacionados
|
|
416
|
+
parked_jobs_hint: à espera de uma vaga
|
|
417
|
+
slots_held: Vagas ocupadas
|
|
418
|
+
slots_held_hint: em todas as chaves
|
|
419
|
+
description: Chaves declaradas com limits_concurrency, as vagas que ocupam e os trabalhos estacionados atrás delas
|
|
420
|
+
discard_parked: Descartar estacionados
|
|
421
|
+
discard_parked_confirm:
|
|
422
|
+
one: Descartar o trabalho estacionado atrás desta chave? Nunca será executado.
|
|
423
|
+
other: Descartar os %{count} trabalhos estacionados atrás desta chave? Nunca serão executados.
|
|
424
|
+
empty: Nenhuma chave de concorrência em uso
|
|
425
|
+
headers:
|
|
426
|
+
in_use: Em uso
|
|
427
|
+
key: Chave
|
|
428
|
+
lease: Concessão
|
|
429
|
+
oldest_wait: Espera mais longa
|
|
430
|
+
parked: Estacionados
|
|
431
|
+
key_released:
|
|
432
|
+
one: "%{key} libertada e 1 trabalho promovido."
|
|
433
|
+
other: "%{key} libertada e %{count} trabalhos promovidos."
|
|
434
|
+
lease:
|
|
435
|
+
expired: Expirada
|
|
436
|
+
live: Ativa
|
|
437
|
+
none: Sem detentor
|
|
438
|
+
no_key: Nenhuma chave de concorrência indicada.
|
|
439
|
+
parked_discarded:
|
|
440
|
+
one: 1 trabalho estacionado descartado.
|
|
441
|
+
other: "%{count} trabalhos estacionados descartados."
|
|
442
|
+
release: Libertar
|
|
443
|
+
release_confirm: Libertar %{key} e promover os seus trabalhos estacionados?
|
|
444
|
+
release_confirm_live: A concessão de %{key} ainda está válida, por isso é provável que um trabalho continue a correr. Libertar permite que outro arranque ao lado. Continuar?
|
|
445
|
+
title: Concorrência
|
|
407
446
|
index:
|
|
408
447
|
all_locks_discarded:
|
|
409
448
|
one: 1 bloqueio descartado.
|
|
@@ -427,7 +466,9 @@ pt:
|
|
|
427
466
|
one: 1 bloqueio descartado.
|
|
428
467
|
other: "%{count} bloqueios descartados."
|
|
429
468
|
none_selected: Nenhum bloqueio selecionado.
|
|
430
|
-
|
|
469
|
+
page_description: Chaves de unicidade e vagas de concorrência que estão a reter trabalhos
|
|
470
|
+
title: Bloqueios
|
|
471
|
+
uniqueness_title: Chaves de unicidade
|
|
431
472
|
outbox:
|
|
432
473
|
index:
|
|
433
474
|
description: Entradas da caixa de saída transacional pendentes de publicação para PGMQ
|
data/config/locales/sv.yml
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
@@ -158,12 +158,26 @@ module Pgbus
|
|
|
158
158
|
# already cached at claim time). If this write fails, process!'s rescue
|
|
159
159
|
# re-raises, the consumer leaves the message for VT redelivery, and the
|
|
160
160
|
# still-pending claim re-runs — at-least-once, never a silent drop.
|
|
161
|
+
#
|
|
162
|
+
# Wrapped in StaleConnectionRetry because this is the one AR write that
|
|
163
|
+
# happens AFTER handle() has already succeeded: a socket dropped here
|
|
164
|
+
# costs the host app a paging exception for work that was in fact done.
|
|
165
|
+
# The stamp is an idempotent `SET completed_at = <now>`, so repeating a
|
|
166
|
+
# statement that may already have committed is safe.
|
|
167
|
+
#
|
|
168
|
+
# Phase 1 (claim_idempotency?) is deliberately NOT wrapped. Its INSERT
|
|
169
|
+
# may have committed before the socket died, and on a legacy schema the
|
|
170
|
+
# retry's empty `result.rows` would read as "someone else owns this
|
|
171
|
+
# claim" and return false — turning a recoverable drop into a silently
|
|
172
|
+
# skipped event. VT redelivery is the correct recovery there.
|
|
161
173
|
def complete_claim!(event_id)
|
|
162
174
|
return unless ProcessedEvent.completion_column?
|
|
163
175
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
176
|
+
StaleConnectionRetry.call(context: self.class.name) do
|
|
177
|
+
ProcessedEvent
|
|
178
|
+
.where(event_id: event_id, handler_class: self.class.name)
|
|
179
|
+
.update_all(completed_at: Time.now.utc)
|
|
180
|
+
end
|
|
167
181
|
self.class.dedup_cache.mark!(dedup_key(event_id))
|
|
168
182
|
end
|
|
169
183
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module EventBus
|
|
5
|
+
# Reconnect + one retry for an ActiveRecord socket that died underneath a
|
|
6
|
+
# long-lived consumer thread.
|
|
7
|
+
#
|
|
8
|
+
# A consumer holds its leased connection for the life of the message, and a
|
|
9
|
+
# pooler restart (PgBouncer `server_idle_timeout`), an admin disconnect or a
|
|
10
|
+
# brief failover can kill that socket at any point. Rails reconnects most
|
|
11
|
+
# statements transparently, but `Relation#update_all` is marked
|
|
12
|
+
# `allow_retry: false`, so the drop propagates — and the only call that
|
|
13
|
+
# matters here, the phase-2 claim stamp, is exactly an `update_all`. The
|
|
14
|
+
# host app saw a paging exception on a message whose handler had in fact
|
|
15
|
+
# succeeded.
|
|
16
|
+
#
|
|
17
|
+
# Deliberately narrower than `Client::STALE_CONNECTION_PATTERNS`, and for
|
|
18
|
+
# the opposite reason. That list excludes mid-flight drops because a
|
|
19
|
+
# half-committed *enqueue* would duplicate a message on retry. This one
|
|
20
|
+
# only ever wraps an idempotent `UPDATE ... SET completed_at = <now>`, so a
|
|
21
|
+
# statement that may already have committed is safe to repeat — which is
|
|
22
|
+
# what makes the mid-flight shapes retryable here and not there.
|
|
23
|
+
module StaleConnectionRetry
|
|
24
|
+
# The socket dying under a statement, not a server refusing us. A refused
|
|
25
|
+
# or timed-out connection is an outage: retrying it in-process buys
|
|
26
|
+
# nothing and hides the outage behind a doubled statement timeout.
|
|
27
|
+
#
|
|
28
|
+
# "ssl syscall error" is libpq's wording when the peer vanished without a
|
|
29
|
+
# TLS close_notify — the same drop as "unexpected eof while reading",
|
|
30
|
+
# reported from the syscall layer instead. It is in
|
|
31
|
+
# Client::STALE_CONNECTION_PATTERNS for the same reason.
|
|
32
|
+
TRANSIENT_DROP = /
|
|
33
|
+
PQconsumeInput|
|
|
34
|
+
server\ closed\ the\ connection\ unexpectedly|
|
|
35
|
+
unexpected\ eof\ while\ reading|
|
|
36
|
+
ssl\ syscall\ error
|
|
37
|
+
/ix
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# Runs the block, and on a transient drop reconnects this thread's lease
|
|
42
|
+
# and runs it exactly once more. A second drop raises: the socket is not
|
|
43
|
+
# coming back inside this attempt, and PGMQ's visibility timeout is the
|
|
44
|
+
# right recovery — it redelivers to a healthy consumer.
|
|
45
|
+
#
|
|
46
|
+
# context — an identifier for the log line (the handler class name).
|
|
47
|
+
def call(context: nil, &block)
|
|
48
|
+
block.call
|
|
49
|
+
rescue ActiveRecord::ConnectionFailed => e
|
|
50
|
+
raise unless transient_drop?(e)
|
|
51
|
+
|
|
52
|
+
reconnect_leased!
|
|
53
|
+
Pgbus.logger.warn do
|
|
54
|
+
"[Pgbus::EventBus] Retrying after stale ActiveRecord connection drop" \
|
|
55
|
+
"#{" (#{context})" if context}: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
block.call
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def transient_drop?(error)
|
|
61
|
+
return false unless error.is_a?(ActiveRecord::ConnectionFailed)
|
|
62
|
+
|
|
63
|
+
[error.message, error.cause&.message].compact.any? { |message| TRANSIENT_DROP.match?(message) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Only the connections this thread has actually leased.
|
|
67
|
+
# `clear_all_connections!` would yank sockets out from under sibling
|
|
68
|
+
# consumers sharing the process, turning one recoverable drop into many.
|
|
69
|
+
# `active_connection?` returns the lease or nil, and is the accessor this
|
|
70
|
+
# gem uses everywhere (Streams#current_open_transaction documents why we
|
|
71
|
+
# never reach for `ActiveRecord::Base.connection`). Its `active_connection`
|
|
72
|
+
# alias is `:nodoc:` and does not exist at all before Rails 7.2 — below
|
|
73
|
+
# our floor, so calling it would raise NoMethodError on the one path that
|
|
74
|
+
# exists to recover from an error.
|
|
75
|
+
def reconnect_leased!
|
|
76
|
+
ActiveRecord::Base.connection_handler.each_connection_pool(:all) do |pool|
|
|
77
|
+
connection = pool.active_connection?
|
|
78
|
+
next unless connection
|
|
79
|
+
|
|
80
|
+
connection.reconnect!
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Streams
|
|
5
|
+
# The thread-local channel that carries pgbus-specific broadcast options
|
|
6
|
+
# from a `Turbo::Broadcastable` / `Turbo::StreamsChannel` call site down to
|
|
7
|
+
# `TurboBroadcastable#broadcast_stream_to`, which is the only place with a
|
|
8
|
+
# `Pgbus::Streams::Stream` to hand them to.
|
|
9
|
+
#
|
|
10
|
+
# turbo-rails' broadcast helpers funnel everything through
|
|
11
|
+
# `broadcast_stream_to(*streamables, content:)` — a signature with nowhere
|
|
12
|
+
# to put `durable:`, `exclude:`, `visible_to:`, `event:` or `coalesce:`.
|
|
13
|
+
# Rather than fork turbo's whole helper surface, the patches pull those
|
|
14
|
+
# kwargs out before calling `super` and stash them here for the duration of
|
|
15
|
+
# the broadcast.
|
|
16
|
+
#
|
|
17
|
+
# Both patches (`BroadcastableOverride` on the model concern and
|
|
18
|
+
# `TurboBroadcastable` on the channel) share this module so there is one
|
|
19
|
+
# definition of which keys exist and how they are saved/restored.
|
|
20
|
+
module BroadcastOpts
|
|
21
|
+
# Options a caller may pass to any Turbo broadcast helper. They are
|
|
22
|
+
# deleted from the kwargs so they never reach turbo-rails' renderer.
|
|
23
|
+
KEYS = %i[durable exclude visible_to event coalesce].freeze
|
|
24
|
+
|
|
25
|
+
# `coalesce_target` is not a caller-facing kwarg — it is derived from the
|
|
26
|
+
# broadcast's own `target:`/`targets:` by the channel patch, because
|
|
27
|
+
# coalescing keys on `(stream, target)` and `broadcast_stream_to` never
|
|
28
|
+
# sees the target otherwise.
|
|
29
|
+
THREAD_LOCALS = {
|
|
30
|
+
durable: :pgbus_broadcast_durable,
|
|
31
|
+
exclude: :pgbus_broadcast_exclude,
|
|
32
|
+
visible_to: :pgbus_broadcast_visible_to,
|
|
33
|
+
event: :pgbus_broadcast_event,
|
|
34
|
+
coalesce: :pgbus_broadcast_coalesce,
|
|
35
|
+
coalesce_target: :pgbus_broadcast_coalesce_target
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Removes the pgbus options from `kwargs` (mutating it) and returns them.
|
|
39
|
+
# Keys the caller did not pass are absent from the result, so an outer
|
|
40
|
+
# `BroadcastOpts.with` block's values survive an inner broadcast that
|
|
41
|
+
# doesn't set them.
|
|
42
|
+
def self.extract!(kwargs)
|
|
43
|
+
KEYS.each_with_object({}) do |key, opts|
|
|
44
|
+
opts[key] = kwargs.delete(key) if kwargs.key?(key)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Sets the thread-locals for the given options for the duration of the
|
|
49
|
+
# block, restoring the previous values afterwards (nesting-safe), even on
|
|
50
|
+
# error. Only the keys passed are touched.
|
|
51
|
+
def self.with(**opts)
|
|
52
|
+
previous = {}
|
|
53
|
+
|
|
54
|
+
opts.each do |key, value|
|
|
55
|
+
tl_key = THREAD_LOCALS.fetch(key)
|
|
56
|
+
previous[tl_key] = Thread.current[tl_key]
|
|
57
|
+
Thread.current[tl_key] = value
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
yield
|
|
61
|
+
ensure
|
|
62
|
+
previous.each { |tl_key, value| Thread.current[tl_key] = value }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.[](key)
|
|
66
|
+
Thread.current[THREAD_LOCALS.fetch(key)]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -40,7 +40,7 @@ module Pgbus
|
|
|
40
40
|
# don't understand. We pull them out of kwargs (so they never reach
|
|
41
41
|
# turbo-rails' renderer) and thread them to broadcast_stream_to via
|
|
42
42
|
# thread-locals, mirroring the original durable: shim.
|
|
43
|
-
PGBUS_BROADCAST_OPTS =
|
|
43
|
+
PGBUS_BROADCAST_OPTS = BroadcastOpts::KEYS
|
|
44
44
|
|
|
45
45
|
BROADCAST_METHODS.each do |method_name|
|
|
46
46
|
define_method(method_name) do |*streamables, **kwargs|
|
|
@@ -128,32 +128,19 @@ module Pgbus
|
|
|
128
128
|
private
|
|
129
129
|
|
|
130
130
|
def extract_pgbus_broadcast_opts(kwargs)
|
|
131
|
-
|
|
132
|
-
opts[key] = kwargs.delete(key) if kwargs.key?(key)
|
|
133
|
-
end
|
|
131
|
+
BroadcastOpts.extract!(kwargs)
|
|
134
132
|
end
|
|
135
133
|
|
|
136
134
|
# Set the pgbus broadcast thread-locals for the duration of the block,
|
|
137
135
|
# restoring previous values afterwards (nested/concurrent-safe). Only
|
|
138
136
|
# keys actually passed are touched, so unrelated outer broadcasts keep
|
|
139
137
|
# their values.
|
|
140
|
-
def with_pgbus_broadcast_opts(durable: :__unset__, exclude: :__unset__, visible_to: :__unset__,
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
previous[tl_key] = Thread.current[tl_key]
|
|
146
|
-
Thread.current[tl_key] = value
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
set.call(:pgbus_broadcast_durable, durable)
|
|
150
|
-
set.call(:pgbus_broadcast_exclude, exclude)
|
|
151
|
-
set.call(:pgbus_broadcast_visible_to, visible_to)
|
|
152
|
-
set.call(:pgbus_broadcast_event, event)
|
|
138
|
+
def with_pgbus_broadcast_opts(durable: :__unset__, exclude: :__unset__, visible_to: :__unset__,
|
|
139
|
+
event: :__unset__, coalesce: :__unset__, &)
|
|
140
|
+
opts = { durable: durable, exclude: exclude, visible_to: visible_to, event: event, coalesce: coalesce }
|
|
141
|
+
opts.reject! { |_key, value| value == :__unset__ }
|
|
153
142
|
|
|
154
|
-
|
|
155
|
-
ensure
|
|
156
|
-
previous.each { |tl_key, value| Thread.current[tl_key] = value }
|
|
143
|
+
BroadcastOpts.with(**opts, &)
|
|
157
144
|
end
|
|
158
145
|
end
|
|
159
146
|
end
|
|
@@ -34,9 +34,57 @@ module Pgbus
|
|
|
34
34
|
# long as `Turbo.signed_stream_verifier_key` is set, which the Rails
|
|
35
35
|
# app is already responsible for).
|
|
36
36
|
module TurboBroadcastable
|
|
37
|
+
# Every targeted broadcast helper (`broadcast_replace_to`,
|
|
38
|
+
# `broadcast_append_to`, `broadcast_remove_to`, …) funnels through
|
|
39
|
+
# `broadcast_action_to`, so this is the one place that sees both the
|
|
40
|
+
# caller's kwargs and the `target:`/`targets:` the frame will carry.
|
|
41
|
+
#
|
|
42
|
+
# It does two things the model-level `BroadcastableOverride` can't:
|
|
43
|
+
#
|
|
44
|
+
# 1. Extracts the pgbus options from a *direct* channel call —
|
|
45
|
+
# `Turbo::StreamsChannel.broadcast_replace_to(..., coalesce: true)` —
|
|
46
|
+
# which never passes through `Turbo::Broadcastable` at all (this is
|
|
47
|
+
# the path phlex-reactive's `Streamable.broadcast_to` takes).
|
|
48
|
+
# 2. Records the coalescing key. `coalesce:` dedupes on
|
|
49
|
+
# `(stream, target)`, but `broadcast_stream_to` only receives the
|
|
50
|
+
# rendered `content:` — the target is gone by then. We resolve it the
|
|
51
|
+
# same way turbo will (`convert_to_turbo_stream_dom_id`), so a record
|
|
52
|
+
# target keys on its stable dom_id rather than its object id.
|
|
53
|
+
#
|
|
54
|
+
# Resolving the key is skipped entirely unless coalescing was actually
|
|
55
|
+
# requested, so the uncoalesced path stays byte-identical and free.
|
|
56
|
+
def broadcast_action_to(*streamables, action:, target: nil, targets: nil, **rendering)
|
|
57
|
+
opts = BroadcastOpts.extract!(rendering)
|
|
58
|
+
|
|
59
|
+
coalesce = opts.key?(:coalesce) ? opts[:coalesce] : BroadcastOpts[:coalesce]
|
|
60
|
+
opts[:coalesce_target] = pgbus_coalesce_target(target, targets) if coalesce
|
|
61
|
+
|
|
62
|
+
BroadcastOpts.with(**opts) do
|
|
63
|
+
super(*streamables, action: action, target: target, targets: targets, **rendering)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The two helpers that don't pass through `broadcast_action_to`. They
|
|
68
|
+
# carry no target, so they can't coalesce (`Stream#broadcast` raises an
|
|
69
|
+
# actionable error, same as `Pgbus.stream(x).broadcast(coalesce:)`
|
|
70
|
+
# without one) — but they still need their pgbus options pulled out of
|
|
71
|
+
# the kwargs, or a direct channel call leaks `durable: true` into
|
|
72
|
+
# turbo's renderer and it ends up as an HTML attribute.
|
|
73
|
+
def broadcast_refresh_to(*streamables, **attributes)
|
|
74
|
+
BroadcastOpts.with(**BroadcastOpts.extract!(attributes)) do
|
|
75
|
+
super(*streamables, **attributes)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def broadcast_render_to(*streamables, **rendering)
|
|
80
|
+
BroadcastOpts.with(**BroadcastOpts.extract!(rendering)) do
|
|
81
|
+
super(*streamables, **rendering)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
37
85
|
def broadcast_stream_to(*streamables, content:)
|
|
38
86
|
name = stream_name_from(streamables)
|
|
39
|
-
override =
|
|
87
|
+
override = BroadcastOpts[:durable]
|
|
40
88
|
# When no explicit thread-local override is present, let the config
|
|
41
89
|
# resolver decide: it checks `streams_durable_patterns` first (exact
|
|
42
90
|
# string or regex match), then falls back to
|
|
@@ -46,11 +94,24 @@ module Pgbus
|
|
|
46
94
|
durable = override.nil? ? Pgbus.configuration.stream_durable?(name) : override
|
|
47
95
|
Pgbus.stream(name, durable: durable).broadcast(
|
|
48
96
|
content,
|
|
49
|
-
exclude:
|
|
50
|
-
visible_to:
|
|
51
|
-
event:
|
|
97
|
+
exclude: BroadcastOpts[:exclude],
|
|
98
|
+
visible_to: BroadcastOpts[:visible_to],
|
|
99
|
+
event: BroadcastOpts[:event],
|
|
100
|
+
coalesce: BroadcastOpts[:coalesce],
|
|
101
|
+
target: BroadcastOpts[:coalesce_target]
|
|
52
102
|
)
|
|
53
103
|
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# The coalescing key, resolved exactly as turbo resolves the rendered
|
|
108
|
+
# `target=`/`targets=` attribute. `targets:` (a CSS selector) is the
|
|
109
|
+
# fallback turbo itself uses when `target:` is absent; we drop the `#`
|
|
110
|
+
# selector prefix because the key is never rendered — it only has to be
|
|
111
|
+
# stable and distinct.
|
|
112
|
+
def pgbus_coalesce_target(target, targets)
|
|
113
|
+
convert_to_turbo_stream_dom_id(target) || convert_to_turbo_stream_dom_id(targets)
|
|
114
|
+
end
|
|
54
115
|
end
|
|
55
116
|
|
|
56
117
|
# Apply the patch to Turbo::StreamsChannel's singleton class. Idempotent:
|
data/lib/pgbus/version.rb
CHANGED