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
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Web
|
|
5
|
+
class DataSource
|
|
6
|
+
# The dead-letter queue pages (issue #495): list, count, detail, and the
|
|
7
|
+
# retry/discard actions. Moved out of data_source.rb unchanged.
|
|
8
|
+
module DeadLetter
|
|
9
|
+
# The header path the error-class filter reads (DeadLetterHeader::KEY).
|
|
10
|
+
ERROR_CLASS_SQL = "headers #>> '{#{Pgbus::DeadLetterHeader::KEY},error_class}'".freeze
|
|
11
|
+
|
|
12
|
+
# DLQ queue names from queues_with_metrics are already fully qualified
|
|
13
|
+
# (e.g., "pgbus_default_dlq"), so we use them directly without re-prefixing.
|
|
14
|
+
# dlq: one DLQ by its full name (unknown names list nothing);
|
|
15
|
+
# error_class: only messages whose dead-letter block names that class.
|
|
16
|
+
def dlq_messages(page: 1, per_page: 25, dlq: nil, error_class: nil)
|
|
17
|
+
offset = (page - 1) * per_page
|
|
18
|
+
names = dlq_queue_names(dlq)
|
|
19
|
+
return [] if names.empty?
|
|
20
|
+
return paginated_queue_messages(names, per_page, offset) unless error_class
|
|
21
|
+
|
|
22
|
+
paginated_queue_messages(names, per_page, offset, where: "#{ERROR_CLASS_SQL} = $3", binds: [error_class])
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error fetching DLQ messages: #{e.message}" }
|
|
25
|
+
[]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def dlq_total_count(dlq: nil, error_class: nil)
|
|
29
|
+
names = dlq_queue_names(dlq)
|
|
30
|
+
return 0 if names.empty?
|
|
31
|
+
return dlq_counts_by_queue.values_at(*names).sum unless error_class
|
|
32
|
+
|
|
33
|
+
connection.select_value(
|
|
34
|
+
"SELECT COUNT(*) FROM (#{queue_message_fragments(names, "#{ERROR_CLASS_SQL} = $1")}) AS combined",
|
|
35
|
+
"Pgbus DLQ Count",
|
|
36
|
+
[error_class]
|
|
37
|
+
).to_i
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error fetching DLQ count: #{e.message}" }
|
|
40
|
+
0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# { "pgbus_default_dlq" => 12, ... } for the filter chips, from the
|
|
44
|
+
# cached metrics (no query of its own).
|
|
45
|
+
def dlq_counts_by_queue
|
|
46
|
+
queues_with_metrics
|
|
47
|
+
.select { |q| q[:name].end_with?(Pgbus::DEAD_LETTER_SUFFIX) }
|
|
48
|
+
.to_h { |q| [q[:name], q[:queue_length].to_i] }
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error fetching DLQ counts: #{e.message}" }
|
|
51
|
+
{}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# msg_ids are unique per DLQ table only: queue_name: picks the table;
|
|
55
|
+
# without it the first DLQ holding the id wins.
|
|
56
|
+
def dlq_message_detail(msg_id, queue_name: nil)
|
|
57
|
+
dlq_queue_names(queue_name).each do |name|
|
|
58
|
+
row = connection.select_one(
|
|
59
|
+
"SELECT * FROM pgmq.q_#{sanitize_name(name)} WHERE msg_id = $1",
|
|
60
|
+
"Pgbus DLQ Detail",
|
|
61
|
+
[msg_id.to_i]
|
|
62
|
+
)
|
|
63
|
+
return format_message(row, name) if row
|
|
64
|
+
end
|
|
65
|
+
nil
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error fetching DLQ message #{msg_id}: #{e.message}" }
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def retry_dlq_message(queue_name, msg_id)
|
|
72
|
+
# queue_name here is the full DLQ name (already prefixed)
|
|
73
|
+
dlq_suffix = Pgbus::DEAD_LETTER_SUFFIX
|
|
74
|
+
original_queue = queue_name.delete_suffix(dlq_suffix)
|
|
75
|
+
|
|
76
|
+
row = connection.select_one(
|
|
77
|
+
"SELECT * FROM pgmq.q_#{sanitize_name(queue_name)} WHERE msg_id = $1",
|
|
78
|
+
"Pgbus DLQ Read",
|
|
79
|
+
[msg_id.to_i]
|
|
80
|
+
)
|
|
81
|
+
return false unless row
|
|
82
|
+
|
|
83
|
+
@client.transaction do |txn|
|
|
84
|
+
# A live message must not claim to be dead: strip the block, count the trip.
|
|
85
|
+
txn.produce(original_queue, row["message"], headers: DeadLetterHeader.strip_for_retry(row["headers"]))
|
|
86
|
+
txn.delete(queue_name, msg_id.to_i)
|
|
87
|
+
end
|
|
88
|
+
true
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error retrying DLQ message #{msg_id}: #{e.message}" }
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def discard_dlq_message(queue_name, msg_id)
|
|
95
|
+
# queue_name here is the full DLQ name (already prefixed)
|
|
96
|
+
release_lock_for_message(queue_name, msg_id)
|
|
97
|
+
@client.delete_message(queue_name, msg_id.to_i, prefixed: false)
|
|
98
|
+
true
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error discarding DLQ message #{msg_id}: #{e.message}" }
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def retry_all_dlq
|
|
105
|
+
messages = dlq_messages(page: 1, per_page: 1000)
|
|
106
|
+
count = 0
|
|
107
|
+
messages.each do |m|
|
|
108
|
+
retry_dlq_message(m[:queue_name], m[:msg_id]) && count += 1
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error retrying DLQ message #{m[:msg_id]}: #{e.message}" }
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
count
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def discard_all_dlq
|
|
117
|
+
messages = dlq_messages(page: 1, per_page: 1000)
|
|
118
|
+
return 0 if messages.empty?
|
|
119
|
+
|
|
120
|
+
release_locks_for_messages(messages)
|
|
121
|
+
|
|
122
|
+
# Group by queue for batch delete — one call per DLQ instead of N calls
|
|
123
|
+
messages.group_by { |m| m[:queue_name] }.sum do |queue_name, msgs|
|
|
124
|
+
ids = msgs.map { |m| m[:msg_id].to_i }
|
|
125
|
+
@client.delete_batch(queue_name, ids, prefixed: false).size
|
|
126
|
+
rescue StandardError => e
|
|
127
|
+
Pgbus.logger.debug { "[Pgbus::Web] Error batch-discarding DLQ messages from #{queue_name}: #{e.message}" }
|
|
128
|
+
0
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# Only names that are real DLQs ever reach sanitize_name and SQL.
|
|
135
|
+
def dlq_queue_names(dlq)
|
|
136
|
+
names = dlq_counts_by_queue.keys
|
|
137
|
+
dlq.nil? ? names : names & [dlq]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Pgbus
|
|
6
|
+
module Web
|
|
7
|
+
class DataSource
|
|
8
|
+
# The Events page (issue #494): the unified job list scoped to the
|
|
9
|
+
# EventBus handler queues, the words EventState needs about consumers,
|
|
10
|
+
# and Replay of a processed event from its archived message.
|
|
11
|
+
#
|
|
12
|
+
# Handler names and patterns come from the subscriber registry, in Ruby:
|
|
13
|
+
# rows match on the physical queue (queue rows) or the logical one
|
|
14
|
+
# (orphaned failed rows, which carry the name the consumer recorded).
|
|
15
|
+
module EventList
|
|
16
|
+
# Lower bound on archived_at below the oldest claim on a page: the
|
|
17
|
+
# claim always precedes the archive, the margin absorbs clock skew.
|
|
18
|
+
ARCHIVE_PROBE_MARGIN = 60
|
|
19
|
+
|
|
20
|
+
# An archived envelope's routing key, where Publisher and the outbox
|
|
21
|
+
# put it. Without one the consumer would archive a replay unrouted.
|
|
22
|
+
ROUTING_KEY_SQL = "COALESCE(a.message->'headers'->>'routing_key', a.message->>'routing_key')"
|
|
23
|
+
|
|
24
|
+
def event_rows(state: nil, queue_name: nil, page: 1, per_page: 25)
|
|
25
|
+
queues = handler_queue_physical_names
|
|
26
|
+
return [] if queues.empty?
|
|
27
|
+
|
|
28
|
+
rows = job_rows(state: state, queue_name: queue_name, queues: queues, page: page, per_page: per_page)
|
|
29
|
+
by_queue = subscribers_by_queue_name
|
|
30
|
+
rows.map do |row|
|
|
31
|
+
sub = by_queue[row[:queue_name]]
|
|
32
|
+
row.merge(handler_class: sub&.dig(:handler_class), pattern: sub&.dig(:pattern))
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def event_state_counts(queue_name: nil)
|
|
37
|
+
job_state_counts(queue_name: queue_name, queues: handler_queue_physical_names)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def events_ahead(rows) = jobs_ahead(rows)
|
|
41
|
+
|
|
42
|
+
def event_list_context(now: Time.now)
|
|
43
|
+
EventState::Context.new(jobs: job_list_context(now: now), covered_queues: covered_handler_queues,
|
|
44
|
+
claim_window: EventBus::Handler.claim_ownership_window)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# { processed_event id => :replayable | :not_archived | :no_handler }
|
|
48
|
+
# for one page of processed events: one bounded archive probe per
|
|
49
|
+
# handler queue, never one per row.
|
|
50
|
+
def event_replay_states(events)
|
|
51
|
+
subs = replay_subscribers
|
|
52
|
+
states = events.to_h { |e| [e["id"], subs[e["handler_class"]] ? :not_archived : :no_handler] }
|
|
53
|
+
|
|
54
|
+
events.select { |e| subs[e["handler_class"]] }.group_by { |e| subs[e["handler_class"]] }.each do |sub, rows|
|
|
55
|
+
keys = archived_routing_keys(sub[:physical_queue_name], rows)
|
|
56
|
+
rows.each { |e| states[e["id"]] = :replayable if replayable_key?(sub, keys[e["event_id"]]) }
|
|
57
|
+
end
|
|
58
|
+
states
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Re-deliver a processed event's archived envelope to its handler's
|
|
62
|
+
# queue, under a new event_id (so the idempotency claim is new) that
|
|
63
|
+
# links back through replayed_from. Only that handler runs again: a
|
|
64
|
+
# topic publish would re-run every subscriber of the routing key.
|
|
65
|
+
def replay_event(event)
|
|
66
|
+
sub = replay_subscribers[event["handler_class"]]
|
|
67
|
+
return false unless sub
|
|
68
|
+
|
|
69
|
+
archived = archived_event(sub[:physical_queue_name], event)
|
|
70
|
+
return false unless archived
|
|
71
|
+
|
|
72
|
+
message = JSON.parse(archived["message"])
|
|
73
|
+
return false unless replayable_key?(sub, event_routing_key(message))
|
|
74
|
+
|
|
75
|
+
replay = message.merge("event_id" => SecureRandom.uuid, "replayed_from" => event["event_id"])
|
|
76
|
+
@client.transaction do |txn|
|
|
77
|
+
txn.produce(sub[:physical_queue_name], JSON.generate(replay), headers: archived["headers"])
|
|
78
|
+
end
|
|
79
|
+
true
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
Pgbus.logger.error { "[Pgbus::Web] Error replaying event #{event["event_id"]}: #{e.class}: #{e.message}" }
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def subscribers_by_queue_name
|
|
88
|
+
registered_subscribers.each_with_object({}) do |s, out|
|
|
89
|
+
out[s[:physical_queue_name]] ||= s
|
|
90
|
+
out[s[:queue_name]] ||= s
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Physical handler queues some healthy consumer reads: its topics
|
|
95
|
+
# overlap the subscriber's pattern (Registry#queue_names_for_topics'
|
|
96
|
+
# rule). nil when a healthy consumer does not report its topics.
|
|
97
|
+
def covered_handler_queues
|
|
98
|
+
consumers = processes.select { |p| p[:kind].to_s == "consumer" && p[:healthy] }
|
|
99
|
+
topics = consumers.map { |p| p[:metadata].is_a?(Hash) ? p[:metadata]["topics"] : nil }
|
|
100
|
+
return nil if topics.any?(&:nil?)
|
|
101
|
+
|
|
102
|
+
registry = EventBus::Registry.instance
|
|
103
|
+
topics = topics.flatten
|
|
104
|
+
registered_subscribers.select { |s| topics.any? { |t| registry.pattern_overlaps?(t, s[:pattern]) } }
|
|
105
|
+
.to_set { |s| s[:physical_queue_name] }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# { event_id => routing key } for the page's events still in the archive.
|
|
109
|
+
def archived_routing_keys(queue, events)
|
|
110
|
+
ids = events.map { |e| text_array_element(e["event_id"]) }
|
|
111
|
+
rows = connection.select_all(<<~SQL, "Pgbus Archived Events", ["{#{ids.join(",")}}", archive_floor(events)])
|
|
112
|
+
SELECT a.message->>'event_id' AS event_id, #{ROUTING_KEY_SQL} AS routing_key
|
|
113
|
+
FROM pgmq.a_#{sanitize_name(queue)} a
|
|
114
|
+
WHERE a.archived_at >= $2::timestamptz AND a.message->>'event_id' = ANY($1::text[])
|
|
115
|
+
AND #{ROUTING_KEY_SQL} IS NOT NULL
|
|
116
|
+
SQL
|
|
117
|
+
rows.to_a.to_h { |r| [r["event_id"], r["routing_key"]] }
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
Pgbus.logger.error { "[Pgbus::Web] Error reading the archive of #{queue}: #{e.class}: #{e.message}" }
|
|
120
|
+
{}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The registration Replay uses per handler class: the first, so the
|
|
124
|
+
# availability check and the action never disagree.
|
|
125
|
+
def replay_subscribers
|
|
126
|
+
registered_subscribers.each_with_object({}) { |s, out| out[s[:handler_class]] ||= s }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A replay the consumer would archive unrouted is no replay: the key
|
|
130
|
+
# must still match the subscriber's pattern.
|
|
131
|
+
def replayable_key?(sub, routing_key)
|
|
132
|
+
!routing_key.nil? && EventBus::Registry.instance.pattern_matches?(sub[:pattern], routing_key)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# event_id is any string an envelope carried (Edit & Retry accepts any
|
|
136
|
+
# payload), so each element of the bound text[] literal is quoted.
|
|
137
|
+
def text_array_element(value)
|
|
138
|
+
%("#{value.to_s.gsub(/["\\]/) { |c| "\\#{c}" }}")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def archived_event(queue, event)
|
|
142
|
+
connection.select_one(<<~SQL, "Pgbus Archived Event", [event["event_id"].to_s, archive_floor([event])])
|
|
143
|
+
SELECT a.message::text AS message, a.headers::text AS headers FROM pgmq.a_#{sanitize_name(queue)} a
|
|
144
|
+
WHERE a.message->>'event_id' = $1 AND a.archived_at >= $2::timestamptz
|
|
145
|
+
ORDER BY a.archived_at DESC LIMIT 1
|
|
146
|
+
SQL
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def archive_floor(events)
|
|
150
|
+
oldest = events.filter_map { |e| JobState.time(e["processed_at"]) }.min || Time.at(0)
|
|
151
|
+
(oldest - ARCHIVE_PROBE_MARGIN).utc.iso8601(6)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def event_routing_key(message)
|
|
155
|
+
headers = message["headers"]
|
|
156
|
+
(headers["routing_key"] if headers.is_a?(Hash)).presence || message["routing_key"].presence
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgbus
|
|
4
|
+
module Web
|
|
5
|
+
class DataSource
|
|
6
|
+
# The Jobs page's unified list (issue #489): every job the dashboard can
|
|
7
|
+
# see, one row each, whatever table it lives in, with its state derived
|
|
8
|
+
# in SQL so tabs, counts and paging all cut on the same value.
|
|
9
|
+
#
|
|
10
|
+
# Sources, one UNION ALL fragment each:
|
|
11
|
+
# - every non-DLQ queue table, LEFT JOINed to its pgbus_failed_events row
|
|
12
|
+
# (failed_events.queue_name is the LOGICAL name; the physical one is
|
|
13
|
+
# matched too for rows older code wrote)
|
|
14
|
+
# - failed rows whose message is no longer in its queue ("orphans")
|
|
15
|
+
# - pgbus_blocked_executions (jobs parked by limits_concurrency)
|
|
16
|
+
#
|
|
17
|
+
# Queue names reach SQL only through sanitize_name; the state filter and
|
|
18
|
+
# paging are bound parameters. Each fragment carries its own ORDER BY +
|
|
19
|
+
# LIMIT so it stays index-backed and bounded; msg_ids from different
|
|
20
|
+
# queues are not comparable, so the outer sort is by time.
|
|
21
|
+
#
|
|
22
|
+
# queues: cuts the list to those physical queues (the Events page passes
|
|
23
|
+
# the EventBus handler queues); exclude: leaves physical queues out (the
|
|
24
|
+
# Jobs page leaves the handler queues to the Events page). Both are
|
|
25
|
+
# optional, and without them the SQL is unchanged (issue #494).
|
|
26
|
+
module JobList
|
|
27
|
+
# A failure recorded after the read that ran the attempt means the job
|
|
28
|
+
# is backing off; a later read (last_read_at past failed_at) means the
|
|
29
|
+
# retry attempt is running now.
|
|
30
|
+
STATE_CASE = <<~SQL.gsub(/\s+/, " ").strip.freeze
|
|
31
|
+
CASE
|
|
32
|
+
WHEN m.read_ct = 0 AND m.vt > now() THEN 'scheduled'
|
|
33
|
+
WHEN m.read_ct = 0 THEN 'ready'
|
|
34
|
+
WHEN f.failed_at IS NOT NULL AND f.failed_at >= m.last_read_at THEN 'retrying'
|
|
35
|
+
WHEN m.vt > now() THEN 'running'
|
|
36
|
+
ELSE 'ready'
|
|
37
|
+
END
|
|
38
|
+
SQL
|
|
39
|
+
|
|
40
|
+
# Per-fragment ceiling for the tab counts, so a million-row queue never
|
|
41
|
+
# forces a full scan on every auto-refresh. A fragment that reaches it
|
|
42
|
+
# makes its states' counts lower bounds.
|
|
43
|
+
COUNT_CAP = 10_000
|
|
44
|
+
|
|
45
|
+
QUEUE_STATES = %w[ready scheduled running retrying].freeze
|
|
46
|
+
|
|
47
|
+
# A predicate on m that each state branch of STATE_CASE already implies.
|
|
48
|
+
# Without it a filtered tab walks the whole pkey and probes the LATERAL for
|
|
49
|
+
# every row before LIMIT bites (300k-row queue, no matches: ~350 ms); with it
|
|
50
|
+
# only candidates reach the CASE. The exact `q.state = $1` still applies.
|
|
51
|
+
STATE_PREFILTER = {
|
|
52
|
+
"scheduled" => "m.read_ct = 0 AND m.vt > now()",
|
|
53
|
+
"ready" => "m.vt <= now()",
|
|
54
|
+
"running" => "m.read_ct > 0 AND m.vt > now()",
|
|
55
|
+
"retrying" => "m.read_ct > 0"
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
INTEGER_COLUMNS = %i[id msg_id read_ct failed_event_id slots_held slots_max].freeze
|
|
59
|
+
|
|
60
|
+
StateCounts = Data.define(:counts, :capped) do
|
|
61
|
+
def [](state) = counts.fetch(state.to_s, 0)
|
|
62
|
+
def capped?(state) = capped.include?(state.to_s)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def job_rows(state: nil, queue_name: nil, queues: nil, exclude: nil, page: 1, per_page: 25)
|
|
66
|
+
scope = job_list_scope(queue_name, queues: queues, exclude: exclude)
|
|
67
|
+
return [] unless scope
|
|
68
|
+
|
|
69
|
+
binds = []
|
|
70
|
+
state_param = nil
|
|
71
|
+
if state && state != "blocked" && scope[:queues].any?
|
|
72
|
+
binds << state
|
|
73
|
+
state_param = "$1"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
fetch = (page * per_page).to_i
|
|
77
|
+
fragments = job_row_fragments(scope, state, state_param, fetch)
|
|
78
|
+
return [] if fragments.empty?
|
|
79
|
+
|
|
80
|
+
sql = <<~SQL
|
|
81
|
+
SELECT * FROM (#{fragments.join("\nUNION ALL\n")}) AS jobs
|
|
82
|
+
ORDER BY sort_at DESC NULLS LAST, id DESC
|
|
83
|
+
LIMIT $#{binds.size + 1} OFFSET $#{binds.size + 2}
|
|
84
|
+
SQL
|
|
85
|
+
binds.push(per_page, (page - 1) * per_page)
|
|
86
|
+
|
|
87
|
+
connection.select_all(sql, "Pgbus Job List", binds).to_a.map { |row| format_job_row(row) }
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
Pgbus.logger.error { "[Pgbus::Web] Error reading the job list: #{e.class}: #{e.message}" }
|
|
90
|
+
[]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def job_state_counts(queue_name: nil, queues: nil, exclude: nil)
|
|
94
|
+
scope = job_list_scope(queue_name, queues: queues, exclude: exclude)
|
|
95
|
+
return empty_state_counts unless scope
|
|
96
|
+
|
|
97
|
+
kinds = []
|
|
98
|
+
fragments = []
|
|
99
|
+
count_fragments(scope).each do |kind, sql|
|
|
100
|
+
fragments << "(SELECT #{kinds.size} AS frag, state FROM (#{sql}) c LIMIT #{COUNT_CAP + 1})"
|
|
101
|
+
kinds << kind
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
rows = connection.select_all(<<~SQL, "Pgbus Job State Counts").to_a
|
|
105
|
+
SELECT frag, state, COUNT(*) AS n FROM (#{fragments.join("\nUNION ALL\n")}) AS counted
|
|
106
|
+
GROUP BY frag, state
|
|
107
|
+
SQL
|
|
108
|
+
tally_state_counts(rows, kinds)
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
Pgbus.logger.error { "[Pgbus::Web] Error counting job states: #{e.class}: #{e.message}" }
|
|
111
|
+
empty_state_counts
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# { [physical_queue, msg_id] => jobs_ahead } for the ready queue rows
|
|
115
|
+
# on one page: one bounded COUNT per distinct queue, never per row.
|
|
116
|
+
def jobs_ahead(rows)
|
|
117
|
+
ready = rows.select { |r| r[:source] == "queue" && r[:state] == "ready" }
|
|
118
|
+
ready.group_by { |r| r[:queue_name] }.each_with_object({}) do |(queue, queue_rows), out|
|
|
119
|
+
out.merge!(jobs_ahead_in(queue, queue_rows.map { |r| r[:msg_id].to_i }.sort))
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def job_list_context(now: Time.now)
|
|
124
|
+
alive = ->(kind) { processes.any? { |p| p[:kind].to_s == kind && p[:healthy] } }
|
|
125
|
+
JobState::Context.new(now: now, max_retries: Pgbus.configuration.max_retries,
|
|
126
|
+
paused: paused_queue_names.to_set, drained: drained_queue_names,
|
|
127
|
+
workers_alive: alive.call("worker"), handler_queues: handler_queue_physical_names.to_set,
|
|
128
|
+
consumers_alive: alive.call("consumer"))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
# The physical non-DLQ queues in view, grouped under their logical
|
|
134
|
+
# name, or nil when the requested queue (or every listed one) does not
|
|
135
|
+
# exist. Excluded queues stay known, so their failed rows never show up
|
|
136
|
+
# as orphans of an unknown queue.
|
|
137
|
+
def job_list_scope(queue_name, queues: nil, exclude: nil)
|
|
138
|
+
names = queues_with_metrics.map { |q| q[:name] }.reject { |n| n.end_with?(Pgbus::DEAD_LETTER_SUFFIX) }
|
|
139
|
+
names &= queues if queues
|
|
140
|
+
return nil if queues && names.empty?
|
|
141
|
+
|
|
142
|
+
excluded = exclude ? names & exclude : []
|
|
143
|
+
names -= excluded
|
|
144
|
+
if queue_name
|
|
145
|
+
return nil unless names.include?(queue_name)
|
|
146
|
+
|
|
147
|
+
names = [queue_name]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
pairs = ->(list) { list.map { |n| [sanitize_name(n), logical_queue_name(n)] } }
|
|
151
|
+
scoped = pairs.call(names)
|
|
152
|
+
{ queues: scoped, by_logical: scoped.group_by(&:last), excluded: pairs.call(excluded),
|
|
153
|
+
filtered: !queue_name.nil? || !queues.nil? }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def job_row_fragments(scope, state, state_param, fetch)
|
|
157
|
+
fragments = []
|
|
158
|
+
unless state == "blocked"
|
|
159
|
+
fragments.concat(scope[:queues].map { |(qtable, logical)| queue_row_fragment(qtable, logical, state, state_param, fetch) })
|
|
160
|
+
end
|
|
161
|
+
fragments.concat(orphan_fragments(scope, fetch)) if state.nil? || state == "retrying"
|
|
162
|
+
fragments << blocked_fragment(scope, fetch) if state.nil? || state == "blocked"
|
|
163
|
+
fragments
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def queue_row_fragment(qtable, logical, state, state_param, fetch)
|
|
167
|
+
where = state_param ? "WHERE q.state = #{state_param}" : ""
|
|
168
|
+
prefilter = state_param && STATE_PREFILTER[state] ? "WHERE #{STATE_PREFILTER[state]}" : ""
|
|
169
|
+
<<~SQL.strip
|
|
170
|
+
(SELECT * FROM (
|
|
171
|
+
SELECT 'queue'::text AS source, m.msg_id AS id, '#{qtable}'::text AS queue_name,
|
|
172
|
+
'#{logical}'::text AS logical_queue, m.msg_id, m.message->>'job_class' AS job_class,
|
|
173
|
+
m.read_ct, m.enqueued_at, m.last_read_at, m.vt, #{STATE_CASE} AS state,
|
|
174
|
+
f.error_class, f.error_message, f.id AS failed_event_id,
|
|
175
|
+
NULL::text AS concurrency_key, NULL::integer AS slots_held, NULL::integer AS slots_max,
|
|
176
|
+
m.message::text AS payload, m.headers::text AS headers, m.enqueued_at AS sort_at
|
|
177
|
+
FROM pgmq.q_#{qtable} m
|
|
178
|
+
#{failed_join(qtable, logical)}
|
|
179
|
+
#{prefilter}
|
|
180
|
+
) q #{where} ORDER BY q.msg_id DESC LIMIT #{fetch})
|
|
181
|
+
SQL
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def failed_join(qtable, logical)
|
|
185
|
+
<<~SQL.strip
|
|
186
|
+
LEFT JOIN LATERAL (
|
|
187
|
+
SELECT f.id, f.error_class::text AS error_class, f.error_message,
|
|
188
|
+
f.failed_at::timestamptz AS failed_at
|
|
189
|
+
FROM pgbus_failed_events f
|
|
190
|
+
WHERE f.queue_name IN ('#{logical}', '#{qtable}') AND f.msg_id = m.msg_id
|
|
191
|
+
ORDER BY f.failed_at DESC LIMIT 1
|
|
192
|
+
) f ON true
|
|
193
|
+
SQL
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Failed rows whose message left its queue: one fragment per logical
|
|
197
|
+
# queue, plus (unfiltered) one for rows whose queue no longer exists.
|
|
198
|
+
def orphan_fragments(scope, fetch)
|
|
199
|
+
fragments = scope[:by_logical].map do |logical, tables|
|
|
200
|
+
missing = tables.map { |(qtable, _)| "NOT EXISTS (SELECT 1 FROM pgmq.q_#{qtable} m WHERE m.msg_id = f.msg_id)" }
|
|
201
|
+
names = quoted_list([logical, *tables.map(&:first)])
|
|
202
|
+
orphan_fragment("'#{logical}'::text", "f.queue_name IN (#{names}) AND #{missing.join(" AND ")}", fetch)
|
|
203
|
+
end
|
|
204
|
+
return fragments if scope[:filtered]
|
|
205
|
+
|
|
206
|
+
known = scope[:by_logical].flat_map { |logical, tables| [logical, *tables.map(&:first)] }
|
|
207
|
+
known += scope[:excluded].flat_map { |(qtable, logical)| [logical, qtable] }
|
|
208
|
+
where = known.empty? ? "true" : "NOT (f.queue_name = ANY(ARRAY[#{quoted_list(known)}]))"
|
|
209
|
+
fragments << orphan_fragment("f.queue_name::text", where, fetch)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def orphan_fragment(logical_sql, where, fetch)
|
|
213
|
+
<<~SQL.strip
|
|
214
|
+
(SELECT 'failed'::text AS source, f.id, f.queue_name::text AS queue_name, #{logical_sql} AS logical_queue,
|
|
215
|
+
f.msg_id, f.payload->>'job_class' AS job_class, (f.retry_count + 1) AS read_ct,
|
|
216
|
+
NULL::timestamptz AS enqueued_at, NULL::timestamptz AS last_read_at, NULL::timestamptz AS vt,
|
|
217
|
+
'retrying'::text AS state, f.error_class::text AS error_class, f.error_message,
|
|
218
|
+
f.id AS failed_event_id, NULL::text AS concurrency_key, NULL::integer AS slots_held,
|
|
219
|
+
NULL::integer AS slots_max, f.payload::text AS payload, f.headers::text AS headers,
|
|
220
|
+
f.failed_at::timestamptz AS sort_at
|
|
221
|
+
FROM pgbus_failed_events f WHERE #{where} ORDER BY f.id DESC LIMIT #{fetch})
|
|
222
|
+
SQL
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def blocked_fragment(scope, fetch)
|
|
226
|
+
<<~SQL.strip
|
|
227
|
+
(SELECT 'blocked'::text AS source, b.id, b.queue_name::text AS queue_name,
|
|
228
|
+
b.queue_name::text AS logical_queue, NULL::bigint AS msg_id, b.payload->>'job_class' AS job_class,
|
|
229
|
+
NULL::integer AS read_ct, b.created_at::timestamptz AS enqueued_at,
|
|
230
|
+
NULL::timestamptz AS last_read_at, NULL::timestamptz AS vt, 'blocked'::text AS state,
|
|
231
|
+
NULL::text AS error_class, NULL::text AS error_message, NULL::bigint AS failed_event_id,
|
|
232
|
+
b.concurrency_key::text AS concurrency_key, s.value AS slots_held, s.max_value AS slots_max,
|
|
233
|
+
b.payload::text AS payload, NULL::text AS headers, b.created_at::timestamptz AS sort_at
|
|
234
|
+
FROM pgbus_blocked_executions b
|
|
235
|
+
LEFT JOIN pgbus_semaphores s ON s.key = b.concurrency_key
|
|
236
|
+
#{blocked_where(scope)} ORDER BY b.created_at DESC LIMIT #{fetch})
|
|
237
|
+
SQL
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def blocked_where(scope)
|
|
241
|
+
if scope[:filtered]
|
|
242
|
+
return "WHERE false" if scope[:queues].empty?
|
|
243
|
+
|
|
244
|
+
"WHERE b.queue_name IN (#{quoted_list(scope[:queues].flat_map { |(qtable, logical)| [logical, qtable] })})"
|
|
245
|
+
elsif scope[:excluded].any?
|
|
246
|
+
"WHERE b.queue_name NOT IN (#{quoted_list(scope[:excluded].flat_map { |(qtable, logical)| [logical, qtable] })})"
|
|
247
|
+
else
|
|
248
|
+
""
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# [kind, sql selecting a `state` column] per fragment, uncapped.
|
|
253
|
+
def count_fragments(scope)
|
|
254
|
+
queue = scope[:queues].map do |(qtable, logical)|
|
|
255
|
+
[:queue, "SELECT #{STATE_CASE} AS state FROM pgmq.q_#{qtable} m #{failed_join(qtable, logical)}"]
|
|
256
|
+
end
|
|
257
|
+
orphans = orphan_fragments(scope, COUNT_CAP + 1).map { |sql| [:orphan, "SELECT state FROM #{sql} o"] }
|
|
258
|
+
queue + orphans + [[:blocked, "SELECT state FROM #{blocked_fragment(scope, COUNT_CAP + 1)} b2"]]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def tally_state_counts(rows, kinds)
|
|
262
|
+
counts = Hash.new(0)
|
|
263
|
+
per_fragment = Hash.new(0)
|
|
264
|
+
rows.each do |row|
|
|
265
|
+
n = row["n"].to_i
|
|
266
|
+
counts[row["state"]] += n
|
|
267
|
+
per_fragment[row["frag"].to_i] += n
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
capped = Set.new
|
|
271
|
+
per_fragment.each do |frag, total|
|
|
272
|
+
next if total <= COUNT_CAP
|
|
273
|
+
|
|
274
|
+
capped.merge(capped_states_for(kinds[frag]))
|
|
275
|
+
end
|
|
276
|
+
counts["all"] = counts.values.sum
|
|
277
|
+
StateCounts.new(counts: counts.to_h, capped: capped)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def capped_states_for(kind)
|
|
281
|
+
states = { queue: QUEUE_STATES, orphan: %w[retrying], blocked: %w[blocked] }.fetch(kind)
|
|
282
|
+
[*states, "all"]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def empty_state_counts
|
|
286
|
+
StateCounts.new(counts: {}, capped: Set.new)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def jobs_ahead_in(queue, msg_ids)
|
|
290
|
+
@jobs_ahead_memo ||= {}
|
|
291
|
+
@jobs_ahead_memo[[queue, msg_ids]] ||= begin
|
|
292
|
+
rows = connection.select_all(<<~SQL, "Pgbus Jobs Ahead", ["{#{msg_ids.join(",")}}"])
|
|
293
|
+
SELECT x.msg_id, (
|
|
294
|
+
SELECT COUNT(*) FROM (
|
|
295
|
+
SELECT 1 FROM pgmq.q_#{sanitize_name(queue)} m
|
|
296
|
+
WHERE m.vt <= now() AND m.msg_id < x.msg_id LIMIT #{JobState::AHEAD_CAP}
|
|
297
|
+
) s
|
|
298
|
+
) AS ahead
|
|
299
|
+
FROM unnest($1::bigint[]) AS x(msg_id)
|
|
300
|
+
SQL
|
|
301
|
+
rows.to_a.to_h { |r| [[queue, r["msg_id"].to_i], r["ahead"].to_i] }
|
|
302
|
+
end
|
|
303
|
+
rescue StandardError => e
|
|
304
|
+
Pgbus.logger.error { "[Pgbus::Web] Error counting jobs ahead in #{queue}: #{e.class}: #{e.message}" }
|
|
305
|
+
{}
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Values are sanitized queue names (word characters only), so quoting
|
|
309
|
+
# them as literals is safe.
|
|
310
|
+
def quoted_list(names)
|
|
311
|
+
names.uniq.map { |n| "'#{sanitize_name(n)}'" }.join(", ")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def format_job_row(row)
|
|
315
|
+
row.to_h.each_with_object({}) do |(key, value), out|
|
|
316
|
+
sym = key.to_sym
|
|
317
|
+
out[sym] = INTEGER_COLUMNS.include?(sym) && !value.nil? ? value.to_i : value
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|