pgbus 0.17.1 → 0.17.2
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 +38 -0
- data/README.md +48 -13
- 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/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/modules/charts.js +119 -77
- data/app/frontend/pgbus/style.css +1 -1
- data/app/frontend/pgbus/tailwind.css +18 -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/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/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.rb +47 -2
- data/lib/pgbus/dead_letter_header.rb +129 -0
- data/lib/pgbus/doctor.rb +8 -4
- 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/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/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/time_format.rb +128 -0
- metadata +43 -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
|
@@ -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
|
data/lib/pgbus/configuration.rb
CHANGED
|
@@ -590,6 +590,21 @@ module Pgbus
|
|
|
590
590
|
entry.fetch(:read_ahead, nil) || read_ahead
|
|
591
591
|
end
|
|
592
592
|
|
|
593
|
+
# Recycle limits a capsule may set for its own workers (issue #503).
|
|
594
|
+
RECYCLE_LIMIT_KEYS = %i[max_jobs_per_worker max_memory_mb max_worker_lifetime].freeze
|
|
595
|
+
|
|
596
|
+
# How many identical worker processes the supervisor forks for one
|
|
597
|
+
# capsule (issue #503). Default 1.
|
|
598
|
+
def processes_for(entry)
|
|
599
|
+
entry.fetch(:processes, nil) || 1
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# One recycle limit for a capsule, falling back to the global value
|
|
603
|
+
# (mirrors read_ahead_for). nil means the limit is off.
|
|
604
|
+
def recycle_limit_for(entry, key)
|
|
605
|
+
entry.fetch(key, nil) || public_send(key)
|
|
606
|
+
end
|
|
607
|
+
|
|
593
608
|
# Returns the execution mode for a specific worker config hash,
|
|
594
609
|
# falling back to the global execution_mode setting.
|
|
595
610
|
def execution_mode_for(worker_config)
|
|
@@ -846,6 +861,7 @@ module Pgbus
|
|
|
846
861
|
end
|
|
847
862
|
|
|
848
863
|
validate_read_ahead!
|
|
864
|
+
validate_capsule_processes!
|
|
849
865
|
|
|
850
866
|
if priority_levels && !(priority_levels.is_a?(Integer) && priority_levels >= 1 && priority_levels <= 10)
|
|
851
867
|
raise Pgbus::ConfigurationError, "priority_levels must be an integer between 1 and 10"
|
|
@@ -879,6 +895,27 @@ module Pgbus
|
|
|
879
895
|
warn_read_ahead_without_heartbeat(settings)
|
|
880
896
|
end
|
|
881
897
|
|
|
898
|
+
# Per-capsule processes (a positive Integer) and recycle limits (a positive
|
|
899
|
+
# number; unset falls back to the global value). The Array form of
|
|
900
|
+
# +workers=+ bypasses +capsule+, so both are checked here (issue #503).
|
|
901
|
+
def validate_capsule_processes!
|
|
902
|
+
Array(workers).each do |w|
|
|
903
|
+
validate_processes!(w[:processes]) unless w[:processes].nil?
|
|
904
|
+
RECYCLE_LIMIT_KEYS.each do |key|
|
|
905
|
+
value = w[key]
|
|
906
|
+
next if value.nil? || (value.is_a?(Numeric) && value.positive?)
|
|
907
|
+
|
|
908
|
+
raise Pgbus::ConfigurationError, "capsule #{key} must be a positive number (got #{value.inspect})"
|
|
909
|
+
end
|
|
910
|
+
end
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
def validate_processes!(value)
|
|
914
|
+
return if value.is_a?(Integer) && value.positive?
|
|
915
|
+
|
|
916
|
+
raise Pgbus::ConfigurationError, "capsule processes must be a positive Integer (got #{value.inspect})"
|
|
917
|
+
end
|
|
918
|
+
|
|
882
919
|
# Buffered claims are kept invisible only by the visibility heartbeat.
|
|
883
920
|
# Without it a message buffered longer than visibility_timeout can be
|
|
884
921
|
# claimed by another worker while it still waits here. Legal, but warned.
|
|
@@ -1209,15 +1246,23 @@ module Pgbus
|
|
|
1209
1246
|
#
|
|
1210
1247
|
# c.capsule :critical, queues: %w[critical], threads: 5
|
|
1211
1248
|
# c.capsule :gated, queues: %w[gated], threads: 1, single_active_consumer: true
|
|
1249
|
+
# c.capsule :render, queues: %w[render], threads: 1, processes: 4,
|
|
1250
|
+
# max_memory_mb: 1_536, max_worker_lifetime: 6.hours
|
|
1251
|
+
#
|
|
1252
|
+
# processes: N forks N identical workers for the capsule (CPU-bound jobs:
|
|
1253
|
+
# threads share one GVL, processes do not). The capsule-level recycle
|
|
1254
|
+
# limits override the global ones for this capsule's workers only.
|
|
1212
1255
|
#
|
|
1213
1256
|
# Names must be unique. Queues must not overlap with capsules already
|
|
1214
1257
|
# defined (would cause double-processing). Composes with the string DSL —
|
|
1215
1258
|
# +c.workers "..."+ followed by +c.capsule :name, ...+ appends the
|
|
1216
1259
|
# named capsule to the list parsed from the string.
|
|
1217
|
-
def capsule(name, queues:, threads:, **)
|
|
1260
|
+
def capsule(name, queues:, threads:, **options)
|
|
1218
1261
|
raise Pgbus::ConfigurationError, "capsule queues must be a non-empty Array" unless queues.is_a?(Array) && queues.any?
|
|
1219
1262
|
raise Pgbus::ConfigurationError, "capsule threads must be a positive Integer" unless threads.is_a?(Integer) && threads.positive?
|
|
1220
1263
|
|
|
1264
|
+
validate_processes!(options[:processes]) unless options[:processes].nil?
|
|
1265
|
+
|
|
1221
1266
|
normalized_name = name.to_s
|
|
1222
1267
|
@workers ||= []
|
|
1223
1268
|
|
|
@@ -1227,7 +1272,7 @@ module Pgbus
|
|
|
1227
1272
|
|
|
1228
1273
|
validate_no_queue_overlap!(queues)
|
|
1229
1274
|
|
|
1230
|
-
@workers << { name: normalized_name, queues: queues, threads: threads, ** }
|
|
1275
|
+
@workers << { name: normalized_name, queues: queues, threads: threads, **options }
|
|
1231
1276
|
end
|
|
1232
1277
|
|
|
1233
1278
|
# 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
|
data/lib/pgbus/doctor.rb
CHANGED
|
@@ -402,23 +402,27 @@ module Pgbus
|
|
|
402
402
|
# per worker/consumer fork. Streams add one per web-server process, which
|
|
403
403
|
# the doctor cannot count from here, so it is reported as a clause.
|
|
404
404
|
def check_connection_budget
|
|
405
|
-
|
|
405
|
+
worker_configs = @config.role_enabled?(:workers) ? Array(@config.workers) : []
|
|
406
|
+
capsules = worker_configs.size
|
|
407
|
+
# A processes: N capsule forks N workers, each with its own LISTEN under :fork scope.
|
|
408
|
+
worker_forks = worker_configs.sum { |w| @config.processes_for(w) }
|
|
406
409
|
consumers = @config.role_enabled?(:consumers) ? Array(@config.event_consumers).size : 0
|
|
407
410
|
|
|
408
411
|
count =
|
|
409
412
|
if !@config.worker_notify_wakeup?
|
|
410
413
|
0
|
|
411
414
|
elsif @config.worker_notify_scope == :supervisor
|
|
412
|
-
(
|
|
415
|
+
(worker_forks + consumers).positive? ? 1 : 0
|
|
413
416
|
else
|
|
414
|
-
|
|
417
|
+
worker_forks + consumers
|
|
415
418
|
end
|
|
416
419
|
|
|
417
420
|
detail = format(
|
|
418
421
|
"%<count>d direct LISTEN connection%<plural>s pinned (scope=%<scope>s; " \
|
|
419
|
-
"%<capsules>d capsule%<cap_plural>s + %<consumers>d consumer%<con_plural>s%<share>s)",
|
|
422
|
+
"%<capsules>d capsule%<cap_plural>s%<forks>s + %<consumers>d consumer%<con_plural>s%<share>s)",
|
|
420
423
|
count: count, plural: count == 1 ? "" : "s", scope: @config.worker_notify_scope,
|
|
421
424
|
capsules: capsules, cap_plural: capsules == 1 ? "" : "s",
|
|
425
|
+
forks: worker_forks == capsules ? "" : " (#{worker_forks} worker processes)",
|
|
422
426
|
consumers: consumers, con_plural: consumers == 1 ? "" : "s",
|
|
423
427
|
share: count == 1 && @config.worker_notify_scope == :supervisor ? " share it" : ""
|
|
424
428
|
)
|
|
@@ -15,6 +15,23 @@ module Pgbus
|
|
|
15
15
|
def dedup_cache
|
|
16
16
|
@dedup_cache ||= DedupCache.new
|
|
17
17
|
end
|
|
18
|
+
|
|
19
|
+
# How long a pending claim may stay silent before its holder counts as
|
|
20
|
+
# dead. ClaimBeat refreshes a live claim from the visibility heartbeat,
|
|
21
|
+
# which lands every extension inside [interval, 1.5 * interval] of the
|
|
22
|
+
# previous one — two intervals leaves margin for a late beat without
|
|
23
|
+
# stretching the window past the visibility timeout it rides on.
|
|
24
|
+
#
|
|
25
|
+
# With the heartbeat disabled a claim is never refreshed, so the window
|
|
26
|
+
# degrades to "roughly the first two thirds of one visibility timeout
|
|
27
|
+
# after the claim" — a redelivery, which cannot arrive before the VT has
|
|
28
|
+
# lapsed, still re-runs exactly as it did before issue #470.
|
|
29
|
+
#
|
|
30
|
+
# The dashboard reads the same window to tell a claim being handled
|
|
31
|
+
# from one that went silent (issue #494).
|
|
32
|
+
def claim_ownership_window
|
|
33
|
+
Pgbus.configuration.effective_visibility_heartbeat_interval * 2
|
|
34
|
+
end
|
|
18
35
|
end
|
|
19
36
|
|
|
20
37
|
# Outcome of the two-phase claim. `age` is how long the losing delivery
|
|
@@ -196,18 +213,8 @@ module Pgbus
|
|
|
196
213
|
ClaimResult.new(status: :claimed, age: age)
|
|
197
214
|
end
|
|
198
215
|
|
|
199
|
-
# How long a pending claim may stay silent before its holder counts as
|
|
200
|
-
# dead. ClaimBeat refreshes a live claim from the visibility heartbeat,
|
|
201
|
-
# which lands every extension inside [interval, 1.5 * interval] of the
|
|
202
|
-
# previous one — two intervals leaves margin for a late beat without
|
|
203
|
-
# stretching the window past the visibility timeout it rides on.
|
|
204
|
-
#
|
|
205
|
-
# With the heartbeat disabled a claim is never refreshed, so the window
|
|
206
|
-
# degrades to "roughly the first two thirds of one visibility timeout
|
|
207
|
-
# after the claim" — a redelivery, which cannot arrive before the VT has
|
|
208
|
-
# lapsed, still re-runs exactly as it did before issue #470.
|
|
209
216
|
def claim_ownership_window
|
|
210
|
-
|
|
217
|
+
self.class.claim_ownership_window
|
|
211
218
|
end
|
|
212
219
|
|
|
213
220
|
# Register this claim with the message's beat for exactly the duration of
|
|
@@ -125,6 +125,12 @@ module Pgbus
|
|
|
125
125
|
subscription_pattern.start_with?(topic_filter.delete_suffix(".#"))
|
|
126
126
|
end
|
|
127
127
|
|
|
128
|
+
# Whether +routing_key+ matches a subscription +pattern+ ("*" one word,
|
|
129
|
+
# "#" any). The dashboard checks a replay with it (issue #494).
|
|
130
|
+
def pattern_matches?(pattern, routing_key)
|
|
131
|
+
matches?(pattern, routing_key)
|
|
132
|
+
end
|
|
133
|
+
|
|
128
134
|
private
|
|
129
135
|
|
|
130
136
|
# True when running inside a rake schema/asset task, where setup_all!(safe:)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
3
5
|
module Pgbus
|
|
4
6
|
# Records job failures to pgbus_failed_events for dashboard visibility.
|
|
5
7
|
# Uses upsert (INSERT ON CONFLICT UPDATE) keyed on (queue_name, msg_id)
|
|
@@ -47,6 +49,31 @@ module Pgbus
|
|
|
47
49
|
false
|
|
48
50
|
end
|
|
49
51
|
|
|
52
|
+
# The message's latest recorded failure, read by a mover just before it
|
|
53
|
+
# dead-letters the message and clears the row (issue #495). failed_at
|
|
54
|
+
# comes back as an ISO 8601 UTC string whatever the adapter decodes it
|
|
55
|
+
# to. A failed read returns nil: it must never stop a dead-letter.
|
|
56
|
+
def last_error(queue_name:, msg_id:)
|
|
57
|
+
row = connection.select_one(
|
|
58
|
+
"SELECT error_class, error_message, backtrace, retry_count, failed_at " \
|
|
59
|
+
"FROM pgbus_failed_events WHERE queue_name = $1 AND msg_id = $2 LIMIT 1",
|
|
60
|
+
"FailedEvent Last Error",
|
|
61
|
+
[queue_name, msg_id.to_i]
|
|
62
|
+
)
|
|
63
|
+
return unless row
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
error_class: row["error_class"],
|
|
67
|
+
error_message: row["error_message"],
|
|
68
|
+
backtrace: row["backtrace"],
|
|
69
|
+
retry_count: row["retry_count"].to_i,
|
|
70
|
+
failed_at: iso_utc(row["failed_at"])
|
|
71
|
+
}
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
Pgbus.logger.debug { "[Pgbus] FailedEvent last_error read failed: #{e.class}: #{e.message}" }
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
50
77
|
# Drops every row recorded under any of `queue_names`. Called when a
|
|
51
78
|
# queue is dropped: PGMQ restarts msg_ids on recreate, so a surviving
|
|
52
79
|
# row would aim the dashboard's retry/discard at an unrelated message.
|
|
@@ -82,6 +109,13 @@ module Pgbus
|
|
|
82
109
|
|
|
83
110
|
private
|
|
84
111
|
|
|
112
|
+
def iso_utc(value)
|
|
113
|
+
time = value.is_a?(String) ? Time.parse(value) : value
|
|
114
|
+
time&.utc&.iso8601(6)
|
|
115
|
+
rescue ArgumentError
|
|
116
|
+
value.to_s
|
|
117
|
+
end
|
|
118
|
+
|
|
85
119
|
def connection
|
|
86
120
|
if defined?(BusRecord) && BusRecord.connected?
|
|
87
121
|
BusRecord.connection
|
|
@@ -15,7 +15,8 @@ module Pgbus
|
|
|
15
15
|
# pgbus.executor.execute — full job execution (deserialize + perform + archive)
|
|
16
16
|
# pgbus.job_completed — job archived successfully
|
|
17
17
|
# pgbus.job_failed — job raised; carries :exception_object
|
|
18
|
-
# pgbus.job_dead_lettered — job exceeded max_retries and was DLQ-routed
|
|
18
|
+
# pgbus.job_dead_lettered — job exceeded max_retries and was DLQ-routed;
|
|
19
|
+
# :error is the last recorded error class (or nil)
|
|
19
20
|
# pgbus.job_visibility_extended — heartbeat re-armed a running job's visibility timeout
|
|
20
21
|
# payload: queue, job_class, msg_id, vt, extensions
|
|
21
22
|
# pgbus.event_processed — event handler succeeded
|
data/lib/pgbus/mcp/base_tool.rb
CHANGED
|
@@ -70,6 +70,31 @@ module Pgbus
|
|
|
70
70
|
::MCP::Tool::Response.new([{ type: "text", text: JSON.generate(redacted) }])
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# Past the count cap the total is a lower bound, so only a full page
|
|
74
|
+
# can have another one behind it. At the page ceiling the next call
|
|
75
|
+
# would clamp back to this page, so there is no "more" to fetch.
|
|
76
|
+
def more_pages?(count, page:, per_page:, shown:)
|
|
77
|
+
return false if page >= self::MAX_PAGE
|
|
78
|
+
|
|
79
|
+
count.capped? ? shown == per_page : page * per_page < count.total
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
DEAD_LETTER_SUMMARY_KEYS = %w[reason source source_queue attempts max_retries dead_lettered_at
|
|
83
|
+
retries_from_dlq error_class error_attempt error_recorded_at].freeze
|
|
84
|
+
DEAD_LETTER_ERROR_KEYS = %w[error_message backtrace].freeze
|
|
85
|
+
|
|
86
|
+
# Why a DLQ row died, from its DeadLetterHeader block (issue #495), or
|
|
87
|
+
# nil for a row dead-lettered before the block existed. The error
|
|
88
|
+
# message and backtrace can quote payload values, and the Redactor
|
|
89
|
+
# does not know those keys, so they are added only with include_error.
|
|
90
|
+
def dead_letter_summary(row, include_error:)
|
|
91
|
+
block = Pgbus::DeadLetterHeader.parse(row[:headers])
|
|
92
|
+
return unless block
|
|
93
|
+
|
|
94
|
+
keys = include_error ? DEAD_LETTER_SUMMARY_KEYS + DEAD_LETTER_ERROR_KEYS : DEAD_LETTER_SUMMARY_KEYS
|
|
95
|
+
block.slice(*keys)
|
|
96
|
+
end
|
|
97
|
+
|
|
73
98
|
# Wrap an error message as an MCP error response (isError: true) so the
|
|
74
99
|
# client surfaces it as a tool failure rather than a normal result.
|
|
75
100
|
def error_response(message)
|
|
@@ -17,14 +17,33 @@ module Pgbus
|
|
|
17
17
|
their limit — plus up to 100 key rows with value/limit, lease expiry,
|
|
18
18
|
whether the lease is still fresh, parked count and oldest wait. A key
|
|
19
19
|
with a stale lease and a parked backlog is a stuck pipeline: its holder
|
|
20
|
-
died before releasing the slot.
|
|
20
|
+
died before releasing the slot. The key rows are paginated (default
|
|
21
|
+
100 per page, capped at 100); the response also carries page,
|
|
22
|
+
per_page, total (number of keys, capped at 10000) and has_more.
|
|
21
23
|
DESC
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
MAX_PER_PAGE = 100
|
|
26
|
+
MAX_PAGE = 1_000
|
|
24
27
|
|
|
25
|
-
|
|
28
|
+
input_schema(
|
|
29
|
+
properties: {
|
|
30
|
+
page: { type: "integer", description: "1-based page number (default 1, max 1000).", minimum: 1 },
|
|
31
|
+
per_page: { type: "integer", description: "Rows per page (default 100, max 100).", minimum: 1 }
|
|
32
|
+
},
|
|
33
|
+
required: []
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def self.call(page: 1, per_page: MAX_PER_PAGE, server_context: nil)
|
|
26
37
|
data_source = data_source_from(server_context)
|
|
27
|
-
|
|
38
|
+
per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
|
|
39
|
+
page = page.to_i.clamp(1, MAX_PAGE)
|
|
40
|
+
stats = data_source.concurrency_stats(page: page, per_page: per_page)
|
|
41
|
+
count = data_source.list_count(:concurrency_keys)
|
|
42
|
+
|
|
43
|
+
json_response(
|
|
44
|
+
stats.merge(page: page, per_page: per_page, total: count.total,
|
|
45
|
+
has_more: more_pages?(count, page: page, per_page: per_page, shown: stats[:keys].size))
|
|
46
|
+
)
|
|
28
47
|
end
|
|
29
48
|
end
|
|
30
49
|
end
|
|
@@ -12,7 +12,7 @@ module Pgbus
|
|
|
12
12
|
title "Pgbus Dead-Letter Detail"
|
|
13
13
|
description <<~DESC
|
|
14
14
|
Inspect one dead-letter message: read_ct, vt, enqueued_at, source
|
|
15
|
-
queue. Pass the full physical DLQ queue name (e.g.
|
|
15
|
+
queue, and a dead_letter summary of why it died. Pass the full physical DLQ queue name (e.g.
|
|
16
16
|
"pgbus_default_dlq") in +queue+ to disambiguate — msg_ids are only
|
|
17
17
|
unique within a single DLQ table, so the same id can exist in more
|
|
18
18
|
than one. Without +queue+ the tool scans every "_dlq" table and
|
|
@@ -48,7 +48,9 @@ module Pgbus
|
|
|
48
48
|
return error_response("Dead-letter message #{msg_id} not found") unless detail
|
|
49
49
|
return detail if detail.is_a?(::MCP::Tool::Response)
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
summary = dead_letter_summary(detail, include_error: payloads_allowed?(server_context, include_payloads))
|
|
52
|
+
json_response({ message: detail.merge(dead_letter: summary) },
|
|
53
|
+
server_context: server_context, include_payloads: include_payloads)
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
# When the caller didn't specify a queue, scan every DLQ for the id and
|
|
@@ -10,9 +10,12 @@ module Pgbus
|
|
|
10
10
|
title "Pgbus Dead-Letter Queue"
|
|
11
11
|
description <<~DESC
|
|
12
12
|
List messages sitting in dead-letter queues (queues whose name ends
|
|
13
|
-
in "_dlq") with read_ct, vt and enqueued_at
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
in "_dlq") with read_ct, vt and enqueued_at, and a dead_letter
|
|
14
|
+
summary of why each died (reason, source queue, attempts, error
|
|
15
|
+
class; null for messages dead-lettered before pgbus recorded it).
|
|
16
|
+
Paginated (default 25, max 100). Message bodies, headers and the
|
|
17
|
+
error message/backtrace are redacted unless the server allows
|
|
18
|
+
payloads and include_payloads is set.
|
|
16
19
|
DESC
|
|
17
20
|
|
|
18
21
|
MAX_PER_PAGE = 100
|
|
@@ -34,7 +37,10 @@ module Pgbus
|
|
|
34
37
|
data_source = data_source_from(server_context)
|
|
35
38
|
per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
|
|
36
39
|
page = page.to_i.clamp(1, MAX_PAGE)
|
|
37
|
-
|
|
40
|
+
include_error = payloads_allowed?(server_context, include_payloads)
|
|
41
|
+
rows = data_source.dlq_messages(page: page, per_page: per_page).map do |row|
|
|
42
|
+
row.merge(dead_letter: dead_letter_summary(row, include_error: include_error))
|
|
43
|
+
end
|
|
38
44
|
|
|
39
45
|
json_response(
|
|
40
46
|
{ page: page, per_page: per_page, messages: rows },
|