solid_observer 0.3.0 → 0.4.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 +21 -0
- data/README.md +134 -81
- data/app/assets/stylesheets/solid_observer/application.css +18 -0
- data/app/controllers/solid_observer/cache_dashboard_controller.rb +59 -0
- data/app/controllers/solid_observer/cache_operations_controller.rb +24 -0
- data/app/controllers/solid_observer/dashboard_controller.rb +44 -1
- data/app/controllers/solid_observer/storages_controller.rb +1 -1
- data/app/helpers/solid_observer/application_helper.rb +154 -5
- data/app/helpers/solid_observer/dashboard_helper.rb +30 -11
- data/app/models/solid_observer/cache_event.rb +15 -0
- data/app/models/solid_observer/cache_metric.rb +14 -0
- data/app/models/solid_observer/storage_info.rb +4 -1
- data/app/views/layouts/solid_observer/application.html.erb +144 -17
- data/app/views/solid_observer/cache_dashboard/_charts.html.erb +40 -0
- data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +34 -0
- data/app/views/solid_observer/cache_dashboard/_summary.html.erb +39 -0
- data/app/views/solid_observer/cache_dashboard/index.html.erb +62 -0
- data/app/views/solid_observer/cache_operations/_confirm_clear.html.erb +6 -0
- data/app/views/solid_observer/cache_operations/index.html.erb +60 -0
- data/app/views/solid_observer/dashboard/index.html.erb +34 -4
- data/app/views/solid_observer/jobs/show.html.erb +3 -3
- data/app/views/solid_observer/storages/show.html.erb +64 -32
- data/bin/quality_gate +1 -1
- data/config/routes.rb +5 -0
- data/db/migrate/20260601000001_create_solid_observer_cache_events.rb +22 -0
- data/db/migrate/20260601000002_create_solid_observer_cache_metrics.rb +18 -0
- data/db/migrate/20260602000001_add_component_to_solid_observer_storage_infos.rb +8 -0
- data/lib/generators/solid_observer/templates/initializer.rb.tt +2 -1
- data/lib/solid_observer/cache_event_buffer.rb +53 -0
- data/lib/solid_observer/cache_subscriber.rb +47 -0
- data/lib/solid_observer/cli/storage.rb +16 -13
- data/lib/solid_observer/configuration.rb +22 -3
- data/lib/solid_observer/engine.rb +44 -7
- data/lib/solid_observer/services/cache_operations.rb +115 -0
- data/lib/solid_observer/services/cache_stats.rb +329 -0
- data/lib/solid_observer/services/cleanup_storage.rb +18 -2
- data/lib/solid_observer/services/database_size.rb +13 -8
- data/lib/solid_observer/services/flush_cache_event_buffer.rb +54 -0
- data/lib/solid_observer/services/record_cache_event.rb +142 -0
- data/lib/solid_observer/services/record_cache_metric.rb +74 -0
- data/lib/solid_observer/services/storage_info_snapshot.rb +128 -0
- data/lib/solid_observer/version.rb +1 -1
- data/lib/tasks/solid_observer.rake +29 -0
- metadata +23 -1
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "cache_event_buffer"
|
|
4
|
+
require_relative "cache_subscriber"
|
|
5
|
+
require_relative "services/record_cache_event"
|
|
6
|
+
require_relative "services/record_cache_metric"
|
|
7
|
+
require_relative "services/flush_cache_event_buffer"
|
|
8
|
+
require_relative "services/cache_stats"
|
|
9
|
+
require_relative "services/cache_operations"
|
|
10
|
+
|
|
3
11
|
module SolidObserver
|
|
4
12
|
class Engine < ::Rails::Engine
|
|
5
13
|
isolate_namespace SolidObserver
|
|
6
14
|
|
|
15
|
+
SOLID_QUEUE_AVAILABLE = defined?(::SolidQueue)
|
|
16
|
+
SOLID_CACHE_AVAILABLE = defined?(::SolidCache)
|
|
17
|
+
|
|
7
18
|
middleware.use ActionDispatch::Cookies
|
|
8
19
|
middleware.use ActionDispatch::Session::CookieStore, key: "_solid_observer_session"
|
|
9
20
|
middleware.use ActionDispatch::Flash
|
|
10
21
|
|
|
11
22
|
class << self
|
|
12
23
|
def check_solid_queue_availability
|
|
13
|
-
return if defined?(SolidQueue)
|
|
24
|
+
return if defined?(::SolidQueue)
|
|
14
25
|
|
|
15
26
|
Rails.logger.warn "[SolidObserver] SolidQueue not detected. Queue observability features will be limited."
|
|
16
27
|
end
|
|
17
28
|
|
|
29
|
+
def check_solid_cache_availability
|
|
30
|
+
return if defined?(::SolidCache)
|
|
31
|
+
return unless SolidObserver.config.observe_cache
|
|
32
|
+
|
|
33
|
+
Rails.logger.warn "[SolidObserver] SolidCache not detected. Cache observability features will be disabled."
|
|
34
|
+
end
|
|
35
|
+
|
|
18
36
|
def check_ui_authentication
|
|
19
37
|
Services::UiAuthCheck.call(config: SolidObserver.config)
|
|
20
38
|
end
|
|
@@ -28,10 +46,11 @@ module SolidObserver
|
|
|
28
46
|
|
|
29
47
|
def activate_subscribers
|
|
30
48
|
return activate_subscribers_in_realtime if SolidObserver.config.realtime_mode?
|
|
31
|
-
return if
|
|
49
|
+
return if activation_skipped_for_table_status_for_enabled_components?
|
|
32
50
|
|
|
33
51
|
Rails.logger.info "[SolidObserver] Activating event subscribers"
|
|
34
|
-
Subscriber.subscribe!
|
|
52
|
+
Subscriber.subscribe! if should_activate_queue_subscriber?
|
|
53
|
+
CacheSubscriber.subscribe! if should_activate_cache_subscriber?
|
|
35
54
|
end
|
|
36
55
|
|
|
37
56
|
private
|
|
@@ -54,13 +73,22 @@ module SolidObserver
|
|
|
54
73
|
|
|
55
74
|
def activate_subscribers_in_realtime
|
|
56
75
|
Rails.logger.info "[SolidObserver] Starting in real-time mode (no persistence)"
|
|
57
|
-
Subscriber.subscribe!
|
|
76
|
+
Subscriber.subscribe! if should_activate_queue_subscriber?
|
|
77
|
+
CacheSubscriber.subscribe! if should_activate_cache_subscriber?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def activation_skipped_for_table_status_for_enabled_components?
|
|
81
|
+
enabled_tables = []
|
|
82
|
+
enabled_tables << "solid_observer_queue_events" if should_activate_queue_subscriber?
|
|
83
|
+
enabled_tables << "solid_observer_cache_events" if should_activate_cache_subscriber?
|
|
84
|
+
|
|
85
|
+
enabled_tables.any? { |table_name| skip_activation_for_missing_table?(table_name) }
|
|
58
86
|
end
|
|
59
87
|
|
|
60
|
-
def
|
|
61
|
-
case table_status(
|
|
88
|
+
def skip_activation_for_missing_table?(table_name)
|
|
89
|
+
case table_status(table_name)
|
|
62
90
|
when :absent
|
|
63
|
-
log_activation_skip("Tables not found. Run: rails solid_observer:install:migrations && rails db:migrate")
|
|
91
|
+
log_activation_skip("Tables not found (missing: #{table_name}). Run: rails solid_observer:install:migrations && rails db:migrate")
|
|
64
92
|
true
|
|
65
93
|
when :unknown
|
|
66
94
|
log_activation_skip("Database not reachable at boot. Skipping subscriber activation.")
|
|
@@ -70,6 +98,14 @@ module SolidObserver
|
|
|
70
98
|
end
|
|
71
99
|
end
|
|
72
100
|
|
|
101
|
+
def should_activate_queue_subscriber?
|
|
102
|
+
SolidObserver.config.solid_queue_enabled?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def should_activate_cache_subscriber?
|
|
106
|
+
SolidObserver.config.solid_cache_enabled?
|
|
107
|
+
end
|
|
108
|
+
|
|
73
109
|
def log_activation_skip(message)
|
|
74
110
|
Rails.logger.info("[SolidObserver] #{message}")
|
|
75
111
|
end
|
|
@@ -109,6 +145,7 @@ module SolidObserver
|
|
|
109
145
|
|
|
110
146
|
config.before_initialize do
|
|
111
147
|
Engine.check_solid_queue_availability
|
|
148
|
+
Engine.check_solid_cache_availability
|
|
112
149
|
end
|
|
113
150
|
|
|
114
151
|
config.after_initialize do
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
module Services
|
|
5
|
+
class CacheOperations
|
|
6
|
+
MESSAGES = {
|
|
7
|
+
clear: {
|
|
8
|
+
confirmation: "Clear all SolidCache entries? This evicts cached application data and may slow requests while the cache rebuilds. This cannot be undone.",
|
|
9
|
+
success: "Cache cleared successfully.",
|
|
10
|
+
failure: "Cache clear failed. SolidCache is unavailable or rejected the operation."
|
|
11
|
+
}.freeze,
|
|
12
|
+
prune: {
|
|
13
|
+
success: "Expired cache entries pruned successfully.",
|
|
14
|
+
failure: "Cache prune failed. SolidCache is unavailable or rejected the operation."
|
|
15
|
+
}.freeze,
|
|
16
|
+
unavailable: "Cache controls are unavailable because SolidCache is not enabled or not detected."
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def available?
|
|
21
|
+
new.available?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def clear
|
|
25
|
+
new.clear
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def prune
|
|
29
|
+
new.prune
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def message(operation, key = nil)
|
|
33
|
+
return MESSAGES.fetch(:unavailable) if operation == :unavailable
|
|
34
|
+
|
|
35
|
+
MESSAGES.fetch(operation).fetch(key)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def unavailable_message
|
|
39
|
+
message(:unavailable)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def available?
|
|
44
|
+
SolidObserver.config.solid_cache_enabled? && compatible_store?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def clear
|
|
48
|
+
messages = self.class
|
|
49
|
+
return {ok: false, message: messages.unavailable_message} unless available?
|
|
50
|
+
|
|
51
|
+
perform_operation(
|
|
52
|
+
:clear,
|
|
53
|
+
success_message: messages.message(:clear, :success),
|
|
54
|
+
failure_message: messages.message(:clear, :failure)
|
|
55
|
+
) do
|
|
56
|
+
cache_store.clear
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def prune
|
|
61
|
+
messages = self.class
|
|
62
|
+
return {ok: false, message: messages.unavailable_message} unless available?
|
|
63
|
+
|
|
64
|
+
perform_operation(
|
|
65
|
+
:prune,
|
|
66
|
+
success_message: messages.message(:prune, :success),
|
|
67
|
+
failure_message: messages.message(:prune, :failure)
|
|
68
|
+
) do
|
|
69
|
+
prune_with_fallback
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def compatible_store?
|
|
76
|
+
defined?(::SolidCache::Store) && cache_store.is_a?(::SolidCache::Store)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def cache_store
|
|
80
|
+
Rails.cache
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def perform_operation(name, success_message:, failure_message:)
|
|
84
|
+
yield
|
|
85
|
+
{ok: true, message: success_message}
|
|
86
|
+
rescue => error
|
|
87
|
+
log_failure(name, error)
|
|
88
|
+
{ok: false, message: failure_message}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def prune_with_fallback
|
|
92
|
+
cache_store.cleanup
|
|
93
|
+
rescue NotImplementedError
|
|
94
|
+
prune_with_solid_cache_fallback
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def prune_with_solid_cache_fallback
|
|
98
|
+
cache_store.with_each_connection do
|
|
99
|
+
::SolidCache::Entry.expire(
|
|
100
|
+
cache_store.expiry_batch_size,
|
|
101
|
+
max_age: cache_store.max_age,
|
|
102
|
+
max_entries: cache_store.max_entries,
|
|
103
|
+
max_size: cache_store.max_size
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
rescue NameError
|
|
107
|
+
raise "cleanup unsupported"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def log_failure(name, error)
|
|
111
|
+
Rails.logger&.warn("[SolidObserver] Cache #{name} failed: #{error.class}")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
module Services
|
|
5
|
+
class CacheStats
|
|
6
|
+
RANGES = {
|
|
7
|
+
"15m" => 15.minutes,
|
|
8
|
+
"30m" => 30.minutes,
|
|
9
|
+
"1h" => 1.hour,
|
|
10
|
+
"7h" => 7.hours,
|
|
11
|
+
"1d" => 1.day,
|
|
12
|
+
"7d" => 7.days,
|
|
13
|
+
"14d" => 14.days
|
|
14
|
+
}.freeze
|
|
15
|
+
DEFAULT_RANGE = "15m"
|
|
16
|
+
ACTIVITY_TREND_EMPTY = {
|
|
17
|
+
available: false,
|
|
18
|
+
hit_rate: [],
|
|
19
|
+
operations: [],
|
|
20
|
+
errors: []
|
|
21
|
+
}.freeze
|
|
22
|
+
STABILITY_EMPTY = {
|
|
23
|
+
available: false,
|
|
24
|
+
state: :stable,
|
|
25
|
+
error_count: 0,
|
|
26
|
+
slow_count: 0,
|
|
27
|
+
latest_recorded_at: nil
|
|
28
|
+
}.freeze
|
|
29
|
+
BUCKET_RULES = [
|
|
30
|
+
[2.hours.to_i, 1.minute.to_i],
|
|
31
|
+
[1.day.to_i, 15.minutes.to_i],
|
|
32
|
+
[7.days.to_i, 2.hours.to_i]
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
class TrendData
|
|
36
|
+
class BucketSnapshot
|
|
37
|
+
attr_reader :operations_count, :hits_count, :misses_count, :errors_count
|
|
38
|
+
|
|
39
|
+
def initialize
|
|
40
|
+
@operations_count = 0
|
|
41
|
+
@hits_count = 0
|
|
42
|
+
@misses_count = 0
|
|
43
|
+
@errors_count = 0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def add(row)
|
|
47
|
+
@operations_count += row[1].to_i
|
|
48
|
+
@hits_count += row[2].to_i
|
|
49
|
+
@misses_count += row[3].to_i
|
|
50
|
+
@errors_count += row[4].to_i
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def hit_rate
|
|
54
|
+
read_outcomes = hits_count + misses_count
|
|
55
|
+
return 0.0 if read_outcomes.zero?
|
|
56
|
+
|
|
57
|
+
hits_count.to_f / read_outcomes
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def value_for(key)
|
|
61
|
+
public_send(key)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def initialize(metric_rows:, window:, current_time:)
|
|
66
|
+
@metric_rows = metric_rows
|
|
67
|
+
@window = window
|
|
68
|
+
@current_time = current_time
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_h
|
|
72
|
+
return CacheStats::ACTIVITY_TREND_EMPTY.dup if metric_rows.empty?
|
|
73
|
+
|
|
74
|
+
buckets = blank_buckets
|
|
75
|
+
metric_rows.each do |row|
|
|
76
|
+
buckets[align_bucket(row[0].to_i)]&.add(row)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
available: true,
|
|
81
|
+
hit_rate: hit_rate_series(buckets),
|
|
82
|
+
operations: count_series(buckets, :operations_count),
|
|
83
|
+
errors: count_series(buckets, :errors_count)
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
attr_reader :metric_rows, :window, :current_time
|
|
90
|
+
|
|
91
|
+
def blank_buckets
|
|
92
|
+
start_bucket = align_bucket((current_time - window).to_i)
|
|
93
|
+
end_bucket = align_bucket(current_time.to_i)
|
|
94
|
+
|
|
95
|
+
start_bucket.step(end_bucket, bucket_seconds).each_with_object({}) do |timestamp, buckets|
|
|
96
|
+
buckets[timestamp] = BucketSnapshot.new
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def hit_rate_series(buckets)
|
|
101
|
+
buckets.map do |timestamp, totals|
|
|
102
|
+
{t: timestamp, v: totals.hit_rate}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def count_series(buckets, key)
|
|
107
|
+
buckets.map do |timestamp, totals|
|
|
108
|
+
{t: timestamp, v: totals.value_for(key)}
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def bucket_seconds
|
|
113
|
+
seconds = window.to_i
|
|
114
|
+
CacheStats::BUCKET_RULES.find { |limit, _bucket| seconds <= limit }&.last || 4.hours.to_i
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def align_bucket(value)
|
|
118
|
+
(value / bucket_seconds) * bucket_seconds
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
class StabilityData
|
|
123
|
+
class EventCounts
|
|
124
|
+
attr_reader :error_count, :slow_count, :latest_recorded_at
|
|
125
|
+
|
|
126
|
+
def initialize
|
|
127
|
+
@error_count = 0
|
|
128
|
+
@slow_count = 0
|
|
129
|
+
@latest_recorded_at = nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def record(recorded_at:, error_class:, duration:)
|
|
133
|
+
kind = event_kind(error_class: error_class, duration: duration)
|
|
134
|
+
return unless kind
|
|
135
|
+
|
|
136
|
+
@latest_recorded_at = [latest_recorded_at, recorded_at].compact.max
|
|
137
|
+
@error_count += 1 if kind == :error
|
|
138
|
+
@slow_count += 1 if kind == :slow
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def state
|
|
142
|
+
return :critical if error_count.positive?
|
|
143
|
+
return :degraded if slow_count.positive?
|
|
144
|
+
|
|
145
|
+
:stable
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def to_h
|
|
149
|
+
{
|
|
150
|
+
available: true,
|
|
151
|
+
state: state,
|
|
152
|
+
error_count: error_count,
|
|
153
|
+
slow_count: slow_count,
|
|
154
|
+
latest_recorded_at: latest_recorded_at
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def event_kind(error_class:, duration:)
|
|
161
|
+
return :error if error_class.present?
|
|
162
|
+
return :slow if duration.to_f >= SolidObserver.config.cache_slow_threshold.to_f
|
|
163
|
+
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def initialize(window:, current_time:)
|
|
169
|
+
@window = window
|
|
170
|
+
@current_time = current_time
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def to_h
|
|
174
|
+
event_counts.to_h
|
|
175
|
+
rescue ActiveRecord::StatementInvalid
|
|
176
|
+
CacheStats::STABILITY_EMPTY.dup
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
attr_reader :window, :current_time
|
|
182
|
+
|
|
183
|
+
def event_counts
|
|
184
|
+
counts = EventCounts.new
|
|
185
|
+
|
|
186
|
+
SolidObserver::CacheEvent.where(recorded_at: window_range).pluck(
|
|
187
|
+
:recorded_at,
|
|
188
|
+
:duration,
|
|
189
|
+
:error_class
|
|
190
|
+
).each do |recorded_at, duration, error_class|
|
|
191
|
+
counts.record(recorded_at: recorded_at, error_class: error_class, duration: duration)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
counts
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def window_range
|
|
198
|
+
(current_time - window)..current_time
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
class << self
|
|
203
|
+
def parse_range(value, fallback: DEFAULT_RANGE)
|
|
204
|
+
range_key = value.to_s
|
|
205
|
+
RANGES.key?(range_key) ? range_key : fallback
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def range_duration(value, fallback: DEFAULT_RANGE)
|
|
209
|
+
RANGES.fetch(parse_range(value, fallback: fallback))
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def self.call(window:)
|
|
214
|
+
new.call(window: window)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def call(window:)
|
|
218
|
+
current_time = Time.current
|
|
219
|
+
dashboard_response(window: window, current_time: current_time)
|
|
220
|
+
rescue => error
|
|
221
|
+
error_response(error.message)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
def dashboard_response(window:, current_time:)
|
|
227
|
+
time_window = (current_time - window)..current_time
|
|
228
|
+
metric_rows = metric_rows(time_window: time_window)
|
|
229
|
+
|
|
230
|
+
build_response(
|
|
231
|
+
window: window,
|
|
232
|
+
totals: metric_totals(time_window: time_window),
|
|
233
|
+
dashboard_data: dashboard_data(window: window, current_time: current_time, metric_rows: metric_rows)
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def build_response(window:, totals:, dashboard_data:)
|
|
238
|
+
operations_count, hits_count, misses_count, errors_count, duration_total = totals.values_at(
|
|
239
|
+
:operations_count,
|
|
240
|
+
:hits_count,
|
|
241
|
+
:misses_count,
|
|
242
|
+
:errors_count,
|
|
243
|
+
:duration_total
|
|
244
|
+
)
|
|
245
|
+
read_outcomes_count = hits_count + misses_count
|
|
246
|
+
window_minutes = [window.to_f / 60.0, 1.0].max
|
|
247
|
+
|
|
248
|
+
{
|
|
249
|
+
hit_rate: ratio(hits_count, read_outcomes_count),
|
|
250
|
+
throughput: operations_count.to_f / window_minutes,
|
|
251
|
+
error_rate: ratio(errors_count, operations_count),
|
|
252
|
+
avg_duration: ratio(duration_total, operations_count),
|
|
253
|
+
operations_count: operations_count,
|
|
254
|
+
hits_count: hits_count,
|
|
255
|
+
misses_count: misses_count,
|
|
256
|
+
errors_count: errors_count,
|
|
257
|
+
duration_total: duration_total,
|
|
258
|
+
activity_trends: dashboard_data[:activity_trends],
|
|
259
|
+
stability: dashboard_data[:stability]
|
|
260
|
+
}
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def dashboard_data(window:, current_time:, metric_rows:)
|
|
264
|
+
{
|
|
265
|
+
activity_trends: TrendData.new(
|
|
266
|
+
metric_rows: metric_rows,
|
|
267
|
+
window: window,
|
|
268
|
+
current_time: current_time
|
|
269
|
+
).to_h,
|
|
270
|
+
stability: StabilityData.new(window: window, current_time: current_time).to_h
|
|
271
|
+
}
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def metric_rows(time_window:)
|
|
275
|
+
SolidObserver::CacheMetric.where(period_start: time_window).pluck(
|
|
276
|
+
:period_start,
|
|
277
|
+
:operations_count,
|
|
278
|
+
:hits_count,
|
|
279
|
+
:misses_count,
|
|
280
|
+
:errors_count,
|
|
281
|
+
:duration_total
|
|
282
|
+
)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def metric_totals(time_window:)
|
|
286
|
+
operations_count, hits_count, misses_count, errors_count, duration_total = SolidObserver::CacheMetric.where(
|
|
287
|
+
period_start: time_window
|
|
288
|
+
).pick(
|
|
289
|
+
Arel.sql("COALESCE(SUM(operations_count), 0)"),
|
|
290
|
+
Arel.sql("COALESCE(SUM(hits_count), 0)"),
|
|
291
|
+
Arel.sql("COALESCE(SUM(misses_count), 0)"),
|
|
292
|
+
Arel.sql("COALESCE(SUM(errors_count), 0)"),
|
|
293
|
+
Arel.sql("COALESCE(SUM(duration_total), 0.0)")
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
{
|
|
297
|
+
operations_count: operations_count,
|
|
298
|
+
hits_count: hits_count,
|
|
299
|
+
misses_count: misses_count,
|
|
300
|
+
errors_count: errors_count,
|
|
301
|
+
duration_total: duration_total
|
|
302
|
+
}
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def ratio(numerator, denominator)
|
|
306
|
+
return 0.0 if denominator.to_i.zero?
|
|
307
|
+
|
|
308
|
+
numerator.to_f / denominator
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def error_response(message)
|
|
312
|
+
{
|
|
313
|
+
hit_rate: 0.0,
|
|
314
|
+
throughput: 0.0,
|
|
315
|
+
error_rate: 0.0,
|
|
316
|
+
avg_duration: 0.0,
|
|
317
|
+
operations_count: 0,
|
|
318
|
+
hits_count: 0,
|
|
319
|
+
misses_count: 0,
|
|
320
|
+
errors_count: 0,
|
|
321
|
+
duration_total: 0.0,
|
|
322
|
+
activity_trends: ACTIVITY_TREND_EMPTY.dup,
|
|
323
|
+
stability: STABILITY_EMPTY.dup,
|
|
324
|
+
error: message
|
|
325
|
+
}
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "database_size"
|
|
4
|
+
require_relative "storage_info_snapshot"
|
|
4
5
|
|
|
5
6
|
module SolidObserver
|
|
6
7
|
module Services
|
|
@@ -46,10 +47,25 @@ module SolidObserver
|
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
def record_snapshot_after_cleanup
|
|
50
|
+
snapshots = StorageInfoSnapshot.call
|
|
51
|
+
|
|
49
52
|
# StorageInfo.db_size_bytes is NOT NULL; record_snapshot coerces nil to 0.
|
|
53
|
+
StorageInfo.record_snapshot(db_size: current_database_size, event_count: QueueEvent.count)
|
|
54
|
+
|
|
55
|
+
snapshots.each do |snapshot|
|
|
56
|
+
record_component_snapshot(snapshot)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def record_component_snapshot(snapshot)
|
|
61
|
+
return unless snapshot[:available]
|
|
62
|
+
component = snapshot[:component]
|
|
63
|
+
return if component == "queue_observer"
|
|
64
|
+
|
|
50
65
|
StorageInfo.record_snapshot(
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
component: component,
|
|
67
|
+
db_size: snapshot[:db_size_bytes],
|
|
68
|
+
event_count: snapshot[:event_count]
|
|
53
69
|
)
|
|
54
70
|
end
|
|
55
71
|
|
|
@@ -7,14 +7,15 @@ module SolidObserver
|
|
|
7
7
|
# SQLite uses whole-database page accounting; PostgreSQL and MySQL/Trilogy
|
|
8
8
|
# use table + index size from adapter-native system functions.
|
|
9
9
|
class DatabaseSize
|
|
10
|
-
|
|
10
|
+
DEFAULT_TABLE_NAME = "solid_observer_queue_events"
|
|
11
11
|
|
|
12
|
-
def self.call(connection:)
|
|
13
|
-
new(connection).call
|
|
12
|
+
def self.call(connection:, table_name: DEFAULT_TABLE_NAME)
|
|
13
|
+
new(connection, table_name: table_name).call
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
def initialize(connection)
|
|
16
|
+
def initialize(connection, table_name:)
|
|
17
17
|
@connection = connection
|
|
18
|
+
@table_name = table_name
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
def call
|
|
@@ -26,7 +27,7 @@ module SolidObserver
|
|
|
26
27
|
|
|
27
28
|
private
|
|
28
29
|
|
|
29
|
-
attr_reader :connection
|
|
30
|
+
attr_reader :connection, :table_name
|
|
30
31
|
|
|
31
32
|
def adapter_key
|
|
32
33
|
case connection.adapter_name.to_s.downcase
|
|
@@ -52,16 +53,20 @@ module SolidObserver
|
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
def sqlite_size
|
|
55
|
-
connection.query_value("
|
|
56
|
+
page_count = connection.query_value("PRAGMA page_count")
|
|
57
|
+
page_size = connection.query_value("PRAGMA page_size")
|
|
58
|
+
return unless page_count && page_size
|
|
59
|
+
|
|
60
|
+
page_count.to_i * page_size.to_i
|
|
56
61
|
end
|
|
57
62
|
|
|
58
63
|
def postgresql_size
|
|
59
|
-
quoted_table = connection.quote(
|
|
64
|
+
quoted_table = connection.quote(table_name)
|
|
60
65
|
connection.query_value("SELECT pg_total_relation_size(#{quoted_table})")&.to_i
|
|
61
66
|
end
|
|
62
67
|
|
|
63
68
|
def mysql_size
|
|
64
|
-
quoted_table = connection.quote(
|
|
69
|
+
quoted_table = connection.quote(table_name)
|
|
65
70
|
|
|
66
71
|
connection.query_value(<<~SQL)&.to_i
|
|
67
72
|
SELECT COALESCE(data_length + index_length, 0)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
module Services
|
|
5
|
+
class FlushCacheEventBuffer
|
|
6
|
+
BATCH_SIZE = 100
|
|
7
|
+
|
|
8
|
+
def self.call(events)
|
|
9
|
+
new(events).call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(events)
|
|
13
|
+
@events = events
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
return 0 if @events.empty?
|
|
18
|
+
|
|
19
|
+
insert_all_events
|
|
20
|
+
@events.size
|
|
21
|
+
rescue ActiveRecord::RecordInvalid, ActiveRecord::StatementInvalid => error
|
|
22
|
+
fallback_insert_count(error)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def insert_all_events
|
|
28
|
+
CacheEvent.transaction { CacheEvent.insert_all!(@events) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def fallback_insert_count(error)
|
|
32
|
+
Rails.logger&.error("[SolidObserver] Cache bulk insert failed, retrying in batches: #{error.message}") if defined?(Rails)
|
|
33
|
+
@events.each_slice(BATCH_SIZE).sum { |batch| insert_batch(batch) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def insert_batch(batch)
|
|
37
|
+
size = batch.size
|
|
38
|
+
CacheEvent.insert_all(batch, returning: false)
|
|
39
|
+
size
|
|
40
|
+
rescue ActiveRecord::RecordInvalid, ActiveRecord::StatementInvalid => error
|
|
41
|
+
handle_batch_insert_error(size, error)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def log_batch_warning(size, error)
|
|
45
|
+
Rails.logger&.warn("[SolidObserver] Failed to insert cache batch of #{size} events: #{error.message}") if defined?(Rails)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def handle_batch_insert_error(size, error)
|
|
49
|
+
log_batch_warning(size, error)
|
|
50
|
+
0
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|