solid_observer 0.4.0 → 0.6.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 +30 -0
- data/README.md +100 -25
- data/app/assets/javascripts/solid_observer/live_poll.js +3 -1
- data/app/controllers/solid_observer/application_controller.rb +1 -0
- data/app/controllers/solid_observer/cable_dashboard_controller.rb +52 -0
- data/app/controllers/solid_observer/cable_operations_controller.rb +16 -0
- data/app/controllers/solid_observer/cache_dashboard_controller.rb +33 -40
- data/app/controllers/solid_observer/dashboard_controller.rb +21 -13
- data/app/controllers/solid_observer/traces_controller.rb +17 -0
- data/app/helpers/solid_observer/application_helper.rb +145 -3
- data/app/models/solid_observer/cable_event.rb +13 -0
- data/app/models/solid_observer/cable_metric.rb +12 -0
- data/app/models/solid_observer/cache_metric.rb +1 -2
- data/app/models/solid_observer/storage_info.rb +1 -1
- data/app/views/layouts/solid_observer/application.html.erb +35 -9
- data/app/views/solid_observer/cable_dashboard/_charts.html.erb +31 -0
- data/app/views/solid_observer/cable_dashboard/_recent_events.html.erb +39 -0
- data/app/views/solid_observer/cable_dashboard/_summary.html.erb +34 -0
- data/app/views/solid_observer/cable_dashboard/index.html.erb +118 -0
- data/app/views/solid_observer/cache_dashboard/_recent_events.html.erb +6 -1
- data/app/views/solid_observer/dashboard/_component_cards.html.erb +18 -0
- data/app/views/solid_observer/dashboard/_health.html.erb +6 -0
- data/app/views/solid_observer/dashboard/_home.html.erb +10 -0
- data/app/views/solid_observer/dashboard/_queue.html.erb +138 -0
- data/app/views/solid_observer/dashboard/_queue_table.html.erb +1 -0
- data/app/views/solid_observer/dashboard/_unified_feed.html.erb +38 -0
- data/app/views/solid_observer/dashboard/index.html.erb +3 -139
- data/app/views/solid_observer/events/index.html.erb +7 -1
- data/app/views/solid_observer/jobs/index.html.erb +1 -0
- data/app/views/solid_observer/storages/show.html.erb +29 -3
- data/app/views/solid_observer/traces/show.html.erb +80 -0
- data/config/routes.rb +3 -0
- data/db/migrate/20260612000001_add_event_type_recorded_at_index_to_cache_events.rb +21 -0
- data/db/migrate/20260619000001_create_solid_observer_cable_events.rb +22 -0
- data/db/migrate/20260619000002_create_solid_observer_cable_metrics.rb +17 -0
- data/db/migrate/20260630000001_add_correlation_id_to_cache_events.rb +8 -0
- data/db/migrate/20260630000002_add_correlation_id_to_cable_events.rb +8 -0
- data/lib/generators/solid_observer/install_generator.rb +8 -1
- data/lib/generators/solid_observer/templates/initializer.rb.tt +24 -5
- data/lib/solid_observer/base_event.rb +1 -1
- data/lib/solid_observer/base_metric.rb +1 -1
- data/lib/solid_observer/base_record.rb +8 -0
- data/lib/solid_observer/cable_event_buffer.rb +28 -0
- data/lib/solid_observer/cable_metric_buffer.rb +230 -0
- data/lib/solid_observer/cable_subscriber.rb +57 -0
- data/lib/solid_observer/cache_event_buffer.rb +11 -36
- data/lib/solid_observer/cache_metric_buffer.rb +229 -0
- data/lib/solid_observer/chart_buffer.rb +84 -27
- data/lib/solid_observer/cli/health.rb +49 -0
- data/lib/solid_observer/cli/trace.rb +106 -0
- data/lib/solid_observer/configuration.rb +47 -7
- data/lib/solid_observer/correlated.rb +23 -0
- data/lib/solid_observer/engine.rb +46 -28
- data/lib/solid_observer/event_buffer_core.rb +218 -0
- data/lib/solid_observer/queries/trace_query.rb +151 -0
- data/lib/solid_observer/queue_event_buffer.rb +9 -201
- data/lib/solid_observer/services/cable_operations.rb +74 -0
- data/lib/solid_observer/services/cable_stats.rb +385 -0
- data/lib/solid_observer/services/cache_stats.rb +35 -18
- data/lib/solid_observer/services/cleanup_storage.rb +82 -47
- data/lib/solid_observer/services/flush_cable_event_buffer.rb +54 -0
- data/lib/solid_observer/services/flush_cable_metrics.rb +54 -0
- data/lib/solid_observer/services/flush_cache_metrics.rb +56 -0
- data/lib/solid_observer/services/health_score.rb +85 -0
- data/lib/solid_observer/services/record_cable_event.rb +115 -0
- data/lib/solid_observer/services/record_cable_metric.rb +73 -0
- data/lib/solid_observer/services/record_cache_event.rb +24 -0
- data/lib/solid_observer/services/record_cache_metric.rb +13 -21
- data/lib/solid_observer/services/storage_info_snapshot.rb +103 -15
- data/lib/solid_observer/services/unified_feed.rb +101 -0
- data/lib/solid_observer/version.rb +1 -1
- data/lib/solid_observer.rb +42 -11
- data/lib/tasks/solid_observer.rake +97 -21
- metadata +41 -6
- data/app/assets/stylesheets/solid_observer/application.css +0 -18
- data/bin/console +0 -11
- data/bin/quality_gate +0 -95
- data/bin/setup +0 -8
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent/timer_task"
|
|
4
|
+
|
|
5
|
+
module SolidObserver
|
|
6
|
+
module EventBufferCore
|
|
7
|
+
INITIAL_METRICS = {
|
|
8
|
+
flush_failures_count: 0,
|
|
9
|
+
drops_count: 0,
|
|
10
|
+
last_flush_at: nil,
|
|
11
|
+
last_flush_duration_ms: nil,
|
|
12
|
+
last_flush_error: nil
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize_event_buffer
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@metrics_mutex = Mutex.new
|
|
18
|
+
@buffer = []
|
|
19
|
+
@metrics = INITIAL_METRICS.dup
|
|
20
|
+
@timer_task = nil
|
|
21
|
+
end
|
|
22
|
+
private :initialize_event_buffer
|
|
23
|
+
|
|
24
|
+
def push(event_data)
|
|
25
|
+
return unless (config = SolidObserver.config).persistence_mode?
|
|
26
|
+
|
|
27
|
+
drops_count, should_flush = sync_push_and_check(event_data, config)
|
|
28
|
+
record_drop(drops_count) if drops_count.positive?
|
|
29
|
+
ensure_timer_running
|
|
30
|
+
flush! if should_flush
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def flush!
|
|
34
|
+
events_to_flush = drain_buffer
|
|
35
|
+
return if events_to_flush.empty?
|
|
36
|
+
|
|
37
|
+
flush_events(events_to_flush, monotonic_ms)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def flush
|
|
41
|
+
flush!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def size
|
|
45
|
+
@mutex.synchronize { @buffer.size }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def clear
|
|
49
|
+
@mutex.synchronize { @buffer.clear }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def metrics
|
|
53
|
+
current_size = @mutex.synchronize { @buffer.size }
|
|
54
|
+
snapshot = @metrics_mutex.synchronize { @metrics.dup }
|
|
55
|
+
{
|
|
56
|
+
size: current_size,
|
|
57
|
+
max_buffer_size: SolidObserver.config.max_buffer_size
|
|
58
|
+
}.merge(snapshot)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def shutdown
|
|
62
|
+
stop_timer
|
|
63
|
+
flush!
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def drain_buffer
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
events_to_flush = @buffer.dup
|
|
71
|
+
@buffer.clear
|
|
72
|
+
events_to_flush
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def flush_events(events_to_flush, started_at_ms)
|
|
77
|
+
flush_service.call(events_to_flush)
|
|
78
|
+
record_flush_success(monotonic_ms - started_at_ms)
|
|
79
|
+
rescue => error
|
|
80
|
+
handle_flush_error(error, events_to_flush)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def handle_flush_error(error, events_to_flush)
|
|
84
|
+
requeue_failed_events(events_to_flush)
|
|
85
|
+
record_flush_failure(error)
|
|
86
|
+
log_flush_failure(error)
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def log_flush_failure(error)
|
|
91
|
+
Rails.logger&.error("[SolidObserver] #{log_label} flush failed: #{error.message}") if defined?(Rails)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def apply_overflow_policy(event_data, config)
|
|
95
|
+
if @buffer.size < config.max_buffer_size
|
|
96
|
+
@buffer << event_data
|
|
97
|
+
return 0
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
handle_overflow(event_data, config.buffer_overflow_strategy)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def handle_overflow(event_data, overflow_strategy)
|
|
104
|
+
case overflow_strategy
|
|
105
|
+
when :drop_old
|
|
106
|
+
@buffer.shift
|
|
107
|
+
@buffer << event_data
|
|
108
|
+
when :drop_new
|
|
109
|
+
nil
|
|
110
|
+
else
|
|
111
|
+
raise ArgumentError, "Unsupported buffer_overflow_strategy: #{overflow_strategy.inspect}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
1
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def requeue_failed_events(events_to_flush)
|
|
118
|
+
dropped_count = sync_requeue_events(events_to_flush, SolidObserver.config.max_buffer_size)
|
|
119
|
+
record_drop(dropped_count) if dropped_count.positive?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def trim_events_for_capacity(events, max_buffer_size)
|
|
123
|
+
dropped_count = events.size - max_buffer_size
|
|
124
|
+
return [events, 0] if dropped_count <= 0
|
|
125
|
+
|
|
126
|
+
[events_to_keep(events, max_buffer_size), dropped_count]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def ensure_timer_running
|
|
130
|
+
timer_to_start, timer_to_stop = replace_timer_if_stopped
|
|
131
|
+
return unless timer_to_start
|
|
132
|
+
|
|
133
|
+
timer_to_stop&.shutdown
|
|
134
|
+
timer_to_start.execute
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def replace_timer_if_stopped
|
|
138
|
+
@mutex.synchronize do
|
|
139
|
+
current_timer_task = @timer_task
|
|
140
|
+
return [nil, nil] if timer_running?(current_timer_task)
|
|
141
|
+
|
|
142
|
+
[build_timer_task, current_timer_task]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def stop_timer
|
|
147
|
+
timer_to_stop = @mutex.synchronize do
|
|
148
|
+
current_timer = @timer_task
|
|
149
|
+
@timer_task = nil
|
|
150
|
+
current_timer
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
timer_to_stop&.shutdown
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def record_flush_success(duration_ms)
|
|
157
|
+
@metrics_mutex.synchronize do
|
|
158
|
+
@metrics.merge!(
|
|
159
|
+
last_flush_at: Time.current,
|
|
160
|
+
last_flush_duration_ms: duration_ms,
|
|
161
|
+
last_flush_error: nil
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def record_flush_failure(error)
|
|
167
|
+
@metrics_mutex.synchronize do
|
|
168
|
+
@metrics[:flush_failures_count] += 1
|
|
169
|
+
@metrics[:last_flush_error] = error.message
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def record_drop(count = 1)
|
|
174
|
+
@metrics_mutex.synchronize do
|
|
175
|
+
@metrics[:drops_count] += count
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def sync_push_and_check(event_data, config)
|
|
180
|
+
@mutex.synchronize do
|
|
181
|
+
drops_count = apply_overflow_policy(event_data, config)
|
|
182
|
+
[drops_count, @buffer.size >= config.buffer_size]
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def sync_requeue_events(events_to_flush, max_buffer_size)
|
|
187
|
+
@mutex.synchronize do
|
|
188
|
+
combined_events = events_to_flush + @buffer
|
|
189
|
+
kept_events, dropped_count = trim_events_for_capacity(combined_events, max_buffer_size)
|
|
190
|
+
@buffer.replace(kept_events)
|
|
191
|
+
dropped_count
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def events_to_keep(events, max_buffer_size)
|
|
196
|
+
if SolidObserver.config.buffer_overflow_strategy == :drop_old
|
|
197
|
+
events.last(max_buffer_size)
|
|
198
|
+
else
|
|
199
|
+
events.first(max_buffer_size)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def timer_running?(timer_task)
|
|
204
|
+
timer_task && !timer_task.shuttingdown?
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def build_timer_task
|
|
208
|
+
@timer_task = Concurrent::TimerTask.new(
|
|
209
|
+
execution_interval: SolidObserver.config.flush_interval,
|
|
210
|
+
run_now: false
|
|
211
|
+
) { flush! }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def monotonic_ms
|
|
215
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
module Queries
|
|
5
|
+
class TraceQuery
|
|
6
|
+
DEFAULT_LIMIT = 100
|
|
7
|
+
CABLE_CAP = 50
|
|
8
|
+
|
|
9
|
+
Result = Struct.new(:rows, :unavailable_components, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
COMPONENT_PRIORITY = {queue: 0, cache: 1, cable: 2}.freeze
|
|
12
|
+
|
|
13
|
+
COMPONENT_CONFIG = {
|
|
14
|
+
queue: {
|
|
15
|
+
enabled: :solid_queue_enabled?,
|
|
16
|
+
model: :QueueEvent,
|
|
17
|
+
columns: [:recorded_at, :event_type, :job_class, :queue_name, :duration, :id]
|
|
18
|
+
},
|
|
19
|
+
cache: {
|
|
20
|
+
enabled: :solid_cache_enabled?,
|
|
21
|
+
model: :CacheEvent,
|
|
22
|
+
columns: [:recorded_at, :event_type, :hit, :error_class, :duration, :id]
|
|
23
|
+
},
|
|
24
|
+
cable: {
|
|
25
|
+
enabled: :solid_cable_enabled?,
|
|
26
|
+
model: :CableEvent,
|
|
27
|
+
columns: [:recorded_at, :event_type, :channel_class, :broadcasting_digest, :error_class, :duration, :id]
|
|
28
|
+
}
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
CONNECTION_ERRORS = [
|
|
32
|
+
ActiveRecord::ConnectionNotEstablished,
|
|
33
|
+
ActiveRecord::StatementInvalid,
|
|
34
|
+
*([PG::ConnectionBad] if defined?(PG::ConnectionBad)),
|
|
35
|
+
*([Mysql2::Error::ConnectionError] if defined?(Mysql2::Error::ConnectionError)),
|
|
36
|
+
*([Trilogy::Error] if defined?(Trilogy::Error)),
|
|
37
|
+
*([SQLite3::CantOpenException] if defined?(SQLite3::CantOpenException))
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
def call(correlation_id:, limit: DEFAULT_LIMIT)
|
|
41
|
+
rows, unavailable = gather_rows(correlation_id)
|
|
42
|
+
effective_limit = limit.to_i
|
|
43
|
+
effective_limit = DEFAULT_LIMIT unless effective_limit.positive?
|
|
44
|
+
Result.new(rows: process_rows(rows, effective_limit), unavailable_components: unavailable)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def gather_rows(correlation_id)
|
|
50
|
+
COMPONENT_CONFIG.each_with_object([[], []]) do |(component, config), (rows, unavailable)|
|
|
51
|
+
next unless SolidObserver.config.public_send(config[:enabled])
|
|
52
|
+
|
|
53
|
+
component_rows = fetch_component_rows(component, config, correlation_id)
|
|
54
|
+
component_rows ? rows.concat(component_rows) : unavailable << component
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def fetch_component_rows(component, config, correlation_id)
|
|
59
|
+
model = SolidObserver.const_get(config[:model])
|
|
60
|
+
return nil unless data_source_available?(model)
|
|
61
|
+
|
|
62
|
+
pluck_rows(model, component, correlation_id)
|
|
63
|
+
rescue *CONNECTION_ERRORS, TypeError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def data_source_available?(model)
|
|
68
|
+
table_name = model.table_name.to_s
|
|
69
|
+
table_name.present? && model.connection.data_source_exists?(table_name)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def pluck_rows(model, component, correlation_id)
|
|
73
|
+
columns = COMPONENT_CONFIG[component][:columns]
|
|
74
|
+
model.where(correlation_id: correlation_id)
|
|
75
|
+
.order(:recorded_at)
|
|
76
|
+
.pluck(*columns)
|
|
77
|
+
.map { |values| build_row(component, columns, values) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_row(component, columns, values)
|
|
81
|
+
row = {component: component}
|
|
82
|
+
columns.each_with_index { |column, index| row[column] = values[index] }
|
|
83
|
+
row
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def process_rows(rows, limit)
|
|
87
|
+
collapsed = collapse_cable_rows(rows)
|
|
88
|
+
capped = apply_cable_cap(collapsed)
|
|
89
|
+
sorted = sort_rows(capped)
|
|
90
|
+
return sorted if capped.size <= limit
|
|
91
|
+
|
|
92
|
+
sort_rows(sorted.last(limit))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def collapse_cable_rows(rows)
|
|
96
|
+
cable_rows, other_rows = rows.partition { |row| row[:component] == :cable }
|
|
97
|
+
return rows if cable_rows.empty?
|
|
98
|
+
|
|
99
|
+
other_rows + cable_rows.chunk_while(&method(:collapse_match?)).map(&method(:collapse_group))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def collapse_match?(first_row, second_row)
|
|
103
|
+
return false unless first_row[:event_type] == "broadcast" && second_row[:event_type] == "broadcast"
|
|
104
|
+
|
|
105
|
+
digest = first_row[:broadcasting_digest]
|
|
106
|
+
digest.present? && digest == second_row[:broadcasting_digest]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def collapse_group(group)
|
|
110
|
+
row = group.first
|
|
111
|
+
size = group.size
|
|
112
|
+
row[:collapsed_count] = size if size > 1
|
|
113
|
+
row.delete(:broadcasting_digest)
|
|
114
|
+
row
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def apply_cable_cap(rows)
|
|
118
|
+
cable_rows, other_rows = rows.partition { |row| row[:component] == :cable }
|
|
119
|
+
total = cable_rows.size
|
|
120
|
+
return rows if total <= CABLE_CAP
|
|
121
|
+
|
|
122
|
+
other_rows + cap_cable_rows(cable_rows, total)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def cap_cable_rows(cable_rows, total)
|
|
126
|
+
keep_count = CABLE_CAP - 1
|
|
127
|
+
[build_summary_row(cable_rows.first(total - keep_count))] + tail_rows(cable_rows, keep_count)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def tail_rows(rows, count)
|
|
131
|
+
rows.last(count)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def build_summary_row(overflow)
|
|
135
|
+
{
|
|
136
|
+
recorded_at: overflow.last[:recorded_at],
|
|
137
|
+
component: :cable,
|
|
138
|
+
event_type: "cable_summary",
|
|
139
|
+
duration: nil,
|
|
140
|
+
channel_class: nil,
|
|
141
|
+
collapsed_count: overflow.size,
|
|
142
|
+
id: -1
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def sort_rows(rows)
|
|
147
|
+
rows.sort_by { |row| [row[:recorded_at], COMPONENT_PRIORITY[row[:component]], row[:id] || 0] }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -1,221 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "singleton"
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
require_relative "event_buffer_core"
|
|
5
6
|
|
|
6
7
|
module SolidObserver
|
|
7
8
|
# Thread-safe buffer for collecting queue events before batch insertion.
|
|
8
|
-
#
|
|
9
|
-
# Events are buffered in memory and flushed either when:
|
|
10
|
-
# - Buffer size reaches the configured threshold
|
|
11
|
-
# - Flush interval timer expires
|
|
12
|
-
#
|
|
13
|
-
# @example Push an event to the buffer
|
|
14
|
-
# QueueEventBuffer.instance.push(event_data)
|
|
15
9
|
class QueueEventBuffer
|
|
16
10
|
include Singleton
|
|
11
|
+
include EventBufferCore
|
|
17
12
|
|
|
18
|
-
INITIAL_METRICS =
|
|
19
|
-
flush_failures_count: 0,
|
|
20
|
-
drops_count: 0,
|
|
21
|
-
last_flush_at: nil,
|
|
22
|
-
last_flush_duration_ms: nil,
|
|
23
|
-
last_flush_error: nil
|
|
24
|
-
}.freeze
|
|
13
|
+
INITIAL_METRICS = EventBufferCore::INITIAL_METRICS
|
|
25
14
|
|
|
26
15
|
def initialize
|
|
27
|
-
|
|
28
|
-
@metrics_mutex = Mutex.new
|
|
29
|
-
@buffer = []
|
|
30
|
-
@metrics = INITIAL_METRICS.dup
|
|
31
|
-
@timer_task = nil
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# Adds an event to the buffer and triggers flush if threshold reached.
|
|
35
|
-
#
|
|
36
|
-
# @param event_data [Hash] Event data to buffer
|
|
37
|
-
# @return [void]
|
|
38
|
-
def push(event_data)
|
|
39
|
-
return unless (config = SolidObserver.config).persistence_mode?
|
|
40
|
-
|
|
41
|
-
drops_count, should_flush = sync_push_and_check(event_data, config)
|
|
42
|
-
record_drop(drops_count) if drops_count.positive?
|
|
43
|
-
ensure_timer_running
|
|
44
|
-
flush! if should_flush
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Flushes all buffered events to the database.
|
|
48
|
-
#
|
|
49
|
-
# @return [void]
|
|
50
|
-
def flush!
|
|
51
|
-
events_to_flush = nil
|
|
52
|
-
|
|
53
|
-
@mutex.synchronize do
|
|
54
|
-
return if @buffer.empty?
|
|
55
|
-
events_to_flush = @buffer.dup
|
|
56
|
-
@buffer.clear
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
started_at_ms = monotonic_ms
|
|
60
|
-
begin
|
|
61
|
-
Services::FlushEventBuffer.call(events_to_flush)
|
|
62
|
-
rescue => e
|
|
63
|
-
requeue_failed_events(events_to_flush)
|
|
64
|
-
record_flush_failure(e)
|
|
65
|
-
Rails.logger&.error "[SolidObserver] Buffer flush failed: #{e.message}" if defined?(Rails)
|
|
66
|
-
return
|
|
67
|
-
end
|
|
68
|
-
record_flush_success(monotonic_ms - started_at_ms)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def size
|
|
72
|
-
@mutex.synchronize { @buffer.size }
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def clear
|
|
76
|
-
@mutex.synchronize { @buffer.clear }
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def metrics
|
|
80
|
-
current_size = @mutex.synchronize { @buffer.size }
|
|
81
|
-
snapshot = @metrics_mutex.synchronize { @metrics.dup }
|
|
82
|
-
{
|
|
83
|
-
size: current_size,
|
|
84
|
-
max_buffer_size: SolidObserver.config.max_buffer_size
|
|
85
|
-
}.merge(snapshot)
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def shutdown
|
|
89
|
-
stop_timer
|
|
90
|
-
flush!
|
|
16
|
+
initialize_event_buffer
|
|
91
17
|
end
|
|
92
18
|
|
|
93
19
|
private
|
|
94
20
|
|
|
95
|
-
def
|
|
96
|
-
|
|
97
|
-
@buffer << event_data
|
|
98
|
-
return 0
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
handle_overflow(event_data, config.buffer_overflow_strategy)
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def handle_overflow(event_data, overflow_strategy)
|
|
105
|
-
case overflow_strategy
|
|
106
|
-
when :drop_old
|
|
107
|
-
@buffer.shift
|
|
108
|
-
@buffer << event_data
|
|
109
|
-
when :drop_new
|
|
110
|
-
# No-op: drop incoming event, keep oldest buffered events.
|
|
111
|
-
else
|
|
112
|
-
raise ArgumentError, "Unsupported buffer_overflow_strategy: #{overflow_strategy.inspect}"
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
1
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def requeue_failed_events(events_to_flush)
|
|
119
|
-
return unless events_to_flush
|
|
120
|
-
|
|
121
|
-
dropped_count = sync_requeue_events(events_to_flush, SolidObserver.config.max_buffer_size)
|
|
122
|
-
record_drop(dropped_count) if dropped_count.positive?
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def trim_events_for_capacity(events, max_buffer_size)
|
|
126
|
-
dropped_count = events.size - max_buffer_size
|
|
127
|
-
return [events, 0] if dropped_count <= 0
|
|
128
|
-
|
|
129
|
-
[events_to_keep(events, max_buffer_size), dropped_count]
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def ensure_timer_running
|
|
133
|
-
timer_to_start, timer_to_stop = replace_timer_if_stopped
|
|
134
|
-
return unless timer_to_start
|
|
135
|
-
|
|
136
|
-
timer_to_stop&.shutdown
|
|
137
|
-
timer_to_start.execute
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def replace_timer_if_stopped
|
|
141
|
-
@mutex.synchronize do
|
|
142
|
-
current_timer_task = @timer_task
|
|
143
|
-
return [nil, nil] if timer_running?(current_timer_task)
|
|
144
|
-
|
|
145
|
-
[build_timer_task, current_timer_task]
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def stop_timer
|
|
150
|
-
timer_to_stop = @mutex.synchronize do
|
|
151
|
-
current_timer = @timer_task
|
|
152
|
-
@timer_task = nil
|
|
153
|
-
current_timer
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
timer_to_stop&.shutdown
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def record_flush_success(duration_ms)
|
|
160
|
-
@metrics_mutex.synchronize do
|
|
161
|
-
@metrics.merge!(
|
|
162
|
-
last_flush_at: Time.current,
|
|
163
|
-
last_flush_duration_ms: duration_ms,
|
|
164
|
-
last_flush_error: nil
|
|
165
|
-
)
|
|
166
|
-
end
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def record_flush_failure(error)
|
|
170
|
-
@metrics_mutex.synchronize do
|
|
171
|
-
@metrics[:flush_failures_count] += 1
|
|
172
|
-
@metrics[:last_flush_error] = error.message
|
|
173
|
-
end
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def record_drop(count = 1)
|
|
177
|
-
@metrics_mutex.synchronize do
|
|
178
|
-
@metrics[:drops_count] += count
|
|
179
|
-
end
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
def sync_push_and_check(event_data, config)
|
|
183
|
-
@mutex.synchronize do
|
|
184
|
-
drops_count = apply_overflow_policy(event_data, config)
|
|
185
|
-
[drops_count, @buffer.size >= config.buffer_size]
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
def sync_requeue_events(events_to_flush, max_buffer_size)
|
|
190
|
-
@mutex.synchronize do
|
|
191
|
-
combined_events = events_to_flush + @buffer
|
|
192
|
-
kept_events, dropped_count = trim_events_for_capacity(combined_events, max_buffer_size)
|
|
193
|
-
@buffer.replace(kept_events)
|
|
194
|
-
dropped_count
|
|
195
|
-
end
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
def events_to_keep(events, max_buffer_size)
|
|
199
|
-
if SolidObserver.config.buffer_overflow_strategy == :drop_old
|
|
200
|
-
events.last(max_buffer_size)
|
|
201
|
-
else
|
|
202
|
-
events.first(max_buffer_size)
|
|
203
|
-
end
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
def timer_running?(timer_task)
|
|
207
|
-
timer_task && !timer_task.shuttingdown?
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def build_timer_task
|
|
211
|
-
@timer_task = Concurrent::TimerTask.new(
|
|
212
|
-
execution_interval: SolidObserver.config.flush_interval,
|
|
213
|
-
run_now: false
|
|
214
|
-
) { flush! }
|
|
21
|
+
def flush_service
|
|
22
|
+
Services::FlushEventBuffer
|
|
215
23
|
end
|
|
216
24
|
|
|
217
|
-
def
|
|
218
|
-
|
|
25
|
+
def log_label
|
|
26
|
+
"Buffer"
|
|
219
27
|
end
|
|
220
28
|
end
|
|
221
29
|
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidObserver
|
|
4
|
+
module Services
|
|
5
|
+
class CableOperations
|
|
6
|
+
MESSAGES = {
|
|
7
|
+
trim: {
|
|
8
|
+
success: "Expired/trimmable Solid Cable messages trimmed.",
|
|
9
|
+
failure: "Cable trim failed. No raw Cable payloads or adapter details are shown. Use solid_observer:cable:trim if the problem continues."
|
|
10
|
+
}.freeze,
|
|
11
|
+
unavailable: "Cable controls are unavailable because Solid Cable support is disabled or not detected."
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def available?
|
|
16
|
+
new.available?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def trim
|
|
20
|
+
new.trim
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def message(operation, key = nil)
|
|
24
|
+
return MESSAGES.fetch(:unavailable) if operation == :unavailable
|
|
25
|
+
|
|
26
|
+
MESSAGES.fetch(operation).fetch(key)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def unavailable_message
|
|
30
|
+
message(:unavailable)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def available?
|
|
35
|
+
SolidObserver.config.solid_cable_enabled? && !!defined?(::SolidCable::Message)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def trim
|
|
39
|
+
messages = self.class
|
|
40
|
+
return {ok: false, message: messages.unavailable_message} unless available?
|
|
41
|
+
|
|
42
|
+
perform_operation(
|
|
43
|
+
:trim,
|
|
44
|
+
success_message: messages.message(:trim, :success),
|
|
45
|
+
failure_message: messages.message(:trim, :failure)
|
|
46
|
+
) do
|
|
47
|
+
trim_cable_messages
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def trim_cable_messages
|
|
54
|
+
if defined?(::SolidCable::TrimJob)
|
|
55
|
+
::SolidCable::TrimJob.perform_now
|
|
56
|
+
else
|
|
57
|
+
::SolidCable::Message.trimmable.delete_all
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def perform_operation(name, success_message:, failure_message:)
|
|
62
|
+
yield
|
|
63
|
+
{ok: true, message: success_message}
|
|
64
|
+
rescue => error
|
|
65
|
+
log_failure(name, error)
|
|
66
|
+
{ok: false, message: failure_message}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def log_failure(name, error)
|
|
70
|
+
Rails.logger&.warn("[SolidObserver] Cable #{name} failed: #{error.class}")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|