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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +83 -13
- data/Rakefile +26 -0
- data/app/assets/javascripts/pgbus/stream_source_element.js +34 -2
- data/app/assets/javascripts/pgbus/stream_source_element.min.js +6 -0
- data/app/assets/javascripts/pgbus/stream_source_element.min.js.map +1 -0
- data/app/controllers/concerns/pgbus/job_listing.rb +37 -0
- data/app/controllers/concerns/pgbus/process_listing.rb +17 -0
- data/app/controllers/pgbus/application_controller.rb +5 -2
- data/app/controllers/pgbus/batches_controller.rb +4 -1
- data/app/controllers/pgbus/dashboard_controller.rb +20 -6
- data/app/controllers/pgbus/dead_letter_controller.rb +27 -3
- data/app/controllers/pgbus/events_controller.rb +52 -3
- data/app/controllers/pgbus/frontends_controller.rb +25 -11
- data/app/controllers/pgbus/insights_controller.rb +19 -1
- data/app/controllers/pgbus/jobs_controller.rb +45 -26
- data/app/controllers/pgbus/locks_controller.rb +7 -2
- data/app/controllers/pgbus/outbox_controller.rb +4 -1
- data/app/controllers/pgbus/processes_controller.rb +3 -1
- data/app/controllers/pgbus/queues_controller.rb +16 -4
- data/app/controllers/pgbus/recurring_tasks_controller.rb +5 -7
- data/app/frontend/pgbus/application.min.js +2 -0
- data/app/frontend/pgbus/application.min.js.map +1 -0
- data/app/frontend/pgbus/modules/charts.js +137 -77
- data/app/frontend/pgbus/modules/charts.min.js +2 -0
- data/app/frontend/pgbus/modules/charts.min.js.map +1 -0
- data/app/frontend/pgbus/style.css +1 -1
- data/app/frontend/pgbus/tailwind.css +18 -0
- data/app/frontend/pgbus/vendor/turbo.min.js +54 -0
- data/app/frontend/pgbus/vendor/turbo.min.js.map +1 -0
- data/app/helpers/pgbus/application_helper.rb +155 -113
- data/app/helpers/pgbus/button_helper.rb +88 -0
- data/app/helpers/pgbus/dashboard_helper.rb +157 -0
- data/app/helpers/pgbus/events_helper.rb +40 -0
- data/app/helpers/pgbus/insights_helper.rb +39 -0
- data/app/helpers/pgbus/processes_helper.rb +79 -0
- data/app/views/layouts/pgbus/application.html.erb +0 -1
- data/app/views/pgbus/batches/_batches_table.html.erb +16 -13
- data/app/views/pgbus/batches/show.html.erb +3 -4
- data/app/views/pgbus/dashboard/_processes_table.html.erb +11 -11
- data/app/views/pgbus/dashboard/_queue_health.html.erb +42 -51
- data/app/views/pgbus/dashboard/_queues_table.html.erb +18 -16
- data/app/views/pgbus/dashboard/_recent_failures.html.erb +16 -12
- data/app/views/pgbus/dashboard/_stats_cards.html.erb +70 -36
- data/app/views/pgbus/dashboard/show.html.erb +1 -1
- data/app/views/pgbus/dead_letter/_error.html.erb +13 -0
- data/app/views/pgbus/dead_letter/_messages_table.html.erb +37 -96
- data/app/views/pgbus/dead_letter/_row.html.erb +75 -0
- data/app/views/pgbus/dead_letter/index.html.erb +6 -9
- data/app/views/pgbus/dead_letter/show.html.erb +37 -21
- data/app/views/pgbus/events/_list.html.erb +55 -0
- data/app/views/pgbus/events/_processed_table.html.erb +45 -0
- data/app/views/pgbus/events/_replay.html.erb +10 -0
- data/app/views/pgbus/events/_row.html.erb +162 -0
- data/app/views/pgbus/events/_tabs.html.erb +13 -0
- data/app/views/pgbus/events/index.html.erb +27 -48
- data/app/views/pgbus/events/show.html.erb +25 -7
- data/app/views/pgbus/insights/_chart_card.html.erb +21 -0
- data/app/views/pgbus/insights/_latency_table.html.erb +20 -0
- data/app/views/pgbus/insights/_status_table.html.erb +19 -0
- data/app/views/pgbus/insights/_throughput_table.html.erb +18 -0
- data/app/views/pgbus/insights/show.html.erb +83 -100
- data/app/views/pgbus/jobs/_list.html.erb +64 -0
- data/app/views/pgbus/jobs/_row.html.erb +107 -0
- data/app/views/pgbus/jobs/_tabs.html.erb +14 -0
- data/app/views/pgbus/jobs/index.html.erb +10 -10
- data/app/views/pgbus/jobs/show.html.erb +18 -20
- data/app/views/pgbus/locks/_concurrency.html.erb +25 -23
- data/app/views/pgbus/locks/_uniqueness.html.erb +20 -22
- data/app/views/pgbus/outbox/index.html.erb +30 -27
- data/app/views/pgbus/processes/_processes_table.html.erb +6 -25
- data/app/views/pgbus/processes/_row.html.erb +48 -0
- data/app/views/pgbus/processes/_status.html.erb +4 -0
- data/app/views/pgbus/queues/_queues_list.html.erb +28 -32
- data/app/views/pgbus/queues/show.html.erb +69 -118
- data/app/views/pgbus/recurring_tasks/_tasks_table.html.erb +75 -76
- data/app/views/pgbus/recurring_tasks/index.html.erb +1 -1
- data/app/views/pgbus/recurring_tasks/show.html.erb +45 -51
- data/app/views/pgbus/shared/_empty_row.html.erb +2 -0
- data/app/views/pgbus/shared/_pager.html.erb +34 -0
- data/config/i18n-tasks.yml +11 -2
- data/config/locales/da.yml +405 -113
- data/config/locales/de.yml +405 -113
- data/config/locales/en.yml +405 -113
- data/config/locales/es.yml +404 -112
- data/config/locales/fi.yml +404 -112
- data/config/locales/fr.yml +406 -114
- data/config/locales/it.yml +405 -113
- data/config/locales/ja.yml +405 -113
- data/config/locales/nb.yml +405 -113
- data/config/locales/nl.yml +405 -113
- data/config/locales/pt.yml +406 -114
- data/config/locales/sv.yml +404 -112
- data/config/routes.rb +1 -1
- data/lib/generators/pgbus/add_presence_generator.rb +2 -1
- data/lib/generators/pgbus/add_presence_seats_generator.rb +45 -0
- data/lib/generators/pgbus/templates/add_presence.rb.erb +32 -7
- data/lib/generators/pgbus/templates/add_presence_seats.rb.erb +38 -0
- data/lib/generators/pgbus/templates/initializer.rb.erb +4 -0
- data/lib/pgbus/active_job/executor.rb +16 -6
- data/lib/pgbus/cli/dlq.rb +32 -5
- data/lib/pgbus/client.rb +4 -2
- data/lib/pgbus/configuration/presence_settings.rb +103 -0
- data/lib/pgbus/configuration.rb +51 -2
- data/lib/pgbus/dead_letter_header.rb +129 -0
- data/lib/pgbus/doctor.rb +8 -4
- data/lib/pgbus/engine.rb +13 -7
- data/lib/pgbus/event_bus/handler.rb +18 -11
- data/lib/pgbus/event_bus/registry.rb +6 -0
- data/lib/pgbus/failed_event_recorder.rb +34 -0
- data/lib/pgbus/generators/migration_detector.rb +9 -1
- data/lib/pgbus/instrumentation.rb +2 -1
- data/lib/pgbus/mcp/base_tool.rb +25 -0
- data/lib/pgbus/mcp/tools/concurrency_tool.rb +23 -4
- data/lib/pgbus/mcp/tools/dlq_detail_tool.rb +4 -2
- data/lib/pgbus/mcp/tools/dlq_tool.rb +10 -4
- data/lib/pgbus/mcp/tools/locks_tool.rb +23 -5
- data/lib/pgbus/mcp/tools/recurring_tool.rb +33 -4
- data/lib/pgbus/outbox/poller.rb +1 -1
- data/lib/pgbus/process/consumer.rb +53 -3
- data/lib/pgbus/process/dispatcher.rb +1 -1
- data/lib/pgbus/process/supervisor.rb +32 -11
- data/lib/pgbus/process/worker.rb +45 -8
- data/lib/pgbus/recurring/scheduler.rb +2 -1
- data/lib/pgbus/streams/presence.rb +317 -81
- data/lib/pgbus/version.rb +1 -1
- data/lib/pgbus/web/data_source/dashboard_signals.rb +170 -0
- data/lib/pgbus/web/data_source/dead_letter.rb +142 -0
- data/lib/pgbus/web/data_source/event_list.rb +161 -0
- data/lib/pgbus/web/data_source/job_list.rb +323 -0
- data/lib/pgbus/web/data_source/list_counts.rb +80 -0
- data/lib/pgbus/web/data_source/process_list.rb +69 -0
- data/lib/pgbus/web/data_source/queue_summary.rb +122 -0
- data/lib/pgbus/web/data_source.rb +132 -254
- data/lib/pgbus/web/dead_letter_reason.rb +81 -0
- data/lib/pgbus/web/event_state.rb +71 -0
- data/lib/pgbus/web/health_app.rb +7 -3
- data/lib/pgbus/web/health_verdict.rb +78 -0
- data/lib/pgbus/web/insights_charts.rb +84 -0
- data/lib/pgbus/web/job_state.rb +111 -0
- data/lib/pgbus/web/process_activity.rb +97 -0
- data/lib/pgbus/web/process_state.rb +187 -0
- data/lib/pgbus/web/queue_summary.rb +104 -0
- data/lib/pgbus/web/stream_app.rb +18 -6
- data/lib/pgbus/web/streamer/connection.rb +7 -1
- data/lib/pgbus/web/streamer/falcon_connection.rb +7 -1
- data/lib/pgbus/web/streamer/heartbeat.rb +9 -3
- data/lib/pgbus/web/streamer/instance.rb +2 -1
- data/lib/pgbus/web/streamer/presence_tracker.rb +155 -0
- data/lib/pgbus/web/streamer/stream_event_dispatcher.rb +8 -37
- data/lib/pgbus/web/time_format.rb +128 -0
- metadata +55 -4
- data/app/views/pgbus/events/_pending_table.html.erb +0 -169
- data/app/views/pgbus/jobs/_enqueued_table.html.erb +0 -118
- 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:
|
|
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.
|
|
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
|
-
#
|
|
12
|
-
#
|
|
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
|
-
|
|
23
|
+
where: "connection_id IS NULL",
|
|
24
|
+
name: "idx_pgbus_presence_members_member_key"
|
|
18
25
|
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 "-" *
|
|
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 "-" *
|
|
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
|
-
|
|
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:
|
|
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
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -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
|