pgbus 0.17.1 → 0.17.3

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 (156) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +83 -13
  4. data/Rakefile +26 -0
  5. data/app/assets/javascripts/pgbus/stream_source_element.js +34 -2
  6. data/app/assets/javascripts/pgbus/stream_source_element.min.js +6 -0
  7. data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -0
  8. data/app/controllers/concerns/pgbus/job_listing.rb +37 -0
  9. data/app/controllers/concerns/pgbus/process_listing.rb +17 -0
  10. data/app/controllers/pgbus/application_controller.rb +5 -2
  11. data/app/controllers/pgbus/batches_controller.rb +4 -1
  12. data/app/controllers/pgbus/dashboard_controller.rb +20 -6
  13. data/app/controllers/pgbus/dead_letter_controller.rb +27 -3
  14. data/app/controllers/pgbus/events_controller.rb +52 -3
  15. data/app/controllers/pgbus/frontends_controller.rb +25 -11
  16. data/app/controllers/pgbus/insights_controller.rb +19 -1
  17. data/app/controllers/pgbus/jobs_controller.rb +45 -26
  18. data/app/controllers/pgbus/locks_controller.rb +7 -2
  19. data/app/controllers/pgbus/outbox_controller.rb +4 -1
  20. data/app/controllers/pgbus/processes_controller.rb +3 -1
  21. data/app/controllers/pgbus/queues_controller.rb +16 -4
  22. data/app/controllers/pgbus/recurring_tasks_controller.rb +5 -7
  23. data/app/frontend/pgbus/application.min.js +2 -0
  24. data/app/frontend/pgbus/application.min.js.map +1 -0
  25. data/app/frontend/pgbus/modules/charts.js +137 -77
  26. data/app/frontend/pgbus/modules/charts.min.js +2 -0
  27. data/app/frontend/pgbus/modules/charts.min.js.map +1 -0
  28. data/app/frontend/pgbus/style.css +1 -1
  29. data/app/frontend/pgbus/tailwind.css +18 -0
  30. data/app/frontend/pgbus/vendor/turbo.min.js +54 -0
  31. data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -0
  32. data/app/helpers/pgbus/application_helper.rb +155 -113
  33. data/app/helpers/pgbus/button_helper.rb +88 -0
  34. data/app/helpers/pgbus/dashboard_helper.rb +157 -0
  35. data/app/helpers/pgbus/events_helper.rb +40 -0
  36. data/app/helpers/pgbus/insights_helper.rb +39 -0
  37. data/app/helpers/pgbus/processes_helper.rb +79 -0
  38. data/app/views/layouts/pgbus/application.html.erb +0 -1
  39. data/app/views/pgbus/batches/_batches_table.html.erb +16 -13
  40. data/app/views/pgbus/batches/show.html.erb +3 -4
  41. data/app/views/pgbus/dashboard/_processes_table.html.erb +11 -11
  42. data/app/views/pgbus/dashboard/_queue_health.html.erb +42 -51
  43. data/app/views/pgbus/dashboard/_queues_table.html.erb +18 -16
  44. data/app/views/pgbus/dashboard/_recent_failures.html.erb +16 -12
  45. data/app/views/pgbus/dashboard/_stats_cards.html.erb +70 -36
  46. data/app/views/pgbus/dashboard/show.html.erb +1 -1
  47. data/app/views/pgbus/dead_letter/_error.html.erb +13 -0
  48. data/app/views/pgbus/dead_letter/_messages_table.html.erb +37 -96
  49. data/app/views/pgbus/dead_letter/_row.html.erb +75 -0
  50. data/app/views/pgbus/dead_letter/index.html.erb +6 -9
  51. data/app/views/pgbus/dead_letter/show.html.erb +37 -21
  52. data/app/views/pgbus/events/_list.html.erb +55 -0
  53. data/app/views/pgbus/events/_processed_table.html.erb +45 -0
  54. data/app/views/pgbus/events/_replay.html.erb +10 -0
  55. data/app/views/pgbus/events/_row.html.erb +162 -0
  56. data/app/views/pgbus/events/_tabs.html.erb +13 -0
  57. data/app/views/pgbus/events/index.html.erb +27 -48
  58. data/app/views/pgbus/events/show.html.erb +25 -7
  59. data/app/views/pgbus/insights/_chart_card.html.erb +21 -0
  60. data/app/views/pgbus/insights/_latency_table.html.erb +20 -0
  61. data/app/views/pgbus/insights/_status_table.html.erb +19 -0
  62. data/app/views/pgbus/insights/_throughput_table.html.erb +18 -0
  63. data/app/views/pgbus/insights/show.html.erb +83 -100
  64. data/app/views/pgbus/jobs/_list.html.erb +64 -0
  65. data/app/views/pgbus/jobs/_row.html.erb +107 -0
  66. data/app/views/pgbus/jobs/_tabs.html.erb +14 -0
  67. data/app/views/pgbus/jobs/index.html.erb +10 -10
  68. data/app/views/pgbus/jobs/show.html.erb +18 -20
  69. data/app/views/pgbus/locks/_concurrency.html.erb +25 -23
  70. data/app/views/pgbus/locks/_uniqueness.html.erb +20 -22
  71. data/app/views/pgbus/outbox/index.html.erb +30 -27
  72. data/app/views/pgbus/processes/_processes_table.html.erb +6 -25
  73. data/app/views/pgbus/processes/_row.html.erb +48 -0
  74. data/app/views/pgbus/processes/_status.html.erb +4 -0
  75. data/app/views/pgbus/queues/_queues_list.html.erb +28 -32
  76. data/app/views/pgbus/queues/show.html.erb +69 -118
  77. data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +75 -76
  78. data/app/views/pgbus/recurring_tasks/index.html.erb +1 -1
  79. data/app/views/pgbus/recurring_tasks/show.html.erb +45 -51
  80. data/app/views/pgbus/shared/_empty_row.html.erb +2 -0
  81. data/app/views/pgbus/shared/_pager.html.erb +34 -0
  82. data/config/i18n-tasks.yml +11 -2
  83. data/config/locales/da.yml +405 -113
  84. data/config/locales/de.yml +405 -113
  85. data/config/locales/en.yml +405 -113
  86. data/config/locales/es.yml +404 -112
  87. data/config/locales/fi.yml +404 -112
  88. data/config/locales/fr.yml +406 -114
  89. data/config/locales/it.yml +405 -113
  90. data/config/locales/ja.yml +405 -113
  91. data/config/locales/nb.yml +405 -113
  92. data/config/locales/nl.yml +405 -113
  93. data/config/locales/pt.yml +406 -114
  94. data/config/locales/sv.yml +404 -112
  95. data/config/routes.rb +1 -1
  96. data/lib/generators/pgbus/add_presence_generator.rb +2 -1
  97. data/lib/generators/pgbus/add_presence_seats_generator.rb +45 -0
  98. data/lib/generators/pgbus/templates/add_presence.rb.erb +32 -7
  99. data/lib/generators/pgbus/templates/add_presence_seats.rb.erb +38 -0
  100. data/lib/generators/pgbus/templates/initializer.rb.erb +4 -0
  101. data/lib/pgbus/active_job/executor.rb +16 -6
  102. data/lib/pgbus/cli/dlq.rb +32 -5
  103. data/lib/pgbus/client.rb +4 -2
  104. data/lib/pgbus/configuration/presence_settings.rb +103 -0
  105. data/lib/pgbus/configuration.rb +51 -2
  106. data/lib/pgbus/dead_letter_header.rb +129 -0
  107. data/lib/pgbus/doctor.rb +8 -4
  108. data/lib/pgbus/engine.rb +13 -7
  109. data/lib/pgbus/event_bus/handler.rb +18 -11
  110. data/lib/pgbus/event_bus/registry.rb +6 -0
  111. data/lib/pgbus/failed_event_recorder.rb +34 -0
  112. data/lib/pgbus/generators/migration_detector.rb +9 -1
  113. data/lib/pgbus/instrumentation.rb +2 -1
  114. data/lib/pgbus/mcp/base_tool.rb +25 -0
  115. data/lib/pgbus/mcp/tools/concurrency_tool.rb +23 -4
  116. data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +4 -2
  117. data/lib/pgbus/mcp/tools/dlq_tool.rb +10 -4
  118. data/lib/pgbus/mcp/tools/locks_tool.rb +23 -5
  119. data/lib/pgbus/mcp/tools/recurring_tool.rb +33 -4
  120. data/lib/pgbus/outbox/poller.rb +1 -1
  121. data/lib/pgbus/process/consumer.rb +53 -3
  122. data/lib/pgbus/process/dispatcher.rb +1 -1
  123. data/lib/pgbus/process/supervisor.rb +32 -11
  124. data/lib/pgbus/process/worker.rb +45 -8
  125. data/lib/pgbus/recurring/scheduler.rb +2 -1
  126. data/lib/pgbus/streams/presence.rb +317 -81
  127. data/lib/pgbus/version.rb +1 -1
  128. data/lib/pgbus/web/data_source/dashboard_signals.rb +170 -0
  129. data/lib/pgbus/web/data_source/dead_letter.rb +142 -0
  130. data/lib/pgbus/web/data_source/event_list.rb +161 -0
  131. data/lib/pgbus/web/data_source/job_list.rb +323 -0
  132. data/lib/pgbus/web/data_source/list_counts.rb +80 -0
  133. data/lib/pgbus/web/data_source/process_list.rb +69 -0
  134. data/lib/pgbus/web/data_source/queue_summary.rb +122 -0
  135. data/lib/pgbus/web/data_source.rb +132 -254
  136. data/lib/pgbus/web/dead_letter_reason.rb +81 -0
  137. data/lib/pgbus/web/event_state.rb +71 -0
  138. data/lib/pgbus/web/health_app.rb +7 -3
  139. data/lib/pgbus/web/health_verdict.rb +78 -0
  140. data/lib/pgbus/web/insights_charts.rb +84 -0
  141. data/lib/pgbus/web/job_state.rb +111 -0
  142. data/lib/pgbus/web/process_activity.rb +97 -0
  143. data/lib/pgbus/web/process_state.rb +187 -0
  144. data/lib/pgbus/web/queue_summary.rb +104 -0
  145. data/lib/pgbus/web/stream_app.rb +18 -6
  146. data/lib/pgbus/web/streamer/connection.rb +7 -1
  147. data/lib/pgbus/web/streamer/falcon_connection.rb +7 -1
  148. data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
  149. data/lib/pgbus/web/streamer/instance.rb +2 -1
  150. data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
  151. data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +8 -37
  152. data/lib/pgbus/web/time_format.rb +128 -0
  153. metadata +55 -4
  154. data/app/views/pgbus/events/_pending_table.html.erb +0 -169
  155. data/app/views/pgbus/jobs/_enqueued_table.html.erb +0 -118
  156. data/app/views/pgbus/jobs/_failed_table.html.erb +0 -76
data/config/routes.rb CHANGED
@@ -99,7 +99,7 @@ Pgbus::Engine.routes.draw do
99
99
  end
100
100
 
101
101
  scope :frontend, controller: :frontends, defaults: { version: Pgbus::VERSION.tr(".", "-") } do
102
- get "modules/:version/:id", action: :module, as: :frontend_module, constraints: { format: "js" }
102
+ get "modules/:version/:id", action: :module, as: :frontend_module, constraints: { format: /js|map/ }
103
103
  get "static/:version/:id", action: :static, as: :frontend_static
104
104
  end
105
105
  end
@@ -33,7 +33,8 @@ module Pgbus
33
33
  say " 2. Use in your code:"
34
34
  say " Pgbus.stream(@room).presence.join(member_id: current_user.id.to_s)"
35
35
  say " Pgbus.stream(@room).presence.members"
36
- say " 3. Run a periodic sweeper to expire idle members:"
36
+ say " 3. Connection-driven presence (streams_presence_patterns) expires silent"
37
+ say " seats on its own. Rows you join by member_id need a periodic sweeper:"
37
38
  say " Pgbus.stream(@room).presence.sweep!(older_than: 60.seconds.ago)"
38
39
  say ""
39
40
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+ require_relative "migration_path"
6
+
7
+ module Pgbus
8
+ module Generators
9
+ class AddPresenceSeatsGenerator < Rails::Generators::Base
10
+ include ActiveRecord::Generators::Migration
11
+ include MigrationPath
12
+
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ desc "Add per-connection seats (connection_id, seat, state) to pgbus_presence_members"
16
+
17
+ class_option :database,
18
+ type: :string,
19
+ default: nil,
20
+ desc: "Use a separate database for pgbus tables (e.g. --database=pgbus)"
21
+
22
+ def create_migration_file
23
+ migration_template "add_presence_seats.rb.erb",
24
+ File.join(pgbus_migrate_path, "add_pgbus_presence_seats.rb")
25
+ end
26
+
27
+ def display_post_install
28
+ say ""
29
+ say "Pgbus presence seats installed!", :green
30
+ say ""
31
+ say "Next steps:"
32
+ say " 1. Run: rails db:migrate#{migrate_command_suffix}"
33
+ say " 2. Restart the app: connection-driven presence (streams_presence_patterns)"
34
+ say " then keeps one row per SSE connection, expired after streams_presence_ttl"
35
+ say ""
36
+ end
37
+
38
+ private
39
+
40
+ def migration_version
41
+ "[#{ActiveRecord::Migration.current_version}]"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -4,23 +4,48 @@ class AddPgbusPresence < ActiveRecord::Migration<%= migration_version %>
4
4
  t.string :stream_name, null: false
5
5
  t.string :member_id, null: false
6
6
  t.jsonb :metadata, null: false, default: {}
7
+ # One row per SSE connection for connection-driven presence (seats).
8
+ # NULL for rows written by member-keyed join/leave calls.
9
+ t.string :connection_id
10
+ # The client's per-tab id (sent by <pgbus-stream-source presence> as ?seat=).
11
+ t.string :seat
12
+ # Ephemeral per-seat state, written by Presence#set_state.
13
+ t.jsonb :state, null: false, default: {}
7
14
  t.datetime :joined_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
8
15
  t.datetime :last_seen_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
9
16
  end
10
17
 
11
- # Composite primary key (stream_name, member_id) — a member can only
12
- # appear once per stream. Used by the upsert path in
13
- # Pgbus::Streams::Presence#join.
18
+ # A member-keyed row (no connection) appears once per stream. Upsert
19
+ # target of Pgbus::Streams::Presence#join(member_id:).
14
20
  add_index :pgbus_presence_members,
15
21
  %i[stream_name member_id],
16
22
  unique: true,
17
- name: "idx_pgbus_presence_members_pk"
23
+ where: "connection_id IS NULL",
24
+ name: "idx_pgbus_presence_members_member_key"
18
25
 
19
- # Sweeping by last_seen_at expires stale members. The Dispatcher's
20
- # periodic sweeper queries WHERE stream_name = ? AND last_seen_at < ?,
21
- # so this composite index makes that fast.
26
+ # A seat row appears once per connection. Upsert target of
27
+ # Pgbus::Streams::Presence#join(connection_id:).
28
+ add_index :pgbus_presence_members,
29
+ %i[stream_name connection_id],
30
+ unique: true,
31
+ where: "connection_id IS NOT NULL",
32
+ name: "idx_pgbus_presence_members_connection_key"
33
+
34
+ # All rows of one member (several tabs) on a stream.
35
+ add_index :pgbus_presence_members,
36
+ %i[stream_name member_id],
37
+ name: "idx_pgbus_presence_members_member"
38
+
39
+ # Sweeping by last_seen_at expires stale members:
40
+ # WHERE stream_name = ? AND last_seen_at < ?
22
41
  add_index :pgbus_presence_members,
23
42
  %i[stream_name last_seen_at],
24
43
  name: "idx_pgbus_presence_members_sweep"
44
+
45
+ # The streamer heartbeat expires silent seats across every stream.
46
+ add_index :pgbus_presence_members,
47
+ :last_seen_at,
48
+ where: "connection_id IS NOT NULL",
49
+ name: "idx_pgbus_presence_members_seat_expiry"
25
50
  end
26
51
  end
@@ -0,0 +1,38 @@
1
+ class AddPgbusPresenceSeats < ActiveRecord::Migration<%= migration_version %>
2
+ def up
3
+ add_column :pgbus_presence_members, :connection_id, :string
4
+ add_column :pgbus_presence_members, :seat, :string
5
+ add_column :pgbus_presence_members, :state, :jsonb, null: false, default: {}
6
+
7
+ # Existing rows are member-keyed (connection_id IS NULL) and keep their
8
+ # one-row-per-member key; seat rows get one row per connection.
9
+ remove_index :pgbus_presence_members, name: "idx_pgbus_presence_members_pk"
10
+ add_index :pgbus_presence_members, %i[stream_name member_id],
11
+ unique: true, where: "connection_id IS NULL",
12
+ name: "idx_pgbus_presence_members_member_key"
13
+ add_index :pgbus_presence_members, %i[stream_name connection_id],
14
+ unique: true, where: "connection_id IS NOT NULL",
15
+ name: "idx_pgbus_presence_members_connection_key"
16
+ add_index :pgbus_presence_members, %i[stream_name member_id],
17
+ name: "idx_pgbus_presence_members_member"
18
+ add_index :pgbus_presence_members, :last_seen_at,
19
+ where: "connection_id IS NOT NULL",
20
+ name: "idx_pgbus_presence_members_seat_expiry"
21
+ end
22
+
23
+ def down
24
+ # Seat rows cannot satisfy the old one-row-per-member key.
25
+ execute "DELETE FROM pgbus_presence_members WHERE connection_id IS NOT NULL"
26
+
27
+ remove_index :pgbus_presence_members, name: "idx_pgbus_presence_members_seat_expiry"
28
+ remove_index :pgbus_presence_members, name: "idx_pgbus_presence_members_member"
29
+ remove_index :pgbus_presence_members, name: "idx_pgbus_presence_members_connection_key"
30
+ remove_index :pgbus_presence_members, name: "idx_pgbus_presence_members_member_key"
31
+ add_index :pgbus_presence_members, %i[stream_name member_id],
32
+ unique: true, name: "idx_pgbus_presence_members_pk"
33
+
34
+ remove_column :pgbus_presence_members, :state
35
+ remove_column :pgbus_presence_members, :seat
36
+ remove_column :pgbus_presence_members, :connection_id
37
+ end
38
+ end
@@ -36,6 +36,10 @@ Pgbus.configure do |c|
36
36
  c.workers = []
37
37
  c.capsule :default, queues: %w[critical default], threads: 5
38
38
  c.capsule :low, queues: %w[low], threads: 2
39
+ # CPU-bound jobs (rendering, reports, PDFs): threads share one GVL, so use
40
+ # processes instead. Capsule-level recycle limits override the global ones.
41
+ # c.capsule :render, queues: %w[render], threads: 1, processes: 4,
42
+ # max_memory_mb: 1_536
39
43
 
40
44
  # --- Worker recycling (prevents memory bloat) --------------------------
41
45
  c.max_jobs_per_worker = 10_000
@@ -26,7 +26,10 @@ module Pgbus
26
26
  read_count = message.read_ct.to_i
27
27
 
28
28
  if read_count > config.max_retries
29
- handle_dead_letter(message, queue_name, payload, source_queue: source_queue)
29
+ # Read the last error before clear! deletes the only row that has
30
+ # it: the DLQ copy carries it in its headers (issue #495).
31
+ last_error = FailedEventRecorder.last_error(queue_name: queue_name, msg_id: message.msg_id.to_i)
32
+ handle_dead_letter(message, queue_name, payload, source_queue: source_queue, last_error: last_error)
30
33
  FailedEventRecorder.clear!(queue_name: queue_name, msg_id: message.msg_id.to_i)
31
34
  signal_concurrency(payload)
32
35
  signal_batch_discarded(payload)
@@ -39,7 +42,8 @@ module Pgbus
39
42
  job_id: payload["job_id"],
40
43
  provider_job_id: payload["provider_job_id"],
41
44
  read_ct: read_count,
42
- msg_id: message.msg_id.to_i
45
+ msg_id: message.msg_id.to_i,
46
+ error: last_error&.dig(:error_class)
43
47
  )
44
48
  Pgbus.logger.debug { "[Pgbus::Executor] dead_lettered #{log_tag(message, queue_name)} job_class=#{job_class}" }
45
49
  return :dead_lettered
@@ -428,20 +432,26 @@ module Pgbus
428
432
  defined?(PGMQ::Errors::ConnectionError) && error.is_a?(PGMQ::Errors::ConnectionError)
429
433
  end
430
434
 
431
- def handle_dead_letter(message, queue_name, payload, source_queue: nil)
435
+ def handle_dead_letter(message, queue_name, payload, source_queue: nil, last_error: nil)
432
436
  Pgbus.logger.warn do
433
437
  job_class = payload["job_class"] || "unknown"
434
- "[Pgbus] Moving job #{job_class} to dead letter queue after #{message.read_ct} attempts"
438
+ error = last_error ? " (last error: #{last_error[:error_class]})" : ""
439
+ "[Pgbus] Moving job #{job_class} to dead letter queue after #{message.read_ct} attempts#{error}"
435
440
  end
441
+ headers = DeadLetterHeader.build(
442
+ existing: message.headers, reason: DeadLetterHeader::REASON_MAX_RETRIES, source: "worker",
443
+ source_queue: source_queue || config.queue_name(queue_name), attempts: message.read_ct.to_i,
444
+ max_retries: config.max_retries, error: last_error
445
+ )
436
446
  if source_queue
437
447
  client.ensure_dead_letter_queue(queue_name)
438
448
  dlq_name = config.dead_letter_queue_name(queue_name)
439
449
  client.transaction do |txn|
440
- txn.produce(dlq_name, message.message, headers: message.headers)
450
+ txn.produce(dlq_name, message.message, headers: headers)
441
451
  txn.delete(source_queue, message.msg_id.to_i)
442
452
  end
443
453
  else
444
- client.move_to_dead_letter(queue_name, message)
454
+ client.move_to_dead_letter(queue_name, message, headers: headers)
445
455
  end
446
456
  end
447
457
  end
data/lib/pgbus/cli/dlq.rb CHANGED
@@ -12,7 +12,8 @@ module Pgbus
12
12
  module DLQ
13
13
  module_function
14
14
 
15
- ROW_FORMAT = "%-10s %-32s %-28s %-8s %s"
15
+ ROW_FORMAT = "%-10s %-32s %-28s %-8s %-26s %s"
16
+ REASON_WIDTH = 40
16
17
 
17
18
  def start(args, data_source: Pgbus::Web::DataSource.new)
18
19
  subcommand = args.first
@@ -41,7 +42,7 @@ module Pgbus
41
42
  end
42
43
 
43
44
  print_list_table(messages)
44
- puts "-" * 96
45
+ puts "-" * 140
45
46
  puts "Total dead-letter messages: #{data_source.dlq_total_count} " \
46
47
  "(page #{options[:page]}, #{options[:per_page]} per page)"
47
48
  end
@@ -56,6 +57,7 @@ module Pgbus
56
57
  puts "origin: #{origin_queue(detail[:queue_name])}"
57
58
  puts "read_ct: #{detail[:read_ct]}"
58
59
  puts "enqueued_at: #{detail[:enqueued_at]}"
60
+ print_dead_letter(Pgbus::Web::DeadLetterReason.present(detail))
59
61
  puts "payload:"
60
62
  puts Pgbus::Web::PayloadFilter.filter_json(detail[:message])
61
63
  end
@@ -120,14 +122,39 @@ module Pgbus
120
122
  end
121
123
 
122
124
  def print_list_table(messages)
123
- puts format(ROW_FORMAT, "MSG_ID", "DLQ QUEUE", "ORIGIN QUEUE", "READ_CT", "ENQUEUED_AT")
124
- puts "-" * 96
125
+ puts format(ROW_FORMAT, "MSG_ID", "DLQ QUEUE", "ORIGIN QUEUE", "READ_CT", "ENQUEUED_AT", "REASON")
126
+ puts "-" * 140
125
127
  messages.each do |m|
128
+ reason = Pgbus::Web::DeadLetterReason.present(m)
126
129
  puts format(ROW_FORMAT, m[:msg_id], m[:queue_name], origin_queue(m[:queue_name]),
127
- m[:read_ct], m[:enqueued_at])
130
+ m[:read_ct], m[:enqueued_at], (reason.error_class || reason.reason_key).to_s[0, REASON_WIDTH])
128
131
  end
129
132
  end
130
133
 
134
+ # Why the message died, from its pgbus_dead_letter header (issue #495).
135
+ def print_dead_letter(result)
136
+ if result.legacy?
137
+ puts "reason: not recorded (dead-lettered before pgbus #{Pgbus::DeadLetterHeader::SINCE})"
138
+ return
139
+ end
140
+
141
+ puts "died: #{result.died_at}"
142
+ puts "attempts: #{result.attempts}/#{result.max_retries}"
143
+ puts "source: #{result.source_queue}"
144
+ puts "reason: #{result.reason}"
145
+ puts "error: #{dead_letter_error(result)}"
146
+ puts "retried: #{result.retried_before} time(s) from the DLQ before" if result.retried_before
147
+ end
148
+
149
+ def dead_letter_error(result)
150
+ return "none recorded" unless result.error_class
151
+
152
+ error = "#{result.error_class}: #{result.error_message} (attempt #{result.error_attempt})"
153
+ # A stored message is untrusted: newlines would break the layout and
154
+ # ANSI sequences could rewrite the operator's terminal.
155
+ error.gsub(/[\x00-\x1f\x7f]/) { |char| format("\\x%02X", char.ord) }
156
+ end
157
+
131
158
  def origin_queue(dlq_queue_name)
132
159
  dlq_queue_name.delete_suffix(Pgbus::DEAD_LETTER_SUFFIX)
133
160
  end
data/lib/pgbus/client.rb CHANGED
@@ -592,7 +592,9 @@ module Pgbus
592
592
  end
593
593
  end
594
594
 
595
- def move_to_dead_letter(queue_name, message)
595
+ # headers: what the DLQ copy carries. Movers pass the message's headers
596
+ # merged with a DeadLetterHeader block; the default copies them verbatim.
597
+ def move_to_dead_letter(queue_name, message, headers: message.headers)
596
598
  dlq_name = config.dead_letter_queue_name(queue_name)
597
599
  full_queue = config.queue_name(queue_name)
598
600
 
@@ -600,7 +602,7 @@ module Pgbus
600
602
  ensure_dead_letter_queue(queue_name)
601
603
  synchronized do
602
604
  @pgmq.transaction do |txn|
603
- txn.produce(dlq_name, message.message, headers: message.headers)
605
+ txn.produce(dlq_name, message.message, headers: headers)
604
606
  txn.delete(full_queue, message.msg_id.to_i)
605
607
  end
606
608
  end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ class Configuration
5
+ # Seat expiry and the change hook for connection-driven presence
6
+ # (issue #515). EXPERIMENTAL, like the other streams_presence_* settings.
7
+ #
8
+ # config.streams_presence_ttl = 60 # every stream
9
+ # config.streams_presence_ttl = { /^doc:/ => 5.minutes, default: 60 } # per pattern
10
+ # config.streams_presence_on_change = ->(stream_name, change) { ... }
11
+ #
12
+ # `change` is { type: :join | :leave, connection_id:, seat:, member_id: }.
13
+ # An expired seat is reported as a :leave.
14
+ module PresenceSettings
15
+ DEFAULT_PRESENCE_TTL = 60
16
+
17
+ attr_accessor :streams_presence_on_change
18
+ attr_writer :streams_presence_ttl
19
+
20
+ def streams_presence_ttl
21
+ @streams_presence_ttl.nil? ? DEFAULT_PRESENCE_TTL : @streams_presence_ttl
22
+ end
23
+
24
+ # Seconds a seat may stay silent on `stream_name` before the heartbeat
25
+ # expires it. A pattern matches like streams_presence_patterns (exact
26
+ # String or Regexp); the first match wins.
27
+ def presence_ttl_for(stream_name)
28
+ ttl = streams_presence_ttl
29
+ return presence_seconds(ttl) unless ttl.is_a?(Hash)
30
+
31
+ match = ttl.find { |pattern, _| pattern_matches?(pattern, stream_name) }
32
+ presence_seconds(match ? match.last : ttl.fetch(:default, DEFAULT_PRESENCE_TTL))
33
+ end
34
+
35
+ # The smallest ttl configured: the expiry scan's candidate cutoff.
36
+ def presence_min_ttl
37
+ ttl = streams_presence_ttl
38
+ return presence_seconds(ttl) unless ttl.is_a?(Hash)
39
+
40
+ [*ttl.values, ttl.fetch(:default, DEFAULT_PRESENCE_TTL)].map { |v| presence_seconds(v) }.min
41
+ end
42
+
43
+ # Reports one join, leave or expiry to streams_presence_on_change.
44
+ # A raising hook is logged and swallowed, like the rest of the
45
+ # presence path: it must not break a connection or a sweep.
46
+ def notify_presence_change(stream_name, change)
47
+ hook = streams_presence_on_change
48
+ return unless hook
49
+
50
+ hook.call(stream_name, change)
51
+ rescue StandardError => e
52
+ Pgbus.logger.error do
53
+ "[Pgbus] streams_presence_on_change raised for #{stream_name}: #{e.class}: #{e.message}"
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def validate_presence_settings!
60
+ if !streams_presence_on_change.nil? && !streams_presence_on_change.respond_to?(:call)
61
+ raise Pgbus::ConfigurationError,
62
+ "streams_presence_on_change must respond to #call (a Proc/lambda) or be nil"
63
+ end
64
+
65
+ ttl = streams_presence_ttl
66
+ values = ttl.is_a?(Hash) ? ttl.values : [ttl]
67
+ unless values.all? { |v| v.is_a?(Numeric) && v.to_f.positive? }
68
+ raise Pgbus::ConfigurationError,
69
+ "streams_presence_ttl must be a positive number of seconds or a Hash of pattern => seconds"
70
+ end
71
+
72
+ warn_presence_ttl_below_heartbeat
73
+ end
74
+
75
+ # A ttl at or below the heartbeat interval lets another process expire
76
+ # live seats between touches, so every tick reports a leave and a join.
77
+ def warn_presence_ttl_below_heartbeat
78
+ heartbeat = streams_heartbeat_interval
79
+ return unless heartbeat.is_a?(Numeric) && presence_min_ttl <= heartbeat
80
+
81
+ Pgbus.logger.warn do
82
+ "[Pgbus] streams_presence_ttl (#{presence_min_ttl}s) should exceed streams_heartbeat_interval " \
83
+ "(#{heartbeat}s), or live presence seats are expired and re-joined every tick"
84
+ end
85
+ end
86
+
87
+ # Seconds as an Integer when whole, a Float otherwise (a Duration
88
+ # becomes plain seconds), so fractional ttls are not truncated.
89
+ def presence_seconds(value)
90
+ seconds = value.to_f
91
+ seconds == seconds.floor ? seconds.to_i : seconds
92
+ end
93
+
94
+ def pattern_matches?(pattern, stream_name)
95
+ case pattern
96
+ when Regexp then pattern.match?(stream_name)
97
+ when :default then false
98
+ else pattern.to_s == stream_name
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -4,6 +4,8 @@ require "logger"
4
4
 
5
5
  module Pgbus
6
6
  class Configuration
7
+ include PresenceSettings
8
+
7
9
  # Connection settings
8
10
  attr_accessor :database_url, :connection_params, :pool_size, :pool_timeout
9
11
 
@@ -590,6 +592,21 @@ module Pgbus
590
592
  entry.fetch(:read_ahead, nil) || read_ahead
591
593
  end
592
594
 
595
+ # Recycle limits a capsule may set for its own workers (issue #503).
596
+ RECYCLE_LIMIT_KEYS = %i[max_jobs_per_worker max_memory_mb max_worker_lifetime].freeze
597
+
598
+ # How many identical worker processes the supervisor forks for one
599
+ # capsule (issue #503). Default 1.
600
+ def processes_for(entry)
601
+ entry.fetch(:processes, nil) || 1
602
+ end
603
+
604
+ # One recycle limit for a capsule, falling back to the global value
605
+ # (mirrors read_ahead_for). nil means the limit is off.
606
+ def recycle_limit_for(entry, key)
607
+ entry.fetch(key, nil) || public_send(key)
608
+ end
609
+
593
610
  # Returns the execution mode for a specific worker config hash,
594
611
  # falling back to the global execution_mode setting.
595
612
  def execution_mode_for(worker_config)
@@ -846,6 +863,7 @@ module Pgbus
846
863
  end
847
864
 
848
865
  validate_read_ahead!
866
+ validate_capsule_processes!
849
867
 
850
868
  if priority_levels && !(priority_levels.is_a?(Integer) && priority_levels >= 1 && priority_levels <= 10)
851
869
  raise Pgbus::ConfigurationError, "priority_levels must be an integer between 1 and 10"
@@ -879,6 +897,27 @@ module Pgbus
879
897
  warn_read_ahead_without_heartbeat(settings)
880
898
  end
881
899
 
900
+ # Per-capsule processes (a positive Integer) and recycle limits (a positive
901
+ # number; unset falls back to the global value). The Array form of
902
+ # +workers=+ bypasses +capsule+, so both are checked here (issue #503).
903
+ def validate_capsule_processes!
904
+ Array(workers).each do |w|
905
+ validate_processes!(w[:processes]) unless w[:processes].nil?
906
+ RECYCLE_LIMIT_KEYS.each do |key|
907
+ value = w[key]
908
+ next if value.nil? || (value.is_a?(Numeric) && value.positive?)
909
+
910
+ raise Pgbus::ConfigurationError, "capsule #{key} must be a positive number (got #{value.inspect})"
911
+ end
912
+ end
913
+ end
914
+
915
+ def validate_processes!(value)
916
+ return if value.is_a?(Integer) && value.positive?
917
+
918
+ raise Pgbus::ConfigurationError, "capsule processes must be a positive Integer (got #{value.inspect})"
919
+ end
920
+
882
921
  # Buffered claims are kept invisible only by the visibility heartbeat.
883
922
  # Without it a message buffered longer than visibility_timeout can be
884
923
  # claimed by another worker while it still waits here. Legal, but warned.
@@ -1056,6 +1095,8 @@ module Pgbus
1056
1095
  raise Pgbus::ConfigurationError, "streams_presence_member must respond to #call (a Proc/lambda) or be nil"
1057
1096
  end
1058
1097
 
1098
+ validate_presence_settings!
1099
+
1059
1100
  return if streams_orphan_threshold.nil?
1060
1101
  return if streams_orphan_threshold.is_a?(Numeric) && streams_orphan_threshold.positive?
1061
1102
 
@@ -1209,15 +1250,23 @@ module Pgbus
1209
1250
  #
1210
1251
  # c.capsule :critical, queues: %w[critical], threads: 5
1211
1252
  # c.capsule :gated, queues: %w[gated], threads: 1, single_active_consumer: true
1253
+ # c.capsule :render, queues: %w[render], threads: 1, processes: 4,
1254
+ # max_memory_mb: 1_536, max_worker_lifetime: 6.hours
1255
+ #
1256
+ # processes: N forks N identical workers for the capsule (CPU-bound jobs:
1257
+ # threads share one GVL, processes do not). The capsule-level recycle
1258
+ # limits override the global ones for this capsule's workers only.
1212
1259
  #
1213
1260
  # Names must be unique. Queues must not overlap with capsules already
1214
1261
  # defined (would cause double-processing). Composes with the string DSL —
1215
1262
  # +c.workers "..."+ followed by +c.capsule :name, ...+ appends the
1216
1263
  # named capsule to the list parsed from the string.
1217
- def capsule(name, queues:, threads:, **)
1264
+ def capsule(name, queues:, threads:, **options)
1218
1265
  raise Pgbus::ConfigurationError, "capsule queues must be a non-empty Array" unless queues.is_a?(Array) && queues.any?
1219
1266
  raise Pgbus::ConfigurationError, "capsule threads must be a positive Integer" unless threads.is_a?(Integer) && threads.positive?
1220
1267
 
1268
+ validate_processes!(options[:processes]) unless options[:processes].nil?
1269
+
1221
1270
  normalized_name = name.to_s
1222
1271
  @workers ||= []
1223
1272
 
@@ -1227,7 +1276,7 @@ module Pgbus
1227
1276
 
1228
1277
  validate_no_queue_overlap!(queues)
1229
1278
 
1230
- @workers << { name: normalized_name, queues: queues, threads: threads, ** }
1279
+ @workers << { name: normalized_name, queues: queues, threads: threads, **options }
1231
1280
  end
1232
1281
 
1233
1282
  # Look up a capsule by its name. Accepts symbol or string. Returns the
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Pgbus
7
+ # The `pgbus_dead_letter` block a mover writes into a message's PGMQ
8
+ # headers when it moves the message to a dead-letter queue (issue #495):
9
+ # why it was moved, from where, after how many attempts, and the last
10
+ # recorded error. Pure — no I/O — so the executor, the consumer, the
11
+ # dashboard, MCP and the CLI all read and write the same shape.
12
+ #
13
+ # PGMQ headers travel as raw JSON strings, so every entry point accepts a
14
+ # String (or a Hash, or nil) and build/strip_for_retry return a String.
15
+ module DeadLetterHeader
16
+ KEY = "pgbus_dead_letter"
17
+ # Top-level counter on a live message retried out of a DLQ. Folded into
18
+ # the block as `retries_from_dlq` when the message dies again.
19
+ RETRIES_KEY = "pgbus_dlq_retries"
20
+ ORIGINAL_KEY = "pgbus_original_headers"
21
+ VERSION = 1
22
+ REASON_MAX_RETRIES = "max_retries_exceeded"
23
+ # The release that started writing the block: rows without it predate it.
24
+ SINCE = "0.18.0"
25
+ MESSAGE_LIMIT = 1_000
26
+ BACKTRACE_LINES = 10
27
+ BACKTRACE_LIMIT = 2_000
28
+
29
+ module_function
30
+
31
+ # error: FailedEventRecorder.last_error's Hash, or nil when no failure
32
+ # was recorded. Returns the merged headers as a JSON string.
33
+ def build(existing:, reason:, source:, source_queue:, attempts:, max_retries:, error: nil, now: Time.now.utc)
34
+ headers = decode(existing, warn: true)
35
+ retries = count(headers.delete(RETRIES_KEY))
36
+ block = {
37
+ "version" => VERSION,
38
+ "reason" => reason,
39
+ "source" => source,
40
+ "source_queue" => source_queue,
41
+ "attempts" => attempts,
42
+ "max_retries" => max_retries,
43
+ "dead_lettered_at" => iso(now)
44
+ }
45
+ block["retries_from_dlq"] = retries if retries.positive?
46
+ block.merge!(error_fields(error)) if error
47
+ JSON.generate(headers.merge(KEY => block))
48
+ end
49
+
50
+ # The block as a string-keyed Hash, or nil. Never raises.
51
+ def parse(headers)
52
+ block = decode(headers, warn: false)[KEY]
53
+ block.is_a?(Hash) ? block : nil
54
+ end
55
+
56
+ # A live message must not claim to be dead: drop the block, and carry
57
+ # how many times it has come back out of a DLQ. A DLQ message has no
58
+ # top-level counter (build folded it into the block), so the block is
59
+ # the only place to read the previous count from.
60
+ def strip_for_retry(headers)
61
+ retries = count(parse(headers)&.dig("retries_from_dlq")) + 1
62
+ stripped = decode(headers, warn: false)
63
+ stripped.delete(KEY)
64
+ stripped[RETRIES_KEY] = retries
65
+ JSON.generate(stripped)
66
+ end
67
+
68
+ # A counter read from headers anyone could have written: an Integer or a
69
+ # numeric String counts, anything else is 0. Never raises, so a bad value
70
+ # can't stop a dead-letter.
71
+ def count(value)
72
+ case value
73
+ when Integer then value
74
+ when String then Integer(value, 10, exception: false) || 0
75
+ else 0
76
+ end
77
+ end
78
+
79
+ def error_fields(error)
80
+ {
81
+ "error_class" => error[:error_class],
82
+ "error_message" => error[:error_message]&.to_s&.slice(0, MESSAGE_LIMIT),
83
+ "backtrace" => backtrace_head(error[:backtrace]),
84
+ "error_attempt" => error[:retry_count].to_i + 1,
85
+ "error_recorded_at" => error[:failed_at] && iso(error[:failed_at])
86
+ }.compact
87
+ end
88
+
89
+ def backtrace_head(backtrace)
90
+ return if backtrace.nil? || backtrace.empty?
91
+
92
+ lines = backtrace.is_a?(Array) ? backtrace : backtrace.to_s.split("\n")
93
+ lines.first(BACKTRACE_LINES).join("\n").slice(0, BACKTRACE_LIMIT)
94
+ end
95
+
96
+ def iso(value)
97
+ case value
98
+ when Time then value.getutc.iso8601(6)
99
+ when String then value
100
+ else value.respond_to?(:to_time) ? value.to_time.utc.iso8601(6) : value.to_s
101
+ end
102
+ end
103
+
104
+ # Always a Hash. Headers that are not a JSON object are kept, not
105
+ # dropped, under ORIGINAL_KEY.
106
+ def decode(headers, warn:)
107
+ case headers
108
+ when nil, "" then {}
109
+ when Hash then headers.transform_keys(&:to_s)
110
+ else wrap(JSON.parse(headers.to_s), headers, warn)
111
+ end
112
+ rescue JSON::ParserError
113
+ log_kept(headers) if warn
114
+ { ORIGINAL_KEY => headers }
115
+ end
116
+
117
+ def wrap(parsed, raw, warn)
118
+ return parsed if parsed.is_a?(Hash)
119
+
120
+ log_kept(raw) if warn
121
+ { ORIGINAL_KEY => parsed }
122
+ end
123
+
124
+ # The raw value is not logged: headers can carry caller secrets.
125
+ def log_kept(_raw)
126
+ Pgbus.logger.warn { "[Pgbus] Dead-letter headers were not a JSON object; kept under #{ORIGINAL_KEY}" }
127
+ end
128
+ end
129
+ end