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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +134 -81
  4. data/app/assets/stylesheets/solid_observer/application.css +18 -0
  5. data/app/controllers/solid_observer/cache_dashboard_controller.rb +59 -0
  6. data/app/controllers/solid_observer/cache_operations_controller.rb +24 -0
  7. data/app/controllers/solid_observer/dashboard_controller.rb +44 -1
  8. data/app/controllers/solid_observer/storages_controller.rb +1 -1
  9. data/app/helpers/solid_observer/application_helper.rb +154 -5
  10. data/app/helpers/solid_observer/dashboard_helper.rb +30 -11
  11. data/app/models/solid_observer/cache_event.rb +15 -0
  12. data/app/models/solid_observer/cache_metric.rb +14 -0
  13. data/app/models/solid_observer/storage_info.rb +4 -1
  14. data/app/views/layouts/solid_observer/application.html.erb +144 -17
  15. data/app/views/solid_observer/cache_dashboard/_charts.html.erb +40 -0
  16. data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +34 -0
  17. data/app/views/solid_observer/cache_dashboard/_summary.html.erb +39 -0
  18. data/app/views/solid_observer/cache_dashboard/index.html.erb +62 -0
  19. data/app/views/solid_observer/cache_operations/_confirm_clear.html.erb +6 -0
  20. data/app/views/solid_observer/cache_operations/index.html.erb +60 -0
  21. data/app/views/solid_observer/dashboard/index.html.erb +34 -4
  22. data/app/views/solid_observer/jobs/show.html.erb +3 -3
  23. data/app/views/solid_observer/storages/show.html.erb +64 -32
  24. data/bin/quality_gate +1 -1
  25. data/config/routes.rb +5 -0
  26. data/db/migrate/20260601000001_create_solid_observer_cache_events.rb +22 -0
  27. data/db/migrate/20260601000002_create_solid_observer_cache_metrics.rb +18 -0
  28. data/db/migrate/20260602000001_add_component_to_solid_observer_storage_infos.rb +8 -0
  29. data/lib/generators/solid_observer/templates/initializer.rb.tt +2 -1
  30. data/lib/solid_observer/cache_event_buffer.rb +53 -0
  31. data/lib/solid_observer/cache_subscriber.rb +47 -0
  32. data/lib/solid_observer/cli/storage.rb +16 -13
  33. data/lib/solid_observer/configuration.rb +22 -3
  34. data/lib/solid_observer/engine.rb +44 -7
  35. data/lib/solid_observer/services/cache_operations.rb +115 -0
  36. data/lib/solid_observer/services/cache_stats.rb +329 -0
  37. data/lib/solid_observer/services/cleanup_storage.rb +18 -2
  38. data/lib/solid_observer/services/database_size.rb +13 -8
  39. data/lib/solid_observer/services/flush_cache_event_buffer.rb +54 -0
  40. data/lib/solid_observer/services/record_cache_event.rb +142 -0
  41. data/lib/solid_observer/services/record_cache_metric.rb +74 -0
  42. data/lib/solid_observer/services/storage_info_snapshot.rb +128 -0
  43. data/lib/solid_observer/version.rb +1 -1
  44. data/lib/tasks/solid_observer.rake +29 -0
  45. 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 activation_skipped_for_table_status?
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 activation_skipped_for_table_status?
61
- case table_status("solid_observer_queue_events")
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
- db_size: current_database_size,
52
- event_count: QueueEvent.count
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
- TABLE_NAME = "solid_observer_queue_events"
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("SELECT pragma_page_count() * pragma_page_size()")&.to_i
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(TABLE_NAME)
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(TABLE_NAME)
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