solid_observer 0.1.1 → 0.3.0
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 +63 -0
- data/README.md +157 -28
- data/app/assets/javascripts/solid_observer/live_poll.js +376 -0
- data/app/controllers/concerns/solid_observer/paginatable.rb +17 -0
- data/app/controllers/concerns/solid_observer/require_persistence_mode.rb +19 -0
- data/app/controllers/concerns/solid_observer/require_solid_queue.rb +19 -0
- data/app/controllers/solid_observer/application_controller.rb +69 -0
- data/app/controllers/solid_observer/dashboard_controller.rb +79 -0
- data/app/controllers/solid_observer/events_controller.rb +50 -0
- data/app/controllers/solid_observer/jobs_controller.rb +85 -0
- data/app/controllers/solid_observer/storages_controller.rb +12 -0
- data/app/helpers/solid_observer/application_helper.rb +95 -0
- data/app/helpers/solid_observer/dashboard_helper.rb +39 -0
- data/app/models/solid_observer/queue_event.rb +134 -0
- data/app/models/solid_observer/queue_metric.rb +1 -1
- data/app/presenters/solid_observer/execution_presenter.rb +50 -0
- data/app/views/layouts/solid_observer/application.html.erb +470 -0
- data/app/views/solid_observer/dashboard/_chart.html.erb +28 -0
- data/app/views/solid_observer/dashboard/_live_state.html.erb +20 -0
- data/app/views/solid_observer/dashboard/_queue_table.html.erb +34 -0
- data/app/views/solid_observer/dashboard/_right_now.html.erb +3 -0
- data/app/views/solid_observer/dashboard/_throughput.html.erb +32 -0
- data/app/views/solid_observer/dashboard/index.html.erb +113 -0
- data/app/views/solid_observer/errors/storage_unavailable.html.erb +27 -0
- data/app/views/solid_observer/events/index.html.erb +53 -0
- data/app/views/solid_observer/events/show.html.erb +47 -0
- data/app/views/solid_observer/jobs/index.html.erb +61 -0
- data/app/views/solid_observer/jobs/show.html.erb +71 -0
- data/app/views/solid_observer/shared/_empty_state.html.erb +5 -0
- data/app/views/solid_observer/shared/_pagination.html.erb +17 -0
- data/app/views/solid_observer/shared/_stat_card.html.erb +9 -0
- data/app/views/solid_observer/storages/show.html.erb +39 -0
- data/bin/quality_gate +95 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20260424000001_add_composite_indexes_to_queue_events.rb +30 -0
- data/lib/generators/solid_observer/install_generator.rb +12 -25
- data/lib/generators/solid_observer/templates/initializer.rb.tt +5 -6
- data/lib/solid_observer/base_metric.rb +1 -1
- data/lib/solid_observer/chart_buffer.rb +83 -0
- data/lib/solid_observer/cli/base.rb +2 -2
- data/lib/solid_observer/cli/jobs.rb +2 -2
- data/lib/solid_observer/cli/status.rb +20 -2
- data/lib/solid_observer/cli/storage.rb +41 -40
- data/lib/solid_observer/configuration.rb +47 -37
- data/lib/solid_observer/correlation_id_resolver.rb +8 -6
- data/lib/solid_observer/engine.rb +72 -17
- data/lib/solid_observer/params/events_filter.rb +37 -0
- data/lib/solid_observer/params/jobs_filter.rb +35 -0
- data/lib/solid_observer/queries/events_query.rb +27 -0
- data/lib/solid_observer/queries/execution_finder.rb +42 -0
- data/lib/solid_observer/queries/job_executions_query.rb +73 -0
- data/lib/solid_observer/queue_event_buffer.rb +163 -25
- data/lib/solid_observer/queue_stats.rb +165 -19
- data/lib/solid_observer/services/cleanup_storage.rb +58 -42
- data/lib/solid_observer/services/database_size.rb +86 -0
- data/lib/solid_observer/services/flush_event_buffer.rb +31 -15
- data/lib/solid_observer/services/install_migrations.rb +49 -0
- data/lib/solid_observer/services/record_event.rb +51 -14
- data/lib/solid_observer/services/ui_auth_check.rb +65 -0
- data/lib/solid_observer/subscriber.rb +15 -8
- data/lib/solid_observer/version.rb +1 -1
- data/lib/solid_observer.rb +7 -0
- data/lib/tasks/solid_observer.rake +10 -2
- metadata +55 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
class JobsController < ApplicationController
|
|
5
|
+
include Paginatable
|
|
6
|
+
include RequireSolidQueue
|
|
7
|
+
|
|
8
|
+
PER_PAGE = 25
|
|
9
|
+
CACHE_KEY_QUEUES = "solid_observer/jobs/available_queues"
|
|
10
|
+
CACHE_KEY_JOB_CLASSES = "solid_observer/jobs/available_job_classes"
|
|
11
|
+
|
|
12
|
+
def index
|
|
13
|
+
filter = Params::JobsFilter.from_params(params)
|
|
14
|
+
@status = filter.status
|
|
15
|
+
@queue_name = filter.queue_name
|
|
16
|
+
@job_class = filter.job_class
|
|
17
|
+
@page = filter.page
|
|
18
|
+
result = Queries::JobExecutionsQuery.new(filter).call
|
|
19
|
+
offset = paginate_scope(result, per_page: PER_PAGE)
|
|
20
|
+
@jobs = if result.is_a?(Array)
|
|
21
|
+
result.drop(offset).first(PER_PAGE)
|
|
22
|
+
else
|
|
23
|
+
result.limit(PER_PAGE).offset(offset).includes(:job).to_a
|
|
24
|
+
end
|
|
25
|
+
@available_queues = fetch_available_queues
|
|
26
|
+
@available_job_classes = fetch_available_job_classes
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def show
|
|
30
|
+
@execution = find_execution_for_show
|
|
31
|
+
return redirect_to jobs_path, alert: "Job not found" unless @execution
|
|
32
|
+
|
|
33
|
+
assign_show_presenter
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def retry
|
|
37
|
+
id = params[:id]
|
|
38
|
+
execution = Queries::ExecutionFinder.find_failed(id)
|
|
39
|
+
return redirect_to(jobs_path, alert: "Failed job not found") unless execution
|
|
40
|
+
|
|
41
|
+
execution.retry
|
|
42
|
+
redirect_to jobs_path(status: "failed"), notice: "Job #{id} queued for retry"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def discard
|
|
46
|
+
id = params[:id]
|
|
47
|
+
execution = Queries::ExecutionFinder.find_failed(id)
|
|
48
|
+
return redirect_to(jobs_path, alert: "Failed job not found") unless execution
|
|
49
|
+
|
|
50
|
+
execution.discard
|
|
51
|
+
redirect_to jobs_path(status: "failed"), notice: "Job #{id} discarded"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def find_execution_for_show
|
|
57
|
+
id = params[:id]
|
|
58
|
+
Queries::ExecutionFinder.find_by_status(id, params[:status]) ||
|
|
59
|
+
Queries::ExecutionFinder.find_any(id)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def assign_show_presenter
|
|
63
|
+
@presenter = ExecutionPresenter.new(@execution)
|
|
64
|
+
@job = @presenter.job
|
|
65
|
+
@status = @presenter.status
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def fetch_available_queues
|
|
69
|
+
Rails.cache.fetch(CACHE_KEY_QUEUES, expires_in: SolidObserver.config.filter_cache_ttl) do
|
|
70
|
+
next [] unless defined?(SolidQueue::Queue)
|
|
71
|
+
SolidQueue::Queue.all.map(&:name)
|
|
72
|
+
end
|
|
73
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
|
|
74
|
+
[]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def fetch_available_job_classes
|
|
78
|
+
Rails.cache.fetch(CACHE_KEY_JOB_CLASSES, expires_in: SolidObserver.config.filter_cache_ttl) do
|
|
79
|
+
SolidQueue::Job.distinct.pluck(:class_name).compact.sort
|
|
80
|
+
end
|
|
81
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
|
|
82
|
+
[]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
class StoragesController < ApplicationController
|
|
5
|
+
include RequirePersistenceMode
|
|
6
|
+
|
|
7
|
+
def show
|
|
8
|
+
@current_storage = SolidObserver::StorageInfo.order(recorded_at: :desc).first
|
|
9
|
+
@storage_history = SolidObserver::StorageInfo.recent(20)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
module ApplicationHelper
|
|
5
|
+
STATUS_COLORS = {
|
|
6
|
+
"completed" => "success",
|
|
7
|
+
"ready" => "success",
|
|
8
|
+
"failed" => "danger",
|
|
9
|
+
"retry_stopped" => "danger",
|
|
10
|
+
"scheduled" => "warning",
|
|
11
|
+
"claimed" => "warning",
|
|
12
|
+
"enqueued" => "info",
|
|
13
|
+
"discarded" => "info"
|
|
14
|
+
}.freeze
|
|
15
|
+
DURATION_SEMANTICS = {
|
|
16
|
+
"job_enqueued" => "Time spent in the ActiveJob enqueue call (Rails internal; typically sub-millisecond to single-digit ms)",
|
|
17
|
+
"job_completed" => "Time spent performing the job",
|
|
18
|
+
"job_failed" => "Time spent performing the job before the exception was raised",
|
|
19
|
+
"job_discarded" => "Time before discard decision was made"
|
|
20
|
+
}.freeze
|
|
21
|
+
STABILITY_STATES = {
|
|
22
|
+
stable: {label: "Stable", tone: "success"},
|
|
23
|
+
degraded: {label: "Degraded", tone: "warning"},
|
|
24
|
+
critical: {label: "Critical", tone: "danger"}
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def execution_status(execution)
|
|
28
|
+
ExecutionPresenter.new(execution).status
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def format_duration(seconds)
|
|
32
|
+
return "0ms" if seconds.to_f.zero?
|
|
33
|
+
|
|
34
|
+
if seconds < 1
|
|
35
|
+
"#{(seconds * 1000).round}ms"
|
|
36
|
+
else
|
|
37
|
+
"#{"%.1f" % seconds}s"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def status_badge(status)
|
|
42
|
+
status_str = status.to_s
|
|
43
|
+
color = STATUS_COLORS.fetch(status_str, "default")
|
|
44
|
+
content_tag(:span, status_str.humanize, class: "so-badge so-badge--#{color}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def duration_with_semantic(value, event_type)
|
|
48
|
+
return content_tag(:span, "—", class: "so-text-muted") unless value
|
|
49
|
+
|
|
50
|
+
content_tag(:abbr, format_duration(value), title: DURATION_SEMANTICS.fetch(event_type.to_s))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def mode_badge
|
|
54
|
+
config = SolidObserver.config
|
|
55
|
+
color = config.persistence_mode? ? "info" : "warning"
|
|
56
|
+
content_tag(:span, config.storage_mode.to_s.capitalize, class: "so-badge so-badge--#{color}")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def turbo_frame_tag(id, **options, &block)
|
|
60
|
+
return super if defined?(super)
|
|
61
|
+
|
|
62
|
+
content = options.delete(:content)
|
|
63
|
+
body = block_given? ? capture(&block) : content
|
|
64
|
+
|
|
65
|
+
content_tag(:"turbo-frame", body, **options.merge(id: id).compact)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def stability_state(stats)
|
|
69
|
+
return :critical if stats[:failed_last_hour].to_i.positive?
|
|
70
|
+
return :degraded if stats[:failed_last_24h].to_i.positive?
|
|
71
|
+
|
|
72
|
+
:stable
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def stability_badge(stats)
|
|
76
|
+
meta = STABILITY_STATES.fetch(stability_state(stats))
|
|
77
|
+
dot = tag.svg(tag.circle(r: 3, cx: 3, cy: 3),
|
|
78
|
+
class: "so-badge__dot", viewBox: "0 0 6 6", "aria-hidden": "true")
|
|
79
|
+
tag.span(class: "so-badge so-badge--pill so-badge--#{meta[:tone]}") do
|
|
80
|
+
safe_join([dot, meta[:label]], " ")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def stability_detail(stats)
|
|
85
|
+
failures_24h = stats[:failed_last_24h].to_i
|
|
86
|
+
return "No failures in the last 24h" if failures_24h.zero?
|
|
87
|
+
|
|
88
|
+
"#{pluralize(failures_24h, "failure")} in the last 24h, latest #{latest_failure_phrase(stats[:latest_failure_at])}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def latest_failure_phrase(timestamp)
|
|
92
|
+
timestamp ? "#{time_ago_in_words(timestamp)} ago" : "unknown"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Mirrors live_poll.js Sparkline.render — keep in sync.
|
|
4
|
+
module SolidObserver
|
|
5
|
+
module DashboardHelper
|
|
6
|
+
SVG_W = 120
|
|
7
|
+
SVG_H = 32
|
|
8
|
+
|
|
9
|
+
def spark_points(series, width: SVG_W, height: SVG_H)
|
|
10
|
+
return "" if series.blank?
|
|
11
|
+
|
|
12
|
+
t_min = series.first[:t]
|
|
13
|
+
t_max = series.last[:t]
|
|
14
|
+
v_max = [series.max_by { |point| point[:v] }[:v], 1].max
|
|
15
|
+
time_span = t_max - t_min
|
|
16
|
+
|
|
17
|
+
series.map { |point|
|
|
18
|
+
val = point[:v]
|
|
19
|
+
point_x = time_span.zero? ? width / 2.0 : ((point[:t] - t_min).to_f / time_span) * (width - 2) + 1
|
|
20
|
+
point_y = height - 1 - (val.to_f / v_max) * (height - 2)
|
|
21
|
+
format("%.1f,%.1f", point_x, point_y)
|
|
22
|
+
}.join(" ")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
RANGE_LABELS = {
|
|
26
|
+
"15m" => "in last 15m",
|
|
27
|
+
"30m" => "in last 30m",
|
|
28
|
+
"1h" => "in last hour",
|
|
29
|
+
"7h" => "in last 7h",
|
|
30
|
+
"1d" => "in last day",
|
|
31
|
+
"7d" => "in last 7d",
|
|
32
|
+
"14d" => "in last 14d"
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
def range_label(range_key)
|
|
36
|
+
RANGE_LABELS.fetch(range_key, "in selected range")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -10,6 +10,7 @@ module SolidObserver
|
|
|
10
10
|
job_failed
|
|
11
11
|
job_discarded
|
|
12
12
|
].freeze
|
|
13
|
+
DISTINCT_FILTER_LIMIT = 500
|
|
13
14
|
|
|
14
15
|
validates :event_type, presence: true, inclusion: {in: EVENT_TYPES}
|
|
15
16
|
validates :recorded_at, presence: true
|
|
@@ -19,5 +20,138 @@ module SolidObserver
|
|
|
19
20
|
scope :by_event_type, ->(event_type) { where(event_type: event_type) }
|
|
20
21
|
scope :since, ->(time) { where("recorded_at >= ?", time) }
|
|
21
22
|
scope :before, ->(time) { where("recorded_at < ?", time) }
|
|
23
|
+
scope :recent, ->(limit = 10) { order(recorded_at: :desc).limit(limit) }
|
|
24
|
+
scope :recent_failures, ->(limit = 5) { by_event_type("job_failed").order(recorded_at: :desc).limit(limit) }
|
|
25
|
+
scope :distinct_job_classes, -> {
|
|
26
|
+
where("recorded_at >= ?", SolidObserver.config.event_retention.ago)
|
|
27
|
+
.where.not(job_class: nil)
|
|
28
|
+
.distinct
|
|
29
|
+
.limit(DISTINCT_FILTER_LIMIT)
|
|
30
|
+
.pluck(:job_class)
|
|
31
|
+
.sort
|
|
32
|
+
}
|
|
33
|
+
scope :distinct_queue_names, -> {
|
|
34
|
+
where("recorded_at >= ?", SolidObserver.config.event_retention.ago)
|
|
35
|
+
.where.not(queue_name: nil)
|
|
36
|
+
.distinct
|
|
37
|
+
.limit(DISTINCT_FILTER_LIMIT)
|
|
38
|
+
.pluck(:queue_name)
|
|
39
|
+
.sort
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def self.performed_count_last(duration)
|
|
43
|
+
by_event_type("job_completed").since(duration.ago).count
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.failed_count_last(duration)
|
|
47
|
+
by_event_type("job_failed").since(duration.ago).count
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.enqueue_rate_per_minute(window: 5.minutes)
|
|
51
|
+
count = by_event_type("job_enqueued").since(window.ago).count
|
|
52
|
+
return 0.0 if count.zero?
|
|
53
|
+
|
|
54
|
+
(count.to_f / (window.to_f / 60.0)).round(1)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.enqueued_count_last(duration)
|
|
58
|
+
by_event_type("job_enqueued").since(duration.ago).count
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.avg_duration_last(duration)
|
|
62
|
+
by_event_type("job_completed").since(duration.ago).average(:duration).to_f
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.count_by_queue_and_event_type(window:, event_type:)
|
|
66
|
+
since(window.ago)
|
|
67
|
+
.where(event_type: event_type)
|
|
68
|
+
.where.not(queue_name: nil)
|
|
69
|
+
.group(:queue_name)
|
|
70
|
+
.count
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.count_by_time_bucket(event_type:, window:, bucket_seconds:)
|
|
74
|
+
context = build_bucket_context(window: window, bucket_seconds: bucket_seconds)
|
|
75
|
+
return [] unless context
|
|
76
|
+
|
|
77
|
+
counts_by_bucket = fetch_counts_by_bucket(event_type: event_type, context: context)
|
|
78
|
+
fill_missing_buckets(context: context, counts_by_bucket: counts_by_bucket)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class << self
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def build_bucket_context(window:, bucket_seconds:)
|
|
85
|
+
bucket_size = bucket_seconds.to_i
|
|
86
|
+
return nil if bucket_size <= 0 || window.to_i <= 0
|
|
87
|
+
|
|
88
|
+
end_time = Time.current
|
|
89
|
+
start_time = end_time - window
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
bucket_size: bucket_size,
|
|
93
|
+
start_time: start_time,
|
|
94
|
+
end_time: end_time,
|
|
95
|
+
start_bucket: align_bucket(start_time.to_i, bucket_size),
|
|
96
|
+
end_bucket: align_bucket(end_time.to_i, bucket_size)
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def fetch_counts_by_bucket(event_type:, context:)
|
|
101
|
+
rows = fetch_grouped_rows(event_type: event_type, context: context)
|
|
102
|
+
rows.to_h { |row| [row["bucket_time"].to_i, row["bucket_count"].to_i] }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def fetch_grouped_rows(event_type:, context:)
|
|
106
|
+
pool = BaseEvent.connection_pool
|
|
107
|
+
query_context = context.merge(
|
|
108
|
+
event_type: event_type,
|
|
109
|
+
adapter: pool.db_config.adapter.to_s.downcase
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
pool.with_connection do |connection|
|
|
113
|
+
connection.select_all(grouped_counts_sql(connection: connection, query_context: query_context)).to_a
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def grouped_counts_sql(connection:, query_context:)
|
|
118
|
+
<<~SQL.squish
|
|
119
|
+
SELECT #{bucket_time_sql(adapter: query_context[:adapter], bucket_size: query_context[:bucket_size])} AS bucket_time, COUNT(*) AS bucket_count
|
|
120
|
+
FROM #{table_name}
|
|
121
|
+
WHERE event_type = #{connection.quote(query_context[:event_type])}
|
|
122
|
+
AND recorded_at >= #{connection.quote(query_context[:start_time])}
|
|
123
|
+
AND recorded_at <= #{connection.quote(query_context[:end_time])}
|
|
124
|
+
GROUP BY bucket_time
|
|
125
|
+
ORDER BY bucket_time ASC
|
|
126
|
+
SQL
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def bucket_time_sql(adapter:, bucket_size:)
|
|
130
|
+
case adapter
|
|
131
|
+
when "sqlite3", "sqlite"
|
|
132
|
+
"(CAST(strftime('%s', recorded_at) AS INTEGER) / #{bucket_size}) * #{bucket_size}"
|
|
133
|
+
when "postgresql"
|
|
134
|
+
if bucket_size == 60
|
|
135
|
+
"EXTRACT(EPOCH FROM date_trunc('minute', recorded_at))::bigint"
|
|
136
|
+
else
|
|
137
|
+
"(EXTRACT(EPOCH FROM recorded_at)::bigint / #{bucket_size}) * #{bucket_size}"
|
|
138
|
+
end
|
|
139
|
+
when "mysql2", "trilogy", "mysql"
|
|
140
|
+
"(UNIX_TIMESTAMP(recorded_at) DIV #{bucket_size}) * #{bucket_size}"
|
|
141
|
+
else
|
|
142
|
+
raise ArgumentError, "Unsupported adapter for bucket aggregation: #{adapter.inspect}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def fill_missing_buckets(context:, counts_by_bucket:)
|
|
147
|
+
context[:start_bucket].step(context[:end_bucket], context[:bucket_size]).map do |timestamp|
|
|
148
|
+
{t: timestamp, v: counts_by_bucket.fetch(timestamp, 0)}
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def align_bucket(value, bucket_size)
|
|
153
|
+
(value / bucket_size) * bucket_size
|
|
154
|
+
end
|
|
155
|
+
end
|
|
22
156
|
end
|
|
23
157
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module SolidObserver
|
|
4
4
|
# QueueMetric provides time-series metrics storage for queue statistics.
|
|
5
5
|
#
|
|
6
|
-
# NOTE: Metrics functionality is planned for
|
|
6
|
+
# NOTE: Metrics functionality is planned for a future release. This class currently
|
|
7
7
|
# serves as a placeholder and inherits base functionality from BaseMetric.
|
|
8
8
|
# The database connection will be configured by the Engine when metrics
|
|
9
9
|
# are fully implemented.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
class ExecutionPresenter
|
|
5
|
+
STATUS_MAP = {
|
|
6
|
+
"SolidQueue::ReadyExecution" => "ready",
|
|
7
|
+
"SolidQueue::ScheduledExecution" => "scheduled",
|
|
8
|
+
"SolidQueue::ClaimedExecution" => "claimed",
|
|
9
|
+
"SolidQueue::FailedExecution" => "failed"
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(execution)
|
|
13
|
+
@execution = execution
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def status
|
|
17
|
+
STATUS_MAP.fetch(@execution.class.name, "unknown")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def job
|
|
21
|
+
@execution.job
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def queue_name
|
|
25
|
+
responded, value = value_from(@execution, :queue_name)
|
|
26
|
+
return value if responded
|
|
27
|
+
|
|
28
|
+
value_from(job, :queue_name).last
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def priority
|
|
32
|
+
responded, value = value_from(@execution, :priority)
|
|
33
|
+
return value if responded
|
|
34
|
+
|
|
35
|
+
value_from(job, :priority).last
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_model
|
|
39
|
+
@execution
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def value_from(target, method_name)
|
|
45
|
+
return [false, nil] unless target&.respond_to?(method_name)
|
|
46
|
+
|
|
47
|
+
[true, target.method(method_name).call]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|