solid_observer 0.1.1 → 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 +84 -0
- data/README.md +241 -59
- data/app/assets/javascripts/solid_observer/live_poll.js +376 -0
- data/app/assets/stylesheets/solid_observer/application.css +18 -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/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 +122 -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 +244 -0
- data/app/helpers/solid_observer/dashboard_helper.rb +58 -0
- 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/queue_event.rb +134 -0
- data/app/models/solid_observer/queue_metric.rb +1 -1
- data/app/models/solid_observer/storage_info.rb +4 -1
- data/app/presenters/solid_observer/execution_presenter.rb +50 -0
- data/app/views/layouts/solid_observer/application.html.erb +597 -0
- 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/_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 +143 -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 +71 -0
- data/bin/quality_gate +95 -0
- data/config/routes.rb +22 -0
- data/db/migrate/20260424000001_add_composite_indexes_to_queue_events.rb +30 -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/install_generator.rb +12 -25
- data/lib/generators/solid_observer/templates/initializer.rb.tt +6 -6
- data/lib/solid_observer/base_metric.rb +1 -1
- data/lib/solid_observer/cache_event_buffer.rb +53 -0
- data/lib/solid_observer/cache_subscriber.rb +47 -0
- 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 +48 -44
- data/lib/solid_observer/configuration.rb +67 -38
- data/lib/solid_observer/correlation_id_resolver.rb +8 -6
- data/lib/solid_observer/engine.rb +110 -18
- 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/cache_operations.rb +115 -0
- data/lib/solid_observer/services/cache_stats.rb +329 -0
- data/lib/solid_observer/services/cleanup_storage.rb +73 -41
- data/lib/solid_observer/services/database_size.rb +91 -0
- data/lib/solid_observer/services/flush_cache_event_buffer.rb +54 -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_cache_event.rb +142 -0
- data/lib/solid_observer/services/record_cache_metric.rb +74 -0
- data/lib/solid_observer/services/record_event.rb +51 -14
- data/lib/solid_observer/services/storage_info_snapshot.rb +128 -0
- 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 +39 -2
- metadata +77 -1
|
@@ -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,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "database_size"
|
|
4
|
+
require_relative "storage_info_snapshot"
|
|
5
|
+
|
|
3
6
|
module SolidObserver
|
|
4
7
|
module Services
|
|
5
8
|
class CleanupStorage
|
|
@@ -10,86 +13,115 @@ module SolidObserver
|
|
|
10
13
|
def call
|
|
11
14
|
return 0 if SolidObserver.config.realtime_mode?
|
|
12
15
|
|
|
13
|
-
deleted_count =
|
|
16
|
+
deleted_count = perform_cleanup_transaction
|
|
17
|
+
post_cleanup(deleted_count)
|
|
18
|
+
rescue => e
|
|
19
|
+
handle_cleanup_failure(e)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def handle_cleanup_failure(error)
|
|
25
|
+
Rails.logger.error "[SolidObserver] Cleanup failed: #{error.message}"
|
|
26
|
+
raise
|
|
27
|
+
end
|
|
14
28
|
|
|
29
|
+
def perform_cleanup_transaction
|
|
15
30
|
QueueEvent.transaction do
|
|
16
31
|
deleted_count = delete_old_events
|
|
17
32
|
record_snapshot_after_cleanup
|
|
33
|
+
deleted_count
|
|
18
34
|
end
|
|
35
|
+
end
|
|
19
36
|
|
|
37
|
+
def post_cleanup(deleted_count)
|
|
20
38
|
vacuum_database
|
|
21
|
-
|
|
22
39
|
check_storage_warnings
|
|
23
40
|
log_results(deleted_count)
|
|
24
|
-
|
|
25
41
|
deleted_count
|
|
26
|
-
rescue => e
|
|
27
|
-
Rails.logger.error "[SolidObserver] Cleanup failed: #{e.message}"
|
|
28
|
-
raise
|
|
29
42
|
end
|
|
30
43
|
|
|
31
|
-
private
|
|
32
|
-
|
|
33
44
|
def delete_old_events
|
|
34
45
|
cutoff = SolidObserver.config.event_retention.ago
|
|
35
46
|
QueueEvent.where("recorded_at < ?", cutoff).delete_all
|
|
36
47
|
end
|
|
37
48
|
|
|
38
49
|
def record_snapshot_after_cleanup
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
snapshots = StorageInfoSnapshot.call
|
|
51
|
+
|
|
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"
|
|
41
64
|
|
|
42
65
|
StorageInfo.record_snapshot(
|
|
43
|
-
|
|
44
|
-
|
|
66
|
+
component: component,
|
|
67
|
+
db_size: snapshot[:db_size_bytes],
|
|
68
|
+
event_count: snapshot[:event_count]
|
|
45
69
|
)
|
|
46
70
|
end
|
|
47
71
|
|
|
48
72
|
def vacuum_database
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
when "postgresql"
|
|
54
|
-
QueueEvent.connection.execute("VACUUM ANALYZE solid_observer_queue_events")
|
|
55
|
-
when "mysql2", "trilogy"
|
|
56
|
-
QueueEvent.connection.execute("OPTIMIZE TABLE solid_observer_queue_events")
|
|
57
|
-
end
|
|
73
|
+
statement = maintenance_statement
|
|
74
|
+
return unless statement
|
|
75
|
+
|
|
76
|
+
QueueEvent.connection.execute(statement)
|
|
58
77
|
rescue => e
|
|
59
78
|
Rails.logger.warn "[SolidObserver] Database maintenance failed: #{e.message}"
|
|
60
79
|
end
|
|
61
80
|
|
|
62
|
-
def
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
Rails.logger.warn
|
|
67
|
-
0
|
|
81
|
+
def check_storage_warnings
|
|
82
|
+
current_size = current_database_size
|
|
83
|
+
return unless warning_needed?(current_size)
|
|
84
|
+
|
|
85
|
+
Rails.logger.warn(storage_warning_message(current_size))
|
|
68
86
|
end
|
|
69
87
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
threshold = SolidObserver.config.warning_threshold
|
|
73
|
-
current_size = calculate_database_size
|
|
88
|
+
def warning_needed?(current_size)
|
|
89
|
+
return false unless current_size
|
|
74
90
|
|
|
75
|
-
|
|
91
|
+
config = SolidObserver.config
|
|
92
|
+
max_size = config.max_db_size
|
|
93
|
+
threshold = config.warning_threshold
|
|
94
|
+
current_size > (max_size * threshold)
|
|
95
|
+
end
|
|
76
96
|
|
|
97
|
+
def storage_warning_message(current_size)
|
|
98
|
+
max_size = SolidObserver.config.max_db_size
|
|
77
99
|
percentage = ((current_size.to_f / max_size) * 100).round(1)
|
|
78
|
-
|
|
100
|
+
current_size_human = human_size(current_size)
|
|
101
|
+
max_size_human = human_size(max_size)
|
|
102
|
+
"[SolidObserver] Queue DB approaching limit: #{current_size_human} / #{max_size_human} (#{percentage}%)"
|
|
79
103
|
end
|
|
80
104
|
|
|
81
|
-
def
|
|
82
|
-
|
|
105
|
+
def human_size(bytes)
|
|
106
|
+
ActiveSupport::NumberHelper.number_to_human_size(bytes, precision: 1, significant: false, strip_insignificant_zeros: false)
|
|
83
107
|
end
|
|
84
108
|
|
|
85
|
-
def
|
|
86
|
-
return
|
|
109
|
+
def current_database_size
|
|
110
|
+
return @current_database_size if defined?(@current_database_size)
|
|
87
111
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
exp = [exp, units.length - 1].min
|
|
112
|
+
@current_database_size = DatabaseSize.call(connection: QueueEvent.connection)
|
|
113
|
+
end
|
|
91
114
|
|
|
92
|
-
|
|
115
|
+
def log_results(deleted_count)
|
|
116
|
+
Rails.logger.info "[SolidObserver] Cleaned #{deleted_count} queue events"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def maintenance_statement
|
|
120
|
+
case QueueEvent.connection.adapter_name.downcase
|
|
121
|
+
when "sqlite" then "VACUUM"
|
|
122
|
+
when "postgresql" then "VACUUM ANALYZE solid_observer_queue_events"
|
|
123
|
+
when "mysql2", "trilogy" then "OPTIMIZE TABLE solid_observer_queue_events"
|
|
124
|
+
end
|
|
93
125
|
end
|
|
94
126
|
end
|
|
95
127
|
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
module Services
|
|
5
|
+
# Returns bytes used by solid_observer_queue_events across supported adapters.
|
|
6
|
+
#
|
|
7
|
+
# SQLite uses whole-database page accounting; PostgreSQL and MySQL/Trilogy
|
|
8
|
+
# use table + index size from adapter-native system functions.
|
|
9
|
+
class DatabaseSize
|
|
10
|
+
DEFAULT_TABLE_NAME = "solid_observer_queue_events"
|
|
11
|
+
|
|
12
|
+
def self.call(connection:, table_name: DEFAULT_TABLE_NAME)
|
|
13
|
+
new(connection, table_name: table_name).call
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(connection, table_name:)
|
|
17
|
+
@connection = connection
|
|
18
|
+
@table_name = table_name
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
fetch_size
|
|
23
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished => e
|
|
24
|
+
log_query_failure(e.message)
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
attr_reader :connection, :table_name
|
|
31
|
+
|
|
32
|
+
def adapter_key
|
|
33
|
+
case connection.adapter_name.to_s.downcase
|
|
34
|
+
when /sqlite/ then :sqlite
|
|
35
|
+
when /postgres|postgis/ then :postgresql
|
|
36
|
+
when "mysql2", "trilogy" then :mysql
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def fetch_size
|
|
41
|
+
case adapter_key
|
|
42
|
+
when :sqlite then sqlite_size
|
|
43
|
+
when :postgresql then postgresql_size
|
|
44
|
+
when :mysql then mysql_size
|
|
45
|
+
else
|
|
46
|
+
unknown_adapter_size
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def unknown_adapter_size
|
|
51
|
+
log_unknown_adapter
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def sqlite_size
|
|
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
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def postgresql_size
|
|
64
|
+
quoted_table = connection.quote(table_name)
|
|
65
|
+
connection.query_value("SELECT pg_total_relation_size(#{quoted_table})")&.to_i
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def mysql_size
|
|
69
|
+
quoted_table = connection.quote(table_name)
|
|
70
|
+
|
|
71
|
+
connection.query_value(<<~SQL)&.to_i
|
|
72
|
+
SELECT COALESCE(data_length + index_length, 0)
|
|
73
|
+
FROM information_schema.tables
|
|
74
|
+
WHERE table_schema = DATABASE()
|
|
75
|
+
AND table_name = #{quoted_table}
|
|
76
|
+
SQL
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def log_unknown_adapter
|
|
80
|
+
Rails.logger&.warn(
|
|
81
|
+
"[SolidObserver] Unknown adapter for DatabaseSize: " \
|
|
82
|
+
"#{connection.adapter_name.inspect} — storage monitoring disabled"
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def log_query_failure(message)
|
|
87
|
+
Rails.logger&.warn("[SolidObserver] DatabaseSize query failed: #{message}")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -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
|
|
@@ -26,31 +26,47 @@ module SolidObserver
|
|
|
26
26
|
def call
|
|
27
27
|
return 0 if @events.empty?
|
|
28
28
|
|
|
29
|
+
bulk_insert
|
|
30
|
+
@events.size
|
|
31
|
+
rescue ActiveRecord::RecordInvalid, ActiveRecord::StatementInvalid => e
|
|
32
|
+
handle_bulk_insert_failure(e)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def bulk_insert
|
|
29
38
|
QueueEvent.transaction do
|
|
30
39
|
QueueEvent.insert_all!(@events)
|
|
31
40
|
end
|
|
41
|
+
end
|
|
32
42
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
log_error("Bulk insert failed, retrying in batches: #{e.message}")
|
|
43
|
+
def handle_bulk_insert_failure(error)
|
|
44
|
+
log_error("Bulk insert failed, retrying in batches: #{error.message}")
|
|
36
45
|
retry_with_smaller_batches
|
|
37
46
|
end
|
|
38
47
|
|
|
39
|
-
private
|
|
40
|
-
|
|
41
48
|
def retry_with_smaller_batches
|
|
42
|
-
inserted =
|
|
49
|
+
inserted = @events.each_slice(BATCH_SIZE).sum { |batch| insert_batch(batch) }
|
|
50
|
+
log_failed_count if @failed_count.positive?
|
|
51
|
+
inserted
|
|
52
|
+
end
|
|
43
53
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
def insert_batch(batch)
|
|
55
|
+
QueueEvent.insert_all(batch, returning: false)
|
|
56
|
+
batch.size
|
|
57
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordInvalid => e
|
|
58
|
+
register_failed_batch(batch, e)
|
|
59
|
+
0
|
|
60
|
+
end
|
|
51
61
|
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
def register_failed_batch(batch, error)
|
|
63
|
+
batch_size = batch.size
|
|
64
|
+
@failed_count += batch_size
|
|
65
|
+
log_warning("Failed to insert batch of #{batch_size} events: #{error.message}")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def log_failed_count
|
|
69
|
+
log_warning("#{@failed_count} events could not be saved")
|
|
54
70
|
end
|
|
55
71
|
|
|
56
72
|
def log_error(message)
|