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
@@ -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
- title: Chaves de unicidade
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
@@ -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"
@@ -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
- ProcessedEvent
165
- .where(event_id: event_id, handler_class: self.class.name)
166
- .update_all(completed_at: Time.now.utc)
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
@@ -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
@@ -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 = %i[durable exclude visible_to event].freeze
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
- PGBUS_BROADCAST_OPTS.each_with_object({}) do |key, opts|
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__, event: :__unset__)
141
- previous = {}
142
- set = lambda do |tl_key, value|
143
- next if value == :__unset__
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
- yield
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 = Thread.current[:pgbus_broadcast_durable]
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: Thread.current[:pgbus_broadcast_exclude],
50
- visible_to: Thread.current[:pgbus_broadcast_visible_to],
51
- event: Thread.current[:pgbus_broadcast_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
@@ -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.6"
5
5
  end