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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -0
  3. data/README.md +241 -59
  4. data/app/assets/javascripts/solid_observer/live_poll.js +376 -0
  5. data/app/assets/stylesheets/solid_observer/application.css +18 -0
  6. data/app/controllers/concerns/solid_observer/paginatable.rb +17 -0
  7. data/app/controllers/concerns/solid_observer/require_persistence_mode.rb +19 -0
  8. data/app/controllers/concerns/solid_observer/require_solid_queue.rb +19 -0
  9. data/app/controllers/solid_observer/application_controller.rb +69 -0
  10. data/app/controllers/solid_observer/cache_dashboard_controller.rb +59 -0
  11. data/app/controllers/solid_observer/cache_operations_controller.rb +24 -0
  12. data/app/controllers/solid_observer/dashboard_controller.rb +122 -0
  13. data/app/controllers/solid_observer/events_controller.rb +50 -0
  14. data/app/controllers/solid_observer/jobs_controller.rb +85 -0
  15. data/app/controllers/solid_observer/storages_controller.rb +12 -0
  16. data/app/helpers/solid_observer/application_helper.rb +244 -0
  17. data/app/helpers/solid_observer/dashboard_helper.rb +58 -0
  18. data/app/models/solid_observer/cache_event.rb +15 -0
  19. data/app/models/solid_observer/cache_metric.rb +14 -0
  20. data/app/models/solid_observer/queue_event.rb +134 -0
  21. data/app/models/solid_observer/queue_metric.rb +1 -1
  22. data/app/models/solid_observer/storage_info.rb +4 -1
  23. data/app/presenters/solid_observer/execution_presenter.rb +50 -0
  24. data/app/views/layouts/solid_observer/application.html.erb +597 -0
  25. data/app/views/solid_observer/cache_dashboard/_charts.html.erb +40 -0
  26. data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +34 -0
  27. data/app/views/solid_observer/cache_dashboard/_summary.html.erb +39 -0
  28. data/app/views/solid_observer/cache_dashboard/index.html.erb +62 -0
  29. data/app/views/solid_observer/cache_operations/_confirm_clear.html.erb +6 -0
  30. data/app/views/solid_observer/cache_operations/index.html.erb +60 -0
  31. data/app/views/solid_observer/dashboard/_chart.html.erb +28 -0
  32. data/app/views/solid_observer/dashboard/_live_state.html.erb +20 -0
  33. data/app/views/solid_observer/dashboard/_queue_table.html.erb +34 -0
  34. data/app/views/solid_observer/dashboard/_right_now.html.erb +3 -0
  35. data/app/views/solid_observer/dashboard/_throughput.html.erb +32 -0
  36. data/app/views/solid_observer/dashboard/index.html.erb +143 -0
  37. data/app/views/solid_observer/errors/storage_unavailable.html.erb +27 -0
  38. data/app/views/solid_observer/events/index.html.erb +53 -0
  39. data/app/views/solid_observer/events/show.html.erb +47 -0
  40. data/app/views/solid_observer/jobs/index.html.erb +61 -0
  41. data/app/views/solid_observer/jobs/show.html.erb +71 -0
  42. data/app/views/solid_observer/shared/_empty_state.html.erb +5 -0
  43. data/app/views/solid_observer/shared/_pagination.html.erb +17 -0
  44. data/app/views/solid_observer/shared/_stat_card.html.erb +9 -0
  45. data/app/views/solid_observer/storages/show.html.erb +71 -0
  46. data/bin/quality_gate +95 -0
  47. data/config/routes.rb +22 -0
  48. data/db/migrate/20260424000001_add_composite_indexes_to_queue_events.rb +30 -0
  49. data/db/migrate/20260601000001_create_solid_observer_cache_events.rb +22 -0
  50. data/db/migrate/20260601000002_create_solid_observer_cache_metrics.rb +18 -0
  51. data/db/migrate/20260602000001_add_component_to_solid_observer_storage_infos.rb +8 -0
  52. data/lib/generators/solid_observer/install_generator.rb +12 -25
  53. data/lib/generators/solid_observer/templates/initializer.rb.tt +6 -6
  54. data/lib/solid_observer/base_metric.rb +1 -1
  55. data/lib/solid_observer/cache_event_buffer.rb +53 -0
  56. data/lib/solid_observer/cache_subscriber.rb +47 -0
  57. data/lib/solid_observer/chart_buffer.rb +83 -0
  58. data/lib/solid_observer/cli/base.rb +2 -2
  59. data/lib/solid_observer/cli/jobs.rb +2 -2
  60. data/lib/solid_observer/cli/status.rb +20 -2
  61. data/lib/solid_observer/cli/storage.rb +48 -44
  62. data/lib/solid_observer/configuration.rb +67 -38
  63. data/lib/solid_observer/correlation_id_resolver.rb +8 -6
  64. data/lib/solid_observer/engine.rb +110 -18
  65. data/lib/solid_observer/params/events_filter.rb +37 -0
  66. data/lib/solid_observer/params/jobs_filter.rb +35 -0
  67. data/lib/solid_observer/queries/events_query.rb +27 -0
  68. data/lib/solid_observer/queries/execution_finder.rb +42 -0
  69. data/lib/solid_observer/queries/job_executions_query.rb +73 -0
  70. data/lib/solid_observer/queue_event_buffer.rb +163 -25
  71. data/lib/solid_observer/queue_stats.rb +165 -19
  72. data/lib/solid_observer/services/cache_operations.rb +115 -0
  73. data/lib/solid_observer/services/cache_stats.rb +329 -0
  74. data/lib/solid_observer/services/cleanup_storage.rb +73 -41
  75. data/lib/solid_observer/services/database_size.rb +91 -0
  76. data/lib/solid_observer/services/flush_cache_event_buffer.rb +54 -0
  77. data/lib/solid_observer/services/flush_event_buffer.rb +31 -15
  78. data/lib/solid_observer/services/install_migrations.rb +49 -0
  79. data/lib/solid_observer/services/record_cache_event.rb +142 -0
  80. data/lib/solid_observer/services/record_cache_metric.rb +74 -0
  81. data/lib/solid_observer/services/record_event.rb +51 -14
  82. data/lib/solid_observer/services/storage_info_snapshot.rb +128 -0
  83. data/lib/solid_observer/services/ui_auth_check.rb +65 -0
  84. data/lib/solid_observer/subscriber.rb +15 -8
  85. data/lib/solid_observer/version.rb +1 -1
  86. data/lib/solid_observer.rb +7 -0
  87. data/lib/tasks/solid_observer.rake +39 -2
  88. 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 = 0
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
- db_size = calculate_database_size
40
- event_count = QueueEvent.count
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
- db_size: db_size,
44
- event_count: event_count
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
- adapter = QueueEvent.connection.adapter_name.downcase
50
- case adapter
51
- when "sqlite"
52
- QueueEvent.connection.execute("VACUUM")
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 calculate_database_size
63
- db_path = QueueEvent.connection_db_config.database
64
- File.size(db_path) if File.exist?(db_path)
65
- rescue => e
66
- Rails.logger.warn "[SolidObserver] Could not calculate DB size: #{e.message}"
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 check_storage_warnings
71
- max_size = SolidObserver.config.max_db_size
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
- return unless current_size > (max_size * threshold)
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
- Rails.logger.warn "[SolidObserver] Queue DB approaching limit: #{format_bytes(current_size)} / #{format_bytes(max_size)} (#{percentage}%)"
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 log_results(deleted_count)
82
- Rails.logger.info "[SolidObserver] Cleaned #{deleted_count} queue events"
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 format_bytes(bytes)
86
- return "0 B" if bytes.zero?
109
+ def current_database_size
110
+ return @current_database_size if defined?(@current_database_size)
87
111
 
88
- units = ["B", "KB", "MB", "GB"]
89
- exp = (Math.log(bytes) / Math.log(1024)).to_i
90
- exp = [exp, units.length - 1].min
112
+ @current_database_size = DatabaseSize.call(connection: QueueEvent.connection)
113
+ end
91
114
 
92
- "%.1f %s" % [bytes.to_f / (1024**exp), units[exp]]
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
- @events.size
34
- rescue ActiveRecord::RecordInvalid, ActiveRecord::StatementInvalid => e
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 = 0
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
- @events.each_slice(BATCH_SIZE) do |batch|
45
- QueueEvent.insert_all(batch, returning: false)
46
- inserted += batch.size
47
- rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordInvalid => e
48
- @failed_count += batch.size
49
- log_warning("Failed to insert batch of #{batch.size} events: #{e.message}")
50
- end
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
- log_warning("#{@failed_count} events could not be saved") if @failed_count.positive?
53
- inserted
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)